Why: Accounts without project.create need isolated general-agent chats. The sidebar groups every scratch session under one "无项目对话" entry instead of the project switcher, and the client no longer auto-creates a default project. Evidence: npx vitest run (64 files, 677 tests) and npx tsc -b --noEmit pass locally (SELF); CI on the PR head is authoritative. Impact: createTask uses POST /api/scratch-sessions in scratch mode or when no real project exists; scratch rows hide cross-read toggles and project settings; "新建项目…" stays gated by project.create. Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md; agent-exec receipt u3-run5 (cursor, success) Co-authored-by: Cursor <cursoragent@cursor.com>
130 lines
6.5 KiB
TypeScript
130 lines
6.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { auth } from '../src/auth'
|
|
import { canCreateProject, store } from '../src/store'
|
|
import type { Conversation, Project } from '../src/contracts'
|
|
|
|
const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' })
|
|
const task = (id: string, projectId: string, scratch = false, updatedAt = ''): Conversation =>
|
|
({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) })
|
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
|
const detail = (conversation: Conversation) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
|
|
|
let projects: Project[]
|
|
let tasks: Conversation[]
|
|
let me: Record<string, unknown>
|
|
let requests: { path: string; init?: RequestInit }[]
|
|
let scratchCount: number
|
|
|
|
const posts = () => requests.filter(r => r.init?.method === 'POST').map(r => r.path)
|
|
|
|
beforeEach(async () => {
|
|
projects = []; tasks = []; requests = []; scratchCount = 0
|
|
me = { owner: 'bob', name: 'Bob', auth: true, systemActions: [] }
|
|
const saved = new Map<string, string>()
|
|
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
|
setItem: (key: string, value: string) => { saved.set(key, value) },
|
|
removeItem: (key: string) => { saved.delete(key) } })
|
|
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
|
requests.push({ path, init })
|
|
if (path === '/api/projects' && !init?.method) return response(projects)
|
|
if (path === '/api/conversations' && !init?.method) return response(tasks)
|
|
if (path === '/api/me') return response(me)
|
|
if (path === '/api/scratch-sessions' && init?.method === 'POST') {
|
|
const id = `scratch-${++scratchCount}`
|
|
const conversation = task(`${id}-conv`, id, true)
|
|
tasks = [conversation, ...tasks]
|
|
return response({ project: project(id), conversation })
|
|
}
|
|
const created = path.match(/^\/api\/projects\/([^/]+)\/conversations$/)
|
|
if (created && init?.method === 'POST') {
|
|
const conversation = task(`${created[1]}-new`, created[1])
|
|
tasks = [conversation, ...tasks]
|
|
return response(conversation)
|
|
}
|
|
const conv = path.match(/^\/api\/conversations\/([^/]+)$/)
|
|
if (conv && !init?.method) {
|
|
const found = tasks.find(t => t.id === conv[1])
|
|
return found ? detail(found) : response({ error: 'not found' }, 404)
|
|
}
|
|
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
|
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
|
|
if (path.endsWith('/cards')) return response({ cards: [] })
|
|
return response({})
|
|
})
|
|
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
|
auth.beginSession()
|
|
await store.refreshMe()
|
|
})
|
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
|
|
|
describe('scratch session entry', () => {
|
|
it('enters scratch mode for an account without project creation and no projects, and never creates a project', async () => {
|
|
await store.refreshProjects()
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, projectsLoaded: true, currentProjectId: null })
|
|
requests = []
|
|
await store.createTask()
|
|
expect(posts()).toEqual(['/api/scratch-sessions'])
|
|
expect(requests.some(r => r.path === '/api/projects' && r.init?.method === 'POST')).toBe(false)
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-1-conv', currentProjectId: 'scratch-1', projects: [] })
|
|
})
|
|
|
|
it('always creates a scratch session in scratch mode even when real projects exist', async () => {
|
|
projects = [project('host-a')]
|
|
await store.refreshProjects()
|
|
expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' })
|
|
await store.enterScratchMode()
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, currentProjectId: null, currentConvId: null })
|
|
requests = []
|
|
await store.createTask()
|
|
await store.createTask()
|
|
expect(posts()).toEqual(['/api/scratch-sessions', '/api/scratch-sessions'])
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-2-conv', currentProjectId: 'scratch-2' })
|
|
})
|
|
|
|
it('keeps creating project conversations in project mode', async () => {
|
|
projects = [project('host-a')]
|
|
await store.refreshProjects()
|
|
requests = []
|
|
await store.createTask()
|
|
expect(posts()).toEqual(['/api/projects/host-a/conversations'])
|
|
expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'host-a-new', currentProjectId: 'host-a' })
|
|
})
|
|
|
|
it('returns to the remembered scratch view on reload and opens the latest scratch task', async () => {
|
|
projects = [project('host-a')]
|
|
tasks = [task('old', 'scratch-o', true, '2026-01-01'), task('new', 'scratch-n', true, '2026-02-01'), task('work', 'host-a')]
|
|
await store.refreshProjects()
|
|
await store.enterScratchMode()
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' })
|
|
expect(store.getState().ui.lastScratch).toBe(true)
|
|
await store.refreshProjects()
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' })
|
|
await store.openProject('host-a')
|
|
expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' })
|
|
expect(store.getState().ui.lastScratch).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('scratch mode follows the opened task', () => {
|
|
it('sets scratch mode for a scratch task and clears it for a normal task', async () => {
|
|
projects = [project('host-a')]
|
|
tasks = [task('scratch-conv', 'scratch-1', true), task('work', 'host-a')]
|
|
await store.refreshProjects()
|
|
await store.openTask('scratch-conv')
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' })
|
|
expect(store.getState().ui.lastProjectId).toBe('host-a')
|
|
await store.openTask('work')
|
|
expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'work', currentProjectId: 'host-a' })
|
|
})
|
|
})
|
|
|
|
describe('canCreateProject', () => {
|
|
it('allows project creation without auth or with project.create only', () => {
|
|
expect(canCreateProject({ auth: false })).toBe(true)
|
|
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
|
|
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
|
|
expect(canCreateProject({ auth: true })).toBe(false)
|
|
expect(canCreateProject(null)).toBe(false)
|
|
})
|
|
})
|