Why: - Independent review found trusted-boundary, semantic parity, replay-budget, comparison, persistence, cancellation, provenance, and routing gaps. - Storage migration must include the new durable delivery table. Evidence: - go test -count=1 ./... - go test -race ./internal/docgen ./internal/tools ./internal/runmgr - go test ./... (deps/agent-document-renderer) - go vet ./... - go build ./... - CGO_ENABLED=0 go build -trimpath ./cmd/agent-system - Python house-style unittest suite and git diff --check Impact: - Word delivery now uses host-owned freezes, Markdown-first resumable persistence, closed registration boundaries, semantic shared rendering, and real comparison. - Controlled/project documents still fail closed to RDM and non-Word flows remain unchanged. Refs: - Unified Document Renderer implementation plan Task 10 - /tmp/task10-backend-review-last.md - Renderer d5cd8c126045a27d5a5cc5da23f64d3c737e116a
187 lines
7.5 KiB
Go
187 lines
7.5 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-system-backend/internal/docgen"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
type capturedArtifact struct {
|
|
metadata core.Artifact
|
|
content []byte
|
|
}
|
|
|
|
type documentRendererCtx struct {
|
|
ctx context.Context
|
|
artifacts []capturedArtifact
|
|
receipt docgen.OperationReceipt
|
|
err error
|
|
}
|
|
|
|
func (c *documentRendererCtx) ProjectID() string { return "conversation-project" }
|
|
func (c *documentRendererCtx) ConversationID() string { return "conversation" }
|
|
func (c *documentRendererCtx) OwnerID() string { return "alice" }
|
|
func (c *documentRendererCtx) WorkspaceRoot() string { return "" }
|
|
func (c *documentRendererCtx) SettingBool(string, bool) bool {
|
|
return false
|
|
}
|
|
func (c *documentRendererCtx) Context() context.Context {
|
|
if c.ctx != nil {
|
|
return c.ctx
|
|
}
|
|
return context.Background()
|
|
}
|
|
func (c *documentRendererCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *documentRendererCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil }
|
|
func (c *documentRendererCtx) PeerConversationMessages(string, int) ([]core.Message, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *documentRendererCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
|
artifact := core.Artifact{ID: core.ID("art-" + name), ProjectID: "conversation-project", Owner: "alice", Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}
|
|
c.artifacts = append(c.artifacts, capturedArtifact{metadata: artifact, content: append([]byte(nil), content...)})
|
|
return artifact, nil
|
|
}
|
|
func (c *documentRendererCtx) ExecuteDocumentDelivery(context.Context, docgen.OperationRequest) (docgen.OperationReceipt, error) {
|
|
return c.receipt, c.err
|
|
}
|
|
|
|
func TestRenderDocumentToolWritesMarkdownFirstAndReportsPackageHashes(t *testing.T) {
|
|
registry := NewRegistry()
|
|
if err := registry.RegisterBuiltin(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
arguments, _ := json.Marshal(map[string]any{
|
|
"operationId": "operation-1", "name": "研发纪要", "documentId": "workspace-minutes", "documentType": "general", "language": "zh-CN",
|
|
"markdown": "# 研发纪要\n\n正文。\n", "mode": "preview",
|
|
"controlled": false, "projectDocument": false,
|
|
})
|
|
ctx := &documentRendererCtx{receipt: docgen.OperationReceipt{
|
|
OperationID: "operation-1", Outcome: "complete", State: "complete", Renderer: docgen.RendererIdentity,
|
|
FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC),
|
|
Artifacts: []docgen.ReceiptArtifact{
|
|
{Role: "markdown", ID: "md", Name: "研发纪要.md", SHA256: strings.Repeat("a", 64)},
|
|
{Role: "docx", ID: "docx", Name: "研发纪要.docx", SHA256: strings.Repeat("b", 64)},
|
|
{Role: "manifest", ID: "manifest", Name: "研发纪要.manifest.json", SHA256: strings.Repeat("c", 64)},
|
|
{Role: "archive", ID: "archive", Name: "研发纪要.zip", SHA256: strings.Repeat("d", 64)},
|
|
},
|
|
}}
|
|
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-1", Name: "render_document", Arguments: arguments})
|
|
if result.Status != "ok" {
|
|
t.Fatalf("render_document: %s", result.Output)
|
|
}
|
|
var receipt docgen.OperationReceipt
|
|
if err := json.Unmarshal([]byte(result.Output), &receipt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if receipt.OperationID != "operation-1" || receipt.Outcome != "complete" || len(receipt.Artifacts) != 4 || receipt.FrozenAt.IsZero() {
|
|
t.Fatalf("durable receipt = %#v", receipt)
|
|
}
|
|
for _, artifact := range receipt.Artifacts {
|
|
if !strings.Contains(result.Output, artifact.SHA256) {
|
|
t.Errorf("tool output omitted %s hash %s", artifact.Role, artifact.SHA256)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderDocumentToolSchemaIsClosedAndUsesOnlyOperationIdentity(t *testing.T) {
|
|
registry := NewRegistry()
|
|
if err := registry.RegisterBuiltin(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
description, _, found := registry.Get("render_document")
|
|
if !found {
|
|
t.Fatal("render_document is not registered")
|
|
}
|
|
var schema struct {
|
|
AdditionalProperties *bool `json:"additionalProperties"`
|
|
Properties map[string]json.RawMessage `json:"properties"`
|
|
Required []string `json:"required"`
|
|
}
|
|
if err := json.Unmarshal(description.Parameters, &schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if schema.AdditionalProperties == nil || *schema.AdditionalProperties {
|
|
t.Fatal("render_document schema is not closed")
|
|
}
|
|
if _, found := schema.Properties["operationId"]; !found {
|
|
t.Fatal("render_document schema omitted operationId")
|
|
}
|
|
if _, found := schema.Properties["frozenAt"]; found {
|
|
t.Fatal("render_document still accepts model-supplied frozenAt")
|
|
}
|
|
wantRequired := map[string]bool{"operationId": true, "name": true, "documentId": true, "markdown": true, "controlled": true, "projectDocument": true}
|
|
for _, name := range schema.Required {
|
|
delete(wantRequired, name)
|
|
}
|
|
if len(wantRequired) != 0 {
|
|
t.Fatalf("render_document required fields missing: %#v", wantRequired)
|
|
}
|
|
arguments := json.RawMessage(`{"operationId":"closed-schema","name":"Report","documentId":"report","markdown":"# Report\n","controlled":false,"projectDocument":false,"frozenAt":"2000-01-01T00:00:00Z"}`)
|
|
result := registry.Execute(&documentRendererCtx{}, core.ToolCallRequest{CallID: "closed-schema", Name: "render_document", Arguments: arguments})
|
|
if result.Status != "error" || !strings.Contains(result.Output, `unknown field "frozenAt"`) {
|
|
t.Fatalf("model-supplied frozenAt was accepted: %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestPythonToolDescriptionDoesNotAdvertiseWordBypass(t *testing.T) {
|
|
registry := NewRegistry()
|
|
if err := RegisterInterpreterTools(registry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
description, _, found := registry.Get("run_python")
|
|
if !found {
|
|
t.Fatal("run_python is not registered")
|
|
}
|
|
for _, forbidden := range []string{"python-docx", "生成文档", "docx_kit"} {
|
|
if strings.Contains(description.Description, forbidden) {
|
|
t.Fatalf("run_python description advertises Word bypass %q: %s", forbidden, description.Description)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderDocumentToolRequiresExplicitRouteDeclaration(t *testing.T) {
|
|
registry := NewRegistry()
|
|
if err := registry.RegisterBuiltin(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
arguments, _ := json.Marshal(map[string]any{
|
|
"operationId": "operation-ambiguous", "name": "未声明路由", "documentId": "ambiguous", "markdown": "# 文档\n",
|
|
"mode": "preview",
|
|
})
|
|
ctx := &documentRendererCtx{}
|
|
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-3", Name: "render_document", Arguments: arguments})
|
|
if result.Status != "error" || !strings.Contains(result.Output, "controlled and projectDocument must be explicit") {
|
|
t.Fatalf("ambiguous route result = %#v", result)
|
|
}
|
|
if len(ctx.artifacts) != 0 {
|
|
t.Fatalf("ambiguous route wrote %d artifacts", len(ctx.artifacts))
|
|
}
|
|
}
|
|
|
|
func TestRenderDocumentToolRejectsControlledRouteWithoutArtifacts(t *testing.T) {
|
|
registry := NewRegistry()
|
|
if err := registry.RegisterBuiltin(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
arguments, _ := json.Marshal(map[string]any{
|
|
"operationId": "operation-controlled", "name": "受控文档", "documentId": "controlled", "documentType": "dhf", "language": "zh-CN",
|
|
"markdown": "# 受控文档\n", "mode": "preview",
|
|
"controlled": true, "projectDocument": false,
|
|
})
|
|
ctx := &documentRendererCtx{}
|
|
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-2", Name: "render_document", Arguments: arguments})
|
|
if result.Status != "error" || !strings.Contains(result.Output, "RDM") {
|
|
t.Fatalf("controlled render result = %#v", result)
|
|
}
|
|
if len(ctx.artifacts) != 0 {
|
|
t.Fatalf("controlled fallback wrote %d artifacts", len(ctx.artifacts))
|
|
}
|
|
}
|