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>
151 lines
5.5 KiB
Go
151 lines
5.5 KiB
Go
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)
|
||
}
|
||
}
|