Files
Agent-System-Backend/internal/runmgr/crossread_test.go
T
weichen d6bef8663f Return deliverable generation to the agent and fix the artifact-card root cause
Why: two field reports against the live build — deliverable cards never
appeared in chat (only model-written filenames) and the artifacts panel
felt dead. Root cause found in runmgr.ExecuteTool: registry tools that
write artifacts via rc.WriteArtifact never had res.Artifact set (only
the oversized-result path did), so llm.go never emitted artifact_ref
parts for real tool output; the unit suite passed via fakes that set
it directly. Architecturally, document generation moves back to the
agent (user directive): system provides execution primitives and the
delivery bridge, not domain tools like write_docx.

Evidence:
- runmgr: toolCtx tracks artifacts written during a call and
  ExecuteTool backfills res.Artifact (new runmgr test proves
  write_artifact now surfaces one; negative test: no phantom artifact)
- new tools/register_artifact: bridge from workspace files (binary
  docx/xlsx/pptx/pdf/images, <=10MB) to deliverable artifacts;
  run_shell/run_python interpreter executors are designed
  (script-to-disk + whitelisted bash/python3, sandbox cwd, caps) and
  land in a follow-up batch behind the security gate decision
- removed builtin write_docx + its prompt; write_artifact prompt now
  text-only with explicit no-binary guidance; new always-on base
  prompt base.deliverables teaches the generate->register->summarize
  workflow with python-docx/openpyxl/python-pptx recipes;
  task.doc-minutes rewritten to the workflow
- final-output contract: inline <think> blocks in streamed content are
  split into thinking (process) parts so the final reply stays clean
  (unit + runner tests)
- go test ./... 19 packages ok; vet clean

Impact: every tool artifact now lands as an in-chat deliverable card;
mock #doc unchanged as demo fixture; binary office mimes stay out of
the text index; write_docx callers see the tool gone (prompt reroutes
them).

Refs: task feature/agent-native-deliverables-r1
2026-10-01 03:32:30 +00:00

190 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 跨会话读取门禁矩阵(提案 2026-09-30-cross-session-reading 验收表):
// 项目开关 × 出站 allowCrossRead × 入站 crossPrivate × 跨项目/跨租户 × 自读 × 归档。
// 门禁实现在 toolCtx(本包),经真实 store + settings 覆盖链验证,fail-closed。
package runmgr
import (
"errors"
"path/filepath"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
type crossEnv struct {
m *Manager
svc *settings.Service
st *store.Store
projA core.Project
projA2 core.Project // alice 的另一项目(跨项目负例)
projB core.Project // bob 的项目(跨租户负例)
convA core.Conversation
convB core.Conversation // 普通同伴
convC core.Conversation // 私有同伴
convD core.Conversation // 归档同伴
convE core.Conversation // bob 名下
convF core.Conversation // alice 另一项目下
}
func newCrossEnv(t *testing.T) *crossEnv {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "cross.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
e := &crossEnv{m: &Manager{st: st}, svc: settings.NewService(st), st: st}
for i, mk := range []struct {
owner string
dst *core.Project
}{
{"alice", &e.projA}, {"alice", &e.projA2}, {"bob", &e.projB},
} {
p, err := st.CreateProject(mk.owner, "p"+string(rune('a'+i)))
if err != nil {
t.Fatal(err)
}
*mk.dst = p
}
newConv := func(owner string, p core.Project, title string) core.Conversation {
c, err := st.CreateConversation(owner, p.ID, title, "")
if err != nil {
t.Fatal(err)
}
return c
}
e.convA = newConv("alice", e.projA, "A 调用方")
e.convB = newConv("alice", e.projA, "B 同伴")
e.convC = newConv("alice", e.projA, "C 私有")
e.convD = newConv("alice", e.projA, "D 归档")
e.convE = newConv("bob", e.projB, "E 他租户")
e.convF = newConv("alice", e.projA2, "F 他项目")
yes := true
if err := st.SetConversationFlags("alice", e.convC.ID, nil, nil, nil, &yes); err != nil {
t.Fatal(err)
}
if err := st.SetConversationFlags("alice", e.convD.ID, nil, &yes, nil, nil); err != nil {
t.Fatal(err)
}
addMsgs := func(c core.Conversation, texts ...string) {
for _, txt := range texts {
m := core.Message{ConversationID: c.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: txt}}}
if err := st.AppendMessage(&m); err != nil {
t.Fatal(err)
}
}
}
addMsgs(e.convA, "A-1")
addMsgs(e.convB, "B-1", "B-2", "B-3")
return e
}
// peerCtx 构造门禁视角:owner=alice 在 projA 下经 convA 发起读取。
func (e *crossEnv) peerCtx(t *testing.T) *toolCtx {
t.Helper()
eff := e.svc.Effective("alice", e.projA.ID)
return &toolCtx{rc: &runCtx{mgr: e.m, eff: eff, run: core.Run{Owner: "alice", ProjectID: e.projA.ID, ConversationID: e.convA.ID}}}
}
func (e *crossEnv) setGate(t *testing.T, on bool) {
t.Helper()
if err := e.svc.MergeProject("alice", e.projA.ID, map[string]any{"crossSession": map[string]any{"read": on}}); err != nil {
t.Fatal(err)
}
}
func TestCrossReadGateMatrix(t *testing.T) {
e := newCrossEnv(t)
tru, fls := true, false
// 1) 全关默认:项目闸门关,读与列均拒绝。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("gate off must disable read, got %v", err)
}
if _, err := e.peerCtx(t).PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("gate off must disable list, got %v", err)
}
// 2) 正常放行:开闸后读 B 最近记录(默认 limit 50 ≥ 3 条全量)。
e.setGate(t, true)
msgs, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 || msgs[0].Parts[0].Text != "B-1" || msgs[2].Parts[0].Text != "B-3" {
t.Fatalf("expected 3 ordered messages, got %+v", msgs)
}
// 3) 出站关闭:A 不读他人(列表与读取都拒;自读仍可,见 7)。
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, &fls, nil); err != nil {
t.Fatal(err)
}
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("outbound off must disable read, got %v", err)
}
if _, err := e.peerCtx(t).PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("outbound off must disable list, got %v", err)
}
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, &tru, nil); err != nil { // 恢复出站
t.Fatal(err)
}
// 4) 目标私有:C 拒读且不出现在列表;列表含归档 D、不含自身。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convC.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
t.Fatalf("private target must be forbidden, got %v", err)
}
convs, err := e.peerCtx(t).PeerConversations()
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, c := range convs {
ids[c.ID] = true
}
if !ids[e.convB.ID] || !ids[e.convD.ID] || ids[e.convC.ID] || ids[e.convA.ID] {
t.Fatalf("list must include B/D, exclude private C and self A, got %v", ids)
}
// 5) 跨项目:同租户另一项目的 F 拒读。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convF.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
t.Fatalf("cross-project target must be forbidden, got %v", err)
}
// 6) 跨租户:bob 名下 E 一律 NotFound(存在性不泄露)。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convE.ID, 0); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant target must be NotFound, got %v", err)
}
// 7) 自读:A 读自身(含窗口外历史)放行;A 设为私有后自读仍放行(入站豁免)。
selfMsgs, err := e.peerCtx(t).PeerConversationMessages(e.convA.ID, 0)
if err != nil || len(selfMsgs) != 1 {
t.Fatalf("self read must pass, got %d msgs err %v", len(selfMsgs), err)
}
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, nil, &tru); err != nil {
t.Fatal(err)
}
if _, err := e.peerCtx(t).PeerConversationMessages(e.convA.ID, 0); err != nil {
t.Fatalf("self read of a private self must pass, got %v", err)
}
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, nil, &fls); err != nil { // 还原非私有
t.Fatal(err)
}
// 8) limit:正数生效;越界钳制。
last2, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 2)
if err != nil || len(last2) != 2 || last2[1].Parts[0].Text != "B-3" {
t.Fatalf("limit=2 must return last two, got %d err %v", len(last2), err)
}
if msgs, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 99999); err != nil || len(msgs) != 3 {
t.Fatalf("oversized limit must clamp without error, got %d err %v", len(msgs), err)
}
// 9) 闸门经设置覆盖链关闭后回 1) 行为。
e.setGate(t, false)
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("re-closed gate must disable read, got %v", err)
}
}