cc-haha/src/server/__tests__/sessions.test.ts
程序员阿江(Relakkes) f5cf5932b4 fix: keep workspace changes and session checkpoints reliable
The desktop workspace panel and chat transcript were mixing project-level file state with per-turn session history, which made rewind, file attachment, and changed-file previews brittle across multi-turn and historical sessions. This keeps turn checkpoints durable in the transcript, makes workspace refreshes happen at the right lifecycle points, and hardens long file previews without blocking the UI.

Constraint: Right-side workspace changes are project working-tree state, while chat turn cards are session checkpoint state.
Rejected: Treat every changed-file panel entry as session-local | new sessions must still reveal existing dirty project files.
Confidence: high
Scope-risk: moderate
Directive: Do not couple global workspace status to session checkpoint cards without preserving both product meanings.
Tested: cd desktop && bun run test src/components/workspace/WorkspacePanel.test.tsx
Tested: cd desktop && bun run lint
Tested: git diff --check
Tested: cd desktop && bun run test
Not-tested: Packaged Tauri runtime smoke test
2026-05-01 20:53:04 +08:00

2187 lines
74 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 } from '../services/sessionService.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
}
/** 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
}
// 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',
}
}
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 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 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 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 sanitized = sanitizePath(workDir)
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 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 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 { sessionId } = await service.createSession(os.tmpdir())
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo).not.toBeNull()
expect(launchInfo!.workDir).toBe(os.tmpdir())
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/: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'),
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
expect(res.status).toBe(200)
const body = (await res.json()) as { messages: unknown[] }
expect(body.messages).toHaveLength(2)
})
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('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(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/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('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('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')
})
})