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
253 lines
9.2 KiB
Go
253 lines
9.2 KiB
Go
// 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','<?xml version=\"1.0\"?><Types/>')\n z.writestr('word/document.xml','<?xml version=\"1.0\"?><doc/>')\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)
|
|
}
|
|
}
|
|
}
|