// ExecuteTool 工件回填契约(交付物卡片根因修复的验收):注册表工具经 // rc.WriteArtifact 写入的工件必须出现在 ToolCallResult.Artifact 上—— // llm.go 据此生成对话内 artifact_ref 卡片部件。此前只有超长截断路径会设置它。 package runmgr import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "reflect" "strings" "testing" "time" "agent-document-renderer/render" "agent-system-backend/internal/bus" "agent-system-backend/internal/docgen" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" core "agent-system-core" ) func newExecEnv(t *testing.T) (*runCtx, *store.Store) { t.Helper() st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "exec.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) b := bus.New() reg := tools.NewRegistry() if err := reg.RegisterBuiltin(); err != nil { t.Fatal(err) } proj, err := st.CreateProject("alice", "p1") if err != nil { t.Fatal(err) } conv, err := st.CreateConversation("alice", proj.ID, "c1", "") if err != nil { t.Fatal(err) } svc := settings.NewService(st) m := &Manager{st: st, bus: b, registry: reg, settings: svc, events: newEventBuffer(st, b)} eff := svc.Effective("alice", "") c := &runCtx{ mgr: m, run: core.Run{ProjectID: proj.ID, ConversationID: conv.ID, Owner: "alice"}, prof: core.AgentProfile{}, eff: eff, ctx: context.Background(), } return c, st } func TestExecuteToolBackfillsArtifact(t *testing.T) { c, _ := newExecEnv(t) res := c.ExecuteTool(core.ToolCallRequest{ CallID: "call1", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"summary.md","kind":"document","content":"# 摘要正文"}`), }) if res.Status != "ok" { t.Fatalf("write_artifact: %s", res.Output) } if res.Artifact == nil { t.Fatal("ToolCallResult.Artifact 未回填——对话内交付物卡片不会出现(回归)") } if res.Artifact.Name != "summary.md" { t.Errorf("回填工件名不符: %+v", res.Artifact) } } // Arbitrary Python may create local DOCX bytes, but cannot publish them as a // trusted Word delivery without the host renderer operation. func TestRunPythonDOCXCannotRegisterAsTrustedDelivery(t *testing.T) { c, st := newExecEnv(t) root := t.TempDir() c.mgr.SetWorkspaceRoot(root) if err := tools.RegisterExecTools(c.mgr.registry); err != nil { t.Fatal(err) } if err := tools.RegisterInterpreterTools(c.mgr.registry); err != nil { t.Fatal(err) } // 夹具必须是结构合法的最小 docx:登记口会做 OPC 结构自检, // 伪 zip(PK 魔数+垃圾字节)现在会被拒绝而非登记。 py := c.ExecuteTool(core.ToolCallRequest{ CallID: "py1", Name: "run_python", Arguments: json.RawMessage(`{"script":"import zipfile\nwith zipfile.ZipFile('note.docx','w') as z:\n z.writestr('[Content_Types].xml','')\n z.writestr('word/document.xml','')\n"}`), }) if py.Status != "ok" || !strings.Contains(py.Output, "exit=0") { t.Fatalf("run_python: %s %s", py.Status, py.Output) } res := c.ExecuteTool(core.ToolCallRequest{ CallID: "reg1", Name: "register_artifact", Arguments: json.RawMessage(`{"path":"note.docx"}`), }) if res.Status != "error" || res.Artifact != nil || !strings.Contains(res.Output, "render_document") { t.Fatalf("direct DOCX registration was not rejected: %+v", res) } b, err := os.ReadFile(filepath.Join(root, "alice", c.run.ConversationID, "note.docx")) if err != nil { t.Fatal(err) } if string(b[:4]) != "PK\x03\x04" { t.Fatalf("stored bytes: %q", b) } artifacts, err := st.ListArtifacts("alice", c.run.ProjectID) if err != nil || len(artifacts) != 0 { t.Fatalf("rejected DOCX became visible: %+v err=%v", artifacts, err) } } // 空手工具(如 time_now)不得凭空挂 Artifact。 func TestExecuteToolNoArtifactByDefault(t *testing.T) { c, _ := newExecEnv(t) res := c.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "time_now", Arguments: json.RawMessage(`{}`)}) if res.Status != "ok" || res.Artifact != nil { t.Errorf("无产物工具不应携带 Artifact: %+v", res) } } func TestGeneratedDocxIsSearchable(t *testing.T) { c, st := newExecEnv(t) const marker = "zebraindexword" delivery, err := docgen.Generate(context.Background(), docgen.Request{ DocumentID: "searchable", Title: "纪要", DocumentType: "general", Language: "zh-CN", Markdown: []byte("结论:" + marker), Mode: render.ModePreview, FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), RequestedBy: "alice", }) if err != nil { t.Fatal(err) } mime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document" if _, err := c.WriteArtifact("note.docx", "document", mime, delivery.DOCX.Bytes); err != nil { t.Fatal(err) } hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: marker, ProjectID: c.run.ProjectID, TopK: 5}) if err != nil { t.Fatal(err) } if len(hits) == 0 { t.Fatal("generated docx text was not indexed") } if _, err := c.WriteArtifact("bad.docx", "document", mime, []byte("notazipmarker")); err != nil { t.Fatal(err) } hits, err = st.SearchKnowledge("alice", core.RetrievalQuery{Text: "notazipmarker", ProjectID: c.run.ProjectID, TopK: 5}) if err != nil { t.Fatal(err) } if len(hits) != 0 { t.Fatalf("unparseable docx bytes should not be indexed, got %d hits", len(hits)) } } func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) { databasePath := filepath.Join(t.TempDir(), "renderer-restart.db") st, err := store.OpenSQLite(databasePath) if err != nil { t.Fatal(err) } if err := st.EnsureUser("alice", "Alice"); err != nil { t.Fatal(err) } registry := tools.NewRegistry() if err := registry.RegisterBuiltin(); err != nil { t.Fatal(err) } project, err := st.CreateProject("alice", "p1") if err != nil { t.Fatal(err) } conversation, err := st.CreateConversation("alice", project.ID, "c1", "") if err != nil { t.Fatal(err) } eventBus := bus.New() events := newEventBuffer(st, eventBus) eventsStopped := false t.Cleanup(func() { if !eventsStopped { events.stop() } }) manager := &Manager{st: st, bus: eventBus, registry: registry, settings: settings.NewService(st), events: events} run := &runCtx{ mgr: manager, run: core.Run{ID: "run-before-restart", ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"}, prof: core.AgentProfile{}, eff: manager.settings.Effective("alice", ""), ctx: context.Background(), } args := json.RawMessage(`{"operationId":"restart-operation","name":"重启纪要","documentId":"restart-minutes","documentType":"general","language":"zh-CN","markdown":"# 重启纪要\n\n缓存身份保持。\n","mode":"preview","controlled":false,"projectDocument":false}`) result := run.ExecuteTool(core.ToolCallRequest{CallID: "render-restart", Name: "render_document", Arguments: args}) if result.Status != "ok" { t.Fatalf("render_document: %s", result.Output) } var firstReceipt docgen.OperationReceipt if err := json.Unmarshal([]byte(result.Output), &firstReceipt); err != nil || firstReceipt.FrozenAt.IsZero() { t.Fatalf("first receipt=%#v err=%v", firstReceipt, err) } artifacts, err := st.ListArtifacts("alice", project.ID) if err != nil || len(artifacts) != 4 { t.Fatalf("stored artifacts=%d err=%v", len(artifacts), err) } want := map[string]string{} for _, item := range artifacts { raw, err := st.ArtifactContentByID(item.ID) if err != nil { t.Fatal(err) } sum := sha256.Sum256(raw) want[item.ID] = hex.EncodeToString(sum[:]) } events.stop() eventsStopped = true if err := st.Close(); err != nil { t.Fatal(err) } reopened, err := store.OpenSQLite(databasePath) if err != nil { t.Fatal(err) } defer reopened.Close() reopenedBus := bus.New() reopenedEvents := newEventBuffer(reopened, reopenedBus) defer reopenedEvents.stop() reopenedManager := &Manager{st: reopened, bus: reopenedBus, registry: registry, settings: settings.NewService(reopened), events: reopenedEvents} reopenedRun := &runCtx{ mgr: reopenedManager, run: core.Run{ID: "run-after-restart", ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"}, prof: core.AgentProfile{}, eff: reopenedManager.settings.Effective("alice", ""), ctx: context.Background(), } retryResult := reopenedRun.ExecuteTool(core.ToolCallRequest{CallID: "render-restart-retry", Name: "render_document", Arguments: args}) if retryResult.Status != "ok" { t.Fatalf("restart retry: %s", retryResult.Output) } var retryReceipt docgen.OperationReceipt if err := json.Unmarshal([]byte(retryResult.Output), &retryReceipt); err != nil || !retryReceipt.CacheHit || !retryReceipt.FrozenAt.Equal(firstReceipt.FrozenAt) || !reflect.DeepEqual(retryReceipt.Artifacts, firstReceipt.Artifacts) { t.Fatalf("restart receipt changed: first=%#v retry=%#v err=%v", firstReceipt, retryReceipt, err) } for artifactID, wantHash := range want { raw, err := reopened.ArtifactContentByID(artifactID) if err != nil { t.Fatalf("artifact %s after restart: %v", artifactID, err) } sum := sha256.Sum256(raw) if got := hex.EncodeToString(sum[:]); got != wantHash { t.Errorf("artifact %s hash after restart = %s, want %s", artifactID, got, wantHash) } } }