Compare commits

..
Author SHA1 Message Date
weichen 35c6995546 Fix cross-action slot inheritance and fail-closed cancel errors
Why: review blockers B1/M2 - Suspend inherited the previous action's
accumulated slots into a new action, and applyCancel swallowed store
errors so an abandoned fill could become an unconfirmed real cancel.
Evidence: review-a1-run.json findings fixed; intentsession/server
tests pass with the reviewer's sequences (verify-a1fix.json).
Impact: different-action suspend starts fresh; non-stale cancel store
errors fail closed; all other flows unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 11:27:45 +00:00
weichen 9ee752c380 Add intent_session to the storage-migrate manifest
Why: the A1 session table joined the schema but the backup tool's
copyOrder list lagged; the coverage guard caught it.
Evidence: storage-migrate tests pass (verify-a1sr.json, exit=0).
Impact: backup tool manifest only; existing table semantics unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 10:41:58 +00:00
weichen c2e3104970 Route the cancel intent through cmdbus with explicit targets
Why: A1 - the cancel library command was declined as host-unconsumed,
so "取消刚才的任务" could not act on a real run.
Evidence: route/server/intentsession tests pass (verify-a1c.json);
explicit-id, session-bound, ambiguous and cross-owner cases added;
confirmation card semantics unchanged.
Impact: cancel decisions dispatch via the existing host path; other
intents unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 10:04:23 +00:00
weichen 0fb64c488e Add conversation-scoped intent sessions for multi-turn closure
Why: A1 - the host declined set_slot/change_slot/cancel and never kept
pending expectations, so multi-turn clarifications could not complete
an action.
Evidence: intentsession/store/server tests pass (verify-a1b.json);
TTL, revision, isolation, expiry and legacy-behavior cases added.
Impact: new table + service; chat flow suspends/fills expectations;
cmdbus confirmation semantics untouched (U-A1c routes cancel next).
Refs: feature/intent-multi-turn-a1
2026-10-22 09:30:25 +00:00
weichen a75783fd2d Merge commit '7bcbbdb37684' into HEAD 2026-10-22 07:10:53 +00:00
weichen 92c2b4eb12 Pin the nested agent-squad to the followups tip
Why: composition consistency - Dev pins agent-squad at the nested
native M5 bump; the backend container's nested copy must match
(check-pins).
Evidence: pin only.
Impact: nested gitlink pointer change.
Refs: feature/audit-followups-pin-gate-m5
2026-10-22 06:48:37 +00:00
weichen 7bcbbdb376 Merge pull request 'Qualify the role permission version upsert and wait for the second HITL card' (#246) from fix/pg-role-policy-version-and-hitl-wait into main
governance / governance (push) Successful in 21s
ci / vet-and-test (push) Successful in 1m29s
2026-10-22 06:42:24 +00:00
weichenandCursor 8cd4dd0ab7 Qualify the role permission version upsert and wait for the second HITL card
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m27s
Why: Dev composite test-pg failed: PostgreSQL rejects the unqualified
permission_version self-reference in the role_permissions upsert
(SQLSTATE 42702), and TestResolveHITLSecondRoundKeepsTerminalStatus read
the round-2 card before it was persisted.

Evidence: go test ./internal/store ./internal/runmgr pass and the HITL
test passes 20 consecutive runs locally on SQLite (SELF); PostgreSQL is
verified by the Dev composite test-pg job.

Impact: role_permissions.permission_version+1 on conflict; test-only
wait mirrors round 1. No other behavior change.

Refs: Agent-System-Backend#240, Agent-System-Dev#691 (test-pg run 4841);
agent-exec receipt u7-run (pi, success)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 06:40:12 +00:00
weichen 3ab7cd71a4 Pin the nested agent-native to the M5 fix
Why: composition consistency - Dev pins agent-native at the M5
preflight tip; the backend container's nested copy must match
(check-pins).
Evidence: pin only; adapters/native tests green on the new pin.
Impact: nested gitlink pointer change.
Refs: feature/audit-followups-pin-gate-m5
2026-10-22 06:26:13 +00:00
weichen fd69932c7d Merge pull request 'Land agent-stack audit batch-2 on main' (#245) from fix/agent-stack-audit-b2 into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m34s
2026-10-22 05:46:42 +00:00
weichen 443e7af1b8 Merge branch 'main' into fix/agent-stack-audit-b2
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m22s
2026-10-22 05:44:47 +00:00
weichen 029917e843 Merge pull request 'Add isolated scratch sessions (no-project general-agent chats)' (#241) from feature/scratch-session-general-agent into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Failing after 1m18s
2026-10-22 05:14:44 +00:00
weichen 9b40595728 Pin the nested agent-squad to the repaired batch-2 tip
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m35s
Why: composition consistency - the squad container advanced with the
frame-order repair; backend's nested copy (and its nested native) must
match so the composed chain carries the new event fields.
Evidence: pin only; squad and native verified per-unit.
Impact: nested gitlink pointer changes.
Refs: feature/agent-stack-audit-remediation-b2
2026-10-22 04:18:21 +00:00
weichen 7b8a34d1aa Synchronize the shared tool-run state behind the direct path
Why: the U11C concurrent event plumbing exposed a data race between
runToolCtx writes and callback reads; -race failed the package on
fc0a050 but passed on its parent.
Evidence: full-package race run green (verify-u11cr.json, exit=0).
Impact: synchronization only; no behavior change.
Refs: feature/agent-stack-audit-remediation-b2 U11c repair
2026-10-22 04:17:55 +00:00
weichenandCursor 2424f8e3f7 Merge origin/main into feature/scratch-session-general-agent
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m24s
Why: main advanced with agent-stack audit batch-1; resolve the Bus
struct conflict by keeping both hostProjectGuard and operationQuery.

Evidence: go test ./internal/cmdbus ./internal/app ./internal/runmgr
./internal/server ./internal/store pass locally (SELF).

Impact: scratch host guard retained alongside the new operation query
port; deps gitlinks match origin/main.

Refs: Agent-System-Backend#241
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 04:06:52 +00:00
weichenandCursor b704ba9897 Fail closed both ways when the scratch host lookup fails
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m23s
Why: Returning true from scratchProject on a lookup error closed the
isolation gates correctly, but the same helper also skipped RDM
observation and project context for a normal project during a
transient store failure.

Evidence: go test ./internal/runmgr ./internal/cmdbus ./internal/app
./internal/server ./internal/store pass locally (SELF); CI is authoritative.

Impact: confirmedScratchProject (lookup succeeded and marked scratch)
drives the relaxing gates; scratchProject (lookup error => true) keeps
driving the tightening ones.

Refs: Agent-System-Backend#241; agent-exec receipt u2d-run (pi, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 04:02:11 +00:00
weichen b4290c03bd Merge pull request 'Land the agent stack audit batch-1 fixes on main' (#244) from fix/agent-stack-audit-b1 into main
governance / governance (push) Successful in 17s
ci / vet-and-test (push) Successful in 1m21s
2026-10-22 04:00:42 +00:00
weichenandCursor 2fc6e7e0a7 Gate project context and project commands for scratch hosts at the source
Why: Independent review found scratch runs still called the project
context resolver, and chat intent routing plus system.intent re-dispatch
could deliver rdm.* commands with a scratch host project.

Evidence: go test ./internal/runmgr ./internal/cmdbus ./internal/app
./internal/server ./internal/store pass locally (SELF); CI is authoritative.

Impact: ProjectContext returns nothing when project context is disabled;
Bus.Dispatch applies a host-project guard to every request carrying a host
project, rejecting rdm.* and formal project commands on scratch hosts;
scratch lookups that fail keep isolation gates closed.

Refs: Agent-System-Backend#241; agent-exec receipt u2c-run (pi, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:53:46 +00:00
weichen fc0a0505b3 Honor tool-call identity and real status in squad runner events
Why: F14 backend half - tool events used the tool name as the call id
and hardcoded status ok, so same-named tools could not be paired and
failures displayed as success; parallel callbacks shared artifact state.
Evidence: squad runner event tests pass (verify-u11c.json); pairing,
error mapping and legacy passthrough cases added; artifact list locked.
Impact: legacy empty-field events keep byte-identical output.
Refs: feature/agent-stack-audit-remediation-b2 U11c
2026-10-22 03:49:37 +00:00
weichen f8e9e9f2d2 Pin nested agent-native and agent-squad to the batch-2 tips
Why: U11C consumes the new ProcessEvent CallID/Status/ErrorReason
fields; the backend container's nested copies must match the Dev pins
for U11c and check-pins.
Evidence: pin only; both libraries verified per-unit.
Impact: nested gitlink pointer changes.
Refs: feature/agent-stack-audit-remediation-b2
2026-10-22 03:25:54 +00:00
weichenandCursor f3f70a7978 Mark scratch conversations in conversation responses
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m27s
Why: The Frontend groups scratch sessions under a separate sidebar category and
needs an explicit marker instead of inferring it from the project list.
Evidence: agent-exec run 2d662c46 (pi, success); go test ./internal/store ./internal/server pass.
Impact: listTasks, listConversations, conversationDetail and POST /api/scratch-sessions
return scratch: true for scratch conversations; other conversations keep the same JSON (omitempty).
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:05:25 +00:00
weichenandCursor 613c068c79 Isolate scratch sessions from project-only capabilities
Why: A scratch session is a private general-agent chat; it must work for its
owner without the RDM provider and must not share documents or context with any
other conversation.
Evidence: agent-exec run fb04c4a9 (pi, success); go test ./internal/store ./internal/server ./internal/runmgr ./internal/app pass.
Impact: Owner work/read on scratch bypasses the domain provider; non-owners and
shared adapters get NotFound. Scratch runs skip project context, knowledge
retrieval, owner/project context entries, user/project memory, and cross-session
reads (both directions). Knowledge add/search 400, rdm.* and formal project
commands 403, settings writes 400, uploads skip indexing, and deleting the sole
conversation removes the scratch project.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:53:31 +00:00
weichenandCursor 3b4a7af4b4 Add scratch sessions as inherent private no-project conversations
Why: Accounts without project.create need an isolated general-agent chat; each
scratch session is a private shell project (settings host.kind=scratch) with
exactly one cross-private conversation.
Evidence: agent-exec run 9b3d31a3 (pi, success); go test ./internal/store ./internal/server -run Scratch pass.
Impact: New POST /api/scratch-sessions (no project.create needed); project lists
omit scratch; second conversation under scratch 409; client writes cannot add or
remove the scratch marker; POST /api/projects rejects the marker.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:16:15 +00:00
40 changed files with 6997 additions and 139 deletions
+4
View File
@@ -307,6 +307,10 @@ var copyOrder = []string{
"exec_env_requests", "exec_env_bindings",
// U8:活动服务锁的库内标记(无外键;迁移须保留以免目标库丢掉锁根)
"instance_guard",
// v52 intent_session(A1/U-A1b):会话维度的挂起动作预期 + 已收集槽位
// (无外键,纯数据表;键含 owner+project+conversation,随 conversations
// 之后迁移即可,无需额外排序)
"intent_session",
}
// schemaSeededSingletons:schema 迁移在每个新库上预置的单行表。它们不是目标
+39
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net"
@@ -542,10 +543,16 @@ func Run(opt Options) error {
}
return nil
}}
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
return scratchCommandHostGuard(st, req)
})
bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error {
if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" {
return fmt.Errorf("formal project command requires an authenticated conversation source")
}
if err := rejectScratchCommandHost(st, req); err != nil {
return err
}
conv, err := st.GetConversation(req.Owner, req.ConversationID)
if err != nil || string(conv.ProjectID) != req.HostProjectID {
return fmt.Errorf("formal project command conversation binding changed")
@@ -1042,6 +1049,38 @@ func splitKeyPool(s string) []string {
return out
}
// ErrScratchCommandHost 是命令分发的临时会话门禁:临时会话没有 RDM 项目域,
// 任何以基座项目为前提的命令(rdm.* 与正式项目命令)在里面一律不可用。
var ErrScratchCommandHost = errors.New("not available in scratch sessions")
// rejectScratchCommandHost 在命令总线内部分发(宿主与插件处理器共用的分发面)
// 上执行同一个门禁。基座项目是临时会话时拒绝;不是临时会话(包括项目读不到)
// 时交回原路径,不改变任何现有判定。
func rejectScratchCommandHost(st *store.Store, req cmdbus.Request) error {
if st == nil || req.HostProjectID == "" {
return nil
}
handled, err := st.ScratchProjectDecision(req.HostProjectID, req.Owner)
if err != nil {
return err
}
if handled {
return ErrScratchCommandHost
}
return nil
}
// scratchCommandHostGuard 是命令总线上的基座项目门禁(组合根装配):
// 项目域命令(rdm.* 全族与正式项目命令 cmdbus.RequiresProjectWorkDecision)在
// 临时会话宿主上一律拒绝,其它命令与普通项目宿主原样通过。它挂在分发面本身,
// 所以聊天意图路由与 system.intent 再分发不再绕过 HTTP 403 与正式命令预检。
func scratchCommandHostGuard(st *store.Store, req cmdbus.Request) error {
if !strings.HasPrefix(req.ID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(req.ID) {
return nil
}
return rejectScratchCommandHost(st, req)
}
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
type busAdapter struct{ bus *cmdbus.Bus }
+181
View File
@@ -0,0 +1,181 @@
package app
// 临时会话的内部命令分发门禁(U2):组合根在命令总线的项目预检入口上拒绝
// “基座项目是临时会话”的正式项目命令,宿主与插件处理器共用这一条分发面。
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func newScratchGateStore(t *testing.T) (*store.Store, core.Project, core.Conversation) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch-gate.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
t.Fatal(err)
}
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
return st, project, conversation
}
// TestScratchCommandHostGateDecidesOwnership 门禁本身:属主的临时会话拒绝,
// 普通项目与无基座项目的分发不受影响;非属主按存储判定(NotFound)。
func TestScratchCommandHostGateDecidesOwnership(t *testing.T) {
st, project, _ := newScratchGateStore(t)
if err := rejectScratchCommandHost(st, cmdbus.Request{
ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(project.ID),
}); !errors.Is(err, ErrScratchCommandHost) {
t.Fatalf("scratch host project accepted a formal command: %v", err)
}
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
if err := rejectScratchCommandHost(st, cmdbus.Request{
ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(normal.ID),
}); err != nil {
t.Fatalf("normal host project refused: %v", err)
}
if err := rejectScratchCommandHost(st, cmdbus.Request{ID: "rdm.reqs.add", Owner: "alice"}); err != nil {
t.Fatalf("hostless dispatch refused: %v", err)
}
// 非属主不能把自己的“属主身份”带进临时会话。
if err := rejectScratchCommandHost(st, cmdbus.Request{
ID: "rdm.reqs.add", Owner: "bob", HostProjectID: string(project.ID),
}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("non-owner scratch dispatch = %v", err)
}
}
// TestScratchCommandHostGateOnBusPreflight 门禁在真实分发面上生效:正式项目命令
// 在临时会话宿主上被拒,普通项目宿主不受影响(失败原因来自后续的会话来源校验,
// 不是临时会话门禁)。
func TestScratchCommandHostGateOnBusPreflight(t *testing.T) {
st, project, conversation := newScratchGateStore(t)
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
normalConv, err := st.CreateConversation("alice", string(normal.ID), "会话", "")
if err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error {
if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" {
return errors.New("formal project command requires an authenticated conversation source")
}
if err := rejectScratchCommandHost(st, req); err != nil {
return err
}
conv, err := st.GetConversation(req.Owner, req.ConversationID)
if err != nil || string(conv.ProjectID) != req.HostProjectID {
return errors.New("formal project command conversation binding changed")
}
return errors.New("source analysis did not commit a proceed decision")
})
if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"},
func(context.Context, cmdbus.Request) (any, error) { return map[string]any{"ok": true}, nil }); err != nil {
t.Fatal(err)
}
scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
})
if scratchResp.Receipt.State != agentcommand.StateRejected ||
!strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
t.Fatalf("scratch host dispatch = %+v (want %s)", scratchResp.Receipt, ErrScratchCommandHost)
}
normalResp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice",
ConversationID: string(normalConv.ID), AnchorMessageID: "msg-1", HostProjectID: string(normal.ID),
})
if normalResp.Receipt.State != agentcommand.StateRejected ||
strings.Contains(normalResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v", normalResp.Receipt)
}
}
// TestScratchCommandHostGateOnBusDispatch 基座项目门禁挂在命令总线上(组合根的
// 真实接线):不经 HTTP 403、也不经正式命令预检——聊天意图路由与 system.intent
// 再分发走的就是这条面——rdm.* 在临时会话宿主上被拒且处理器不会被调用;普通项目
// 宿主与无宿主分发不受这条门禁影响。
func TestScratchCommandHostGateOnBusDispatch(t *testing.T) {
st, project, conversation := newScratchGateStore(t)
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
handled, preflights := 0, 0
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
return scratchCommandHostGuard(st, req)
})
bus.SetProjectWorkPreflight(func(context.Context, cmdbus.Request) error { preflights++; return nil })
for _, spec := range []agentcommand.CommandSpec{
{ID: "rdm.reqs.add", Title: "登记需求"},
{ID: "demo.ping", Title: "会话命令"},
} {
id := spec.ID
if err := bus.RegisterPluginCommand(spec,
func(context.Context, cmdbus.Request) (any, error) { handled++; return map[string]any{"id": id}, nil }); err != nil {
t.Fatal(err)
}
}
scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
})
if scratchResp.Receipt.State != agentcommand.StateRejected ||
!strings.HasPrefix(scratchResp.Receipt.Reason, "host project: ") ||
!strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
t.Fatalf("scratch host dispatch = %+v (want host project: %s)", scratchResp.Receipt, ErrScratchCommandHost)
}
if handled != 0 || preflights != 0 {
t.Fatalf("scratch host reached the command handler %d times / preflight %d times", handled, preflights)
}
// 同一宿主上的会话类命令照旧可用(临时会话不是禁用一切命令)。
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
}); resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
}
// 正面:普通项目宿主不被这条门禁拒绝;无宿主的分发也不走门禁。
for _, req := range []cmdbus.Request{
{ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID)},
{ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice"},
} {
if err := scratchCommandHostGuard(st, req); err != nil {
t.Fatalf("host guard refused %+v: %v", req, err)
}
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID),
})
if resp.Receipt.State != agentcommand.StateCommitted || strings.Contains(resp.Receipt.Reason, ErrScratchCommandHost.Error()) || handled != 2 {
t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v (handler calls %d)", resp.Receipt, handled)
}
}
+17
View File
@@ -241,6 +241,7 @@ type Bus struct {
strictAccountWrites bool
allowImplicitLocal bool
projectWorkPreflight func(context.Context, Request) error
hostProjectGuard func(context.Context, Request) error
// operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。
operationQuery OperationQuery
}
@@ -249,6 +250,15 @@ func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) {
b.projectWorkPreflight = fn
}
// SetHostProjectGuard installs the composition-root check applied to every
// dispatch that carries a base project, before the project-work preflight and
// before any handler runs. It is the single bus-level place where host-project
// capability gates live, so every entrance (slash, exec, chat intent routing,
// system.intent re-dispatch, agent tool) is covered by one decision.
func (b *Bus) SetHostProjectGuard(fn func(context.Context, Request) error) {
b.hostProjectGuard = fn
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
type PendingStore interface {
SavePendingConfirm(cardID, owner, payload string) error
@@ -481,6 +491,13 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
if err := validateParams(spec.ParamsSchema, req.Params); err != nil {
return receipt(agentcommand.StateRejected, "", "params: "+err.Error())
}
// 基座项目门禁:所有带 HostProjectID 的分发都在这里过一道,命令种类与宿主
// 能力的匹配面由组合根决定(nil 时保持原行为)。
if req.HostProjectID != "" && b.hostProjectGuard != nil {
if err := b.hostProjectGuard(ctx, req); err != nil {
return receipt(agentcommand.StateRejected, "", "host project: "+err.Error())
}
}
if RequiresProjectWorkDecision(req.ID) {
if b.projectWorkPreflight == nil {
return receipt(agentcommand.StateRejected, "", "project source preflight unavailable")
+94
View File
@@ -0,0 +1,94 @@
package cmdbus
// 基座项目门禁在命令总线上的行为(临时会话隔离,U2):SetHostProjectGuard 装
// 的门禁对所有带 HostProjectID 的分发生效,且在 handler 与项目工作预检之前。
import (
"context"
"errors"
"strings"
"testing"
"time"
agentcommand "agent-command"
)
// TestHostProjectGuardRefusesBeforeHandler 基座项目门禁:带宿主的请求先过门禁,
// 拒绝时 handler 与预检都不该被调用;同一个命令换一个普通宿主项目照旧执行。
func TestHostProjectGuardRefusesBeforeHandler(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
handled, preflights := 0, 0
bus.SetProjectWorkPreflight(func(context.Context, Request) error { preflights++; return nil })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, "",
func(context.Context, Request) (any, error) { handled++; return map[string]any{"ok": true}, nil }); err != nil {
t.Fatal(err)
}
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
if strings.HasPrefix(req.ID, "rdm.") || RequiresProjectWorkDecision(req.ID) {
if req.HostProjectID == "prj_scratch" {
return errors.New("not available in scratch sessions")
}
}
return nil
})
resp := bus.Dispatch(context.Background(), Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: "cnv_scratch", HostProjectID: "prj_scratch",
})
if resp.Receipt.State != agentcommand.StateRejected {
t.Fatalf("scratch host dispatch state = %s, want rejected", resp.Receipt.State)
}
if !strings.HasPrefix(resp.Receipt.Reason, "host project: ") ||
!strings.Contains(resp.Receipt.Reason, "not available in scratch sessions") {
t.Fatalf("scratch host rejection reason = %q", resp.Receipt.Reason)
}
if handled != 0 || preflights != 0 {
t.Fatalf("guard ran late: handler=%d preflight=%d", handled, preflights)
}
// 正面:同一命令在普通项目宿主上不被这条门禁拒绝。
ok := bus.Dispatch(context.Background(), Request{
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
ConversationID: "cnv_normal", HostProjectID: "prj_normal",
})
if ok.Receipt.State != agentcommand.StateCommitted || handled != 1 || preflights != 1 {
t.Fatalf("normal host dispatch = %+v handler=%d preflight=%d", ok.Receipt, handled, preflights)
}
}
// TestHostProjectGuardSkipsHostlessAndUnrelatedCommands 门禁只作用于带宿主的
// 请求与非项目命令;会话类命令在同一个临时会话宿主上照旧可用。
func TestHostProjectGuardSkipsHostlessAndUnrelatedCommands(t *testing.T) {
bus := New(&memAudit{}, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "",
func(context.Context, Request) (any, error) { return map[string]any{"pong": true}, nil }); err != nil {
t.Fatal(err)
}
guarded := 0
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
guarded++
if req.HostProjectID == "prj_scratch" && strings.HasPrefix(req.ID, "rdm.") {
return errors.New("not available in scratch sessions")
}
return nil
})
resp := bus.Dispatch(context.Background(), Request{
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: "prj_scratch",
})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
}
if guarded != 1 {
t.Fatalf("host guard calls = %d, want 1", guarded)
}
hostless := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: "api", Actor: "alice", Owner: "alice"})
if hostless.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("hostless dispatch = %+v", hostless.Receipt)
}
if guarded != 1 {
t.Fatalf("host guard ran for a hostless dispatch: %d", guarded)
}
}
+481
View File
@@ -0,0 +1,481 @@
// Package intentsession 是宿主侧的「会话内意图会话」服务(A1/U-A1b)。
//
// 它把一条可执行动作的预期(动作引用 + 仍缺的槽位 + 已收集的槽位)留在
// 会话里,等用户后续轮次把槽位补齐,而不是把同一次识别丢掉重来:
//
// Pending 读当前挂起预期(无会话/已过期即没有,过期行读侧惰性删除)
// Suspend 识别出「动作可执行但槽位未就绪」后挂起预期
// ApplyAnswer 把一轮用户回答折叠进会话、推进 revision、重新判定齐全性
// ApplyCommand 消费库层命令:set_slot/change_slot 改槽位、cancel 清预期
// Binding 读会话当前绑定的运行上下文(A1/U-A1c:取消意图的靶子来源)
// RecordCancel 记一次已派发的取消:清预期,留一条短期「已取消某运行」记录
//
// 纪律:
// - 会话语义只携带槽位与预期,绝不携带授权:高危动作的确认仍由 cmdbus
// 收口,会话补齐不会绕过任何确认门禁。
// - 键是 (owner, project, conversation):两个用户、两个项目的会话在构造上
// 互不可见。
// - revision 单调递增:陈旧回答(期间已被别的轮次推进或取消)一律拒绝
// (ErrStale),绝不允许旧槽位漏进新任务。
// - 库层 Stage-1 语义不变:本服务只消费库层产出的 set_slot/change_slot/
// cancel/expectation 命令,不改动识别分支语义。
// - 「已取消」记录不是待补齐的动作:它不再吸收槽位、不再被回答折叠,
// 只供紧接着的追问(如「确认取消」)如实回话(CancelRecordTTL 到期即
// 惰性删除)。
package intentsession
import (
"errors"
"strings"
"time"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/store"
)
// DefaultTTL 是挂起预期的默认存活时间(30 分钟;每次更新续期)。
const DefaultTTL = 30 * time.Minute
// CancelRecordTTL 是「已取消」记录的留存时间:只够紧接着的追问读到,
// 绝不跨会话长期驻留。
const CancelRecordTTL = 5 * time.Minute
// CancelActionKind 标记会话行的预期是「一次已派发的取消」记录,而不是待补齐
// 的动作。宿主据此不再把本行当挂起动作折叠,只把它当如实回话的依据。
const CancelActionKind = "cancel"
// cancelledRunSlot 是取消记录里被取消运行号所在的槽位键(宿主不透明)。
const cancelledRunSlot = "cancelled_run_id"
// runSlotKeys 是识别结果里承载运行号的槽位键(按优先级;固定序,结果确定)。
var runSlotKeys = []string{"run_id", "runId", "run"}
// ErrStale 表示调用方持有的 revision 已过期(期间会话已被推进或取消)。
// 调用方遇到它必须丢弃这次折叠,不得据此执行任何动作。
var ErrStale = errors.New("intentsession: stale session revision")
// SlotValidator 按动作声明的 schema 校验一个槽位值(宿主注入:命令总线侧的
// DECLARED schema)。返回错误即不采纳该值——非法值永远进不了会话槽位表。
// 动作不可解析(未注册/非命令)时宿主返回 nil 表示不校验。
type SlotValidator func(actionID, slot, value string) error
// Service 是意图会话服务。用 New 构造;零值不可用。
type Service struct {
st *store.Store
validate SlotValidator
clock func() time.Time
ttlValue time.Duration
}
// New 构造意图会话服务(挂起期默认 DefaultTTL,即每次更新后 30 分钟)。
func New(st *store.Store) *Service {
return &Service{st: st, clock: time.Now, ttlValue: DefaultTTL}
}
// SetSlotValidator 接线动作声明 schema 的槽位校验(启动期调用)。
func (s *Service) SetSlotValidator(v SlotValidator) { s.validate = v }
// SetTTL 覆盖挂起期(<=0 回落 DefaultTTL;仅启动期与测试期调用)。
func (s *Service) SetTTL(d time.Duration) {
if d <= 0 {
d = DefaultTTL
}
s.ttlValue = d
}
func (s *Service) ttl() time.Duration {
if s.ttlValue <= 0 {
return DefaultTTL
}
return s.ttlValue
}
func (s *Service) now() time.Time {
if s.clock == nil {
return time.Now()
}
return s.clock()
}
// Key 是一个会话的标识。Owner 与 Conversation 必填:owner 缺失即 fail-closed,
// 绝不会退化成跨用户共享的会话状态。
type Key struct {
Owner string
Project string
Conversation string
}
func (k Key) valid() bool { return k.Owner != "" && k.Conversation != "" }
// Expectation 是挂起的可执行动作预期。Action 是权威动作引用(命令/工作流/
// 小队/auto),Missing 是仍缺的槽位及建议提问。
type Expectation struct {
IntentID string
Action intentgate.ActionRef
Missing []intentgate.SlotQuestion
Question string
}
// Session 是一个会话当前的意图会话状态。
type Session struct {
Key Key
// Expectation 为 nil 表示没有挂起动作(已取消或已补齐)。
Expectation *Expectation
// Cancelled 非 nil 表示这一行是一次已派发的取消记录(不是待补齐动作)。
Cancelled *CancelRecord
Slots map[string]string
Revision int
ExpiresAt time.Time
}
// CancelRecord 是一次已派发的取消的短期记录:被取消的运行与其来源动作。
type CancelRecord struct {
RunID string
Intent string
Action string
}
// Binding 是会话当前绑定的运行上下文——「取消」意图的靶子来源(A1/U-A1c)。
//
// 它只声明「这个会话当时对着哪个运行」,不是授权:派发与确认仍在宿主侧的
// 既有取消路径上收口。
type Binding struct {
RunID string
Intent string
Action string
// Cancelled 为真时这条绑定来自一次已派发的取消(而不是待补齐动作):
// 宿主不应把它再当成可取消的靶子。
Cancelled bool
}
// Answer 是一轮用户回答折叠后的结果(由宿主从识别结果与库层命令投影)。
type Answer struct {
// Slots 是本轮补上的槽位;Invalid 是本轮给出但没通过权威校验的槽位
// (key→原因:值不采纳,仍按非法追问)。
Slots map[string]string
Invalid map[string]string
// Cancel 为真时本轮是取消:不折叠任何槽位,只清空挂起预期。
Cancel bool
}
// Outcome 是一次折叠的结果。nil 结果(error==nil)表示本轮没有任何会话
// 语义:宿主保持既有行为,不改响应。
type Outcome struct {
// Session 是折叠后的会话(取消或补齐被消费时为 nil)。
Session *Session
// Complete 表示动作已齐全:宿主可按与一次完整识别完全相同的准入与
// 派发路径执行(确认语义仍由 cmdbus 收口)。
Complete bool
// Cancelled 表示用户取消了挂起的补充(不得派发任何业务动作)。
Cancelled bool
// Slots 是累计后的全部槽位(Complete 时用于派发)。
Slots map[string]string
// Remaining/Invalid 是仍缺的槽位与本轮未通过的槽位(Complete=false 时
// 用于如实重新澄清)。
Remaining []intentgate.SlotQuestion
Invalid []intentgate.SlotDefect
Question string
}
// Pending 返回仍然有效的挂起预期。没有行、没有预期或已过期都返回 nil;
// 过期行在读侧惰性删除(过期会话绝不影响新的一轮)。
func (s *Service) Pending(key Key) (*Session, error) {
return s.load(key)
}
// Suspend 挂起一个可执行动作的预期并累计已抽到的槽位(续期)。已抽到的
// 槽位不再计入仍缺清单(挂起即如实反映还差什么)。
//
// 累计只在同一个动作内成立:新预期指向别的动作(意图 id 或动作引用变了)
// 时,上一轮已抽到的槽位一律丢弃——新任务从零开始,revision 照常推进,
// 陈旧回答因此作废。否则上一轮抽到的运行号会跟着新动作一起派发:旧任务的
// 槽位漏进新任务,正是本包纪律里明令禁止的那一类。
func (s *Service) Suspend(key Key, expectation Expectation, slots map[string]string) (*Session, error) {
cur, err := s.load(key)
if err != nil {
return nil, err
}
if cur != nil && cur.Expectation != nil && sameExpectation(*cur.Expectation, expectation) {
slots = mergeSlots(cur.Slots, slots)
}
var missing []intentgate.SlotQuestion
for _, q := range expectation.Missing {
if _, ok := slots[q.Key]; !ok {
missing = append(missing, q)
}
}
stored, err := s.st.PutIntentSession(key.Owner, key.Project, key.Conversation,
storeExpectation(Expectation{IntentID: expectation.IntentID, Action: expectation.Action,
Missing: missing, Question: expectation.Question}), slots, s.ttl())
if err != nil {
return nil, err
}
return fromStore(key, stored), nil
}
// ApplyAnswer 把一轮用户回答折叠进挂起的会话:合并槽位、推进 revision、
// 重新判定齐全性。revision 不匹配时返回 ErrStale(不改任何状态)。
func (s *Service) ApplyAnswer(key Key, revision int, answer Answer) (*Outcome, error) {
if answer.Cancel {
return s.ApplyCommand(key, revision, string(intentgate.CommandCancel), "", "")
}
cur, err := s.current(key, revision)
if err != nil || cur == nil {
return nil, err
}
if cur.Expectation == nil || cur.Cancelled != nil {
return nil, nil
}
if len(answer.Slots) == 0 && len(answer.Invalid) == 0 {
return nil, nil // 本轮没有槽位信息:不消耗 revision
}
merged := mergeSlots(cur.Slots, s.accepted(cur, answer))
expectation := *cur.Expectation
remaining, invalid := s.recheck(&expectation, merged, answer)
out := &Outcome{Slots: merged, Remaining: remaining, Invalid: invalid, Question: expectation.Question}
if len(remaining) == 0 && len(invalid) == 0 {
// 补齐即消费:会话状态归零,宿主按既有准入与派发路径执行。
if err := s.st.ClearIntentSession(key.Owner, key.Project, key.Conversation); err != nil {
return nil, err
}
out.Complete = true
return out, nil
}
stored, err := s.st.PutIntentSession(key.Owner, key.Project, key.Conversation,
storeExpectation(Expectation{IntentID: expectation.IntentID, Action: expectation.Action,
Missing: remaining, Question: expectation.Question}), merged, s.ttl())
if err != nil {
return nil, err
}
out.Session = fromStore(key, stored)
return out, nil
}
// ApplyCommand 消费库层会话命令:
// - set_slot / change_slot 写槽位(动作声明可解析时按声明校验,非法值不采纳);
// - cancel 清空挂起预期(保留已收集槽位,revision +1 让在途回答作废);
// - 其它种类(start/clarify/chitchat/handoff…)一律忽略,如实返回 nil。
func (s *Service) ApplyCommand(key Key, revision int, kind, name, value string) (*Outcome, error) {
switch kind {
case string(intentgate.CommandSetSlot), string(intentgate.CommandChangeSlot):
if strings.TrimSpace(name) == "" {
return nil, nil
}
return s.ApplyAnswer(key, revision, Answer{Slots: map[string]string{name: value}})
case string(intentgate.CommandCancel):
cur, err := s.current(key, revision)
if err != nil || cur == nil {
return nil, err
}
if cur.Expectation == nil || cur.Cancelled != nil {
// 没有挂起动作可放弃;已取消记录更不是「待补齐的补充」,
// 如实报告「本会话没有会话语义」,绝不假装又取消了一次。
return nil, nil
}
if _, err := s.st.ClearIntentSessionExpectation(key.Owner, key.Project, key.Conversation); err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
return nil, err
}
return &Outcome{Cancelled: true, Slots: cur.Slots}, nil
default:
return nil, nil // 未知命令种类:忽略,绝不假装已消费
}
}
// Drop 清空整个会话状态(动作已派发或用户明确放弃时用;幂等)。
func (s *Service) Drop(key Key) error {
if !key.valid() {
return nil
}
return s.st.ClearIntentSession(key.Owner, key.Project, key.Conversation)
}
// Binding 返回会话当前绑定的运行上下文:
// - 挂起预期里已经抽到的运行号(连同其意图与动作);
// - 或上一次取消留下的短期记录(Cancelled=true)。
//
// 没有绑定(无会话/已过期/抽到的槽位里没有运行号)一律返回 nil:宿主据此
// 澄清,绝不臆造一个靶子。读取失败向上传播,宿主一律 fail-closed。
func (s *Service) Binding(key Key) (*Binding, error) {
cur, err := s.load(key)
if err != nil || cur == nil || cur.Expectation == nil {
return nil, err
}
if cur.Cancelled != nil {
return &Binding{RunID: cur.Cancelled.RunID, Intent: cur.Cancelled.Intent,
Action: cur.Cancelled.Action, Cancelled: true}, nil
}
runID := runIDOf(cur.Slots)
if runID == "" {
return nil, nil
}
return &Binding{RunID: runID, Intent: cur.Expectation.IntentID, Action: cur.Expectation.Action.ID}, nil
}
// RecordCancel 记录一次已派发的取消:清空挂起预期(连带作废在途回答,
// revision +1),留下一条短期可读的「已取消某运行」记录,供紧接着的追问
// (如「确认取消」)如实回话。没有运行号或键不完整即不记录(fail-closed)。
func (s *Service) RecordCancel(key Key, b Binding) (*Session, error) {
if !key.valid() || strings.TrimSpace(b.RunID) == "" {
return nil, nil
}
slots := map[string]string{cancelledRunSlot: strings.TrimSpace(b.RunID)}
stored, err := s.st.PutIntentSession(key.Owner, key.Project, key.Conversation,
storeExpectation(Expectation{IntentID: b.Intent,
Action: intentgate.ActionRef{Kind: CancelActionKind, ID: b.Action}}),
slots, CancelRecordTTL)
if err != nil {
return nil, err
}
return fromStore(key, stored), nil
}
// current 读出会话并校验 revision:过期行惰性删除返回 nil;revision 不匹配
// 返回 ErrStale。
func (s *Service) current(key Key, revision int) (*Session, error) {
cur, err := s.load(key)
if err != nil || cur == nil {
return nil, err
}
if revision > 0 && revision != cur.Revision {
return nil, ErrStale
}
return cur, nil
}
// load 读出会话;过期行惰性删除后返回 nil。
func (s *Service) load(key Key) (*Session, error) {
if !key.valid() {
return nil, nil
}
cur, err := s.st.GetIntentSession(key.Owner, key.Project, key.Conversation)
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if !s.live(cur) {
if err := s.st.ClearIntentSession(key.Owner, key.Project, key.Conversation); err != nil {
return nil, err
}
return nil, nil
}
return fromStore(key, cur), nil
}
// live 报告会话是否仍在有效期内。没有挂起预期的行(已取消/已补齐)对宿主
// 等于「没有会话」。
func (s *Service) live(cur store.IntentSession) bool {
if cur.Expectation == nil {
return false
}
if cur.ExpiresAt.IsZero() {
return true
}
return s.now().Before(cur.ExpiresAt)
}
// recheck 折叠后重算仍缺的槽位与本轮未通过校验的槽位(确定性顺序:沿用挂起
// 时的缺失序;未通过的槽位保留其原提问并带上权威原因)。
func (s *Service) recheck(expectation *Expectation, slots map[string]string, answer Answer) ([]intentgate.SlotQuestion, []intentgate.SlotDefect) {
var remaining []intentgate.SlotQuestion
var invalid []intentgate.SlotDefect
for _, q := range expectation.Missing {
if reason, bad := answer.Invalid[q.Key]; bad {
invalid = append(invalid, intentgate.SlotDefect{Key: q.Key, Reason: reason, Question: q.Question, Invalid: true})
continue
}
if _, ok := slots[q.Key]; !ok {
remaining = append(remaining, q)
}
}
return remaining, invalid
}
// accepted 采纳本轮通过校验的槽位:非法值不采纳(仍按非法追问),其余按
// 动作声明校验(宿主注入;未接线即不校验)。
func (s *Service) accepted(cur *Session, answer Answer) map[string]string {
out := map[string]string{}
for k, v := range answer.Slots {
if _, bad := answer.Invalid[k]; bad {
continue
}
if s.validate != nil && cur.Expectation != nil && cur.Expectation.Action.ID != "" {
if err := s.validate(cur.Expectation.Action.ID, k, v); err != nil {
continue
}
}
out[k] = v
}
return out
}
// sameExpectation 报告新旧预期是否同一个动作(意图 id 与动作引用都一致):
// 只有同一个动作再次挂起才继续累计已抽到的槽位。预期是「已取消」记录时
// 动作种类为 cancel,与任何待补齐的动作都不相同——取消记录绝不被借走。
func sameExpectation(a, b Expectation) bool {
return a.IntentID == b.IntentID && a.Action.Kind == b.Action.Kind && a.Action.ID == b.Action.ID
}
// missingOf 投影到存储形态(缺失槽位表)。
func missingOf(list []intentgate.SlotQuestion) []store.IntentSlotQuestion {
out := make([]store.IntentSlotQuestion, 0, len(list))
for _, q := range list {
out = append(out, store.IntentSlotQuestion{Key: q.Key, Question: q.Question})
}
return out
}
// storeExpectation 投影到存储形态。
func storeExpectation(e Expectation) *store.IntentExpectation {
return &store.IntentExpectation{
IntentID: e.IntentID, ActionKind: e.Action.Kind, ActionID: e.Action.ID,
Missing: missingOf(e.Missing), Question: e.Question,
}
}
// fromStore 从存储行投影出宿主视图。
func fromStore(key Key, cur store.IntentSession) *Session {
out := &Session{Key: key, Slots: cur.Slots, Revision: cur.Revision, ExpiresAt: cur.ExpiresAt}
if cur.Expectation != nil {
exp := &Expectation{IntentID: cur.Expectation.IntentID, Question: cur.Expectation.Question,
Action: intentgate.ActionRef{Kind: cur.Expectation.ActionKind, ID: cur.Expectation.ActionID}}
for _, q := range cur.Expectation.Missing {
exp.Missing = append(exp.Missing, intentgate.SlotQuestion{Key: q.Key, Question: q.Question})
}
out.Expectation = exp
if exp.Action.Kind == CancelActionKind {
out.Cancelled = &CancelRecord{RunID: strings.TrimSpace(cur.Slots[cancelledRunSlot]),
Intent: exp.IntentID, Action: exp.Action.ID}
}
}
if out.Slots == nil {
out.Slots = map[string]string{}
}
return out
}
// runIDOf 从槽位表里取运行号(固定键序,结果与 map 迭代序无关;无则空)。
func runIDOf(slots map[string]string) string {
for _, key := range runSlotKeys {
if v := strings.TrimSpace(slots[key]); v != "" {
return v
}
}
return ""
}
// mergeSlots 合并两组槽位(后者覆盖前者,返回新表:不改调用方的 map)。
func mergeSlots(base, add map[string]string) map[string]string {
out := map[string]string{}
for k, v := range base {
out[k] = v
}
for k, v := range add {
out[k] = v
}
return out
}
@@ -0,0 +1,588 @@
package intentsession
import (
"errors"
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/store"
)
// A1/U-A1b:会话内意图会话。合成数据 + 嵌入式存储(PG 档由 store 包双档
// 测试覆盖同一张表)。命令动作(run-cancel 形态)先行,取消路由归 U-A1c。
func newService(t *testing.T) *Service {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "intent-session.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return New(st)
}
func cancelKey() Key { return Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_1"} }
// cancelExpectation 是首个动作(取消运行):动作引用 + 仍缺槽位 + 提问。
func cancelExpectation() Expectation {
return Expectation{
IntentID: "demo.run-cancel",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
Missing: []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"},
{Key: "reason", Question: "取消原因?"},
},
Question: "取消哪个运行?",
}
}
func questionKeys(list []intentgate.SlotQuestion) []string {
out := make([]string, 0, len(list))
for _, q := range list {
out = append(out, q.Key)
}
return out
}
// 无会话时 Pending 就是「没有」,且不产生任何行。
func TestPendingWithoutSession(t *testing.T) {
svc := newService(t)
got, err := svc.Pending(cancelKey())
if err != nil || got != nil {
t.Fatalf("无会话时应为空:%v %v", got, err)
}
// 归属不完整即 fail-closed(不会退化成跨用户共享状态)。
if got, err := svc.Pending(Key{Conversation: "cnv_1"}); err != nil || got != nil {
t.Fatalf("无 owner 的键必须被拒:%v %v", got, err)
}
}
// 缺槽位动作 → 挂起 → 回答补齐:齐全即消费会话(宿主按既有准入派发)。
func TestSuspendThenAnswerCompletes(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-1"})
if err != nil {
t.Fatal(err)
}
if suspended.Revision != 1 || suspended.Expectation == nil || suspended.Expectation.Action.ID != "demo.run-cancel" {
t.Fatalf("挂起结果:%+v", suspended)
}
if keys := questionKeys(suspended.Expectation.Missing); len(keys) != 1 || keys[0] != "reason" {
t.Fatalf("已抽到的槽位不应再算缺失:%v", keys)
}
if !suspended.ExpiresAt.After(time.Now()) {
t.Fatalf("到期时间未设:%v", suspended.ExpiresAt)
}
// 部分回答:仍缺原因 → 仍挂起、revision 递增、到期时间续期。
partial, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"run_id": "R-2"}})
if err != nil {
t.Fatal(err)
}
if partial.Complete || partial.Cancelled {
t.Fatalf("部分回答不得视为完成:%+v", partial)
}
if keys := questionKeys(partial.Remaining); len(keys) != 1 || keys[0] != "reason" {
t.Fatalf("剩余缺失:%v", keys)
}
if partial.Session.Revision != 2 || !partial.Session.ExpiresAt.After(suspended.ExpiresAt) {
t.Fatalf("revision/续期:%+v", partial.Session)
}
// 补齐最后一个:Complete=true,会话被消费。
done, err := svc.ApplyAnswer(key, partial.Session.Revision, Answer{Slots: map[string]string{"reason": "误触发"}})
if err != nil {
t.Fatal(err)
}
if !done.Complete || done.Session != nil {
t.Fatalf("补齐应完成并消费会话:%+v", done)
}
if done.Slots["run_id"] != "R-2" || done.Slots["reason"] != "误触发" {
t.Fatalf("累计槽位:%v", done.Slots)
}
if got, err := svc.Pending(key); err != nil || got != nil {
t.Fatalf("完成后仍挂起:%v %v", got, err)
}
}
// 换动作即换任务(跨动作槽位继承的回归序列):A 抽到运行号后挂起 → 用户
// 改口做另一个动作 B(B 缺别的槽位)→ 用户补齐 B → B 齐全时只带 B 自己
// 的槽位。A 的运行号绝不跟着 B 一起派发。
func TestSuspendDiscardsSlotsOfAnotherAction(t *testing.T) {
svc := newService(t)
key := cancelKey()
first, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-7"})
if err != nil {
t.Fatal(err)
}
report := Expectation{IntentID: "demo.report",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"},
Missing: []intentgate.SlotQuestion{{Key: "scope", Question: "哪个范围?"}},
Question: "哪个范围?"}
second, err := svc.Suspend(key, report, nil)
if err != nil {
t.Fatal(err)
}
if second.Revision <= first.Revision {
t.Fatalf("换动作必须推进 revision(让在途回答作废):%d vs %d", second.Revision, first.Revision)
}
if second.Expectation == nil || second.Expectation.IntentID != "demo.report" ||
second.Expectation.Action.ID != "demo.report" {
t.Fatalf("挂起的应是新动作:%+v", second)
}
if _, leaked := second.Slots["run_id"]; leaked {
t.Fatalf("上一个动作的槽位被继承:%+v", second.Slots)
}
if keys := questionKeys(second.Expectation.Missing); len(keys) != 1 || keys[0] != "scope" {
t.Fatalf("新动作的缺失清单:%v", keys)
}
// 旧动作的陈旧回答也作废(不得借新动作的壳补齐)。
if _, err := svc.ApplyAnswer(key, first.Revision,
Answer{Slots: map[string]string{"reason": "误触发"}}); !errors.Is(err, ErrStale) {
t.Fatalf("旧 revision 必须被拒:%v", err)
}
// 用户补齐 B:B 齐全,且槽位只有 B 自己的(没有 A 的 run_id)。
done, err := svc.ApplyAnswer(key, second.Revision, Answer{Slots: map[string]string{"scope": "华东"}})
if err != nil {
t.Fatal(err)
}
if !done.Complete || done.Session != nil {
t.Fatalf("B 应被补齐并消费会话:%+v", done)
}
if len(done.Slots) != 1 || done.Slots["scope"] != "华东" {
t.Fatalf("派发槽位混进了别的动作的值:%v", done.Slots)
}
}
// 同一动作再次挂起仍累计已抽到的槽位(缺失清单变窄不算换动作)。
func TestSuspendOfSameActionKeepsAccumulatedSlots(t *testing.T) {
svc := newService(t)
key := cancelKey()
first, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
// 同一意图与动作引用,只是路由面收敛了缺失清单(仍缺原因)。
narrowed := cancelExpectation()
narrowed.Missing = []intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}
again, err := svc.Suspend(key, narrowed, map[string]string{"run_id": "R-9"})
if err != nil {
t.Fatal(err)
}
if again.Revision <= first.Revision {
t.Fatalf("再次挂起必须推进 revision:%d vs %d", again.Revision, first.Revision)
}
if again.Slots["run_id"] != "R-9" {
t.Fatalf("同一动作的累计槽位被丢弃:%+v", again.Slots)
}
if keys := questionKeys(again.Expectation.Missing); len(keys) != 1 || keys[0] != "reason" {
t.Fatalf("同一动作的缺失清单:%v", keys)
}
done, err := svc.ApplyAnswer(key, again.Revision, Answer{Slots: map[string]string{"reason": "误触发"}})
if err != nil {
t.Fatal(err)
}
if !done.Complete || done.Slots["run_id"] != "R-9" || done.Slots["reason"] != "误触发" {
t.Fatalf("同一动作补齐:%+v", done)
}
}
// 两轮部分填充:set_slot 落一个槽位,再由回答补另一个。
func TestSetSlotThenAnswerCompletes(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
set, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "run_id", "R-7")
if err != nil {
t.Fatal(err)
}
if set.Complete || set.Session.Slots["run_id"] != "R-7" || set.Session.Revision != 2 {
t.Fatalf("set_slot 后:%+v", set)
}
if keys := questionKeys(set.Remaining); len(keys) != 1 || keys[0] != "reason" {
t.Fatalf("剩余缺失:%v", keys)
}
change, err := svc.ApplyCommand(key, set.Session.Revision, string(intentgate.CommandChangeSlot), "run_id", "R-8")
if err != nil {
t.Fatal(err)
}
if change.Session.Slots["run_id"] != "R-8" {
t.Fatalf("change_slot 未改写:%+v", change.Session)
}
done, err := svc.ApplyAnswer(key, change.Session.Revision, Answer{Slots: map[string]string{"reason": "重复提交"}})
if err != nil {
t.Fatal(err)
}
if !done.Complete || done.Slots["run_id"] != "R-8" || done.Slots["reason"] != "重复提交" {
t.Fatalf("两轮补齐:%+v", done)
}
}
// 取消清空挂起预期:确认取消、不假装成功,陈旧回答也作废。
func TestCancelClearsPendingExpectation(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-1"})
if err != nil {
t.Fatal(err)
}
out, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandCancel), "", "")
if err != nil {
t.Fatal(err)
}
if !out.Cancelled || out.Complete || out.Session != nil {
t.Fatalf("取消结果:%+v", out)
}
if got, err := svc.Pending(key); err != nil || got != nil {
t.Fatalf("取消后仍有挂起预期:%v %v", got, err)
}
if _, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"reason": "误触发"}}); err != nil {
t.Fatalf("取消后不应再有会话可折叠:%v", err)
}
}
// 陈旧回答一律拒绝:期间 revision 已被别的轮次推进过。
func TestStaleRevisionRejected(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
moved, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "run_id", "R-7")
if err != nil {
t.Fatal(err)
}
if _, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"reason": "旧回答"}}); !errors.Is(err, ErrStale) {
t.Fatalf("陈旧回答必须被拒:%v", err)
}
if _, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandCancel), "", ""); !errors.Is(err, ErrStale) {
t.Fatalf("陈旧取消必须被拒:%v", err)
}
// 状态未被陈旧回答改动:仍是推进后的那一版。
cur, err := svc.Pending(key)
if err != nil || cur.Revision != moved.Session.Revision || cur.Slots["run_id"] != "R-7" || cur.Slots["reason"] != "" {
t.Fatalf("陈旧回答改动了状态:%+v %v", cur, err)
}
}
// 过期会话既不补齐旧动作,也不影响新的挂起:读侧惰性删除过期行。
func TestExpiredSessionNeverCompletesOldAction(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
// 时钟前移越过到期时间:过期会话对任何新轮次都不存在。
svc.clock = func() time.Time { return suspended.ExpiresAt.Add(time.Second) }
got, err := svc.Pending(key)
if err != nil || got != nil {
t.Fatalf("过期会话仍可见:%v %v", got, err)
}
if out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"run_id": "R-1", "reason": "误触发"}}); err != nil || out != nil {
t.Fatalf("过期回答不得补齐旧动作:%+v %v", out, err)
}
// 过期行已删除 → 新任务重新挂起时从干净状态开始。
fresh, err := svc.Suspend(key, Expectation{IntentID: "demo.purge",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.purge"},
Missing: []intentgate.SlotQuestion{{Key: "target", Question: "清理哪个对象?"}},
Question: "清理哪个对象?"}, nil)
if err != nil {
t.Fatal(err)
}
if fresh.Revision != 1 || fresh.Slots["run_id"] != "" {
t.Fatalf("新挂起被旧会话污染:%+v", fresh)
}
}
// 两个用户、两个项目在构造上互不可见。
func TestSessionsAreIsolatedByOwnerAndProject(t *testing.T) {
svc := newService(t)
alice := Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_1"}
bob := Key{Owner: "bob", Project: "prj_1", Conversation: "cnv_1"}
other := Key{Owner: "alice", Project: "prj_2", Conversation: "cnv_1"}
otherConv := Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_2"}
suspended, err := svc.Suspend(alice, cancelExpectation(), map[string]string{"run_id": "R-alice"})
if err != nil {
t.Fatal(err)
}
for _, key := range []Key{bob, other, otherConv} {
got, err := svc.Pending(key)
if err != nil || got != nil {
t.Fatalf("%+v 读到了别人的会话:%v %v", key, got, err)
}
// 陈旧的答案也绝不命中别人的会话。
out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"run_id": "R-x", "reason": "x"}})
if err != nil || out != nil {
t.Fatalf("%+v 的回答折叠进了别人的会话:%+v %v", key, out, err)
}
if err := svc.Drop(key); err != nil {
t.Fatal(err)
}
}
cur, err := svc.Pending(alice)
if err != nil || cur == nil || cur.Slots["run_id"] != "R-alice" || cur.Revision != 1 {
t.Fatalf("属主会话被影响:%+v %v", cur, err)
}
}
// 槽位值按动作声明 schema 校验:非法值不采纳(仍按缺失追问),
// 合法值正常折叠。
func TestAnswerSlotsValidatedAgainstDeclaredSchema(t *testing.T) {
svc := newService(t)
key := cancelKey()
var validated []string
svc.SetSlotValidator(func(actionID, slot, value string) error {
if actionID != "demo.run-cancel" || slot != "count" {
return nil
}
validated = append(validated, value)
if value == "abc" {
return errors.New("不是整数")
}
return nil
})
expectation := Expectation{IntentID: "demo.run-cancel",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
Missing: []intentgate.SlotQuestion{{Key: "count", Question: "几次?"}, {Key: "reason", Question: "原因?"}},
Question: "几次?"}
suspended, err := svc.Suspend(key, expectation, nil)
if err != nil {
t.Fatal(err)
}
bad, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"count": "abc"}})
if err != nil {
t.Fatal(err)
}
if bad.Complete || bad.Session.Slots["count"] != "" {
t.Fatalf("非法值被采纳:%+v", bad.Session)
}
if keys := questionKeys(bad.Remaining); len(keys) != 2 {
t.Fatalf("非法值应仍按缺失追问:%v", keys)
}
if len(validated) != 1 || validated[0] != "abc" {
t.Fatalf("声明校验未生效:%v", validated)
}
good, err := svc.ApplyAnswer(key, bad.Session.Revision, Answer{Slots: map[string]string{"count": "3", "reason": "误触发"}})
if err != nil {
t.Fatal(err)
}
if !good.Complete || good.Slots["count"] != "3" {
t.Fatalf("合法值未补齐:%+v", good)
}
}
// 本轮给出但没通过权威校验的值:如实按非法追问(值不采纳)。
func TestInvalidAnswerIsReportedNotAdopted(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, Expectation{IntentID: "demo.run-cancel",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
Missing: []intentgate.SlotQuestion{{Key: "mode", Question: "要哪种模式?"}},
Question: "要哪种模式?"}, nil)
if err != nil {
t.Fatal(err)
}
out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{
Slots: map[string]string{"mode": "随手写"},
Invalid: map[string]string{"mode": "value not in enum"},
})
if err != nil {
t.Fatal(err)
}
if out.Complete || out.Session.Slots["mode"] != "" {
t.Fatalf("非法值被采纳:%+v", out)
}
if len(out.Invalid) != 1 || out.Invalid[0].Key != "mode" || out.Invalid[0].Reason != "value not in enum" {
t.Fatalf("非法原因未透传:%+v", out.Invalid)
}
if len(out.Remaining) != 0 {
t.Fatalf("非法不应重复计入缺失:%+v", out.Remaining)
}
}
// 未知命令种类如实忽略:不改状态、不假装已消费。
func TestUnknownCommandsAreIgnored(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
for _, kind := range []string{string(intentgate.CommandStart), string(intentgate.CommandClarify),
string(intentgate.CommandChitchat), string(intentgate.CommandHandoff), "made-up"} {
out, err := svc.ApplyCommand(key, suspended.Revision, kind, "run_id", "R-9")
if err != nil || out != nil {
t.Fatalf("%s 不得被当成会话命令:%+v %v", kind, out, err)
}
}
cur, err := svc.Pending(key)
if err != nil || cur.Revision != suspended.Revision || cur.Slots["run_id"] != "" {
t.Fatalf("未知命令改动了状态:%+v %v", cur, err)
}
// 没有槽位名时同样忽略(不写入空键)。
if out, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), " ", "R-9"); err != nil || out != nil {
t.Fatalf("空槽位名必须被忽略:%+v %v", out, err)
}
}
// Drop 幂等清空;之后重新挂起从 revision 1 开始。
func TestDropIsIdempotent(t *testing.T) {
svc := newService(t)
key := cancelKey()
if _, err := svc.Suspend(key, cancelExpectation(), nil); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := svc.Drop(key); err != nil {
t.Fatalf("drop %d:%v", i, err)
}
}
if err := svc.Drop(Key{Conversation: "cnv_1"}); err != nil {
t.Fatalf("不完整的键必须安全无操作:%v", err)
}
again, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
if again.Revision != 1 || again.Slots["run_id"] != "" {
t.Fatalf("drop 后重新挂起:%+v", again)
}
}
// ---- A1/U-A1c:取消的靶子来源(Binding)与已取消记录(RecordCancel)----
// 挂起预期里已经抽到的运行号就是取消的靶子;连同意图与动作一并透出。
func TestBindingComesFromSuspendedSlots(t *testing.T) {
svc := newService(t)
key := cancelKey()
// 没有会话 → 没有靶子(绝不臆造)。
if got, err := svc.Binding(key); err != nil || got != nil {
t.Fatalf("无会话时必须有 binding:%v %v", got, err)
}
// 槽位里没有运行号 → 同样没有靶子。
if _, err := svc.Suspend(key, cancelExpectation(), nil); err != nil {
t.Fatal(err)
}
if got, err := svc.Binding(key); err != nil || got != nil {
t.Fatalf("无运行号时必须没有靶子:%v %v", got, err)
}
// 抽到运行号 → 靶子就绪(取消意图据此可无澄清地派发)。
svc.Drop(key)
if _, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-7"}); err != nil {
t.Fatal(err)
}
got, err := svc.Binding(key)
if err != nil || got == nil {
t.Fatalf("有运行号时必须解出靶子:%v %v", got, err)
}
if got.RunID != "R-7" || got.Intent != "demo.run-cancel" || got.Action != "demo.run-cancel" || got.Cancelled {
t.Fatalf("靶子上下文:%+v", got)
}
// runId/run 两种写法同样识别(键序固定,结果确定)。
svc.Drop(key)
for _, slot := range []string{"runId", "run"} {
if _, err := svc.Suspend(key, cancelExpectation(), map[string]string{slot: "R-8", "reason": "误触发"}); err != nil {
t.Fatal(err)
}
if got, _ := svc.Binding(key); got == nil || got.RunID != "R-8" {
t.Fatalf("槽位 %s 未被识别为运行号:%+v", slot, got)
}
svc.Drop(key)
}
}
// 已派发的取消:清挂起预期 + 留短期记录;记录不可再被回答折叠,也不作靶子。
func TestRecordCancelClearsExpectationAndKeepsShortLivedRecord(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-7"})
if err != nil {
t.Fatal(err)
}
recorded, err := svc.RecordCancel(key, Binding{RunID: "R-7", Intent: "demo.task-cancel", Action: "demo.run-cancel"})
if err != nil {
t.Fatal(err)
}
if recorded == nil || recorded.Cancelled == nil || recorded.Cancelled.RunID != "R-7" {
t.Fatalf("取消记录:%+v", recorded)
}
if recorded.Cancelled.Intent != "demo.task-cancel" || recorded.Cancelled.Action != "demo.run-cancel" {
t.Fatalf("取消记录上下文:%+v", recorded.Cancelled)
}
if recorded.Revision <= suspended.Revision {
t.Fatalf("取消必须推进 revision(让在途回答作废):%d vs %d", recorded.Revision, suspended.Revision)
}
if len(recorded.Expectation.Missing) != 0 || recorded.Slots["run_id"] != "" {
t.Fatalf("取消后不得还留着待补齐的槽位/缺失:%+v", recorded)
}
// 记录仍可读:宿主据此如实回答「确认取消」这类追问。
got, err := svc.Binding(key)
if err != nil || got == nil || got.RunID != "R-7" || !got.Cancelled {
t.Fatalf("取消记录应可读且标记为已取消:%+v %v", got, err)
}
// 取消记录不是待补齐的动作:回答与 cancel 命令都不再折叠进它。
if out, err := svc.ApplyAnswer(key, recorded.Revision, Answer{Slots: map[string]string{"reason": "误触发"}}); err != nil || out != nil {
t.Fatalf("取消记录不得吸收回答:%+v %v", out, err)
}
if out, err := svc.ApplyCommand(key, recorded.Revision, string(intentgate.CommandCancel), "", ""); err != nil || out != nil {
t.Fatalf("取消记录不得再被取消一次:%+v %v", out, err)
}
if cur, err := svc.Pending(key); err != nil || cur == nil || cur.Cancelled == nil {
t.Fatalf("取消记录不得被当成挂起动作改写:%+v %v", cur, err)
}
// 记录短期留存:越过 CancelRecordTTL 后对任何新轮次都不存在。
svc.clock = func() time.Time { return recorded.ExpiresAt.Add(time.Second) }
if got, err := svc.Binding(key); err != nil || got != nil {
t.Fatalf("过期记录仍可见:%+v %v", got, err)
}
if _, err := svc.Pending(key); err != nil {
t.Fatal(err)
}
if _, err := svc.st.GetIntentSession(key.Owner, key.Project, key.Conversation); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("过期行未被惰性删除:%v", err)
}
}
// 缺键或没有运行号时如实不记录(fail-closed,绝不写半条取消记录)。
func TestRecordCancelIsFailClosed(t *testing.T) {
svc := newService(t)
key := cancelKey()
if got, err := svc.RecordCancel(Key{Conversation: "cnv_1"}, Binding{RunID: "R-7"}); err != nil || got != nil {
t.Fatalf("缺 owner 的键必须无操作:%+v %v", got, err)
}
if got, err := svc.RecordCancel(key, Binding{RunID: " "}); err != nil || got != nil {
t.Fatalf("没有运行号必须无操作:%+v %v", got, err)
}
if got, err := svc.Binding(key); err != nil || got != nil {
t.Fatalf("无操作不得留下任何行:%+v %v", got, err)
}
}
// 两个用户的绑定互不可见:别人的运行号绝不会成为本会话的靶子。
func TestBindingIsIsolatedByOwner(t *testing.T) {
svc := newService(t)
alice := Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_1"}
bob := Key{Owner: "bob", Project: "prj_1", Conversation: "cnv_1"}
if _, err := svc.Suspend(alice, cancelExpectation(), map[string]string{"run_id": "R-alice"}); err != nil {
t.Fatal(err)
}
if _, err := svc.RecordCancel(bob, Binding{RunID: "R-bob"}); err != nil {
t.Fatal(err)
}
if got, _ := svc.Binding(bob); got == nil || got.RunID != "R-bob" {
t.Fatalf("bob 的绑定:%+v", got)
}
if got, _ := svc.Binding(alice); got == nil || got.RunID != "R-alice" || got.Cancelled {
t.Fatalf("alice 的绑定被污染:%+v", got)
}
}
+122 -12
View File
@@ -18,9 +18,18 @@
// 当前版本的已知边界(如实记录,不静默):
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
// 本版本调用方按普通回合处理并保留 Decision 供审计。
// - SetSlot/ChangeSlot/Cancel/Handoff 由库层产出,但 Backend 尚无会话槽位
// 状态机与语义层:它们记入 Unsupported 并回落普通回合,绝不假装已支持
// (例如用户说「取消」时不会把同一句话里的 Start 当成业务动作派发)。
// - Handoff 仍由宿主不消费,记 Unsupported 并回落普通回合。
// - Cancel 已是宿主可执行的既有动作(A1/U-A1c):本包只解出「取消哪一个
// 运行」——原文点名的运行号优先,其次当前会话绑定动作的运行上下文
// (Env.SessionRun,宿主从意图会话注入)。解不出唯一靶子时 Decision
// 如实留空 RunID 并写明原因,由宿主澄清,绝不替用户猜一个。
// 本包不派发取消、也不认识取消的实现(收件箱/命令总线的确认语义都在
// 宿主侧),更不自动确认。
// - SetSlot/ChangeSlot 已被宿主消费,但消费的语义不在本包:宿主 chat 入口
// 把它们折叠进会话里的挂起预期(internal/intentsession 的会话状态机,
// A1/U-A1b)——落槽与改槽是「同一动作的补充说明」,不是可派发的动作。
// 本包仍不把它们当动作派发:命中即记 Unsupported 并回落普通回合,
// 同时绝不把同一句话里的 Start 当作业务动作派发。
package route
import (
@@ -38,8 +47,44 @@ const (
KindWorkflow Kind = "workflow"
KindSquad Kind = "squad"
KindTurn Kind = "turn"
// KindCancel 是「取消」意图的裁决:取消是既有动作(取消一个运行),
// 不是可以顺带启动的动作。靶子解出与否都在 Decision.Cancel 里,
// 派发与确认由宿主按既有取消路径完成。
KindCancel Kind = "cancel"
)
// CancelSource 说明取消靶子的来源(宿主据此如实回话;不是授权凭据)。
type CancelSource string
const (
// CancelSourceExplicit:用户这一轮点名了运行(命令值或 run_id 槽位)。
CancelSourceExplicit CancelSource = "explicit"
// CancelSourceSession:当前会话绑定动作的运行上下文(意图会话)。
CancelSourceSession CancelSource = "session"
)
// CancelTarget 是「取消」的裁决靶子。
//
// 纪律:RunID 为空就是「没有唯一靶子」——宿主必须如实澄清,绝不猜一个
// 「大概是它」;带靶子的裁决也只声明「要取消谁」,不代表已获授权。
type CancelTarget struct {
// RunID 是要取消的运行(空 = 待宿主澄清)。
RunID string
Source CancelSource
// Intent/Action 是靶子的来源上下文(会话绑定的意图与动作;审计与回话用)。
Intent string
Action string
// Candidates 是库层随取消命令给出的候选(如有):如实透出,不做取舍。
Candidates []string
}
// SessionRun 是会话(意图会话)里绑定的运行上下文,由宿主注入给 Env。
type SessionRun struct {
RunID string
Intent string
Action string
}
// Clarification 是「业务动作未派发,需要向用户澄清」的诉求。
// 槽位缺失与非法都带结构化原因(key + reason + question),
// 宿主据此渲染澄清话术或转成回合提示,不再自行猜测。
@@ -62,8 +107,10 @@ type Decision struct {
Missing []intentgate.SlotQuestion
Invalid []intentgate.SlotDefect
Risk string // 权威意图声明的风险档(low/medium/high)
Reason string // 回落 turn 的原因(显式声明不可解析、准入未过等)
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
Reason string // 回落 turn 的原因(显式声明不可解析、准入未过、取消无唯一靶子等)
StartFound bool // 本轮是否会启动动作(runmgr 转轨判定用;取消裁决下恒为 false——取消与启动互斥)
// Cancel 非 nil 时本轮是「取消」意图的裁决(Kind=KindCancel)。
Cancel *CancelTarget
// RequiresConfirm 是意图声明的显式确认要求(与命令自身的 danger 分级正交,
// 本包只透传,不替代 cmdbus 的高危确认收口)。
RequiresConfirm bool
@@ -78,25 +125,34 @@ type Env struct {
HasCommand func(id string) bool
WorkflowFor func(intentID string) (core.WorkflowDef, bool)
SquadReady bool
// SessionRun 返回当前会话绑定动作的运行上下文(取消靶子的会话来源;
// 宿主从意图会话注入)。nil 或 false = 会话没有给出靶子——绝不臆造。
SessionRun func() (SessionRun, bool)
}
// unconsumedCommandKinds 是宿主 v1 不消费的库层命令种类(无会话槽位状态机)。
// 命中即不派发业务动作:把「取消」当成「启动」是比不响更糟的错配。
// unconsumedCommandKinds 是本包不派发的库层命令种类。
//
// 命中即不派发业务动作:把「改槽」当成「启动」是比不响更糟的错配。
// set_slot/change_slot 已由宿主的会话状态机消费(internal/intentsession,
// A1/U-A1b)——它们改的是会话里挂起动作的槽位;cancel 自 U-A1c 起由宿主
// 按「取消既有动作」消费(解靶子 → 宿主派发既有取消路径),故不在本表。
// chitchat/handoff 由宿主自行处理。
var unconsumedCommandKinds = map[string]bool{
string(intentgate.CommandSetSlot): true,
string(intentgate.CommandChangeSlot): true,
string(intentgate.CommandCancel): true,
string(intentgate.CommandHandoff): true,
string(intentgate.CommandChitchat): true,
}
// Decide 裁决一次识别结果。规则:
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
// 2. 出现宿主不消费的库层命令(cancel/set_slot/…)→ turn,并如实记录
// 2. 出现 cancel 命令 → 取消裁决(KindCancel):解出唯一靶子,宿主据此派发
// 既有取消动作;同句的 Start 一并不派发(取消与启动互斥)。
// 3. 出现宿主不消费的库层命令(set_slot/handoff/…)→ turn,并如实记录
// Unsupported 与 Reason(不假装支持,也不派发同句的 Start)。
// 3. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto
// 4. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto
// (兼容兜底,与现网一致)。
// 4. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入);
// 5. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入);
// workflow→工作流(草稿,缺槽照常启动);blueprint→squad 决策;
// handler/auto→命令总线 > 工作流 > turn。
func Decide(res intentgate.MatchResult, env Env) Decision {
@@ -106,7 +162,7 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
return d
}
d.Unsupported = unconsumedKinds(res.Commands)
var start, clarify *intentgate.Command
var start, clarify, cancel *intentgate.Command
for i := range res.Commands {
switch res.Commands[i].Kind {
case intentgate.CommandStart:
@@ -117,8 +173,18 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
if clarify == nil {
clarify = &res.Commands[i]
}
case intentgate.CommandCancel:
if cancel == nil {
cancel = &res.Commands[i]
}
}
}
if cancel != nil {
// 取消优先于同句的其它命令:用户这一轮要的是「取消」,不是「顺便
// 启动」。StartFound 刻意不置位——本轮不会启动任何动作,runmgr 的
// 转轨判定因此保持与取消前一致。
return d.decideCancel(*cancel, res, env)
}
if len(d.Unsupported) > 0 {
d.Reason = "宿主未消费库层命令:" + strings.Join(d.Unsupported, "、")
if clarify != nil {
@@ -168,6 +234,50 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
return d
}
// decideCancel 裁决「取消」:解出唯一靶子,把「取消哪一个运行」交给宿主。
//
// 靶子优先级(确定性,用户点名优先于会话绑定):
// 1. 取消命令自身的值 / 本轮槽位里的运行号——用户明确指认;
// 2. 当前会话绑定动作的运行上下文(Env.SessionRun,宿主从意图会话读)。
//
// 解不出唯一靶子时 RunID 留空并写明原因:宿主据此如实澄清,绝不猜。
// 本包不派发、不确认:执行授权始终由宿主的既有取消路径收口。
// 同句里其余未消费的库层命令(如 set_slot)仍如实记在 Unsupported 里供审计,
// 但既不派发也不参与靶子解算——取消与启动互斥。
func (d Decision) decideCancel(cmd intentgate.Command, res intentgate.MatchResult, env Env) Decision {
d.Kind = KindCancel
t := &CancelTarget{Intent: res.ID, Candidates: append([]string(nil), cmd.Candidates...)}
if runID := explicitRunID(res, cmd); runID != "" {
t.RunID, t.Source = runID, CancelSourceExplicit
} else if env.SessionRun != nil {
if bound, ok := env.SessionRun(); ok && strings.TrimSpace(bound.RunID) != "" {
t.RunID, t.Source, t.Action = strings.TrimSpace(bound.RunID), CancelSourceSession, bound.Action
if t.Intent == "" {
t.Intent = bound.Intent
}
}
}
if t.RunID == "" {
d.Reason = "取消没有唯一靶子:原文未指明运行,当前会话也没有绑定运行"
}
d.Cancel = t
return d
}
// explicitRunID 取消命令里点名的运行:命令自身的值优先,其次本轮槽位里的
// 运行号(键序固定,结果与 map 迭代序无关)。
func explicitRunID(res intentgate.MatchResult, cmd intentgate.Command) string {
if v := strings.TrimSpace(cmd.Value); v != "" {
return v
}
for _, key := range []string{"run_id", "runId", "run"} {
if v := strings.TrimSpace(res.Slots[key]); v != "" {
return v
}
}
return ""
}
// dispatchCommand 派发可执行业务动作前的最后一道闸:槽位必须齐全且通过权威校验。
// 未就绪时不碰业务动作,改为带结构化原因的澄清诉求(Kind 留在 turn)。
func (d Decision) dispatchCommand(id string, res intentgate.MatchResult, clarify *intentgate.Command) Decision {
+166 -7
View File
@@ -153,21 +153,22 @@ func TestWorkflowDraftStartsWhileGatheringSlots(t *testing.T) {
}
}
// 显式的取消/改槽命令不得被静默当成已支持:回落普通回合,如实记录。
// 显式的取消/改槽命令不得被静默当成已支持:改槽回落普通回合,如实记录;
// 取消已经是宿主可执行的既有动作(U-A1c),但解不出靶子时绝不假装已取消。
func TestUnsupportedLibraryCommandsAreNotClaimed(t *testing.T) {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}},
{Kind: intentgate.CommandCancel, Target: "demo.brief"},
}}
d := Decide(in, hasCommandEnv())
if d.Kind != KindTurn || d.CommandID != "" {
t.Fatalf("未消费的库层命令不得派发业务动作:%+v", d)
if d.Kind != KindCancel || d.CommandID != "" {
t.Fatalf("取消不得被当成新业务动作派发:%+v", d)
}
if len(d.Unsupported) != 1 || d.Unsupported[0] != string(intentgate.CommandCancel) {
t.Fatalf("未消费命令种类未如实记录:%+v", d.Unsupported)
if len(d.Unsupported) != 0 {
t.Fatalf("取消已由宿主消费,不得再记为未消费:%+v", d.Unsupported)
}
if d.Reason == "" {
t.Fatalf("回落原因缺失:%+v", d)
if d.Cancel == nil || d.Cancel.RunID != "" || d.Reason == "" {
t.Fatalf("没有唯一靶子时必须如实留空并说明原因:%+v", d)
}
// 只有改槽命令时同样回落,且不得声称支持。
only := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
@@ -202,3 +203,161 @@ func TestLegacyAutoStillNeedsCompleteSlots(t *testing.T) {
t.Fatalf("旧路径也必须过槽位准入:%+v", d)
}
}
// A1/U-A1b:set_slot/change_slot 由宿主的会话状态机消费(internal/intentsession),
// 但本包的公开契约不变——落槽不是可派发的动作。
func TestSessionCommandsStayUnsupportedForDispatch(t *testing.T) {
start := intentgate.Command{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"},
Slots: map[string]string{"topic": "核能"}}
sessionKinds := []struct {
command intentgate.Command
kind string
}{
{intentgate.Command{Kind: intentgate.CommandSetSlot, Name: "topic", Value: "电机"}, string(intentgate.CommandSetSlot)},
{intentgate.Command{Kind: intentgate.CommandChangeSlot, Name: "topic", Value: "电机"}, string(intentgate.CommandChangeSlot)},
}
for _, item := range sessionKinds {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief",
Commands: []intentgate.Command{start, item.command}}
d := Decide(in, hasCommandEnv())
if d.Kind != KindTurn || d.CommandID != "" {
t.Fatalf("%s 不得被当成动作派发:%+v", item.kind, d)
}
if len(d.Unsupported) != 1 || d.Unsupported[0] != item.kind || d.Reason == "" {
t.Fatalf("%s 未如实记录为宿主已消费但不派发:%+v", item.kind, d)
}
}
// 取消是既有动作(U-A1c):它派发的是「取消某个运行」,不是同句里的
// demo.brief;靶子缺失时留给宿主澄清,绝不假装已取消。
cancel := intentgate.MatchResult{Branch: "matched", ID: "demo.brief",
Commands: []intentgate.Command{start, {Kind: intentgate.CommandCancel, Target: "demo.brief"}}}
if d := Decide(cancel, hasCommandEnv()); d.Kind != KindCancel || d.CommandID != "" || d.Cancel == nil {
t.Fatalf("取消必须走取消裁决,不得派发别的新动作:%+v", d)
}
if d := Decide(cancel, hasCommandEnv()); d.StartFound {
t.Fatalf("取消轮不得声称会启动动作(runmgr 转轨判定依赖此位):%+v", d)
}
}
// ---- A1/U-A1c:取消路由——解出唯一靶子,否则如实留给宿主澄清 ----
// sessionEnv 是「会话里绑定了一个运行」的路由环境。
func sessionEnv(runID string) Env {
return Env{SessionRun: func() (SessionRun, bool) {
return SessionRun{RunID: runID, Intent: "demo.run-cancel", Action: "demo.run-cancel"}, runID != ""
}}
}
func cancelResult(slots map[string]string, cancel intentgate.Command) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: "demo.task-cancel", Slots: slots,
Commands: []intentgate.Command{cancel}}
}
func TestCancelResolvesTargetFromTextAndSession(t *testing.T) {
cases := []struct {
name string
in intentgate.MatchResult
env Env
runID string
source CancelSource
}{
{
name: "命令点名",
in: cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel, Value: "run_7"}),
env: hasCommandEnv(),
runID: "run_7",
source: CancelSourceExplicit,
},
{
name: "槽位里的运行号",
in: cancelResult(map[string]string{"run_id": "run_8"}, intentgate.Command{Kind: intentgate.CommandCancel}),
env: hasCommandEnv(),
runID: "run_8",
source: CancelSourceExplicit,
},
{
name: "会话绑定的运行",
in: cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel}),
env: sessionEnv("run_9"),
runID: "run_9",
source: CancelSourceSession,
},
{
// 用户点名优先于会话绑定(确定性:显式意图高于隐式上下文)。
name: "显式优先于会话",
in: cancelResult(map[string]string{"run_id": "run_8"},
intentgate.Command{Kind: intentgate.CommandCancel}),
env: sessionEnv("run_9"),
runID: "run_8",
source: CancelSourceExplicit,
},
}
for _, c := range cases {
d := Decide(c.in, c.env)
if d.Kind != KindCancel || d.Cancel == nil {
t.Fatalf("%s:取消未走取消裁决:%+v", c.name, d)
}
if d.Cancel.RunID != c.runID || d.Cancel.Source != c.source {
t.Fatalf("%s:靶子/来源 = %q/%q;want %q/%q", c.name, d.Cancel.RunID, d.Cancel.Source, c.runID, c.source)
}
if d.CommandID != "" || d.Reason != "" || d.Clarification.Needed {
t.Fatalf("%s:解出靶子后不得再回落/再澄清:%+v", c.name, d)
}
if d.Cancel.Intent != "demo.task-cancel" {
t.Fatalf("%s:意图 id 未透传:%+v", c.name, d.Cancel)
}
}
}
// 没有唯一靶子(无绑定、无点名)时如实留空,绝不替用户猜一个。
func TestCancelWithoutTargetAsksForClarification(t *testing.T) {
envs := map[string]Env{
"无环境": {},
"无绑定": sessionEnv(""),
"绑定为空运行": {SessionRun: func() (SessionRun, bool) { return SessionRun{}, true }},
}
for name, env := range envs {
d := Decide(cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel}), env)
if d.Kind != KindCancel {
t.Fatalf("%s:没有靶子也应给出取消裁决(宿主据此澄清):%+v", name, d)
}
if d.Cancel.RunID != "" || d.Reason == "" {
t.Fatalf("%s:无靶子必须如实留空并写明原因:%+v", name, d)
}
if d.CommandID != "" {
t.Fatalf("%s:取消轮不得派发别的新动作:%+v", name, d)
}
}
}
// 库层随取消给出的候选如实透出(不在路由层做取舍)。
func TestCancelKeepsLibraryCandidates(t *testing.T) {
d := Decide(cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel,
Candidates: []string{"run_1", "run_2"}}), Env{})
if d.Cancel == nil || len(d.Cancel.Candidates) != 2 {
t.Fatalf("候选未透传:%+v", d.Cancel)
}
if d.Cancel.RunID != "" {
t.Fatalf("多个候选时不得替用户选一个:%+v", d.Cancel)
}
}
// 非 matched 分支的取消不产生任何靶子(与既有回落逐字节一致)。
func TestCancelIgnoredOnNonMatched(t *testing.T) {
for _, branch := range []string{"fallback", "guard_hit", "ambiguous", "expectation_hit"} {
d := Decide(intentgate.MatchResult{Branch: branch, ID: "demo.task-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Value: "run_7"}}}, sessionEnv("run_9"))
if d.Kind != KindTurn || d.Cancel != nil {
t.Fatalf("%s:非命中不得产生取消裁决:%+v", branch, d)
}
}
}
// Env 未接线会话(nil hook)时行为与 U-A1b 一致:没有靶子,如实澄清。
func TestCancelWithoutSessionHookKeepsLegacyContract(t *testing.T) {
d := Decide(cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel}), Env{})
if d.Cancel == nil || d.Cancel.RunID != "" {
t.Fatalf("无会话接线不得臆造靶子:%+v", d)
}
}
+28 -5
View File
@@ -2,6 +2,7 @@ package runmgr
import (
"context"
"errors"
"time"
"agent-native/runtime/memory"
@@ -17,23 +18,38 @@ type storeMemory struct {
projectID string
checked bool
allowImplicitDefault bool
// scratch 为真时端口对 user/project 记忆完全关闭:临时会话不读也不写
// 跨会话记忆(U2 隔离)。会话内状态不受影响。
scratch bool
}
// ErrScratchMemory 临时会话不写记忆:记忆是跨会话的共享层。
var ErrScratchMemory = errors.New("memory is not available in scratch sessions")
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
return newStoreMemory(st, owner, projectID, false)
}
func newStoreMemory(st *store.Store, owner, projectID string, checked bool) *storeMemory {
if owner == "" {
owner = store.OwnerDefault
}
return &storeMemory{st: st, owner: owner, projectID: projectID}
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: checked}
}
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store {
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault}
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) *storeMemory {
port := newStoreMemory(st, owner, projectID, true)
port.allowImplicitDefault = allowImplicitDefault
return port
}
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
if m.scratch {
return nil, nil
}
projectID := ""
if q.Scope == "project" {
projectID = m.projectID
@@ -65,14 +81,21 @@ func (c *runCtx) StoreMemory() memory.Store {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
var port *storeMemory
if c.mgr.accountEffectiveAuthorization != nil {
return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
port = newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
} else {
port = newStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, false)
}
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
port.scratch = c.scratchProject()
return port
}
// Append 写回一条(project scope 用适配器的项目上下文)。
func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
if m.scratch {
return ErrScratchMemory
}
projectID := e.ProjectID
if e.Scope == "project" && projectID == "" {
projectID = m.projectID
+8 -2
View File
@@ -31,8 +31,11 @@ func (m *Manager) SetProjectContextSourceRechecker(fn func(context.Context, exte
m.projectSourceRechecker = fn
}
// ProjectContextEnabled 报告本回合是否组装项目上下文。临时会话没有 RDM 项目
// 上下文(它就是一个通用代理会话),所以即使 provider 已注册也不启用;基座项目
// 读不到时按普通项目处理,门禁照旧收紧。
func (c *runCtx) ProjectContextEnabled() bool {
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil && !c.confirmedScratchProject()
}
// ValidateProjectCrossGuard links separate model streams in one persisted run.
@@ -282,7 +285,10 @@ func (c *runCtx) ProjectEvidence(ctx context.Context, id string) (extension.Proj
// ProjectContext reloads identity and project from trusted run and conversation
// rows on every model request, including a resumed or background run.
func (c *runCtx) ProjectContext(ctx context.Context, goal string, budget int) (extension.ProjectContextBundle, bool, error) {
if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil {
// ProjectContextEnabled 已经是本回合唯一的项目上下文开关(含临时会话):不在
// 这里再调 resolver,否则 llm.go 的 refresh.ProjectContext、GuardProjectStream
// 与其它任何调用方都会替临时会话把项目上下文取回来。
if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil || !c.ProjectContextEnabled() {
return extension.ProjectContextBundle{}, false, nil
}
req, err := c.trustedProjectContextRequest(ctx, budget)
+117 -5
View File
@@ -344,7 +344,7 @@ func (m *Manager) SetProjectWorkGate(preflight projectWorkPreflight, respond pro
}
func (c *runCtx) requireProjectWorkDecision(action string) error {
if !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" {
if !c.projectWorkObservationApplies() {
return nil
}
for {
@@ -408,7 +408,71 @@ func (m *Manager) cancelActiveOwnerRuns(owner string) {
cancel()
}
}
// scratchHostFlag 按存储里的 host 标记判定本次 run 的基座项目是否为临时会话。
// ok=false 表示这次没判定成功(读不到项目),此时不缓存答案——run 生命周期内项目
// 不变,下次读得到时再判定。两个方向的 fail closed 由调用点各取所需。
func (c *runCtx) scratchHostFlag() (host bool, ok bool) {
if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" {
return false, false
}
c.scratchMu.Lock()
host, known := c.scratchHost, c.scratchKnown
c.scratchMu.Unlock()
if known {
return host, true
}
host, err := c.mgr.st.IsScratchProjectID(string(c.run.ProjectID))
if err != nil {
return false, false
}
c.scratchMu.Lock()
c.scratchHost, c.scratchKnown = host, true
c.scratchMu.Unlock()
return host, true
}
// scratchProject 报告本次 run 的基座项目是否是临时会话,供隔离门禁使用(记忆、
// 上下文条目、检索、跨会话读取、记忆端口)。读不到项目时 fail closed:本次调用按
// 临时会话处理且不缓存这个未知答案——这些门禁只收紧,读不到宿主就不能为整个 run
// 把它们全部放开。
func (c *runCtx) scratchProject() bool {
if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" {
return false
}
host, ok := c.scratchHostFlag()
if !ok {
return true
}
return host
}
// confirmedScratchProject 只在存储里读到 host 标记后确认本次 run 是临时会话。
// 读不到项目时为假且不缓存——“临时会话”在这个方向上是放宽信号(RDM 观察/预检
// 门禁、项目上下文),一次读失败不能为整个 run 把它们放开。
func (c *runCtx) confirmedScratchProject() bool {
host, ok := c.scratchHostFlag()
return ok && host
}
// projectWorkObservationApplies 报告本回合是否走 RDM 观察/预检通道。临时会话
// 没有 RDM(provider 从未见过它),观察与预检门都不适用:它就是一次普通聊天。
func (c *runCtx) projectWorkObservationApplies() bool {
return c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && !c.confirmedScratchProject()
}
// authorizeProjectWork is the run-side project work gate. A scratch project is
// never known to the domain provider (RDM would always report false), so the
// stored ownership decides: its owner works without the provider, everyone else
// is NotFound. Ordinary projects keep the provider decision unchanged.
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
if m.st != nil {
if handled, err := m.st.ScratchProjectDecision(string(id), owner); err != nil {
return err
} else if handled {
return nil
}
}
if m.projectWorkAuthorization == nil {
return nil
}
@@ -531,6 +595,11 @@ type runCtx struct {
launch *workflow.Launch
files *filestate.Tracker
permissionMode string
// scratchHost 缓存本次 run 的基座项目是否为临时会话(U2 隔离门禁);
// scratchKnown 为 false 表示还没判定成功过(读不到项目),不缓存未知答案。
scratchHost bool
scratchKnown bool
scratchMu sync.Mutex
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
materialNote string
workContext string
@@ -807,11 +876,17 @@ func (c *runCtx) Injections() []runtime.ContextInjection {
if err != nil {
return nil
}
// 临时会话只带本会话自己的上下文条目:owner 级/项目级条目会把别的会话的
// 材料带进来,与“私有会话”相反。
scratch := c.scratchProject()
out := make([]runtime.ContextInjection, 0, len(list))
for _, e := range list {
if !e.Enabled {
continue
}
if scratch && e.ConversationID != c.run.ConversationID {
continue
}
out = append(out, runtime.ContextInjection{
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
@@ -824,6 +899,11 @@ func (c *runCtx) Memories() []core.MemoryEntry {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
// 临时会话不读 user/project 级记忆:记忆是跨会话的,正好是被隔离的那一层。
// 会话内状态(SessionNote)不受影响。
if c.scratchProject() {
return nil
}
list, err := c.mgr.st.ListMemory(c.run.Owner, "", "")
if err != nil {
return nil
@@ -843,7 +923,7 @@ func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) {
if c.projectWorkObservationApplies() && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) {
// Project reply text is released only through the committed message after
// the complete run-source manifest receives a domain risk decision.
return nil
@@ -1237,8 +1317,13 @@ func indexTextOf(name, mime string, content []byte) string {
}
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
// 临时会话没有可检索的共享知识:@knowledge 与 knowledge_search 一律空手
// 而非报错(会话里的附件仍以材料形式进入本回合)。
if c.scratchProject() {
return []core.RetrievalHit{}, nil
}
hits, err := c.retrieveRaw(q)
if err != nil || !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" {
if err != nil || !c.projectWorkObservationApplies() {
return hits, err
}
for _, hit := range hits {
@@ -1364,7 +1449,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
if ct.ID != call.Name {
continue
}
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" {
if c.projectWorkObservationApplies() {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project command tool awaits trusted source preflight"}
}
var params map[string]any
@@ -1403,7 +1488,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
last := tc.arts[len(tc.arts)-1]
res.Artifact = &last
}
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" {
if c.projectWorkObservationApplies() {
if call.CallID == "" {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project tool result lacks a stable call ID"}
}
@@ -1494,7 +1579,11 @@ var (
)
// crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。
// 临时会话直接关闭出站读取:它的材料不流向任何其他会话(U2)。
func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
if t.rc.scratchProject() {
return core.Conversation{}, ErrCrossReadDisabled
}
if !t.rc.eff.GetBool("crossSession.read", false) {
return core.Conversation{}, ErrCrossReadDisabled
}
@@ -1505,6 +1594,20 @@ func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
return self, nil
}
// scratchPeer reports whether a conversation lives in a scratch project. Peers are
// grouped by project so the trusted lookup runs once per distinct project.
func (t *toolCtx) scratchPeer(c core.Conversation, cache map[core.ID]bool) bool {
if seen, ok := cache[c.ProjectID]; ok {
return seen
}
scratch, err := t.rc.mgr.st.IsScratchProjectID(string(c.ProjectID))
if err != nil {
scratch = false
}
cache[c.ProjectID] = scratch
return scratch
}
func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
self, err := t.crossReadSelf()
if err != nil {
@@ -1517,12 +1620,17 @@ func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
if err != nil {
return nil, err
}
scratchProjects := map[core.ID]bool{}
out := make([]core.Conversation, 0, len(convs))
for _, c := range convs {
// 只读他人:排除自身;私有会话不出现(存在性本身是信息)。
if c.ID == self.ID || c.CrossPrivate {
continue
}
// 临时会话永不作为同伴出现:它不是共享材料,而是别人的私有会话。
if t.scratchPeer(c, scratchProjects) {
continue
}
out = append(out, c)
}
return out, nil
@@ -1548,6 +1656,10 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]
if !isSelf && !self.AllowCrossRead {
return nil, ErrCrossReadDisabled
}
// 临时会话的材料不出会话:即使同租户、即使未标私有也拒(自读除外)。
if !isSelf && t.scratchPeer(target, map[core.ID]bool{}) {
return nil, ErrCrossReadForbidden
}
if !isSelf && (target.CrossPrivate || target.ProjectID != t.rc.run.ProjectID) {
return nil, ErrCrossReadForbidden
}
+18
View File
@@ -217,6 +217,24 @@ func TestResolveHITLSecondRoundKeepsTerminalStatus(t *testing.T) {
case <-time.After(10 * time.Second):
t.Fatal("runner did not reach the second HITL question")
}
// asked 只保证 runner 已经发出提问,不保证 waiting_hitl 行与卡片都已落库;
// 与第 1 轮同一序列:等 run 处于 waiting_hitl 且存在未解决卡片后再读取。
waitUntilWithin(t, 10*time.Second, func() bool {
got, err := st.GetRunByID(run.ID)
if err != nil || got.Status != core.RunWaitingHITL {
return false
}
cards, err := st.ActiveHITLCards(conv.Owner, conv.ID)
if err != nil {
return false
}
for _, c := range cards {
if !c.Resolved {
return true
}
}
return false
})
second := pendingCard(t, st, conv, 1)
if got, err := st.GetRunByID(run.ID); err != nil || got.Status != core.RunWaitingHITL {
t.Fatalf("round 2 status=%s err=%v (want waiting_hitl)", got.Status, err)
+464
View File
@@ -0,0 +1,464 @@
package runmgr
import (
"context"
"errors"
"path/filepath"
"testing"
"agent-native/runtime/memory"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// 临时会话隔离门禁(U2,runmgr 侧)。产品规则:临时会话是“只属于自己的通用代理
// 会话”——属主的回合像普通聊天一样跑,但它不读检索、不带 owner/项目级上下文、
// 不读不写跨会话记忆、也不与任何会话互相读。
type scratchRunEnv struct {
mgr *Manager
st *store.Store
svc *settings.Service
project core.Project // scratch 宿主项目
scratch core.Conversation
normal core.Project
normalAt core.Conversation // 普通项目的调用方会话
peer core.Conversation // 普通项目的同伴会话
}
func newScratchRunEnv(t *testing.T) *scratchRunEnv {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "Bob", "", false); err != nil {
t.Fatal(err)
}
env := &scratchRunEnv{st: st, mgr: &Manager{st: st}, svc: settings.NewService(st)}
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
env.project, env.scratch = project, conversation
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
env.normal = normal
if env.normalAt, err = st.CreateConversation("alice", normal.ID, "调用方", ""); err != nil {
t.Fatal(err)
}
if env.peer, err = st.CreateConversation("alice", normal.ID, "同伴", ""); err != nil {
t.Fatal(err)
}
msg := core.Message{ConversationID: env.peer.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "同伴内容"}}}
if err := st.AppendMessage(&msg); err != nil {
t.Fatal(err)
}
return env
}
// runCtx 造一次“回合视角”:项目 + 会话 + 属主。
func (e *scratchRunEnv) runCtx(projectID, conversationID, owner string) *runCtx {
return &runCtx{mgr: e.mgr, eff: e.svc.Effective(owner, projectID),
run: core.Run{ID: "run-1", Owner: core.ID(owner), ProjectID: core.ID(projectID), ConversationID: core.ID(conversationID)}}
}
func (e *scratchRunEnv) scratchCtx() *runCtx {
return e.runCtx(string(e.project.ID), e.scratch.ID, "alice")
}
func (e *scratchRunEnv) normalCtx() *runCtx {
return e.runCtx(string(e.normal.ID), e.normalAt.ID, "alice")
}
// 门禁①:runmgr.authorizeRunWork —— 领域 provider 恒 false 时,scratch 属主照常
// 工作(provider 从未见过这个项目),其他人 NotFound;普通项目仍由 provider 决定。
func TestScratchRunWorkUsesOwnerInsteadOfDomainProvider(t *testing.T) {
env := newScratchRunEnv(t)
asked := 0
env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) {
asked++
return false, nil // RDM 视角:永远没有这个项目
})
scratchID := core.ID(env.project.ID)
if err := env.mgr.authorizeRunWork(context.Background(), "alice", scratchID); err != nil {
t.Fatalf("scratch owner blocked by the domain provider: %v", err)
}
if asked != 0 {
t.Fatalf("scratch owner work consulted the provider %d times", asked)
}
if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("non-owner reached a scratch run: %v", err)
}
// 负面:普通项目没有被这条门禁放宽。
if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("normal project bypassed the provider decision: %v", err)
}
// 正面:provider 放行的普通项目照旧放行。
env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); err != nil {
t.Fatalf("provider-approved normal project blocked: %v", err)
}
// 无 provider 注册时,非属主仍然进不去 scratch。
env.mgr.projectWorkAuthorization = nil
if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("unregistered host opened a scratch run for a non-owner: %v", err)
}
}
// 门禁②:RDM 预检/观察通道不适用于临时会话。
func TestScratchRunSkipsDomainPreflightGate(t *testing.T) {
env := newScratchRunEnv(t)
preflights := 0
env.mgr.SetProjectWorkGate(
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
preflights++
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil
},
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil
},
)
rc := env.scratchCtx()
rc.h = &runHandle{}
if err := rc.requireProjectWorkDecision("conversation.run"); err != nil {
t.Fatalf("scratch run hit the domain preflight: %v", err)
}
if preflights != 0 {
t.Fatalf("scratch run called the domain preflight %d times", preflights)
}
if rc.projectWorkObservationApplies() {
t.Fatal("scratch run must not enter the RDM observation channel")
}
}
// 门禁③:项目上下文(X1)即使 provider 已注册也不装配。
func TestScratchProjectContextDisabled(t *testing.T) {
env := newScratchRunEnv(t)
resolved := 0
env.mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
resolved++
return extension.ProjectContextBundle{ProjectID: env.project.ID}, nil
})
if env.scratchCtx().ProjectContextEnabled() {
t.Fatal("scratch run assembled project context")
}
if resolved != 0 {
t.Fatalf("scratch run resolved project context %d times", resolved)
}
// 正面:普通项目照旧启用。
if !env.normalCtx().ProjectContextEnabled() {
t.Fatal("normal project lost project context")
}
}
// 门禁③bis:ProjectContext 本身(llm.go refresh.ProjectContext、GuardProjectStream
// 与其它调用方走的同一个入口)在临时会话回合里一次 resolver 都不调;普通项目的
// 回合照旧调用。
func TestScratchProjectContextNeverReachesResolver(t *testing.T) {
env := newScratchRunEnv(t)
env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
resolved := 0
env.mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
resolved++
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.ProjectID}, nil
})
bundle, active, err := env.scratchCtx().ProjectContext(context.Background(), "临时会话的目标", 900)
if err != nil || active {
t.Fatalf("scratch ProjectContext = active:%v err:%v", active, err)
}
if bundle.Status != "" || bundle.ProjectID != "" || len(bundle.Items) != 0 || len(bundle.Evidence) != 0 {
t.Fatalf("scratch ProjectContext returned a bundle: %+v", bundle)
}
if resolved != 0 {
t.Fatalf("scratch run called the project context resolver %d times", resolved)
}
// ProjectEvidence 同样不得借此拿到材料。
if _, err := env.scratchCtx().ProjectEvidence(context.Background(), "ref-a"); err == nil {
t.Fatal("scratch run read project evidence")
}
// 正面:普通项目的已持久化 run 照旧装一次上下文。
run := core.Run{ConversationID: env.normalAt.ID, ProjectID: env.normal.ID, Owner: env.normalAt.Owner,
Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking}
if err := env.st.CreateRun(&run); err != nil {
t.Fatal(err)
}
normalRC := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), string(env.normalAt.Owner))
normalRC.run = run
if _, active, err := normalRC.ProjectContext(context.Background(), "普通项目的目标", 900); err != nil || !active {
t.Fatalf("normal project ProjectContext = active:%v err:%v", active, err)
}
if resolved != 1 {
t.Fatalf("normal run called the project context resolver %d times, want 1", resolved)
}
}
// 门禁③ter:基座项目读不到时 fail closed——本次调用按临时会话处理,且不把这个
// 未知答案缓存下来(否则一次读失败就会为整个 run 放开记忆、上下文条目与跨会话读取)。
func TestScratchProjectFailsClosedOnLookupError(t *testing.T) {
env := newScratchRunEnv(t)
rc := env.runCtx("prj-missing", string(env.scratch.ID), "alice")
if !rc.scratchProject() {
t.Fatal("unreadable host project was treated as a normal project")
}
// 未缓存未知答案:换成能读到的项目后重新判定(缓存 false 会在这里露馅)。
rc.run.ProjectID = env.project.ID
if !rc.scratchProject() {
t.Fatal("real scratch project was not recognised after a failed lookup")
}
// 正面:读得到时按存储标记判定,普通项目不受影响。
normal := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), "alice")
if normal.scratchProject() {
t.Fatal("normal project was treated as scratch")
}
scratch := env.runCtx(string(env.project.ID), string(env.scratch.ID), "alice")
if !scratch.scratchProject() {
t.Fatal("scratch project was not recognised")
}
// 无项目 run 不受影响(没有基座项目就没有临时会话可谈)。
if env.runCtx("", string(env.scratch.ID), "alice").scratchProject() {
t.Fatal("projectless run was treated as scratch")
}
}
// 门禁③quater:同一件事(基座项目读不到)在两个方向上都 fail closed。收紧型门禁
// (记忆、上下文条目、检索、跨会话读取、记忆端口)按临时会话处理;放宽型门禁(RDM
// 观察/预检、项目上下文)按普通项目处理。读到标记之前谁都不能被一次读失败固定下来。
func TestScratchGatesFailClosedBothWaysOnLookupError(t *testing.T) {
env := newScratchRunEnv(t)
preflights := 0
configure := func(mgr *Manager) {
mgr.SetProjectWorkGate(
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
preflights++
return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable")
},
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable")
},
)
mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return extension.ProjectContextBundle{}, nil
})
}
configure(env.mgr)
// 基线:普通项目上,两个方向本来就是收紧(不隔离)/ 放宽门生效(观察+上下文)。
before := env.normalCtx()
if before.scratchProject() {
t.Fatal("normal project was treated as scratch")
}
if !before.projectWorkObservationApplies() {
t.Fatal("normal project lost the RDM observation channel")
}
if !before.ProjectContextEnabled() {
t.Fatal("normal project lost project context")
}
// 关掉存储句柄:项目行没变(仍是普通项目),只是 host 标记读不到了。
if err := env.st.Close(); err != nil {
t.Fatal(err)
}
rc := env.normalCtx()
if !rc.scratchProject() {
t.Fatal("unreadable host project stopped tightening the isolation gates")
}
if !rc.projectWorkObservationApplies() {
t.Fatal("a failed scratch lookup skipped the RDM observation/preflight gate")
}
if !rc.ProjectContextEnabled() {
t.Fatal("a failed scratch lookup disabled project context for a normal project")
}
// 观察门真的走到领域预检:门没有因为读不到标记而被整体跳过。
rc.h = &runHandle{}
if err := rc.requireProjectWorkDecision("conversation.run"); err == nil {
t.Fatal("project work gate skipped the domain preflight after a failed lookup")
}
if preflights != 1 {
t.Fatalf("domain preflight consulted %d times after a failed lookup, want 1", preflights)
}
// 未缓存未知答案:读失败后在能读到的存储上重新判定,两个方向都只认存储标记。
again := newScratchRunEnv(t)
configure(again.mgr)
probe := again.runCtx("prj-missing", string(again.scratch.ID), "alice")
if !probe.scratchProject() || probe.confirmedScratchProject() {
t.Fatalf("failed lookup did not split the two directions: isolation=%t relaxing=%t",
probe.scratchProject(), probe.confirmedScratchProject())
}
probe.run.ProjectID = again.project.ID
if !probe.scratchProject() || !probe.confirmedScratchProject() {
t.Fatal("real scratch project was not recognised after a failed lookup")
}
normal := again.normalCtx()
if normal.scratchProject() || normal.confirmedScratchProject() {
t.Fatal("normal project was cached as scratch after a failed lookup")
}
if !normal.projectWorkObservationApplies() || !normal.ProjectContextEnabled() {
t.Fatal("readable normal project lost the observation gate or project context")
}
// 真实临时会话依旧拿不到这些放宽面。
if scratch := again.scratchCtx(); scratch.projectWorkObservationApplies() || scratch.ProjectContextEnabled() {
t.Fatal("scratch project gained the RDM observation gate or project context")
}
}
// 门禁④:检索为空 —— @knowledge / knowledge_search 在临时会话里拿不到任何东西。
func TestScratchRunRetrievalReturnsNoHits(t *testing.T) {
env := newScratchRunEnv(t)
docs := []core.KnowledgeDoc{
{Owner: "alice", ProjectID: core.ID(env.project.ID), Source: "manual", Title: "临时项目文档", Chunk: "临时会话项目内的文档"},
{Owner: "alice", Source: "manual", Title: "属主文档", Chunk: "属主全局文档"},
}
if err := env.st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
query := core.RetrievalQuery{Text: "文档", TopK: 10}
hits, err := env.scratchCtx().Retrieve(query)
if err != nil || len(hits) != 0 {
t.Fatalf("scratch retrieval must return no hits: %+v %v", hits, err)
}
// 工具面(toolCtx.Retrieve)同样空手。
tc := &toolCtx{rc: env.scratchCtx(), callID: "call-1"}
if hits, err := tc.Retrieve(query); err != nil || len(hits) != 0 {
t.Fatalf("scratch tool retrieval must return no hits: %+v %v", hits, err)
}
// 正面:普通项目照旧检索得到(这里是项目文档 + 属主文档)。
hits, err = env.normalCtx().Retrieve(query)
if err != nil || len(hits) == 0 {
t.Fatalf("normal project retrieval lost hits: %+v %v", hits, err)
}
}
// 门禁⑤:上下文注入只留本会话条目;记忆不读不写。
func TestScratchRunKeepsNoSharedContextAndNoMemory(t *testing.T) {
env := newScratchRunEnv(t)
// 三种作用域各一条:属主级、项目级(本临时会话项目)、本会话级。
for _, entry := range []store.ContextEntry{
{Keys: []string{"owner"}, Content: "属主级上下文", Enabled: true},
{Keys: []string{"project"}, ProjectID: env.project.ID, Content: "项目级上下文", Enabled: true},
{Keys: []string{"conversation"}, ProjectID: env.project.ID, ConversationID: env.scratch.ID, Content: "本会话上下文", Enabled: true},
} {
if _, err := env.st.SaveContextEntry("alice", entry); err != nil {
t.Fatal(err)
}
}
got := map[string]string{}
for _, inj := range env.scratchCtx().Injections() {
for _, key := range inj.Keys {
got[key] = inj.Content
}
}
if got["conversation"] != "本会话上下文" {
t.Fatalf("scratch run lost its own context entry: %+v", got)
}
if _, ok := got["owner"]; ok {
t.Fatalf("owner-wide context entry leaked into a scratch run: %+v", got)
}
if _, ok := got["project"]; ok {
t.Fatalf("project-wide context entry leaked into a scratch run: %+v", got)
}
// 正面:普通项目的回合仍然带属主级上下文。
normalInj := map[string]bool{}
for _, inj := range env.normalCtx().Injections() {
for _, key := range inj.Keys {
normalInj[key] = true
}
}
if !normalInj["owner"] {
t.Fatalf("normal run lost owner-wide context: %+v", normalInj)
}
// 记忆:临时会话既不读也不写 user/project 记忆。
if _, err := env.st.PutMemory("alice", core.MemoryEntry{Scope: "user", Type: "note", Content: "属主记忆", Confidence: 0.9}); err != nil {
t.Fatal(err)
}
if entries := env.scratchCtx().Memories(); len(entries) != 0 {
t.Fatalf("scratch run read user memory: %+v", entries)
}
if entries := env.normalCtx().Memories(); len(entries) != 1 || entries[0].Content != "属主记忆" {
t.Fatalf("normal run lost user memory: %+v", entries)
}
scratchPort := env.scratchCtx().StoreMemory()
if entries, err := scratchPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) != 0 {
t.Fatalf("scratch memory port recalled: %+v %v", entries, err)
}
if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "临时记忆"}); !errors.Is(err, ErrScratchMemory) {
t.Fatalf("scratch memory port accepted a user-scope write: %v", err)
}
if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "project", Type: "note", Content: "临时项目记忆"}); !errors.Is(err, ErrScratchMemory) {
t.Fatalf("scratch memory port accepted a project-scope write: %v", err)
}
if entries, err := env.st.ListMemory("alice", "", ""); err != nil || len(entries) != 1 {
t.Fatalf("refused writes reached the store: %+v %v", entries, err)
}
// 正面:普通项目的记忆端口照常读写。
normalPort := env.normalCtx().StoreMemory()
if err := normalPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "普通记忆"}); err != nil {
t.Fatalf("normal memory port write: %v", err)
}
if entries, err := normalPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) < 2 {
t.Fatalf("normal memory port recall: %+v %v", entries, err)
}
}
// 门禁⑥:跨会话读取 —— 临时会话读不到别人;别人的列表里也没有临时会话。
func TestScratchCrossSessionReadIsIsolated(t *testing.T) {
env := newScratchRunEnv(t)
// 临时会话自己的项目开关:即使显式开闸也不放行。
if err := env.svc.MergeProject("alice", string(env.project.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil {
t.Fatal(err)
}
if err := env.svc.MergeProject("alice", string(env.normal.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil {
t.Fatal(err)
}
scratchTool := &toolCtx{rc: env.scratchCtx(), callID: "call-1"}
if _, err := scratchTool.PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("scratch run listed peers: %v", err)
}
if _, err := scratchTool.PeerConversationMessages(env.peer.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("scratch run read a peer: %v", err)
}
normalTool := &toolCtx{rc: env.normalCtx(), callID: "call-2"}
convs, err := normalTool.PeerConversations()
if err != nil {
t.Fatalf("normal peer listing: %v", err)
}
for _, c := range convs {
if c.ID == env.scratch.ID {
t.Fatalf("scratch conversation showed up in a normal peer list: %+v", convs)
}
}
found := false
for _, c := range convs {
if c.ID == env.peer.ID {
found = true
}
}
if !found {
t.Fatalf("normal peer listing lost the real peer: %+v", convs)
}
if _, err := normalTool.PeerConversationMessages(env.scratch.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
t.Fatalf("normal run read a scratch conversation: %v", err)
}
// 正面:普通项目的同伴会话照旧可读。
if msgs, err := normalTool.PeerConversationMessages(env.peer.ID, 0); err != nil || len(msgs) != 1 {
t.Fatalf("normal peer read: %+v %v", msgs, err)
}
}
+19 -2
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"agent-native/adapters/native"
"agent-native/runtime/tools"
@@ -25,14 +26,30 @@ type producedSink interface {
add(ref *core.ArtifactRef)
}
// artifactList 是本次运行已登记的工件表。宿主工具可能被内核并行调用,
// 因此写入与读取都在同一把锁下:并发调用既不丢条目也不读到半成品。
type artifactList struct {
mu sync.Mutex
items []*core.ArtifactRef
}
func (a *artifactList) add(ref *core.ArtifactRef) {
if ref != nil {
a.items = append(a.items, ref)
if ref == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.items = append(a.items, ref)
}
// snapshot 返回当前工件的副本,供终局检查与部件装配使用。
func (a *artifactList) snapshot() []*core.ArtifactRef {
if a == nil {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
return append([]*core.ArtifactRef(nil), a.items...)
}
// bridgeHostTools 把画像允许的产品工具包成内核宿主工具。
+104 -34
View File
@@ -110,6 +110,13 @@ type toolEvidence struct {
VerifyText string // 给验证器的尾部摘要,比界面上的摘要长
}
// toolSlot 是一次在途工具调用的过程状态。
//
// 并行工具执行下槽位有两个并发访问方:工具 goroutine 回写结果字段,
// 内核事件回调(onEvent 的 tool_execution_end)在 toolMu 下读它们装部件。
// 字段一律经 directRun.writeSlot 在 toolMu 下访问,直接写即数据竞争。
// claimed 只在 toolMu 下访问:并行且无 ctx 注入的调用(宿主 UnknownTool
// 直调)按认领取槽位,两个 goroutine 不会写同一个槽位、结果不会互相覆盖。
type toolSlot struct {
call agentllm.ToolCall
start time.Time
@@ -117,6 +124,7 @@ type toolSlot struct {
verifyText string
status string
truncated bool
claimed bool // 已被某次工具执行认领(无注入路径据此避免撞槽)
artifact *core.ArtifactRef
}
@@ -565,9 +573,11 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
call, ok := nruntime.ToolCallFromContext(ctx)
slot := st.slotFor("")
if ok {
var slot *toolSlot
if ok && call.ID != "" {
slot = st.slotFor(call.ID)
} else {
slot = st.slotFor("")
}
if slot == nil {
return
@@ -576,28 +586,74 @@ func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result
if res.IsError {
summary = "error: " + summary
}
slot.summary = summary
slot.status = statusOf(nil)
status := statusOf(nil)
if res.IsError {
slot.status = "error"
status = "error"
}
slot.verifyText = tailRunes(res.Content, 600)
verifyText := tailRunes(res.Content, 600)
st.writeSlot(slot, func(s *toolSlot) {
s.summary = summary
s.status = status
s.verifyText = verifyText
})
}
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
// writeSlot 在 toolMu 下回写在途槽位:工具 goroutine 写、事件回调读的是同一
// 块内存,只有同一把锁才能给出 happens-before。
func (st *directRun) writeSlot(slot *toolSlot, apply func(*toolSlot)) {
if slot == nil {
return
}
st.toolMu.Lock()
defer st.toolMu.Unlock()
apply(slot)
}
// appendFinalPart 在 toolMu 下追加最终部件:并行工具执行(工件门面卡、卡片
// 工具)与事件回调共享同一片 finalParts。
func (st *directRun) appendFinalPart(parts ...core.Part) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.finalParts = append(st.finalParts, parts...)
}
// markExplicitCard / cardSuppressed 记「模型已自己发过卡片」:写方是卡片工具
// (保序执行,仍与并行工具 goroutine 同时在跑),读方是工件门面卡分支。
func (st *directRun) markExplicitCard() {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.explicitCard = true
}
func (st *directRun) cardSuppressed() bool {
st.toolMu.Lock()
defer st.toolMu.Unlock()
return st.explicitCard
}
// slotFor 按调用 ID 取在途槽位;ID 为空时认领一个尚未被认领的槽位(兼容无
// 注入的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
func (st *directRun) slotFor(callID string) *toolSlot {
st.toolMu.Lock()
defer st.toolMu.Unlock()
if callID != "" {
return st.inflight[callID]
slot := st.inflight[callID]
if slot != nil {
slot.claimed = true
}
return slot
}
if len(st.inflight) == 1 {
for _, slot := range st.inflight {
return slot
var free *toolSlot
for _, slot := range st.inflight {
if !slot.claimed {
free = slot
break
}
}
return nil
if free != nil {
free.claimed = true
}
return free
}
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
@@ -725,18 +781,22 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.toolMu.Unlock()
return
}
// 锁内快照结果字段:工具回写(并行工具 goroutine)与本回调分属两个
// goroutine,解锁后再读槽位字段即数据竞争,事件只带快照走。
call, start := slot.call, slot.start
status, summary, truncated := slot.status, slot.summary, slot.truncated
end := st.l.now()
st.finalParts = append(st.finalParts,
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: slot.call.ID, Name: slot.call.Name, Arguments: slot.call.Arguments, Duration: durationMs(slot.start, end)}},
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary}},
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: call.ID, Name: call.Name, Arguments: call.Arguments, Duration: durationMs(start, end)}},
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: status, Summary: summary}},
)
if slot.artifact != nil {
st.deliverables = append(st.deliverables, slot.artifact)
}
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
st.evidence = append(st.evidence, toolEvidence{Name: call.Name, Status: status, Summary: summary, VerifyText: slot.verifyText})
st.toolMu.Unlock()
st.noteFailureOutcome(slot.status)
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
st.noteFailureOutcome(status)
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: status, Summary: summary, Truncated: truncated, DurationMs: durationMs(start, end)})
}
}
@@ -1129,17 +1189,17 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
}
}
if res.Artifact != nil && slot != nil {
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
if st.cardflow && res.Truncated && !st.explicitCard {
st.writeSlot(slot, func(s *toolSlot) {
s.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
})
if st.cardflow && res.Truncated && !st.cardSuppressed() {
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
if ferr == nil {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
}
}
}
if slot != nil {
slot.truncated = res.Truncated
}
st.writeSlot(slot, func(s *toolSlot) { s.truncated = res.Truncated })
}
summary := summarize(out, nil)
@@ -1148,11 +1208,12 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
summary = "error: " + err.Error()
out = summary
}
if slot != nil {
slot.summary = summary
slot.status = status
slot.verifyText = tailRunes(out, 600)
}
verifyText := tailRunes(out, 600)
st.writeSlot(slot, func(s *toolSlot) {
s.summary = summary
s.status = status
s.verifyText = verifyText
})
if r := []rune(out); len(r) > 16000 {
out = string(r[:16000]) + "…(超长截断)"
}
@@ -1257,8 +1318,8 @@ func (st *directRun) submitCard(args string) string {
if cerr != nil {
return "submit_card 失败: " + cerr.Error()
}
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
st.explicitCard = true
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
st.markExplicitCard()
where := "对话"
if a.Scope == "project" {
where = "项目"
@@ -1311,18 +1372,21 @@ func (st *directRun) commitCard() string {
if cerr != nil {
return "card_commit 失败: " + cerr.Error()
}
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
st.explicitCard = true
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
st.markExplicitCard()
return fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
}
func (st *directRun) pinSubagentArtifacts(text string) {
st.toolMu.Lock()
seen := map[string]bool{}
for _, ref := range st.deliverables {
if ref != nil {
seen[ref.ArtifactID] = true
}
}
st.toolMu.Unlock()
pinned := make([]*core.ArtifactRef, 0, 4)
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
if seen[id] {
continue
@@ -1332,8 +1396,14 @@ func (st *directRun) pinSubagentArtifacts(text string) {
continue
}
seen[id] = true
st.deliverables = append(st.deliverables, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
pinned = append(pinned, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
}
if len(pinned) == 0 {
return
}
st.toolMu.Lock()
st.deliverables = append(st.deliverables, pinned...)
st.toolMu.Unlock()
}
func errString(err error) string {
+30 -2
View File
@@ -27,6 +27,7 @@ type fakeRunCtx struct {
cardflow bool
events []emittedEvent
eventsMu sync.Mutex
stateMu sync.Mutex // 并行工具执行下会写回的假状态(计数/卡片/用量/后台)
assistant []core.Part
cards []core.ActivityCard
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
@@ -80,8 +81,15 @@ func (f *fakeRunCtx) emittedEvents() []emittedEvent {
defer f.eventsMu.Unlock()
return append([]emittedEvent(nil), f.events...)
}
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error {
f.stateMu.Lock()
defer f.stateMu.Unlock()
f.assistant = parts
return nil
}
func (f *fakeRunCtx) AddUsage(u core.Usage) {
f.stateMu.Lock()
defer f.stateMu.Unlock()
f.usageTotal.PromptTokens += u.PromptTokens
f.usageTotal.CompletionTokens += u.CompletionTokens
f.usageTotal.TotalTokens += u.TotalTokens
@@ -113,6 +121,8 @@ func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (cor
}
func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
f.stateMu.Lock()
defer f.stateMu.Unlock()
f.toolRuns++
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
if f.execStatus != "" {
@@ -133,6 +143,8 @@ func (f *fakeRunCtx) History() []core.Message {
return []core.Message{{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}}
}
func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) {
f.stateMu.Lock()
defer f.stateMu.Unlock()
f.bgCalls++
if f.bgErr != nil {
return "", f.bgErr
@@ -174,6 +186,14 @@ func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
func (f *fakeRunCtx) Subtask() string { return "" }
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
f.stateMu.Lock()
defer f.stateMu.Unlock()
return f.createCardLocked(card)
}
// createCardLocked 建卡(调用方须持有 stateMu);卡片工具与工件门面卡
// 可能从并行工具 goroutine 同时进来。
func (f *fakeRunCtx) createCardLocked(card core.ActivityCard) (core.ActivityCard, error) {
card.ID = core.ID(fmt.Sprintf("card_%d", len(f.cards)+1))
f.cards = append(f.cards, card)
return card, nil
@@ -199,7 +219,9 @@ func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, erro
// runmgr/cardsvc 语义,供工具编排层断言。
func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
card.Status = core.CardActive
created, err := f.CreateCard(card)
f.stateMu.Lock()
defer f.stateMu.Unlock()
created, err := f.createCardLocked(card)
if err != nil {
return created, err
}
@@ -208,6 +230,8 @@ func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard,
}
func (f *fakeRunCtx) AppendStreamCard(text string) error {
f.stateMu.Lock()
defer f.stateMu.Unlock()
if f.streamCardID == "" {
return errors.New("card: no active stream card (card_begin first)")
}
@@ -226,6 +250,8 @@ func (f *fakeRunCtx) AppendStreamCard(text string) error {
}
func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
f.stateMu.Lock()
defer f.stateMu.Unlock()
if f.streamCardID == "" {
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
}
@@ -240,6 +266,8 @@ func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
return core.ActivityCard{}, errors.New("card: not found")
}
func (f *fakeRunCtx) Card(id string) *core.ActivityCard {
f.stateMu.Lock()
defer f.stateMu.Unlock()
for i := range f.cards {
if string(f.cards[i].ID) == id {
return &f.cards[i]
+188
View File
@@ -0,0 +1,188 @@
package runtime
import (
"strings"
"sync"
"agent-squad/squad"
core "agent-system-core"
)
// toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。
const toolResultSummaryLen = 160
// toolEventID 是宿主侧的工具调用身份:优先用上游 CallID(并行同名工具据此配对,
// 而不是按到达顺序);空串 = legacy/未知,退回工具名,保持既有展示不变。
func toolEventID(ev squad.ProcessEvent) string {
if id := strings.TrimSpace(ev.CallID); id != "" {
return id
}
return ev.Tool
}
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的真实结局。
//
// 取值域与上游一致(ok | error | denied | canceled),不新增、不改写上游状态:
// - Status 非空:原样透传;
// - Status 为空且没有失败信号(ErrorReason 也空):legacy/未知,沿用旧的 "ok"
// 与 Detail 摘要(字节一致,不臆造结局);
// - Status 为空但有 ErrorReason:有失败信号而状态未知,报 error 而不是 ok;
// - 非 ok 时摘要取 ErrorReason 原文(截断),上游没给原因才回退 Detail。
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
status = strings.TrimSpace(ev.Status)
if status == "" {
if strings.TrimSpace(ev.ErrorReason) == "" {
return squad.ToolStatusOK, truncateRun(ev.Detail, toolResultSummaryLen)
}
status = squad.ToolStatusError
}
if status == squad.ToolStatusOK {
return status, truncateRun(ev.Detail, toolResultSummaryLen)
}
if strings.TrimSpace(ev.ErrorReason) != "" {
return status, truncateRun(ev.ErrorReason, toolResultSummaryLen)
}
return status, truncateRun(ev.Detail, toolResultSummaryLen)
}
// toolFailure 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。
type toolFailure struct {
Tool string
CallID string
Status string
Summary string
}
// squadEventLog 是小队过程帧在宿主侧的聚合点。
//
// 并行工具调用会从多个 goroutine 回调进来(内核 executeParallel 逐个派发),
// 因此流式文本、工具部件、用量与 pending 配对表全部在同一把锁下更新:
// 既不丢条目,也不会把 A 调用的 start 与 B 调用的 end 交错写坏。
// 事件发射与部件追加在同一临界区内,保持到达顺序与最终部件顺序一致。
type squadEventLog struct {
rc RunCtx
mu sync.Mutex
streamed strings.Builder
textIdx int
parts []core.Part
pending map[string]string // callID → 工具名(start/end 配对)
failure *toolFailure // 最近一次失败;同名工具后续再起即视为已恢复
}
// newSquadEventLog 绑定一次运行的过程帧回调。
func newSquadEventLog(rc RunCtx) *squadEventLog {
return &squadEventLog{rc: rc, pending: map[string]string{}}
}
// onEvent 是小队过程帧回调(并发安全)。
func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
l.mu.Lock()
defer l.mu.Unlock()
switch ev.Type {
case "text_delta":
if ev.Text == "" {
return
}
l.streamed.WriteString(ev.Text)
_ = l.rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: l.textIdx, Text: ev.Text})
case "reasoning_delta":
if ev.Text != "" {
_ = l.rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
}
case "tool_execution_start":
id := toolEventID(ev)
if id != "" {
l.pending[id] = ev.Tool
}
l.noteRecovered(ev.Tool)
_ = l.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: id, Name: ev.Tool, Arguments: ev.Detail})
l.parts = append(l.parts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: id, Name: ev.Tool, Arguments: ev.Detail}})
case "tool_execution_end":
id := toolEventID(ev)
if tool, ok := l.pending[id]; ok && ev.Tool == "" {
ev.Tool = tool
}
delete(l.pending, id)
status, summary := toolEventOutcome(ev)
// 发射与部件同源:前端过程行与落库部件说的是同一件事。
_ = l.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: id, Status: status, Summary: summary})
l.parts = append(l.parts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: id, Status: status, Summary: summary}})
if status == squad.ToolStatusOK {
l.noteRecovered(ev.Tool)
} else {
l.failure = &toolFailure{Tool: ev.Tool, CallID: id, Status: status, Summary: summary}
}
case "usage":
if sink, ok := l.rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(core.Usage{
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
})
}
}
}
// noteRecovered 把同名工具的后续进展(再次发起或调用成功)记为已恢复:
// 失败部件仍留在过程部件里,只是不再让收尾文本替它宣布成功。
func (l *squadEventLog) noteRecovered(tool string) {
if l.failure != nil && l.failure.Tool == tool {
l.failure = nil
}
}
// reply 是本次运行聚合到的模型文本(不含任何宿主补写)。
func (l *squadEventLog) reply() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.streamed.String()
}
// parts 返回过程部件的快照(复制,避免把内部切片交给最终部件)。
func (l *squadEventLog) toolParts() []core.Part {
l.mu.Lock()
defer l.mu.Unlock()
return append([]core.Part(nil), l.parts...)
}
// unresolvedFailure 返回终局时仍未恢复的工具失败,没有则 nil。
func (l *squadEventLog) unresolvedFailure() *toolFailure {
l.mu.Lock()
defer l.mu.Unlock()
if l.failure == nil {
return nil
}
f := *l.failure
return &f
}
// finalReply 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的
// 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件
// 自相矛盾。legacy 事件(无 CallID/Status)走不到这里,展示与旧行为一致。
func (l *squadEventLog) finalReply(modelText, summary, fallback string) string {
reply := strings.TrimSpace(modelText)
if reply == "" {
reply = strings.TrimSpace(summary)
}
if reply != "" {
return reply
}
if f := l.unresolvedFailure(); f != nil {
return failedToolLine(f)
}
return fallback
}
// failedToolLine 如实描述未恢复的工具失败,不声称成功,也不编造原因。
func failedToolLine(f *toolFailure) string {
name := strings.TrimSpace(f.Tool)
if name == "" {
name = "工具"
}
reason := strings.TrimSpace(f.Summary)
if reason == "" {
return name + " 未完成(" + f.Status + ")"
}
return name + " 未完成(" + f.Status + "):" + reason
}
+443
View File
@@ -0,0 +1,443 @@
package runtime
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
agentllm "agent-native/runtime/llm"
"agent-squad/squad"
core "agent-system-core"
)
// eventProbe 记录小队过程帧回调发射出去的事件(顺序与内容都要能断言)。
// Emit 由 squadEventLog 的临界区内调用,因此这里无需再自锁。
type eventProbe struct {
*entryFake
rec *squadEventLog
toolCall []core.ToolCallPayload
result []core.ToolResultPayload
}
func newEventProbe() *eventProbe {
p := &eventProbe{entryFake: &entryFake{}}
p.rec = newSquadEventLog(p)
return p
}
func (p *eventProbe) Emit(typ core.StreamEventType, payload any) error {
switch v := payload.(type) {
case core.ToolCallPayload:
p.toolCall = append(p.toolCall, v)
case core.ToolResultPayload:
p.result = append(p.result, v)
}
return p.entryFake.Emit(typ, payload)
}
func (p *eventProbe) onEvent(ev squad.ProcessEvent) { p.rec.onEvent(ev) }
func (p *eventProbe) parts() []core.Part { return p.rec.toolParts() }
func (p *eventProbe) calls() []core.ToolCallPayload {
return append([]core.ToolCallPayload(nil), p.toolCall...)
}
func (p *eventProbe) results() []core.ToolResultPayload {
return append([]core.ToolResultPayload(nil), p.result...)
}
func toolCallsOf(parts []core.Part) []core.ToolCallPart {
var out []core.ToolCallPart
for _, p := range parts {
if p.ToolCall != nil {
out = append(out, *p.ToolCall)
}
}
return out
}
func toolResultsOf(parts []core.Part) []core.ToolResultPart {
var out []core.ToolResultPart
for _, p := range parts {
if p.ToolResult != nil {
out = append(out, *p.ToolResult)
}
}
return out
}
// TestSquadEventsPairByCallID:两个同名工具的 start/end 必须按 CallID 配对,
// 不得因名字相同而互相对调(这是并行同名工具显示错乱的根因)。
func TestSquadEventsPairByCallID(t *testing.T) {
p := newEventProbe()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_a", Detail: `{"q":"a"}`})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_b", Detail: `{"q":"b"}`})
// 交错到达:B 先结束。
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_b", Status: squad.ToolStatusOK, Detail: "命中 B"})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_a", Status: squad.ToolStatusError, ErrorReason: "路径不存在"})
parts := p.parts()
calls := toolCallsOf(parts)
if len(calls) != 2 || calls[0].ID != "call_a" || calls[1].ID != "call_b" {
t.Fatalf("tool call parts = %+v", calls)
}
emittedCalls := p.calls()
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "call_a" || emittedCalls[1].CallID != "call_b" {
t.Fatalf("emitted tool calls = %+v", emittedCalls)
}
results := toolResultsOf(parts)
if len(results) != 2 {
t.Fatalf("tool result parts = %+v", results)
}
if results[0].CallID != "call_b" || results[0].Status != squad.ToolStatusOK || results[0].Summary != "命中 B" {
t.Fatalf("result B = %+v", results[0])
}
if results[1].CallID != "call_a" || results[1].Status != squad.ToolStatusError || results[1].Summary != "路径不存在" {
t.Fatalf("result A = %+v", results[1])
}
if p.rec.unresolvedFailure() == nil || p.rec.unresolvedFailure().CallID != "call_a" {
t.Fatalf("unresolved failure = %+v", p.rec.unresolvedFailure())
}
}
// TestSquadEventsStatusIsTruthful:失败/取消/拒绝必须原样透传,不得一律报 ok,
// 摘要取真实原因(截断),部件与发射载荷完全一致。
func TestSquadEventsStatusIsTruthful(t *testing.T) {
long := strings.Repeat("失败原因", 80)
cases := []struct {
name string
ev squad.ProcessEvent
status string
summary string
}{
{"error", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c1",
Status: squad.ToolStatusError, ErrorReason: "权限不足", Detail: "旧摘要"}, squad.ToolStatusError, "权限不足"},
{"denied", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
Status: squad.ToolStatusDenied, ErrorReason: "策略拒绝执行"}, squad.ToolStatusDenied, "策略拒绝执行"},
{"canceled", squad.ProcessEvent{Type: "tool_execution_end", Tool: "web_search", CallID: "c3",
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"}, squad.ToolStatusCanceled, "用户取消"},
{"ok", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c4",
Status: squad.ToolStatusOK, Detail: "命中 3 处"}, squad.ToolStatusOK, "命中 3 处"},
// 有失败信号但状态未知:不得报 ok。
{"status-less error signal", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c5",
ErrorReason: "连接被拒绝", Detail: "旧摘要"}, squad.ToolStatusError, "连接被拒绝"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, summary := toolEventOutcome(tc.ev)
if status != tc.status || summary != tc.summary {
t.Fatalf("outcome = %q/%q, want %q/%q", status, summary, tc.status, tc.summary)
}
p := newEventProbe()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool, CallID: tc.ev.CallID})
p.onEvent(tc.ev)
got := toolResultsOf(p.parts())
if len(got) != 1 {
t.Fatalf("parts = %+v", p.parts())
}
// 落库部件与发射载荷同源。
if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.CallID {
t.Fatalf("result part = %+v", got[0])
}
wantPayload := core.ToolResultPayload{CallID: tc.ev.CallID, Status: tc.status, Summary: tc.summary}
if gotPayloads := p.results(); len(gotPayloads) != 1 || gotPayloads[0] != wantPayload {
t.Fatalf("emitted payload = %+v", gotPayloads)
}
if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.CallID || gotCalls[0].Name != tc.ev.Tool {
t.Fatalf("emitted tool call = %+v", gotCalls)
}
})
}
t.Run("summary truncated", func(t *testing.T) {
status, summary := toolEventOutcome(squad.ProcessEvent{Status: squad.ToolStatusError, ErrorReason: long})
if status != squad.ToolStatusError {
t.Fatalf("status = %q", status)
}
if want := truncateRun(long, toolResultSummaryLen); summary != want {
t.Fatalf("summary = %q, want truncated %q", summary, want)
}
})
}
// TestSquadEventsLegacyFramesUnchanged:字段全空的 legacy 事件必须与旧行为字节一致
// (按工具名配对、状态恒为 ok、摘要取 Detail)。
func TestSquadEventsLegacyFramesUnchanged(t *testing.T) {
p := newEventProbe()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Detail: strings.Repeat("d", 200)})
p.onEvent(squad.ProcessEvent{Type: "text_delta", Text: "结论"})
p.onEvent(squad.ProcessEvent{Type: "usage", PromptTokens: 3, CompletionTokens: 1, LastPromptTokens: 2, CacheReadTokens: 1})
parts := p.parts()
calls := toolCallsOf(parts)
if len(calls) != 1 {
t.Fatalf("calls = %+v", calls)
}
wantCall := core.ToolCallPart{ID: "grep", Name: "grep", Arguments: `{"q":"x"}`}
if calls[0] != wantCall {
t.Fatalf("call part = %+v, want %+v", calls[0], wantCall)
}
results := toolResultsOf(parts)
wantResult := core.ToolResultPart{CallID: "grep", Status: "ok", Summary: truncateRun(strings.Repeat("d", 200), 160)}
if len(results) != 1 || results[0] != wantResult {
t.Fatalf("result part = %+v, want %+v", results, wantResult)
}
emitted := p.results()
if len(emitted) != 1 || emitted[0].CallID != "grep" || emitted[0].Status != "ok" ||
emitted[0].Summary != truncateRun(strings.Repeat("d", 200), 160) {
t.Fatalf("emitted = %+v", emitted)
}
if p.rec.reply() != "结论" {
t.Fatalf("reply = %q", p.rec.reply())
}
if f := p.rec.unresolvedFailure(); f != nil {
t.Fatalf("legacy frames must not register a failure: %+v", f)
}
if p.rec.finalReply("", "", "已完成。") != "已完成。" {
t.Fatalf("legacy fallback text changed: %q", p.rec.finalReply("", "", "已完成。"))
}
}
// TestSquadEventsConcurrentCallbacksKeepEveryEntry:并行回调(-race)下不得丢条目、
// 不得串字段;每次调用自己的 start/end 仍成对。
func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) {
const calls = 64
p := newEventProbe()
wantStatus := map[string]string{}
var wg sync.WaitGroup
for i := 0; i < calls; i++ {
id := fmt.Sprintf("call_%02d", i)
wantStatus[id] = squad.ToolStatusOK
failing := i%8 == 0
if failing {
wantStatus[id] = squad.ToolStatusError
}
wg.Add(1)
go func() {
defer wg.Done()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: id, Detail: id})
if failing {
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
Status: squad.ToolStatusError, ErrorReason: "boom " + id})
return
}
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
Status: squad.ToolStatusOK, Detail: "ok " + id})
}()
}
wg.Wait()
parts := p.parts()
if got := len(toolCallsOf(parts)); got != calls {
t.Fatalf("tool call parts = %d, want %d", got, calls)
}
results := toolResultsOf(parts)
if len(results) != calls {
t.Fatalf("tool result parts = %d, want %d", len(results), calls)
}
seenStart := map[string]bool{}
for _, c := range toolCallsOf(parts) {
if seenStart[c.ID] {
t.Fatalf("duplicate start for %s", c.ID)
}
seenStart[c.ID] = true
}
emitted := map[string]string{}
for _, r := range results {
if _, dup := emitted[r.CallID]; dup {
t.Fatalf("duplicate end for %s", r.CallID)
}
emitted[r.CallID] = r.Status
if !seenStart[r.CallID] {
t.Fatalf("end without start: %s", r.CallID)
}
}
for id, status := range emitted {
if status != wantStatus[id] {
t.Fatalf("end %s status = %q, want %q", id, status, wantStatus[id])
}
}
}
// TestSquadEventsConcurrentToolCallbacksShareArtifactList:宿主工具被并行调用时,
// 登记的工件一条都不能丢,snapshot 也不应把内部切片直接交出去。
func TestSquadEventsConcurrentToolCallbacksShareArtifactList(t *testing.T) {
const n = 128
produced := &artifactList{}
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
produced.add(&core.ArtifactRef{ArtifactID: core.ID(fmt.Sprintf("art_%03d", i)), Name: "纪要.docx", Kind: "document"})
}(i)
}
wg.Wait()
got := produced.snapshot()
if len(got) != n {
t.Fatalf("artifacts = %d, want %d", len(got), n)
}
ids := map[string]bool{}
for _, ref := range got {
ids[string(ref.ArtifactID)] = true
}
for i := 0; i < n; i++ {
if !ids[fmt.Sprintf("art_%03d", i)] {
t.Fatalf("missing artifact %d", i)
}
}
got[0] = nil // 快照必须是副本:改动不得回写内部表
if produced.snapshot()[0] == nil {
t.Fatal("snapshot aliases the internal list")
}
}
// TestSquadEventsFinalTextMatchesToolOutcome:未恢复的失败不得被收尾文本改写成成功;
// 同名工具重试成功后收尾文本恢复旧行为。
func TestSquadEventsFinalTextMatchesToolOutcome(t *testing.T) {
p := newEventProbe()
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"})
if got := p.rec.finalReply("", "", "已完成。"); got == "已完成。" || !strings.Contains(got, "run_command") || !strings.Contains(got, "用户取消") {
t.Fatalf("fallback text = %q", got)
}
if got := p.rec.finalReply("已按你的要求处理。", "", "已完成。"); got != "已按你的要求处理。" {
t.Fatalf("model text must be preserved: %q", got)
}
// 失败部件必须留在最终部件里,不被收尾文本覆盖。
if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != squad.ToolStatusCanceled {
t.Fatalf("failure part dropped: %+v", results)
}
// 同名工具重试成功 → 失败记为已恢复,兜底文本回到旧行为。
q := newEventProbe()
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
Status: squad.ToolStatusError, ErrorReason: "超时"})
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c2"})
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
Status: squad.ToolStatusOK, Detail: "done"})
if f := q.rec.unresolvedFailure(); f != nil {
t.Fatalf("retry should clear the failure: %+v", f)
}
if got := q.rec.finalReply("", "", "已完成。"); got != "已完成。" {
t.Fatalf("recovered run fallback = %q", got)
}
}
// entryProbe 在真实运行桩上记录工具事件载荷,并让指定工具真的失败,
// 用来端到端验证「工具结局 → 过程事件 → 落库部件」这条链。
type entryProbe struct {
*entryFake
mu sync.Mutex
calls []core.ToolCallPayload
results []core.ToolResultPayload
}
func (p *entryProbe) Emit(typ core.StreamEventType, payload any) error {
switch v := payload.(type) {
case core.ToolCallPayload:
p.mu.Lock()
p.calls = append(p.calls, v)
p.mu.Unlock()
case core.ToolResultPayload:
p.mu.Lock()
p.results = append(p.results, v)
p.mu.Unlock()
}
return p.entryFake.Emit(typ, payload)
}
func (p *entryProbe) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
if call.Name == "knowledge_search" {
return core.ToolCallResult{Status: "error", Output: "索引不可用"}
}
return p.entryFake.ExecuteTool(call)
}
func (p *entryProbe) resultPayloads() []core.ToolResultPayload {
p.mu.Lock()
defer p.mu.Unlock()
return append([]core.ToolResultPayload(nil), p.results...)
}
// TestSquadEntryFailedToolReachesFinalParts:真实跑一次小队入口,同名并行工具
// 必须按各自 CallID 落部件,失败必须显示为失败,且不被收尾文本改写成成功。
func TestSquadEntryFailedToolReachesFinalParts(t *testing.T) {
dir := t.TempDir()
base := baseEntry(dir)
rc := &entryProbe{entryFake: base}
rc.tools = []core.ToolDesc{{Name: "knowledge_search", Description: "查资料", Parameters: json.RawMessage(`{"type":"object"}`)}}
runner := &SquadRunner{
Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{},
Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled},
Stream: scriptedCalls(t, nil, [][]agentllm.Event{
{{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
ToolCalls: []agentllm.ToolCall{
{ID: "call_k1", Name: "knowledge_search", Arguments: `{"q":"a"}`},
{ID: "call_k2", Name: "knowledge_search", Arguments: `{"q":"b"}`},
}}}},
{{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: mustDocArgs()}}}}},
{{Kind: agentllm.KindTextDelta, Text: "写完了。"},
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "写完了。", StopReason: agentllm.StopEnd}}},
}),
}
if err := runner.Execute(rc); err != nil {
t.Fatal(err)
}
parts := base.parts
calls := toolCallsOf(parts)
ids := map[string]bool{}
for _, c := range calls {
if c.Name != "knowledge_search" {
continue
}
if c.ID == "knowledge_search" || ids[c.ID] {
t.Fatalf("tool call part lost its call id: %+v (all %+v)", c, calls)
}
ids[c.ID] = true
}
if len(ids) != 2 {
t.Fatalf("distinct call ids = %v", ids)
}
results := toolResultsOf(parts)
search := map[string]core.ToolResultPart{}
for _, r := range results {
if ids[r.CallID] {
search[r.CallID] = r
}
}
if len(search) != 2 {
t.Fatalf("tool results for knowledge_search = %+v", results)
}
for id, r := range search {
if r.Status != squad.ToolStatusError {
t.Fatalf("%s status = %q, want error", id, r.Status)
}
if r.Summary != "索引不可用" {
t.Fatalf("%s summary = %q", id, r.Summary)
}
}
// 发射载荷与落库部件一致:前端过程行不得说 ok。
emitted := map[string]string{}
for _, pl := range rc.resultPayloads() {
if ids[pl.CallID] {
emitted[pl.CallID] = pl.Status
}
}
for id, r := range search {
if emitted[id] != r.Status {
t.Fatalf("emitted %s status = %q, part status = %q", id, emitted[id], r.Status)
}
}
// 收尾文本保留模型原话,失败部件不被覆盖。
if last := parts[len(parts)-1]; last.Type != core.PartText || last.Text != "写完了。" {
t.Fatalf("final part = %+v", last)
}
}
+8 -42
View File
@@ -30,37 +30,8 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
prof := rc.Profile()
maxTurns, maxTools := loopCaps(prof.Budget)
produced := &artifactList{}
var streamed strings.Builder
var toolParts []core.Part
textIdx := 0
onEvent := func(ev squad.ProcessEvent) {
switch ev.Type {
case "text_delta":
if ev.Text == "" {
return
}
streamed.WriteString(ev.Text)
_ = rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: ev.Text})
case "reasoning_delta":
if ev.Text != "" {
_ = rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
}
case "tool_execution_start":
_ = rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail})
toolParts = append(toolParts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail}})
case "tool_execution_end":
_ = rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)})
toolParts = append(toolParts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}})
case "usage":
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(core.Usage{
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
})
}
}
}
// 过程帧可能来自并行的工具回调:聚合全部走 squadEventLog(内部互斥)。
evlog := newSquadEventLog(rc)
router := s.Router
if router == nil {
router = newModelRouter(rc)
@@ -73,7 +44,7 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
Goal: entryGoal(rc),
HostTools: bridgeHostTools(rc, produced),
OnEvent: onEvent,
OnEvent: evlog.onEvent,
Stream: stream,
Router: router,
Checker: s.Checker,
@@ -90,14 +61,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
if err != nil {
return err
}
reply := strings.TrimSpace(streamed.String())
if reply == "" {
reply = strings.TrimSpace(res.Summary)
}
if reply == "" {
reply = "已完成。"
}
if claimsFalseDeliverable(reply, produced.items, rc) {
// 收尾文本:模型文本原样保留;仍有未恢复的工具失败时不用「已完成。」兜底。
reply := evlog.finalReply(evlog.reply(), res.Summary, "已完成。")
if claimsFalseDeliverable(reply, produced.snapshot(), rc) {
return fmt.Errorf("声称已生成文件,但没有登记对应工件")
}
if strings.TrimSpace(res.Body) != "" {
@@ -105,9 +71,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
return werr
}
}
parts := append([]core.Part{}, toolParts...)
parts := evlog.toolParts()
parts = append(parts, core.Part{Type: core.PartText, Text: reply})
for _, ref := range produced.items {
for _, ref := range produced.snapshot() {
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
}
return rc.SetAssistantParts(parts)
+220
View File
@@ -0,0 +1,220 @@
package server
// 取消路由(A1/U-A1c):把「取消刚才的任务」这类意图派发成**既有**取消动作。
//
// 纪律:
// - 唯一入口:路由层解出唯一靶子(原文点名的运行号,或本会话绑定动作的
// 运行上下文),本文件只负责把它交给既有取消路径,绝不自己发明取消
// 语义、绝不绕过命令总线。
// - 靶子不唯一就不猜:没有绑定也没有点名时如实澄清,并把发起人自己最近
// 的运行如实列出来(别人的运行在构造上就不可见)。
// - 确认语义不变:注册了取消命令的部署走 Bus.Dispatch(与任何高后果
// 动作同款——审计信封、内联确认卡原样生效,绝不自动确认);未注册时
// 回落到 cancelRun 已有的投递(PG 档且 run 不在本实例 → 收件箱持久命令
// EnqueueCommandAccountChecked;本实例活跃 → 就地 CancelRun),与
// POST /api/runs/{id}/cancel 逐字同款。
// - 不谎报成功:投递失败、运行不可见或已终态都如实回话,不写 accepted。
import (
"net/http"
"sort"
"strings"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsession"
"agent-system-backend/internal/route"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// intentCancelCommandID 是「取消」意图派发时优先使用的命令总线 id:注册了
// 该命令的部署走与任何其他业务动作同款的分发路径(准入、审计信封、高危
// 确认卡全部原样生效);未注册时回落到既有的收件箱投递,语义不变。
const intentCancelCommandID = "run.cancel"
// intentCancelParam 是取消命令声明里运行号的参数名(与既有取消用例一致)。
const intentCancelParam = "run_id"
// cancelCandidateLimit 是澄清时如实列出的候选运行上限(防止回话过长)。
const cancelCandidateLimit = 5
// intentCancelResult 是取消派发的如实回执(响应上的 cancel 字段;不新增 API 面)。
type intentCancelResult struct {
RunID string `json:"runId"`
Source string `json:"source"`
Intent string `json:"intent,omitempty"`
Action string `json:"action,omitempty"`
// Accepted 表示取消请求已被既有路径受理(命令已投递);高危档还需用户
// 显式确认,响应里的 card 就是那道门禁。未受理时 Reason 说明为什么。
Accepted bool `json:"accepted"`
Via string `json:"via"`
Reason string `json:"reason,omitempty"`
}
// planIntentCancel 裁决要不要真的取消:
// - 返回 runID != "":靶子唯一且对发起人可见,按既有路径派发;
// - 返回澄清诉求(runID == ""):靶子缺失/不可见/候选不唯一——如实澄清,
// 绝不猜一个。
//
// 可见性只认发起人自己的运行:GetRun(owner, id) 查不到就与「不存在」同一
// 出口(不据此泄露别人的运行是否存在)。
func (s *Server) planIntentCancel(owner string, conv core.Conversation,
res intentgate.MatchResult, dec route.Decision) (string, *clarifyPayload) {
t := dec.Cancel
if t == nil || strings.TrimSpace(t.RunID) == "" {
return "", s.cancelClarify(owner, conv, res, "没有可取消的运行:请点名运行号,或先发起一个任务")
}
if _, err := s.st.GetRun(owner, t.RunID); err != nil {
return "", s.cancelClarify(owner, conv, res, "该运行不可见或不存在")
}
return t.RunID, nil
}
// cancelClarify 组装取消的澄清诉求:问题问「取消哪一个」,候选如实列出
// (发起人自己最近的未终态运行,按新到旧)。一个候选都没有时如实说明。
func (s *Server) cancelClarify(owner string, conv core.Conversation,
res intentgate.MatchResult, reason string) *clarifyPayload {
c := &clarifyPayload{Intent: res.ID, Command: intentCancelCommandID, Question: "要取消哪一个任务?", Reason: reason}
candidates := s.cancelCandidateRuns(owner, conv.ID)
for _, r := range candidates {
c.Missing = append(c.Missing, intentgate.SlotQuestion{
Key: string(r.ID), Question: "取消运行 " + string(r.ID) + "(" + string(r.Status) + ")"})
}
if len(candidates) == 0 {
c.Question = "现在没有可取消的任务。"
}
return c
}
// cancelCandidateRuns 列出发起人自己最近的未终态运行(会话维度、按创建时间
// 倒序、上限 cancelCandidateLimit)。别人的运行与已终态的运行一律不列。
func (s *Server) cancelCandidateRuns(owner, convID string) []core.Run {
runs, err := s.st.ListRunsByConversation(convID)
if err != nil {
return nil
}
out := []core.Run{}
for _, r := range runs {
if string(r.Owner) != owner || runTerminal(r.Status) {
continue
}
out = append(out, r)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
if len(out) > cancelCandidateLimit {
out = out[:cancelCandidateLimit]
}
return out
}
func runTerminal(status core.RunStatus) bool {
switch status {
case core.RunCompleted, core.RunFailed, core.RunCancelled, core.RunInterrupted:
return true
}
return false
}
// handleIntentCancel 派发一次取消意图(靶子已唯一且可见)。响应形态与既有
// 命令入口同源:消息落库后派发,回执/确认卡/如实回话都在响应里。
func (s *Server) handleIntentCancel(w http.ResponseWriter, r *http.Request, conv core.Conversation,
msg core.Message, target *route.CancelTarget, runID string) {
owner := s.owner(r)
result := intentCancelResult{RunID: runID, Source: string(target.Source),
Intent: target.Intent, Action: target.Action}
out := map[string]any{"message": msg}
awaitingConfirm := false
switch {
case s.Bus != nil && s.Bus.HasCommand(intentCancelCommandID):
// 已注册取消命令:走与任何其他业务动作完全相同的分发路径。高危档
// 在这里会拿到内联确认卡并返回 accepted(awaiting explicit confirm),
// 确认前处理器不执行——绝不自动确认。
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: intentCancelCommandID,
Params: map[string]any{intentCancelParam: runID, chatTextParam: strings.TrimSpace(messageText(msg))},
// chat 入口的规范 Entrance;Actor 是发起人,Owner 是归属租户。
Entrance: "chat", Actor: owner, Owner: owner, ConversationID: conv.ID,
AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID),
})
result.Via, result.Accepted = "bus", resp.Receipt.State != agentcommand.StateRejected
awaitingConfirm = resp.Card != nil
out["command"], out["receipt"] = intentCancelCommandID, resp.Receipt
if resp.Card != nil {
out["card"] = resp.Card
}
if m, ok := resp.Payload.(map[string]any); ok {
for k, v := range m {
out[k] = v
}
}
default:
// 未注册取消命令:与 cancelRun 同款投递——PG 档且 run 不在本实例时
// 写持久命令由执行实例消费,否则就地级联取消本实例的句柄。
err := s.dispatchCancel(owner, runID)
result.Via = "local"
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
result.Via = "command"
}
if err != nil {
result.Reason = err.Error()
} else {
result.Accepted = true
}
}
out["cancel"] = result
switch {
case !result.Accepted:
// 没有真的取消:不写会话记录,也不声称已取消(原因随 cancel 字段透出)。
case awaitingConfirm:
// 已提交、等待显式确认:尚未生效,因此不记「已取消」;确认后的收尾
// 走确认卡自带的路径。
default:
// 已派发的取消记回会话:清挂起预期,留短期「已取消某运行」记录。
out["intent_session"] = s.recordIntentCancel(conv, owner, result, target, runID)
}
writeJSON(w, http.StatusOK, out)
}
// dispatchCancel 派发一次取消(与 cancelRun 同一条路径):跨实例投递走收件箱
// 持久命令,本实例活跃时就地取消句柄。投递本身经 store 的账号围栏——别人的
// 运行号在构造上不可投递。
func (s *Server) dispatchCancel(owner, runID string) error {
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
return s.enqueueCancelCommand(owner, runID)
}
return s.mgr.CancelRun(runID)
}
// enqueueCancelCommand 是 cancelRun 与取消意图共用的投递入口:写一条持久
// cancel 命令,由目标 run 的当前执行实例消费。
func (s *Server) enqueueCancelCommand(owner, runID string) error {
return s.st.EnqueueCommandAccountChecked(owner, store.NewID("cmd"), "cancel", runID, 0,
runmgr.CancelCommandPayload{Owner: owner, RunID: runID}, s.Auth == nil || !s.Auth.Enabled)
}
// recordIntentCancel 把一次已派发的取消记回会话:清挂起预期,留一条短期
// 「已取消某运行」记录,供紧接着的追问(如「确认取消」)如实回话。
func (s *Server) recordIntentCancel(conv core.Conversation, owner string,
result intentCancelResult, target *route.CancelTarget, runID string) *intentSessionNote {
reply := "已取消运行 " + runID + "。"
if result.Via == "command" {
// 跨实例投递:取消由目标 run 的执行实例收尾,这里只如实说「已提交」。
reply = "已提交取消运行 " + runID + "(由执行实例处理)。"
}
note := &intentSessionNote{Kind: "cancelled", Intent: target.Intent, Action: target.Action,
RunID: runID, Reply: reply}
svc := s.intentSessionService()
key := intentSessionKey(owner, conv)
if svc == nil || !keyUsable(key) {
return note
}
if _, err := svc.RecordCancel(key, intentsession.Binding{
RunID: runID, Intent: target.Intent, Action: target.Action}); err != nil {
// 记录失败不改变已发生的取消;回话仍如实说明取消了哪个运行。
note.Reply += "(会话记录未更新)"
}
return note
}
+433
View File
@@ -0,0 +1,433 @@
package server
// A1/U-A1c 取消路由的 chat 端到端(合成数据 + 现有 fakes):
// - 会话里绑定了一个活跃运行 → 无澄清地按既有取消路径派发(回执可见),
// 并在会话上留下「已取消某运行」的短期记录;
// - 原文点名了运行 → 点名优先于会话绑定;
// - 靶子缺失/不可见 → 如实澄清并列出自己最近的运行,绝不猜;
// - 高危取消命令 → 仍先出确认卡,确认前不执行(会话不绕过确认门禁)。
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsession"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// taskCancelIntent 是「取消刚才的任务」的一次识别(库层 cancel 命令;与
// U-A1b 的「取消运行 <id>」是不同意图:前者取消运行,后者放弃补槽)。
func taskCancelIntent(slots map[string]string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: "demo.task-cancel", Slots: slots,
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.task-cancel"}}}
}
// liveRun 起一个仍在进行的运行(mock runner 的 #hitl 挂起等待审批),返回
// run id;它一直活到被取消或被审批——就是「我刚才的任务」。
func liveRun(t *testing.T, h http.Handler, convID string) string {
t.Helper()
runID := postMessage(t, h, convID, "#hitl")
waitActiveHITL(t, h, convID)
return runID
}
// waitActiveHITL 等到运行挂起在审批上(run 处于活跃/等待态)。
func waitActiveHITL(t *testing.T, h http.Handler, convID string) {
t.Helper()
deadline := time.Now().Add(testRunTerminalTimeout)
for time.Now().Before(deadline) {
if code, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil); code == 200 {
if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 {
return
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("运行没有进入等待审批状态")
}
// waitRunStatus 等运行落到期望状态(取消是异步收尾的)。
func waitRunStatus(t *testing.T, h http.Handler, runID, want string) map[string]any {
t.Helper()
deadline := time.Now().Add(testRunTerminalTimeout)
for time.Now().Before(deadline) {
if code, d := doJSON(t, h, "GET", "/api/runs/"+runID, nil); code == 200 {
if r, ok := d["run"].(map[string]any); ok && r["status"] == want {
return r
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("运行 %s 未到达状态 %s", runID, want)
return nil
}
// seedBoundRun 在会话上挂起一个已抽到运行号的预期(等价于「上一轮澄清里
// 抽到了这个运行」),用于驱动取消的会话来源。
func seedBoundRun(t *testing.T, st *store.Store, projectID, convID, runID string) {
t.Helper()
_, err := intentsession.New(st).Suspend(intentsession.Key{
Owner: store.OwnerDefault, Project: projectID, Conversation: convID},
intentsession.Expectation{IntentID: "demo.run-cancel",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
Missing: []intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}},
Question: "取消原因?"},
map[string]string{"run_id": runID})
if err != nil {
t.Fatal(err)
}
}
// 会话里绑定了活跃运行 → 无澄清地派发既有取消动作(回执可见、运行真的停了)。
func TestIntentCancelDispatchesRunBoundInSession(t *testing.T) {
srv, st, gate, _ := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
seedBoundRun(t, st, projectID, convID, runID)
gate.script("取消刚才的任务", taskCancelIntent(nil))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消刚才的任务"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
if _, asked := out["clarify"]; asked {
t.Fatalf("有唯一靶子时不得澄清:%v", out)
}
cancel, _ := out["cancel"].(map[string]any)
if cancel == nil || cancel["runId"] != runID || cancel["accepted"] != true || cancel["source"] != "session" {
t.Fatalf("取消回执:%v", out)
}
if _, ran := out["run"]; ran {
t.Fatalf("取消轮不得再起一个回合:%v", out)
}
note, _ := out["intent_session"].(map[string]any)
if note == nil || note["kind"] != "cancelled" || note["runId"] != runID || note["pending"] != false {
t.Fatalf("会话回话:%v", out)
}
// 派发真的生效:运行被取消。
waitRunStatus(t, h, runID, string(core.RunCancelled))
// 会话上留下短期「已取消」记录:挂起预期已清、记录指向被取消的运行。
sess := pendingIntent(t, st, projectID, convID)
if sess == nil || sess.Cancelled == nil || sess.Cancelled.RunID != runID {
t.Fatalf("取消记录:%+v", sess)
}
if len(sess.Expectation.Missing) != 0 || sess.Slots["run_id"] != "" {
t.Fatalf("取消后仍留着待补齐的槽位:%+v", sess)
}
}
// 原文点名的运行优先于会话绑定:被点的那个被取消,会话绑定的那个纹丝不动。
func TestIntentCancelExplicitRunIDWinsOverSessionBinding(t *testing.T) {
srv, st, gate, _ := cancelServer(t)
h := srv.Handler()
projectID, _ := setupConversation(t, h)
// 两个会话各起一个活跃运行(同一用户、同一项目)。
boundConv := mustCode(t, h, "POST", "/api/projects/"+projectID+"/conversations",
map[string]any{"title": "另一个会话"})["id"].(string)
boundRun := liveRun(t, h, boundConv)
_, convID := setupConversation(t, h)
target := liveRun(t, h, convID)
seedBoundRun(t, st, projectID, convID, boundRun)
gate.script("取消刚才的任务 编号:"+target, taskCancelIntent(map[string]string{"run_id": target}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消刚才的任务 编号:" + target})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
cancel, _ := out["cancel"].(map[string]any)
if cancel == nil || cancel["runId"] != target || cancel["source"] != "explicit" || cancel["accepted"] != true {
t.Fatalf("显式运行号必须优先:%v", out)
}
waitRunStatus(t, h, target, string(core.RunCancelled))
if code, out := doJSON(t, h, "GET", "/api/runs/"+boundRun, nil); code == 200 {
if r, _ := out["run"].(map[string]any); r["status"] == string(core.RunCancelled) {
t.Fatalf("会话绑定的运行被误取消:%v", r)
}
}
}
// insertRun 写入一条合成运行行(未在本地执行的运行:候选与可见性用)。
func insertRun(t *testing.T, st *store.Store, id, convID, projectID, owner, status string, age time.Duration) {
t.Helper()
if _, err := st.DB().Exec(`INSERT INTO runs
(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at)
VALUES (?,'',?,?,?,'conversation','',?,'','',?)`,
id, convID, projectID, owner, status, time.Now().UTC().Add(-age).Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
}
// 靶子不唯一:不猜。如实澄清,并把发起人自己最近的运行列出来(新到旧)。
func TestIntentCancelWithoutTargetClarifiesWithOwnRuns(t *testing.T) {
srv, st, gate, _ := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
// 会话里三条未终态运行:两条自己的、一条别人的(后者不得出现在候选里)。
insertRun(t, st, "run_old", convID, projectID, store.OwnerDefault, "running", 2*time.Minute)
insertRun(t, st, "run_new", convID, projectID, store.OwnerDefault, "waiting_hitl", 1*time.Minute)
insertRun(t, st, "run_foreign", convID, projectID, "mallory", "running", 30*time.Second)
insertRun(t, st, "run_done", convID, projectID, store.OwnerDefault, string(core.RunCompleted), 10*time.Second)
gate.script("取消刚才的任务", taskCancelIntent(nil))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消刚才的任务"})
if code != 200 {
t.Fatalf("澄清轮:%d %v", code, out)
}
if _, dispatched := out["cancel"]; dispatched {
t.Fatalf("没有唯一靶子时不得派发:%v", out)
}
c := clarifyOf(t, out)
if c["question"] != "要取消哪一个任务?" {
t.Fatalf("澄清提问:%v", c)
}
missing, _ := c["missing"].([]any)
got := []string{}
for _, m := range missing {
got = append(got, m.(map[string]any)["key"].(string))
}
if strings.Join(got, ",") != "run_new,run_old" {
t.Fatalf("候选必须是自己最近的未终态运行(新到旧):%v", got)
}
settle(t, h, out)
// 澄清不是取消:候选运行的状态一动不动(自己的可见,别人的依旧不可见)。
for _, id := range []string{"run_old", "run_new"} {
if _, err := st.GetRun(store.OwnerDefault, id); err != nil {
t.Fatalf("澄清轮改动了运行 %s:%v", id, err)
}
}
if _, err := st.GetRun("mallory", "run_foreign"); err != nil {
t.Fatalf("澄清轮改动了别人的运行:%v", err)
}
// 一个可取消的运行都没有时如实说明,而不是空问一句。
_, idle := setupConversation(t, h)
gate.script("取消任务", taskCancelIntent(nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+idle+"/messages",
map[string]any{"text": "取消任务"})
if code != 200 {
t.Fatalf("无候选轮:%d %v", code, out)
}
if q := clarifyOf(t, out)["question"]; q != "现在没有可取消的任务。" {
t.Fatalf("无候选时的提问:%v", q)
}
settle(t, h, out)
}
// 别人的运行既不可见也不可取消:不派发、不泄露存在性。
func TestIntentCancelNeverResolvesAnotherOwnerRun(t *testing.T) {
srv, st, gate, _ := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
foreign := "run_foreign"
insertRun(t, st, foreign, convID, "prj_foreign", "mallory", "running", time.Minute)
if _, err := st.GetRun("mallory", foreign); err != nil {
t.Fatalf("前置:别人的运行应存在:%v", err)
}
gate.script("取消别人的任务", taskCancelIntent(map[string]string{"run_id": foreign}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消别人的任务"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
if _, dispatched := out["cancel"]; dispatched {
t.Fatalf("别人的运行不得被取消:%v", out)
}
c := clarifyOf(t, out)
if reason, _ := c["reason"].(string); !strings.Contains(reason, "不可见") {
t.Fatalf("应如实说明不可见(与不存在同一出口):%v", c)
}
if missing, _ := c["missing"].([]any); len(missing) != 0 {
t.Fatalf("不得把别人的运行列为候选:%v", c)
}
settle(t, h, out)
if _, err := st.GetRun("mallory", foreign); err != nil {
t.Fatalf("别人的运行被改动了:%v", err)
}
}
// 确认语义不变:注册了高危取消命令时先出确认卡,确认前处理器不执行;
// 确认后才真的派发。会话不记录「已取消」(尚未生效)。
func TestIntentCancelSurfacesHighDangerConfirmCard(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
risky := agentcommand.CommandSpec{ID: intentCancelCommandID, Title: "取消运行(高危)",
Danger: agentcommand.DangerHigh,
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id"],
"properties":{"run_id":{"type":"string"},"text":{"type":"string"}}}`)}
if err := srv.Bus.RegisterBuiltin(risky, "", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
gate.script("取消刚才的任务", taskCancelIntent(map[string]string{"run_id": runID}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消刚才的任务"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
if out["command"] != intentCancelCommandID {
t.Fatalf("注册了取消命令时应走命令总线:%v", out)
}
receipt, _ := out["receipt"].(map[string]any)
if receipt == nil || receipt["state"] != string(agentcommand.StateAccepted) {
t.Fatalf("高危命令的受理回执:%v", out)
}
card, _ := out["card"].(map[string]any)
if card == nil {
t.Fatalf("高危取消必须出确认卡:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("确认前不得执行处理器:%v", log.snapshot())
}
if _, noted := out["intent_session"]; noted {
t.Fatalf("确认前不得在会话上记「已取消」:%v", out)
}
if sess := pendingIntent(t, st, projectID, convID); sess != nil {
t.Fatalf("确认前会话不得被改写:%+v", sess)
}
// 卡上确认 → 与任何其他高后果命令同款:处理器执行,参数带运行号。
cardID, _ := card["id"].(string)
code, confirmed := doJSON(t, h, "POST", "/api/cards/"+cardID+"/actions/confirm", map[string]any{})
if code != 200 {
t.Fatalf("确认:%d %v", code, confirmed)
}
rec, _ := confirmed["receipt"].(map[string]any)
if rec == nil || rec["state"] != string(agentcommand.StateCommitted) {
t.Fatalf("确认回执:%v", confirmed)
}
calls := log.snapshot()
if len(calls) != 1 || calls[0]["run_id"] != runID || calls[0]["text"] != "取消刚才的任务" {
t.Fatalf("确认后的派发参数:%v", calls)
}
}
// 没有会话、也没有点名运行的历史行为逐字节不变:普通回合照常起,响应里既
// 没有取消字段,也没有会话语义字段。
func TestIntentCancelLegacyTurnUnchangedWithoutSession(t *testing.T) {
srv, _, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("今天天气如何", intentgate.MatchResult{Branch: "fallback"})
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "今天天气如何"})
if code != 200 {
t.Fatalf("普通轮:%d %v", code, out)
}
for _, key := range []string{"cancel", "clarify", "intent_session", "command"} {
if _, has := out[key]; has {
t.Fatalf("普通轮不应出现 %s:%v", key, out)
}
}
if _, ran := out["run"]; !ran {
t.Fatalf("普通轮照常起 run:%v", out)
}
settle(t, h, out)
if len(log.snapshot()) != 0 {
t.Fatalf("普通轮不得派发命令:%v", log.snapshot())
}
}
// 会话上的「已取消」记录不再是取消的靶子:重复取消如实回到澄清。
func TestIntentCancelRecordIsNotACancelTarget(t *testing.T) {
srv, st, gate, _ := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
seedBoundRun(t, st, projectID, convID, runID)
gate.script("取消刚才的任务", taskCancelIntent(nil))
if code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消刚才的任务"}); code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
waitRunStatus(t, h, runID, string(core.RunCancelled))
gate.script("再取消一次", taskCancelIntent(nil))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "再取消一次"})
if code != 200 {
t.Fatalf("第二轮取消:%d %v", code, out)
}
if _, dispatched := out["cancel"]; dispatched {
t.Fatalf("已取消的记录不得再当靶子:%v", out)
}
if _, kept := out["intent_session"]; kept {
t.Fatalf("澄清轮不得改写会话记录:%v", out)
}
settle(t, h, out)
}
// 命令值里的运行号(库层 cancel 命令自带 value)同样能解出靶子。
func TestIntentCancelResolvesRunIDFromCommandValue(t *testing.T) {
srv, _, gate, _ := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
withValue := taskCancelIntent(nil)
withValue.Commands[0].Value = runID
gate.script("取消那个", withValue)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消那个"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
cancel, _ := out["cancel"].(map[string]any)
if cancel == nil || cancel["runId"] != runID || cancel["accepted"] != true {
t.Fatalf("命令值里的运行号未解出:%v", out)
}
waitRunStatus(t, h, runID, string(core.RunCancelled))
}
// 消息原文进 params:取消命令与其它命令入口同款(恒带 text)。
func TestIntentCancelPassesOriginalText(t *testing.T) {
srv, _, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: intentCancelCommandID,
Title: "取消运行", ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id"],
"properties":{"run_id":{"type":"string"},"text":{"type":"string"}}}`)},
"", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
raw, _ := json.Marshal(req)
if !strings.Contains(string(raw), `"Entrance":"chat"`) {
t.Errorf("Entrance 必须归一为 chat:%s", raw)
}
if req.Actor == "" || req.Owner == "" {
t.Errorf("Actor/Owner 必须是发起人:%+v", req)
}
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
gate.script("取消刚才的任务", taskCancelIntent(map[string]string{"run_id": runID}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消刚才的任务"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
calls := log.snapshot()
if len(calls) != 1 || calls[0]["run_id"] != runID || calls[0]["text"] != "取消刚才的任务" {
t.Fatalf("派发参数:%v", calls)
}
}
+344
View File
@@ -0,0 +1,344 @@
package server
// 意图会话语义(A1/U-A1b):chat 入口消费库层的会话命令(set_slot/
// change_slot/cancel/expectation),把「动作可执行但槽位未就绪」的那一轮
// 记成会话里的挂起预期,等用户后续轮次把槽位补齐。
//
// 纪律:
// - 补齐后走的是与一次完整识别完全相同的 route.Decide + admitIntentCommand
// + Bus.Dispatch 路径;会话只携带槽位与预期,绝不携带授权——高危动作
// 的确认仍由 cmdbus 收口。
// - 没有挂起预期时本段整体空转:响应逐字节与既有行为一致。
// - 取消分两件事,宿主如实区分,绝不混为一谈:
// · 「放弃本次补充」(会话语义):cancel 命令 + 有挂起预期 + 解不出要
// 取消的运行 → 清预期并如实确认,不派发任何业务动作;
// · 「取消一个运行」(业务动作,A1/U-A1c):解出了唯一靶子 → 走既有
// 取消路径派发,见 intent_cancel.go。
// 两者都不会绕过 cmdbus 的确认收口。
import (
"errors"
"fmt"
"strings"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsession"
"agent-system-backend/internal/route"
core "agent-system-core"
)
// IntentSessions 是意图会话的宿主消费面(intentsession.Service 的端口)。
// 组合根/测试可注入替身;nil 时按存储自动装配。
type IntentSessions interface {
Pending(intentsession.Key) (*intentsession.Session, error)
Suspend(intentsession.Key, intentsession.Expectation, map[string]string) (*intentsession.Session, error)
ApplyAnswer(intentsession.Key, int, intentsession.Answer) (*intentsession.Outcome, error)
ApplyCommand(intentsession.Key, int, string, string, string) (*intentsession.Outcome, error)
Drop(intentsession.Key) error
Binding(intentsession.Key) (*intentsession.Binding, error)
RecordCancel(intentsession.Key, intentsession.Binding) (*intentsession.Session, error)
}
// SetIntentSessionService 注入意图会话服务(组合根与测试用;nil 回落按存储
// 自动装配)。
func (s *Server) SetIntentSessionService(svc IntentSessions) {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
s.sessions = svc
}
// intentSessionService 取会话服务:未注入时从存储惰性装配一次,并把槽位
// 校验接到命令总线的 DECLARED schema 上。
func (s *Server) intentSessionService() IntentSessions {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
if s.sessions == nil && s.st != nil {
svc := intentsession.New(s.st)
svc.SetSlotValidator(s.validateIntentSlot)
s.sessions = svc
}
return s.sessions
}
// intentSessionKey 是会话状态的键:owner + project + conversation(三元组
// 缺一不可,两个用户/项目在构造上互不可见)。
func intentSessionKey(owner string, conv core.Conversation) intentsession.Key {
return intentsession.Key{Owner: owner, Project: string(conv.ProjectID), Conversation: string(conv.ID)}
}
// validateIntentSlot 按动作声明的 schema 校验一个槽位值:声明不可解析
// (命令不存在/未启用)时不校验,交由既有准入面 fail-closed。
func (s *Server) validateIntentSlot(actionID, slot, value string) error {
if s.Bus == nil || actionID == "" {
return nil
}
spec, _, err := s.Bus.Registry().Resolve(actionID)
if err != nil {
return nil
}
props, _, ok := objectSchema(spec.ParamsSchema)
if !ok {
return nil
}
decl, declared := props[slot]
if !declared {
return fmt.Errorf("命令未声明参数:%s", slot)
}
if _, ok := castParam(decl, value); !ok {
return fmt.Errorf("参数类型与声明不符:%s", slot)
}
return nil
}
// intentSessionOutcome 是本轮会话语义的结果,供响应面如实回话。
type intentSessionOutcome struct {
// cancelled 表示用户取消了挂起的补充(不派发任何业务动作)。
cancelled bool
// clarify 是仍缺槽位时的澄清诉求(仍缺时才非空)。
clarify *clarifyPayload
// note 是取消这类「不需要派发」的如实回话。
note *intentSessionNote
}
// intentSessionNote 是会话语义的回话标记(既有响应上的附加字段,不新增 API 面)。
type intentSessionNote struct {
Kind string `json:"kind"`
Action string `json:"action,omitempty"`
Intent string `json:"intent,omitempty"`
RunID string `json:"runId,omitempty"`
Reply string `json:"reply"`
Pending bool `json:"pending"`
}
// fuseIntentSession 把本轮用户输入折叠进挂起预期:补齐即合成一条与完整
// 识别同形的 MatchResult(下游照常 route → admit → dispatch);仍缺则
// 如实重新澄清;取消则清理会话且不派发。
// 返回 nil 结果表示本轮与会话语义无关(无挂起预期、或属于别的任务),
// 调用方保持既有行为。
func (s *Server) fuseIntentSession(key intentsession.Key,
recognized intentgate.MatchResult) (intentgate.MatchResult, *intentSessionOutcome, error) {
svc := s.intentSessionService()
if svc == nil || !keyUsable(key) {
return recognized, nil, nil
}
pending, err := svc.Pending(key)
if err != nil {
return recognized, nil, err
}
// 「已取消」记录不是待补齐的动作:它不再吸收本轮的槽位补充(那一轮由
// 取消路由自己处理),只作为如实回话的依据。
if pending == nil || pending.Expectation == nil || pending.Cancelled != nil {
return recognized, nil, nil
}
answer := answerFromRecognition(pending, recognized)
if answer.Cancel {
outcome, cerr := s.applyCancel(svc, key, pending, answer)
if cerr != nil {
return recognized, nil, cerr
}
return recognized, outcome, nil
}
// 本轮对同一动作已是一次完整的新识别:它就是新的指令,按它派发;
// 旧累计槽位不混进新任务(会话就此作废)。
if recognized.Branch == "matched" && recognized.ID != "" &&
recognized.ID == pending.Expectation.IntentID && recognized.Complete() {
return recognized, nil, svc.Drop(key)
}
if len(answer.Slots) == 0 && len(answer.Invalid) == 0 {
// 本轮没有补槽信息(含「命中的是别的意图」):保持既有行为,挂起预期
// 原样留着,由续期或过期收尾——绝不拿别的任务的输入凑当前动作。
return recognized, nil, nil
}
out, err := svc.ApplyAnswer(key, pending.Revision, answer)
if errors.Is(err, intentsession.ErrStale) {
// 会话已被别的轮次推进或取消:陈旧回答作废,绝不据此执行动作。
return recognized, nil, nil
}
if err != nil || out == nil {
return recognized, nil, err
}
if out.Complete {
// 补齐:recognized 已是与完整识别同形的结果,直接走既有准入与
// 派发路径(无需任何附加回话字段)。
return completedRecognition(pending, out), &intentSessionOutcome{}, nil
}
return recognized, &intentSessionOutcome{clarify: sessionClarify(pending, out)}, nil
}
// applyCancel 消费库层 cancel:清空挂起预期并如实确认取消(不派发业务动作)。
//
// fail-closed:除 ErrStale(会话已被推进/取消,按「没有会话语义」如实处理)
// 之外的任何存储错误一律向上传播。绝不把「清不掉挂起预期」当成「本轮没有
// 会话语义」——那一行里的运行号仍会被 sessionRunTarget 解成取消靶子,一次
// 未确认的「放弃补充」就会变成一次真的取消。
func (s *Server) applyCancel(svc IntentSessions, key intentsession.Key,
pending *intentsession.Session, answer intentsession.Answer) (*intentSessionOutcome, error) {
out, err := svc.ApplyAnswer(key, pending.Revision, answer)
if errors.Is(err, intentsession.ErrStale) {
// 陈旧取消作废:如实按「没有会话语义」处理。
return nil, nil
}
if err != nil {
return nil, err
}
if out == nil || !out.Cancelled {
return nil, nil
}
action, intent := "", pending.Expectation.IntentID
if pending.Expectation.Action.ID != "" {
action = pending.Expectation.Action.ID
}
return &intentSessionOutcome{
cancelled: true,
note: &intentSessionNote{Kind: "cancelled", Action: action, Intent: intent, Pending: false,
Reply: "已取消本次槽位补充,未执行任何动作。"},
}, nil
}
// answerFromRecognition 把一次识别投影成会话语义的答案:库层会话命令
// (cancel 优先)、挂起意图自身的槽位与非法原因。归属别的意图时一概不碰
// 本会话(别的任务的输入绝不混进当前动作)。
func answerFromRecognition(pending *intentsession.Session, recognized intentgate.MatchResult) intentsession.Answer {
answer := intentsession.Answer{}
sameIntent := recognized.ID != "" && recognized.ID == pending.Expectation.IntentID
otherIntent := recognized.ID != "" && !sameIntent
for _, cmd := range recognized.Commands {
switch cmd.Kind {
case intentgate.CommandCancel:
if !otherIntent { // 取消指向别的意图时不碰本会话
answer.Cancel = true
return answer
}
case intentgate.CommandSetSlot, intentgate.CommandChangeSlot:
if strings.TrimSpace(cmd.Name) == "" {
continue
}
if answer.Slots == nil {
answer.Slots = map[string]string{}
}
answer.Slots[cmd.Name] = cmd.Value // 显式落槽命令:不看意图归属
}
}
if !sameIntent {
return answer
}
if len(recognized.Slots) > 0 {
if answer.Slots == nil {
answer.Slots = make(map[string]string, len(recognized.Slots))
}
for k, v := range recognized.Slots {
answer.Slots[k] = v
}
}
for _, defect := range recognized.Invalid {
if answer.Invalid == nil {
answer.Invalid = map[string]string{}
}
answer.Invalid[defect.Key] = firstNonEmpty(defect.Reason, "value rejected")
}
return answer
}
// completedRecognition 合成一条与「一次完整识别」同形的 MatchResult:同一
// 意图 ID、累计槽位、同一动作引用的 Start 命令。风险与确认声明刻意不搬运
// ——会话不携带授权语义,确认始终由 cmdbus 按命令 danger 收口。
func completedRecognition(pending *intentsession.Session, out *intentsession.Outcome) intentgate.MatchResult {
action := pending.Expectation.Action
res := intentgate.MatchResult{
Branch: "matched",
ID: pending.Expectation.IntentID,
Slots: out.Slots,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart, Action: action, Slots: out.Slots}},
}
if action.ID != "" {
ref := action
res.Action = &ref
}
return res
}
// sessionClarify 仍缺槽位时的澄清诉求:只报还差什么(缺失/非法各带原因)。
func sessionClarify(pending *intentsession.Session, out *intentsession.Outcome) *clarifyPayload {
action, intent := "", pending.Expectation.IntentID
if pending.Expectation.Action.ID != "" {
action = pending.Expectation.Action.ID
}
// 提问优先问「还差的那个」:缺失在前、非法在后,都没有才回落挂起时的
// 原始提问。
question := ""
if len(out.Remaining) > 0 {
question = strings.TrimSpace(out.Remaining[0].Question)
} else if len(out.Invalid) > 0 {
question = strings.TrimSpace(out.Invalid[0].Question)
}
if question == "" {
question = strings.TrimSpace(out.Question)
}
c := &clarifyPayload{Intent: intent, Command: action, Question: question,
Missing: append([]intentgate.SlotQuestion(nil), out.Remaining...),
Invalid: append([]intentgate.SlotDefect(nil), out.Invalid...)}
if c.Question == "" {
return nil
}
return c
}
// suspendIntentSession 把「动作可执行但槽位未就绪」的那一轮记成会话里的
// 挂起预期(U6 准入面:声明的必填参数无法由原文+槽位满足;路由面:缺失或
// 非法的必填槽位)。响应面不受影响——澄清话术仍由既有 clarify 字段给出。
func (s *Server) suspendIntentSession(key intentsession.Key, recognized intentgate.MatchResult,
dec route.Decision, adm *intentAdmission) error {
svc := s.intentSessionService()
if svc == nil || !keyUsable(key) {
return nil
}
commandID := dec.Clarification.Command
var missing []intentgate.SlotQuestion
question := strings.TrimSpace(dec.Clarification.Question)
switch {
case dec.Kind == route.KindCommand && adm != nil && adm.Blocked:
// 路由已认为动作可派发,但准入未过(声明的必填参数补不齐)。
commandID = dec.CommandID
for _, name := range adm.MissingParams {
missing = append(missing, intentgate.SlotQuestion{Key: name, Question: name})
}
case dec.Clarification.Needed && commandID != "":
// 路由层槽位缺口/非法:动作已解析到已存在的命令,只是槽位不齐。
missing = append(missing, dec.Clarification.Missing...)
for _, defect := range dec.Clarification.Invalid {
missing = append(missing, intentgate.SlotQuestion{Key: defect.Key, Question: defect.Question})
}
default:
return nil // 没有待补齐的可执行动作:不挂起
}
if commandID == "" {
return nil
}
if question == "" && len(missing) > 0 {
question = strings.TrimSpace(missing[0].Question)
}
_, err := svc.Suspend(key, intentsession.Expectation{
IntentID: recognized.ID,
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: commandID},
Missing: missing, Question: question,
}, dec.Slots)
return err
}
// keyUsable 拒绝不完整的键(无归属即不落任何会话状态)。
func keyUsable(key intentsession.Key) bool { return key.Owner != "" && key.Conversation != "" }
// sessionRunTarget 把意图会话里绑定的运行上下文投影给路由器(取消靶子的
// 会话来源)。没有绑定、绑定已取消、或读取失败一律返回 false:宿主随即
// 如实澄清,绝不臆造一个靶子。
func (s *Server) sessionRunTarget(key intentsession.Key) (route.SessionRun, bool) {
svc := s.intentSessionService()
if svc == nil || !keyUsable(key) {
return route.SessionRun{}, false
}
b, err := svc.Binding(key)
if err != nil || b == nil || b.Cancelled || strings.TrimSpace(b.RunID) == "" {
return route.SessionRun{}, false
}
return route.SessionRun{RunID: b.RunID, Intent: b.Intent, Action: b.Action}, true
}
+733
View File
@@ -0,0 +1,733 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"sync"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsession"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// A1/U-A1b 会话语义的 chat 端到端(合成数据 + 现有 fakes):缺槽位动作先
// 澄清挂起,下一轮补齐后按同一条准入/派发路径执行;取消清理会话;过期会话
// 不影响任何新轮次。高危确认仍由 cmdbus 收口(会话只带槽位)。
// scriptedGate 按输入文本返回脚本化的识别结果;未命中的输入走普通回合
// (fallback)。这是库层识别的替身,不改 Stage-1 语义。
type scriptedGate struct {
mu sync.Mutex
results map[string]intentgate.MatchResult
}
func (g *scriptedGate) script(input string, res intentgate.MatchResult) {
g.mu.Lock()
defer g.mu.Unlock()
if g.results == nil {
g.results = map[string]intentgate.MatchResult{}
}
g.results[input] = res
}
func (g *scriptedGate) Recognize(_ context.Context, input string) intentgate.MatchResult {
g.mu.Lock()
defer g.mu.Unlock()
res, ok := g.results[input]
if !ok {
return intentgate.MatchResult{Branch: "fallback"}
}
return res
}
func (g *scriptedGate) Match(_ context.Context, input string) (string, string) {
res := g.Recognize(context.Background(), input)
return res.Branch, res.ID
}
// cancelStart 是「取消运行」动作的一次完整/不完整识别(run-cancel 形态)。
func cancelStart(slots map[string]string, missing []intentgate.SlotQuestion) intentgate.MatchResult {
res := intentgate.MatchResult{
Branch: "matched", ID: "demo.run-cancel", Slots: slots, Missing: missing,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.run-cancel"}, Slots: slots}},
}
if len(missing) > 0 {
res.Commands = append(res.Commands, intentgate.Command{Kind: intentgate.CommandClarify,
Question: missing[0].Question})
}
return res
}
// cancelAnswer 是「对挂起预期的回答」(库层 expectation_hit 分支的同形结果)。
func cancelAnswer(slots map[string]string, missing []intentgate.SlotQuestion) intentgate.MatchResult {
res := intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel", Slots: slots, Missing: missing}
if len(missing) == 0 {
res.Commands = []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.run-cancel"}, Slots: slots}}
}
return res
}
var cancelSpec = agentcommand.CommandSpec{ID: "demo.run-cancel", Title: "取消运行",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id","reason"],
"properties":{"run_id":{"type":"string"},"reason":{"type":"string"}}}`)}
// cancelServer 组装带脚本化意图闸门与 demo.run-cancel 命令的服务器。
func cancelServer(t *testing.T) (*Server, *store.Store, *scriptedGate, *dispatchLog) {
t.Helper()
return cancelServerWith(t, cancelSpec)
}
// cancelServerWith 同上但命令声明可替换(同 id 只注册一次)。
func cancelServerWith(t *testing.T, spec agentcommand.CommandSpec) (*Server, *store.Store, *scriptedGate, *dispatchLog) {
t.Helper()
srv, st := newTestServer(t)
log := &dispatchLog{}
if err := srv.Bus.RegisterBuiltin(spec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
gate := &scriptedGate{}
srv.Gate = gate
return srv, st, gate, log
}
// settle 等本轮启动的回合落终态(会话槽位随即释放,便于下一轮)。
func settle(t *testing.T, h http.Handler, out map[string]any) map[string]any {
t.Helper()
if run, ok := out["run"].(map[string]any); ok {
if id, _ := run["id"].(string); id != "" {
waitRunTerminal(t, h, id, testRunTerminalTimeout)
}
}
return out
}
// 缺必填槽位 → 澄清并挂起;下一轮补齐 → 按同一条准入与派发路径执行。
func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
runIDQ := "取消哪个运行?"
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: runIDQ}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("槽位未就绪不得派发业务动作:%v", out)
}
c := clarifyOf(t, out)
if c["command"] != "demo.run-cancel" || c["question"] != runIDQ {
t.Fatalf("澄清诉求:%v", c)
}
settle(t, h, out)
if len(log.snapshot()) != 0 {
t.Fatalf("澄清轮不得派发:%v", log.snapshot())
}
pending := pendingIntent(t, st, "", convID)
if pending == nil || pending.Expectation.Action.ID != "demo.run-cancel" {
t.Fatalf("未挂起预期:%+v", pending)
}
// 下一轮:仅补齐槽位(库层挂起预期命中分支的同形结果)。
answerText := "编号:R-7 原因:误触发"
gate.script(answerText, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answerText})
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发同一动作:%d %v", code, out)
}
if _, hasClarify := out["clarify"]; hasClarify {
t.Fatalf("补齐后不应再澄清:%v", out)
}
calls := log.snapshot()
if len(calls) != 1 {
t.Fatalf("处理器调用次数:%d", len(calls))
}
params := calls[0]
if params["run_id"] != "R-7" || params["reason"] != "误触发" || params["text"] != answerText {
t.Fatalf("累计槽位未进派发参数:%v", params)
}
if pendingIntent(t, st, "", convID) != nil {
t.Fatal("补齐后会话应被消费")
}
}
// 两轮部分填充:先 set_slot 落一个槽位(仍缺 → 重新澄清),再由回答补齐。
func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
srv, _, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
// 第二轮:库层 set_slot(只带一个槽位),仍缺原因 → 如实重新澄清。
gate.script("编号 R-7", intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandSetSlot, Name: "run_id", Value: "R-7"}}})
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号 R-7"})
if code != 200 {
t.Fatalf("set_slot 轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("仍缺槽位不得派发:%v", out)
}
c := clarifyOf(t, out)
if c["question"] != "取消原因?" {
t.Fatalf("仍缺的提问:%v", c)
}
missing, _ := c["missing"].([]any)
if len(missing) != 1 || missing[0].(map[string]any)["key"] != "reason" {
t.Fatalf("仍缺清单:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("部分填充不得派发:%v", log.snapshot())
}
// 第三轮:补上原因 → 派发,两个槽位都来自会话累计。
gate.script("原因:误触发", cancelAnswer(map[string]string{"reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "原因:误触发"})
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发:%d %v", code, out)
}
calls := log.snapshot()
if len(calls) != 1 || calls[0]["run_id"] != "R-7" || calls[0]["reason"] != "误触发" {
t.Fatalf("累计槽位:%v", calls)
}
}
// 取消挂起的补充:如实确认取消,不派发任何业务动作,会话清空。
func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
gate.script("算了不取消了", intentgate.MatchResult{Branch: "matched", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.run-cancel"}}})
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "算了不取消了"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("取消不得派发业务动作:%v", out)
}
note, ok := out["intent_session"].(map[string]any)
if !ok {
t.Fatalf("取消应如实回话:%v", out)
}
if note["kind"] != "cancelled" || note["action"] != "demo.run-cancel" || note["reply"] == "" || note["pending"] != false {
t.Fatalf("取消回话:%v", note)
}
if len(log.snapshot()) != 0 {
t.Fatalf("取消轮派发了动作:%v", log.snapshot())
}
if pendingIntent(t, st, "", convID) != nil {
t.Fatal("取消后仍挂起预期")
}
// 取消之后的回答不得补齐旧动作(这里识别仍不完整 → 只澄清,不派发)。
gate.script("编号:R-7", cancelAnswer(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号:R-7"})
if code != 200 {
t.Fatalf("取消后回答轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("取消后的回答不得补齐旧动作:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("取消后的回答派发了动作:%v", log.snapshot())
}
}
// 过期会话既不补齐旧动作,也不影响新的动作:过期的累计槽位不得被新的一轮
// 借走,新的任务照常派发。
func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
// 第一轮:抽到 run_id,仍缺原因 → 挂起(累计槽位含 run_id)。
gate.script("取消运行 编号:R-7", cancelStart(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消运行 编号:R-7"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
pending := pendingIntent(t, st, projectID, convID)
if pending == nil || pending.Slots["run_id"] != "R-7" {
t.Fatalf("未挂起预期/未累计槽位:%+v", pending)
}
// 把挂起预期推到过去(等价于 30 分钟未续期后仍在库里的行)。
expired := &store.IntentExpectation{IntentID: "demo.run-cancel", ActionKind: "command",
ActionID: "demo.run-cancel", Missing: []store.IntentSlotQuestion{{Key: "reason", Question: "取消原因?"}}}
if _, err := st.PutIntentSession(store.OwnerDefault, projectID, convID, expired,
map[string]string{"run_id": "R-7"}, -time.Minute); err != nil {
t.Fatal(err)
}
// 第二轮只补了原因:过期的 run_id 不得被借来凑齐动作。
answerText := "原因:误触发"
gate.script(answerText, cancelStart(map[string]string{"reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answerText})
if code != 200 {
t.Fatalf("过期后的回答轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("过期会话不得补齐旧动作:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("过期会话派发了动作:%v", log.snapshot())
}
c := clarifyOf(t, out)
if c["command"] != "demo.run-cancel" {
t.Fatalf("澄清诉求:%v", c)
}
missing, _ := c["missing"].([]any)
foundRunID := false
for _, m := range missing {
if m.(map[string]any)["key"] == "run_id" {
foundRunID = true
}
}
if !foundRunID {
t.Fatalf("过期的累计槽位被借走了:%v", c)
}
if _, busy := out["intent_session"]; busy {
t.Fatalf("过期会话不得产生会话语义字段:%v", out)
}
if _, err := st.GetIntentSession(store.OwnerDefault, projectID, convID); err != store.ErrNotFound {
t.Fatalf("过期行未被惰性删除:%v", err)
}
// 新任务照常派发(过期会话从不干扰新轮次)。
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.purge", Title: "清理缓存",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
"", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
gate.script("清理缓存", otherIntentStart("demo.purge"))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "清理缓存"})
if code != 200 || out["command"] != "demo.purge" {
t.Fatalf("新任务应照常派发:%d %v", code, out)
}
if calls := log.snapshot(); len(calls) != 1 || calls[0]["text"] != "清理缓存" {
t.Fatalf("新任务派发参数:%v", calls)
}
}
// reportSpec 是第二个动作(生成报告):必填 scope,并声明了可选 run_id。
// 声明过的键即准入会映射的键,所以上一轮继承来的运行号会在派发参数里
// 现形——这正是回归要断言的那条泄漏路径。
var reportSpec = agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","scope"],
"properties":{"scope":{"type":"string"},"run_id":{"type":"string"}}}`)}
// reportStart 是第二个动作的一次识别(缺 scope → 路由层澄清 + 挂起)。
func reportStart(slots map[string]string, missing []intentgate.SlotQuestion) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: "demo.report", Slots: slots, Missing: missing,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.report"}, Slots: slots}}}
}
// reportAnswer 是对第二个动作挂起预期的回答(库层 expectation_hit 同形)。
func reportAnswer(slots map[string]string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.report", Slots: slots}
}
// 换动作即换任务(跨动作槽位继承的回归序列):A 抽到运行号后仍缺原因而
// 挂起 → 用户改口做另一个动作 B(B 缺 scope)→ 用户补齐 B 的 scope →
// B 派发时只带 B 自己的槽位,A 的运行号绝不跟着过去。
func TestIntentSessionNewActionDoesNotInheritOldSlots(t *testing.T) {
srv, st, gate, log := cancelServer(t)
if err := srv.Bus.RegisterBuiltin(reportSpec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
h := srv.Handler()
projectID, convID := setupConversation(t, h)
// T1:动作 A(demo.run-cancel)抽到 run_id,仍缺原因 → 挂起。
gate.script("取消运行 编号:R-7", cancelStart(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消运行 编号:R-7"})
if code != 200 {
t.Fatalf("A 轮:%d %v", code, out)
}
settle(t, h, out)
pending := pendingIntent(t, st, projectID, convID)
if pending == nil || pending.Expectation.Action.ID != "demo.run-cancel" || pending.Slots["run_id"] != "R-7" {
t.Fatalf("A 未挂起/未累计槽位:%+v", pending)
}
// T2:用户改口做动作 B(demo.report,缺 scope)→ 新预期,不继承 A 的槽位。
scopeQ := "哪个范围?"
gate.script("生成报告", reportStart(nil, []intentgate.SlotQuestion{{Key: "scope", Question: scopeQ}}))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告"})
if code != 200 {
t.Fatalf("B 轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("槽位未就绪不得派发:%v", out)
}
if c := clarifyOf(t, out); c["command"] != "demo.report" || c["question"] != scopeQ {
t.Fatalf("B 的澄清诉求:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("澄清轮不得派发:%v", log.snapshot())
}
pending = pendingIntent(t, st, projectID, convID)
if pending == nil || pending.Expectation == nil || pending.Expectation.Action.ID != "demo.report" {
t.Fatalf("B 未挂起预期:%+v", pending)
}
if _, leaked := pending.Slots["run_id"]; leaked {
t.Fatalf("A 的槽位被 B 继承:%+v", pending.Slots)
}
// T3:用户补齐 B 的 scope → B 派发,参数里只有 B 的槽位(没有 A 的 run_id)。
answerText := "范围:华东"
gate.script(answerText, reportAnswer(map[string]string{"scope": "华东"}))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": answerText})
if code != 200 || out["command"] != "demo.report" {
t.Fatalf("补齐后应派发 B:%d %v", code, out)
}
if _, asked := out["clarify"]; asked {
t.Fatalf("补齐后不应再澄清:%v", out)
}
calls := log.snapshot()
if len(calls) != 1 {
t.Fatalf("派发次数:%d", len(calls))
}
if calls[0]["scope"] != "华东" || calls[0]["text"] != answerText {
t.Fatalf("B 的派发参数:%v", calls[0])
}
if _, leaked := calls[0]["run_id"]; leaked {
t.Fatalf("A 的运行号漏进了 B 的派发参数:%v", calls[0])
}
if pendingIntent(t, st, projectID, convID) != nil {
t.Fatal("补齐后会话应被消费")
}
}
// otherIntentStart 是另一个动作的完整识别(换任务)。
func otherIntentStart(id string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: id,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: id}}}}
}
// 别的会话/项目的挂起预期绝不互相影响(键含 project + conversation)。
func TestIntentSessionIsolatesConversations(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
projectA, convA := setupConversation(t, h)
projectB, convB := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convA+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("A 会话第一轮:%d %v", code, out)
}
settle(t, h, out)
if pendingIntent(t, st, projectA, convA) == nil {
t.Fatal("A 会话未挂起预期")
}
if pendingIntent(t, st, projectB, convB) != nil {
t.Fatal("B 会话看到了 A 的挂起预期")
}
// B 会话收到同样的回答:A 的槽位不得被借走(没有 A 的累计值)。
answer := "编号:R-7 原因:误触发"
gate.script(answer, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convB+"/messages", map[string]any{"text": answer})
if code != 200 {
t.Fatalf("B 会话回答轮:%d %v", code, out)
}
settle(t, h, out)
if len(log.snapshot()) != 0 {
t.Fatalf("无挂起预期的会话不得凭别人的槽位派发:%v", log.snapshot())
}
if pendingIntent(t, st, projectA, convA) == nil {
t.Fatal("B 会话的轮次改动了 A 的挂起预期")
}
}
// 高危动作仍走 cmdbus 确认:会话补齐不绕过确认卡(处理器不执行)。
func TestIntentSessionStillRequiresHighRiskConfirmation(t *testing.T) {
risky := cancelSpec
risky.Danger = agentcommand.DangerHigh
srv, _, gate, log := cancelServerWith(t, risky)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
answer := "编号:R-7 原因:误触发"
gate.script(answer, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answer})
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发到总线:%d %v", code, out)
}
if out["card"] == nil {
t.Fatalf("高危动作必须仍要确认卡:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("确认前不得执行处理器:%v", log.snapshot())
}
}
// 非法槽位的补充照旧按非法追问:值不采纳、动作不派发。
func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
gate.script("编号:R-9", intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel",
Slots: map[string]string{"run_id": "R-9"},
Invalid: []intentgate.SlotDefect{{Key: "run_id", Reason: "unknown run", Question: "取消哪个运行?", Invalid: true}}})
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号:R-9"})
if code != 200 {
t.Fatalf("非法补充轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("非法补充不得派发:%v", out)
}
c := clarifyOf(t, out)
invalid, _ := c["invalid"].([]any)
if len(invalid) != 1 || invalid[0].(map[string]any)["reason"] != "unknown run" {
t.Fatalf("非法原因未透传:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("非法补充派发了动作:%v", log.snapshot())
}
pending := pendingIntent(t, st, "", convID)
if pending == nil || pending.Slots["run_id"] != "" {
t.Fatalf("非法值不得进入会话槽位表:%+v", pending)
}
}
// 无会话时行为逐字节不变:与「会话关闭」的服务器跑同一脚本,响应(去掉
// run/message 这类每次都不同的回执)逐字节一致,且不得出现会话语义字段。
func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
script := func(gate *scriptedGate) {
// 槽位齐全:照常派发(会话层不得介入)。
gate.script("取消运行 编号:R-3 原因:误触发", cancelStart(
map[string]string{"run_id": "R-3", "reason": "误触发"}, nil))
gate.script("今天天气如何", intentgate.MatchResult{Branch: "fallback"})
gate.script("算了", intentgate.MatchResult{Branch: "matched", ID: "demo.purge",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.purge"}}})
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
}
// summarize 把响应投影成与运行无关的稳定形态:顶层键、命令、澄清诉求。
summarize := func(out map[string]any) string {
keys := make([]string, 0, len(out))
for k := range out {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(strings.Join(keys, ","))
if cmd, ok := out["command"]; ok {
raw, _ := json.Marshal(cmd)
b.WriteString("|command=" + string(raw))
}
if c, ok := out["clarify"]; ok {
raw, _ := json.Marshal(c)
b.WriteString("|clarify=" + string(raw))
}
if n, ok := out["intent_session"]; ok {
raw, _ := json.Marshal(n)
b.WriteString("|session=" + string(raw))
}
return b.String()
}
run := func(t *testing.T, sessions IntentSessions) []string {
t.Helper()
srv, _, gate, log := cancelServer(t)
if sessions != nil {
srv.SetIntentSessionService(sessions)
}
script(gate)
h := srv.Handler()
_, convID := setupConversation(t, h)
var summaries []string
for _, text := range []string{"取消运行 编号:R-3 原因:误触发", "今天天气如何", "算了", "取消运行"} {
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
if code != 200 {
t.Fatalf("%s:%d %v", text, code, out)
}
settle(t, h, out)
summaries = append(summaries, text+" -> "+summarize(out))
}
if len(log.snapshot()) != 1 {
t.Fatalf("脚本只应派发一次完整动作:%v", log.snapshot())
}
return summaries
}
withSessions := run(t, nil)
// 关闭会话语义(替身永不挂起/折叠)后同一脚本的响应必须逐字节一致。
without := run(t, inertSessions{})
if len(withSessions) != len(without) {
t.Fatalf("轮次数不一致:%v vs %v", withSessions, without)
}
for i := range withSessions {
if withSessions[i] != without[i] {
t.Fatalf("无会话时行为发生变化:\n 有:%s\n 无:%s", withSessions[i], without[i])
}
if strings.Contains(withSessions[i], "session=") {
t.Fatalf("无会话语义的轮次带上了会话语义字段:%s", withSessions[i])
}
}
if !strings.Contains(strings.Join(withSessions, "\n"), "|clarify=") {
t.Fatalf("脚本未触发澄清,测试失去意义:%v", withSessions)
}
}
// brokenCancelStore 是「放弃补充时写库失败」的替身:其余方法照常委派给
// 真实会话服务,只有 cancel 的折叠返回非 ErrStale 的存储错误(真实写库
// 故障的注入点)。
type brokenCancelStore struct{ IntentSessions }
func (b brokenCancelStore) ApplyAnswer(key intentsession.Key, revision int,
answer intentsession.Answer) (*intentsession.Outcome, error) {
if answer.Cancel {
return nil, errors.New("clear intent session: injected store failure")
}
return b.IntentSessions.ApplyAnswer(key, revision, answer)
}
// 放弃补充时写库失败 → fail-closed:本轮报错,绝不降级成「没有会话语义」
// 去规划/派发一次取消(挂起行里的运行号仍在,会被解成取消靶子)。
func TestIntentSessionCancelFailsClosedOnStoreError(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
seedBoundRun(t, st, projectID, convID, runID)
srv.SetIntentSessionService(brokenCancelStore{IntentSessions: intentsession.New(st)})
// 放弃本次槽位补充(库层 cancel,指向正在挂起的 A)——写库故障就在此刻。
gate.script("算了不取消了", intentgate.MatchResult{Branch: "matched", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.run-cancel"}}})
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "算了不取消了"})
if code == 200 {
t.Fatalf("写库失败的一轮不得照常应答:%d %v", code, out)
}
if _, asked := out["cancel"]; asked {
t.Fatalf("写库失败时不得回执一次取消:%v", out)
}
if _, ran := out["run"]; ran {
t.Fatalf("写库失败时不得起新回合:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("写库失败时不得派发:%v", log.snapshot())
}
// 挂起行确实没被清掉(故障没被伪装成成功),这一轮因此更不能取消它。
if pendingIntent(t, st, projectID, convID) == nil {
t.Fatal("注入的写库失败不应真的清空挂起预期")
}
// 绝不能真的取消那个运行:给可能的误派发一点时间,再如实断言。
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if code, d := doJSON(t, h, "GET", "/api/runs/"+runID, nil); code == 200 {
if r, _ := d["run"].(map[string]any); r["status"] == string(core.RunCancelled) {
t.Fatalf("写库失败的一轮仍然取消了运行 %s:%v", runID, r)
}
}
time.Sleep(20 * time.Millisecond)
}
}
// inertSessions 是「关闭会话语义」的替身:任何调用都如实报告没有会话。
type inertSessions struct{}
func (inertSessions) Pending(intentsession.Key) (*intentsession.Session, error) { return nil, nil }
func (inertSessions) Suspend(intentsession.Key, intentsession.Expectation, map[string]string) (*intentsession.Session, error) {
return nil, nil
}
func (inertSessions) ApplyAnswer(intentsession.Key, int, intentsession.Answer) (*intentsession.Outcome, error) {
return nil, nil
}
func (inertSessions) ApplyCommand(intentsession.Key, int, string, string, string) (*intentsession.Outcome, error) {
return nil, nil
}
func (inertSessions) Drop(intentsession.Key) error { return nil }
func (inertSessions) Binding(intentsession.Key) (*intentsession.Binding, error) {
return nil, nil
}
func (inertSessions) RecordCancel(intentsession.Key, intentsession.Binding) (*intentsession.Session, error) {
return nil, nil
}
// pendingIntent 读当前会话的挂起预期(测试只读断言面;过期即视为没有)。
func pendingIntent(t *testing.T, st *store.Store, projectID, convID string) *intentsession.Session {
t.Helper()
conv, err := st.GetConversation(store.OwnerDefault, convID)
if err != nil {
t.Fatal(err)
}
if projectID == "" {
projectID = string(conv.ProjectID)
}
sess, err := intentsession.New(st).Pending(intentsession.Key{
Owner: store.OwnerDefault, Project: projectID, Conversation: convID})
if err != nil {
t.Fatal(err)
}
return sess
}
+46
View File
@@ -21,7 +21,24 @@ func (s *Server) lifecycleHost(owner string) extension.Host {
}
return s.projectHost(owner)
}
// scratchProjectWork 让临时会话的属主在没有 RDM provider 时也能工作。scratch 项目
// 从不进入 RDM:provider 只会报 Work=false,属主会被自己的临时会话挡住(U2 核心)。
// 所以门禁先走存储侧归属:属主放行且不再问 provider,非属主一律 NotFound(与
// 项目工作同一错误面)。handled=false 表示这不是临时会话,调用点照旧。
func (s *Server) scratchProjectWork(owner, projectID string) (handled bool, err error) {
if s.st == nil {
return false, nil
}
return s.st.ScratchProjectDecision(projectID, owner)
}
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
return err
} else if handled {
return nil
}
if s.projectLifecycle == nil {
return nil
}
@@ -48,6 +65,12 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error
// With no resource authorizer registered (EXT-8b V2 absent) this is exactly
// projectWork, so un-extended hosts keep their current denial unchanged.
func (s *Server) projectRead(ctx context.Context, owner, projectID string) error {
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
return err
} else if handled {
// 临时会话只对属主可读:共享域回落永远不参与(它不是共享资源)。
return nil
}
if s.projectLifecycle == nil {
return nil
}
@@ -70,7 +93,12 @@ func (s *Server) projectRead(ctx context.Context, owner, projectID string) error
// createPrivateConversation is shared by the API and /new. Only live work
// authorization grants member creation; the resulting conversation stays owned.
// A scratch project already holds its one private conversation, so this shared
// path refuses a second one instead of handing out a normal session under it.
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
if err := s.rejectScratchProjectConversation(projectID, owner); err != nil {
return core.Conversation{}, err
}
if s.projectLifecycle != nil {
if err := s.projectWork(ctx, owner, projectID); err != nil {
return core.Conversation{}, err
@@ -83,6 +111,24 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID
}
return c, err
}
// rejectScratchProjectConversation keeps the scratch 1:1 rule on the shared
// conversation-create path (POST /api/projects/{id}/conversations and /new).
// The lookup is the internal trusted one, and it is ownership-aware: a non-owner
// is not told anything about the session (projectWork answers NotFound), while
// the owner still gets the 1:1 refusal. Nothing here is returned to the caller
// for an unreadable project, which the store path decides as before.
func (s *Server) rejectScratchProjectConversation(projectID, owner string) error {
if s.st == nil {
return nil
}
handled, err := s.st.ScratchProjectDecision(projectID, owner)
if !handled || err != nil {
return nil
}
return store.ErrScratchSessionSingle
}
func writeProjectErr(w http.ResponseWriter, err error) {
var coded interface{ StatusCode() int }
if errors.As(err, &coded) {
+150
View File
@@ -0,0 +1,150 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/store"
)
// 会话响应上的 scratch 标记(U2B):临时会话在任务列表、项目会话列表与详情里都
// 带 scratch=true,普通项目的会话没有这个键——前端据此把临时会话归到“无项目对话”,
// 不必再从项目列表反推。
// conversationRow 保留原始 JSON 对象:标记在不在键里都能看见。
type conversationRow map[string]any
func decodeConversationRows(t *testing.T, rec *httptest.ResponseRecorder) map[string]conversationRow {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("list conversations: %d %s", rec.Code, rec.Body.String())
}
var rows []conversationRow
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String())
}
byID := map[string]conversationRow{}
for _, row := range rows {
id, _ := row["id"].(string)
if id == "" {
t.Fatalf("conversation without id: %s", rec.Body.String())
}
byID[id] = row
}
return byID
}
// assertScratchFlag 校验一条会话:want=true 必须带 scratch=true;want=false 时
// 必须完全没有 scratch 键(普通项目的会话 JSON 与加标记之前同形)。
func assertScratchFlag(t *testing.T, where string, row conversationRow, want bool) {
t.Helper()
if row == nil {
t.Fatalf("%s: conversation missing from the response", where)
}
raw, ok := row["scratch"]
if !want {
if ok {
t.Fatalf("%s: non-scratch conversation carries scratch: %v", where, raw)
}
return
}
if flag, _ := raw.(bool); !ok || !flag {
t.Fatalf("%s: scratch conversation is not marked: %v", where, raw)
}
}
func createdConversationObject(t *testing.T, rec *httptest.ResponseRecorder) conversationRow {
t.Helper()
var body struct {
Conversation conversationRow `json:"conversation"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String())
}
return body.Conversation
}
// scratchFlagIDs 是 fixture 造出来的两条会话(普通 + 临时)的 id 与项目 id。
type scratchFlagIDs struct {
normalID, normalProjectID string
scratchID, scratchProjectID string
}
// scratchFlagFixture 造一个普通项目的会话和一个临时会话。临时会话走 HTTP 创建,
// 因此创建响应上的标记也一并断言。
func scratchFlagFixture(t *testing.T, h http.Handler, st *store.Store, key string) scratchFlagIDs {
t.Helper()
project, err := st.CreateProjectAccountChecked("engineer", "普通项目", false)
if err != nil {
t.Fatal(err)
}
normal, err := st.CreateConversation("engineer", string(project.ID), "普通会话", "")
if err != nil {
t.Fatal(err)
}
created := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
if created.Code != http.StatusOK {
t.Fatalf("create scratch session: %d %s", created.Code, created.Body.String())
}
view := decodeScratchSessionHTTP(t, created)
if view.Project.ID == "" || view.Conversation.ID == "" {
t.Fatalf("incomplete scratch session: %s", created.Body.String())
}
assertScratchFlag(t, "POST /api/scratch-sessions", createdConversationObject(t, created), true)
return scratchFlagIDs{
normalID: string(normal.ID), normalProjectID: string(project.ID),
scratchID: view.Conversation.ID, scratchProjectID: view.Project.ID,
}
}
// TestScratchFlagOnConversationResponses 同一份 fixture 上验证三条读取路径:
// 任务列表、项目会话列表、会话详情——临时会话带 scratch=true,普通项目的会话
// 不带这个键。
func TestScratchFlagOnConversationResponses(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key := scratchAccountKey(t, st, "engineer")
ids := scratchFlagFixture(t, h, st, key)
tasks := decodeConversationRows(t, bearerRequest(h, "GET", "/api/conversations", "", key))
if len(tasks) != 2 {
t.Fatalf("task list must hold both conversations: %d %+v", len(tasks), tasks)
}
if tasks[ids.scratchID] == nil || tasks[ids.normalID] == nil {
t.Fatalf("task list does not cover both conversations: %+v", tasks)
}
assertScratchFlag(t, "GET /api/conversations (scratch)", tasks[ids.scratchID], true)
assertScratchFlag(t, "GET /api/conversations (normal)", tasks[ids.normalID], false)
scratchUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.scratchProjectID+"/conversations", "", key))
normalUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.normalProjectID+"/conversations", "", key))
assertScratchFlag(t, "GET /api/projects/{scratch}/conversations", scratchUnder[ids.scratchID], true)
assertScratchFlag(t, "GET /api/projects/{normal}/conversations", normalUnder[ids.normalID], false)
for _, tc := range []struct {
name string
id string
scratch bool
}{
{"scratch", ids.scratchID, true},
{"normal", ids.normalID, false},
} {
rec := bearerRequest(h, "GET", "/api/conversations/"+tc.id, "", key)
if rec.Code != http.StatusOK {
t.Fatalf("detail %s: %d %s", tc.name, rec.Code, rec.Body.String())
}
var body struct {
Conversation conversationRow `json:"conversation"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode detail %s: %v body=%s", tc.name, err, rec.Body.String())
}
if body.Conversation["id"] != tc.id {
t.Fatalf("detail %s returned %v", tc.name, body.Conversation["id"])
}
assertScratchFlag(t, "GET /api/conversations/{id} ("+tc.name+")", body.Conversation, tc.scratch)
}
}
+311
View File
@@ -0,0 +1,311 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// 临时会话隔离门禁(U2)。产品规则:scratch 会话是“只属于自己的通用代理会话”——
// 属主能像普通聊天一样用它,但它不共享任何文档/上下文,也不触达项目域能力。
// 每个门禁都成对验证:owner 放行、其他人/其它项目照旧。
// scratchGateFixture 建一个已登录的属主账号并落下一个临时会话。
func scratchGateFixture(t *testing.T, srv *Server, st *store.Store, owner string) (key, projectID, conversationID string) {
t.Helper()
key = scratchAccountKey(t, st, owner)
rec := bearerRequest(srv.Handler(), "POST", "/api/scratch-sessions", `{}`, key)
if rec.Code != http.StatusOK {
t.Fatalf("create scratch session: %d %s", rec.Code, rec.Body.String())
}
view := decodeScratchSessionHTTP(t, rec)
return key, view.Project.ID, view.Conversation.ID
}
// TestScratchOwnerWorksWithoutRDMProvider 是 U2 的核心:注册一个 Work 恒为 false
// 的 projectLifecycle(RDM 从未听说过 scratch 项目)后,属主仍能在自己的临时
// 会话里发消息,而别的账号读不到这个临时会话——即使共享授权适配器对所有人放行。
func TestScratchOwnerWorksWithoutRDMProvider(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
aliceKey, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
bobKey := scratchAccountKey(t, st, "bob")
providerCalls := 0
srv.SetProjectLifecycle(extension.ProjectLifecycle{
Work: func(context.Context, string, core.ID) (bool, error) {
providerCalls++
return false, nil // RDM 视角:没有这个项目
},
}, nil)
// 共享域适配器对任何人都放行(最宽松的现网配置),临时会话仍不可共享。
srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
srv.SetResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"帮我总结这段话"}`, aliceKey)
if rec.Code != http.StatusOK {
t.Fatalf("scratch owner could not chat without the provider: %d %s", rec.Code, rec.Body.String())
}
msgs, err := st.ListMessages(conversationID)
if err != nil || len(msgs) == 0 {
t.Fatalf("owner message not persisted: %+v %v", msgs, err)
}
if providerCalls != 0 {
t.Fatalf("scratch owner work asked the domain provider %d times", providerCalls)
}
// 负面:别的账号读不到这个临时会话(共享授权适配器一律放行也不作数)。
if rec := bearerRequest(h, "GET", "/api/projects/"+projectID+"/conversations", "", bobKey); rec.Code != http.StatusNotFound {
t.Fatalf("non-owner listed a scratch project: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/projects/"+projectID+"/conversations", `{"title":"混入"}`, bobKey); rec.Code != http.StatusNotFound {
t.Fatalf("non-owner created a conversation under scratch: %d %s", rec.Code, rec.Body.String())
}
if err := srv.projectRead(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("projectRead opened a scratch project for a non-owner: %v", err)
}
if err := srv.projectWork(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("projectWork opened a scratch project for a non-owner: %v", err)
}
// 属主的读面不受共享适配器影响(存储归属即可)。
if err := srv.projectRead(context.Background(), "alice", projectID); err != nil {
t.Fatalf("scratch owner projectRead: %v", err)
}
// 普通项目不受影响:provider 说 false 就是 false。
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
if err := srv.projectWork(context.Background(), "alice", string(normal.ID)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("normal project bypassed the provider decision: %v", err)
}
}
// TestScratchHasNoKnowledgeBase 知识库是项目域能力:入库与检索两个面都 400。
func TestScratchHasNoKnowledgeBase(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
doc := `[{"title":"手动文档","chunk":"知识库内容"}]`
for name, path := range map[string]string{"scratch": projectID, "normal": normal} {
rec := bearerRequest(h, "POST", "/api/projects/"+path+"/knowledge", doc, key)
switch name {
case "scratch":
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") {
t.Fatalf("addKnowledge on scratch = %d %s", rec.Code, rec.Body.String())
}
case "normal":
if rec.Code != http.StatusOK {
t.Fatalf("addKnowledge on a normal project = %d %s", rec.Code, rec.Body.String())
}
}
}
// 被拒的写入没有落库。
if docs, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "手动文档", TopK: 10}); err != nil || len(docs) != 0 {
t.Fatalf("scratch project kept knowledge rows: %+v %v", docs, err)
}
rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+projectID, "", key)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") {
t.Fatalf("searchKnowledge on scratch = %d %s", rec.Code, rec.Body.String())
}
// 正面:普通项目仍然检索得到自己刚入库的文档。
if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+normal, "", key); rec.Code != http.StatusOK {
t.Fatalf("searchKnowledge on a normal project = %d %s", rec.Code, rec.Body.String())
}
// 无 project 参数的全局检索不受门禁影响(它本来就不落在临时会话上)。
if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库", "", key); rec.Code != http.StatusOK {
t.Fatalf("global search = %d %s", rec.Code, rec.Body.String())
}
}
// TestScratchUploadStaysOutOfKnowledgeIndex 上传仍可用(对话附件),但临时会话的
// 附件不进任何知识索引;普通项目的上传照旧入库。
func TestScratchUploadStaysOutOfKnowledgeIndex(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
upload := func(projectID, name, text string) string {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(text)); err != nil {
t.Fatal(err)
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("upload to %s: %d %s", projectID, rec.Code, rec.Body.String())
}
var up struct {
ArtifactID string `json:"artifactId"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatal(err)
}
return up.ArtifactID
}
scratchArtifact := upload(projectID, "临时附件.txt", "临时会话的附件正文")
if scratchArtifact == "" {
t.Fatal("scratch upload returned no artifact")
}
upload(normal, "普通附件.txt", "普通项目的附件正文")
scratchHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "附件", TopK: 10})
if err != nil {
t.Fatal(err)
}
for _, hit := range scratchHits {
if string(hit.Doc.ProjectID) == projectID || hit.Doc.Ref == scratchArtifact {
t.Fatalf("scratch upload was indexed: %+v", hit.Doc)
}
}
normalHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: normal, Text: "附件", TopK: 10})
if err != nil || len(normalHits) == 0 {
t.Fatalf("normal project upload lost indexing: %+v %v", normalHits, err)
}
}
// TestScratchRejectsDomainCommands 临时会话里 rdm.* 命令 403;不碰项目域的内置
// 命令照旧可用(它就是普通聊天能力)。
func TestScratchRejectsDomainCommands(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key))
srv.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
srv.WireBuiltinCommands()
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, func(ctx context.Context, req cmdbus.Request) (any, error) {
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.list", Title: "需求列表"}, func(ctx context.Context, req cmdbus.Request) (any, error) {
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
// API 入口(POST /api/commands/{id}):临时会话宿主一律 403。
for _, id := range []string{"rdm.reqs.add", "rdm.reqs.list"} {
rec := bearerRequest(h, "POST", "/api/commands/"+id, `{"conversationId":"`+conversationID+`"}`, key)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not available in scratch sessions") {
t.Fatalf("%s on a scratch host = %d %s", id, rec.Code, rec.Body.String())
}
}
// 正面:读侧命令在普通项目宿主上照旧分发。
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{"conversationId":"`+normalConv+`"}`, key); rec.Code != http.StatusOK {
t.Fatalf("rdm.reqs.list on a normal host = %d %s", rec.Code, rec.Body.String())
}
// 没有会话就没有基座项目:门禁不适用,命令走原路径(正式项目命令仍由总线预检拒绝)。
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{}`, key); rec.Code != http.StatusOK {
t.Fatalf("command without a host project = %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.add", `{}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("hostless formal command must keep the bus preflight answer: %d %s", rec.Code, rec.Body.String())
}
// 内置会话命令在临时会话里照旧可用(它不碰项目域)。
rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"/help"}`, key)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "command") {
t.Fatalf("builtin host command in a scratch session = %d %s", rec.Code, rec.Body.String())
}
if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) {
t.Fatalf("scratch marker lost during command gating: %s", projectID)
}
}
func mustScratchSettings(t *testing.T, st *store.Store, projectID string) map[string]any {
t.Helper()
p, err := st.ProjectMetadata(projectID)
if err != nil {
t.Fatal(err)
}
return p.Settings
}
// TestScratchProjectPatchAllowsNameOnly 临时会话只允许改名;任何设置写入 400。
func TestScratchProjectPatchAllowsNameOnly(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
if rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, `{"name":"改个名字"}`, key); rec.Code != http.StatusOK {
t.Fatalf("rename scratch project = %d %s", rec.Code, rec.Body.String())
}
if p, err := st.ProjectMetadata(projectID); err != nil || p.Name != "改个名字" {
t.Fatalf("scratch project not renamed: %+v %v", p, err)
}
for _, body := range []string{`{"settings":{"note":"任何设置"}}`, `{"settings":{"crossSession":{"read":true}}}`, `{"settings":{"host":{"kind":"normal"}}}`} {
rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, body, key)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no project settings") {
t.Fatalf("scratch settings patch %s = %d %s", body, rec.Code, rec.Body.String())
}
}
if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) {
t.Fatal("refused patch changed the scratch marker")
}
// 正面:普通项目的设置写入照旧放行。
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normal, `{"settings":{"note":"普通"}}`, key); rec.Code != http.StatusOK {
t.Fatalf("normal settings patch = %d %s", rec.Code, rec.Body.String())
}
}
// TestDeletingScratchConversationRemovesScratchProject 删会话即删临时会话外壳;
// 普通项目删会话仍保留项目。
func TestDeletingScratchConversationRemovesScratchProject(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
grantProjectCreateFixture(t, st, "alice")
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key))
bobKey := scratchAccountKey(t, st, "bob")
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", bobKey); rec.Code == http.StatusOK {
t.Fatalf("non-owner deleted a scratch conversation: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", key); rec.Code != http.StatusOK {
t.Fatalf("delete scratch conversation = %d %s", rec.Code, rec.Body.String())
}
if _, err := st.ProjectMetadata(projectID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("scratch project survived its conversation: %v", err)
}
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+normalConv, "", key); rec.Code != http.StatusOK {
t.Fatalf("delete normal conversation = %d %s", rec.Code, rec.Body.String())
}
if _, err := st.ProjectMetadata(normal); err != nil {
t.Fatalf("normal project removed with its conversation: %v", err)
}
}
+118
View File
@@ -0,0 +1,118 @@
package server
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 临时会话入口(U1):POST /api/scratch-sessions。
//
// 能力是内生的——任何已启用账号都能建,不需要 project.create,也不在权限目录
// /replaceRolePermissions 里出现为可配置项。项目与会话由 store 在一个事务里
// 写出(scratch 标记 + cross_private 会话),不经过 RDM/projectLifecycle。
// scratchSessionView 是创建结果:调用方同时拿到 scratch 项目与它唯一的会话。
// 会话走 server 本地的 conversationView:刚建出来的会话必然属于 scratch 项目,
// 标记直接置真,不必回查。
type scratchSessionView struct {
Project core.Project `json:"project"`
Conversation conversationView `json:"conversation"`
}
func (s *Server) createScratchSession(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Title string `json:"title"`
ProfileID string `json:"profileId"`
Settings map[string]any `json:"settings"`
}
// 请求体可省略:空体即默认名临时会话。非空体必须是单一 JSON 对象。
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid scratch session request", http.StatusBadRequest)
return
}
// 标记归服务端所有:客户端不能带 settings 上来(哪怕是别的键),
// 否则“scratch 标记”会变成一个客户端可写的项目字段。
if req.Settings != nil {
http.Error(w, "scratch host marker is server-owned", http.StatusBadRequest)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = store.ScratchDefaultProjectName
}
title := strings.TrimSpace(req.Title)
if title == "" {
title = "新会话"
}
// 与其它 create 路径一致:认证关闭(嵌入式档)走默认租户,认证开启时由
// store 的账号有效性边界拒绝停用账号——scratch 不是绕过账号状态的旁路。
project, conversation, err := s.st.CreateScratchSessionAccountChecked(
s.owner(r), name, title, strings.TrimSpace(req.ProfileID), s.Auth == nil || !s.Auth.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
// 状态码随同类创建入口(POST /api/projects、/api/projects/{id}/conversations):
// 200 配 writeJSON,而不是 201。
writeJSON(w, http.StatusOK, scratchSessionView{
Project: project,
Conversation: conversationView{Conversation: conversation, Scratch: true},
})
}
// ---- U2:隔离门禁 ----
//
// 临时会话是“只属于自己的通用代理会话”:没有知识库、没有项目上下文、不与任何
// 会话共享材料。这些门禁只认存储里的 host 标记(store.IsScratchProject),调用点
// 不自己比对标记字符串。
const (
// scratchNoKnowledge 知识库是项目域能力,临时会话一律没有。
scratchNoKnowledge = "scratch sessions have no knowledge base"
// scratchNoSettings 临时会话只有会话,没有可写的项目设置。
scratchNoSettings = "scratch sessions have no project settings"
// scratchNoCommand 项目域命令(RDM 及其它需要基座项目的命令)在临时会话不可用。
scratchNoCommand = "not available in scratch sessions"
)
// scratchProjectOf reports whether projectID is the caller's own scratch project.
// Other owners' projects (and unknown ids) fall through to the normal decision
// path, so the gate never leaks the existence of someone else's scratch session.
func (s *Server) scratchProjectOf(projectID, owner string) bool {
if s.st == nil {
return false
}
handled, err := s.st.ScratchProjectDecision(projectID, owner)
return handled && err == nil
}
// refuseScratchProjectCapability 门禁拒绝:只对调用者自己的临时会话生效。
func (s *Server) refuseScratchProjectCapability(w http.ResponseWriter, projectID, owner, message string) bool {
if !s.scratchProjectOf(projectID, owner) {
return false
}
http.Error(w, message, http.StatusBadRequest)
return true
}
// refuseScratchCommandHost 命令门禁(403):临时会话里不得分发需要基座项目的命令。
// rdm.* 全族与正式项目命令(cmdbus.RequiresProjectWorkDecision)都不行;内置的
// 会话类命令照旧可用。
func (s *Server) refuseScratchCommandHost(w http.ResponseWriter, hostProjectID, owner, commandID string) bool {
if !s.scratchProjectOf(hostProjectID, owner) {
return false
}
if !strings.HasPrefix(commandID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(commandID) {
return false
}
http.Error(w, scratchNoCommand, http.StatusForbidden)
return true
}
+311
View File
@@ -0,0 +1,311 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/store"
)
// 临时会话(U1)HTTP 契约:POST /api/scratch-sessions 对任何已启用账号内生可用,
// 不经 project.create;scratch 项目不进项目列表但会话仍出现在任务列表;1:1、
// 标记只归服务端、目录里没有可配置动作。
type scratchSessionHTTPView struct {
Project struct {
ID string `json:"id"`
Name string `json:"name"`
Settings map[string]any `json:"settings"`
} `json:"project"`
Conversation struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Title string `json:"title"`
CrossPrivate bool `json:"crossPrivate"`
AllowCrossRead bool `json:"allowCrossRead"`
} `json:"conversation"`
}
func decodeScratchSessionHTTP(t *testing.T, rec *httptest.ResponseRecorder) scratchSessionHTTPView {
t.Helper()
var view scratchSessionHTTPView
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String())
}
return view
}
func projectIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
var rows []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode projects: %v body=%s", err, rec.Body.String())
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.ID)
}
return out
}
func conversationIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
var rows []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String())
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.ID)
}
return out
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func createdProjectID(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var project struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &project); err != nil || project.ID == "" {
t.Fatalf("decode project: %v body=%s", err, rec.Body.String())
}
return project.ID
}
// scratchAccountKey 造一个没有密码的普通账号并签发 API key(与 role_authorization_test 同款)。
func scratchAccountKey(t *testing.T, st *store.Store, owner string) string {
t.Helper()
if err := st.CreateAccount(owner, owner, "", false); err != nil {
t.Fatal(err)
}
key, err := st.CreateAPIKey(owner, owner+"-scratch")
if err != nil {
t.Fatal(err)
}
return key
}
// TestScratchSessionNeedsNoProjectCreatePermission 是 U1 的核心契约:没有
// project.create 的已启用账号照样能建临时会话,而同一个账号仍然建不了普通项目。
// 状态码沿用同类创建入口的 200(writeJSON),不是 201。
func TestScratchSessionNeedsNoProjectCreatePermission(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key := scratchAccountKey(t, st, "engineer")
if allowed, _, reason := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); allowed || reason != "not-granted" {
t.Fatalf("fixture must not hold project.create: allowed=%v reason=%s", allowed, reason)
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key); rec.Code != http.StatusForbidden {
t.Fatalf("project created without project.create: %d %s", rec.Code, rec.Body.String())
}
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
if rec.Code != http.StatusOK {
t.Fatalf("scratch session denied without project.create: %d %s", rec.Code, rec.Body.String())
}
view := decodeScratchSessionHTTP(t, rec)
if !store.IsScratchProject(view.Project.Settings) || view.Project.ID == "" {
t.Fatalf("scratch project marker missing: %s", rec.Body.String())
}
if view.Project.Name == "" || view.Conversation.ID == "" || view.Conversation.ProjectID != view.Project.ID {
t.Fatalf("incomplete scratch session: %s", rec.Body.String())
}
if !view.Conversation.CrossPrivate || !view.Conversation.AllowCrossRead {
t.Fatalf("scratch conversation flags: %s", rec.Body.String())
}
// 项目列表里没有 scratch,会话列表与项目会话列表里都有它。
projects := bearerRequest(h, "GET", "/api/projects", "", key)
if projects.Code != http.StatusOK || containsString(projectIDsOf(t, projects), view.Project.ID) {
t.Fatalf("scratch project listed as a normal project: %d %s", projects.Code, projects.Body.String())
}
tasks := bearerRequest(h, "GET", "/api/conversations", "", key)
if tasks.Code != http.StatusOK || !containsString(conversationIDsOf(t, tasks), view.Conversation.ID) {
t.Fatalf("scratch conversation missing from task list: %d %s", tasks.Code, tasks.Body.String())
}
under := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
if under.Code != http.StatusOK || len(conversationIDsOf(t, under)) != 1 {
t.Fatalf("scratch project conversations: %d %s", under.Code, under.Body.String())
}
// 1:1:第二个会话被拒;普通项目下的会话创建不受影响。
second := bearerRequest(h, "POST", "/api/projects/"+view.Project.ID+"/conversations", `{"title":"第二个"}`, key)
if second.Code != http.StatusConflict || !strings.Contains(second.Body.String(), "scratch") {
t.Fatalf("second conversation under scratch project: %d %s", second.Code, second.Body.String())
}
after := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
if len(conversationIDsOf(t, after)) != 1 {
t.Fatalf("refused create still wrote a conversation: %s", after.Body.String())
}
normal, err := st.CreateProjectAccountChecked("engineer", "普通项目", false)
if err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "POST", "/api/projects/"+string(normal.ID)+"/conversations", `{"title":"普通会话"}`, key); rec.Code != http.StatusOK {
t.Fatalf("normal project lost conversation create: %d %s", rec.Code, rec.Body.String())
}
}
// TestScratchSessionCreateForProjectCreateHolder 覆盖另一侧:已经持有
// project.create 的账号走同一条内生路径,不需要额外的开关。
func TestScratchSessionCreateForProjectCreateHolder(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
key := scratchAccountKey(t, st, "creator")
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
if allowed, source, _ := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); !allowed || source != "role" {
t.Fatalf("fixture must hold project.create: allowed=%v source=%s", allowed, source)
}
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"name":"方案草稿","title":"临时会话"}`, key)
if rec.Code != http.StatusOK {
t.Fatalf("scratch session denied for project creator: %d %s", rec.Code, rec.Body.String())
}
view := decodeScratchSessionHTTP(t, rec)
if !store.IsScratchProject(view.Project.Settings) || view.Project.Name != "方案草稿" || view.Conversation.Title != "临时会话" {
t.Fatalf("scratch session body: %s", rec.Body.String())
}
// 显式空体同样可用(默认名)。
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", ``, key); rec.Code != http.StatusOK {
t.Fatalf("empty scratch body: %d %s", rec.Code, rec.Body.String())
}
}
// TestScratchSessionCreateRejectsDisabledAccount 停用账号既进不来(凭证被撤),
// 也不会绕过账号状态写出项目。
func TestScratchSessionCreateRejectsDisabledAccount(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
key := scratchAccountKey(t, st, "disabled")
account, err := st.GetAccountByOwner("disabled")
if err != nil {
t.Fatal(err)
}
state := do(h, "PUT", "/api/admin/users/"+account.ID+"/state",
`{"expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"enabled":false}`, admin)
if state.Code != http.StatusOK {
t.Fatalf("disable account: %d %s", state.Code, state.Body.String())
}
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
// 停用会消费已有凭证,所以请求在认证中间件就被拒(401);即使凭证仍在,
// store 的账号有效性边界也会返回 403——两条路径都不写项目。
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
t.Fatalf("disabled account scratch create: unexpected status %d %s", rec.Code, rec.Body.String())
}
if rec.Code == http.StatusUnauthorized && !strings.Contains(rec.Body.String(), "revoked") {
t.Fatalf("disabled account scratch create: %d %s", rec.Code, rec.Body.String())
}
if _, _, err := st.CreateScratchSessionAccountChecked("disabled", "临时会话", "临时会话", "", false); err == nil {
t.Fatal("disabled account reached the scratch create past the account fence")
}
if projects, err := st.ListProjects("disabled"); err != nil || len(projects) != 0 {
t.Fatalf("disabled account left a project row: %+v %v", projects, err)
}
}
// TestScratchMarkerIsServerOwned 客户端既不能在普通项目上加标记,也不能从
// scratch 项目上摘掉,更不能借 POST /api/projects 直接造一个 scratch。
func TestScratchMarkerIsServerOwned(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
key := scratchAccountKey(t, st, "creator")
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
normal := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)
if normal.Code != http.StatusOK {
t.Fatalf("create project: %d %s", normal.Code, normal.Body.String())
}
normalID := createdProjectID(t, normal)
scratch := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
if scratch.Code != http.StatusOK {
t.Fatalf("create scratch: %d %s", scratch.Code, scratch.Body.String())
}
scratchID := decodeScratchSessionHTTP(t, scratch).Project.ID
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("patch added the scratch marker: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "PATCH", "/api/projects/"+scratchID, `{"settings":{"host":{"kind":"normal"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("patch cleared the scratch marker: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"伪 scratch","settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("POST /api/projects created a scratch: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
t.Fatalf("scratch endpoint accepted client settings: %d %s", rec.Code, rec.Body.String())
}
// 不改标记的普通设置写入照旧放行。
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"note":"保留"}}`, key); rec.Code != http.StatusOK {
t.Fatalf("unrelated settings patch refused: %d %s", rec.Code, rec.Body.String())
}
// 被拒的写入没有落库:普通项目仍然不是 scratch。
stored, err := st.GetProject("creator", normalID)
if err != nil || store.IsScratchProject(stored.Settings) {
t.Fatalf("rejected patch changed the marker: %+v %v", stored.Settings, err)
}
}
// TestScratchIsNotAConfigurablePermission 权限目录与 replaceRolePermissions
// 都没有 scratch 创建动作:能力内生,不可授予也不可撤销。
func TestScratchIsNotAConfigurablePermission(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
viewerKey := scratchAccountKey(t, st, "viewer")
rec := do(h, "GET", "/api/admin/permissions/catalog", "", admin)
if rec.Code != http.StatusOK {
t.Fatalf("catalog: %d %s", rec.Code, rec.Body.String())
}
for _, descriptor := range catalog.Catalog() {
if strings.Contains(strings.ToLower(descriptor.ActionID), "scratch") {
t.Fatalf("catalog exposes a scratch action: %s", descriptor.ActionID)
}
}
if strings.Contains(rec.Body.String(), "scratch") {
t.Fatalf("catalog body mentions scratch: %s", rec.Body.String())
}
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"临时会话职位"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
for _, actionID := range []string{"project.scratch.create", "scratch.create", "scratch.session.create"} {
body := `{"expectedRoleVersion":1,"idempotencyKey":"scratch-action","changes":[{"actionId":"` + actionID + `","allowed":true,"capAllowed":true}]}`
if rec := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", body, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("role policy accepted %s: %d %s", actionID, rec.Code, rec.Body.String())
}
}
view := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
if strings.Contains(view.Body.String(), "scratch") {
t.Fatalf("role view mentions scratch: %s", view.Body.String())
}
me := bearerRequest(h, "GET", "/api/me", "", viewerKey)
if me.Code != http.StatusOK || strings.Contains(me.Body.String(), "scratch") {
t.Fatalf("me mentions a scratch capability: %d %s", me.Code, me.Body.String())
}
}
+160 -8
View File
@@ -19,6 +19,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
agentcommand "agent-command"
@@ -71,6 +72,11 @@ type Server struct {
set *settings.Service
plugins *plugins.Host
static fs.FS
// 会话语义的端口(A1/U-A1b):chat 入口折叠「动作可执行但槽位未就绪」的
// 挂起预期。nil = 按存储惰性装配(intentsession.New);组合根/测试可
// 注入替身,见 SetIntentSessionService。
sessionsMu sync.Mutex
sessions IntentSessions
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
@@ -140,6 +146,9 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action
// Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed
// 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。
func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool {
if s.sharedScratchDenied(actor, projectID) {
return false
}
if s.ProjectAuthzV2 == nil {
return s.authorizeSharedProject(actor, projectID)
}
@@ -164,10 +173,24 @@ func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
if s.ProjectAuthz == nil {
return false
}
if s.sharedScratchDenied(owner, projectID) {
return false
}
ok, err := s.ProjectAuthz(owner, projectID)
return err == nil && ok
}
// sharedScratchDenied 保证共享域授权永远打不开临时会话:临时会话是私有的
// 1:1 会话外壳,不是共享资源,EXT-8/EXT-8b 适配器不得为他人授予它。属主
// 不受影响(属主的读写按存储归属判定)。
func (s *Server) sharedScratchDenied(actor string, projectID core.ID) bool {
if s.st == nil || projectID == "" {
return false
}
handled, err := s.st.ScratchProjectDecision(string(projectID), actor)
return handled && err != nil
}
func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
sub, err := web.Dist()
if err != nil {
@@ -337,6 +360,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/capability/explain", s.explainCapability)
// ---- 项目 / 会话 ----
mux.HandleFunc("POST /api/scratch-sessions", s.createScratchSession)
mux.HandleFunc("GET /api/projects", s.listProjects)
mux.HandleFunc("POST /api/projects", s.createProject)
mux.HandleFunc("PATCH /api/projects/{id}", s.patchProject)
@@ -716,6 +740,10 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
}
out := make([]projectView, 0, len(ps))
for _, p := range ps {
// scratch 项目不作为普通项目出现(provider 列表同样过滤)。
if store.IsScratchProject(p.Settings) {
continue
}
out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner})
}
writeJSON(w, http.StatusOK, out)
@@ -754,12 +782,18 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Name string `json:"name"`
Name string `json:"name"`
Settings map[string]any `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
// 普通项目创建路径不能造 scratch:标记只由 POST /api/scratch-sessions 写。
if store.IsScratchProject(req.Settings) {
http.Error(w, "scratch sessions are created by POST /api/scratch-sessions", http.StatusBadRequest)
return
}
if s.projectLifecycle != nil {
if s.projectLifecycle.Create == nil {
writeProjectErr(w, store.ErrNotFound)
@@ -775,6 +809,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
owner := s.owner(r)
var req struct {
Name *string `json:"name"`
Settings map[string]any `json:"settings"`
@@ -783,6 +818,11 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 临时会话只有会话:除了改名没有任何项目设置可写(标记保护之外再加一道)。
if req.Settings != nil && s.scratchProjectOf(id, owner) {
http.Error(w, scratchNoSettings, http.StatusBadRequest)
return
}
if req.Settings != nil {
values, err := s.set.PrepareProjectMetadataWrite(req.Settings)
if err != nil {
@@ -867,6 +907,37 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
}
// conversationView 是下发给前端的会话视图:core.Conversation 原样透出,外加一个
// 只读的 scratch 标记,供前端把临时会话归到“无项目对话”,不用再从项目列表反推。
// core.Conversation 在另一个仓库,所以标记只存在于这份 server 本地视图里。
// 标记 omitempty:普通项目的会话 JSON 与加标记之前逐字节同形。
type conversationView struct {
core.Conversation
Scratch bool `json:"scratch,omitempty"`
}
// scratchProjectSet 读出 owner 的 scratch 项目 id 集合;读不到时按空集处理——
// 标记只是列表上的附加信息,取不到不改变列表本身(与详情里的辅助查询同款取舍)。
func (s *Server) scratchProjectSet(owner string) map[string]bool {
if s.st == nil {
return map[string]bool{}
}
ids, err := s.st.ScratchProjectIDs(owner)
if err != nil {
return map[string]bool{}
}
return ids
}
// conversationViews 给一批会话打 scratch 标记;返回的每一项与 store 的会话一一对应。
func (s *Server) conversationViews(scratch map[string]bool, cs []core.Conversation) []conversationView {
out := make([]conversationView, 0, len(cs))
for _, c := range cs {
out = append(out, conversationView{Conversation: c, Scratch: scratch[string(c.ProjectID)]})
}
return out
}
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
@@ -882,14 +953,15 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
return
}
cs, err := s.st.ListConversations(owner, r.PathValue("id"))
writeJSONOr(w, cs, err)
writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err)
}
// listTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档,?archived=1 含归档。
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query().Get("archived")
cs, err := s.st.ListTasks(s.owner(r), v == "1" || v == "true")
writeJSONOr(w, cs, err)
owner := s.owner(r)
cs, err := s.st.ListTasks(owner, v == "1" || v == "true")
writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err)
}
// me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。
@@ -955,8 +1027,9 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
resumeSeq = seq - 1
}
}
view := conversationView{Conversation: conv, Scratch: s.scratchProjectSet(owner)[string(conv.ProjectID)]}
writeJSON(w, http.StatusOK, map[string]any{
"conversation": conv, "messages": msgs, "runs": runs,
"conversation": view, "messages": msgs, "runs": runs,
"activeHitl": cards, "lastSeq": lastSeq, "resumeSeq": resumeSeq,
})
}
@@ -1164,8 +1237,21 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
var workflowDef core.WorkflowDef
var admission *intentAdmission
var clarify *clarifyPayload
var sessionNote *intentSessionNote
var cancelTarget *route.CancelTarget
var cancelRunID string
if s.Gate != nil && !strings.HasPrefix(text, "/") && req.TaskRef == nil {
recognized = s.recognize(r.Context(), text)
// 会话语义(A1/U-A1b):有挂起预期时先按它折叠本轮——补齐即按与
// 一次完整识别完全相同的准入与派发路径执行,仍缺则续挂并如实重新
// 澄清,取消则清理会话且不派发。无挂起预期时本段空转(行为不变)。
sessionKey := intentSessionKey(owner, conv)
fused, sessionOutcome, ferr := s.fuseIntentSession(sessionKey, recognized)
if ferr != nil {
writeStoreErr(w, ferr)
return
}
recognized = fused
dec := route.Decide(recognized, route.Env{
HasCommand: func(id string) bool { return s.Bus != nil && s.Bus.HasCommand(id) },
WorkflowFor: func(intentID string) (core.WorkflowDef, bool) {
@@ -1175,8 +1261,22 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
return s.Workflows.ByIntent(intentID)
},
// SquadReady 保持 false:per-run entry 覆盖未就位(见 route 包注释的已知边界)。
// SessionRun:取消靶子的会话来源(意图会话绑定的运行);没有绑定
// 时返回 false,路由层随即把「取消没有唯一靶子」交给宿主澄清。
SessionRun: func() (route.SessionRun, bool) { return s.sessionRunTarget(sessionKey) },
})
switch dec.Kind {
case route.KindCancel:
// 取消是既有动作(带明确对象):靶子唯一且对发起人可见才派发,
// 否则如实澄清(绝不猜一个)。本轮不启动新回合——取消与启动互斥。
// 会话层已经消费掉本轮 cancel(放弃补槽)时不再二次规划。
if sessionOutcome == nil || !sessionOutcome.cancelled {
runID, need := s.planIntentCancel(owner, conv, recognized, dec)
clarify = need
if need == nil {
routeKind, cancelTarget, cancelRunID = route.KindCancel, dec.Cancel, runID
}
}
case route.KindCommand:
// 接纳:按命令 DECLARED schema 把槽位映射成参数;准入未过
// (必填参数无法由原文+槽位满足)就不派发,降为澄清回合(F09)。
@@ -1196,6 +1296,22 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
default:
clarify = newClarifyPayload(recognized, dec, nil)
}
// 动作可执行但槽位未就绪:把这一轮记成会话里的挂起预期,下一轮
// 补齐即可按同一准入与派发路径执行(响应面不受影响)。
if err := s.suspendIntentSession(sessionKey, recognized, dec, admission); err != nil {
writeStoreErr(w, err)
return
}
if sessionOutcome != nil {
switch {
case sessionOutcome.clarify != nil:
// 仍缺槽位:如实按会话的累计状态重新澄清。
clarify = sessionOutcome.clarify
case sessionOutcome.cancelled:
// 取消挂起的补充:如实确认,不派发任何业务动作。
sessionNote = sessionOutcome.note
}
}
}
claimed := false
if routeKind == route.KindTurn || routeKind == route.KindWorkflow {
@@ -1227,6 +1343,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if routeKind == route.KindCancel {
// 取消意图落到既有取消路径(与 POST /api/runs/{id}/cancel 同款):
// 回执、确认卡与会话记录都在响应里如实给出;不启动新回合。
s.handleIntentCancel(w, r, conv, msg, cancelTarget, cancelRunID)
return
}
if routeKind == route.KindCommand {
// 参数按命令 DECLARED schema 映射(槽位→声明键/声明类型);原文恒供。
params := map[string]any{chatTextParam: text}
@@ -1277,6 +1399,11 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
// 澄清诉求(业务动作未派发):既有端点上的附加字段,缺失/非法各带结构化原因。
out["clarify"] = clarify
}
if sessionNote != nil {
// 会话语义的如实回话(取消挂起的补充):同样不新增 API 面,且从不
// 声称已派发任何业务动作。
out["intent_session"] = sessionNote
}
writeJSON(w, http.StatusOK, out)
}
@@ -1309,6 +1436,10 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
http.Error(w, "未知命令 "+slash+"(/help 查看)", http.StatusBadRequest)
return
}
// 临时会话里没有项目域:rdm.* 与正式项目命令一律 403。
if s.refuseScratchCommandHost(w, string(conv.ProjectID), s.owner(r), id) {
return
}
params, err := parseSlashParams(id, fields[1:])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -1392,6 +1523,10 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
}
}
}
// 基座项目是临时会话时,项目域命令一律 403(无会话上下文的命令不在此门)。
if s.refuseScratchCommandHost(w, hostProject, s.owner(r), commandID) {
return
}
dispatch := cmdbus.Request{
ID: commandID, Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
@@ -1441,8 +1576,7 @@ func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
// conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远
// 无法送达(同批实测)。
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "cancel", runID, 0,
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}, s.Auth == nil || !s.Auth.Enabled); cerr != nil {
if cerr := s.enqueueCancelCommand(s.owner(r), runID); cerr != nil {
writeStoreErr(w, cerr)
return
}
@@ -1732,7 +1866,9 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
}
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
if !s.DisableUploadIndexing {
// 临时会话只有会话:附件进入对话,但不进任何知识索引(否则会话材料会
// 漏进别的会话的检索结果)。
if !s.DisableUploadIndexing && !s.scratchProjectOf(projectID, owner) {
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
var indexErr error
if s.Indexer != nil {
@@ -2312,6 +2448,10 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
// 知识库是项目域能力:临时会话不接收任何文档入库。
if s.refuseScratchProjectCapability(w, r.PathValue("id"), owner, scratchNoKnowledge) {
return
}
for i := range docs {
docs[i].Owner = owner
docs[i].ProjectID = r.PathValue("id")
@@ -2331,6 +2471,10 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
owner := s.owner(r)
// 检索面同样先问门禁:临时会话没有知识库(而不是“搜出来是空的”)。
if s.refuseScratchProjectCapability(w, query.ProjectID, owner, scratchNoKnowledge) {
return
}
if s.projectLifecycle != nil && query.ProjectID != "" {
id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID)
if err != nil {
@@ -2438,6 +2582,14 @@ func writeOKOr(w http.ResponseWriter, err error) {
}
func writeStoreErr(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrScratchMarkerReserved) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, store.ErrScratchSessionSingle) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if errors.Is(err, store.ErrAccountForbidden) {
http.Error(w, err.Error(), http.StatusForbidden)
return
+166
View File
@@ -0,0 +1,166 @@
package store
// 意图会话(A1/U-A1b):会话维度挂起一条「可执行动作预期」——动作引用、
// 仍缺的槽位、已收集的槽位、单调 revision 与到期时间。宿主
// (internal/intentsession)在澄清回合后写入它,下一轮把用户的补充折叠
// 回去;槽位齐全时宿主按同一条准入与派发路径执行(授权与高危确认始终
// 由 cmdbus 收口——本表不携带任何权限字段)。
//
// 键是 (owner, project_id, conversation_id):两个用户、两个项目的会话在
// 构造上互不可见,读写一律带 owner 过滤。行是运行态(默认 30 分钟后
// 过期、读侧惰性删除),不进 storage-migrate 的 copyOrder(同
// admission_leases:进程/会话瞬态,迁移没有意义)。
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// IntentSlotQuestion 是一个仍缺的必填槽位及其建议提问。
type IntentSlotQuestion struct {
Key string `json:"key"`
Question string `json:"question,omitempty"`
}
// IntentExpectation 是挂起的可执行动作预期:等后续轮次补齐槽位。
// ActionKind/ActionID 是权威动作引用(command/workflow/blueprint/auto)。
type IntentExpectation struct {
IntentID string `json:"intent_id,omitempty"`
ActionKind string `json:"action_kind,omitempty"`
ActionID string `json:"action_id,omitempty"`
Missing []IntentSlotQuestion `json:"missing,omitempty"`
Question string `json:"question,omitempty"`
}
// IntentSession 是一个会话当前的意图会话状态。Expectation=nil 表示当前
// 没有挂起动作(例如用户已取消或动作已补齐),Slots 保留已收集值,
// Revision 单调递增用于挡住陈旧回答。
type IntentSession struct {
Owner string
ProjectID string
ConversationID string
Expectation *IntentExpectation
Slots map[string]string
Revision int
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// GetIntentSession 按 owner+project+conversation 读回意图会话。
// 没有行(别的用户/项目/会话、或尚未挂起)一律返回 ErrNotFound:
// 会话状态不跨用户可见,调用方不得据此猜测存在性。
func (s *Store) GetIntentSession(owner, projectID, conversationID string) (IntentSession, error) {
if owner == "" || conversationID == "" {
return IntentSession{}, ErrNotFound
}
row := s.db.QueryRow(s.q(`SELECT expectation,slots,revision,expires_at,created_at,updated_at
FROM intent_session WHERE owner=? AND project_id=? AND conversation_id=?`),
owner, projectID, conversationID)
return scanIntentSession(owner, projectID, conversationID, row)
}
// PutIntentSession 挂起一个动作预期并累计槽位(upsert,revision 每次 +1、
// 到期时间每次刷新)。expectation=nil 表示清空挂起动作(保留已收集槽位)。
// 返回写入后的会话(含新的 revision),调用方据此推进自己的视图。
func (s *Store) PutIntentSession(owner, projectID, conversationID string,
expectation *IntentExpectation, slots map[string]string, ttl time.Duration) (IntentSession, error) {
if owner == "" || conversationID == "" {
return IntentSession{}, errors.New("store: intent session key incomplete")
}
var rawExpectation any
if expectation != nil {
rawExpectation = marshalJSON(expectation)
}
if slots == nil {
slots = map[string]string{}
}
now := time.Now().UTC()
expiresAt := ""
if ttl > 0 {
expiresAt = now.Add(ttl).Format(time.RFC3339Nano)
}
if _, err := s.db.Exec(s.q(`INSERT INTO intent_session
(owner,project_id,conversation_id,expectation,slots,revision,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,1,?,?,?)
ON CONFLICT(owner,project_id,conversation_id) DO UPDATE SET
expectation=excluded.expectation, slots=excluded.slots,
revision=intent_session.revision+1, expires_at=excluded.expires_at,
updated_at=excluded.updated_at`),
owner, projectID, conversationID, rawExpectation, marshalJSON(slots),
expiresAt, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
return IntentSession{}, fmt.Errorf("put intent session: %w", err)
}
return s.GetIntentSession(owner, projectID, conversationID)
}
// ClearIntentSession 删除整行会话状态(幂等;动作已派发或会话结束时用)。
func (s *Store) ClearIntentSession(owner, projectID, conversationID string) error {
if owner == "" || conversationID == "" {
return nil
}
if _, err := s.db.Exec(s.q(`DELETE FROM intent_session WHERE owner=? AND project_id=? AND conversation_id=?`),
owner, projectID, conversationID); err != nil {
return fmt.Errorf("clear intent session: %w", err)
}
return nil
}
// ClearIntentSessionExpectation 只清空挂起的动作预期(用户取消):行保留
// 已收集槽位,revision +1 让任何在途的陈旧回答都作废。无行时返回
// ErrNotFound。
func (s *Store) ClearIntentSessionExpectation(owner, projectID, conversationID string) (IntentSession, error) {
if owner == "" || conversationID == "" {
return IntentSession{}, ErrNotFound
}
res, err := s.db.Exec(s.q(`UPDATE intent_session SET expectation=NULL, revision=revision+1, updated_at=?
WHERE owner=? AND project_id=? AND conversation_id=?`), nowISO(), owner, projectID, conversationID)
if err != nil {
return IntentSession{}, fmt.Errorf("clear intent session expectation: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return IntentSession{}, ErrNotFound
}
return s.GetIntentSession(owner, projectID, conversationID)
}
// scanIntentSession 组装一行会话状态;缺行一律 ErrNotFound。
func scanIntentSession(owner, projectID, conversationID string, row *sql.Row) (IntentSession, error) {
var rawExpectation sql.NullString
var rawSlots string
var revision int
var expiresAt, createdAt, updatedAt string
if err := row.Scan(&rawExpectation, &rawSlots, &revision, &expiresAt, &createdAt, &updatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return IntentSession{}, ErrNotFound
}
return IntentSession{}, err
}
out := IntentSession{
Owner: owner, ProjectID: projectID, ConversationID: conversationID,
Slots: map[string]string{}, Revision: revision,
}
if rawExpectation.Valid && strings.TrimSpace(rawExpectation.String) != "" {
exp, err := unmarshalJSON[IntentExpectation](rawExpectation.String)
if err != nil {
return IntentSession{}, fmt.Errorf("intent session expectation: %w", err)
}
out.Expectation = &exp
}
if strings.TrimSpace(rawSlots) != "" {
slots, err := unmarshalJSON[map[string]string](rawSlots)
if err != nil {
return IntentSession{}, fmt.Errorf("intent session slots: %w", err)
}
if slots != nil {
out.Slots = slots
}
}
out.ExpiresAt, _ = parseTime(expiresAt)
out.CreatedAt, _ = parseTime(createdAt)
out.UpdatedAt, _ = parseTime(updatedAt)
return out, nil
}
+186
View File
@@ -0,0 +1,186 @@
package store
import (
"errors"
"testing"
"time"
)
// 意图会话表(v52 / A1-U-A1b):键含 owner+project+conversation、revision
// 单调递增、到期时间随更新刷新;旧库(v51)是纯增量升级。
func cancelExpectation() *IntentExpectation {
return &IntentExpectation{
IntentID: "demo.run-cancel", ActionKind: "command", ActionID: "demo.run-cancel",
Missing: []IntentSlotQuestion{{Key: "run_id", Question: "取消哪个运行?"}},
Question: "取消哪个运行?",
}
}
func TestIntentSessionUpsertIncrementsRevisionAndRefreshesExpiry(t *testing.T) {
st := openTestStore(t)
exp := cancelExpectation()
first, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp, map[string]string{"run_id": "R-1"}, time.Minute)
if err != nil {
t.Fatal(err)
}
if first.Revision != 1 || first.Expectation == nil || first.Expectation.ActionID != "demo.run-cancel" {
t.Fatalf("首次挂起:%+v", first)
}
if first.Slots["run_id"] != "R-1" || len(first.Expectation.Missing) != 1 {
t.Fatalf("槽位/缺失未落库:%+v", first)
}
if !first.ExpiresAt.After(time.Now()) {
t.Fatalf("到期时间未设置:%+v", first)
}
// 同一会话再挂一次:revision +1、槽位累计、到期时间刷新。
second, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp,
map[string]string{"run_id": "R-1", "reason": "误触发"}, 2*time.Minute)
if err != nil {
t.Fatal(err)
}
if second.Revision != 2 {
t.Fatalf("revision 未递增:%+v", second)
}
if second.Slots["reason"] != "误触发" || second.CreatedAt != first.CreatedAt {
t.Fatalf("槽位累计/创建时间被改写:%+v", second)
}
if !second.ExpiresAt.After(first.ExpiresAt) {
t.Fatalf("到期时间未刷新:%v -> %v", first.ExpiresAt, second.ExpiresAt)
}
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
if err != nil || got.Revision != 2 || got.Expectation.Question != "取消哪个运行?" {
t.Fatalf("读回:%+v %v", got, err)
}
}
func TestIntentSessionClearKeepsNothingAndClearExpectationKeepsSlots(t *testing.T) {
st := openTestStore(t)
exp := cancelExpectation()
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp, map[string]string{"run_id": "R-1"}, time.Minute); err != nil {
t.Fatal(err)
}
// 取消:清空预期、保留已收集槽位、revision +1(陈旧回答就此作废)。
cleared, err := st.ClearIntentSessionExpectation("alice", "prj_1", "cnv_1")
if err != nil {
t.Fatal(err)
}
if cleared.Expectation != nil || cleared.Revision != 2 || cleared.Slots["run_id"] != "R-1" {
t.Fatalf("取消后的状态:%+v", cleared)
}
// 动作补齐/会话结束:整行清掉(幂等)。
if err := st.ClearIntentSession("alice", "prj_1", "cnv_1"); err != nil {
t.Fatal(err)
}
if err := st.ClearIntentSession("alice", "prj_1", "cnv_1"); err != nil {
t.Fatalf("重复清理必须幂等:%v", err)
}
if _, err := st.GetIntentSession("alice", "prj_1", "cnv_1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("清理后仍可读:%v", err)
}
if _, err := st.ClearIntentSessionExpectation("alice", "prj_1", "cnv_1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("无行时清理预期必须报 not found:%v", err)
}
}
// 两个用户与两个项目在构造上互不可见:别人的挂起读不到、写不进。
func TestIntentSessionIsolatesOwnersAndProjects(t *testing.T) {
st := openTestStore(t)
exp := cancelExpectation()
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp, map[string]string{"run_id": "R-alice"}, time.Minute); err != nil {
t.Fatal(err)
}
for _, probe := range []struct{ owner, project, conversation string }{
{"bob", "prj_1", "cnv_1"}, // 换用户
{"alice", "prj_2", "cnv_1"}, // 换项目
{"alice", "prj_1", "cnv_2"}, // 换会话
{"", "prj_1", "cnv_1"}, // 无归属
} {
if _, err := st.GetIntentSession(probe.owner, probe.project, probe.conversation); !errors.Is(err, ErrNotFound) {
t.Fatalf("%v 读到了别人的会话:%v", probe, err)
}
if err := st.ClearIntentSession(probe.owner, probe.project, probe.conversation); err != nil {
t.Fatalf("清理越权键:%v", err)
}
}
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
if err != nil || got.Slots["run_id"] != "R-alice" {
t.Fatalf("越权清理影响了属主会话:%+v %v", got, err)
}
// 同 owner 换项目写入互不覆盖。
if _, err := st.PutIntentSession("alice", "prj_2", "cnv_1", exp, nil, time.Minute); err != nil {
t.Fatal(err)
}
if a, _ := st.GetIntentSession("alice", "prj_1", "cnv_1"); a.Slots["run_id"] != "R-alice" {
t.Fatalf("另一项目的写入串了会话:%+v", a)
}
}
// intentSessionTableExists 双档目录探测(SQLite sqlite_master / PG information_schema)。
func intentSessionTableExists(t *testing.T, st *Store) bool {
t.Helper()
query := `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='intent_session'`
if st.Dialect() == "postgres" {
query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=current_schema() AND table_name='intent_session'`
}
var n int
if err := st.db.QueryRow(query).Scan(&n); err != nil {
t.Fatal(err)
}
return n > 0
}
// 旧库(v51,无 intent_session 表)重开即升级:无数据迁移,既有行不受影响。
func TestIntentSessionUpgradesLegacyDatabase(t *testing.T) {
st := openTestStore(t)
if err := st.MigrateDown(51); err != nil {
t.Fatalf("回滚到 v51:%v", err)
}
if intentSessionTableExists(t, st) {
t.Fatal("v51 库不应有 intent_session 表")
}
if err := st.migrate(); err != nil {
t.Fatalf("升级 v51 -> v52:%v", err)
}
assertFullLedger(t, st)
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", cancelExpectation(), map[string]string{"run_id": "R-1"}, time.Minute); err != nil {
t.Fatalf("升级后写入:%v", err)
}
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
if err != nil || got.Slots["run_id"] != "R-1" || got.Revision != 1 {
t.Fatalf("升级后读回:%+v %v", got, err)
}
// 回滚可重复演练(down 语句齐备)。
if err := st.MigrateDown(51); err != nil {
t.Fatalf("回滚 v52:%v", err)
}
if err := st.migrate(); err != nil {
t.Fatalf("再次升级:%v", err)
}
assertFullLedger(t, st)
}
// 空值约定:没有挂起动作时 expectation 为 NULL,已收集槽位仍是合法 JSON。
func TestIntentSessionNullableExpectationAndEmptySlots(t *testing.T) {
st := openTestStore(t)
if _, err := st.PutIntentSession("alice", "", "cnv_1", nil, nil, 0); err != nil {
t.Fatal(err)
}
got, err := st.GetIntentSession("alice", "", "cnv_1")
if err != nil {
t.Fatal(err)
}
if got.Expectation != nil || got.Slots == nil || len(got.Slots) != 0 {
t.Fatalf("空预期/空槽位:%+v", got)
}
if !got.ExpiresAt.IsZero() {
t.Fatalf("ttl<=0 时不应有过期时间:%+v", got)
}
if _, err := st.PutIntentSession("", "prj_1", "cnv_1", nil, nil, time.Minute); err == nil {
t.Fatal("无 owner 的写入必须被拒")
}
}
+29
View File
@@ -1225,9 +1225,38 @@ func init() {
"postgres": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`},
},
},
migration{version: 52, name: "intent_session",
// 意图会话(A1/U-A1b):会话维度挂起一条可执行动作预期 + 已收集
// 槽位 + 单调 revision + 到期时间。纯新表,既有库是空操作(新表
// 增量升级,无需数据迁移);运行态行随到期惰性清理。
up: map[string][]string{"sqlite": intentSessionUp, "postgres": intentSessionUp},
down: map[string][]string{"sqlite": intentSessionDown, "postgres": intentSessionDown},
},
)
}
// v52 意图会话表:键含 owner+project+conversation,两个用户的会话在构造上
// 互不可见。expectation 可空(NULL = 没有挂起动作,只剩已收集槽位),
// slots/revision/expires_at 是折叠与陈旧回答防护的宿主状态。
var intentSessionUp = []string{
`CREATE TABLE IF NOT EXISTS intent_session (
owner TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL,
expectation TEXT,
slots TEXT NOT NULL DEFAULT '{}',
revision INTEGER NOT NULL DEFAULT 0,
expires_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (owner, project_id, conversation_id)
)`,
}
var intentSessionDown = []string{
`DROP TABLE IF EXISTS intent_session`,
}
// v35 分层 Python 环境:扩展包申请与会话绑定。层目录本身在环境根,不入库。
var execEnvUp = []string{
`CREATE TABLE IF NOT EXISTS exec_env_requests (
+1
View File
@@ -58,6 +58,7 @@ var migrationBreaksOldReaders = map[int]bool{
49: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏
50: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变
51: true, // 超级管理员标记是账号权威边界;不认识它的旧账号写者必须排除
52: false, // intent_session 是新表;旧制品不读取会话内的意图状态
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+52 -17
View File
@@ -19,17 +19,34 @@ func (s *Store) CreateProject(owner, name string) (core.Project, error) {
return p, err
}
// ListProjects 不含 scratch 项目:临时会话不作为普通项目出现在列表里。
func (s *Store) ListProjects(owner string) ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE owner=? ORDER BY updated_at DESC`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProjects(rows)
all, err := scanProjects(rows)
if err != nil {
return nil, err
}
return withoutScratchProjects(all), nil
}
func withoutScratchProjects(in []core.Project) []core.Project {
out := make([]core.Project, 0, len(in))
for _, p := range in {
if IsScratchProject(p.Settings) {
continue
}
out = append(out, p)
}
return out
}
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
// 与 ListProjects 一致,scratch 项目不进入候选。
func (s *Store) ListAllProjects() ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
if err != nil {
@@ -48,6 +65,9 @@ func (s *Store) ListAllProjects() ([]core.Project, error) {
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
if IsScratchProject(p.Settings) {
continue
}
out = append(out, p)
}
return out, rows.Err()
@@ -91,11 +111,11 @@ func (s *Store) UpdateProjectSettings(owner, id string, values map[string]any) e
if err != nil {
return err
}
cur := map[string]any{}
if settings != "" && settings != "{}" {
cur, _ = unmarshalJSON[map[string]any](settings)
}
cur := decodeProjectSettings(settings)
merged := core.MergeSettings(cur, values)
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
return err
}
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?, updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, owner)
if err != nil {
return err
@@ -215,11 +235,11 @@ func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map
if err != nil {
return err
}
cur := map[string]any{}
if settings != "" && settings != "{}" {
cur, _ = unmarshalJSON[map[string]any](settings)
}
cur := decodeProjectSettings(settings)
merged := core.MergeSettings(cur, values)
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
return err
}
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, actor)
if err != nil {
return err
@@ -255,15 +275,18 @@ func scanProjects(rows *sql.Rows) ([]core.Project, error) {
// ---- 会话 ----
func (s *Store) CreateConversation(owner, projectID, title, profileID string) (core.Conversation, error) {
// 项目归属校验:会话必须落在请求者的项目下。
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&one)
// 项目归属校验:会话必须落在请求者的项目下;scratch 项目保持 1:1。
var settings string
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&settings)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
if err := refuseScratchConversation(settings); err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
@@ -273,14 +296,17 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
var settings string
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=?`), projectID).Scan(&settings)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
if err := refuseScratchConversation(settings); err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
@@ -438,8 +464,13 @@ func (s *Store) SetPermissionMode(owner, id, mode string) error {
return nil
}
// DeleteConversation deletes one conversation; deleting the sole conversation of
// a scratch project also removes that scratch project, so a scratch session is
// never left as an empty shell project.
func (s *Store) DeleteConversation(owner, id string) error {
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
return s.deleteConversationCascade(s.db, owner, id, func() error {
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
})
}
// withOwnedConversationWrite locks the actor before the conversation and
@@ -495,10 +526,14 @@ func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID str
})
}
// DeleteConversationAccountChecked retains the legacy owner-only delete rule.
// DeleteConversationAccountChecked deletes the conversation; when it was the
// sole conversation of a scratch project, that scratch project goes with it in
// the same write transaction, so a scratch session cannot be left half-deleted.
func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
return s.deleteConversationCascade(tx, actor, id, func() error {
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
})
})
}
+1 -1
View File
@@ -2252,7 +2252,7 @@ func (s *Store) upsertRolePermissionsTx(tx *sql.Tx, actorID, roleID string, curr
for _, row := range writes {
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed,
permission_version=permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
permission_version=role_permissions.permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
roleID, row.ActionID, row.Allowed, row.CapAllowed, 1, actorID, now); err != nil {
return err
}
+230
View File
@@ -0,0 +1,230 @@
package store
import (
"database/sql"
"errors"
"time"
core "agent-system-core"
)
// 临时会话(scratch session):一个 scratch 项目 + 恰好一个私有会话。
// scratch 属性只由项目 settings 的 host.kind 表达——它随项目行一起存储,
// 所以判定只需要一个读函数,任何调用点都不自己比对字符串。
const (
// ScratchHostKind 是 projects.settings.host.kind 的临时会话取值。
ScratchHostKind = "scratch"
// ScratchDefaultProjectName 是未指定名称时临时会话项目使用的默认名。
ScratchDefaultProjectName = "临时会话"
)
var (
// ErrScratchSessionSingle 会话与临时会话项目是 1:1:第二个会话被拒。
ErrScratchSessionSingle = errors.New("store: scratch session holds exactly one conversation")
// ErrScratchMarkerReserved 标记归服务端所有:客户端既不能给普通项目
// 加上 scratch,也不能从已有 scratch 项目上摘掉它。
ErrScratchMarkerReserved = errors.New("store: scratch host marker is server-owned")
)
// ScratchHostSettings 返回标记 scratch 项目的 settings 值。
func ScratchHostSettings() map[string]any {
return map[string]any{"host": map[string]any{"kind": ScratchHostKind}}
}
// IsScratchProject 报告 settings 是否带 scratch 宿主标记。已存储的
// projects.settings 与尚未合并的客户端 settings 都用它判定。
func IsScratchProject(settings map[string]any) bool {
host, ok := settings["host"].(map[string]any)
if !ok {
return false
}
kind, _ := host["kind"].(string)
return kind == ScratchHostKind
}
// ScratchProjectIDs 返回 owner 名下带 scratch 标记的项目 id 集合:一次查询读出
// 该 owner 的全部 projects.settings,逐行按 IsScratchProject 判定。列表响应要给
// 一批会话打标时走它,不必逐个会话回查项目;别人的 scratch 项目不在结果里。
func (s *Store) ScratchProjectIDs(owner string) (map[string]bool, error) {
rows, err := s.db.Query(s.q(`SELECT id,settings FROM projects WHERE owner=?`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var id, settings string
if err := rows.Scan(&id, &settings); err != nil {
return nil, err
}
if IsScratchProject(decodeProjectSettings(settings)) {
out[id] = true
}
}
return out, rows.Err()
}
// IsScratchProjectID 是内部可信判定:按 id 读 projects.settings 的 host 标记。
// 调用点(HTTP 门禁、回合门禁、命令分发)一律走它,不自己比对标记字符串。
func (s *Store) IsScratchProjectID(projectID string) (bool, error) {
if projectID == "" {
return false, nil
}
p, err := s.ProjectMetadata(projectID)
if err != nil {
return false, err
}
return IsScratchProject(p.Settings), nil
}
// ScratchProjectDecision 是临时会话的归属判定,回答“这个调用是否发生在某个
// 临时会话里”:
// - handled=false:不是临时会话(或项目读不到),调用点继续走原来的判定面;
// - handled=true, err=nil:调用者是该临时会话的属主,放行且不必再问 RDM;
// - handled=true, err=ErrNotFound:临时会话不是共享资源,其他人一律不可见。
//
// 判据只来自存储里的属主列与 host 标记,不接受调用方自称的身份。
func (s *Store) ScratchProjectDecision(projectID, actor string) (bool, error) {
if projectID == "" {
return false, nil
}
p, err := s.ProjectMetadata(projectID)
if err != nil {
// 读不到项目(不存在或存储异常):存在性与共享判定留给原路径,
// 这里不把“读不到”当成临时会话,也就不会放宽任何门禁。
return false, nil
}
if !IsScratchProject(p.Settings) {
return false, nil
}
if p.Owner != core.ID(actor) {
return true, ErrNotFound
}
return true, nil
}
// scratchQuerier 是本文件里临时会话判定可用的两种执行面(*sql.DB 与 *sql.Tx),
// 于是同一个事务内的级联删除与 store 级删除共用一份语义。
type scratchQuerier interface {
QueryRow(string, ...any) *sql.Row
Exec(string, ...any) (sql.Result, error)
}
// scratchProjectOfConversation 返回会话所属的 scratch 项目 id;普通项目返回空串。
func (s *Store) scratchProjectOfConversation(q scratchQuerier, owner, conversationID string) (string, error) {
var projectID, settings string
err := q.QueryRow(s.q(`SELECT c.project_id, COALESCE(p.settings,'') FROM conversations c LEFT JOIN projects p ON p.id=c.project_id WHERE c.id=? AND c.owner=?`),
conversationID, owner).Scan(&projectID, &settings)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if !IsScratchProject(decodeProjectSettings(settings)) {
return "", nil
}
return projectID, nil
}
// deleteScratchProjectIfEmpty 在会话已被删掉之后连带删掉它的临时会话项目:
// 临时会话项目只承载这一个会话,删会话即删临时会话(同一个执行面、同一个门禁)。
// 仍有会话时不动(1:1 由创建面保证,删除面不依赖该不变量做正确性);普通项目
// id 传进来也不会被删。
func (s *Store) deleteScratchProjectIfEmpty(q scratchQuerier, owner, projectID string) error {
if projectID == "" {
return nil
}
var remaining int
if err := q.QueryRow(s.q(`SELECT COUNT(1) FROM conversations WHERE project_id=?`), projectID).Scan(&remaining); err != nil {
return err
}
if remaining > 0 {
return nil
}
res, err := q.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), projectID, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// deleteConversationCascade 删除一个会话,并在它是临时会话唯一会话时连带删掉
// 临时会话项目。account-checked 路径在最终写事务内调用,legacy 路径在同一执行
// 面上调用:语义只有一份。
func (s *Store) deleteConversationCascade(q scratchQuerier, owner, id string, del func() error) error {
scratchProject, err := s.scratchProjectOfConversation(q, owner, id)
if err != nil {
return err
}
if err := del(); err != nil {
return err
}
return s.deleteScratchProjectIfEmpty(q, owner, scratchProject)
}
// decodeProjectSettings 把存储的 settings JSON 读成 map;缺省或损坏都是空表。
func decodeProjectSettings(raw string) map[string]any {
if raw == "" || raw == "{}" {
return map[string]any{}
}
cur, err := unmarshalJSON[map[string]any](raw)
if err != nil || cur == nil {
return map[string]any{}
}
return cur
}
// rejectScratchMarkerWrite 拒绝改变 scratch 标记的客户端写入。保持标记不变的
// 普通设置写入(例如给 scratch 项目改别的键)照旧通过。
func rejectScratchMarkerWrite(current, merged map[string]any) error {
if IsScratchProject(current) != IsScratchProject(merged) {
return ErrScratchMarkerReserved
}
return nil
}
// refuseScratchConversation 按存储的 projects.settings 判定 1:1:scratch 项目
// 下再建会话即拒,错误在会话插入之前返回。
func refuseScratchConversation(rawSettings string) error {
if IsScratchProject(decodeProjectSettings(rawSettings)) {
return ErrScratchSessionSingle
}
return nil
}
// CreateScratchSessionAccountChecked 在一个事务里建出 scratch 项目与它唯一的
// 会话:项目写入 host.kind 标记,会话写 cross_private=true。两者都发生在校验
// 过的 actor 行锁之下,所以临时会话与普通项目走同一条账号有效性边界。
// 调用方不需要(也不应)先调 CreateProjectAccountChecked——该路径不带标记,
// 无法表示临时会话。
func (s *Store) CreateScratchSessionAccountChecked(actor, name, title, profileID string, allowImplicitDefault bool) (core.Project, core.Conversation, error) {
now := time.Now().UTC()
project := core.Project{
ID: newID("prj"), Owner: core.ID(actor), Name: name,
CreatedAt: now, UpdatedAt: now, Settings: ScratchHostSettings(),
}
conversation := core.Conversation{
ID: newID("cnv"), ProjectID: project.ID, Owner: core.ID(actor), Title: title, ProfileID: profileID,
AllowCrossRead: true, CrossPrivate: true, CreatedAt: now, UpdatedAt: now,
}
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if _, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
project.ID, actor, project.Name, marshalJSON(project.Settings),
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
return err
}
_, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,cross_private,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`),
conversation.ID, conversation.ProjectID, actor, conversation.Title, conversation.ProfileID,
true, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
return err
})
if err != nil {
return core.Project{}, core.Conversation{}, err
}
return project, conversation, nil
}
+385
View File
@@ -0,0 +1,385 @@
package store
import (
"errors"
"testing"
)
// 临时会话 store 契约(U1):一个 scratch 项目 + 恰好一个私有会话,
// 项目列表不出现它,会话列表仍然出现它,标记不可被客户端写入改动。
func scratchAccountFixture(t *testing.T, st *Store, owner string, admin bool) {
t.Helper()
if err := st.CreateAccount(owner, owner, "", admin); err != nil {
t.Fatal(err)
}
}
func TestScratchSessionCreatesOnePrivateConversationUnderScratchProject(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatalf("create scratch session: %v", err)
}
if !IsScratchProject(project.Settings) {
t.Fatalf("project is not marked scratch: %+v", project.Settings)
}
if string(project.Owner) != "alice" || project.ID == "" {
t.Fatalf("unexpected project: %+v", project)
}
if string(conversation.ProjectID) != string(project.ID) || string(conversation.Owner) != "alice" {
t.Fatalf("conversation is not under the scratch project: %+v", conversation)
}
if !conversation.CrossPrivate || !conversation.AllowCrossRead {
t.Fatalf("scratch conversation must be private and cross-readable: %+v", conversation)
}
// 读回的项目行带着同一个标记,判定只有 IsScratchProject 一个来源。
stored, err := st.GetProject("alice", string(project.ID))
if err != nil || !IsScratchProject(stored.Settings) {
t.Fatalf("stored project marker: %+v %v", stored.Settings, err)
}
}
func TestScratchProjectsStayOutOfProjectListsButNotConversationLists(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
normal, err := st.CreateProject("alice", "普通项目")
if err != nil {
t.Fatal(err)
}
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// 他人的 scratch 也不进全量候选(共享适配器枚举)。
if _, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false); err != nil {
t.Fatal(err)
}
projects, err := st.ListProjects("alice")
if err != nil {
t.Fatal(err)
}
if len(projects) != 1 || projects[0].ID != normal.ID {
t.Fatalf("ListProjects must hide scratch projects: %+v", projects)
}
all, err := st.ListAllProjects()
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || all[0].ID != normal.ID {
t.Fatalf("ListAllProjects must hide scratch projects: %+v", all)
}
// 侧栏任务列表仍然要能看到临时会话。
tasks, err := st.ListTasks("alice", false)
if err != nil {
t.Fatal(err)
}
found := false
for _, task := range tasks {
if task.ID == conversation.ID {
found = true
}
}
if !found {
t.Fatalf("ListTasks dropped the scratch conversation: %+v", tasks)
}
conversations, err := st.ListConversations("alice", string(project.ID))
if err != nil || len(conversations) != 1 || conversations[0].ID != conversation.ID {
t.Fatalf("ListConversations under scratch: %+v %v", conversations, err)
}
}
func TestScratchProjectRefusesSecondConversationOnEveryStoreCreatePath(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// account-checked 路径由服务端共用的会话创建入口(createPrivateConversation,
// 覆盖 POST /api/projects/{id}/conversations 与 /new)先行拒绝,见
// internal/server/scratch_session_test.go;这里的 store 契约覆盖本文件内
// 的两个创建函数。
for name, create := range map[string]func() error{
"legacy": func() error {
_, err := st.CreateConversation("alice", string(project.ID), "第二个", "")
return err
},
"shared": func() error {
_, err := st.CreateConversationInProject("alice", string(project.ID), "第二个", "")
return err
},
} {
if err := create(); !errors.Is(err, ErrScratchSessionSingle) {
t.Fatalf("%s path allowed a second conversation: %v", name, err)
}
}
conversations, err := st.ListConversations("alice", string(project.ID))
if err != nil || len(conversations) != 1 {
t.Fatalf("refused create still wrote a row: %+v %v", conversations, err)
}
// 普通项目不受影响。
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "正常会话", "", false, true); err != nil {
t.Fatalf("normal project lost conversation create: %v", err)
}
}
func TestScratchHostMarkerIsServerOwned(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// 普通项目不能被加上标记(两种写入路径都不行)。
for name, write := range map[string]func(id string) error{
"legacy": func(id string) error {
return st.UpdateProjectSettings("alice", id, ScratchHostSettings())
},
"account-checked": func(id string) error {
return st.UpdateProjectSettingsAccountChecked("alice", id, ScratchHostSettings(), false)
},
} {
if err := write(string(normal.ID)); !errors.Is(err, ErrScratchMarkerReserved) {
t.Fatalf("%s path added the scratch marker: %v", name, err)
}
}
// scratch 项目也不能被摘掉标记。
cleared := map[string]any{"host": map[string]any{"kind": "normal"}}
if err := st.UpdateProjectSettings("alice", string(project.ID), cleared); !errors.Is(err, ErrScratchMarkerReserved) {
t.Fatalf("scratch marker cleared: %v", err)
}
if err := st.UpdateProjectSettingsAccountChecked("alice", string(project.ID), map[string]any{"host": nil}, false); !errors.Is(err, ErrScratchMarkerReserved) {
t.Fatalf("scratch marker dropped by null: %v", err)
}
// 不改变标记的普通写入照旧放行。
if err := st.UpdateProjectSettings("alice", string(project.ID), map[string]any{"note": "保留标记"}); err != nil {
t.Fatalf("unrelated settings write refused: %v", err)
}
kept, err := st.GetProject("alice", string(project.ID))
if err != nil || !IsScratchProject(kept.Settings) || kept.Settings["note"] != "保留标记" {
t.Fatalf("scratch project settings after unrelated write: %+v %v", kept.Settings, err)
}
if err := st.UpdateProjectSettings("alice", string(normal.ID), map[string]any{"note": "普通"}); err != nil {
t.Fatalf("normal project settings write refused: %v", err)
}
}
func TestScratchSessionCreateFollowsAccountState(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "admin", true)
scratchAccountFixture(t, st, "alice", false)
if _, _, err := st.CreateScratchSessionAccountChecked("missing", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("unknown actor created a scratch session: %v", err)
}
account, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled account created a scratch session: %v", err)
}
projects, err := st.ListProjects("alice")
if err != nil || len(projects) != 0 {
t.Fatalf("refused create left a project row: %+v %v", projects, err)
}
}
func TestScratchSessionTransactionLeavesNothingOnFailure(t *testing.T) {
st := openTestStore(t)
// 缺账号时事务回滚,项目与会话都不落库。
if _, _, err := st.CreateScratchSessionAccountChecked("ghost", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("ghost actor: %v", err)
}
all, err := st.ListAllProjects()
if err != nil || len(all) != 0 {
t.Fatalf("rolled back create left rows: %+v %v", all, err)
}
}
// ---- U2:隔离门禁的 store 契约 ----
// IsScratchProjectID / ScratchProjectDecision 是所有门禁唯一的判定来源:
// 属主放行,其他人 NotFound,普通项目不接管(交回原判定面)。
func TestScratchProjectLookupAndOwnershipDecision(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
projectID string
scratch bool
err error
}{
{"scratch", string(project.ID), true, nil},
{"normal", string(normal.ID), false, nil},
{"empty", "", false, nil},
{"missing", "prj_missing", false, ErrNotFound},
} {
got, err := st.IsScratchProjectID(tc.projectID)
if got != tc.scratch || !errors.Is(err, tc.err) {
t.Fatalf("%s: IsScratchProjectID = %v, %v (want %v, %v)", tc.name, got, err, tc.scratch, tc.err)
}
}
// 属主:门禁接管并放行(不查 RDM)。
handled, err := st.ScratchProjectDecision(string(project.ID), "alice")
if !handled || err != nil {
t.Fatalf("scratch owner decision = %v, %v", handled, err)
}
// 其他人:NotFound——临时会话不是共享资源。
if handled, err := st.ScratchProjectDecision(string(project.ID), "bob"); !handled || !errors.Is(err, ErrNotFound) {
t.Fatalf("scratch non-owner decision = %v, %v", handled, err)
}
// 普通项目与空项目 id 都不接管,原路径继续。
if handled, err := st.ScratchProjectDecision(string(normal.ID), "bob"); handled || err != nil {
t.Fatalf("normal project decision = %v, %v", handled, err)
}
if handled, err := st.ScratchProjectDecision("", "bob"); handled || err != nil {
t.Fatalf("empty project decision = %v, %v", handled, err)
}
// 读不到的项目不按临时会话处理(不因此放宽任何门禁)。
if handled, err := st.ScratchProjectDecision("prj_missing", "alice"); handled || err != nil {
t.Fatalf("missing project decision = %v, %v", handled, err)
}
_ = conversation
}
// ScratchProjectIDs 只报属主自己的 scratch 项目:列表响应靠它给会话打标,
// 所以普通项目与他人临时会话都不在里面。
func TestScratchProjectIDsCoverOnlyOwnScratchProjects(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
scratch, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
second, _, err := st.CreateScratchSessionAccountChecked("alice", "方案草稿", "方案草稿", "", false)
if err != nil {
t.Fatal(err)
}
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
other, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
ids, err := st.ScratchProjectIDs("alice")
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 || !ids[string(scratch.ID)] || !ids[string(second.ID)] {
t.Fatalf("alice scratch ids = %+v", ids)
}
if ids[string(normal.ID)] {
t.Fatalf("normal project marked scratch: %+v", ids)
}
// 租户边界:他人的临时会话不出现在 alice 的集合里。
if ids[string(other.ID)] {
t.Fatalf("other owner's scratch project leaked: %+v", ids)
}
others, err := st.ScratchProjectIDs("bob")
if err != nil {
t.Fatal(err)
}
if len(others) != 1 || !others[string(other.ID)] {
t.Fatalf("bob scratch ids = %+v", others)
}
// 没有项目的人拿到空集合而不是 nil 之外的东西(判空与遍历都安全)。
if empty, err := st.ScratchProjectIDs("carol"); err != nil || len(empty) != 0 {
t.Fatalf("empty scratch ids = %+v, %v", empty, err)
}
}
// 删掉临时会话的唯一会话即删掉它的 scratch 项目(不留空壳);普通项目照旧。
func TestDeletingScratchConversationDeletesScratchProject(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
scratchAccountFixture(t, st, "bob", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
// 他人删除:仍然 NotFound,且临时会话原样存在。
if err := st.DeleteConversationAccountChecked("bob", string(conversation.ID), false); !errors.Is(err, ErrNotFound) {
t.Fatalf("non-owner delete = %v", err)
}
if _, err := st.ProjectMetadata(string(project.ID)); err != nil {
t.Fatalf("non-owner delete removed the scratch project: %v", err)
}
if err := st.DeleteConversationAccountChecked("alice", string(conversation.ID), false); err != nil {
t.Fatalf("owner delete: %v", err)
}
if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) {
t.Fatalf("scratch project survived its only conversation: %v", err)
}
if scratch, err := st.IsScratchProjectID(string(project.ID)); scratch || !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted scratch project still answers as scratch: %v %v", scratch, err)
}
// 普通项目的会话删除不动项目。
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "会话", "", false, true)
if err != nil {
t.Fatal(err)
}
if err := st.DeleteConversationAccountChecked("alice", string(conv.ID), false); err != nil {
t.Fatal(err)
}
if _, err := st.ProjectMetadata(string(normal.ID)); err != nil {
t.Fatalf("normal project removed with its conversation: %v", err)
}
}
// legacy DeleteConversation 是同一条语义:删会话即删临时会话外壳。
func TestLegacyDeleteConversationCascadesScratchProject(t *testing.T) {
st := openTestStore(t)
scratchAccountFixture(t, st, "alice", false)
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
if err != nil {
t.Fatal(err)
}
if err := st.DeleteConversation("alice", string(conversation.ID)); err != nil {
t.Fatalf("legacy delete: %v", err)
}
if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) {
t.Fatalf("legacy delete left the scratch shell: %v", err)
}
}