// ExecuteTool 工件回填契约(交付物卡片根因修复的验收):注册表工具经 // rc.WriteArtifact 写入的工件必须出现在 ToolCallResult.Artifact 上—— // llm.go 据此生成对话内 artifact_ref 卡片部件。此前只有超长截断路径会设置它。 package runmgr import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "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) } } // run_python 生成文件后 register_artifact 必须把工件回填到 ToolCallResult.Artifact。 func TestRunPythonRegisterArtifactBackfill(t *testing.T) { c, _ := 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) } py := c.ExecuteTool(core.ToolCallRequest{ CallID: "py1", Name: "run_python", Arguments: json.RawMessage(`{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\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 != "ok" { t.Fatalf("register_artifact: %s", res.Output) } if res.Artifact == nil || res.Artifact.Name != "note.docx" { t.Fatalf("ToolCallResult.Artifact 未回填: %+v", res.Artifact) } if res.Artifact.MIME != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" { t.Errorf("docx mime: %s", res.Artifact.MIME) } 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) } } // 空手工具(如 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" docx, err := docgen.Build("纪要", "结论:"+marker) if err != nil { t.Fatal(err) } mime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document" if _, err := c.WriteArtifact("note.docx", "document", mime, docx); 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)) } }