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>
312 lines
12 KiB
Go
312 lines
12 KiB
Go
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")
|
|
}
|
|
}
|
|
|
|
// 门禁④:检索为空 —— @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)
|
|
}
|
|
}
|