mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Workspace inspection now shares the same diff renderer between chat change cards and panel previews, supports scoped preview-tab closing, confirms checkpoint undo, resolves rewind checkpoints from the prompt cwd, and removes light-only workspace chrome so dark theme remains coherent. Constraint: Workspace review must keep working for existing transcript sessions and non-git session-derived changes. Rejected: Keep a separate chat diff preview | it drifted from panel rendering and dark theme behavior. Confidence: high Scope-risk: moderate Directive: Keep chat and workspace diff rendering shared so theme and truncation behavior do not diverge again. Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/components/chat/MessageList.test.tsx src/stores/workspacePanelStore.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun test src/server/__tests__/sessions.test.ts Tested: Browser dark workspace preview at http://127.0.0.1:59468/ with src/App.jsx diff Not-tested: macOS packaged app build
1851 lines
62 KiB
TypeScript
1851 lines
62 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')
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 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' })
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 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')
|
|
})
|
|
})
|