cc-haha/src/server/__tests__/sessions.test.ts
程序员阿江(Relakkes) eea8ea5cbd fix(desktop): avoid duplicate worktree startup status
Continuing an already materialized worktree session should behave like a normal existing session. The launch path now checks whether the session has already moved into its worktree before showing the startup bubble or passing worktree creation flags again.

Constraint: Existing session metadata can retain repository.worktree after the worktree transcript becomes the active session file
Rejected: Frontend-only suppression | the server could still launch the CLI with duplicate worktree creation flags
Confidence: high
Scope-risk: narrow
Tested: bun run check:server
Tested: bun test src/server/__tests__/conversations.test.ts -t worktree --timeout 30000
Tested: bun test src/server/__tests__/sessions.test.ts -t "newest duplicate session file" --timeout 30000
Not-tested: bun run verify still fails on unrelated desktop Settings.tsx and MessageList.tsx gates from the broader worktree
2026-05-10 12:16:15 +08:00

3359 lines
117 KiB
TypeScript

/**
* 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<string> {
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<void> {
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<string> {
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<string, unknown>[]
): Promise<string> {
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<string, unknown>[],
): Promise<string> {
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<void> {
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<string> {
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<string> {
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<string, unknown> {
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<string, unknown>,
): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> }>,
parentUuid?: string,
): Record<string, unknown> {
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<string, unknown> {
return {
parentUuid: null,
isSidechain: false,
type: 'user',
message: { role: 'user', content: '<local-command-caveat>internal</local-command-caveat>' },
isMeta: true,
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:30.000Z',
}
}
function makeSessionMetaEntry(workDir: string): Record<string, unknown> {
return {
type: 'session-meta',
isMeta: true,
workDir,
timestamp: '2026-01-01T00:00:00.000Z',
}
}
function makeWorktreeStateEntry(
sessionId: string,
worktreePath: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
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<void> {
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<ThreeTurnCheckpointFixture> {
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<unknown>
}
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)\n<usage>total_tokens: 10\ntool_uses: 2\nduration_ms: 30</usage>`,
},
],
},
],
},
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: '<synthetic>',
},
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:03.000Z',
},
{
type: 'user',
message: {
role: 'user',
content: '<command-name>/exit</command-name>\n<command-message>exit</command-message>\n<command-args></command-args>',
},
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(
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
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('<task-notification>')
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([
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website 重新设计首页</command-args>',
].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: [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website</command-args>',
].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<typeof Bun.serve> | 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(
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed &amp; stopped</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
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('<task-notification>')
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<string, { workDir: string }>
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<string, { workDir: string }>
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<string, { workDir: string }>
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 <main>Tetris</main> }\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(
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
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 <main>Tetris</main> }')
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(
'<teammate-message teammate_id="reviewer">internal status that the main chat hides</teammate-message>',
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(
'<teammate-message teammate_id="reviewer">internal status that the main chat hides</teammate-message>',
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 <main>Todo</main>\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')
})
})