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>
386 lines
15 KiB
Go
386 lines
15 KiB
Go
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)
|
||
}
|
||
}
|