/** * Unit tests for SessionService and Sessions API */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import * as fs from 'node:fs/promises' import { execFileSync } from 'node:child_process' import * as path from 'node:path' import * as os from 'node:os' import { SessionService, sessionService } from '../services/sessionService.js' import { getRepositoryContext, prepareSessionWorkspace, } from '../services/repositoryLaunchService.js' import { conversationService } from '../services/conversationService.js' import { clearCommandsCache } from '../../commands.js' import { sanitizePath } from '../../utils/sessionStoragePortable.js' // ============================================================================ // Test helpers // ============================================================================ let tmpDir: string let service: SessionService /** Create a temporary config dir and configure the service to use it. */ async function setupTmpConfigDir(): Promise { tmpDir = path.join(os.tmpdir(), `claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true }) process.env.CLAUDE_CONFIG_DIR = tmpDir return tmpDir } async function cleanupTmpDir(): Promise { if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }) } delete process.env.CLAUDE_CONFIG_DIR } function git(cwd: string, ...args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf8', }) } async function createCleanGitRepo(baseDir: string): Promise { const workDir = path.join( baseDir, `repo-${Date.now()}-${Math.random().toString(36).slice(2)}`, ) await fs.mkdir(workDir, { recursive: true }) git(workDir, 'init') git(workDir, 'config', 'user.email', 'sessions-api@example.com') git(workDir, 'config', 'user.name', 'Sessions API') git(workDir, 'checkout', '-b', 'main') await fs.writeFile(path.join(workDir, 'README.md'), 'main\n') git(workDir, 'add', 'README.md') git(workDir, 'commit', '-m', 'initial') git(workDir, 'checkout', '-b', 'feature/rail') await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n') git(workDir, 'add', 'feature.txt') git(workDir, 'commit', '-m', 'feature') git(workDir, 'checkout', 'main') return workDir } /** Write a JSONL session file with given entries. */ async function writeSessionFile( projectDir: string, sessionId: string, entries: Record[] ): Promise { const dir = path.join(tmpDir, 'projects', projectDir) await fs.mkdir(dir, { recursive: true }) const filePath = path.join(dir, `${sessionId}.jsonl`) const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n' await fs.writeFile(filePath, content, 'utf-8') return filePath } async function writeSubagentTranscriptFile( projectDir: string, sessionId: string, agentId: string, entries: Record[], ): Promise { const dir = path.join(tmpDir, 'projects', projectDir, sessionId, 'subagents') await fs.mkdir(dir, { recursive: true }) const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}` const filePath = path.join(dir, `${normalizedAgentId}.jsonl`) const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n' await fs.writeFile(filePath, content, 'utf-8') return filePath } async function writeSkill( rootDir: string, skillName: string, description: string, ): Promise { const skillDir = path.join(rootDir, skillName) await fs.mkdir(skillDir, { recursive: true }) await fs.writeFile( path.join(skillDir, 'SKILL.md'), ['---', `description: ${description}`, '---', '', `# ${skillName}`].join('\n'), 'utf-8', ) } function git(cwd: string, ...args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf8', }) } async function createWorkspaceApiGitRepo(baseDir: string): Promise { const workDir = path.join( baseDir, `workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`, ) await fs.mkdir(path.join(workDir, 'src'), { recursive: true }) git(workDir, 'init') git(workDir, 'config', 'user.email', 'sessions-api@example.com') git(workDir, 'config', 'user.name', 'Sessions API') await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\n') await fs.writeFile(path.join(workDir, 'src', 'app.ts'), 'export const answer = 42\n') git(workDir, 'add', 'tracked.txt', 'src/app.ts') git(workDir, 'commit', '-m', 'initial') await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\nafter\n') return workDir } async function createCleanGitRepo(baseDir: string): Promise { const workDir = path.join( baseDir, `repo-${Date.now()}-${Math.random().toString(36).slice(2)}`, ) await fs.mkdir(workDir, { recursive: true }) git(workDir, 'init') git(workDir, 'config', 'user.email', 'sessions-api@example.com') git(workDir, 'config', 'user.name', 'Sessions API') git(workDir, 'checkout', '-b', 'main') await fs.writeFile(path.join(workDir, 'README.md'), 'main\n') git(workDir, 'add', 'README.md') git(workDir, 'commit', '-m', 'initial') git(workDir, 'checkout', '-b', 'feature/rail') await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n') git(workDir, 'add', 'feature.txt') git(workDir, 'commit', '-m', 'feature') git(workDir, 'checkout', 'main') return workDir } // Sample entries matching real CLI format function makeSnapshotEntry(): Record { return { type: 'file-history-snapshot', messageId: crypto.randomUUID(), snapshot: { messageId: crypto.randomUUID(), trackedFileBackups: {}, timestamp: '2026-01-01T00:00:00.000Z', }, isSnapshotUpdate: false, } } function makeFileHistorySnapshotEntry( snapshotMessageId: string, trackedFileBackups: Record, ): Record { return { type: 'file-history-snapshot', messageId: crypto.randomUUID(), snapshot: { messageId: snapshotMessageId, trackedFileBackups, timestamp: '2026-01-01T00:00:00.000Z', }, isSnapshotUpdate: false, } } function makeUserEntry(content: string, uuid?: string): Record { return { parentUuid: null, isSidechain: false, type: 'user', message: { role: 'user', content }, uuid: uuid || crypto.randomUUID(), timestamp: '2026-01-01T00:01:00.000Z', userType: 'external', cwd: '/tmp/test', sessionId: 'test-session', } } function makeAssistantEntry(content: string, parentUuid?: string): Record { return { parentUuid: parentUuid || null, isSidechain: false, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: [{ type: 'text', text: content }], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:02:00.000Z', } } function makeAssistantToolUseEntry( toolUses: Array<{ id: string; name: string; input: Record }>, parentUuid?: string, ): Record { return { parentUuid: parentUuid || null, isSidechain: false, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: toolUses.map((toolUse) => ({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input, })), }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:02:00.000Z', } } function makeMetaUserEntry(): Record { return { parentUuid: null, isSidechain: false, type: 'user', message: { role: 'user', content: 'internal' }, isMeta: true, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:30.000Z', } } function makeSessionMetaEntry(workDir: string): Record { return { type: 'session-meta', isMeta: true, workDir, timestamp: '2026-01-01T00:00:00.000Z', } } function makeWorktreeStateEntry( sessionId: string, worktreePath: string, overrides: Record = {}, ): Record { return { type: 'worktree-state', sessionId, worktreeSession: { originalCwd: '/tmp/source', worktreePath, worktreeName: 'desktop-main-12345678', worktreeBranch: 'worktree-desktop-main-12345678', originalBranch: 'main', sessionId, ...overrides, }, } } async function writeFileHistoryBackup( sessionId: string, backupFileName: string, content: string, ): Promise { const dir = path.join(tmpDir, 'file-history', sessionId) await fs.mkdir(dir, { recursive: true }) await fs.writeFile(path.join(dir, backupFileName), content, 'utf-8') } type ThreeTurnCheckpointFixture = { sessionId: string workDir: string stepFile: string createdFile: string firstUserId: string secondUserId: string thirdUserId: string } async function createThreeTurnCheckpointFixture( sessionId: string, ): Promise { const workDir = path.join(tmpDir, `turn-checkpoints-${sessionId}`) const stepFile = path.join(workDir, 'src', 'step.js') const createdFile = path.join(workDir, 'notes', 'generated.txt') const firstUserId = crypto.randomUUID() const secondUserId = crypto.randomUUID() const thirdUserId = crypto.randomUUID() const backupBase = `${sessionId}-step@v1` const backupV1 = `${sessionId}-step@v2` const backupV2 = `${sessionId}-step@v3` await fs.mkdir(path.dirname(stepFile), { recursive: true }) await fs.mkdir(path.dirname(createdFile), { recursive: true }) await fs.writeFile(stepFile, "export const STEP = 'v3'\n", 'utf-8') await fs.writeFile(createdFile, 'generated third turn\n', 'utf-8') await writeFileHistoryBackup(sessionId, backupBase, "export const STEP = 'base'\n") await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'v1'\n") await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v2'\n") await writeSessionFile('-tmp-api-turn-checkpoints', sessionId, [ makeSessionMetaEntry(workDir), makeFileHistorySnapshotEntry(firstUserId, { 'src/step.js': { backupFileName: backupBase, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make v1', firstUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE v1', firstUserId), makeFileHistorySnapshotEntry(secondUserId, { 'src/step.js': { backupFileName: backupV1, version: 2, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make v2', secondUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE v2', secondUserId), makeFileHistorySnapshotEntry(thirdUserId, { 'src/step.js': { backupFileName: backupV2, version: 3, backupTime: '2026-01-01T00:00:00.000Z', }, 'notes/generated.txt': { backupFileName: null, version: 3, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make v3 and create file', thirdUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE v3', thirdUserId), ]) return { sessionId, workDir, stepFile, createdFile, firstUserId, secondUserId, thirdUserId, } } // ============================================================================ // SessionService tests // ============================================================================ describe('SessionService', () => { beforeEach(async () => { await setupTmpConfigDir() service = new SessionService() }) afterEach(async () => { clearCommandsCache() await cleanupTmpDir() }) // -------------------------------------------------------------------------- // listSessions // -------------------------------------------------------------------------- it('should return empty list when no sessions exist', async () => { const result = await service.listSessions() expect(result.sessions).toEqual([]) expect(result.total).toBe(0) }) it('should list sessions from JSONL files', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-testproject', sessionId, [ makeSnapshotEntry(), makeUserEntry('Hello Claude'), makeAssistantEntry('Hi there!'), ]) const result = await service.listSessions() expect(result.total).toBe(1) expect(result.sessions).toHaveLength(1) const session = result.sessions[0]! expect(session.id).toBe(sessionId) expect(session.title).toBe('Hello Claude') expect(session.messageCount).toBe(2) // 1 user + 1 assistant expect(session.projectPath).toBe('-tmp-testproject') }) it('should paginate results with limit and offset', async () => { // Create 3 sessions for (let i = 0; i < 3; i++) { const id = `0000000${i}-bbbb-cccc-dddd-eeeeeeeeeeee` await writeSessionFile('-tmp-test', id, [ makeSnapshotEntry(), makeUserEntry(`Message ${i}`), ]) } const page1 = await service.listSessions({ limit: 2, offset: 0 }) expect(page1.total).toBe(3) expect(page1.sessions).toHaveLength(2) const page2 = await service.listSessions({ limit: 2, offset: 2 }) expect(page2.total).toBe(3) expect(page2.sessions).toHaveLength(1) }) it('should only parse the requested page when listing many sessions', async () => { for (let i = 0; i < 12; i++) { const id = `1000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee` const filePath = await writeSessionFile('-tmp-many-sessions', id, [ makeSnapshotEntry(), makeUserEntry(`Message ${i}`), ]) const mtime = new Date(Date.now() - i * 1000) await fs.utimes(filePath, mtime, mtime) } const serviceWithSpy = service as unknown as { readJsonlFile: (...args: unknown[]) => Promise } const originalReadJsonlFile = serviceWithSpy.readJsonlFile.bind(service) let readCount = 0 serviceWithSpy.readJsonlFile = async (...args) => { readCount += 1 return originalReadJsonlFile(...args) } const result = await service.listSessions({ limit: 3, offset: 0 }) expect(result.total).toBe(12) expect(result.sessions).toHaveLength(3) expect(readCount).toBe(3) }) it('should filter sessions by project', async () => { const id1 = 'aaaaaaaa-1111-cccc-dddd-eeeeeeeeeeee' const id2 = 'aaaaaaaa-2222-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-project-a', id1, [makeSnapshotEntry(), makeUserEntry('In A')]) await writeSessionFile('-project-b', id2, [makeSnapshotEntry(), makeUserEntry('In B')]) const resultA = await service.listSessions({ project: '/project/a' }) expect(resultA.total).toBe(1) expect(resultA.sessions[0]!.id).toBe(id1) }) // -------------------------------------------------------------------------- // getSession // -------------------------------------------------------------------------- it('should return null for non-existent session', async () => { const result = await service.getSession('00000000-0000-0000-0000-000000000000') expect(result).toBeNull() }) it('should return session detail with messages', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const userUuid = crypto.randomUUID() await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry('Tell me a joke', userUuid), makeAssistantEntry('Why did the chicken cross the road?', userUuid), ]) const detail = await service.getSession(sessionId) expect(detail).not.toBeNull() expect(detail!.id).toBe(sessionId) expect(detail!.title).toBe('Tell me a joke') expect(detail!.messages).toHaveLength(2) expect(detail!.messages[0]!.type).toBe('user') expect(detail!.messages[1]!.type).toBe('assistant') }) it('should skip meta entries in messages', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeMetaUserEntry(), makeUserEntry('Real message'), ]) const detail = await service.getSession(sessionId) expect(detail!.messages).toHaveLength(1) expect(detail!.messages[0]!.content).toBe('Real message') }) // -------------------------------------------------------------------------- // getSessionMessages // -------------------------------------------------------------------------- it('should throw for non-existent session messages', async () => { expect( service.getSessionMessages('00000000-0000-0000-0000-000000000000') ).rejects.toThrow('Session not found') }) it('should return messages only', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry('Hello'), makeAssistantEntry('World'), ]) const messages = await service.getSessionMessages(sessionId) expect(messages).toHaveLength(2) }) it('should append subagent tool calls under their parent agent tool result', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const projectDir = '-tmp-project' const agentId = 'abc123' await writeSessionFile(projectDir, sessionId, [ makeSnapshotEntry(), makeUserEntry('Dispatch an agent'), { type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 'Agent:0', name: 'Agent', input: { description: 'Inspect alpha' }, }, ], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:02.000Z', }, { type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'Agent:0', content: [ { type: 'text', text: `alpha summary\nagentId: ${agentId} (use SendMessage with to: '${agentId}' to continue this agent)\ntotal_tokens: 10\ntool_uses: 2\nduration_ms: 30`, }, ], }, ], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:03.000Z', }, ]) await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [ { type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 'Read:0', name: 'Read', input: { file_path: '/tmp/alpha.txt' }, }, ], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:04.000Z', }, { type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'Read:0', content: 'alpha body', }, ], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:05.000Z', }, ]) const messages = await service.getSessionMessages(sessionId) const childToolUse = messages.find( (message) => message.type === 'tool_use' && message.parentToolUseId === 'Agent:0', ) const childToolResult = messages.find( (message) => message.type === 'tool_result' && message.parentToolUseId === 'Agent:0', ) expect(childToolUse?.content).toEqual([ { type: 'tool_use', id: 'Agent:0/abc123/Read:0', name: 'Read', input: { file_path: '/tmp/alpha.txt' }, }, ]) expect(childToolResult?.content).toEqual([ { type: 'tool_result', tool_use_id: 'Agent:0/abc123/Read:0', content: 'alpha body', }, ]) }) it('should hide synthetic interruption, no-response, and command breadcrumb transcript entries', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry('正常用户消息', crypto.randomUUID()), { type: 'user', message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:02.000Z', }, { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'No response requested.' }], model: '', }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:03.000Z', }, { type: 'user', message: { role: 'user', content: '/exit\nexit\n', }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:04.000Z', }, makeAssistantEntry('正常助手消息', crypto.randomUUID()), ]) const messages = await service.getSessionMessages(sessionId) expect(messages).toHaveLength(2) expect(messages[0]).toMatchObject({ type: 'user', content: '正常用户消息' }) expect(messages[1]).toMatchObject({ type: 'assistant', content: [{ type: 'text', text: '正常助手消息' }], }) }) it('should hide task-notification turns and their automatic responses from history', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const firstUserId = crypto.randomUUID() const firstAssistantId = crypto.randomUUID() const taskNotificationId = crypto.randomUUID() const taskAssistantId = crypto.randomUUID() const taskToolUseMessageId = crypto.randomUUID() const taskToolResultId = crypto.randomUUID() const taskAfterToolId = crypto.randomUUID() const realFollowUpId = crypto.randomUUID() const realAssistantId = crypto.randomUUID() await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), { ...makeUserEntry('创建一个项目', firstUserId), parentUuid: null, }, { ...makeAssistantEntry('项目已经创建', firstUserId), uuid: firstAssistantId, }, { ...makeUserEntry( '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n', taskNotificationId, ), parentUuid: firstAssistantId, }, { ...makeAssistantEntry('旧后台任务通知,无需处理', taskNotificationId), uuid: taskAssistantId, }, { ...makeAssistantToolUseEntry([{ id: 'toolu_restart', name: 'Bash', input: { command: 'npm run dev' }, }], taskAssistantId), uuid: taskToolUseMessageId, }, { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_restart', content: 'server restarted', }], }, uuid: taskToolResultId, parentUuid: taskToolUseMessageId, timestamp: '2026-01-01T00:03:00.000Z', }, { ...makeAssistantEntry('后台任务触发的工具调用完成', taskToolResultId), uuid: taskAfterToolId, }, { ...makeUserEntry('继续真实问题', realFollowUpId), parentUuid: taskAfterToolId, }, { ...makeAssistantEntry('真实回答', realFollowUpId), uuid: realAssistantId, }, ]) const messages = await service.getSessionMessages(sessionId) const taskNotifications = await service.getSessionTaskNotifications(sessionId) expect(messages.map((message) => message.id)).toEqual([ firstUserId, firstAssistantId, realFollowUpId, realAssistantId, ]) expect(JSON.stringify(messages)).not.toContain('') expect(JSON.stringify(messages)).not.toContain('旧后台任务通知') expect(JSON.stringify(messages)).not.toContain('server restarted') expect(JSON.stringify(messages)).not.toContain('后台任务触发的工具调用完成') expect(taskNotifications).toEqual([ { taskId: 'bg-1', toolUseId: 'toolu_bg', status: 'completed', summary: 'Background command completed', }, ]) }) it('should reconstruct parent agent tool linkage from parentUuid chains', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const userUuid = crypto.randomUUID() const agentAssistantUuid = crypto.randomUUID() const childAssistantUuid = crypto.randomUUID() await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry('Inspect the codebase', userUuid), { parentUuid: userUuid, isSidechain: false, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: [ { type: 'tool_use', name: 'Agent', id: 'agent-tool-1', input: { description: 'Inspect src/components' }, }, ], }, uuid: agentAssistantUuid, timestamp: '2026-01-01T00:02:00.000Z', }, { parentUuid: agentAssistantUuid, isSidechain: true, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: [ { type: 'tool_use', name: 'Read', id: 'read-tool-1', input: { file_path: 'src/components/App.tsx' }, }, ], }, uuid: childAssistantUuid, timestamp: '2026-01-01T00:02:30.000Z', }, { parentUuid: childAssistantUuid, isSidechain: true, type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'read-tool-1', content: 'ok', is_error: false, }, ], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:03:00.000Z', userType: 'external', cwd: '/tmp/test', sessionId: 'test-session', }, ]) const messages = await service.getSessionMessages(sessionId) expect(messages[1]).toMatchObject({ type: 'tool_use', parentToolUseId: undefined, }) expect(messages[2]).toMatchObject({ type: 'tool_use', parentToolUseId: 'agent-tool-1', }) expect(messages[3]).toMatchObject({ type: 'tool_result', parentToolUseId: 'agent-tool-1', }) }) it('should recover workDir from session-meta entries', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry('/tmp/from-meta'), makeUserEntry('Hello'), ]) const workDir = await service.getSessionWorkDir(sessionId) expect(workDir).toBe('/tmp/from-meta') }) it('should recover workDir from the latest session-meta entry', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry('/tmp/old-worktree'), makeUserEntry('Hello'), makeSessionMetaEntry('/tmp/latest-worktree'), ]) const workDir = await service.getSessionWorkDir(sessionId) expect(workDir).toBe('/tmp/latest-worktree') }) it('should prefer the newest duplicate session file when worktree metadata moves', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sourceFile = await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry('/tmp/project'), ]) const worktreeFile = await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry('/tmp/project/.claude/worktrees/desktop-main-12345678'), ]) const oldTime = new Date('2026-01-01T00:00:00.000Z') const newTime = new Date('2026-01-01T00:00:01.000Z') await fs.utimes(sourceFile, oldTime, oldTime) await fs.utimes(worktreeFile, newTime, newTime) const workDir = await service.getSessionWorkDir(sessionId) expect(workDir).toBe('/tmp/project/.claude/worktrees/desktop-main-12345678') }) it('should recover CLI worktree state from transcript metadata', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry('/tmp/project/.claude/worktrees/desktop-main-12345678'), makeWorktreeStateEntry(sessionId, '/tmp/project/.claude/worktrees/desktop-main-12345678', { originalCwd: '/tmp/project', }), makeUserEntry('Hello from CLI worktree'), ]) const launchInfo = await service.getSessionLaunchInfo(sessionId) expect(launchInfo?.worktreeSession).toMatchObject({ originalCwd: '/tmp/project', worktreePath: '/tmp/project/.claude/worktrees/desktop-main-12345678', worktreeName: 'desktop-main-12345678', worktreeBranch: 'worktree-desktop-main-12345678', originalBranch: 'main', }) }) it('should preserve repository metadata when replacing placeholder transcripts', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId, workDir: sessionWorkDir } = await service.createSession( workDir, { branch: 'feature/rail', worktree: true }, ) await service.clearSessionTranscript(sessionId, sessionWorkDir) const launchInfo = await service.getSessionLaunchInfo(sessionId) expect(launchInfo?.workDir).toBe(sessionWorkDir) expect(launchInfo?.repository).toMatchObject({ requestedWorkDir: await fs.realpath(workDir), worktree: true, worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')), }) }) it('should remove stale placeholder files after native CLI worktree startup', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sourceFile = await writeSessionFile('-tmp-source', sessionId, [ makeSnapshotEntry(), { type: 'session-meta', isMeta: true, workDir: '/tmp/source', timestamp: '2026-01-01T00:00:00.000Z' }, { type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:02.000Z' }, ]) const worktreeFile = await writeSessionFile('-tmp-source--claude-worktrees-desktop-agent', sessionId, [ makeSnapshotEntry(), { type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:01.000Z' }, makeUserEntry('Hello from worktree'), ]) const removed = await service.deletePlaceholderSessionFiles( sessionId, '/tmp/source/.claude/worktrees/desktop-agent', ) expect(removed).toBe(1) await expect(fs.access(sourceFile)).rejects.toThrow() await expect(fs.access(worktreeFile)).resolves.toBeNull() }) it('should move repository metadata to the CLI worktree transcript before deleting placeholders', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId } = await service.createSession( workDir, { branch: 'main', worktree: true }, ) const initialLaunchInfo = await service.getSessionLaunchInfo(sessionId) const worktreePath = initialLaunchInfo?.repository?.worktreePath expect(worktreePath).toBeTruthy() const worktreeFile = await writeSessionFile(sanitizePath(worktreePath!), sessionId, [ makeSnapshotEntry(), { type: 'system', subtype: 'init', cwd: worktreePath, timestamp: '2026-01-01T00:00:01.000Z', }, makeUserEntry('Hello from worktree'), ]) await service.appendSessionMetadata(sessionId, { workDir: worktreePath!, }) const removed = await service.deletePlaceholderSessionFiles(sessionId, worktreePath!) const launchInfo = await service.getSessionLaunchInfo(sessionId) expect(removed).toBe(1) await expect(fs.access(worktreeFile)).resolves.toBeNull() expect(launchInfo?.workDir).toBe(worktreePath) expect(launchInfo?.repository).toMatchObject({ requestedWorkDir: await fs.realpath(workDir), branch: 'main', worktree: true, worktreePath, worktreeSlug: initialLaunchInfo?.repository?.worktreeSlug, }) }) it('should recover workDir from transcript cwd when session-meta is missing', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), { ...makeUserEntry('Hello'), cwd: '/tmp/from-cwd', }, ]) const workDir = await service.getSessionWorkDir(sessionId) expect(workDir).toBe('/tmp/from-cwd') }) // -------------------------------------------------------------------------- // createSession // -------------------------------------------------------------------------- it('should create a new session file', async () => { const workDir = path.join(tmpDir, 'workspace', 'my-project') await fs.mkdir(workDir, { recursive: true }) const { sessionId } = await service.createSession(workDir) expect(sessionId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ ) // Verify the file was created const canonicalWorkDir = await fs.realpath(workDir) const sanitized = sanitizePath(canonicalWorkDir) const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`) const stat = await fs.stat(filePath) expect(stat.isFile()).toBe(true) // Verify the file starts with the initial snapshot entry const content = await fs.readFile(filePath, 'utf-8') const entry = JSON.parse(content.trim().split('\n')[0]!) expect(entry.type).toBe('file-history-snapshot') }) it('should defer isolated worktree creation until CLI startup', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId, workDir: sessionWorkDir } = await service.createSession( workDir, { branch: 'feature/rail', worktree: true }, ) expect(sessionWorkDir).toBe(await fs.realpath(workDir)) expect(git(workDir, 'branch', '--show-current')).toBe('main\n') expect(git(workDir, 'status', '--porcelain')).toBe('') const sanitized = sanitizePath(await fs.realpath(workDir)) const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`) const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n') const metadata = JSON.parse(lines[1]!) const plannedWorktreePath = metadata.repository.worktreePath as string expect(metadata.workDir).toBe(await fs.realpath(workDir)) expect(metadata.repository).toMatchObject({ requestedWorkDir: await fs.realpath(workDir), branch: 'feature/rail', worktree: true, baseRef: 'feature/rail', worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')), worktreeBranch: expect.stringContaining('worktree-desktop-feature-rail-'), worktreeSlug: expect.stringContaining('desktop-feature-rail-'), }) await expect(fs.access(plannedWorktreePath)).rejects.toThrow() const context = await getRepositoryContext(workDir) expect(context.state).toBe('ok') expect(context.branches.map((branch) => branch.name)).not.toContain( path.basename(plannedWorktreePath).replace(/^desktop-/, 'worktree-desktop-'), ) expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false) }) it('should defer direct branch switching until CLI startup when worktree isolation is disabled', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId, workDir: sessionWorkDir } = await service.createSession( workDir, { branch: 'feature/rail', worktree: false }, ) expect(sessionWorkDir).toBe(await fs.realpath(workDir)) expect(git(workDir, 'branch', '--show-current')).toBe('main\n') const sanitized = sanitizePath(await fs.realpath(workDir)) const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`) const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n') const metadata = JSON.parse(lines[1]!) expect(metadata.workDir).toBe(await fs.realpath(workDir)) expect(metadata.repository).toMatchObject({ requestedWorkDir: await fs.realpath(workDir), branch: 'feature/rail', worktree: false, baseRef: 'feature/rail', }) }) it('should not list hidden desktop worktree branches', async () => { const workDir = await createCleanGitRepo(tmpDir) const existingWorktree = path.join(tmpDir, `desktop-hidden-${Date.now()}`) git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-hidden', existingWorktree, 'feature/rail') expect(git(existingWorktree, 'branch', '--show-current')).toBe('worktree-desktop-hidden\n') const context = await getRepositoryContext(existingWorktree) expect(context.state).toBe('ok') expect(context.currentBranch).toBe('worktree-desktop-hidden') expect(context.branches.some((branch) => branch.name === context.currentBranch)).toBe(false) expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false) }) it('should let git carry compatible dirty changes during direct branch launch', async () => { const workDir = await createCleanGitRepo(tmpDir) await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n') const { sessionId } = await service.createSession( workDir, { branch: 'feature/rail', worktree: false }, ) expect(git(workDir, 'branch', '--show-current')).toBe('main\n') expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8')) .toContain('local-pricing-edit') const prepared = await prepareSessionWorkspace( workDir, { branch: 'feature/rail', worktree: false }, sessionId, ) expect(prepared.workDir).toBe(await fs.realpath(workDir)) expect(git(workDir, 'branch', '--show-current')).toBe('feature/rail\n') expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8')) .toContain('local-pricing-edit') }) it('should plan isolated worktrees from dirty source checkouts without switching branches', async () => { const workDir = await createCleanGitRepo(tmpDir) await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n') const { sessionId } = await service.createSession( workDir, { branch: 'feature/rail', worktree: true }, ) const launchInfo = await service.getSessionLaunchInfo(sessionId) expect(launchInfo?.repository).toMatchObject({ branch: 'feature/rail', worktree: true, baseRef: 'feature/rail', }) expect(git(workDir, 'branch', '--show-current')).toBe('main\n') expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8')) .toContain('local-pricing-edit') }) it('should defer checked-out direct branch launch validation until CLI startup', async () => { const workDir = await createCleanGitRepo(tmpDir) const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`) git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail') const { sessionId } = await service.createSession( workDir, { branch: 'feature/rail', worktree: false }, ) expect(git(workDir, 'branch', '--show-current')).toBe('main\n') await expect(prepareSessionWorkspace( workDir, { branch: 'feature/rail', worktree: false }, sessionId, )).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' }) }) it('should reject branch launch outside Git repositories with a stable error code', async () => { const workDir = path.join(tmpDir, `not-git-${Date.now()}`) await fs.mkdir(workDir, { recursive: true }) await expect(service.createSession( workDir, { branch: 'main', worktree: false }, )).rejects.toMatchObject({ code: 'REPOSITORY_NOT_GIT' }) }) it('should reject missing selected branches with a stable error code', async () => { const workDir = await createCleanGitRepo(tmpDir) await expect(service.createSession( workDir, { branch: 'missing/branch', worktree: true }, )).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NOT_FOUND' }) }) it('should create a Windows-safe project directory name', async () => { if (process.platform !== 'win32') return const workDir = process.cwd() const { sessionId } = await service.createSession(workDir) const sanitized = sanitizePath(workDir) const projectDir = path.join(tmpDir, 'projects', sanitized) expect(sanitized.includes(':')).toBe(false) const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`)) expect(stat.isFile()).toBe(true) }) it('should default to the user home directory when workDir is missing', async () => { const { sessionId } = await service.createSession('') const filePath = path.join( tmpDir, 'projects', sanitizePath(os.homedir()), `${sessionId}.jsonl`, ) const stat = await fs.stat(filePath) expect(stat.isFile()).toBe(true) }) it('should throw when workDir does not exist', async () => { expect(service.createSession('/tmp/definitely-missing-claude-code-haha')).rejects.toThrow( 'Working directory does not exist' ) }) // -------------------------------------------------------------------------- // deleteSession // -------------------------------------------------------------------------- it('should delete an existing session', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const filePath = await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()]) await service.deleteSession(sessionId) // File should no longer exist expect(fs.access(filePath)).rejects.toThrow() }) it('should throw when deleting non-existent session', async () => { expect( service.deleteSession('00000000-0000-0000-0000-000000000000') ).rejects.toThrow('Session not found') }) // -------------------------------------------------------------------------- // renameSession // -------------------------------------------------------------------------- it('should rename a session by appending custom-title entry', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const filePath = await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry('Original message'), ]) await service.renameSession(sessionId, 'My Custom Title') // Read the file and check the last entry const content = await fs.readFile(filePath, 'utf-8') const lines = content.trim().split('\n') const lastEntry = JSON.parse(lines[lines.length - 1]!) expect(lastEntry.type).toBe('custom-title') expect(lastEntry.customTitle).toBe('My Custom Title') // Verify the title is now returned in list const detail = await service.getSession(sessionId) expect(detail!.title).toBe('My Custom Title') }) it('should throw when renaming non-existent session', async () => { expect( service.renameSession('00000000-0000-0000-0000-000000000000', 'Title') ).rejects.toThrow('Session not found') }) // -------------------------------------------------------------------------- // Title extraction // -------------------------------------------------------------------------- it('should use first user message as title when no custom title', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeMetaUserEntry(), makeUserEntry('This is my first real question'), ]) const detail = await service.getSession(sessionId) expect(detail!.title).toBe('This is my first real question') }) it('should derive a clean title from slash command breadcrumb metadata', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry([ 'frontend-design', '/frontend-design', '@website 重新设计首页', ].join('\n')), ]) const detail = await service.getSession(sessionId) expect(detail!.title).toBe('/frontend-design @website 重新设计首页') }) it('should display stored AI titles without internal XML tags', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry('fallback message'), { type: 'ai-title', aiTitle: [ 'frontend-design', '/frontend-design', '@website', ].join(' '), timestamp: '2026-01-01T00:02:00.000Z', }, ]) const detail = await service.getSession(sessionId) expect(detail!.title).toBe('/frontend-design @website') }) it('should truncate long titles to 80 chars', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const longMessage = 'A'.repeat(120) await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), makeUserEntry(longMessage), ]) const detail = await service.getSession(sessionId) expect(detail!.title.length).toBe(83) // 80 + '...' expect(detail!.title.endsWith('...')).toBe(true) }) it('should fall back to "Untitled Session" when no user message', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()]) const detail = await service.getSession(sessionId) expect(detail!.title).toBe('Untitled Session') }) it('should detect placeholder launch info for desktop-created sessions', async () => { const workDir = await fs.realpath(os.tmpdir()) const { sessionId } = await service.createSession(workDir) const launchInfo = await service.getSessionLaunchInfo(sessionId) expect(launchInfo).not.toBeNull() expect(launchInfo!.workDir).toBe(workDir) expect(launchInfo!.transcriptMessageCount).toBe(0) expect(launchInfo!.customTitle).toBeNull() }) it('should detect resumable launch info for transcript sessions', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const userUuid = crypto.randomUUID() await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), { type: 'session-meta', isMeta: true, workDir: '/tmp/project', timestamp: '2026-01-01T00:00:00.000Z' }, makeUserEntry('Hello again', userUuid), makeAssistantEntry('Welcome back', userUuid), { type: 'custom-title', customTitle: 'Saved chat', timestamp: '2026-01-01T00:03:00.000Z' }, ]) const launchInfo = await service.getSessionLaunchInfo(sessionId) expect(launchInfo).not.toBeNull() expect(launchInfo!.workDir).toBe('/tmp/project') expect(launchInfo!.transcriptMessageCount).toBe(2) expect(launchInfo!.customTitle).toBe('Saved chat') }) }) // ============================================================================ // Sessions API integration tests // ============================================================================ describe('Sessions API', () => { let baseUrl: string let server: ReturnType | null = null beforeEach(async () => { await setupTmpConfigDir() service = new SessionService() // Import and start a minimal test server const { handleSessionsApi } = await import('../api/sessions.js') const { handleConversationsApi } = await import('../api/conversations.js') const port = 30000 + Math.floor(Math.random() * 10000) baseUrl = `http://127.0.0.1:${port}` server = Bun.serve({ port, hostname: '127.0.0.1', async fetch(req) { const url = new URL(req.url) const segments = url.pathname.split('/').filter(Boolean) if (segments[0] === 'api' && segments[1] === 'sessions') { // Route chat sub-resource to conversations handler if (segments[3] === 'chat') { return handleConversationsApi(req, url, segments) } return handleSessionsApi(req, url, segments) } return new Response('Not Found', { status: 404 }) }, }) }) afterEach(async () => { if (server) { server.stop(true) server = null } await cleanupTmpDir() }) it('GET /api/sessions should return empty list', async () => { const res = await fetch(`${baseUrl}/api/sessions`) expect(res.status).toBe(200) const body = (await res.json()) as { sessions: unknown[]; total: number } expect(body.sessions).toEqual([]) expect(body.total).toBe(0) }) it('POST /api/sessions should create a session', async () => { const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-')) const res = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workDir }), }) expect(res.status).toBe(201) const body = (await res.json()) as { sessionId: string } expect(body.sessionId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ ) }) it('POST /api/sessions should create a session when workDir is omitted', async () => { const res = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) expect(res.status).toBe(201) const body = (await res.json()) as { sessionId: string } expect(body.sessionId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ ) }) it('GET /api/sessions/repository-context should return branch launch metadata', async () => { const workDir = await createCleanGitRepo(tmpDir) const res = await fetch( `${baseUrl}/api/sessions/repository-context?workDir=${encodeURIComponent(workDir)}`, ) expect(res.status).toBe(200) const body = (await res.json()) as { state: string repoName: string currentBranch: string branches: Array<{ name: string; current: boolean; local: boolean }> worktrees: Array<{ path: string; branch: string | null; current: boolean }> } expect(body.state).toBe('ok') expect(body.repoName).toBe(path.basename(workDir)) expect(body.currentBranch).toBe('main') expect(body.branches.some((branch) => branch.name === 'main' && branch.current)).toBe(true) expect(body.branches.some((branch) => branch.name === 'feature/rail' && branch.local)).toBe(true) const realWorkDir = await fs.realpath(workDir) expect(body.worktrees.some((worktree) => worktree.path === realWorkDir && worktree.current)).toBe(true) }) it('GET /api/sessions/recent-projects should keep pending repository launches on the source project', async () => { const workDir = await createCleanGitRepo(tmpDir) const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workDir, repository: { branch: 'feature/rail', worktree: true }, }), }) expect(createRes.status).toBe(201) const created = (await createRes.json()) as { workDir: string } const recentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`) expect(recentRes.status).toBe(200) const body = (await recentRes.json()) as { projects: Array<{ realPath: string; projectName: string; branch: string | null }> } const project = body.projects.find((candidate) => candidate.realPath === created.workDir) expect(project).toBeDefined() expect(project?.projectName).toBe(path.basename(workDir)) expect(project?.branch).toBe('main') expect(project?.realPath).toBe(await fs.realpath(workDir)) }) it('GET /api/sessions/:id should return session detail', async () => { // Create a session file const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [ makeSnapshotEntry(), makeUserEntry('API test message'), makeAssistantEntry('API test response'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`) expect(res.status).toBe(200) const body = (await res.json()) as { id: string; title: string; messages: unknown[] } expect(body.id).toBe(sessionId) expect(body.title).toBe('API test message') expect(body.messages).toHaveLength(2) }) it('GET /api/sessions/:id should 404 for unknown session', async () => { const res = await fetch(`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000`) expect(res.status).toBe(404) }) it('GET /api/sessions/:id/messages should return messages', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [ makeSnapshotEntry(), makeUserEntry('Hello'), makeAssistantEntry('World'), makeUserEntry( '\nbg-1\ntoolu_bg\nfailed\nBackground command failed & stopped\nC:\\Temp\\bg.output\n', crypto.randomUUID(), ), makeAssistantEntry('internal task response'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`) expect(res.status).toBe(200) const body = (await res.json()) as { messages: unknown[] taskNotifications: unknown[] } expect(body.messages).toHaveLength(2) expect(JSON.stringify(body.messages)).not.toContain('') expect(body.taskNotifications).toEqual([ { taskId: 'bg-1', toolUseId: 'toolu_bg', status: 'failed', summary: 'Background command failed & stopped', outputFile: 'C:\\Temp\\bg.output', }, ]) }) it('GET /api/sessions/:id/git-info should prefer the active CLI workDir', async () => { const workDir = await createCleanGitRepo(tmpDir) const activeWorktree = path.join(tmpDir, `active-feature-rail-${Date.now()}`) git(workDir, 'worktree', 'add', activeWorktree, 'feature/rail') const { sessionId } = await sessionService.createSession(workDir) const sessionsMap = (conversationService as any).sessions as Map sessionsMap.set(sessionId, { workDir: activeWorktree }) try { const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`) expect(res.status).toBe(200) const body = (await res.json()) as { branch: string | null; workDir: string } expect(body.workDir).toBe(activeWorktree) expect(body.branch).toBe('feature/rail') } finally { sessionsMap.delete(sessionId) } }) it('GET /api/sessions/:id/git-info should keep the session launch branch stable', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId } = await sessionService.createSession( workDir, { branch: 'feature/rail', worktree: false }, ) const sessionsMap = (conversationService as any).sessions as Map sessionsMap.set(sessionId, { workDir }) git(workDir, 'switch', 'main') try { const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`) expect(res.status).toBe(200) const body = (await res.json()) as { branch: string | null; workDir: string } expect(body.workDir).toBe(workDir) expect(body.branch).toBe('feature/rail') } finally { sessionsMap.delete(sessionId) } }) it('GET /api/sessions/:id/git-info should include isolated worktree identity', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId } = await sessionService.createSession( workDir, { branch: 'main', worktree: true }, ) const launchInfo = await sessionService.getSessionLaunchInfo(sessionId) const repository = launchInfo?.repository expect(repository?.worktreePath).toBeTruthy() expect(repository?.worktreeBranch).toBeTruthy() const activeWorktree = repository!.worktreePath! git(workDir, 'worktree', 'add', '-b', repository!.worktreeBranch!, activeWorktree, 'main') const sessionsMap = (conversationService as any).sessions as Map sessionsMap.set(sessionId, { workDir: activeWorktree }) try { const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`) expect(res.status).toBe(200) const body = (await res.json()) as { branch: string | null workDir: string worktree: { enabled: boolean path: string | null plannedPath: string | null sourceWorkDir: string | null slug: string | null branch: string | null } | null } expect(body.branch).toBe('main') expect(body.workDir).toBe(activeWorktree) expect(body.worktree).toEqual({ enabled: true, path: activeWorktree, plannedPath: activeWorktree, sourceWorkDir: repository!.requestedWorkDir, slug: repository!.worktreeSlug, branch: repository!.worktreeBranch, }) } finally { sessionsMap.delete(sessionId) } }) it('GET /api/sessions/:id/git-info should use CLI worktree-state after reload', async () => { const workDir = await createCleanGitRepo(tmpDir) const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const activeWorktree = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678') git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-main-12345678', activeWorktree, 'main') await writeSessionFile(sanitizePath(activeWorktree), sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry(activeWorktree), makeWorktreeStateEntry(sessionId, activeWorktree, { originalCwd: await fs.realpath(workDir), }), makeUserEntry('Hello from persisted worktree state'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`) expect(res.status).toBe(200) const body = (await res.json()) as { branch: string | null repoName: string | null workDir: string worktree: { enabled: boolean path: string | null plannedPath: string | null sourceWorkDir: string | null slug: string | null branch: string | null } | null } expect(body.branch).toBe('main') expect(body.workDir).toBe(activeWorktree) expect(body.worktree).toEqual({ enabled: true, path: activeWorktree, plannedPath: activeWorktree, sourceWorkDir: await fs.realpath(workDir), slug: 'desktop-main-12345678', branch: 'worktree-desktop-main-12345678', }) }) it('GET /api/sessions/:id/git-info should prefer CLI worktree-state identity over desktop metadata', async () => { const workDir = await createCleanGitRepo(tmpDir) const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const activeWorktree = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678') git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-main-12345678', activeWorktree, 'main') await writeSessionFile(sanitizePath(activeWorktree), sessionId, [ makeSnapshotEntry(), { type: 'session-meta', isMeta: true, workDir: activeWorktree, repository: { requestedWorkDir: '/stale/source', repoRoot: '/stale/source', branch: 'main', worktree: true, baseRef: 'main', worktreePath: '/stale/source/.claude/worktrees/stale', worktreeBranch: 'worktree-stale', worktreeSlug: 'stale', }, timestamp: '2026-01-01T00:00:00.000Z', }, makeWorktreeStateEntry(sessionId, activeWorktree, { originalCwd: await fs.realpath(workDir), }), makeUserEntry('Hello from persisted worktree state'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`) expect(res.status).toBe(200) const body = (await res.json()) as { branch: string | null worktree: { path: string | null plannedPath: string | null sourceWorkDir: string | null slug: string | null branch: string | null } | null } expect(body.branch).toBe('main') expect(body.worktree).toMatchObject({ path: activeWorktree, plannedPath: activeWorktree, sourceWorkDir: await fs.realpath(workDir), slug: 'desktop-main-12345678', branch: 'worktree-desktop-main-12345678', }) }) it('DELETE /api/sessions/:id should delete the session', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }) expect(res.status).toBe(200) // Verify it's gone const res2 = await fetch(`${baseUrl}/api/sessions/${sessionId}`) expect(res2.status).toBe(404) }) it('DELETE /api/sessions/:id should remove matching IM adapter session mappings', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const otherSessionId = 'ffffffff-1111-2222-3333-ffffffffffff' await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()]) await fs.writeFile( path.join(tmpDir, 'adapter-sessions.json'), JSON.stringify({ 'wechat-chat': { sessionId, workDir: '/tmp/project-a', updatedAt: 1, }, 'wechat-chat-2': { sessionId, workDir: '/tmp/project-b', updatedAt: 2, }, 'other-chat': { sessionId: otherSessionId, workDir: '/tmp/project-c', updatedAt: 3, }, }, null, 2), 'utf-8', ) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }) expect(res.status).toBe(200) const persisted = JSON.parse( await fs.readFile(path.join(tmpDir, 'adapter-sessions.json'), 'utf-8'), ) expect(persisted['wechat-chat']).toBeUndefined() expect(persisted['wechat-chat-2']).toBeUndefined() expect(persisted['other-chat'].sessionId).toBe(otherSessionId) }) it('DELETE /api/sessions/:id should roll back the deleted marker when file deletion fails', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()]) const originalDeleteSession = sessionService.deleteSession.bind(sessionService) sessionService.deleteSession = (async (targetSessionId: string) => { if (targetSessionId === sessionId) { throw new Error('simulated unlink failure') } return originalDeleteSession(targetSessionId) }) as typeof sessionService.deleteSession try { const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }) expect(res.status).toBe(500) expect((conversationService as any).deletedSessions.has(sessionId)).toBe(false) const detailRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`) expect(detailRes.status).toBe(200) } finally { sessionService.deleteSession = originalDeleteSession as typeof sessionService.deleteSession conversationService.unmarkSessionDeleted(sessionId) } }) it('PATCH /api/sessions/:id should rename the session', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [ makeSnapshotEntry(), makeUserEntry('Old title message'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'New Custom Title' }), }) expect(res.status).toBe(200) // Verify new title const detailRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`) const detail = (await detailRes.json()) as { title: string } expect(detail.title).toBe('New Custom Title') }) it('GET /api/sessions/:id/slash-commands should include user and project skills before CLI init', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const workDir = path.join(tmpDir, 'workspace', 'app') await fs.mkdir(path.join(workDir, '.claude', 'skills'), { recursive: true }) await fs.mkdir(path.join(tmpDir, 'skills'), { recursive: true }) await writeSkill(path.join(tmpDir, 'skills'), 'user-skill', 'User skill description') await writeSkill(path.join(workDir, '.claude', 'skills'), 'project-skill', 'Project skill description') await writeSessionFile('-tmp-api-test', sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry(workDir), ]) clearCommandsCache() const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`) expect(res.status).toBe(200) const body = (await res.json()) as { commands: Array<{ name: string; description: string }> } expect(body.commands).toContainEqual( expect.objectContaining({ name: 'user-skill', description: 'User skill description' }), ) expect(body.commands).toContainEqual( expect.objectContaining({ name: 'project-skill', description: 'Project skill description' }), ) }) it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => { const workDir = await createWorkspaceApiGitRepo(tmpDir) const { sessionId } = await service.createSession(workDir) const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) expect(statusRes.status).toBe(200) const statusBody = await statusRes.json() as { state: string workDir: string changedFiles: Array<{ path: string; status: string }> isGitRepo: boolean } expect(statusBody.state).toBe('ok') expect(statusBody.workDir).toBe(await fs.realpath(workDir)) expect(statusBody.isGitRepo).toBe(true) expect(statusBody.changedFiles).toEqual( expect.arrayContaining([ expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), ]), ) const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`) expect(treeRes.status).toBe(200) const treeBody = await treeRes.json() as { state: string path: string entries: Array<{ name: string; path: string; isDirectory: boolean }> } expect(treeBody).toMatchObject({ state: 'ok', path: '', }) expect(treeBody.entries).toEqual([ { name: 'src', path: 'src', isDirectory: true }, { name: 'tracked.txt', path: 'tracked.txt', isDirectory: false }, ]) const fileRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`, ) expect(fileRes.status).toBe(200) const fileBody = await fileRes.json() as { state: string path: string content?: string language: string size: number } expect(fileBody).toMatchObject({ state: 'ok', path: 'src/app.ts', language: 'typescript', size: 25, content: 'export const answer = 42\n', }) const diffRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('tracked.txt')}`, ) expect(diffRes.status).toBe(200) const diffBody = await diffRes.json() as { state: string path: string diff?: string } expect(diffBody.state).toBe('ok') expect(diffBody.path).toBe('tracked.txt') expect(diffBody.diff).toContain('tracked.txt') }) it('GET /api/sessions/:id/workspace/* should surface transcript changes for a non-git tmp session', async () => { const sessionId = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff' const workDir = await fs.mkdtemp(path.join(tmpDir, 'workspace-api-non-git-')) const srcDir = path.join(workDir, 'src') const notesDir = path.join(workDir, 'notes') const assetsDir = path.join(workDir, 'assets') await fs.mkdir(srcDir, { recursive: true }) await fs.mkdir(notesDir, { recursive: true }) await fs.mkdir(assetsDir, { recursive: true }) await fs.writeFile(path.join(workDir, 'README.md'), '# Temporary project\n') await fs.writeFile(path.join(srcDir, 'app.ts'), 'export const answer = 2\n') await fs.writeFile(path.join(notesDir, 'todo.md'), '- ship workspace panel\n') await fs.writeFile( path.join(assetsDir, 'pixel.png'), Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64', ), ) await writeSessionFile(sanitizePath(workDir), sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry(workDir), makeUserEntry('Update this temporary project'), makeAssistantToolUseEntry([ { id: 'toolu-edit-app', name: 'Edit', input: { file_path: path.join(workDir, 'src', 'app.ts'), old_string: 'export const answer = 1\n', new_string: 'export const answer = 2\n', }, }, { id: 'toolu-write-todo', name: 'Write', input: { file_path: path.join(workDir, 'notes', 'todo.md'), content: '- ship workspace panel\n', }, }, ]), ]) const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) expect(statusRes.status).toBe(200) const statusBody = await statusRes.json() as { state: string workDir: string repoName: string | null branch: string | null isGitRepo: boolean changedFiles: Array<{ path: string status: string additions: number deletions: number }> } expect(statusBody).toMatchObject({ state: 'ok', workDir, repoName: path.basename(workDir), branch: null, isGitRepo: false, }) expect(statusBody.changedFiles).toEqual([ expect.objectContaining({ path: 'notes/todo.md', status: 'added', additions: 1, deletions: 0, }), expect.objectContaining({ path: 'src/app.ts', status: 'modified', additions: 1, deletions: 1, }), ]) const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`) expect(treeRes.status).toBe(200) const treeBody = await treeRes.json() as { state: string path: string entries: Array<{ name: string; path: string; isDirectory: boolean }> } expect(treeBody).toMatchObject({ state: 'ok', path: '' }) expect(treeBody.entries).toEqual([ { name: 'assets', path: 'assets', isDirectory: true }, { name: 'notes', path: 'notes', isDirectory: true }, { name: 'src', path: 'src', isDirectory: true }, { name: 'README.md', path: 'README.md', isDirectory: false }, ]) const srcTreeRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/tree?path=${encodeURIComponent('src')}`, ) expect(srcTreeRes.status).toBe(200) expect(await srcTreeRes.json()).toMatchObject({ state: 'ok', path: 'src', entries: [{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }], }) const fileRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`, ) expect(fileRes.status).toBe(200) expect(await fileRes.json()).toMatchObject({ state: 'ok', path: 'src/app.ts', previewType: 'text', language: 'typescript', content: 'export const answer = 2\n', }) const imageRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('assets/pixel.png')}`, ) expect(imageRes.status).toBe(200) const imageBody = await imageRes.json() as { state: string path: string previewType: string mimeType: string dataUrl: string } expect(imageBody).toMatchObject({ state: 'ok', path: 'assets/pixel.png', previewType: 'image', mimeType: 'image/png', }) expect(imageBody.dataUrl).toStartWith('data:image/png;base64,') const appDiffRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('src/app.ts')}`, ) expect(appDiffRes.status).toBe(200) const appDiffBody = await appDiffRes.json() as { state: string; path: string; diff?: string } expect(appDiffBody).toMatchObject({ state: 'ok', path: 'src/app.ts' }) expect(appDiffBody.diff).toContain('diff --session a/src/app.ts b/src/app.ts') expect(appDiffBody.diff).toContain('-export const answer = 1') expect(appDiffBody.diff).toContain('+export const answer = 2') const todoDiffRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('notes/todo.md')}`, ) expect(todoDiffRes.status).toBe(200) const todoDiffBody = await todoDiffRes.json() as { state: string; path: string; diff?: string } expect(todoDiffBody).toMatchObject({ state: 'ok', path: 'notes/todo.md' }) expect(todoDiffBody.diff).toContain('--- /dev/null') expect(todoDiffBody.diff).toContain('+++ b/notes/todo.md') expect(todoDiffBody.diff).toContain('+- ship workspace panel') }) it('GET /api/sessions/:id/workspace/* should surface file-history changes for a non-git generated subdirectory', async () => { const sessionId = crypto.randomUUID() const workDir = path.join(tmpDir, 'workspace-file-history-generated') const generatedFile = path.join(workDir, 'aacc', 'src', 'App.tsx') const userId = crypto.randomUUID() await fs.mkdir(path.dirname(generatedFile), { recursive: true }) await fs.writeFile( generatedFile, 'export default function App() { return
Tetris
}\n', 'utf-8', ) await writeSessionFile(sanitizePath(workDir), sessionId, [ makeSessionMetaEntry(workDir), makeFileHistorySnapshotEntry(userId, { 'aacc/src/App.tsx': { backupFileName: null, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('create aacc project', userId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE', userId), makeUserEntry( '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n', crypto.randomUUID(), ), makeAssistantEntry('Background task completed again, no action needed'), ]) const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) expect(statusRes.status).toBe(200) const statusBody = await statusRes.json() as { state: string workDir: string isGitRepo: boolean changedFiles: Array<{ path: string status: string additions: number deletions: number }> } expect(statusBody).toMatchObject({ state: 'ok', workDir, isGitRepo: false, }) expect(statusBody.changedFiles).toEqual([ expect.objectContaining({ path: 'aacc/src/App.tsx', status: 'added', additions: 1, deletions: 0, }), ]) const diffRes = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('aacc/src/App.tsx')}`, ) expect(diffRes.status).toBe(200) const diffBody = await diffRes.json() as { state: string path: string diff: string } expect(diffBody).toMatchObject({ state: 'ok', path: 'aacc/src/App.tsx', }) expect(diffBody.diff).toContain('diff --session /dev/null b/aacc/src/App.tsx') expect(diffBody.diff).toContain('+export default function App() { return
Tetris
}') const checkpointsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`) expect(checkpointsRes.status).toBe(200) const checkpointsBody = await checkpointsRes.json() as { checkpoints: Array<{ target: { targetUserMessageId: string userMessageIndex: number userMessageCount: number } code: { filesChanged: string[] } }> } expect(checkpointsBody.checkpoints).toHaveLength(1) expect(checkpointsBody.checkpoints[0]?.target).toMatchObject({ targetUserMessageId: userId, userMessageIndex: 0, userMessageCount: 1, }) expect(checkpointsBody.checkpoints[0]?.code.filesChanged).toEqual([generatedFile]) }) it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => { const workDir = await createWorkspaceApiGitRepo(tmpDir) const { sessionId } = await service.createSession(workDir) for (const route of ['file', 'diff']) { const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/${route}`) expect(res.status).toBe(400) expect(await res.json()).toMatchObject({ error: 'BAD_REQUEST', }) } }) it('GET /api/sessions/:id/workspace/file and tree should reject traversal with 403', async () => { const workDir = await createWorkspaceApiGitRepo(tmpDir) const { sessionId } = await service.createSession(workDir) for (const route of ['file', 'tree']) { const res = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/${route}?path=${encodeURIComponent('../outside.txt')}`, ) expect(res.status).toBe(403) expect(await res.json()).toMatchObject({ error: 'FORBIDDEN', }) } }) it('GET /api/sessions/:id/workspace/diff should reject traversal with 403', async () => { const workDir = await createWorkspaceApiGitRepo(tmpDir) const { sessionId } = await service.createSession(workDir) const res = await fetch( `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('../outside.txt')}`, ) expect(res.status).toBe(403) expect(await res.json()).toMatchObject({ error: 'FORBIDDEN', }) }) it('GET /api/sessions/:id/workspace/status should 404 for unknown sessions', async () => { const res = await fetch( `${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/workspace/status`, ) expect(res.status).toBe(404) expect(await res.json()).toMatchObject({ error: 'NOT_FOUND', }) }) it('non-GET workspace routes should return 405', async () => { const workDir = await createWorkspaceApiGitRepo(tmpDir) const { sessionId } = await service.createSession(workDir) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`, { method: 'POST', }) expect(res.status).toBe(405) expect(await res.json()).toMatchObject({ error: 'METHOD_NOT_ALLOWED', }) }) it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const firstUserId = crypto.randomUUID() const firstAssistantId = crypto.randomUUID() const secondUserId = crypto.randomUUID() const secondAssistantId = crypto.randomUUID() await writeSessionFile('-tmp-api-test', sessionId, [ makeSnapshotEntry(), { parentUuid: null, isSidechain: false, type: 'user', message: { role: 'user', content: 'first prompt' }, uuid: firstUserId, timestamp: '2026-01-01T00:01:00.000Z', userType: 'external', cwd: '/tmp/test', sessionId, }, { parentUuid: firstUserId, isSidechain: false, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: [{ type: 'text', text: 'first reply' }], }, uuid: firstAssistantId, timestamp: '2026-01-01T00:02:00.000Z', }, { parentUuid: firstAssistantId, isSidechain: false, type: 'user', message: { role: 'user', content: 'second prompt' }, uuid: secondUserId, timestamp: '2026-01-01T00:03:00.000Z', userType: 'external', cwd: '/tmp/test', sessionId, }, { parentUuid: secondUserId, isSidechain: false, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: [{ type: 'text', text: 'second reply' }], }, uuid: secondAssistantId, timestamp: '2026-01-01T00:04:00.000Z', }, ]) const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1, dryRun: true }), }) expect(previewRes.status).toBe(200) const previewBody = await previewRes.json() as { conversation: { messagesRemoved: number } code: { available: boolean } } expect(previewBody.conversation.messagesRemoved).toBe(2) expect(previewBody.code.available).toBe(false) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1 }), }) expect(executeRes.status).toBe(200) const executeBody = await executeRes.json() as { conversation: { messagesRemoved: number; removedMessageIds: string[] } } expect(executeBody.conversation.messagesRemoved).toBe(2) expect(executeBody.conversation.removedMessageIds).toEqual([ secondUserId, secondAssistantId, ]) const remainingMessages = await service.getSessionMessages(sessionId) expect(remainingMessages.map((message) => message.id)).toEqual([ firstUserId, firstAssistantId, ]) }) it('trimSessionMessagesFrom should remove orphan transcript entries beyond the rewind point', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const firstUserId = crypto.randomUUID() const firstAssistantId = crypto.randomUUID() const secondUserId = crypto.randomUUID() const secondAssistantId = crypto.randomUUID() const filePath = await writeSessionFile('-tmp-api-rewind-orphans', sessionId, [ makeSnapshotEntry(), makeSessionMetaEntry('/tmp/project-with-hyphen'), { ...makeUserEntry('first prompt', firstUserId), sessionId, }, { ...makeAssistantEntry('first reply', firstUserId), uuid: firstAssistantId, }, { ...makeUserEntry('second prompt', secondUserId), parentUuid: firstAssistantId, sessionId, }, { ...makeAssistantEntry('second reply', secondUserId), uuid: secondAssistantId, }, { ...makeAssistantEntry('late stale reply', secondUserId), uuid: crypto.randomUUID(), }, ]) const result = await service.trimSessionMessagesFrom(sessionId, firstUserId) expect(result.removedMessageIds).toContain(firstUserId) expect(result.removedMessageIds).toContain(secondUserId) const raw = await fs.readFile(filePath, 'utf-8') expect(raw).toContain('"type":"session-meta"') expect(raw).not.toContain('late stale reply') expect(await service.getSessionMessages(sessionId)).toEqual([]) const launchInfo = await service.getSessionLaunchInfo(sessionId) expect(launchInfo).not.toBeNull() expect(launchInfo!.workDir).toBe('/tmp/project-with-hyphen') expect(launchInfo!.transcriptMessageCount).toBe(0) }) it('POST /api/sessions/:id/rewind should target the selected message id instead of a shifted visible index', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff' const firstUserId = crypto.randomUUID() const firstAssistantId = crypto.randomUUID() const hiddenUserId = crypto.randomUUID() const targetUserId = crypto.randomUUID() const targetAssistantId = crypto.randomUUID() await writeSessionFile('-tmp-api-rewind-id-target', sessionId, [ makeSnapshotEntry(), makeUserEntry('first prompt', firstUserId), { ...makeAssistantEntry('first reply', firstUserId), uuid: firstAssistantId, }, makeUserEntry( 'internal status that the main chat hides', hiddenUserId, ), makeUserEntry('second visible prompt', targetUserId), { ...makeAssistantEntry('second reply', targetUserId), uuid: targetAssistantId, }, ]) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1, targetUserMessageId: targetUserId, expectedContent: 'second visible prompt', }), }) expect(executeRes.status).toBe(200) const executeBody = await executeRes.json() as { target: { targetUserMessageId: string; userMessageIndex: number } conversation: { messagesRemoved: number; removedMessageIds: string[] } } expect(executeBody.target.targetUserMessageId).toBe(targetUserId) expect(executeBody.target.userMessageIndex).toBe(2) expect(executeBody.conversation.messagesRemoved).toBe(2) expect(executeBody.conversation.removedMessageIds).toEqual([ targetUserId, targetAssistantId, ]) const remainingMessages = await service.getSessionMessages(sessionId) expect(remainingMessages.map((message) => message.id)).toEqual([ firstUserId, firstAssistantId, hiddenUserId, ]) }) it('POST /api/sessions/:id/rewind should reject an index fallback when the selected prompt no longer matches', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-000000000000' const firstUserId = crypto.randomUUID() const hiddenUserId = crypto.randomUUID() const targetUserId = crypto.randomUUID() await writeSessionFile('-tmp-api-rewind-index-guard', sessionId, [ makeSnapshotEntry(), makeUserEntry('first prompt', firstUserId), makeUserEntry( 'internal status that the main chat hides', hiddenUserId, ), makeUserEntry('second visible prompt', targetUserId), ]) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1, expectedContent: 'second visible prompt', }), }) expect(executeRes.status).toBe(400) const body = await executeRes.json() as { message: string } expect(body.message).toContain('does not match the selected prompt') const remainingMessages = await service.getSessionMessages(sessionId) expect(remainingMessages.map((message) => message.id)).toEqual([ firstUserId, hiddenUserId, targetUserId, ]) }) it('POST /api/sessions/:id/rewind should restore a single edited file', async () => { const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee' const workDir = path.join(tmpDir, 'single-file-fixture') const targetFile = path.join(workDir, 'src', 'app.js') const userId = crypto.randomUUID() const assistantId = crypto.randomUUID() const backupName = 'single-file@v1' await fs.mkdir(path.dirname(targetFile), { recursive: true }) await fs.writeFile( targetFile, "export const ORIGINAL_VALUE = 'after-rewind'\n", 'utf-8', ) await writeFileHistoryBackup( sessionId, backupName, "export const ORIGINAL_VALUE = 'before-rewind'\n", ) await writeSessionFile('-tmp-api-single-file', sessionId, [ makeSessionMetaEntry(workDir), makeFileHistorySnapshotEntry(userId, { 'src/app.js': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('edit app.js', userId), cwd: workDir, sessionId, }, { ...makeAssistantEntry('DONE', userId), uuid: assistantId, }, ]) const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 0, dryRun: true }), }) expect(previewRes.status).toBe(200) const preview = await previewRes.json() as { code: { available: boolean; filesChanged: string[] } } expect(preview.code.available).toBe(true) expect(preview.code.filesChanged).toEqual([targetFile]) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 0 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(targetFile, 'utf-8')).toBe( "export const ORIGINAL_VALUE = 'before-rewind'\n", ) const remainingMessages = await service.getSessionMessages(sessionId) expect(remainingMessages).toHaveLength(0) }) it('POST /api/sessions/:id/rewind should resolve checkpoint paths from the target prompt cwd', async () => { const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-ffffffffffff' const parentDir = path.join(tmpDir, 'nested-cwd-parent') const workDir = path.join(parentDir, 'testbb') const targetFile = path.join(workDir, 'vite.config.js') const userId = crypto.randomUUID() const assistantId = crypto.randomUUID() const laterUserId = crypto.randomUUID() const backupName = 'nested-cwd@v1' await fs.mkdir(workDir, { recursive: true }) await fs.writeFile(targetFile, "export default 'after'\n", 'utf-8') await writeFileHistoryBackup(sessionId, backupName, "export default 'before'\n") await writeSessionFile(sanitizePath(parentDir), sessionId, [ makeFileHistorySnapshotEntry(userId, { 'testbb/vite.config.js': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('create a nested project', userId), cwd: parentDir, sessionId, }, { ...makeAssistantEntry('DONE', userId), uuid: assistantId, }, { ...makeUserEntry('latest tool result after cd', laterUserId), cwd: workDir, sessionId, }, ]) const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 0, dryRun: true }), }) expect(previewRes.status).toBe(200) const preview = await previewRes.json() as { code: { available: boolean; filesChanged: string[] } } expect(preview.code.available).toBe(true) expect(preview.code.filesChanged).toEqual([targetFile]) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 0 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(targetFile, 'utf-8')).toBe("export default 'before'\n") }) it('POST /api/sessions/:id/rewind should restore multiple files and remove created files', async () => { const sessionId = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee' const workDir = path.join(tmpDir, 'multi-file-fixture') const appFile = path.join(workDir, 'src', 'app.js') const readmeFile = path.join(workDir, 'README.md') const createdFile = path.join(workDir, 'notes', 'generated.txt') const userId = crypto.randomUUID() const backupApp = 'multi-app@v1' const backupReadme = 'multi-readme@v1' await fs.mkdir(path.dirname(appFile), { recursive: true }) await fs.mkdir(path.dirname(createdFile), { recursive: true }) await fs.writeFile(appFile, "export const VALUE = 'edited'\n", 'utf-8') await fs.writeFile(readmeFile, '# changed\n', 'utf-8') await fs.writeFile(createdFile, 'new file\n', 'utf-8') await writeFileHistoryBackup(sessionId, backupApp, "export const VALUE = 'original'\n") await writeFileHistoryBackup(sessionId, backupReadme, '# original\n') await writeSessionFile('-tmp-api-multi-file', sessionId, [ makeSessionMetaEntry(workDir), makeFileHistorySnapshotEntry(userId, { 'src/app.js': { backupFileName: backupApp, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, 'README.md': { backupFileName: backupReadme, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, 'notes/generated.txt': { backupFileName: null, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('edit multiple files', userId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE', userId), ]) const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 0, dryRun: true }), }) expect(previewRes.status).toBe(200) const preview = await previewRes.json() as { code: { available: boolean; filesChanged: string[] } } expect(preview.code.available).toBe(true) expect(preview.code.filesChanged.sort()).toEqual([ appFile, createdFile, readmeFile, ].sort()) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 0 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(appFile, 'utf-8')).toBe("export const VALUE = 'original'\n") expect(await fs.readFile(readmeFile, 'utf-8')).toBe('# original\n') await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('POST /api/sessions/:id/rewind should restore the previous version when rewinding the second edit of the same file', async () => { const sessionId = 'dddddddd-bbbb-cccc-dddd-eeeeeeeeeeee' const workDir = path.join(tmpDir, 'same-file-two-turns') const targetFile = path.join(workDir, 'src', 'app.js') const firstUserId = crypto.randomUUID() const secondUserId = crypto.randomUUID() const backupV1 = 'same-file@v1' const backupV2 = 'same-file@v2' await fs.mkdir(path.dirname(targetFile), { recursive: true }) await fs.writeFile(targetFile, "export const STEP = 'v2'\n", 'utf-8') await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'base'\n") await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v1'\n") await writeSessionFile('-tmp-api-two-turns', sessionId, [ makeSessionMetaEntry(workDir), makeFileHistorySnapshotEntry(firstUserId, { 'src/app.js': { backupFileName: backupV1, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make v1', firstUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE', firstUserId), makeFileHistorySnapshotEntry(secondUserId, { 'src/app.js': { backupFileName: backupV2, version: 2, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make v2', secondUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE', secondUserId), ]) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(targetFile, 'utf-8')).toBe("export const STEP = 'v1'\n") const remainingMessages = await service.getSessionMessages(sessionId) expect(remainingMessages.map((message) => message.id)).toHaveLength(2) expect(remainingMessages[0]?.id).toBe(firstUserId) }) it('POST /api/sessions/:id/rewind should keep first-turn file state when undoing only the latest turn', async () => { const sessionId = 'dddddddd-bbbb-cccc-dddd-ffffffffffff' const workDir = path.join(tmpDir, 'two-turns-separate-files') const firstTurnFile = path.join(workDir, 'src', 'first.js') const secondTurnFile = path.join(workDir, 'src', 'second.js') const firstUserId = crypto.randomUUID() const secondUserId = crypto.randomUUID() const firstBaseBackup = 'separate-first@v1' const firstAfterTurnBackup = 'separate-first@v2' const secondBaseBackup = 'separate-second@v1' await fs.mkdir(path.dirname(firstTurnFile), { recursive: true }) await fs.writeFile(firstTurnFile, "export const FIRST = 'v1'\n", 'utf-8') await fs.writeFile(secondTurnFile, "export const SECOND = 'v2'\n", 'utf-8') await writeFileHistoryBackup(sessionId, firstBaseBackup, "export const FIRST = 'base'\n") await writeFileHistoryBackup(sessionId, firstAfterTurnBackup, "export const FIRST = 'v1'\n") await writeFileHistoryBackup(sessionId, secondBaseBackup, "export const SECOND = 'base'\n") await writeSessionFile('-tmp-api-two-turns-separate-files', sessionId, [ makeSessionMetaEntry(workDir), makeFileHistorySnapshotEntry(firstUserId, { 'src/first.js': { backupFileName: firstBaseBackup, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make first file v1', firstUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE first', firstUserId), makeFileHistorySnapshotEntry(secondUserId, { 'src/first.js': { backupFileName: firstAfterTurnBackup, version: 2, backupTime: '2026-01-01T00:00:00.000Z', }, 'src/second.js': { backupFileName: secondBaseBackup, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make second file v2', secondUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE second', secondUserId), ]) const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1, dryRun: true }), }) expect(previewRes.status).toBe(200) const preview = await previewRes.json() as { code: { available: boolean; filesChanged: string[] } } expect(preview.code.available).toBe(true) expect(preview.code.filesChanged).toEqual([secondTurnFile]) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(firstTurnFile, 'utf-8')).toBe("export const FIRST = 'v1'\n") expect(await fs.readFile(secondTurnFile, 'utf-8')).toBe("export const SECOND = 'base'\n") const remainingMessages = await service.getSessionMessages(sessionId) expect(remainingMessages).toHaveLength(2) expect(remainingMessages[0]?.id).toBe(firstUserId) }) it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => { const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee' const workDir = path.join(tmpDir, 'created-on-second-turn') const firstFile = path.join(workDir, 'src', 'step.js') const createdFile = path.join(workDir, 'notes', 'generated.txt') const firstUserId = crypto.randomUUID() const secondUserId = crypto.randomUUID() const backupV1 = 'second-created-step@v1' const backupV2 = 'second-created-step@v2' await fs.mkdir(path.dirname(firstFile), { recursive: true }) await fs.mkdir(path.dirname(createdFile), { recursive: true }) await fs.writeFile(firstFile, "export const STEP = 'v2'\n", 'utf-8') await fs.writeFile(createdFile, 'generated\n', 'utf-8') await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'base'\n") await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v1'\n") await writeSessionFile('-tmp-api-second-turn-created', sessionId, [ makeSessionMetaEntry(workDir), makeFileHistorySnapshotEntry(firstUserId, { 'src/step.js': { backupFileName: backupV1, version: 1, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make v1', firstUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE', firstUserId), makeFileHistorySnapshotEntry(secondUserId, { 'src/step.js': { backupFileName: backupV2, version: 2, backupTime: '2026-01-01T00:00:00.000Z', }, 'notes/generated.txt': { backupFileName: null, version: 2, backupTime: '2026-01-01T00:00:00.000Z', }, }), { ...makeUserEntry('make v2 and create file', secondUserId), cwd: workDir, sessionId, }, makeAssistantEntry('DONE', secondUserId), ]) const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1, dryRun: true }), }) expect(previewRes.status).toBe(200) const preview = await previewRes.json() as { code: { available: boolean; filesChanged: string[]; insertions: number } } expect(preview.code.filesChanged.sort()).toEqual([ createdFile, firstFile, ].sort()) expect(preview.code.insertions).toBe(2) const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(firstFile, 'utf-8')).toBe("export const STEP = 'v1'\n") await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('GET /api/sessions/:id/turn-checkpoints should list completed turn previews with turn-bound diff stats', async () => { const fixture = await createThreeTurnCheckpointFixture( '99999999-bbbb-cccc-dddd-eeeeeeeeeeee', ) const res = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints`) expect(res.status).toBe(200) const body = await res.json() as { checkpoints: Array<{ target: { targetUserMessageId: string userMessageIndex: number userMessageCount: number } conversation: { messagesRemoved: number } code: { available: boolean filesChanged: string[] insertions: number deletions: number } workDir: string }> } expect(body.checkpoints).toHaveLength(3) expect(body.checkpoints).toEqual([ { target: { targetUserMessageId: fixture.firstUserId, userMessageIndex: 0, userMessageCount: 3, }, conversation: { messagesRemoved: 6 }, code: { available: true, filesChanged: [fixture.stepFile], insertions: 1, deletions: 1, }, workDir: fixture.workDir, }, { target: { targetUserMessageId: fixture.secondUserId, userMessageIndex: 1, userMessageCount: 3, }, conversation: { messagesRemoved: 4 }, code: { available: true, filesChanged: [fixture.stepFile], insertions: 1, deletions: 1, }, workDir: fixture.workDir, }, { target: { targetUserMessageId: fixture.thirdUserId, userMessageIndex: 2, userMessageCount: 3, }, conversation: { messagesRemoved: 2 }, code: { available: true, filesChanged: [fixture.stepFile, fixture.createdFile], insertions: 2, deletions: 1, }, workDir: fixture.workDir, }, ]) }) it('GET /api/sessions/:id/turn-checkpoints/diff should return target-bound checkpoint diffs', async () => { const fixture = await createThreeTurnCheckpointFixture( '99999999-bbbb-cccc-dddd-ffffffffffff', ) const secondTurnRes = await fetch( `${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.secondUserId}&path=src/step.js`, ) expect(secondTurnRes.status).toBe(200) const secondTurnBody = await secondTurnRes.json() as { state: string path: string diff?: string target: { targetUserMessageId: string } } expect(secondTurnBody.target.targetUserMessageId).toBe(fixture.secondUserId) expect(secondTurnBody.state).toBe('ok') expect(secondTurnBody.path).toBe('src/step.js') expect(secondTurnBody.diff).toContain("export const STEP = 'v2'") expect(secondTurnBody.diff).toContain("export const STEP = 'v1'") expect(secondTurnBody.diff).not.toContain("export const STEP = 'v3'") const thirdTurnRes = await fetch( `${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`, ) expect(thirdTurnRes.status).toBe(200) const thirdTurnBody = await thirdTurnRes.json() as { state: string diff?: string target: { targetUserMessageId: string } } expect(thirdTurnBody.target.targetUserMessageId).toBe(fixture.thirdUserId) expect(thirdTurnBody.state).toBe('ok') expect(thirdTurnBody.diff).toContain("export const STEP = 'v3'") expect(thirdTurnBody.diff).toContain("export const STEP = 'v2'") expect(thirdTurnBody.diff).not.toContain("export const STEP = 'v1'") const createdFileRes = await fetch( `${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=notes/generated.txt`, ) expect(createdFileRes.status).toBe(200) const createdFileBody = await createdFileRes.json() as { state: string diff?: string } expect(createdFileBody.state).toBe('ok') expect(createdFileBody.diff).toContain('generated third turn') expect(createdFileBody.diff).toContain('/dev/null') }) it('GET /api/sessions/:id/turn-checkpoints should fall back to transcript tool changes when file snapshots are missing', async () => { const sessionId = '99999999-bbbb-cccc-dddd-000000000001' const workDir = path.join(tmpDir, 'transcript-only-session') const userId = crypto.randomUUID() await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true }) await writeSessionFile('-tmp-transcript-only-session', sessionId, [ makeSessionMetaEntry(workDir), { ...makeUserEntry('build a todo app', userId), cwd: workDir, sessionId, }, makeAssistantToolUseEntry([ { id: 'Write:1', name: 'Write', input: { file_path: path.join(workDir, 'todo-app', 'src', 'App.tsx'), content: 'export function App() {\n return
Todo
\n}\n', }, }, { id: 'Write:2', name: 'Write', input: { file_path: 'todo-app/vite.config.ts', content: 'import { defineConfig } from "vite"\nexport default defineConfig({})\n', }, }, ], userId), makeAssistantEntry('Todo app created', userId), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`) expect(res.status).toBe(200) const body = await res.json() as { checkpoints: Array<{ target: { targetUserMessageId: string } code: { available: boolean filesChanged: string[] insertions: number deletions: number } workDir: string }> } expect(body.checkpoints).toHaveLength(1) expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(userId) expect(body.checkpoints[0]!.workDir).toBe(workDir) expect(body.checkpoints[0]!.code.available).toBe(true) expect(body.checkpoints[0]!.code.filesChanged.sort()).toEqual([ path.join(workDir, 'todo-app', 'src', 'App.tsx'), path.join(workDir, 'todo-app', 'vite.config.ts'), ].sort()) expect(body.checkpoints[0]!.code.insertions).toBe(5) expect(body.checkpoints[0]!.code.deletions).toBe(0) }) it('GET /api/sessions/:id/turn-checkpoints/diff should return transcript tool diffs when file snapshots are missing', async () => { const sessionId = '99999999-bbbb-cccc-dddd-000000000002' const workDir = path.join(tmpDir, 'transcript-only-diff-session') const userId = crypto.randomUUID() await writeSessionFile('-tmp-transcript-only-diff-session', sessionId, [ makeSessionMetaEntry(workDir), { ...makeUserEntry('edit config', userId), cwd: workDir, sessionId, }, makeAssistantToolUseEntry([ { id: 'Edit:1', name: 'Edit', input: { file_path: path.join(workDir, 'todo-app', 'vite.config.ts'), old_string: 'plugins: [react()]', new_string: 'plugins: [react(), tailwindcss()]', }, }, ], userId), makeAssistantEntry('Config updated', userId), ]) const res = await fetch( `${baseUrl}/api/sessions/${sessionId}/turn-checkpoints/diff?targetUserMessageId=${userId}&path=${encodeURIComponent('todo-app/vite.config.ts')}`, ) expect(res.status).toBe(200) const body = await res.json() as { state: string path: string diff?: string target: { targetUserMessageId: string } } expect(body.target.targetUserMessageId).toBe(userId) expect(body.state).toBe('ok') expect(body.path).toBe('todo-app/vite.config.ts') expect(body.diff).toContain('diff --session a/todo-app/vite.config.ts b/todo-app/vite.config.ts') expect(body.diff).toContain('-plugins: [react()]') expect(body.diff).toContain('+plugins: [react(), tailwindcss()]') }) it('GET /api/sessions/:id/turn-checkpoints should include subagent transcript file changes for the parent turn', async () => { const sessionId = '99999999-bbbb-cccc-dddd-000000000003' const workDir = path.join(tmpDir, 'transcript-subagent-session') const firstUserId = crypto.randomUUID() const secondUserId = crypto.randomUUID() const agentMessageId = crypto.randomUUID() await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true }) await writeSessionFile('-tmp-transcript-subagent-session', sessionId, [ makeSessionMetaEntry(workDir), { ...makeUserEntry('build a todo app', firstUserId), cwd: workDir, sessionId, }, { parentUuid: firstUserId, isSidechain: false, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: [{ type: 'tool_use', id: 'Agent:todo', name: 'Agent', input: { description: 'Create todo app files' }, }], }, uuid: agentMessageId, timestamp: '2026-01-01T00:02:00.000Z', }, { ...makeUserEntry('now explain it', secondUserId), parentUuid: agentMessageId, cwd: workDir, sessionId, }, { parentUuid: agentMessageId, isSidechain: true, type: 'assistant', message: { model: 'claude-opus-4-7', id: `msg_${crypto.randomUUID().slice(0, 20)}`, type: 'message', role: 'assistant', content: [{ type: 'tool_use', id: 'Write:child', name: 'Write', input: { file_path: path.join(workDir, 'todo-app', 'src', 'Board.tsx'), content: 'export function Board() {\n return null\n}\n', }, }], }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:03:00.000Z', }, ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`) expect(res.status).toBe(200) const body = await res.json() as { checkpoints: Array<{ target: { targetUserMessageId: string } code: { filesChanged: string[]; insertions: number; deletions: number } }> } expect(body.checkpoints).toHaveLength(1) expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(firstUserId) expect(body.checkpoints[0]!.code.filesChanged).toEqual([ path.join(workDir, 'todo-app', 'src', 'Board.tsx'), ]) expect(body.checkpoints[0]!.code.insertions).toBe(3) expect(body.checkpoints[0]!.code.deletions).toBe(0) }) it('POST /api/sessions/:id/rewind should restore the base state when rewinding the first turn of a three-turn file history', async () => { const fixture = await createThreeTurnCheckpointFixture( 'aaaaaaaa-1111-2222-3333-444444444444', ) const executeRes = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 0 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(fixture.stepFile, 'utf-8')).toBe("export const STEP = 'base'\n") await expect(fs.stat(fixture.createdFile)).rejects.toMatchObject({ code: 'ENOENT' }) const remainingMessages = await service.getSessionMessages(fixture.sessionId) expect(remainingMessages).toHaveLength(0) }) it('POST /api/sessions/:id/rewind should keep the first turn and remove later file changes when rewinding the second turn of a three-turn history', async () => { const fixture = await createThreeTurnCheckpointFixture( 'aaaaaaaa-5555-6666-7777-888888888888', ) const executeRes = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessageIndex: 1 }), }) expect(executeRes.status).toBe(200) expect(await fs.readFile(fixture.stepFile, 'utf-8')).toBe("export const STEP = 'v1'\n") await expect(fs.stat(fixture.createdFile)).rejects.toMatchObject({ code: 'ENOENT' }) const remainingMessages = await service.getSessionMessages(fixture.sessionId) expect(remainingMessages).toHaveLength(2) expect(remainingMessages[0]?.id).toBe(fixture.firstUserId) expect(remainingMessages[1]?.type).toBe('assistant') }) // -------------------------------------------------------------------------- // Conversations API via /api/sessions/:id/chat // -------------------------------------------------------------------------- it('GET /api/sessions/:id/chat/status should return idle by default', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/status`) expect(res.status).toBe(200) const body = (await res.json()) as { state: string } expect(body.state).toBe('idle') }) it('POST /api/sessions/:id/chat should queue a message', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [ makeSnapshotEntry(), makeUserEntry('Previous'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: 'New question' }), }) expect(res.status).toBe(202) const body = (await res.json()) as { messageId: string; status: string } expect(body.status).toBe('queued') expect(body.messageId).toBeTruthy() }) it('POST /api/sessions/:id/chat/stop should reset state to idle', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/stop`, { method: 'POST', }) expect(res.status).toBe(200) // Verify state is idle const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/status`) const status = (await statusRes.json()) as { state: string } expect(status.state).toBe('idle') }) })