mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
16e58b92c0
The context panel could say what was in the window but nothing about what the session had spent: no total token count, no cache hit rate, no generation speed. The numbers were already on the wire — translateCliUsage picked four token buckets out of the CLI's result message and dropped duration_ms, duration_api_ms and num_turns with them — and nothing anywhere accumulated how long the model spent emitting tokens rather than waiting on prefill. Measure that span where it happens: a decode span opens at the first generated delta and closes at message_stop, rides the stream_event up to QueryEngine, and accumulates in cost-tracker beside totalAPIDuration, including the project config restore path so it survives a CLI restart. Tokens/sec is output over that span, never over wall clock, which would divide by tool execution time. The totals needed fixing before they were worth showing. Claude Code persists one JSONL line per content block of a reply and repeats the whole usage object on every one, so summing lines overstated real transcripts by 2.2x — and chooseRicherUsage prefers the larger of two snapshots, so the inspector actively selected the inflated one. The transcript readers and the renderer's history summary now deduplicate on usageAccounting's key, the rule stats.ts and the activity index already use. The panel polls a usageOnly inspection mode while it is open and stops when it closes: one get_session_usage control, no skills-directory scan, no transcript re-read, and no request stacked behind one that has not answered.
9957 lines
397 KiB
TypeScript
9957 lines
397 KiB
TypeScript
/**
|
|
* Unit tests for SessionService and Sessions API
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'
|
|
import * as fs from 'node:fs/promises'
|
|
import { execFileSync } from 'node:child_process'
|
|
import * as path from 'node:path'
|
|
import * as os from 'node:os'
|
|
import { SessionService, sessionService } from '../services/sessionService.js'
|
|
import {
|
|
createRepositoryBranch,
|
|
getRepositoryContext,
|
|
prepareSessionWorkspace,
|
|
} from '../services/repositoryLaunchService.js'
|
|
import { conversationService } from '../services/conversationService.js'
|
|
import { clearCommandsCache } from '../../commands.js'
|
|
import { parseJSONL } from '../../utils/json.js'
|
|
import { createSessionBranch } from '../../utils/sessionBranching.js'
|
|
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
|
import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js'
|
|
import { clearPluginCache } from '../../utils/plugins/pluginLoader.js'
|
|
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
|
import { updateSessionSlashCommands } from '../ws/handler.js'
|
|
import { reduceTranscript } from '../services/localIndex/transcriptReducer.js'
|
|
import { openLocalIndexDatabase } from '../services/localIndex/database.js'
|
|
import { readSessionEntriesByLocator } from '../services/localIndex/sessionEntries.js'
|
|
import {
|
|
createSessionIndex,
|
|
type LocalIndexGateway,
|
|
} from '../services/localIndex/sessionIndex.js'
|
|
import { createSessionProjector } from '../services/localIndex/sessionProjector.js'
|
|
import type {
|
|
SessionListSummary,
|
|
TranscriptChunk,
|
|
TranscriptProjection,
|
|
} from '../services/localIndex/types.js'
|
|
|
|
// ============================================================================
|
|
// Test helpers
|
|
// ============================================================================
|
|
|
|
let tmpDir: string
|
|
let service: SessionService
|
|
|
|
/** Create a temporary config dir and configure the service to use it. */
|
|
async function setupTmpConfigDir(): Promise<string> {
|
|
tmpDir = path.join(os.tmpdir(), `claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
return tmpDir
|
|
}
|
|
|
|
async function cleanupTmpDir(): Promise<void> {
|
|
if (tmpDir) {
|
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
}
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
}
|
|
|
|
function git(cwd: string, ...args: string[]): string {
|
|
return execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
})
|
|
}
|
|
|
|
async function createCleanGitRepo(baseDir: string): Promise<string> {
|
|
const workDir = path.join(
|
|
baseDir,
|
|
`repo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
)
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
git(workDir, 'init')
|
|
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
|
|
git(workDir, 'config', 'user.name', 'Sessions API')
|
|
git(workDir, 'checkout', '-b', 'main')
|
|
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
|
|
git(workDir, 'add', 'README.md')
|
|
git(workDir, 'commit', '-m', 'initial')
|
|
git(workDir, 'checkout', '-b', 'feature/rail')
|
|
await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n')
|
|
git(workDir, 'add', 'feature.txt')
|
|
git(workDir, 'commit', '-m', 'feature')
|
|
git(workDir, 'checkout', 'main')
|
|
|
|
return workDir
|
|
}
|
|
|
|
/** Write a JSONL session file with given entries. */
|
|
async function writeSessionFile(
|
|
projectDir: string,
|
|
sessionId: string,
|
|
entries: Record<string, unknown>[]
|
|
): Promise<string> {
|
|
const dir = path.join(tmpDir, 'projects', projectDir)
|
|
await fs.mkdir(dir, { recursive: true })
|
|
const filePath = path.join(dir, `${sessionId}.jsonl`)
|
|
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
|
|
await fs.writeFile(filePath, content, 'utf-8')
|
|
return filePath
|
|
}
|
|
|
|
async function writeSubagentTranscriptFile(
|
|
projectDir: string,
|
|
sessionId: string,
|
|
agentId: string,
|
|
entries: Record<string, unknown>[],
|
|
): Promise<string> {
|
|
const dir = path.join(tmpDir, 'projects', projectDir, sessionId, 'subagents')
|
|
await fs.mkdir(dir, { recursive: true })
|
|
const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}`
|
|
const filePath = path.join(dir, `${normalizedAgentId}.jsonl`)
|
|
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
|
|
await fs.writeFile(filePath, content, 'utf-8')
|
|
return filePath
|
|
}
|
|
|
|
async function writeSkill(
|
|
rootDir: string,
|
|
skillName: string,
|
|
description: string,
|
|
): Promise<void> {
|
|
const skillDir = path.join(rootDir, skillName)
|
|
await fs.mkdir(skillDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(skillDir, 'SKILL.md'),
|
|
['---', `description: ${description}`, '---', '', `# ${skillName}`].join('\n'),
|
|
'utf-8',
|
|
)
|
|
}
|
|
|
|
async function writeLegacySlashCommand(
|
|
commandsDir: string,
|
|
commandName: string,
|
|
description: string,
|
|
): Promise<void> {
|
|
await fs.mkdir(commandsDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(commandsDir, `${commandName}.md`),
|
|
['---', `description: ${description}`, 'argument-hint: <topic>', '---', '', `Run ${commandName}.`].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 = await fs.mkdtemp(path.join(baseDir, 'w-'))
|
|
|
|
const javaControllerDir = path.join(
|
|
workDir,
|
|
'services',
|
|
'mental-health-service',
|
|
'src',
|
|
'main',
|
|
'java',
|
|
'com',
|
|
'example',
|
|
'campus',
|
|
'mentalhealth',
|
|
'controller',
|
|
)
|
|
await fs.mkdir(path.join(workDir, 'src'), { recursive: true })
|
|
await fs.mkdir(javaControllerDir, { 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')
|
|
await fs.writeFile(
|
|
path.join(javaControllerDir, 'MentalHealthTrendController.java'),
|
|
'package com.example.campus.mentalhealth.controller;\n\npublic final class MentalHealthTrendController {}\n',
|
|
)
|
|
git(workDir, 'add', 'tracked.txt', 'src/app.ts', 'services')
|
|
git(workDir, 'commit', '-m', 'initial')
|
|
|
|
await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\nafter\n')
|
|
|
|
return workDir
|
|
}
|
|
|
|
async function createCleanGitRepo(baseDir: string): Promise<string> {
|
|
const workDir = path.join(
|
|
baseDir,
|
|
`repo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
)
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
git(workDir, 'init')
|
|
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
|
|
git(workDir, 'config', 'user.name', 'Sessions API')
|
|
git(workDir, 'checkout', '-b', 'main')
|
|
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
|
|
git(workDir, 'add', 'README.md')
|
|
git(workDir, 'commit', '-m', 'initial')
|
|
git(workDir, 'checkout', '-b', 'feature/rail')
|
|
await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n')
|
|
git(workDir, 'add', 'feature.txt')
|
|
git(workDir, 'commit', '-m', 'feature')
|
|
git(workDir, 'checkout', 'main')
|
|
|
|
return workDir
|
|
}
|
|
|
|
// Sample entries matching real CLI format
|
|
function makeSnapshotEntry(): Record<string, unknown> {
|
|
return {
|
|
type: 'file-history-snapshot',
|
|
messageId: crypto.randomUUID(),
|
|
snapshot: {
|
|
messageId: crypto.randomUUID(),
|
|
trackedFileBackups: {},
|
|
timestamp: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
isSnapshotUpdate: false,
|
|
}
|
|
}
|
|
|
|
function makeFileHistorySnapshotEntry(
|
|
snapshotMessageId: string,
|
|
trackedFileBackups: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
return {
|
|
type: 'file-history-snapshot',
|
|
messageId: crypto.randomUUID(),
|
|
snapshot: {
|
|
messageId: snapshotMessageId,
|
|
trackedFileBackups,
|
|
timestamp: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
isSnapshotUpdate: false,
|
|
}
|
|
}
|
|
|
|
function makeUserEntry(content: string, uuid?: string): Record<string, unknown> {
|
|
return {
|
|
parentUuid: null,
|
|
isSidechain: false,
|
|
type: 'user',
|
|
message: { role: 'user', content },
|
|
uuid: uuid || crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:01:00.000Z',
|
|
userType: 'external',
|
|
cwd: '/tmp/test',
|
|
sessionId: 'test-session',
|
|
}
|
|
}
|
|
|
|
function makeAssistantEntry(
|
|
content: string,
|
|
parentUuid?: string,
|
|
usage?: {
|
|
input_tokens?: number
|
|
output_tokens?: number
|
|
cache_read_input_tokens?: number
|
|
cache_creation_input_tokens?: number
|
|
},
|
|
): 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 }],
|
|
...(usage ? { usage } : {}),
|
|
},
|
|
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 makeToolResultUserEntry(
|
|
toolUseId: string,
|
|
content: string,
|
|
uuid?: string,
|
|
parentUuid?: string,
|
|
sessionId = 'test-session',
|
|
): Record<string, unknown> {
|
|
return {
|
|
parentUuid: parentUuid || null,
|
|
isSidechain: false,
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [{
|
|
type: 'tool_result',
|
|
tool_use_id: toolUseId,
|
|
content,
|
|
}],
|
|
},
|
|
uuid: uuid || crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:02:30.000Z',
|
|
userType: 'external',
|
|
cwd: '/tmp/test',
|
|
sessionId,
|
|
}
|
|
}
|
|
|
|
function makeMetaUserEntry(): Record<string, unknown> {
|
|
return {
|
|
parentUuid: null,
|
|
isSidechain: false,
|
|
type: 'user',
|
|
message: { role: 'user', content: '<local-command-caveat>internal</local-command-caveat>' },
|
|
isMeta: true,
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:30.000Z',
|
|
}
|
|
}
|
|
|
|
function makeSessionMetaEntry(workDir: string): Record<string, unknown> {
|
|
return {
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
workDir,
|
|
timestamp: '2026-01-01T00:00:00.000Z',
|
|
}
|
|
}
|
|
|
|
function makeWorktreeStateEntry(
|
|
sessionId: string,
|
|
worktreePath: string,
|
|
overrides: Record<string, unknown> = {},
|
|
): Record<string, unknown> {
|
|
return {
|
|
type: 'worktree-state',
|
|
sessionId,
|
|
worktreeSession: {
|
|
originalCwd: '/tmp/source',
|
|
worktreePath,
|
|
worktreeName: 'desktop-main-12345678',
|
|
worktreeBranch: 'worktree-desktop-main-12345678',
|
|
originalBranch: 'main',
|
|
sessionId,
|
|
...overrides,
|
|
},
|
|
}
|
|
}
|
|
|
|
function makeContentReplacementEntry(
|
|
sessionId: string,
|
|
replacements: Array<{ kind: 'tool-result'; toolUseId: string; replacement: string }>,
|
|
): Record<string, unknown> {
|
|
return {
|
|
type: 'content-replacement',
|
|
sessionId,
|
|
replacements,
|
|
}
|
|
}
|
|
|
|
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: 1,
|
|
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()
|
|
clearInstalledPluginsCache()
|
|
clearPluginCache('sessions-api-test-setup')
|
|
resetSettingsCache()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearCommandsCache()
|
|
clearInstalledPluginsCache()
|
|
clearPluginCache('session-service-test-teardown')
|
|
resetSettingsCache()
|
|
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')
|
|
expect(session.projectRoot).toBe('/tmp/test')
|
|
})
|
|
|
|
it('should keep duplicate session ids from different transcript paths distinct', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-duplicate-one', sessionId, [
|
|
makeUserEntry('First physical transcript'),
|
|
])
|
|
await writeSessionFile('-tmp-duplicate-two', sessionId, [
|
|
makeUserEntry('Second physical transcript'),
|
|
])
|
|
|
|
const result = await service.listSessions({ limit: 10 })
|
|
|
|
expect(result.total).toBe(2)
|
|
expect(result.sessions.map((session) => session.projectPath).sort()).toEqual([
|
|
'-tmp-duplicate-one',
|
|
'-tmp-duplicate-two',
|
|
])
|
|
})
|
|
|
|
it('should expose the source project root for persisted worktree sessions', async () => {
|
|
const sourceWorkDir = path.join(tmpDir, 'source-repo')
|
|
const worktreePath = path.join(sourceWorkDir, '.claude', 'worktrees', 'desktop-main-12345678')
|
|
await fs.mkdir(worktreePath, { recursive: true })
|
|
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile(sanitizePath(worktreePath), sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry(worktreePath),
|
|
makeWorktreeStateEntry(sessionId, worktreePath, {
|
|
originalCwd: sourceWorkDir,
|
|
}),
|
|
makeUserEntry('Hello from worktree'),
|
|
])
|
|
|
|
const result = await service.listSessions()
|
|
|
|
expect(result.sessions).toHaveLength(1)
|
|
expect(result.sessions[0]).toMatchObject({
|
|
id: sessionId,
|
|
projectPath: sanitizePath(worktreePath),
|
|
projectRoot: await fs.realpath(sourceWorkDir),
|
|
workDir: worktreePath,
|
|
workDirExists: true,
|
|
workspaceState: 'available',
|
|
})
|
|
})
|
|
|
|
it('should classify a cleaned worktree separately when its source project still exists', async () => {
|
|
const sourceWorkDir = path.join(tmpDir, 'cleaned-worktree-source')
|
|
const worktreePath = path.join(sourceWorkDir, '.claude', 'worktrees', 'desktop-main-87654321')
|
|
await fs.mkdir(sourceWorkDir, { recursive: true })
|
|
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-ffffffffffff'
|
|
await writeSessionFile(sanitizePath(worktreePath), sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry(worktreePath),
|
|
makeWorktreeStateEntry(sessionId, worktreePath, {
|
|
originalCwd: sourceWorkDir,
|
|
}),
|
|
makeUserEntry('History from a cleaned worktree'),
|
|
])
|
|
|
|
const listed = await service.listSessions()
|
|
const detail = await service.getSession(sessionId)
|
|
|
|
expect(listed.sessions[0]).toMatchObject({
|
|
projectRoot: await fs.realpath(sourceWorkDir),
|
|
workDir: worktreePath,
|
|
workDirExists: false,
|
|
workspaceState: 'worktree_removed',
|
|
})
|
|
expect(detail).toMatchObject({
|
|
projectRoot: await fs.realpath(sourceWorkDir),
|
|
workDirExists: false,
|
|
workspaceState: 'worktree_removed',
|
|
})
|
|
})
|
|
|
|
it('should keep a genuinely missing project classified as missing', async () => {
|
|
const missingWorkDir = path.join(tmpDir, 'deleted-project')
|
|
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-111111111111'
|
|
await writeSessionFile(sanitizePath(missingWorkDir), sessionId, [
|
|
makeSessionMetaEntry(missingWorkDir),
|
|
makeUserEntry('History from a deleted project'),
|
|
])
|
|
|
|
const result = await service.listSessions()
|
|
|
|
expect(result.sessions[0]).toMatchObject({
|
|
projectRoot: missingWorkDir,
|
|
workDir: missingWorkDir,
|
|
workDirExists: false,
|
|
workspaceState: 'missing',
|
|
})
|
|
})
|
|
|
|
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 scan summaries before pagination so metadata-only writes cannot skew order', async () => {
|
|
for (let i = 0; i < 12; i++) {
|
|
const id = `1000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
|
|
const filePath = await writeSessionFile('-tmp-many-sessions', id, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry(`Message ${i}`),
|
|
])
|
|
const mtime = new Date(Date.now() - i * 1000)
|
|
await fs.utimes(filePath, mtime, mtime)
|
|
}
|
|
|
|
const serviceWithSpy = service as unknown as {
|
|
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
|
|
let scanCount = 0
|
|
serviceWithSpy.scanSessionListSummary = async (...args) => {
|
|
scanCount += 1
|
|
return originalScanSessionListSummary(...args)
|
|
}
|
|
|
|
const result = await service.listSessions({ limit: 3, offset: 0 })
|
|
|
|
expect(result.total).toBe(12)
|
|
expect(result.sessions).toHaveLength(3)
|
|
expect(scanCount).toBe(12)
|
|
})
|
|
|
|
it('should ignore metadata-only writes when sorting and dating the session list', async () => {
|
|
const activeSessionId = '10000000-aaaa-bbbb-cccc-eeeeeeeeeeee'
|
|
const viewedHistorySessionId = '10000001-aaaa-bbbb-cccc-eeeeeeeeeeee'
|
|
const activeFilePath = await writeSessionFile('-tmp-viewed-history-sessions', activeSessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
...makeUserEntry('Recent real work'),
|
|
timestamp: '2026-07-02T02:00:00.000Z',
|
|
},
|
|
{
|
|
...makeAssistantEntry('Recent reply'),
|
|
timestamp: '2026-07-02T02:05:00.000Z',
|
|
},
|
|
])
|
|
const historyFilePath = await writeSessionFile('-tmp-viewed-history-sessions', viewedHistorySessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
...makeUserEntry('Older work'),
|
|
timestamp: '2026-07-01T02:00:00.000Z',
|
|
},
|
|
{
|
|
...makeAssistantEntry('Older reply'),
|
|
timestamp: '2026-07-01T02:05:00.000Z',
|
|
},
|
|
{
|
|
...makeSessionMetaEntry('/tmp/viewed-history'),
|
|
timestamp: '2026-07-02T03:00:00.000Z',
|
|
},
|
|
])
|
|
await fs.utimes(activeFilePath, new Date('2026-07-02T02:05:00.000Z'), new Date('2026-07-02T02:05:00.000Z'))
|
|
await fs.utimes(historyFilePath, new Date('2026-07-02T03:00:00.000Z'), new Date('2026-07-02T03:00:00.000Z'))
|
|
|
|
const result = await service.listSessions({ project: '/tmp/viewed-history-sessions', limit: 2 })
|
|
|
|
expect(result.sessions.map((session) => session.id)).toEqual([
|
|
activeSessionId,
|
|
viewedHistorySessionId,
|
|
])
|
|
expect(result.sessions.find((session) => session.id === viewedHistorySessionId)?.modifiedAt)
|
|
.toBe('2026-07-01T02:05:00.000Z')
|
|
})
|
|
|
|
it('should leave an incomplete final JSON line out of the session summary', async () => {
|
|
const sessionId = '10000002-aaaa-bbbb-cccc-eeeeeeeeeeee'
|
|
const projectDir = '-tmp-incomplete-summary'
|
|
const dir = path.join(tmpDir, 'projects', projectDir)
|
|
const filePath = path.join(dir, `${sessionId}.jsonl`)
|
|
await fs.mkdir(dir, { recursive: true })
|
|
await fs.writeFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
...makeUserEntry('This line is not durable yet'),
|
|
timestamp: '2026-07-03T02:00:00.000Z',
|
|
}),
|
|
'utf-8',
|
|
)
|
|
const fallbackTime = new Date('2026-07-03T03:00:00.000Z')
|
|
await fs.utimes(filePath, fallbackTime, fallbackTime)
|
|
|
|
const result = await service.listSessions({ project: '/tmp/incomplete-summary' })
|
|
|
|
expect(result.sessions).toHaveLength(1)
|
|
expect(result.sessions[0]).toMatchObject({
|
|
title: 'Untitled Session',
|
|
modifiedAt: fallbackTime.toISOString(),
|
|
messageCount: 0,
|
|
})
|
|
})
|
|
|
|
it('should keep the file scanner and canonical reducer summaries identical', async () => {
|
|
const sessionId = '10000003-aaaa-bbbb-cccc-eeeeeeeeeeee'
|
|
const projectDir = '-tmp-reducer-parity'
|
|
const dir = path.join(tmpDir, 'projects', projectDir)
|
|
const filePath = path.join(dir, `${sessionId}.jsonl`)
|
|
const repository = {
|
|
requestedWorkDir: '/repo',
|
|
repoRoot: '/repo',
|
|
branch: 'main',
|
|
worktree: true,
|
|
baseRef: 'main',
|
|
worktreePath: '/repo/.claude/worktrees/parity',
|
|
worktreeBranch: 'worktree-parity',
|
|
worktreeSlug: 'parity',
|
|
}
|
|
const worktreeSession = {
|
|
originalCwd: '/repo',
|
|
worktreePath: '/repo/.claude/worktrees/parity',
|
|
worktreeName: 'parity',
|
|
sessionId,
|
|
}
|
|
const completeLines = [
|
|
JSON.stringify({
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
workDir: '/repo/.claude/worktrees/parity',
|
|
permissionMode: 'acceptEdits',
|
|
runtimeProviderId: 'provider-a',
|
|
runtimeModelId: 'model-a',
|
|
effortLevel: 'high',
|
|
timestamp: '2026-07-01T01:00:00.000Z',
|
|
}),
|
|
JSON.stringify({
|
|
...makeUserEntry('First user title'),
|
|
cwd: '/repo/fallback',
|
|
repository,
|
|
timestamp: '2026-07-01T02:00:00.000Z',
|
|
}),
|
|
JSON.stringify({
|
|
...makeAssistantEntry('Assistant response'),
|
|
timestamp: '2026-07-01T02:05:00.000Z',
|
|
}),
|
|
'{malformed complete line}',
|
|
JSON.stringify({
|
|
...makeMetaUserEntry(),
|
|
timestamp: '2026-07-02T03:00:00.000Z',
|
|
}),
|
|
JSON.stringify({ type: 'ai-title', aiTitle: 'AI title' }),
|
|
JSON.stringify({
|
|
type: 'system',
|
|
subtype: 'local_command',
|
|
content: '<command-name>/goal</command-name><command-args>Parity goal</command-args>',
|
|
}),
|
|
JSON.stringify({ type: 'worktree-state', worktreeSession }),
|
|
JSON.stringify({
|
|
type: 'custom-title',
|
|
customTitle: 'Canonical parity title',
|
|
timestamp: '2026-07-02T04:00:00.000Z',
|
|
}),
|
|
JSON.stringify({
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
workDir: '/repo/.claude/worktrees/parity-latest',
|
|
runtimeProviderId: null,
|
|
runtimeModelId: 'model-b',
|
|
effortLevel: 'max',
|
|
timestamp: '2026-07-02T05:00:00.000Z',
|
|
}),
|
|
].map((line) => `${line}\n`)
|
|
const incompleteTail = JSON.stringify({
|
|
...makeAssistantEntry('Pending response'),
|
|
timestamp: '2026-07-03T00:00:00.000Z',
|
|
})
|
|
await fs.mkdir(dir, { recursive: true })
|
|
await fs.writeFile(filePath, `${completeLines.join('')}${incompleteTail}`, 'utf-8')
|
|
|
|
const stat = await fs.stat(filePath)
|
|
const scanner = service as unknown as {
|
|
scanSessionListSummary: (
|
|
targetPath: string,
|
|
targetProject: string,
|
|
targetStat: { birthtime: Date; mtime: Date },
|
|
) => Promise<SessionListSummary>
|
|
}
|
|
const scanned = await scanner.scanSessionListSummary(filePath, projectDir, stat)
|
|
const chunks: TranscriptChunk[] = []
|
|
let byteStart = 0
|
|
for (const text of completeLines) {
|
|
chunks.push({ text, byteStart, completeLine: true })
|
|
byteStart += Buffer.byteLength(text)
|
|
}
|
|
chunks.push({ text: incompleteTail, byteStart, completeLine: false })
|
|
const seed: TranscriptProjection = {
|
|
summary: {
|
|
title: 'Untitled Session',
|
|
createdAt: stat.birthtime.toISOString(),
|
|
modifiedAt: stat.mtime.toISOString(),
|
|
messageCount: 0,
|
|
workDir: service.desanitizePath(projectDir),
|
|
},
|
|
indexedBytes: 0,
|
|
pendingTailBytes: 0,
|
|
malformedLineCount: 0,
|
|
}
|
|
const reduced = reduceTranscript(chunks, seed)
|
|
|
|
expect(scanned).toEqual(reduced.summary)
|
|
expect(scanned).toEqual({
|
|
title: 'Canonical parity title',
|
|
createdAt: '2026-07-01T01:00:00.000Z',
|
|
modifiedAt: '2026-07-01T02:05:00.000Z',
|
|
messageCount: 3,
|
|
workDir: '/repo/.claude/worktrees/parity-latest',
|
|
permissionMode: 'acceptEdits',
|
|
runtimeProviderId: null,
|
|
runtimeModelId: 'model-b',
|
|
effortLevel: 'max',
|
|
repository,
|
|
worktreeSession,
|
|
})
|
|
expect(reduced.malformedLineCount).toBe(1)
|
|
expect(reduced.pendingTailBytes).toBe(Buffer.byteLength(incompleteTail))
|
|
})
|
|
|
|
it('should scan a multibuffer CRLF line once without splitting UTF-8 metadata', async () => {
|
|
const sessionId = '10000004-aaaa-bbbb-cccc-eeeeeeeeeeee'
|
|
const projectDir = '-tmp-multibuffer-reducer'
|
|
const dir = path.join(tmpDir, 'projects', projectDir)
|
|
const filePath = path.join(dir, `${sessionId}.jsonl`)
|
|
const streamBufferBytes = 64 * 1024
|
|
const targetCharacterByteStart = streamBufferBytes * 3 - 1
|
|
const buildUserLine = (paddingLength: number) => JSON.stringify({
|
|
type: 'user',
|
|
padding: 'x'.repeat(paddingLength),
|
|
message: { role: 'user', content: '你 boundary title' },
|
|
timestamp: '2026-07-04T01:00:00.000Z',
|
|
cwd: '/fallback/from-user',
|
|
})
|
|
const emptyPaddingLine = buildUserLine(0)
|
|
const emptyCharacterIndex = emptyPaddingLine.indexOf('你')
|
|
const emptyCharacterByteStart = Buffer.byteLength(
|
|
emptyPaddingLine.slice(0, emptyCharacterIndex),
|
|
)
|
|
const userLine = buildUserLine(targetCharacterByteStart - emptyCharacterByteStart)
|
|
const characterIndex = userLine.indexOf('你')
|
|
expect(Buffer.byteLength(userLine.slice(0, characterIndex))).toBe(targetCharacterByteStart)
|
|
|
|
const sessionMetaLine = JSON.stringify({
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
workDir: '/metadata/workdir',
|
|
runtimeProviderId: 'boundary-provider',
|
|
runtimeModelId: 'boundary-model',
|
|
effortLevel: 'xhigh',
|
|
timestamp: '2026-07-04T02:00:00.000Z',
|
|
})
|
|
const firstCompleteLine = `${userLine}\r\n`
|
|
const secondCompleteLine = `${sessionMetaLine}\r\n`
|
|
const content = `${firstCompleteLine}${secondCompleteLine}`
|
|
await fs.mkdir(dir, { recursive: true })
|
|
await fs.writeFile(filePath, content, 'utf-8')
|
|
|
|
const stat = await fs.stat(filePath)
|
|
const scanner = service as unknown as {
|
|
scanSessionListSummary: (
|
|
targetPath: string,
|
|
targetProject: string,
|
|
targetStat: { birthtime: Date; mtime: Date },
|
|
) => Promise<SessionListSummary>
|
|
}
|
|
const originalConcat = Buffer.concat
|
|
let concatCalls = 0
|
|
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
|
|
concatCalls += 1
|
|
return originalConcat(...args)
|
|
}) as typeof Buffer.concat
|
|
|
|
let scanned: SessionListSummary
|
|
try {
|
|
scanned = await scanner.scanSessionListSummary(filePath, projectDir, stat)
|
|
} finally {
|
|
Buffer.concat = originalConcat
|
|
}
|
|
|
|
const seed: TranscriptProjection = {
|
|
summary: {
|
|
title: 'Untitled Session',
|
|
createdAt: stat.birthtime.toISOString(),
|
|
modifiedAt: stat.mtime.toISOString(),
|
|
messageCount: 0,
|
|
workDir: service.desanitizePath(projectDir),
|
|
},
|
|
indexedBytes: 0,
|
|
pendingTailBytes: 0,
|
|
malformedLineCount: 0,
|
|
}
|
|
const secondLineByteStart = Buffer.byteLength(firstCompleteLine)
|
|
const reduced = reduceTranscript([
|
|
{ text: firstCompleteLine, byteStart: 0, completeLine: true },
|
|
{ text: secondCompleteLine, byteStart: secondLineByteStart, completeLine: true },
|
|
], seed)
|
|
|
|
expect(concatCalls).toBe(1)
|
|
expect(scanned).toEqual(reduced.summary)
|
|
expect(scanned).toMatchObject({
|
|
title: '你 boundary title',
|
|
modifiedAt: '2026-07-04T01:00:00.000Z',
|
|
messageCount: 1,
|
|
workDir: '/metadata/workdir',
|
|
runtimeProviderId: 'boundary-provider',
|
|
runtimeModelId: 'boundary-model',
|
|
effortLevel: 'xhigh',
|
|
})
|
|
expect(reduced.indexedBytes).toBe(Buffer.byteLength(content))
|
|
expect(reduced.pendingTailBytes).toBe(0)
|
|
})
|
|
|
|
it('should reuse cached list metadata for repeated requests', async () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
const id = `2000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
|
|
const filePath = await writeSessionFile('-tmp-cached-sessions', id, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry(`Cached message ${i}`),
|
|
])
|
|
const mtime = new Date(Date.now() - i * 1000)
|
|
await fs.utimes(filePath, mtime, mtime)
|
|
}
|
|
|
|
const serviceWithSpy = service as unknown as {
|
|
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
|
|
let scanCount = 0
|
|
serviceWithSpy.scanSessionListSummary = async (...args) => {
|
|
scanCount += 1
|
|
return originalScanSessionListSummary(...args)
|
|
}
|
|
|
|
const first = await service.listSessions({ limit: 3, offset: 0 })
|
|
const second = await service.listSessions({ limit: 3, offset: 0 })
|
|
|
|
expect(first.sessions.map((session) => session.id)).toEqual(second.sessions.map((session) => session.id))
|
|
expect(scanCount).toBe(5)
|
|
})
|
|
|
|
it('should coalesce concurrent session list scans for the same query', async () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
const id = `2400000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
|
|
await writeSessionFile('-tmp-concurrent-session-list', id, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry(`Concurrent message ${i}`),
|
|
])
|
|
}
|
|
|
|
const serviceWithSpy = service as unknown as {
|
|
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
|
|
let scanCount = 0
|
|
let releaseFirstScan: () => void = () => {}
|
|
let markFirstScanStarted: () => void = () => {}
|
|
const firstScanStarted = new Promise<void>((resolve) => {
|
|
markFirstScanStarted = resolve
|
|
})
|
|
const firstScanGate = new Promise<void>((resolve) => {
|
|
releaseFirstScan = resolve
|
|
})
|
|
|
|
serviceWithSpy.scanSessionListSummary = async (...args) => {
|
|
scanCount += 1
|
|
if (scanCount === 1) {
|
|
markFirstScanStarted()
|
|
await firstScanGate
|
|
}
|
|
return originalScanSessionListSummary(...args)
|
|
}
|
|
|
|
const first = service.listSessions({ limit: 3, offset: 0 })
|
|
await firstScanStarted
|
|
const second = service.listSessions({ limit: 3, offset: 0 })
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
releaseFirstScan()
|
|
|
|
const [firstResult, secondResult] = await Promise.all([first, second])
|
|
|
|
expect(firstResult).toEqual(secondResult)
|
|
expect(scanCount).toBe(3)
|
|
})
|
|
|
|
it('should isolate warm session list caches by the active config scope', async () => {
|
|
const scopeRoot = path.join(tmpDir, 'session-list-cache-scopes')
|
|
const firstConfigDir = path.join(scopeRoot, 'first')
|
|
const secondConfigDir = path.join(scopeRoot, 'second')
|
|
const seedScope = async (configDir: string, sessionId: string, title: string) => {
|
|
const projectDir = path.join(configDir, 'projects', '-tmp-cache-scope')
|
|
await fs.mkdir(projectDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(projectDir, `${sessionId}.jsonl`),
|
|
`${JSON.stringify(makeUserEntry(title))}\n`,
|
|
'utf8',
|
|
)
|
|
}
|
|
await seedScope(
|
|
firstConfigDir,
|
|
'24100000-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
'First scope title',
|
|
)
|
|
await seedScope(
|
|
secondConfigDir,
|
|
'24100001-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
'Second scope title',
|
|
)
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = firstConfigDir
|
|
const first = await service.listSessions({ limit: 10, offset: 0 })
|
|
process.env.CLAUDE_CONFIG_DIR = secondConfigDir
|
|
const second = await service.listSessions({ limit: 10, offset: 0 })
|
|
|
|
expect(first.sessions.map(session => session.id)).toEqual([
|
|
'24100000-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
])
|
|
expect(second.sessions.map(session => session.id)).toEqual([
|
|
'24100001-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
])
|
|
})
|
|
|
|
it('should bound session page and summary caches with LRU retention', async () => {
|
|
let now = 1_000
|
|
const projects = ['-tmp-bounded-cache-a', '-tmp-bounded-cache-b', '-tmp-bounded-cache-c']
|
|
for (let index = 0; index < projects.length; index += 1) {
|
|
await writeSessionFile(
|
|
projects[index]!,
|
|
`2420000${index}-bbbb-cccc-dddd-eeeeeeeeeeee`,
|
|
[makeUserEntry(`Bounded cache ${index}`)],
|
|
)
|
|
}
|
|
const boundedService = new SessionService(undefined, {
|
|
now: () => now,
|
|
sessionListCacheMaxEntries: 2,
|
|
sessionListSummaryCacheMaxEntries: 2,
|
|
})
|
|
const internals = boundedService as unknown as {
|
|
sessionListCache: Map<string, unknown>
|
|
sessionListSummaryCache: Map<string, unknown>
|
|
}
|
|
|
|
await boundedService.listSessions({ project: '/tmp/bounded/cache/a', limit: 1 })
|
|
await boundedService.listSessions({ project: '/tmp/bounded/cache/b', limit: 1 })
|
|
now += 6_000
|
|
await boundedService.listSessions({ project: '/tmp/bounded/cache/a', limit: 1 })
|
|
await boundedService.listSessions({ project: '/tmp/bounded/cache/c', limit: 1 })
|
|
|
|
expect(internals.sessionListCache.size).toBe(2)
|
|
expect([...internals.sessionListCache.keys()].some(key => (
|
|
JSON.parse(key) as { project: string }
|
|
).project === '/tmp/bounded/cache/a')).toBe(true)
|
|
expect([...internals.sessionListCache.keys()].some(key => (
|
|
JSON.parse(key) as { project: string }
|
|
).project === '/tmp/bounded/cache/b')).toBe(false)
|
|
expect(internals.sessionListSummaryCache.size).toBe(2)
|
|
expect([...internals.sessionListSummaryCache.keys()].some(filePath => (
|
|
filePath.includes('-tmp-bounded-cache-a')
|
|
))).toBe(true)
|
|
expect([...internals.sessionListSummaryCache.keys()].some(filePath => (
|
|
filePath.includes('-tmp-bounded-cache-b')
|
|
))).toBe(false)
|
|
})
|
|
|
|
it('should remove a deleted transcript from the session summary cache', async () => {
|
|
const sessionId = '24205000-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const filePath = await writeSessionFile(
|
|
'-tmp-deleted-summary-cache',
|
|
sessionId,
|
|
[makeUserEntry('Delete cached summary')],
|
|
)
|
|
const internals = service as unknown as {
|
|
sessionListSummaryCache: Map<string, unknown>
|
|
}
|
|
|
|
await service.listSessions({ limit: 10 })
|
|
expect(internals.sessionListSummaryCache.has(filePath)).toBe(true)
|
|
await service.deleteSession(sessionId)
|
|
|
|
expect(internals.sessionListSummaryCache.has(filePath)).toBe(false)
|
|
})
|
|
|
|
it('should remove expired session pages and caches from inactive config scopes', async () => {
|
|
let now = 1_000
|
|
const boundedService = new SessionService(undefined, { now: () => now })
|
|
const internals = boundedService as unknown as {
|
|
sessionListCache: Map<string, unknown>
|
|
sessionListSummaryCache: Map<string, unknown>
|
|
}
|
|
const scopeRoot = path.join(tmpDir, 'bounded-cache-scopes')
|
|
const firstConfigDir = path.join(scopeRoot, 'first')
|
|
const secondConfigDir = path.join(scopeRoot, 'second')
|
|
const seedScope = async (configDir: string, sessionId: string) => {
|
|
const projectDir = path.join(configDir, 'projects', '-tmp-bounded-scope')
|
|
await fs.mkdir(projectDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(projectDir, `${sessionId}.jsonl`),
|
|
`${JSON.stringify(makeUserEntry(sessionId))}\n`,
|
|
'utf8',
|
|
)
|
|
}
|
|
await seedScope(firstConfigDir, '24210000-bbbb-cccc-dddd-eeeeeeeeeeee')
|
|
await seedScope(secondConfigDir, '24210001-bbbb-cccc-dddd-eeeeeeeeeeee')
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = firstConfigDir
|
|
await boundedService.listSessions({ limit: 1, offset: 0 })
|
|
now += 6_000
|
|
await boundedService.listSessions({ limit: 1, offset: 1 })
|
|
expect(internals.sessionListCache.size).toBe(1)
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = secondConfigDir
|
|
await boundedService.listSessions({ limit: 1, offset: 0 })
|
|
|
|
expect([...internals.sessionListCache.keys()].every(key => (
|
|
JSON.parse(key) as { scope: string }
|
|
).scope === path.resolve(secondConfigDir))).toBe(true)
|
|
expect([...internals.sessionListSummaryCache.keys()].every(filePath => (
|
|
filePath.startsWith(`${path.resolve(secondConfigDir)}${path.sep}`)
|
|
))).toBe(true)
|
|
})
|
|
|
|
it('should not coalesce in-flight session list scans across config scopes', async () => {
|
|
const scopeRoot = path.join(tmpDir, 'session-list-request-scopes')
|
|
const firstConfigDir = path.join(scopeRoot, 'first')
|
|
const secondConfigDir = path.join(scopeRoot, 'second')
|
|
const seedScope = async (configDir: string, sessionId: string, title: string) => {
|
|
const projectDir = path.join(configDir, 'projects', '-tmp-request-scope')
|
|
await fs.mkdir(projectDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(projectDir, `${sessionId}.jsonl`),
|
|
`${JSON.stringify(makeUserEntry(title))}\n`,
|
|
'utf8',
|
|
)
|
|
}
|
|
await seedScope(
|
|
firstConfigDir,
|
|
'24300000-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
'First in-flight scope',
|
|
)
|
|
await seedScope(
|
|
secondConfigDir,
|
|
'24300001-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
'Second in-flight scope',
|
|
)
|
|
|
|
const serviceWithSpy = service as unknown as {
|
|
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
|
|
let releaseFirstScan: () => void = () => {}
|
|
let markFirstScanStarted: () => void = () => {}
|
|
const firstScanStarted = new Promise<void>((resolve) => {
|
|
markFirstScanStarted = resolve
|
|
})
|
|
const firstScanGate = new Promise<void>((resolve) => {
|
|
releaseFirstScan = resolve
|
|
})
|
|
serviceWithSpy.scanSessionListSummary = async (...args) => {
|
|
if (String(args[0]).startsWith(firstConfigDir)) {
|
|
markFirstScanStarted()
|
|
await firstScanGate
|
|
}
|
|
return originalScanSessionListSummary(...args)
|
|
}
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = firstConfigDir
|
|
const firstScopeRequest = service.listSessions({ limit: 10, offset: 0 })
|
|
await firstScanStarted
|
|
process.env.CLAUDE_CONFIG_DIR = secondConfigDir
|
|
const secondScopeRequest = service.listSessions({ limit: 10, offset: 0 })
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
releaseFirstScan()
|
|
|
|
const [first, second] = await Promise.all([firstScopeRequest, secondScopeRequest])
|
|
expect(first.sessions.map(session => session.id)).toEqual([
|
|
'24300000-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
])
|
|
expect(second.sessions.map(session => session.id)).toEqual([
|
|
'24300001-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
])
|
|
})
|
|
|
|
it('should coalesce file summary scans across concurrent pagination queries', async () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
const id = `2420000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
|
|
await writeSessionFile('-tmp-concurrent-session-pages', id, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry(`Concurrent page message ${i}`),
|
|
])
|
|
}
|
|
|
|
const serviceWithSpy = service as unknown as {
|
|
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
|
|
let scanCount = 0
|
|
let releaseFirstScan: () => void = () => {}
|
|
let markFirstScanStarted: () => void = () => {}
|
|
const firstScanStarted = new Promise<void>((resolve) => {
|
|
markFirstScanStarted = resolve
|
|
})
|
|
const firstScanGate = new Promise<void>((resolve) => {
|
|
releaseFirstScan = resolve
|
|
})
|
|
|
|
serviceWithSpy.scanSessionListSummary = async (...args) => {
|
|
scanCount += 1
|
|
if (scanCount === 1) {
|
|
markFirstScanStarted()
|
|
await firstScanGate
|
|
}
|
|
return originalScanSessionListSummary(...args)
|
|
}
|
|
|
|
const sidebarRequest = service.listSessions({ limit: 400, offset: 0 })
|
|
await firstScanStarted
|
|
const tabRestoreRequest = service.listSessions({ limit: 200, offset: 0 })
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
releaseFirstScan()
|
|
|
|
const [sidebarResult, tabRestoreResult] = await Promise.all([
|
|
sidebarRequest,
|
|
tabRestoreRequest,
|
|
])
|
|
|
|
expect(sidebarResult.sessions).toHaveLength(3)
|
|
expect(tabRestoreResult.sessions).toHaveLength(3)
|
|
expect(scanCount).toBe(3)
|
|
})
|
|
|
|
it('should not reuse or cache a list scan started before session metadata changes', async () => {
|
|
const sessionId = '24500000-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-invalidated-session-list', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry('Original title'),
|
|
])
|
|
|
|
const serviceWithSpy = service as unknown as {
|
|
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
|
|
let scanCount = 0
|
|
let releaseFirstScan: () => void = () => {}
|
|
let markFirstScanStarted: () => void = () => {}
|
|
const firstScanStarted = new Promise<void>((resolve) => {
|
|
markFirstScanStarted = resolve
|
|
})
|
|
const firstScanGate = new Promise<void>((resolve) => {
|
|
releaseFirstScan = resolve
|
|
})
|
|
|
|
serviceWithSpy.scanSessionListSummary = async (...args) => {
|
|
scanCount += 1
|
|
const summary = await originalScanSessionListSummary(...args)
|
|
if (scanCount === 1) {
|
|
markFirstScanStarted()
|
|
await firstScanGate
|
|
}
|
|
return summary
|
|
}
|
|
|
|
const staleRequest = service.listSessions({ limit: 10, offset: 0 })
|
|
await firstScanStarted
|
|
await service.renameSession(sessionId, 'Renamed while scanning')
|
|
|
|
const freshRequest = service.listSessions({ limit: 10, offset: 0 })
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
expect(scanCount).toBe(2)
|
|
|
|
const freshResult = await freshRequest
|
|
expect(freshResult.sessions[0]?.title).toBe('Renamed while scanning')
|
|
|
|
releaseFirstScan()
|
|
const staleResult = await staleRequest
|
|
expect(staleResult.sessions[0]?.title).toBe('Original title')
|
|
|
|
const cachedResult = await service.listSessions({ limit: 10, offset: 0 })
|
|
expect(cachedResult.sessions[0]?.title).toBe('Renamed while scanning')
|
|
})
|
|
|
|
it('should reuse unchanged file summaries after the list response cache is cleared', async () => {
|
|
const sessionFiles: Array<{ id: string; filePath: string }> = []
|
|
for (let i = 0; i < 3; i++) {
|
|
const id = `2500000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
|
|
const filePath = await writeSessionFile('-tmp-file-summary-cache', id, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry(`Cached file summary ${i}`),
|
|
])
|
|
const mtime = new Date(Date.now() - i * 1000)
|
|
await fs.utimes(filePath, mtime, mtime)
|
|
sessionFiles.push({ id, filePath })
|
|
}
|
|
|
|
const serviceWithSpy = service as unknown as {
|
|
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const serviceInternals = service as unknown as {
|
|
sessionListCache: Map<string, unknown>
|
|
}
|
|
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
|
|
let scanCount = 0
|
|
serviceWithSpy.scanSessionListSummary = async (...args) => {
|
|
scanCount += 1
|
|
return originalScanSessionListSummary(...args)
|
|
}
|
|
|
|
await service.listSessions({ limit: 3, offset: 0 })
|
|
expect(scanCount).toBe(3)
|
|
|
|
serviceInternals.sessionListCache.clear()
|
|
const second = await service.listSessions({ limit: 3, offset: 0 })
|
|
expect(second.sessions).toHaveLength(3)
|
|
expect(scanCount).toBe(3)
|
|
|
|
await fs.appendFile(
|
|
sessionFiles[1]!.filePath,
|
|
`${JSON.stringify({
|
|
type: 'custom-title',
|
|
customTitle: 'Changed cached file summary',
|
|
timestamp: new Date().toISOString(),
|
|
})}\n`,
|
|
'utf-8',
|
|
)
|
|
serviceInternals.sessionListCache.clear()
|
|
|
|
const third = await service.listSessions({ limit: 3, offset: 0 })
|
|
expect(third.sessions.find((session) => session.id === sessionFiles[1]!.id)?.title)
|
|
.toBe('Changed cached file summary')
|
|
expect(scanCount).toBe(4)
|
|
})
|
|
|
|
it('should invalidate cached list metadata after writes', async () => {
|
|
const sessionId = '30000000-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-cache-invalidation', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry('Original title'),
|
|
])
|
|
|
|
const first = await service.listSessions({ limit: 10, offset: 0 })
|
|
expect(first.sessions[0]!.title).toBe('Original title')
|
|
|
|
await service.renameSession(sessionId, 'Renamed title')
|
|
const second = await service.listSessions({ limit: 10, offset: 0 })
|
|
|
|
expect(second.sessions[0]!.title).toBe('Renamed title')
|
|
})
|
|
|
|
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 derive session detail modifiedAt from transcript messages', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const filePath = await writeSessionFile('-tmp-project', sessionId, [
|
|
{
|
|
...makeSessionMetaEntry('/tmp/project'),
|
|
timestamp: '2026-01-03T00:00:00.000Z',
|
|
},
|
|
{
|
|
...makeUserEntry('Earlier user work'),
|
|
timestamp: '2026-01-01T00:01:00.000Z',
|
|
},
|
|
{
|
|
...makeAssistantEntry('Earlier assistant reply'),
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
},
|
|
{
|
|
type: 'custom-title',
|
|
customTitle: 'Later title metadata',
|
|
timestamp: '2026-01-04T00:00:00.000Z',
|
|
},
|
|
])
|
|
const mtime = new Date('2026-01-05T00:00:00.000Z')
|
|
await fs.utimes(filePath, mtime, mtime)
|
|
|
|
const detail = await service.getSession(sessionId)
|
|
|
|
expect(detail?.modifiedAt).toBe('2026-01-01T00:02:00.000Z')
|
|
})
|
|
|
|
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('preserves structured toolUseResult metadata for AskUserQuestion answers', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'ask-1',
|
|
content: 'User has answered your questions: "Pick one?"="A". You can now continue with the user\'s answers in mind.',
|
|
},
|
|
],
|
|
},
|
|
toolUseResult: {
|
|
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
|
|
answers: { 'Pick one?': 'A' },
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:01.000Z',
|
|
},
|
|
])
|
|
|
|
const messages = await service.getSessionMessages(sessionId)
|
|
|
|
expect(messages).toHaveLength(1)
|
|
expect(messages[0]).toMatchObject({
|
|
type: 'tool_result',
|
|
toolUseResult: {
|
|
answers: { 'Pick one?': 'A' },
|
|
},
|
|
})
|
|
})
|
|
|
|
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' },
|
|
original_tool_use_id: 'Read:0',
|
|
},
|
|
])
|
|
expect(childToolResult?.content).toEqual([
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'Agent:0/abc123/Read:0',
|
|
content: 'alpha body',
|
|
original_tool_use_id: 'Read:0',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('should include linked subagent transcript changes in the message signature', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const projectDir = '-tmp-project'
|
|
const agentId = 'abc123'
|
|
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
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}`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:03.000Z',
|
|
},
|
|
])
|
|
const subagentFile = 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',
|
|
},
|
|
])
|
|
|
|
const before = await service.getSessionMessagesSignature(sessionId)
|
|
|
|
await fs.appendFile(subagentFile, `${JSON.stringify({
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'Read:0',
|
|
content: 'updated alpha body',
|
|
},
|
|
],
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:05.000Z',
|
|
})}\n`)
|
|
|
|
const after = await service.getSessionMessagesSignature(sessionId)
|
|
|
|
expect(before).not.toBe(after)
|
|
})
|
|
|
|
it('should hide synthetic interruption, no-response, and malformed 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',
|
|
},
|
|
{
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<command-name>/agent</command-name>\n<command-message>agent</command-message>\n<command-args>Plan 222</command-args>',
|
|
},
|
|
],
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:05.000Z',
|
|
},
|
|
{
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: '<command-name>/agent</command-name> malformed breadcrumb',
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:06.000Z',
|
|
},
|
|
makeAssistantEntry('正常助手消息', crypto.randomUUID()),
|
|
])
|
|
|
|
const messages = await service.getSessionMessages(sessionId)
|
|
|
|
expect(messages).toHaveLength(4)
|
|
expect(messages[0]).toMatchObject({ type: 'user', content: '正常用户消息' })
|
|
expect(messages[1]).toMatchObject({
|
|
type: 'user',
|
|
content: '<command-name>/exit</command-name>\n<command-message>exit</command-message>\n<command-args></command-args>',
|
|
})
|
|
expect(messages[2]).toMatchObject({
|
|
type: 'user',
|
|
content: [{
|
|
type: 'text',
|
|
text: '<command-name>/agent</command-name>\n<command-message>agent</command-message>\n<command-args>Plan 222</command-args>',
|
|
}],
|
|
})
|
|
expect(messages[3]).toMatchObject({
|
|
type: 'assistant',
|
|
content: [{ type: 'text', text: '正常助手消息' }],
|
|
})
|
|
})
|
|
|
|
it('should keep user-invoked skill command metadata for desktop history restore', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry([
|
|
'<command-message>frontend-design</command-message>',
|
|
'<command-name>/frontend-design</command-name>',
|
|
'<command-args>redesign the settings page</command-args>',
|
|
].join('\n'), 'skill-command-user'),
|
|
makeAssistantEntry('正常助手消息', 'skill-command-user'),
|
|
])
|
|
|
|
const messages = await service.getSessionMessages(sessionId)
|
|
|
|
expect(messages).toHaveLength(2)
|
|
const skillCommandContent = String(messages[0]!.content)
|
|
expect(messages[0]).toMatchObject({
|
|
id: 'skill-command-user',
|
|
type: 'user',
|
|
content: expect.stringContaining('<command-name>/frontend-design</command-name>'),
|
|
})
|
|
expect(skillCommandContent).toContain('<command-args>redesign the settings page</command-args>')
|
|
expect(messages[1]).toMatchObject({ type: 'assistant' })
|
|
})
|
|
|
|
it('should keep /goal local command transcript entries for desktop history restore', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
parentUuid: null,
|
|
isSidechain: false,
|
|
type: 'system',
|
|
subtype: 'local_command',
|
|
content: '<command-name>/goal</command-name>\n<command-message>goal</command-message>\n<command-args>ship persisted goal</command-args>',
|
|
level: 'info',
|
|
timestamp: '2026-01-01T00:00:01.000Z',
|
|
uuid: 'goal-command',
|
|
},
|
|
{
|
|
parentUuid: 'goal-command',
|
|
isSidechain: false,
|
|
type: 'system',
|
|
subtype: 'local_command',
|
|
content: '<local-command-stdout>Goal set: ship persisted goal</local-command-stdout>',
|
|
level: 'info',
|
|
timestamp: '2026-01-01T00:00:02.000Z',
|
|
uuid: 'goal-output',
|
|
},
|
|
{
|
|
parentUuid: 'goal-output',
|
|
isSidechain: false,
|
|
type: 'system',
|
|
subtype: 'local_command',
|
|
content: '<local-command-stdout>Goal continuing: verify persisted follow-up</local-command-stdout>',
|
|
level: 'info',
|
|
timestamp: '2026-01-01T00:00:03.000Z',
|
|
uuid: 'goal-continuing',
|
|
},
|
|
makeAssistantEntry('正常助手消息', crypto.randomUUID()),
|
|
])
|
|
|
|
const messages = await service.getSessionMessages(sessionId)
|
|
|
|
expect(messages).toMatchObject([
|
|
{
|
|
id: 'goal-command',
|
|
type: 'system',
|
|
content: expect.stringContaining('<command-name>/goal</command-name>'),
|
|
},
|
|
{
|
|
id: 'goal-output',
|
|
type: 'system',
|
|
content: expect.stringContaining('Goal set: ship persisted goal'),
|
|
},
|
|
{
|
|
id: 'goal-continuing',
|
|
type: 'system',
|
|
content: expect.stringContaining('Goal continuing: verify persisted follow-up'),
|
|
},
|
|
{
|
|
type: 'assistant',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('should hide task-notification turns and their automatic responses from history', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const firstUserId = crypto.randomUUID()
|
|
const firstAssistantId = crypto.randomUUID()
|
|
const taskNotificationId = crypto.randomUUID()
|
|
const taskAssistantId = crypto.randomUUID()
|
|
const taskToolUseMessageId = crypto.randomUUID()
|
|
const taskToolResultId = crypto.randomUUID()
|
|
const taskAfterToolId = crypto.randomUUID()
|
|
const realFollowUpId = crypto.randomUUID()
|
|
const realAssistantId = crypto.randomUUID()
|
|
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
...makeUserEntry('创建一个项目', firstUserId),
|
|
parentUuid: null,
|
|
},
|
|
{
|
|
...makeAssistantEntry('项目已经创建', firstUserId),
|
|
uuid: firstAssistantId,
|
|
},
|
|
{
|
|
...makeUserEntry(
|
|
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
|
|
taskNotificationId,
|
|
),
|
|
parentUuid: firstAssistantId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('旧后台任务通知,无需处理', taskNotificationId),
|
|
uuid: taskAssistantId,
|
|
},
|
|
{
|
|
...makeAssistantToolUseEntry([{
|
|
id: 'toolu_restart',
|
|
name: 'Bash',
|
|
input: { command: 'npm run dev' },
|
|
}], taskAssistantId),
|
|
uuid: taskToolUseMessageId,
|
|
},
|
|
{
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [{
|
|
type: 'tool_result',
|
|
tool_use_id: 'toolu_restart',
|
|
content: 'server restarted',
|
|
}],
|
|
},
|
|
uuid: taskToolResultId,
|
|
parentUuid: taskToolUseMessageId,
|
|
timestamp: '2026-01-01T00:03:00.000Z',
|
|
},
|
|
{
|
|
...makeAssistantEntry('后台任务触发的工具调用完成', taskToolResultId),
|
|
uuid: taskAfterToolId,
|
|
},
|
|
{
|
|
...makeUserEntry('继续真实问题', realFollowUpId),
|
|
parentUuid: taskAfterToolId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('真实回答', realFollowUpId),
|
|
uuid: realAssistantId,
|
|
},
|
|
])
|
|
|
|
const messages = await service.getSessionMessages(sessionId)
|
|
const taskNotifications = await service.getSessionTaskNotifications(sessionId)
|
|
|
|
expect(messages.map((message) => message.id)).toEqual([
|
|
firstUserId,
|
|
firstAssistantId,
|
|
realFollowUpId,
|
|
realAssistantId,
|
|
])
|
|
expect(JSON.stringify(messages)).not.toContain('<task-notification>')
|
|
expect(JSON.stringify(messages)).not.toContain('旧后台任务通知')
|
|
expect(JSON.stringify(messages)).not.toContain('server restarted')
|
|
expect(JSON.stringify(messages)).not.toContain('后台任务触发的工具调用完成')
|
|
expect(taskNotifications).toEqual([
|
|
{
|
|
taskId: 'bg-1',
|
|
toolUseId: 'toolu_bg',
|
|
status: 'completed',
|
|
summary: 'Background command completed',
|
|
timestamp: '2026-01-01T00:01:00.000Z',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('uses bounded locators for snapshots and task notifications with safe fallback', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
|
const projectDir = '-tmp-locator-consumers'
|
|
const snapshotMessageId = crypto.randomUUID()
|
|
const taskNotification = [
|
|
'<task-notification>',
|
|
'<task-id>locator-task</task-id>',
|
|
'<tool-use-id>toolu_locator</tool-use-id>',
|
|
'<status>completed</status>',
|
|
'<summary>Locator completed</summary>',
|
|
'</task-notification>',
|
|
].join('\n')
|
|
const filePath = await writeSessionFile(projectDir, sessionId, [
|
|
makeFileHistorySnapshotEntry(snapshotMessageId, {}),
|
|
makeUserEntry(taskNotification, 'task-notification-locator'),
|
|
makeAssistantEntry('x'.repeat(256 * 1024), 'task-notification-locator'),
|
|
])
|
|
const snapshot = await fs.stat(filePath)
|
|
const database = openLocalIndexDatabase({ path: path.join(tmpDir, 'index-v1.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: tmpDir })
|
|
await projector.projectSource({
|
|
path: filePath,
|
|
sessionId,
|
|
projectPath: projectDir,
|
|
fallbackCreatedAt: snapshot.birthtime.toISOString(),
|
|
fallbackModifiedAt: snapshot.mtime.toISOString(),
|
|
fallbackWorkDir: '/tmp/locator-consumers',
|
|
modifiedAtMs: snapshot.mtimeMs,
|
|
})
|
|
|
|
let mode: 'off' | 'shadow' | 'on' = 'on'
|
|
let locatorCalls = 0
|
|
let serveWrongEmptyFingerprint = false
|
|
const gateway: LocalIndexGateway = {
|
|
async start() {},
|
|
async stop() {},
|
|
getMode: () => mode,
|
|
getPublicStatus: () => ({
|
|
mode,
|
|
state: mode === 'off' ? 'off' : 'ready',
|
|
discovered: 1,
|
|
indexed: 1,
|
|
degradedSources: 0,
|
|
databaseBytes: 0,
|
|
walBytes: 0,
|
|
lastUpdatedAt: '2026-07-15T00:00:00.000Z',
|
|
lastErrorCode: null,
|
|
}),
|
|
isSessionScopeReady: () => mode !== 'off',
|
|
rebuild: async () => gateway.getPublicStatus(),
|
|
listSessions: options => index.listSessions(options),
|
|
findSessionFiles: id => index.findSessionFiles(id),
|
|
getSessionEntryLocators: (transcriptPath, entryTypes) => {
|
|
locatorCalls += 1
|
|
const page = index.getSessionEntryLocators(transcriptPath, entryTypes)
|
|
if (!page || !serveWrongEmptyFingerprint) return page
|
|
return {
|
|
source: {
|
|
...page.source,
|
|
fileIdentity: null,
|
|
fingerprint: 'wrong-fingerprint',
|
|
},
|
|
entries: [],
|
|
}
|
|
},
|
|
}
|
|
const targetedReads: Array<{ bytesRead: number; rangesRead: number }> = []
|
|
const indexedService = new SessionService(gateway, {
|
|
targetedEntryReader: async options => {
|
|
const result = await readSessionEntriesByLocator(options)
|
|
if (result) targetedReads.push(result)
|
|
return result
|
|
},
|
|
})
|
|
|
|
try {
|
|
expect(await indexedService.getSessionFileHistorySnapshots(sessionId)).toEqual([
|
|
expect.objectContaining({ messageId: snapshotMessageId }),
|
|
])
|
|
expect(await indexedService.getSessionTaskNotifications(sessionId)).toEqual([{
|
|
taskId: 'locator-task',
|
|
toolUseId: 'toolu_locator',
|
|
status: 'completed',
|
|
summary: 'Locator completed',
|
|
timestamp: '2026-01-01T00:01:00.000Z',
|
|
}])
|
|
const fileSize = (await fs.stat(filePath)).size
|
|
expect(targetedReads).toHaveLength(2)
|
|
expect(targetedReads.every(read => read.rangesRead === 1)).toBeTrue()
|
|
expect(targetedReads.every(read => read.bytesRead < fileSize)).toBeTrue()
|
|
|
|
serveWrongEmptyFingerprint = true
|
|
expect(await indexedService.getSessionFileHistorySnapshots(sessionId)).toEqual([
|
|
expect.objectContaining({ messageId: snapshotMessageId }),
|
|
])
|
|
serveWrongEmptyFingerprint = false
|
|
|
|
const callsBeforeFullHistory = locatorCalls
|
|
expect(await indexedService.getSessionMessages(sessionId)).toHaveLength(0)
|
|
expect(locatorCalls).toBe(callsBeforeFullHistory)
|
|
|
|
mode = 'shadow'
|
|
const callsBeforeShadow = locatorCalls
|
|
expect(await indexedService.getSessionTaskNotifications(sessionId)).toHaveLength(1)
|
|
expect(locatorCalls).toBe(callsBeforeShadow)
|
|
|
|
mode = 'on'
|
|
const fallbackService = new SessionService(gateway, {
|
|
targetedEntryReader: async () => {
|
|
throw new Error('injected range read failure')
|
|
},
|
|
})
|
|
expect(await fallbackService.getSessionFileHistorySnapshots(sessionId)).toEqual([
|
|
expect.objectContaining({ messageId: snapshotMessageId }),
|
|
])
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('should reconstruct parent agent tool linkage from parentUuid chains', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const userUuid = crypto.randomUUID()
|
|
const agentAssistantUuid = crypto.randomUUID()
|
|
const childAssistantUuid = crypto.randomUUID()
|
|
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry('Inspect the codebase', userUuid),
|
|
{
|
|
parentUuid: userUuid,
|
|
isSidechain: false,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
name: 'Agent',
|
|
id: 'agent-tool-1',
|
|
input: { description: 'Inspect src/components' },
|
|
},
|
|
],
|
|
},
|
|
uuid: agentAssistantUuid,
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
},
|
|
{
|
|
parentUuid: agentAssistantUuid,
|
|
isSidechain: true,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
name: 'Read',
|
|
id: 'read-tool-1',
|
|
input: { file_path: 'src/components/App.tsx' },
|
|
},
|
|
],
|
|
},
|
|
uuid: childAssistantUuid,
|
|
timestamp: '2026-01-01T00:02:30.000Z',
|
|
},
|
|
{
|
|
parentUuid: childAssistantUuid,
|
|
isSidechain: true,
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'read-tool-1',
|
|
content: 'ok',
|
|
is_error: false,
|
|
},
|
|
],
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:03:00.000Z',
|
|
userType: 'external',
|
|
cwd: '/tmp/test',
|
|
sessionId: 'test-session',
|
|
},
|
|
])
|
|
|
|
const messages = await service.getSessionMessages(sessionId)
|
|
|
|
expect(messages[1]).toMatchObject({
|
|
type: 'tool_use',
|
|
parentToolUseId: undefined,
|
|
})
|
|
expect(messages[2]).toMatchObject({
|
|
type: 'tool_use',
|
|
parentToolUseId: 'agent-tool-1',
|
|
})
|
|
expect(messages[3]).toMatchObject({
|
|
type: 'tool_result',
|
|
parentToolUseId: 'agent-tool-1',
|
|
})
|
|
})
|
|
|
|
it('should recover workDir from session-meta entries', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry('/tmp/from-meta'),
|
|
makeUserEntry('Hello'),
|
|
])
|
|
|
|
const workDir = await service.getSessionWorkDir(sessionId)
|
|
expect(workDir).toBe('/tmp/from-meta')
|
|
})
|
|
|
|
it('should recover workDir from the latest session-meta entry', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry('/tmp/old-worktree'),
|
|
makeUserEntry('Hello'),
|
|
makeSessionMetaEntry('/tmp/latest-worktree'),
|
|
])
|
|
|
|
const workDir = await service.getSessionWorkDir(sessionId)
|
|
expect(workDir).toBe('/tmp/latest-worktree')
|
|
})
|
|
|
|
it('should prefer the newest duplicate session file when worktree metadata moves', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const sourceFile = await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry('/tmp/project'),
|
|
])
|
|
const worktreeFile = await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry('/tmp/project/.claude/worktrees/desktop-main-12345678'),
|
|
])
|
|
|
|
const oldTime = new Date('2026-01-01T00:00:00.000Z')
|
|
const newTime = new Date('2026-01-01T00:00:01.000Z')
|
|
await fs.utimes(sourceFile, oldTime, oldTime)
|
|
await fs.utimes(worktreeFile, newTime, newTime)
|
|
|
|
const workDir = await service.getSessionWorkDir(sessionId)
|
|
expect(workDir).toBe('/tmp/project/.claude/worktrees/desktop-main-12345678')
|
|
})
|
|
|
|
it('should recover CLI worktree state from transcript metadata', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry('/tmp/project/.claude/worktrees/desktop-main-12345678'),
|
|
makeWorktreeStateEntry(sessionId, '/tmp/project/.claude/worktrees/desktop-main-12345678', {
|
|
originalCwd: '/tmp/project',
|
|
}),
|
|
makeUserEntry('Hello from CLI worktree'),
|
|
])
|
|
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
expect(launchInfo?.worktreeSession).toMatchObject({
|
|
originalCwd: '/tmp/project',
|
|
worktreePath: '/tmp/project/.claude/worktrees/desktop-main-12345678',
|
|
worktreeName: 'desktop-main-12345678',
|
|
worktreeBranch: 'worktree-desktop-main-12345678',
|
|
originalBranch: 'main',
|
|
})
|
|
})
|
|
|
|
it('should preserve repository metadata when replacing placeholder transcripts', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: true },
|
|
)
|
|
|
|
await service.clearSessionTranscript(sessionId, sessionWorkDir)
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
|
|
expect(launchInfo?.workDir).toBe(sessionWorkDir)
|
|
expect(launchInfo?.repository).toMatchObject({
|
|
requestedWorkDir: await fs.realpath(workDir),
|
|
worktree: true,
|
|
worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')),
|
|
})
|
|
})
|
|
|
|
it('should preserve permission metadata when clearing placeholder transcripts', async () => {
|
|
const workDir = path.join(tmpDir, 'clear-permission-workdir')
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
const { sessionId } = await (service.createSession as unknown as (
|
|
workDir?: string,
|
|
repositoryOptions?: unknown,
|
|
permissionMode?: string,
|
|
) => Promise<{ sessionId: string; workDir: string }>)(workDir, undefined, 'acceptEdits')
|
|
|
|
await service.clearSessionTranscript(sessionId, workDir)
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
|
|
expect(launchInfo?.workDir).toBe(await fs.realpath(workDir))
|
|
expect(launchInfo?.permissionMode).toBe('acceptEdits')
|
|
})
|
|
|
|
it('should persist session permission mode in launch metadata', async () => {
|
|
const workDir = path.join(tmpDir, 'permission-workdir')
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
const { sessionId } = await (service.createSession as unknown as (
|
|
workDir?: string,
|
|
repositoryOptions?: unknown,
|
|
permissionMode?: string,
|
|
) => Promise<{ sessionId: string; workDir: string }>)(workDir, undefined, 'acceptEdits')
|
|
|
|
let launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
expect(launchInfo?.permissionMode).toBe('acceptEdits')
|
|
|
|
await (service.appendSessionMetadata as unknown as (
|
|
sessionId: string,
|
|
metadata: { workDir: string; permissionMode?: string },
|
|
) => Promise<void>)(sessionId, {
|
|
workDir,
|
|
permissionMode: 'plan',
|
|
})
|
|
|
|
launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
expect(launchInfo?.permissionMode).toBe('plan')
|
|
})
|
|
|
|
it('should round-trip auto through creation, list, metadata update, restore, and clear', async () => {
|
|
const workDir = path.join(tmpDir, 'auto-permission-workdir')
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
|
|
const { sessionId } = await service.createSession(workDir, undefined, 'auto')
|
|
|
|
expect((await service.getSessionLaunchInfo(sessionId))?.permissionMode).toBe('auto')
|
|
expect(
|
|
(await service.listSessions()).sessions.find((session) => session.id === sessionId)
|
|
?.permissionMode,
|
|
).toBe('auto')
|
|
|
|
await service.appendSessionMetadata(sessionId, {
|
|
workDir,
|
|
permissionMode: 'default',
|
|
})
|
|
await service.appendSessionMetadata(sessionId, {
|
|
workDir,
|
|
permissionMode: 'auto',
|
|
})
|
|
expect((await service.getSessionLaunchInfo(sessionId))?.permissionMode).toBe('auto')
|
|
|
|
await service.clearSessionTranscript(sessionId, workDir, 'auto')
|
|
expect((await service.getSessionLaunchInfo(sessionId))?.permissionMode).toBe('auto')
|
|
})
|
|
|
|
it('should expose the latest runtime selection in the session list', async () => {
|
|
const workDir = '/tmp/runtime-list-metadata'
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile(sanitizePath(workDir), sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
...makeSessionMetaEntry(workDir),
|
|
runtimeProviderId: 'provider-latest',
|
|
runtimeModelId: 'anthropic/claude-opus-4.7',
|
|
effortLevel: 'max',
|
|
},
|
|
makeUserEntry('Use the latest runtime metadata'),
|
|
])
|
|
|
|
const listed = (await service.listSessions()).sessions.find((session) => session.id === sessionId)
|
|
|
|
expect(listed).toMatchObject({
|
|
runtimeProviderId: 'provider-latest',
|
|
runtimeModelId: 'anthropic/claude-opus-4.7',
|
|
effortLevel: 'max',
|
|
})
|
|
})
|
|
|
|
it('should not append duplicate runtime metadata when it already matches', async () => {
|
|
const workDir = '/tmp/runtime-idempotent'
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const filePath = await writeSessionFile(sanitizePath(workDir), sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
...makeSessionMetaEntry(workDir),
|
|
runtimeProviderId: 'provider-a',
|
|
runtimeModelId: 'model-a',
|
|
effortLevel: 'max',
|
|
},
|
|
makeUserEntry('Runtime metadata should stay stable'),
|
|
])
|
|
const before = await fs.readFile(filePath, 'utf-8')
|
|
|
|
await service.appendSessionMetadata(sessionId, {
|
|
workDir,
|
|
runtimeProviderId: 'provider-a',
|
|
runtimeModelId: 'model-a',
|
|
effortLevel: 'max',
|
|
})
|
|
|
|
expect(await fs.readFile(filePath, 'utf-8')).toBe(before)
|
|
|
|
await service.appendSessionMetadata(sessionId, {
|
|
workDir,
|
|
runtimeProviderId: 'provider-a',
|
|
runtimeModelId: 'model-b',
|
|
effortLevel: 'max',
|
|
})
|
|
|
|
const afterChange = await fs.readFile(filePath, 'utf-8')
|
|
expect(afterChange).not.toBe(before)
|
|
expect(afterChange).toContain('"runtimeModelId":"model-b"')
|
|
})
|
|
|
|
it('should remove stale placeholder files after native CLI worktree startup', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const sourceFile = await writeSessionFile('-tmp-source', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{ type: 'session-meta', isMeta: true, workDir: '/tmp/source', timestamp: '2026-01-01T00:00:00.000Z' },
|
|
{ type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:02.000Z' },
|
|
])
|
|
const worktreeFile = await writeSessionFile('-tmp-source--claude-worktrees-desktop-agent', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{ type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:01.000Z' },
|
|
makeUserEntry('Hello from worktree'),
|
|
])
|
|
|
|
const removed = await service.deletePlaceholderSessionFiles(
|
|
sessionId,
|
|
'/tmp/source/.claude/worktrees/desktop-agent',
|
|
)
|
|
|
|
expect(removed).toBe(1)
|
|
await expect(fs.access(sourceFile)).rejects.toThrow()
|
|
await expect(fs.access(worktreeFile)).resolves.toBeNull()
|
|
})
|
|
|
|
it('should move repository metadata to the CLI worktree transcript before deleting placeholders', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const { sessionId } = await service.createSession(
|
|
workDir,
|
|
{ branch: 'main', worktree: true },
|
|
)
|
|
const initialLaunchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
const worktreePath = initialLaunchInfo?.repository?.worktreePath
|
|
expect(worktreePath).toBeTruthy()
|
|
|
|
const worktreeFile = await writeSessionFile(sanitizePath(worktreePath!), sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
type: 'system',
|
|
subtype: 'init',
|
|
cwd: worktreePath,
|
|
timestamp: '2026-01-01T00:00:01.000Z',
|
|
},
|
|
makeUserEntry('Hello from worktree'),
|
|
])
|
|
|
|
await service.appendSessionMetadata(sessionId, {
|
|
workDir: worktreePath!,
|
|
})
|
|
const removed = await service.deletePlaceholderSessionFiles(sessionId, worktreePath!)
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
|
|
expect(removed).toBe(1)
|
|
await expect(fs.access(worktreeFile)).resolves.toBeNull()
|
|
expect(launchInfo?.workDir).toBe(worktreePath)
|
|
expect(launchInfo?.repository).toMatchObject({
|
|
requestedWorkDir: await fs.realpath(workDir),
|
|
branch: 'main',
|
|
worktree: true,
|
|
worktreePath,
|
|
worktreeSlug: initialLaunchInfo?.repository?.worktreeSlug,
|
|
})
|
|
})
|
|
|
|
it('should recover workDir from transcript cwd when session-meta is missing', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
...makeUserEntry('Hello'),
|
|
cwd: '/tmp/from-cwd',
|
|
},
|
|
])
|
|
|
|
const workDir = await service.getSessionWorkDir(sessionId)
|
|
expect(workDir).toBe('/tmp/from-cwd')
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// createSession
|
|
// --------------------------------------------------------------------------
|
|
|
|
it('should create a new session file', async () => {
|
|
const workDir = path.join(tmpDir, 'workspace', 'my-project')
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
const { sessionId } = await service.createSession(workDir)
|
|
expect(sessionId).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
)
|
|
|
|
// Verify the file was created
|
|
const canonicalWorkDir = await fs.realpath(workDir)
|
|
const sanitized = sanitizePath(canonicalWorkDir)
|
|
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
|
|
const stat = await fs.stat(filePath)
|
|
expect(stat.isFile()).toBe(true)
|
|
|
|
// Verify the file starts with the initial snapshot entry
|
|
const content = await fs.readFile(filePath, 'utf-8')
|
|
const entry = JSON.parse(content.trim().split('\n')[0]!)
|
|
expect(entry.type).toBe('file-history-snapshot')
|
|
})
|
|
|
|
it('should defer isolated worktree creation until CLI startup', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: true },
|
|
)
|
|
|
|
expect(sessionWorkDir).toBe(await fs.realpath(workDir))
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
|
expect(git(workDir, 'status', '--porcelain')).toBe('')
|
|
|
|
const sanitized = sanitizePath(await fs.realpath(workDir))
|
|
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
|
|
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
|
|
const metadata = JSON.parse(lines[1]!)
|
|
const plannedWorktreePath = metadata.repository.worktreePath as string
|
|
expect(metadata.workDir).toBe(await fs.realpath(workDir))
|
|
expect(metadata.repository).toMatchObject({
|
|
requestedWorkDir: await fs.realpath(workDir),
|
|
branch: 'feature/rail',
|
|
worktree: true,
|
|
baseRef: 'feature/rail',
|
|
worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')),
|
|
worktreeBranch: expect.stringContaining('worktree-desktop-feature-rail-'),
|
|
worktreeSlug: expect.stringContaining('desktop-feature-rail-'),
|
|
})
|
|
await expect(fs.access(plannedWorktreePath)).rejects.toThrow()
|
|
|
|
const context = await getRepositoryContext(workDir)
|
|
expect(context.state).toBe('ok')
|
|
expect(context.branches.map((branch) => branch.name)).not.toContain(
|
|
path.basename(plannedWorktreePath).replace(/^desktop-/, 'worktree-desktop-'),
|
|
)
|
|
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
|
|
})
|
|
|
|
it('should defer direct branch switching until CLI startup when worktree isolation is disabled', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: false },
|
|
)
|
|
|
|
expect(sessionWorkDir).toBe(await fs.realpath(workDir))
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
|
|
|
const sanitized = sanitizePath(await fs.realpath(workDir))
|
|
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
|
|
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
|
|
const metadata = JSON.parse(lines[1]!)
|
|
expect(metadata.workDir).toBe(await fs.realpath(workDir))
|
|
expect(metadata.repository).toMatchObject({
|
|
requestedWorkDir: await fs.realpath(workDir),
|
|
branch: 'feature/rail',
|
|
worktree: false,
|
|
baseRef: 'feature/rail',
|
|
})
|
|
})
|
|
|
|
it('should not list hidden desktop worktree branches', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const existingWorktree = path.join(tmpDir, `desktop-hidden-${Date.now()}`)
|
|
git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-hidden', existingWorktree, 'feature/rail')
|
|
|
|
expect(git(existingWorktree, 'branch', '--show-current')).toBe('worktree-desktop-hidden\n')
|
|
|
|
const context = await getRepositoryContext(existingWorktree)
|
|
expect(context.state).toBe('ok')
|
|
expect(context.currentBranch).toBe('worktree-desktop-hidden')
|
|
expect(context.branches.some((branch) => branch.name === context.currentBranch)).toBe(false)
|
|
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// createRepositoryBranch
|
|
// --------------------------------------------------------------------------
|
|
|
|
it('should create a branch at the selected base without moving HEAD', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
const result = await createRepositoryBranch(workDir, {
|
|
name: 'feature/new-rail',
|
|
from: 'feature/rail',
|
|
})
|
|
|
|
expect(result.branch).toBe('feature/new-rail')
|
|
expect(result.baseRef).toBe('feature/rail')
|
|
expect(git(workDir, 'rev-parse', 'feature/new-rail'))
|
|
.toBe(git(workDir, 'rev-parse', 'feature/rail'))
|
|
// Picking a branch in the launch controls has never checked it out on the
|
|
// spot; the switch happens when the session starts, behind the dirty and
|
|
// already-checked-out guards. Creating the ref must not jump that queue.
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
|
expect(result.context.branches.some((branch) => (
|
|
branch.name === 'feature/new-rail' && branch.local && !branch.current
|
|
))).toBe(true)
|
|
})
|
|
|
|
it('should start a branch at HEAD when no base is given', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
const result = await createRepositoryBranch(workDir, { name: 'from-head' })
|
|
|
|
expect(result.baseRef).toBe('HEAD')
|
|
expect(git(workDir, 'rev-parse', 'from-head')).toBe(git(workDir, 'rev-parse', 'HEAD'))
|
|
})
|
|
|
|
it('should leave uncommitted work untouched when creating a branch', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nwork in progress\n')
|
|
|
|
await createRepositoryBranch(workDir, { name: 'wip', from: 'main' })
|
|
|
|
expect(git(workDir, 'status', '--porcelain')).toBe(' M README.md\n')
|
|
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
|
|
.toBe('main\nwork in progress\n')
|
|
})
|
|
|
|
it('should report the commit each branch points at alongside HEAD', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
await createRepositoryBranch(workDir, { name: 'same-commit', from: 'main' })
|
|
|
|
const context = await getRepositoryContext(workDir)
|
|
const head = git(workDir, 'rev-parse', 'HEAD').trim()
|
|
|
|
// Without these the UI cannot tell a switch that rewrites files from one
|
|
// that only moves the ref, and warns about uncommitted changes for both.
|
|
expect(context.headCommit).toBe(head)
|
|
expect(context.branches.find((branch) => branch.name === 'same-commit')?.commit).toBe(head)
|
|
expect(context.branches.find((branch) => branch.name === 'feature/rail')?.commit)
|
|
.toBe(git(workDir, 'rev-parse', 'feature/rail').trim())
|
|
})
|
|
|
|
it('should reject a branch name that already exists', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
await expect(createRepositoryBranch(workDir, { name: 'feature/rail' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_EXISTS' })
|
|
})
|
|
|
|
it('should start a branch from a remote-only base at the remote commit', async () => {
|
|
const originDir = await createCleanGitRepo(tmpDir)
|
|
const workDir = path.join(tmpDir, `clone-${Date.now()}`)
|
|
git(tmpDir, 'clone', '--quiet', originDir, workDir)
|
|
git(workDir, 'config', 'user.email', 'clone@example.com')
|
|
git(workDir, 'config', 'user.name', 'Clone')
|
|
|
|
const result = await createRepositoryBranch(workDir, {
|
|
name: 'local-rail',
|
|
from: 'feature/rail',
|
|
})
|
|
|
|
// Resolving the base to its plain name instead of the tracking ref would
|
|
// silently branch off HEAD, which is a different commit.
|
|
expect(result.baseRef).toBe('origin/feature/rail')
|
|
expect(git(workDir, 'rev-parse', 'local-rail'))
|
|
.toBe(git(workDir, 'rev-parse', 'origin/feature/rail'))
|
|
expect(git(workDir, 'rev-parse', 'local-rail'))
|
|
.not.toBe(git(workDir, 'rev-parse', 'HEAD'))
|
|
})
|
|
|
|
it('should refuse to shadow a remote-only branch with a local one', async () => {
|
|
const originDir = await createCleanGitRepo(tmpDir)
|
|
const workDir = path.join(tmpDir, `shadow-${Date.now()}`)
|
|
git(tmpDir, 'clone', '--quiet', originDir, workDir)
|
|
|
|
// The picker lists `feature/rail` as a selectable remote branch, and picking
|
|
// it launches `switch --track -c feature/rail origin/feature/rail`. Creating
|
|
// a second local branch under that name off `main` wins the name and makes
|
|
// the launch silently run on main's content instead.
|
|
await expect(createRepositoryBranch(workDir, { name: 'feature/rail', from: 'main' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_EXISTS' })
|
|
expect(git(workDir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads').trim())
|
|
.toBe('main')
|
|
})
|
|
|
|
it('should not create a hidden desktop worktree branch', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
// `listBranches` filters this prefix out, so such a branch would exist on
|
|
// disk and appear in no context the app ever renders — unselectable, and
|
|
// undeletable from the app.
|
|
await expect(createRepositoryBranch(workDir, { name: 'worktree-desktop-sneaky' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NAME_INVALID' })
|
|
expect(git(workDir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads').trim().split('\n'))
|
|
.toEqual(['feature/rail', 'main'])
|
|
})
|
|
|
|
it('should reject a branch name past the length cap', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
await expect(createRepositoryBranch(workDir, { name: 'a'.repeat(201) }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NAME_INVALID' })
|
|
await expect(createRepositoryBranch(workDir, { name: 'a'.repeat(200) }))
|
|
.resolves.toMatchObject({ branch: 'a'.repeat(200) })
|
|
})
|
|
|
|
it('should not let a flag-shaped base branch reach git as an option', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
// `git branch` refuses to create this, but a ref written out of band is
|
|
// listed like any other and can be picked as the base.
|
|
git(workDir, 'update-ref', 'refs/heads/-x', 'feature/rail')
|
|
|
|
const context = await getRepositoryContext(workDir)
|
|
expect(context.branches.some((branch) => branch.name === '-x')).toBe(true)
|
|
|
|
const result = await createRepositoryBranch(workDir, { name: 'from-dash', from: '-x' })
|
|
expect(result.baseRef).toBe('-x')
|
|
expect(git(workDir, 'rev-parse', 'from-dash')).toBe(git(workDir, 'rev-parse', 'feature/rail'))
|
|
})
|
|
|
|
it('should map a case-folded collision onto the already-exists code', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const created = await createRepositoryBranch(workDir, { name: 'Main' })
|
|
.then(() => 'created' as const, (error: { code?: string }) => error.code)
|
|
|
|
// macOS and Windows fold the ref path, so git rejects this even though the
|
|
// branch list has no exact match. Case-sensitive filesystems create it
|
|
// happily, and both outcomes are correct — a raw English `fatal:` in a form
|
|
// translated into five locales is not.
|
|
expect(['created', 'REPOSITORY_BRANCH_EXISTS']).toContain(created)
|
|
})
|
|
|
|
it('should reject branch creation in a repository with no commits', async () => {
|
|
const workDir = path.join(tmpDir, `unborn-${Date.now()}`)
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
git(workDir, 'init')
|
|
git(workDir, 'checkout', '-b', 'main')
|
|
|
|
// The picker renders normally here — `state: 'ok'`, one branch from the
|
|
// current-branch fallback — so "Create branch…" is offered and used to fail
|
|
// with untranslated `fatal: not a valid object name`.
|
|
const context = await getRepositoryContext(workDir)
|
|
expect(context.state).toBe('ok')
|
|
expect(context.headCommit).toBeNull()
|
|
|
|
await expect(createRepositoryBranch(workDir, { name: 'first', from: 'main' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_NO_COMMITS' })
|
|
})
|
|
|
|
it('should branch from the picked worktree HEAD, not the main checkout', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const linked = path.join(tmpDir, `linked-${Date.now()}`)
|
|
git(workDir, 'worktree', 'add', linked, 'feature/rail')
|
|
|
|
const context = await getRepositoryContext(linked)
|
|
const result = await createRepositoryBranch(linked, { name: 'from-linked' })
|
|
|
|
// `repoRoot` resolves a linked worktree back to the main checkout, where
|
|
// HEAD is a different commit than the one `headCommit` just reported.
|
|
expect(result.baseRef).toBe('HEAD')
|
|
expect(git(linked, 'rev-parse', 'from-linked')).toBe(`${context.headCommit}\n`)
|
|
expect(git(linked, 'rev-parse', 'from-linked'))
|
|
.not.toBe(git(workDir, 'rev-parse', 'HEAD'))
|
|
})
|
|
|
|
it.each([
|
|
['a space', 'bad name'],
|
|
['a double dot', 'bad..name'],
|
|
['a trailing slash', 'bad/'],
|
|
['a lock suffix', 'bad.lock'],
|
|
['nothing at all', ' '],
|
|
])('should reject a branch name with %s', async (_label, name) => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
await expect(createRepositoryBranch(workDir, { name }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NAME_INVALID' })
|
|
expect(git(workDir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads').trim().split('\n'))
|
|
.toEqual(['feature/rail', 'main'])
|
|
})
|
|
|
|
it('should not let a branch name starting with a dash reach git as a flag', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
// `-D` / `--delete` would be read as "delete a branch" by any call that
|
|
// interpolates the name before `--`.
|
|
await expect(createRepositoryBranch(workDir, { name: '--delete' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NAME_INVALID' })
|
|
await expect(createRepositoryBranch(workDir, { name: '-D' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NAME_INVALID' })
|
|
expect(git(workDir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads').trim().split('\n'))
|
|
.toEqual(['feature/rail', 'main'])
|
|
})
|
|
|
|
it('should reject a base branch the repository does not have', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
await expect(createRepositoryBranch(workDir, { name: 'ok-name', from: 'missing/branch' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NOT_FOUND' })
|
|
})
|
|
|
|
it('should reject branch creation outside a Git repository', async () => {
|
|
const workDir = path.join(tmpDir, `not-git-branch-${Date.now()}`)
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
|
|
await expect(createRepositoryBranch(workDir, { name: 'ok-name' }))
|
|
.rejects.toMatchObject({ code: 'REPOSITORY_NOT_GIT' })
|
|
})
|
|
|
|
it('should keep stale worktree records when their paths cannot be resolved', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const staleWorktreeName = `stale-worktree-${Date.now()}`
|
|
const staleWorktree = path.join(tmpDir, staleWorktreeName)
|
|
git(workDir, 'worktree', 'add', '-b', 'stale-worktree', staleWorktree, 'feature/rail')
|
|
await fs.rm(staleWorktree, { recursive: true, force: true })
|
|
|
|
const context = await getRepositoryContext(workDir)
|
|
const expectedPath = path.join(await fs.realpath(tmpDir), staleWorktreeName).normalize('NFC')
|
|
expect(context.state).toBe('ok')
|
|
expect(context.worktrees.some((worktree) => (
|
|
worktree.path === expectedPath && worktree.branch === 'stale-worktree' && !worktree.current
|
|
))).toBe(true)
|
|
})
|
|
|
|
it('should let git carry compatible dirty changes during direct branch launch', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
|
|
|
|
const { sessionId } = await service.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: false },
|
|
)
|
|
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
|
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
|
|
.toContain('local-pricing-edit')
|
|
const prepared = await prepareSessionWorkspace(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: false },
|
|
sessionId,
|
|
)
|
|
|
|
expect(prepared.workDir).toBe(await fs.realpath(workDir))
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('feature/rail\n')
|
|
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
|
|
.toContain('local-pricing-edit')
|
|
})
|
|
|
|
it('should plan isolated worktrees from dirty source checkouts without switching branches', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
|
|
|
|
const { sessionId } = await service.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: true },
|
|
)
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
|
|
expect(launchInfo?.repository).toMatchObject({
|
|
branch: 'feature/rail',
|
|
worktree: true,
|
|
baseRef: 'feature/rail',
|
|
})
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
|
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
|
|
.toContain('local-pricing-edit')
|
|
})
|
|
|
|
it('should defer checked-out direct branch launch validation until CLI startup', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`)
|
|
git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail')
|
|
|
|
const { sessionId } = await service.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: false },
|
|
)
|
|
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
|
await expect(prepareSessionWorkspace(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: false },
|
|
sessionId,
|
|
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' })
|
|
})
|
|
|
|
it('should reject branch launch outside Git repositories with a stable error code', async () => {
|
|
const workDir = path.join(tmpDir, `not-git-${Date.now()}`)
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
|
|
await expect(service.createSession(
|
|
workDir,
|
|
{ branch: 'main', worktree: false },
|
|
)).rejects.toMatchObject({ code: 'REPOSITORY_NOT_GIT' })
|
|
})
|
|
|
|
it('should reject missing selected branches with a stable error code', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
|
|
await expect(service.createSession(
|
|
workDir,
|
|
{ branch: 'missing/branch', worktree: true },
|
|
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NOT_FOUND' })
|
|
})
|
|
|
|
it('should create a Windows-safe project directory name', async () => {
|
|
if (process.platform !== 'win32') return
|
|
|
|
const workDir = process.cwd()
|
|
const { sessionId } = await service.createSession(workDir)
|
|
const sanitized = sanitizePath(workDir)
|
|
const projectDir = path.join(tmpDir, 'projects', sanitized)
|
|
|
|
expect(sanitized.includes(':')).toBe(false)
|
|
const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`))
|
|
expect(stat.isFile()).toBe(true)
|
|
})
|
|
|
|
it('should default to the user home directory when workDir is missing', async () => {
|
|
const { sessionId, workDir } = await service.createSession('')
|
|
expect(workDir).toBe(await fs.realpath(os.homedir()))
|
|
const filePath = path.join(
|
|
tmpDir,
|
|
'projects',
|
|
sanitizePath(workDir),
|
|
`${sessionId}.jsonl`,
|
|
)
|
|
|
|
const stat = await fs.stat(filePath)
|
|
expect(stat.isFile()).toBe(true)
|
|
})
|
|
|
|
it('should throw when workDir does not exist', async () => {
|
|
expect(service.createSession('/tmp/definitely-missing-claude-code-haha')).rejects.toThrow(
|
|
'Working directory does not exist'
|
|
)
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// deleteSession
|
|
// --------------------------------------------------------------------------
|
|
|
|
it('should delete an existing session', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const filePath = await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()])
|
|
|
|
await service.deleteSession(sessionId)
|
|
|
|
// File should no longer exist
|
|
expect(fs.access(filePath)).rejects.toThrow()
|
|
})
|
|
|
|
it('should throw when deleting non-existent session', async () => {
|
|
expect(
|
|
service.deleteSession('00000000-0000-0000-0000-000000000000')
|
|
).rejects.toThrow('Session not found')
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// renameSession
|
|
// --------------------------------------------------------------------------
|
|
|
|
it('should rename a session by appending custom-title entry', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const filePath = await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry('Original message'),
|
|
])
|
|
|
|
await service.renameSession(sessionId, 'My Custom Title')
|
|
|
|
// Read the file and check the last entry
|
|
const content = await fs.readFile(filePath, 'utf-8')
|
|
const lines = content.trim().split('\n')
|
|
const lastEntry = JSON.parse(lines[lines.length - 1]!)
|
|
expect(lastEntry.type).toBe('custom-title')
|
|
expect(lastEntry.customTitle).toBe('My Custom Title')
|
|
|
|
// Verify the title is now returned in list
|
|
const detail = await service.getSession(sessionId)
|
|
expect(detail!.title).toBe('My Custom Title')
|
|
})
|
|
|
|
it('should throw when renaming non-existent session', async () => {
|
|
expect(
|
|
service.renameSession('00000000-0000-0000-0000-000000000000', 'Title')
|
|
).rejects.toThrow('Session not found')
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Title extraction
|
|
// --------------------------------------------------------------------------
|
|
|
|
it('should use first user message as title when no custom title', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeMetaUserEntry(),
|
|
makeUserEntry('This is my first real question'),
|
|
])
|
|
|
|
const detail = await service.getSession(sessionId)
|
|
expect(detail!.title).toBe('This is my first real question')
|
|
})
|
|
|
|
it('should derive a clean title from slash command breadcrumb metadata', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry([
|
|
'<command-message>frontend-design</command-message>',
|
|
'<command-name>/frontend-design</command-name>',
|
|
'<command-args>@website 重新设计首页</command-args>',
|
|
].join('\n')),
|
|
])
|
|
|
|
const detail = await service.getSession(sessionId)
|
|
expect(detail!.title).toBe('/frontend-design @website 重新设计首页')
|
|
})
|
|
|
|
it('should keep a goal creation title instead of later goal status titles', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
parentUuid: null,
|
|
isSidechain: false,
|
|
type: 'system',
|
|
subtype: 'local_command',
|
|
content: '<command-name>/goal</command-name>\n<command-message>goal</command-message>\n<command-args>ship the actual objective</command-args>',
|
|
level: 'info',
|
|
timestamp: '2026-01-01T00:00:01.000Z',
|
|
uuid: 'goal-command',
|
|
},
|
|
{
|
|
type: 'ai-title',
|
|
aiTitle: '/goal status',
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
},
|
|
])
|
|
|
|
const detail = await service.getSession(sessionId)
|
|
expect(detail!.title).toBe('/goal ship the actual objective')
|
|
})
|
|
|
|
it('should display stored AI titles without internal XML tags', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry('fallback message'),
|
|
{
|
|
type: 'ai-title',
|
|
aiTitle: [
|
|
'<command-message>frontend-design</command-message>',
|
|
'<command-name>/frontend-design</command-name>',
|
|
'<command-args>@website</command-args>',
|
|
].join(' '),
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
},
|
|
])
|
|
|
|
const detail = await service.getSession(sessionId)
|
|
expect(detail!.title).toBe('/frontend-design @website')
|
|
})
|
|
|
|
it('should truncate long titles to 80 chars', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const longMessage = 'A'.repeat(120)
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry(longMessage),
|
|
])
|
|
|
|
const detail = await service.getSession(sessionId)
|
|
expect(detail!.title.length).toBe(83) // 80 + '...'
|
|
expect(detail!.title.endsWith('...')).toBe(true)
|
|
})
|
|
|
|
it('should fall back to "Untitled Session" when no user message', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()])
|
|
|
|
const detail = await service.getSession(sessionId)
|
|
expect(detail!.title).toBe('Untitled Session')
|
|
})
|
|
|
|
it('should detect placeholder launch info for desktop-created sessions', async () => {
|
|
const workDir = await fs.realpath(os.tmpdir())
|
|
const { sessionId } = await service.createSession(workDir)
|
|
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
expect(launchInfo).not.toBeNull()
|
|
expect(launchInfo!.workDir).toBe(workDir)
|
|
expect(launchInfo!.transcriptMessageCount).toBe(0)
|
|
expect(launchInfo!.customTitle).toBeNull()
|
|
})
|
|
|
|
it('should detect resumable launch info for transcript sessions', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const userUuid = crypto.randomUUID()
|
|
await writeSessionFile('-tmp-project', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{ type: 'session-meta', isMeta: true, workDir: '/tmp/project', timestamp: '2026-01-01T00:00:00.000Z' },
|
|
makeUserEntry('Hello again', userUuid),
|
|
makeAssistantEntry('Welcome back', userUuid),
|
|
{ type: 'custom-title', customTitle: 'Saved chat', timestamp: '2026-01-01T00:03:00.000Z' },
|
|
])
|
|
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
expect(launchInfo).not.toBeNull()
|
|
expect(launchInfo!.workDir).toBe('/tmp/project')
|
|
expect(launchInfo!.transcriptMessageCount).toBe(2)
|
|
expect(launchInfo!.customTitle).toBe('Saved chat')
|
|
})
|
|
|
|
it('should recover Windows drive paths from sanitized project dirs for old transcripts without metadata', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
|
|
const userUuid = crypto.randomUUID()
|
|
const userEntry = makeUserEntry('Resume this Windows session', userUuid)
|
|
delete userEntry.cwd
|
|
await writeSessionFile('g--AI-NTos-NT-deepseek-nano-core', sessionId, [
|
|
makeSnapshotEntry(),
|
|
userEntry,
|
|
makeAssistantEntry('Welcome back', userUuid),
|
|
])
|
|
|
|
const expectedWorkDir = 'g:\\AI\\NTos\\NT\\deepseek\\nano\\core'
|
|
expect(await service.getSessionWorkDir(sessionId)).toBe(expectedWorkDir)
|
|
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
expect(launchInfo).not.toBeNull()
|
|
expect(launchInfo!.workDir).toBe(expectedWorkDir)
|
|
expect(launchInfo!.transcriptMessageCount).toBe(2)
|
|
})
|
|
|
|
it('createSessionBranch should preserve branch metadata, copied snapshots, and filtered replacements', async () => {
|
|
const sessionId = 'branch-source-session'
|
|
const workDir = path.join(tmpDir, 'branch-source')
|
|
const worktreePath = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678')
|
|
const firstUserId = crypto.randomUUID()
|
|
const firstAssistantId = crypto.randomUUID()
|
|
const firstToolResultId = crypto.randomUUID()
|
|
const laterUserId = crypto.randomUUID()
|
|
const laterAssistantId = crypto.randomUUID()
|
|
const repository = {
|
|
branch: 'feature/rail',
|
|
worktree: true,
|
|
baseRef: 'feature/rail',
|
|
repoRoot: workDir,
|
|
}
|
|
const sourceProjectDir = sanitizePath(workDir)
|
|
const sourcePath = await writeSessionFile(sourceProjectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
workDir,
|
|
repository,
|
|
timestamp: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
makeWorktreeStateEntry(sessionId, worktreePath, {
|
|
originalCwd: workDir,
|
|
}),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'src/step.js': {
|
|
backupFileName: 'branch-source-step@v1',
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('branch this conversation', firstUserId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantToolUseEntry([
|
|
{ id: 'tool-1', name: 'Read', input: { path: 'src/step.js' } },
|
|
], firstUserId),
|
|
uuid: firstAssistantId,
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeToolResultUserEntry('tool-1', 'first tool result', firstToolResultId, firstAssistantId, sessionId),
|
|
cwd: workDir,
|
|
},
|
|
makeContentReplacementEntry(sessionId, [
|
|
{ kind: 'tool-result', toolUseId: 'tool-1', replacement: 'preview-1' },
|
|
{ kind: 'tool-result', toolUseId: 'tool-2', replacement: 'preview-2' },
|
|
]),
|
|
makeFileHistorySnapshotEntry(laterUserId, {
|
|
'src/step.js': {
|
|
backupFileName: 'branch-source-step@v2',
|
|
version: 2,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('later prompt', laterUserId),
|
|
parentUuid: firstToolResultId,
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('later reply', laterUserId),
|
|
uuid: laterAssistantId,
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
])
|
|
|
|
const sourceBefore = await fs.readFile(sourcePath, 'utf-8')
|
|
|
|
const branch = await createSessionBranch({
|
|
sourceSessionId: sessionId,
|
|
sourceTranscriptPath: sourcePath,
|
|
targetMessageId: firstToolResultId,
|
|
title: 'Desktop branch',
|
|
sourceWorkDir: workDir,
|
|
sourceRepository: repository,
|
|
sourceWorktreeSession: {
|
|
originalCwd: workDir,
|
|
worktreePath,
|
|
worktreeName: 'desktop-main-12345678',
|
|
worktreeBranch: 'worktree-desktop-main-12345678',
|
|
originalBranch: 'main',
|
|
sessionId,
|
|
},
|
|
})
|
|
|
|
const branchMessages = await service.getSessionMessages(branch.sessionId)
|
|
expect(branchMessages.map((message) => message.id)).toEqual([
|
|
firstUserId,
|
|
firstAssistantId,
|
|
firstToolResultId,
|
|
])
|
|
expect(branch.title).toBe('Desktop branch (Branch)')
|
|
|
|
const launchInfo = await service.getSessionLaunchInfo(branch.sessionId)
|
|
expect(launchInfo).toMatchObject({
|
|
workDir,
|
|
repository,
|
|
worktreeSession: {
|
|
originalCwd: workDir,
|
|
worktreePath,
|
|
},
|
|
})
|
|
|
|
const branchEntries = parseJSONL<Record<string, unknown>>(await fs.readFile(branch.forkPath))
|
|
expect(branchEntries.some((entry) => (
|
|
entry.type === 'content-replacement' &&
|
|
entry.sessionId === branch.sessionId &&
|
|
Array.isArray(entry.replacements) &&
|
|
entry.replacements.length === 1 &&
|
|
(entry.replacements[0] as { toolUseId?: string }).toolUseId === 'tool-1'
|
|
))).toBe(true)
|
|
expect(branchEntries.some((entry) => (
|
|
entry.type === 'file-history-snapshot' &&
|
|
typeof (entry.snapshot as { messageId?: string } | undefined)?.messageId === 'string' &&
|
|
(entry.snapshot as { messageId?: string }).messageId === firstUserId
|
|
))).toBe(true)
|
|
expect(branchEntries.some((entry) => (
|
|
entry.type === 'file-history-snapshot' &&
|
|
typeof (entry.snapshot as { messageId?: string } | undefined)?.messageId === 'string' &&
|
|
(entry.snapshot as { messageId?: string }).messageId === laterUserId
|
|
))).toBe(false)
|
|
expect(branchEntries.some((entry) => (
|
|
entry.type === 'custom-title' &&
|
|
entry.customTitle === 'Desktop branch (Branch)'
|
|
))).toBe(true)
|
|
expect(branchEntries.filter((entry) => (
|
|
entry.type === 'user' ||
|
|
entry.type === 'assistant'
|
|
)).every((entry) => (
|
|
entry.sessionId === branch.sessionId &&
|
|
typeof (entry.forkedFrom as { sessionId?: string } | undefined)?.sessionId === 'string'
|
|
))).toBe(true)
|
|
|
|
const sourceAfter = await fs.readFile(sourcePath, 'utf-8')
|
|
expect(sourceAfter).toBe(sourceBefore)
|
|
})
|
|
|
|
it('createSessionBranch should not expose inherited token usage as usage generated by the fork', async () => {
|
|
const sessionId = 'branch-token-source'
|
|
const workDir = path.join(tmpDir, 'branch-token-source')
|
|
const sourceUserId = crypto.randomUUID()
|
|
const sourceAssistantId = crypto.randomUUID()
|
|
const sourceAssistant = {
|
|
...makeAssistantEntry(
|
|
'source reply',
|
|
sourceUserId,
|
|
{ input_tokens: 1200, output_tokens: 80 },
|
|
),
|
|
uuid: sourceAssistantId,
|
|
sessionId,
|
|
}
|
|
const sourcePath = await writeSessionFile(sanitizePath(workDir), sessionId, [
|
|
{
|
|
...makeUserEntry('branch this conversation', sourceUserId),
|
|
sessionId,
|
|
},
|
|
sourceAssistant,
|
|
])
|
|
|
|
const branch = await createSessionBranch({
|
|
sourceSessionId: sessionId,
|
|
sourceTranscriptPath: sourcePath,
|
|
targetMessageId: sourceAssistantId,
|
|
sourceWorkDir: workDir,
|
|
})
|
|
|
|
const inheritedMessages = await service.getSessionMessages(branch.sessionId)
|
|
expect(inheritedMessages.find((message) => message.id === sourceAssistantId)?.usage)
|
|
.toBeUndefined()
|
|
expect(await service.getTranscriptUsage(branch.sessionId)).toBeNull()
|
|
const inheritedSnapshot = await service.getInspectionTranscriptSnapshot(branch.sessionId)
|
|
expect(inheritedSnapshot?.usage).toBeNull()
|
|
expect(inheritedSnapshot?.contextEstimate?.totalTokens).toBe(1280)
|
|
expect(inheritedSnapshot?.contextEstimate?.apiUsage).toEqual({
|
|
input_tokens: 1200,
|
|
output_tokens: 80,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
})
|
|
|
|
const forkUserId = crypto.randomUUID()
|
|
const forkAssistantId = crypto.randomUUID()
|
|
await fs.appendFile(branch.forkPath, [
|
|
JSON.stringify({
|
|
...makeUserEntry('continue in the fork', forkUserId),
|
|
parentUuid: sourceAssistantId,
|
|
sessionId: branch.sessionId,
|
|
}),
|
|
JSON.stringify({
|
|
...makeAssistantEntry(
|
|
'fork reply',
|
|
forkUserId,
|
|
{ input_tokens: 50, output_tokens: 5 },
|
|
),
|
|
uuid: forkAssistantId,
|
|
sessionId: branch.sessionId,
|
|
}),
|
|
].join('\n') + '\n')
|
|
|
|
const updatedMessages = await service.getSessionMessages(branch.sessionId)
|
|
expect(updatedMessages.find((message) => message.id === forkAssistantId)?.usage).toEqual({
|
|
input_tokens: 50,
|
|
output_tokens: 5,
|
|
})
|
|
expect(await service.getTranscriptUsage(branch.sessionId)).toMatchObject({
|
|
totalInputTokens: 50,
|
|
totalOutputTokens: 5,
|
|
})
|
|
expect((await service.getInspectionTranscriptSnapshot(branch.sessionId))?.usage)
|
|
.toMatchObject({
|
|
totalInputTokens: 50,
|
|
totalOutputTokens: 5,
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// 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')
|
|
|
|
server = Bun.serve({
|
|
port: 0,
|
|
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 })
|
|
},
|
|
})
|
|
baseUrl = `http://127.0.0.1:${server.port}`
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (server) {
|
|
server.stop(true)
|
|
server = null
|
|
}
|
|
clearInstalledPluginsCache()
|
|
clearPluginCache('sessions-api-test-teardown')
|
|
resetSettingsCache()
|
|
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
|
|
index?: { mode: string; state: string; lastErrorCode: string | null }
|
|
}
|
|
expect(body.sessions).toEqual([])
|
|
expect(body.total).toBe(0)
|
|
expect(body.index).toMatchObject({
|
|
mode: expect.any(String),
|
|
state: expect.any(String),
|
|
})
|
|
expect(body.index?.lastErrorCode === null || typeof body.index?.lastErrorCode === 'string').toBe(true)
|
|
})
|
|
|
|
it('GET turn-checkpoints accepts a known memory session across workspace changes and persistence restore', async () => {
|
|
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 0 }))
|
|
resetSettingsCache()
|
|
const created = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: tmpDir }),
|
|
})
|
|
expect(created.status).toBe(201)
|
|
const { sessionId } = await created.json() as { sessionId: string }
|
|
const { sessionService } = await import('../services/sessionService.js')
|
|
const selectedWorkDir = await fs.mkdtemp(path.join(tmpDir, 'selected-project-'))
|
|
// The runtime launch boundary records the user's selected working folder
|
|
// even though retention zero deliberately never materializes a JSONL.
|
|
await sessionService.appendSessionMetadata(sessionId, { workDir: selectedWorkDir })
|
|
expect(await sessionService.getSessionWorkDir(sessionId)).toBe(selectedWorkDir)
|
|
|
|
for (const days of [0, 365]) {
|
|
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: days }))
|
|
resetSettingsCache()
|
|
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(response.status).toBe(200)
|
|
expect(await response.json()).toEqual({ checkpoints: [] })
|
|
expect(await sessionService.getSessionMessagesWithEvidence(sessionId)).toEqual({
|
|
messages: [], transcriptEvidenceComplete: false,
|
|
})
|
|
expect(await sessionService.findSessionFile(sessionId)).toBeNull()
|
|
}
|
|
|
|
// After new enabled content reaches disk, it takes precedence over the
|
|
// retained launch metadata and produces normal completed-turn checkpoints.
|
|
await writeSessionFile(sanitizePath(selectedWorkDir), sessionId, [
|
|
{ type: 'session-meta', workDir: selectedWorkDir },
|
|
{ type: 'user', uuid: 'public-user', parentUuid: null, timestamp: '2026-09-10T00:00:00.000Z', message: { role: 'user', content: 'public prompt' } },
|
|
{ type: 'assistant', uuid: 'public-assistant', parentUuid: 'public-user', timestamp: '2026-09-10T00:00:01.000Z', message: { role: 'assistant', content: 'public reply' } },
|
|
])
|
|
const restored = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(restored.status).toBe(200)
|
|
const body = await restored.json() as { checkpoints: Array<{ target: { targetUserMessageId: string } }> }
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]?.target.targetUserMessageId).toBe('public-user')
|
|
expect((await sessionService.getSessionMessagesWithEvidence(sessionId)).transcriptEvidenceComplete).toBe(true)
|
|
|
|
await sessionService.deleteSession(sessionId)
|
|
expect((await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)).status).toBe(404)
|
|
expect((await fetch(`${baseUrl}/api/sessions/${crypto.randomUUID()}/turn-checkpoints`)).status).toBe(404)
|
|
})
|
|
|
|
for (const origin of ['created', 'resumed']) {
|
|
it(`GET turn-checkpoints keeps a positive ${origin} session known immediately after retention cleanup`, async () => {
|
|
const { sessionService } = await import('../services/sessionService.js')
|
|
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 365 }))
|
|
resetSettingsCache()
|
|
let sessionId: string
|
|
if (origin === 'created') {
|
|
const created = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: tmpDir }),
|
|
})
|
|
expect(created.status).toBe(201)
|
|
sessionId = (await created.json() as { sessionId: string }).sessionId
|
|
} else {
|
|
sessionId = crypto.randomUUID()
|
|
await writeSessionFile(sanitizePath(tmpDir), sessionId, [
|
|
{ type: 'session-meta', workDir: tmpDir },
|
|
{ type: 'user', uuid: 'restored-user', parentUuid: null, message: { role: 'user', content: 'saved before server restart' } },
|
|
])
|
|
// Starting the runtime for an existing disk session updates metadata;
|
|
// this ID was never created by the current SessionService process.
|
|
await sessionService.appendSessionMetadata(sessionId, { workDir: tmpDir })
|
|
}
|
|
const found = await sessionService.findSessionFile(sessionId)
|
|
expect(found).not.toBeNull()
|
|
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 0 }))
|
|
resetSettingsCache()
|
|
await fs.unlink(found!.filePath)
|
|
|
|
// Clicking the still-open tab requests checkpoints before another prompt
|
|
// can refresh runtime metadata. No disk evidence must be created here.
|
|
for (const days of [0, 365]) {
|
|
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: days }))
|
|
resetSettingsCache()
|
|
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(response.status).toBe(200)
|
|
expect(await response.json()).toEqual({ checkpoints: [] })
|
|
expect(await sessionService.getSessionMessagesWithEvidence(sessionId)).toEqual({
|
|
messages: [], transcriptEvidenceComplete: false,
|
|
})
|
|
expect(await sessionService.findSessionFile(sessionId)).toBeNull()
|
|
expect((await fetch(`${baseUrl}/api/sessions/${crypto.randomUUID()}/turn-checkpoints`)).status).toBe(404)
|
|
}
|
|
|
|
// Explicit deletion ends this in-process lifetime even when cleanup has
|
|
// already removed its file; it must not become an immortal empty session.
|
|
await sessionService.deleteSession(sessionId)
|
|
expect((await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)).status).toBe(404)
|
|
await expect(sessionService.deleteSession(sessionId)).rejects.toMatchObject({ statusCode: 404 })
|
|
})
|
|
}
|
|
|
|
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('POST /api/sessions should reject an unknown permission mode', async () => {
|
|
const res = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ permissionMode: 'unknown' }),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/inspection should report persisted permission mode for inactive sessions', async () => {
|
|
const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-permission-'))
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir, permissionMode: 'bypassPermissions' }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
|
|
const { sessionId } = (await createRes.json()) as { sessionId: string }
|
|
const inspectionRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)
|
|
expect(inspectionRes.status).toBe(200)
|
|
|
|
const inspection = (await inspectionRes.json()) as {
|
|
active: boolean
|
|
status: { permissionMode?: string }
|
|
}
|
|
expect(inspection.active).toBe(false)
|
|
expect(inspection.status.permissionMode).toBe('bypassPermissions')
|
|
})
|
|
|
|
it('counts a multi-block assistant reply once when rebuilding transcript usage', async () => {
|
|
const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-usage-dedup-'))
|
|
const sessionId = '11111111-2222-3333-4444-555555555555'
|
|
const projectDir = '-tmp-api-session-usage-dedup'
|
|
const messageId = 'msg_shared_reply'
|
|
const replyUsage = {
|
|
input_tokens: 100,
|
|
output_tokens: 250,
|
|
cache_read_input_tokens: 1_000,
|
|
cache_creation_input_tokens: 20,
|
|
}
|
|
// Claude Code writes one JSONL line per content block of a reply and repeats the complete
|
|
// `usage` object on every one. Three lines here stand for one reply with thinking + text +
|
|
// tool_use; summing them raw is the 2.2x inflation `usageAccounting.ts` documents.
|
|
const blockLine = () => ({
|
|
parentUuid: null,
|
|
isSidechain: false,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: messageId,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'block' }],
|
|
usage: replyUsage,
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
sessionId,
|
|
cwd: workDir,
|
|
})
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeUserEntry('go', crypto.randomUUID()),
|
|
blockLine(),
|
|
blockLine(),
|
|
blockLine(),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
usage?: {
|
|
totalInputTokens: number
|
|
totalOutputTokens: number
|
|
totalCacheReadInputTokens: number
|
|
totalCacheCreationInputTokens: number
|
|
}
|
|
}
|
|
|
|
expect(body.usage?.totalInputTokens).toBe(100)
|
|
expect(body.usage?.totalOutputTokens).toBe(250)
|
|
expect(body.usage?.totalCacheReadInputTokens).toBe(1_000)
|
|
expect(body.usage?.totalCacheCreationInputTokens).toBe(20)
|
|
})
|
|
|
|
it('still totals separate assistant replies separately after dedup', async () => {
|
|
// The negative control for the test above: a dedup key that collapsed too much would make
|
|
// every reply after the first free, which is a far worse error than the inflation it fixes.
|
|
const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-usage-distinct-'))
|
|
const sessionId = '22222222-3333-4444-5555-666666666666'
|
|
const projectDir = '-tmp-api-session-usage-distinct'
|
|
const reply = (messageId: string, outputTokens: number) => ({
|
|
parentUuid: null,
|
|
isSidechain: false,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: messageId,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'reply' }],
|
|
usage: { input_tokens: 10, output_tokens: outputTokens },
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
sessionId,
|
|
cwd: workDir,
|
|
})
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeUserEntry('go', crypto.randomUUID()),
|
|
reply('msg_first', 300),
|
|
reply('msg_second', 70),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)
|
|
const body = await res.json() as { usage?: { totalInputTokens: number; totalOutputTokens: number } }
|
|
|
|
expect(body.usage?.totalOutputTokens).toBe(370)
|
|
expect(body.usage?.totalInputTokens).toBe(20)
|
|
})
|
|
|
|
it('GET /api/sessions/repository-context should return branch launch metadata', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const res = await fetch(
|
|
`${baseUrl}/api/sessions/repository-context?workDir=${encodeURIComponent(workDir)}`,
|
|
)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
state: string
|
|
repoName: string
|
|
currentBranch: string
|
|
branches: Array<{ name: string; current: boolean; local: boolean }>
|
|
worktrees: Array<{ path: string; branch: string | null; current: boolean }>
|
|
}
|
|
expect(body.state).toBe('ok')
|
|
expect(body.repoName).toBe(path.basename(workDir))
|
|
expect(body.currentBranch).toBe('main')
|
|
expect(body.branches.some((branch) => branch.name === 'main' && branch.current)).toBe(true)
|
|
expect(body.branches.some((branch) => branch.name === 'feature/rail' && branch.local)).toBe(true)
|
|
const realWorkDir = await fs.realpath(workDir)
|
|
expect(body.worktrees.some((worktree) => worktree.path === realWorkDir && worktree.current)).toBe(true)
|
|
})
|
|
|
|
it('POST /api/sessions/repository-branch should create a branch and return the refreshed context', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const res = await fetch(`${baseUrl}/api/sessions/repository-branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir, name: 'feature/from-api', from: 'feature/rail' }),
|
|
})
|
|
expect(res.status).toBe(201)
|
|
|
|
const body = (await res.json()) as {
|
|
branch: string
|
|
baseRef: string
|
|
context: { state: string; branches: Array<{ name: string; local: boolean }> }
|
|
}
|
|
expect(body.branch).toBe('feature/from-api')
|
|
expect(body.baseRef).toBe('feature/rail')
|
|
expect(body.context.state).toBe('ok')
|
|
expect(body.context.branches.some((branch) => (
|
|
branch.name === 'feature/from-api' && branch.local
|
|
))).toBe(true)
|
|
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
|
})
|
|
|
|
it('POST /api/sessions/repository-branch should surface a rejected name as a stable error code', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const res = await fetch(`${baseUrl}/api/sessions/repository-branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir, name: 'feature/rail' }),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
// The desktop form translates this code; a changed spelling silently
|
|
// downgrades it to the untranslated generic failure.
|
|
expect((await res.json()).error).toBe('REPOSITORY_BRANCH_EXISTS')
|
|
})
|
|
|
|
it('GET /api/sessions/recent-projects should keep pending repository launches on the source project', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
workDir,
|
|
repository: { branch: 'feature/rail', worktree: true },
|
|
}),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
|
|
const created = (await createRes.json()) as { workDir: string }
|
|
const recentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
|
|
expect(recentRes.status).toBe(200)
|
|
|
|
const body = (await recentRes.json()) as {
|
|
projects: Array<{ realPath: string; projectName: string; branch: string | null }>
|
|
}
|
|
const project = body.projects.find((candidate) => candidate.realPath === created.workDir)
|
|
expect(project).toBeDefined()
|
|
expect(project?.projectName).toBe(path.basename(workDir))
|
|
expect(project?.branch).toBe('main')
|
|
expect(project?.realPath).toBe(await fs.realpath(workDir))
|
|
})
|
|
|
|
it('GET /api/sessions/recent-projects should retain source projects for cleaned worktrees', async () => {
|
|
const sourceWorkDir = await createCleanGitRepo(tmpDir)
|
|
const worktreePath = path.join(sourceWorkDir, '.claude', 'worktrees', 'desktop-main-87654321')
|
|
const sessionId = 'c1000000-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile(sanitizePath(worktreePath), sessionId, [
|
|
makeSessionMetaEntry(worktreePath),
|
|
makeWorktreeStateEntry(sessionId, worktreePath, {
|
|
originalCwd: sourceWorkDir,
|
|
}),
|
|
makeUserEntry('Cleaned worktree history'),
|
|
])
|
|
|
|
const recentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
|
|
expect(recentRes.status).toBe(200)
|
|
|
|
const body = (await recentRes.json()) as {
|
|
projects: Array<{ realPath: string; sessionCount: number }>
|
|
}
|
|
expect(body.projects).toContainEqual(expect.objectContaining({
|
|
realPath: await fs.realpath(sourceWorkDir),
|
|
sessionCount: 1,
|
|
}))
|
|
})
|
|
|
|
it('GET /api/sessions/recent-projects should isolate cached projects by config scope', async () => {
|
|
const firstConfigDir = path.join(tmpDir, 'recent-project-scopes', 'first')
|
|
const secondConfigDir = path.join(tmpDir, 'recent-project-scopes', 'second')
|
|
const firstWorkDir = path.join(tmpDir, 'recent-project-workspaces', 'first')
|
|
const secondWorkDir = path.join(tmpDir, 'recent-project-workspaces', 'second')
|
|
const firstSessionId = 'a1000000-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const secondSessionId = 'a1000001-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const seedScope = async (
|
|
configDir: string,
|
|
sessionId: string,
|
|
workDir: string,
|
|
title: string,
|
|
) => {
|
|
const projectDir = path.join(configDir, 'projects', '-tmp-recent-scope')
|
|
await fs.mkdir(projectDir, { recursive: true })
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(projectDir, `${sessionId}.jsonl`),
|
|
`${JSON.stringify({
|
|
...makeUserEntry(title),
|
|
cwd: workDir,
|
|
sessionId,
|
|
})}\n`,
|
|
'utf8',
|
|
)
|
|
}
|
|
await seedScope(firstConfigDir, firstSessionId, firstWorkDir, 'First recent scope')
|
|
await seedScope(secondConfigDir, secondSessionId, secondWorkDir, 'Second recent scope')
|
|
const firstRealWorkDir = await fs.realpath(firstWorkDir)
|
|
const secondRealWorkDir = await fs.realpath(secondWorkDir)
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = firstConfigDir
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: firstWorkDir }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const firstRecentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
|
|
expect(firstRecentRes.status).toBe(200)
|
|
const firstRecent = await firstRecentRes.json() as {
|
|
projects: Array<{ realPath: string }>
|
|
}
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = secondConfigDir
|
|
const secondRecentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
|
|
expect(secondRecentRes.status).toBe(200)
|
|
const secondRecent = await secondRecentRes.json() as {
|
|
projects: Array<{ realPath: string }>
|
|
}
|
|
|
|
expect(firstRecent.projects.some(project => project.realPath === firstRealWorkDir)).toBe(true)
|
|
expect(secondRecent.projects.some(project => project.realPath === secondRealWorkDir)).toBe(true)
|
|
expect(secondRecent.projects.some(project => project.realPath === firstRealWorkDir)).toBe(false)
|
|
})
|
|
|
|
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', undefined, { input_tokens: 1234, output_tokens: 56 }),
|
|
makeUserEntry(
|
|
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed & stopped</summary>\n<result>Stack trace & failed assertion</result>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
|
|
crypto.randomUUID(),
|
|
),
|
|
makeAssistantEntry('internal task response'),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
messages: unknown[]
|
|
taskNotifications: unknown[]
|
|
}
|
|
expect(body.messages).toHaveLength(2)
|
|
expect(body.messages[1]).toMatchObject({
|
|
type: 'assistant',
|
|
usage: { input_tokens: 1234, output_tokens: 56 },
|
|
})
|
|
expect(JSON.stringify(body.messages)).not.toContain('<task-notification>')
|
|
expect(body.taskNotifications).toEqual([
|
|
{
|
|
taskId: 'bg-1',
|
|
toolUseId: 'toolu_bg',
|
|
status: 'failed',
|
|
summary: 'Background command failed & stopped',
|
|
result: 'Stack trace & failed assertion',
|
|
outputFile: 'C:\\Temp\\bg.output',
|
|
timestamp: expect.any(String),
|
|
},
|
|
])
|
|
})
|
|
|
|
it('GET /api/sessions/:id/messages exposes persisted terminal ownership', async () => {
|
|
const sessionId = 'abababab-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-api-owned-terminal', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
type: 'cc-haha-task-notification',
|
|
isMeta: true,
|
|
taskNotification: {
|
|
taskId: 'nested-workflow-task',
|
|
toolUseId: 'Workflow:0',
|
|
ownerAgentId: 'parent-agent',
|
|
status: 'completed',
|
|
summary: 'Nested workflow completed',
|
|
},
|
|
timestamp: '2026-08-10T00:00:01.000Z',
|
|
},
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { taskNotifications: unknown[] }
|
|
expect(body.taskNotifications).toEqual([{
|
|
taskId: 'nested-workflow-task',
|
|
toolUseId: 'Workflow:0',
|
|
ownerAgentId: 'parent-agent',
|
|
status: 'completed',
|
|
summary: 'Nested workflow completed',
|
|
timestamp: '2026-08-10T00:00:01.000Z',
|
|
}])
|
|
})
|
|
|
|
it('GET /api/sessions/:id/subagents/by-tool/:toolUseId should return a resolved run', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const projectDir = '-tmp-api-subagent-run'
|
|
const agentId = 'abc123'
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'tool-1',
|
|
name: 'Agent',
|
|
input: { description: 'Inspect server seam', prompt: 'Read session routes' },
|
|
},
|
|
]),
|
|
makeToolResultUserEntry('tool-1', `server summary\nagentId: ${agentId}`),
|
|
])
|
|
await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [
|
|
{
|
|
type: 'user',
|
|
message: { role: 'user', content: 'Read session routes' },
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:04.000Z',
|
|
},
|
|
{
|
|
type: 'assistant',
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'Found sessions.ts' }] },
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:05.000Z',
|
|
},
|
|
{
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: '<task-notification>\n<task-id>child-shell-task</task-id>\n<tool-use-id>child-shell.call</tool-use-id>\n<status>killed</status>\n<summary>Child shell stopped</summary>\n</task-notification>',
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:06.000Z',
|
|
},
|
|
{
|
|
type: 'assistant',
|
|
message: { role: 'assistant', content: 'Internal notification response' },
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:07.000Z',
|
|
},
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-1`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
sessionId: string
|
|
toolUseId: string
|
|
agentId: string | null
|
|
description?: string
|
|
prompt?: string
|
|
messages: unknown[]
|
|
taskNotifications: unknown[]
|
|
source: string
|
|
}
|
|
expect(body).toMatchObject({
|
|
sessionId,
|
|
toolUseId: 'tool-1',
|
|
agentId,
|
|
description: 'Inspect server seam',
|
|
prompt: 'Read session routes',
|
|
source: 'subagent-jsonl',
|
|
})
|
|
expect(body.messages).toHaveLength(2)
|
|
expect(JSON.stringify(body.messages)).not.toContain('<task-notification>')
|
|
expect(JSON.stringify(body.messages)).not.toContain('Internal notification response')
|
|
expect(body.taskNotifications).toEqual([{
|
|
taskId: 'child-shell-task',
|
|
toolUseId: 'child-shell.call',
|
|
status: 'stopped',
|
|
summary: 'Child shell stopped',
|
|
timestamp: '2026-01-01T00:00:06.000Z',
|
|
}])
|
|
})
|
|
|
|
it('GET /api/sessions/:id/subagents/by-tool/:toolUseId should use a live task id while running', async () => {
|
|
const sessionId = 'edededed-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const projectDir = '-tmp-api-live-subagent-run'
|
|
const agentId = 'abc123'
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'tool-1',
|
|
name: 'Agent',
|
|
input: { description: 'Inspect live seam', prompt: 'Read the route' },
|
|
}]),
|
|
])
|
|
await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [
|
|
{
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'tool_use', id: 'child-tool-1', name: 'Read', input: {} }],
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:04.000Z',
|
|
},
|
|
{
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'tool_result', tool_use_id: 'child-tool-1', content: 'route source' }],
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:00:05.000Z',
|
|
},
|
|
])
|
|
|
|
const res = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-1?taskId=${agentId}`,
|
|
)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
agentId: string | null
|
|
taskId?: string
|
|
status: string
|
|
messages: unknown[]
|
|
source: string
|
|
}
|
|
expect(body).toMatchObject({
|
|
agentId,
|
|
taskId: agentId,
|
|
status: 'running',
|
|
source: 'subagent-jsonl',
|
|
})
|
|
expect(body.messages).toHaveLength(2)
|
|
})
|
|
|
|
it('POST /api/sessions/:id/subagents/by-tool/:toolUseId/messages should resume the resolved agent', async () => {
|
|
const sessionId = 'fefefefe-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const agentId = 'resumable-agent-1'
|
|
await writeSessionFile('-tmp-api-subagent-message', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'tool-1',
|
|
name: 'Agent',
|
|
input: { description: 'Review the route', prompt: 'Inspect the server API' },
|
|
}]),
|
|
makeToolResultUserEntry('tool-1', `review complete\nagentId: ${agentId}`),
|
|
])
|
|
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({
|
|
agent_id: agentId,
|
|
delivery: 'resumed',
|
|
})
|
|
const hasSession = spyOn(conversationService, 'hasSession').mockReturnValue(false)
|
|
const startSession = spyOn(conversationService, 'startSession').mockResolvedValue()
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-1/messages`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ content: 'Please review the new patch.' }),
|
|
},
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual({
|
|
ok: true,
|
|
agent_id: agentId,
|
|
delivery: 'resumed',
|
|
})
|
|
expect(requestControl).toHaveBeenCalledWith(sessionId, {
|
|
subtype: 'send_agent_message',
|
|
agent_id: agentId,
|
|
content: 'Please review the new patch.',
|
|
})
|
|
expect(startSession).toHaveBeenCalledWith(
|
|
sessionId,
|
|
expect.any(String),
|
|
expect.stringMatching(new RegExp(
|
|
`^ws://127\\.0\\.0\\.1:${new URL(baseUrl).port}/sdk/${sessionId}\\?token=`,
|
|
)),
|
|
expect.objectContaining({ resumeInterruptedTurn: false }),
|
|
)
|
|
} finally {
|
|
startSession.mockRestore()
|
|
hasSession.mockRestore()
|
|
requestControl.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('POST /api/sessions/:id/subagents/by-tool/:toolUseId/messages should reuse a running parent CLI', async () => {
|
|
const sessionId = 'abababab-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const agentId = 'running-parent-agent'
|
|
await writeSessionFile('-tmp-api-running-parent-subagent-message', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'tool-running',
|
|
name: 'Agent',
|
|
input: { description: 'Continue the agent', prompt: 'Stay available' },
|
|
}]),
|
|
makeToolResultUserEntry('tool-running', `ready\nagentId: ${agentId}`),
|
|
])
|
|
const hasSession = spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
|
const startSession = spyOn(conversationService, 'startSession').mockResolvedValue()
|
|
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({
|
|
agent_id: agentId,
|
|
delivery: 'queued',
|
|
})
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-running/messages`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ content: 'Continue from the same context.' }),
|
|
},
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(startSession).not.toHaveBeenCalled()
|
|
expect(requestControl).toHaveBeenCalledWith(sessionId, {
|
|
subtype: 'send_agent_message',
|
|
agent_id: agentId,
|
|
content: 'Continue from the same context.',
|
|
})
|
|
} finally {
|
|
requestControl.mockRestore()
|
|
startSession.mockRestore()
|
|
hasSession.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('POST /api/sessions/:id/subagents/by-tool/:toolUseId should return 405', async () => {
|
|
const res = await fetch(
|
|
`${baseUrl}/api/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/by-tool/tool-1`,
|
|
{ method: 'POST' },
|
|
)
|
|
|
|
expect(res.status).toBe(405)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/subagents/by-tool/:toolUseId/extra should return 404', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const agentId = 'abc123'
|
|
await writeSessionFile('-tmp-api-subagent-run-extra', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'tool-1',
|
|
name: 'Agent',
|
|
input: { description: 'Inspect server seam', prompt: 'Read session routes' },
|
|
},
|
|
]),
|
|
makeToolResultUserEntry('tool-1', `server summary\nagentId: ${agentId}`),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-1/extra`)
|
|
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/subagents/by-tool/:toolUseId should return 404 for malformed encoding', async () => {
|
|
const { handleSessionsApi } = await import('../api/sessions.js')
|
|
const res = await handleSessionsApi(
|
|
new Request(`${baseUrl}/api/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/by-tool/%25E0%25A4%25A`),
|
|
new URL(`${baseUrl}/api/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/by-tool/%25E0%25A4%25A`),
|
|
['api', 'sessions', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'subagents', 'by-tool', '%E0%A4%A'],
|
|
)
|
|
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/git-info should prefer the active CLI workDir', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const activeWorktree = path.join(tmpDir, `active-feature-rail-${Date.now()}`)
|
|
git(workDir, 'worktree', 'add', activeWorktree, 'feature/rail')
|
|
const { sessionId } = await sessionService.createSession(workDir)
|
|
const sessionsMap = (conversationService as any).sessions as Map<string, { workDir: string }>
|
|
|
|
sessionsMap.set(sessionId, { workDir: activeWorktree })
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as { branch: string | null; workDir: string }
|
|
expect(body.workDir).toBe(activeWorktree)
|
|
expect(body.branch).toBe('feature/rail')
|
|
} finally {
|
|
sessionsMap.delete(sessionId)
|
|
}
|
|
})
|
|
|
|
it('GET /api/sessions/:id/git-info should keep the session launch branch stable', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const { sessionId } = await sessionService.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: false },
|
|
)
|
|
const sessionsMap = (conversationService as any).sessions as Map<string, { workDir: string }>
|
|
|
|
sessionsMap.set(sessionId, { workDir })
|
|
git(workDir, 'switch', 'main')
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as { branch: string | null; workDir: string }
|
|
expect(body.workDir).toBe(workDir)
|
|
expect(body.branch).toBe('feature/rail')
|
|
} finally {
|
|
sessionsMap.delete(sessionId)
|
|
}
|
|
})
|
|
|
|
it('GET /api/sessions/:id/git-info should not report the source checkout as a planned worktree', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const { sessionId } = await sessionService.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: true },
|
|
)
|
|
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
|
|
const plannedPath = launchInfo?.repository?.worktreePath
|
|
expect(plannedPath).toBeTruthy()
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
workDir: string
|
|
worktree: {
|
|
path: string | null
|
|
plannedPath: string | null
|
|
} | null
|
|
}
|
|
expect(body.workDir).toBe(launchInfo?.workDir)
|
|
expect(body.worktree).toMatchObject({
|
|
path: null,
|
|
plannedPath,
|
|
})
|
|
expect(body.worktree?.plannedPath).not.toBe(body.workDir)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/git-info should keep the visible launch branch while including isolated worktree identity', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const { sessionId } = await sessionService.createSession(
|
|
workDir,
|
|
{ branch: 'feature/rail', worktree: true },
|
|
)
|
|
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
|
|
const repository = launchInfo?.repository
|
|
expect(repository?.worktreePath).toBeTruthy()
|
|
expect(repository?.worktreeBranch).toBeTruthy()
|
|
|
|
const activeWorktree = repository!.worktreePath!
|
|
git(workDir, 'worktree', 'add', '-b', repository!.worktreeBranch!, activeWorktree, 'feature/rail')
|
|
const sessionsMap = (conversationService as any).sessions as Map<string, { workDir: string }>
|
|
|
|
sessionsMap.set(sessionId, { workDir: activeWorktree })
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
branch: string | null
|
|
workDir: string
|
|
worktree: {
|
|
enabled: boolean
|
|
path: string | null
|
|
plannedPath: string | null
|
|
sourceWorkDir: string | null
|
|
slug: string | null
|
|
branch: string | null
|
|
} | null
|
|
}
|
|
expect(body.branch).toBe('feature/rail')
|
|
expect(body.workDir).toBe(activeWorktree)
|
|
expect(body.worktree).toEqual({
|
|
enabled: true,
|
|
path: activeWorktree,
|
|
plannedPath: activeWorktree,
|
|
sourceWorkDir: repository!.requestedWorkDir,
|
|
slug: repository!.worktreeSlug,
|
|
branch: repository!.worktreeBranch,
|
|
})
|
|
} finally {
|
|
sessionsMap.delete(sessionId)
|
|
}
|
|
})
|
|
|
|
it('GET /api/sessions/:id/git-info should use CLI worktree-state after reload', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const activeWorktree = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678')
|
|
git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-main-12345678', activeWorktree, 'main')
|
|
await writeSessionFile(sanitizePath(activeWorktree), sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry(activeWorktree),
|
|
makeWorktreeStateEntry(sessionId, activeWorktree, {
|
|
originalCwd: await fs.realpath(workDir),
|
|
}),
|
|
makeUserEntry('Hello from persisted worktree state'),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
branch: string | null
|
|
repoName: string | null
|
|
workDir: string
|
|
worktree: {
|
|
enabled: boolean
|
|
path: string | null
|
|
plannedPath: string | null
|
|
sourceWorkDir: string | null
|
|
slug: string | null
|
|
branch: string | null
|
|
} | null
|
|
}
|
|
expect(body.branch).toBe('main')
|
|
expect(body.workDir).toBe(activeWorktree)
|
|
expect(body.worktree).toEqual({
|
|
enabled: true,
|
|
path: activeWorktree,
|
|
plannedPath: activeWorktree,
|
|
sourceWorkDir: await fs.realpath(workDir),
|
|
slug: 'desktop-main-12345678',
|
|
branch: 'worktree-desktop-main-12345678',
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/git-info should prefer CLI worktree-state identity over desktop metadata', async () => {
|
|
const workDir = await createCleanGitRepo(tmpDir)
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const activeWorktree = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678')
|
|
git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-main-12345678', activeWorktree, 'main')
|
|
await writeSessionFile(sanitizePath(activeWorktree), sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
workDir: activeWorktree,
|
|
repository: {
|
|
requestedWorkDir: '/stale/source',
|
|
repoRoot: '/stale/source',
|
|
branch: 'main',
|
|
worktree: true,
|
|
baseRef: 'main',
|
|
worktreePath: '/stale/source/.claude/worktrees/stale',
|
|
worktreeBranch: 'worktree-stale',
|
|
worktreeSlug: 'stale',
|
|
},
|
|
timestamp: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
makeWorktreeStateEntry(sessionId, activeWorktree, {
|
|
originalCwd: await fs.realpath(workDir),
|
|
}),
|
|
makeUserEntry('Hello from persisted worktree state'),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
branch: string | null
|
|
worktree: {
|
|
path: string | null
|
|
plannedPath: string | null
|
|
sourceWorkDir: string | null
|
|
slug: string | null
|
|
branch: string | null
|
|
} | null
|
|
}
|
|
expect(body.branch).toBe('main')
|
|
expect(body.worktree).toMatchObject({
|
|
path: activeWorktree,
|
|
plannedPath: activeWorktree,
|
|
sourceWorkDir: await fs.realpath(workDir),
|
|
slug: 'desktop-main-12345678',
|
|
branch: 'worktree-desktop-main-12345678',
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/git-info should not hang when git blocks in a UTF-8 workDir', async () => {
|
|
if (process.platform === 'win32') return
|
|
|
|
const parentDir = path.join(tmpDir, '数据包看板')
|
|
const workDir = path.join(parentDir, 'datavizprocessingplatform')
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
git(workDir, 'init', '--initial-branch', 'main')
|
|
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
|
|
git(workDir, 'config', 'user.name', 'Sessions API')
|
|
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
|
|
git(workDir, 'add', 'README.md')
|
|
git(workDir, 'commit', '-m', 'initial')
|
|
|
|
const fsmonitorPath = path.join(tmpDir, 'slow-fsmonitor.sh')
|
|
await fs.writeFile(fsmonitorPath, '#!/bin/sh\nsleep 2\nexit 0\n', 'utf-8')
|
|
await fs.chmod(fsmonitorPath, 0o755)
|
|
git(workDir, 'config', 'core.fsmonitor', fsmonitorPath)
|
|
|
|
const { sessionId } = await sessionService.createSession(workDir)
|
|
const oldTimeout = process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS
|
|
process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS = '80'
|
|
|
|
try {
|
|
const startedAt = Date.now()
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`, {
|
|
signal: AbortSignal.timeout(1_000),
|
|
})
|
|
expect(Date.now() - startedAt).toBeLessThan(1_000)
|
|
expect(res.status).toBe(200)
|
|
|
|
const body = (await res.json()) as {
|
|
branch: string | null
|
|
repoName: string | null
|
|
workDir: string
|
|
changedFiles: number
|
|
}
|
|
expect(body.workDir).toBe(await fs.realpath(workDir))
|
|
expect(body.workDir).toContain('数据包看板')
|
|
expect(body.branch).toBe('main')
|
|
expect(body.repoName).toBe('datavizprocessingplatform')
|
|
expect(body.changedFiles).toBe(0)
|
|
} finally {
|
|
if (oldTimeout === undefined) {
|
|
delete process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS
|
|
} else {
|
|
process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS = oldTimeout
|
|
}
|
|
}
|
|
})
|
|
|
|
it('DELETE /api/sessions/:id should delete the session', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
|
|
expect(res.status).toBe(200)
|
|
|
|
// Verify it's gone
|
|
const res2 = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
|
|
expect(res2.status).toBe(404)
|
|
})
|
|
|
|
it('DELETE /api/sessions/:id should invalidate recent projects cache', async () => {
|
|
const workDir = await fs.mkdtemp(path.join(tmpDir, 'recent-cache-delete-'))
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
const realWorkDir = await fs.realpath(workDir)
|
|
|
|
const firstRecentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
|
|
expect(firstRecentRes.status).toBe(200)
|
|
const firstRecent = await firstRecentRes.json() as {
|
|
projects: Array<{ realPath: string }>
|
|
}
|
|
expect(firstRecent.projects.some((project) => project.realPath === realWorkDir)).toBe(true)
|
|
|
|
const deleteRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
|
|
expect(deleteRes.status).toBe(200)
|
|
|
|
const secondRecentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
|
|
expect(secondRecentRes.status).toBe(200)
|
|
const secondRecent = await secondRecentRes.json() as {
|
|
projects: Array<{ realPath: string }>
|
|
}
|
|
expect(secondRecent.projects.some((project) => project.realPath === realWorkDir)).toBe(false)
|
|
})
|
|
|
|
it('DELETE /api/sessions/:id should remove matching IM adapter session mappings', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const otherSessionId = 'ffffffff-1111-2222-3333-ffffffffffff'
|
|
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'adapter-sessions.json'),
|
|
JSON.stringify({
|
|
'wechat-chat': {
|
|
sessionId,
|
|
workDir: '/tmp/project-a',
|
|
updatedAt: 1,
|
|
},
|
|
'wechat-chat-2': {
|
|
sessionId,
|
|
workDir: '/tmp/project-b',
|
|
updatedAt: 2,
|
|
},
|
|
'other-chat': {
|
|
sessionId: otherSessionId,
|
|
workDir: '/tmp/project-c',
|
|
updatedAt: 3,
|
|
},
|
|
}, null, 2),
|
|
'utf-8',
|
|
)
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
|
|
expect(res.status).toBe(200)
|
|
|
|
const persisted = JSON.parse(
|
|
await fs.readFile(path.join(tmpDir, 'adapter-sessions.json'), 'utf-8'),
|
|
)
|
|
expect(persisted['wechat-chat']).toBeUndefined()
|
|
expect(persisted['wechat-chat-2']).toBeUndefined()
|
|
expect(persisted['other-chat'].sessionId).toBe(otherSessionId)
|
|
})
|
|
|
|
it('DELETE /api/sessions/:id should roll back the deleted marker when file deletion fails', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
|
|
|
|
const originalDeleteSession = sessionService.deleteSession.bind(sessionService)
|
|
sessionService.deleteSession = (async (targetSessionId: string) => {
|
|
if (targetSessionId === sessionId) {
|
|
throw new Error('simulated unlink failure')
|
|
}
|
|
return originalDeleteSession(targetSessionId)
|
|
}) as typeof sessionService.deleteSession
|
|
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
|
|
expect(res.status).toBe(500)
|
|
expect((conversationService as any).deletedSessions.has(sessionId)).toBe(false)
|
|
|
|
const detailRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
|
|
expect(detailRes.status).toBe(200)
|
|
} finally {
|
|
sessionService.deleteSession = originalDeleteSession as typeof sessionService.deleteSession
|
|
conversationService.unmarkSessionDeleted(sessionId)
|
|
}
|
|
})
|
|
|
|
it('POST /api/sessions/batch-delete should delete sessions and clean adapter mappings', async () => {
|
|
const sessionIdA = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const sessionIdB = 'ffffffff-1111-2222-3333-ffffffffffff'
|
|
const otherSessionId = '99999999-1111-2222-3333-999999999999'
|
|
await writeSessionFile('-tmp-api-test', sessionIdA, [makeSnapshotEntry()])
|
|
await writeSessionFile('-tmp-api-test', sessionIdB, [makeSnapshotEntry()])
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'adapter-sessions.json'),
|
|
JSON.stringify({
|
|
'wechat-chat-a': {
|
|
sessionId: sessionIdA,
|
|
workDir: '/tmp/project-a',
|
|
updatedAt: 1,
|
|
},
|
|
'wechat-chat-b': {
|
|
sessionId: sessionIdB,
|
|
workDir: '/tmp/project-b',
|
|
updatedAt: 2,
|
|
},
|
|
'other-chat': {
|
|
sessionId: otherSessionId,
|
|
workDir: '/tmp/project-c',
|
|
updatedAt: 3,
|
|
},
|
|
}, null, 2),
|
|
'utf-8',
|
|
)
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/batch-delete`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionIds: [sessionIdA, sessionIdB] }),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual({
|
|
ok: true,
|
|
successes: [sessionIdA, sessionIdB],
|
|
failures: [],
|
|
})
|
|
|
|
expect((await fetch(`${baseUrl}/api/sessions/${sessionIdA}`)).status).toBe(404)
|
|
expect((await fetch(`${baseUrl}/api/sessions/${sessionIdB}`)).status).toBe(404)
|
|
const persisted = JSON.parse(
|
|
await fs.readFile(path.join(tmpDir, 'adapter-sessions.json'), 'utf-8'),
|
|
)
|
|
expect(persisted['wechat-chat-a']).toBeUndefined()
|
|
expect(persisted['wechat-chat-b']).toBeUndefined()
|
|
expect(persisted['other-chat'].sessionId).toBe(otherSessionId)
|
|
})
|
|
|
|
it('POST /api/sessions/batch-delete should report partial failures and roll back failed delete markers', async () => {
|
|
const successSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const failedSessionId = 'ffffffff-1111-2222-3333-ffffffffffff'
|
|
await writeSessionFile('-tmp-api-test', successSessionId, [makeSnapshotEntry()])
|
|
await writeSessionFile('-tmp-api-test', failedSessionId, [makeSnapshotEntry()])
|
|
|
|
const originalDeleteSession = sessionService.deleteSession.bind(sessionService)
|
|
sessionService.deleteSession = (async (targetSessionId: string) => {
|
|
if (targetSessionId === failedSessionId) {
|
|
throw new Error('simulated batch unlink failure')
|
|
}
|
|
return originalDeleteSession(targetSessionId)
|
|
}) as typeof sessionService.deleteSession
|
|
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/sessions/batch-delete`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionIds: [successSessionId, failedSessionId] }),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual({
|
|
ok: false,
|
|
successes: [successSessionId],
|
|
failures: [{
|
|
sessionId: failedSessionId,
|
|
message: 'simulated batch unlink failure',
|
|
}],
|
|
})
|
|
expect((conversationService as any).deletedSessions.has(failedSessionId)).toBe(false)
|
|
expect((await fetch(`${baseUrl}/api/sessions/${successSessionId}`)).status).toBe(404)
|
|
expect((await fetch(`${baseUrl}/api/sessions/${failedSessionId}`)).status).toBe(200)
|
|
} finally {
|
|
sessionService.deleteSession = originalDeleteSession as typeof sessionService.deleteSession
|
|
conversationService.unmarkSessionDeleted(successSessionId)
|
|
conversationService.unmarkSessionDeleted(failedSessionId)
|
|
}
|
|
})
|
|
|
|
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',
|
|
kind: 'skill',
|
|
source: 'user',
|
|
}),
|
|
)
|
|
expect(body.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: 'project-skill',
|
|
description: 'Project skill description',
|
|
kind: 'skill',
|
|
source: 'project',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/slash-commands should include legacy custom commands before CLI init', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeef'
|
|
const workDir = path.join(tmpDir, 'workspace', 'app')
|
|
|
|
await writeLegacySlashCommand(
|
|
path.join(tmpDir, 'commands'),
|
|
'user-probe',
|
|
'User custom slash command',
|
|
)
|
|
await writeLegacySlashCommand(
|
|
path.join(workDir, '.claude', 'commands'),
|
|
'project-probe',
|
|
'Project custom slash command',
|
|
)
|
|
|
|
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; argumentHint?: string }>
|
|
}
|
|
|
|
expect(body.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: 'user-probe',
|
|
description: 'User custom slash command',
|
|
argumentHint: '<topic>',
|
|
kind: 'command',
|
|
}),
|
|
)
|
|
expect(body.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: 'project-probe',
|
|
description: 'Project custom slash command',
|
|
argumentHint: '<topic>',
|
|
kind: 'command',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/slash-commands should preserve cached command argument hints when merging custom commands', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeef001'
|
|
const workDir = path.join(tmpDir, 'workspace', 'app')
|
|
|
|
await writeSkill(
|
|
path.join(workDir, '.claude', 'skills'),
|
|
'project-skill-probe',
|
|
'Project skill description',
|
|
)
|
|
await writeLegacySlashCommand(
|
|
path.join(workDir, '.claude', 'commands'),
|
|
'project-probe',
|
|
'Project custom slash command',
|
|
)
|
|
|
|
await writeSessionFile('-tmp-api-test', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry(workDir),
|
|
])
|
|
|
|
updateSessionSlashCommands(
|
|
sessionId,
|
|
[
|
|
{ name: 'builtin-probe', description: 'Cached CLI command', argumentHint: '<value>' },
|
|
{ name: 'project-skill-probe', description: 'Cached CLI skill', argumentHint: '<topic>' },
|
|
],
|
|
{ notifyClient: false },
|
|
)
|
|
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; argumentHint?: string }>
|
|
}
|
|
|
|
expect(body.commands).toContainEqual({
|
|
name: 'builtin-probe',
|
|
description: 'Cached CLI command',
|
|
argumentHint: '<value>',
|
|
kind: 'command',
|
|
})
|
|
expect(body.commands).toContainEqual({
|
|
name: 'project-skill-probe',
|
|
description: 'Cached CLI skill',
|
|
argumentHint: '<topic>',
|
|
kind: 'skill',
|
|
source: 'project',
|
|
})
|
|
expect(body.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: 'project-probe',
|
|
description: 'Project custom slash command',
|
|
kind: 'command',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/slash-commands should include enabled plugin skills before CLI init', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
|
|
const workDir = path.join(tmpDir, 'workspace', 'app')
|
|
const marketplaceRoot = path.join(tmpDir, 'marketplace-root')
|
|
const pluginRoot = path.join(marketplaceRoot, 'plugins', 'superpowers')
|
|
const pluginsDir = path.join(tmpDir, 'plugins')
|
|
const marketplaceFile = path.join(
|
|
marketplaceRoot,
|
|
'.claude-plugin',
|
|
'marketplace.json',
|
|
)
|
|
|
|
await fs.mkdir(path.join(pluginRoot, '.claude-plugin'), { recursive: true })
|
|
await fs.mkdir(path.dirname(marketplaceFile), { recursive: true })
|
|
await fs.mkdir(pluginsDir, { recursive: true })
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await writeSkill(
|
|
path.join(pluginRoot, 'skills'),
|
|
'brainstorming',
|
|
'Superpowers brainstorming skill',
|
|
)
|
|
await fs.writeFile(
|
|
path.join(pluginRoot, '.claude-plugin', 'plugin.json'),
|
|
JSON.stringify({
|
|
name: 'superpowers',
|
|
version: '5.0.7',
|
|
description: 'Core skills library',
|
|
}),
|
|
'utf-8',
|
|
)
|
|
await fs.writeFile(
|
|
marketplaceFile,
|
|
JSON.stringify({
|
|
name: 'claude-plugins-official',
|
|
owner: { name: 'Test' },
|
|
plugins: [
|
|
{
|
|
name: 'superpowers',
|
|
source: './plugins/superpowers',
|
|
version: '5.0.7',
|
|
},
|
|
],
|
|
}),
|
|
'utf-8',
|
|
)
|
|
await fs.writeFile(
|
|
path.join(pluginsDir, 'known_marketplaces.json'),
|
|
JSON.stringify({
|
|
'claude-plugins-official': {
|
|
source: { source: 'directory', path: marketplaceRoot },
|
|
installLocation: marketplaceRoot,
|
|
lastUpdated: new Date(0).toISOString(),
|
|
},
|
|
}),
|
|
'utf-8',
|
|
)
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'settings.json'),
|
|
JSON.stringify({
|
|
enabledPlugins: {
|
|
'superpowers@claude-plugins-official': true,
|
|
},
|
|
}),
|
|
'utf-8',
|
|
)
|
|
|
|
resetSettingsCache()
|
|
clearPluginCache('sessions-api-plugin-skills')
|
|
clearCommandsCache()
|
|
await writeSessionFile('-tmp-api-test', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry(workDir),
|
|
])
|
|
|
|
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: 'superpowers:brainstorming',
|
|
description: 'Superpowers brainstorming skill',
|
|
kind: 'skill',
|
|
source: 'plugin',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/workspace/status|tree|search|file|diff should return workspace data', async () => {
|
|
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
|
const { sessionId } = await service.createSession(workDir)
|
|
|
|
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
|
|
expect(statusRes.status).toBe(200)
|
|
const statusBody = await statusRes.json() as {
|
|
state: string
|
|
workDir: string
|
|
changedFiles: Array<{ path: string; status: string }>
|
|
isGitRepo: boolean
|
|
}
|
|
expect(statusBody.state).toBe('ok')
|
|
expect(statusBody.workDir).toBe(await fs.realpath(workDir))
|
|
expect(statusBody.isGitRepo).toBe(true)
|
|
expect(statusBody.changedFiles).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ path: 'tracked.txt', status: 'modified' }),
|
|
]),
|
|
)
|
|
|
|
const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`)
|
|
expect(treeRes.status).toBe(200)
|
|
const treeBody = await treeRes.json() as {
|
|
state: string
|
|
path: string
|
|
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
|
}
|
|
expect(treeBody).toMatchObject({
|
|
state: 'ok',
|
|
path: '',
|
|
})
|
|
expect(treeBody.entries).toEqual([
|
|
{ name: 'services', path: 'services', isDirectory: true },
|
|
{ name: 'src', path: 'src', isDirectory: true },
|
|
{ name: 'tracked.txt', path: 'tracked.txt', isDirectory: false },
|
|
])
|
|
|
|
const searchRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/search?query=${encodeURIComponent('MentalHealthTrendController')}`,
|
|
)
|
|
expect(searchRes.status).toBe(200)
|
|
expect(await searchRes.json()).toMatchObject({
|
|
state: 'ok',
|
|
query: 'MentalHealthTrendController',
|
|
truncated: false,
|
|
entries: [{
|
|
name: 'MentalHealthTrendController.java',
|
|
path: 'services/mental-health-service/src/main/java/com/example/campus/mentalhealth/controller/MentalHealthTrendController.java',
|
|
isDirectory: false,
|
|
}],
|
|
})
|
|
|
|
const fileRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`,
|
|
)
|
|
expect(fileRes.status).toBe(200)
|
|
const fileBody = await fileRes.json() as {
|
|
state: string
|
|
path: string
|
|
content?: string
|
|
language: string
|
|
size: number
|
|
}
|
|
expect(fileBody).toMatchObject({
|
|
state: 'ok',
|
|
path: 'src/app.ts',
|
|
language: 'typescript',
|
|
size: 25,
|
|
content: 'export const answer = 42\n',
|
|
})
|
|
|
|
const diffRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('tracked.txt')}`,
|
|
)
|
|
expect(diffRes.status).toBe(200)
|
|
const diffBody = await diffRes.json() as {
|
|
state: string
|
|
path: string
|
|
diff?: string
|
|
}
|
|
expect(diffBody.state).toBe('ok')
|
|
expect(diffBody.path).toBe('tracked.txt')
|
|
expect(diffBody.diff).toContain('tracked.txt')
|
|
})
|
|
|
|
it('GET /api/sessions/:id/workspace/* should surface transcript changes for a non-git tmp session', async () => {
|
|
const sessionId = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff'
|
|
const workDir = await fs.mkdtemp(path.join(tmpDir, 'workspace-api-non-git-'))
|
|
const srcDir = path.join(workDir, 'src')
|
|
const notesDir = path.join(workDir, 'notes')
|
|
const assetsDir = path.join(workDir, 'assets')
|
|
|
|
await fs.mkdir(srcDir, { recursive: true })
|
|
await fs.mkdir(notesDir, { recursive: true })
|
|
await fs.mkdir(assetsDir, { recursive: true })
|
|
await fs.writeFile(path.join(workDir, 'README.md'), '# Temporary project\n')
|
|
await fs.writeFile(path.join(srcDir, 'app.ts'), 'export const answer = 2\n')
|
|
await fs.writeFile(path.join(notesDir, 'todo.md'), '- ship workspace panel\n')
|
|
await fs.writeFile(
|
|
path.join(assetsDir, 'pixel.png'),
|
|
Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
|
'base64',
|
|
),
|
|
)
|
|
|
|
await writeSessionFile(sanitizePath(workDir), sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry(workDir),
|
|
makeUserEntry('Update this temporary project'),
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'toolu-edit-app',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: path.join(workDir, 'src', 'app.ts'),
|
|
old_string: 'export const answer = 1\n',
|
|
new_string: 'export const answer = 2\n',
|
|
},
|
|
},
|
|
{
|
|
id: 'toolu-write-todo',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: path.join(workDir, 'notes', 'todo.md'),
|
|
content: '- ship workspace panel\n',
|
|
},
|
|
},
|
|
]),
|
|
])
|
|
|
|
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
|
|
expect(statusRes.status).toBe(200)
|
|
const statusBody = await statusRes.json() as {
|
|
state: string
|
|
workDir: string
|
|
repoName: string | null
|
|
branch: string | null
|
|
isGitRepo: boolean
|
|
changedFiles: Array<{
|
|
path: string
|
|
status: string
|
|
additions: number
|
|
deletions: number
|
|
}>
|
|
}
|
|
expect(statusBody).toMatchObject({
|
|
state: 'ok',
|
|
workDir,
|
|
repoName: path.basename(workDir),
|
|
branch: null,
|
|
isGitRepo: false,
|
|
})
|
|
expect(statusBody.changedFiles).toEqual([
|
|
expect.objectContaining({
|
|
path: 'notes/todo.md',
|
|
status: 'added',
|
|
additions: 1,
|
|
deletions: 0,
|
|
}),
|
|
expect.objectContaining({
|
|
path: 'src/app.ts',
|
|
status: 'modified',
|
|
additions: 1,
|
|
deletions: 1,
|
|
}),
|
|
])
|
|
|
|
const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`)
|
|
expect(treeRes.status).toBe(200)
|
|
const treeBody = await treeRes.json() as {
|
|
state: string
|
|
path: string
|
|
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
|
}
|
|
expect(treeBody).toMatchObject({ state: 'ok', path: '' })
|
|
expect(treeBody.entries).toEqual([
|
|
{ name: 'assets', path: 'assets', isDirectory: true },
|
|
{ name: 'notes', path: 'notes', isDirectory: true },
|
|
{ name: 'src', path: 'src', isDirectory: true },
|
|
{ name: 'README.md', path: 'README.md', isDirectory: false },
|
|
])
|
|
|
|
const srcTreeRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/tree?path=${encodeURIComponent('src')}`,
|
|
)
|
|
expect(srcTreeRes.status).toBe(200)
|
|
expect(await srcTreeRes.json()).toMatchObject({
|
|
state: 'ok',
|
|
path: 'src',
|
|
entries: [{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }],
|
|
})
|
|
|
|
const fileRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`,
|
|
)
|
|
expect(fileRes.status).toBe(200)
|
|
expect(await fileRes.json()).toMatchObject({
|
|
state: 'ok',
|
|
path: 'src/app.ts',
|
|
previewType: 'text',
|
|
language: 'typescript',
|
|
content: 'export const answer = 2\n',
|
|
})
|
|
|
|
const imageRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('assets/pixel.png')}`,
|
|
)
|
|
expect(imageRes.status).toBe(200)
|
|
const imageBody = await imageRes.json() as {
|
|
state: string
|
|
path: string
|
|
previewType: string
|
|
mimeType: string
|
|
dataUrl: string
|
|
}
|
|
expect(imageBody).toMatchObject({
|
|
state: 'ok',
|
|
path: 'assets/pixel.png',
|
|
previewType: 'image',
|
|
mimeType: 'image/png',
|
|
})
|
|
expect(imageBody.dataUrl).toStartWith('data:image/png;base64,')
|
|
|
|
const appDiffRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('src/app.ts')}`,
|
|
)
|
|
expect(appDiffRes.status).toBe(200)
|
|
const appDiffBody = await appDiffRes.json() as { state: string; path: string; diff?: string }
|
|
expect(appDiffBody).toMatchObject({ state: 'ok', path: 'src/app.ts' })
|
|
expect(appDiffBody.diff).toContain('diff --session a/src/app.ts b/src/app.ts')
|
|
expect(appDiffBody.diff).toContain('-export const answer = 1')
|
|
expect(appDiffBody.diff).toContain('+export const answer = 2')
|
|
|
|
const todoDiffRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('notes/todo.md')}`,
|
|
)
|
|
expect(todoDiffRes.status).toBe(200)
|
|
const todoDiffBody = await todoDiffRes.json() as { state: string; path: string; diff?: string }
|
|
expect(todoDiffBody).toMatchObject({ state: 'ok', path: 'notes/todo.md' })
|
|
expect(todoDiffBody.diff).toContain('--- /dev/null')
|
|
expect(todoDiffBody.diff).toContain('+++ b/notes/todo.md')
|
|
expect(todoDiffBody.diff).toContain('+- ship workspace panel')
|
|
})
|
|
|
|
it('GET /api/sessions/:id/workspace/* should surface file-history changes for a non-git generated subdirectory', async () => {
|
|
const sessionId = crypto.randomUUID()
|
|
const workDir = path.join(tmpDir, 'workspace-file-history-generated')
|
|
const generatedFile = path.join(workDir, 'aacc', 'src', 'App.tsx')
|
|
const userId = crypto.randomUUID()
|
|
|
|
await fs.mkdir(path.dirname(generatedFile), { recursive: true })
|
|
await fs.writeFile(
|
|
generatedFile,
|
|
'export default function App() { return <main>Tetris</main> }\n',
|
|
'utf-8',
|
|
)
|
|
|
|
await writeSessionFile(sanitizePath(workDir), sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'aacc/src/App.tsx': {
|
|
backupFileName: null,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('create aacc project', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantEntry('DONE', userId),
|
|
makeUserEntry(
|
|
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
|
|
crypto.randomUUID(),
|
|
),
|
|
makeAssistantEntry('Background task completed again, no action needed'),
|
|
])
|
|
|
|
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
|
|
expect(statusRes.status).toBe(200)
|
|
const statusBody = await statusRes.json() as {
|
|
state: string
|
|
workDir: string
|
|
isGitRepo: boolean
|
|
changedFiles: Array<{
|
|
path: string
|
|
status: string
|
|
additions: number
|
|
deletions: number
|
|
}>
|
|
}
|
|
expect(statusBody).toMatchObject({
|
|
state: 'ok',
|
|
workDir,
|
|
isGitRepo: false,
|
|
})
|
|
expect(statusBody.changedFiles).toEqual([
|
|
expect.objectContaining({
|
|
path: 'aacc/src/App.tsx',
|
|
status: 'added',
|
|
additions: 1,
|
|
deletions: 0,
|
|
}),
|
|
])
|
|
|
|
const diffRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('aacc/src/App.tsx')}`,
|
|
)
|
|
expect(diffRes.status).toBe(200)
|
|
const diffBody = await diffRes.json() as {
|
|
state: string
|
|
path: string
|
|
diff: string
|
|
}
|
|
expect(diffBody).toMatchObject({
|
|
state: 'ok',
|
|
path: 'aacc/src/App.tsx',
|
|
})
|
|
expect(diffBody.diff).toContain('diff --session /dev/null b/aacc/src/App.tsx')
|
|
expect(diffBody.diff).toContain('+export default function App() { return <main>Tetris</main> }')
|
|
|
|
const checkpointsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(checkpointsRes.status).toBe(200)
|
|
const checkpointsBody = await checkpointsRes.json() as {
|
|
checkpoints: Array<{
|
|
target: {
|
|
targetUserMessageId: string
|
|
userMessageIndex: number
|
|
userMessageCount: number
|
|
}
|
|
code: {
|
|
filesChanged: string[]
|
|
}
|
|
}>
|
|
}
|
|
expect(checkpointsBody.checkpoints).toHaveLength(1)
|
|
expect(checkpointsBody.checkpoints[0]?.target).toMatchObject({
|
|
targetUserMessageId: userId,
|
|
userMessageIndex: 0,
|
|
userMessageCount: 1,
|
|
})
|
|
expect(checkpointsBody.checkpoints[0]?.code.filesChanged).toEqual([generatedFile])
|
|
})
|
|
|
|
it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => {
|
|
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
|
const { sessionId } = await service.createSession(workDir)
|
|
|
|
for (const route of ['file', 'diff']) {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/${route}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.json()).toMatchObject({
|
|
error: 'BAD_REQUEST',
|
|
})
|
|
}
|
|
})
|
|
|
|
it('GET /api/sessions/:id/workspace/search should require a non-empty query', async () => {
|
|
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
|
const { sessionId } = await service.createSession(workDir)
|
|
|
|
for (const suffix of ['', '?query=%20%20']) {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/search${suffix}`)
|
|
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/branch should create a branched session up to the target message', async () => {
|
|
const sessionId = '11111111-1111-4111-8111-111111111111'
|
|
const workDir = path.join(tmpDir, 'branch-api-workdir')
|
|
const firstUserId = crypto.randomUUID()
|
|
const firstAssistantId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const secondAssistantId = crypto.randomUUID()
|
|
|
|
await writeSessionFile(sanitizePath(workDir), sessionId, [
|
|
{
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
workDir,
|
|
timestamp: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
{
|
|
...makeUserEntry('first prompt', firstUserId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('first reply', firstUserId),
|
|
uuid: firstAssistantId,
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeUserEntry('second prompt', secondUserId),
|
|
parentUuid: firstAssistantId,
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('second reply', secondUserId),
|
|
uuid: secondAssistantId,
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
targetMessageId: firstAssistantId,
|
|
title: 'API branch',
|
|
}),
|
|
})
|
|
expect(res.status).toBe(201)
|
|
|
|
const body = await res.json() as {
|
|
sessionId: string
|
|
title: string
|
|
workDir: string
|
|
sourceSessionId: string
|
|
targetMessageId: string
|
|
}
|
|
expect(body).toMatchObject({
|
|
title: 'API branch (Branch)',
|
|
workDir,
|
|
sourceSessionId: sessionId,
|
|
targetMessageId: firstAssistantId,
|
|
})
|
|
|
|
const branchMessages = await service.getSessionMessages(body.sessionId)
|
|
expect(branchMessages.map((message) => message.id)).toEqual([
|
|
firstUserId,
|
|
firstAssistantId,
|
|
])
|
|
})
|
|
|
|
it('POST /api/sessions/:id/branch should reject sidechain targets', async () => {
|
|
const sessionId = '22222222-2222-4222-8222-222222222222'
|
|
const rootUserId = crypto.randomUUID()
|
|
const rootAssistantId = crypto.randomUUID()
|
|
const sidechainId = crypto.randomUUID()
|
|
|
|
await writeSessionFile('-tmp-api-branch-sidechain', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
...makeUserEntry('root prompt', rootUserId),
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('root reply', rootUserId),
|
|
uuid: rootAssistantId,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeUserEntry('side question', sidechainId),
|
|
parentUuid: rootAssistantId,
|
|
isSidechain: true,
|
|
sessionId,
|
|
},
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetMessageId: sidechainId }),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(await res.json()).toMatchObject({
|
|
error: 'BAD_REQUEST',
|
|
})
|
|
})
|
|
|
|
it('POST /api/sessions/:id/branch should validate request bodies and missing sessions', async () => {
|
|
const methodNotAllowedRes = await fetch(`${baseUrl}/api/sessions/33333333-3333-4333-8333-333333333333/branch`)
|
|
expect(methodNotAllowedRes.status).toBe(405)
|
|
|
|
const missingTargetRes = await fetch(`${baseUrl}/api/sessions/branch-missing-target/branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
expect(missingTargetRes.status).toBe(400)
|
|
|
|
const invalidJsonRes = await fetch(`${baseUrl}/api/sessions/branch-invalid-json/branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{',
|
|
})
|
|
expect(invalidJsonRes.status).toBe(400)
|
|
|
|
const invalidTitleRes = await fetch(`${baseUrl}/api/sessions/44444444-4444-4444-8444-444444444444/branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetMessageId: 'message-1', title: 123 }),
|
|
})
|
|
expect(invalidTitleRes.status).toBe(400)
|
|
|
|
const missingSessionRes = await fetch(`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/branch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetMessageId: 'missing-target' }),
|
|
})
|
|
expect(missingSessionRes.status).toBe(404)
|
|
})
|
|
|
|
it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const firstUserId = crypto.randomUUID()
|
|
const firstAssistantId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const secondAssistantId = crypto.randomUUID()
|
|
|
|
await writeSessionFile('-tmp-api-test', sessionId, [
|
|
makeSnapshotEntry(),
|
|
{
|
|
parentUuid: null,
|
|
isSidechain: false,
|
|
type: 'user',
|
|
message: { role: 'user', content: 'first prompt' },
|
|
uuid: firstUserId,
|
|
timestamp: '2026-01-01T00:01:00.000Z',
|
|
userType: 'external',
|
|
cwd: '/tmp/test',
|
|
sessionId,
|
|
},
|
|
{
|
|
parentUuid: firstUserId,
|
|
isSidechain: false,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'first reply' }],
|
|
},
|
|
uuid: firstAssistantId,
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
},
|
|
{
|
|
parentUuid: firstAssistantId,
|
|
isSidechain: false,
|
|
type: 'user',
|
|
message: { role: 'user', content: 'second prompt' },
|
|
uuid: secondUserId,
|
|
timestamp: '2026-01-01T00:03:00.000Z',
|
|
userType: 'external',
|
|
cwd: '/tmp/test',
|
|
sessionId,
|
|
},
|
|
{
|
|
parentUuid: secondUserId,
|
|
isSidechain: false,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'second reply' }],
|
|
},
|
|
uuid: secondAssistantId,
|
|
timestamp: '2026-01-01T00:04:00.000Z',
|
|
},
|
|
])
|
|
|
|
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
|
|
})
|
|
expect(previewRes.status).toBe(200)
|
|
|
|
const previewBody = await previewRes.json() as {
|
|
conversation: { messagesRemoved: number }
|
|
code: { available: boolean }
|
|
}
|
|
expect(previewBody.conversation.messagesRemoved).toBe(2)
|
|
expect(previewBody.code.available).toBe(false)
|
|
|
|
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userMessageIndex: 1 }),
|
|
})
|
|
expect(executeRes.status).toBe(200)
|
|
|
|
const executeBody = await executeRes.json() as {
|
|
conversation: { messagesRemoved: number; removedMessageIds: string[] }
|
|
}
|
|
expect(executeBody.conversation.messagesRemoved).toBe(2)
|
|
expect(executeBody.conversation.removedMessageIds).toEqual([
|
|
secondUserId,
|
|
secondAssistantId,
|
|
])
|
|
|
|
const remainingMessages = await service.getSessionMessages(sessionId)
|
|
expect(remainingMessages.map((message) => message.id)).toEqual([
|
|
firstUserId,
|
|
firstAssistantId,
|
|
])
|
|
})
|
|
|
|
it('trimSessionMessagesFrom should remove orphan transcript entries beyond the rewind point', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const firstUserId = crypto.randomUUID()
|
|
const firstAssistantId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const secondAssistantId = crypto.randomUUID()
|
|
|
|
const filePath = await writeSessionFile('-tmp-api-rewind-orphans', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeSessionMetaEntry('/tmp/project-with-hyphen'),
|
|
{
|
|
...makeUserEntry('first prompt', firstUserId),
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('first reply', firstUserId),
|
|
uuid: firstAssistantId,
|
|
},
|
|
{
|
|
...makeUserEntry('second prompt', secondUserId),
|
|
parentUuid: firstAssistantId,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('second reply', secondUserId),
|
|
uuid: secondAssistantId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('late stale reply', secondUserId),
|
|
uuid: crypto.randomUUID(),
|
|
},
|
|
])
|
|
|
|
const result = await service.trimSessionMessagesFrom(sessionId, firstUserId)
|
|
expect(result.removedMessageIds).toContain(firstUserId)
|
|
expect(result.removedMessageIds).toContain(secondUserId)
|
|
|
|
const raw = await fs.readFile(filePath, 'utf-8')
|
|
expect(raw).toContain('"type":"session-meta"')
|
|
expect(raw).not.toContain('late stale reply')
|
|
expect(await service.getSessionMessages(sessionId)).toEqual([])
|
|
|
|
const launchInfo = await service.getSessionLaunchInfo(sessionId)
|
|
expect(launchInfo).not.toBeNull()
|
|
expect(launchInfo!.workDir).toBe('/tmp/project-with-hyphen')
|
|
expect(launchInfo!.transcriptMessageCount).toBe(0)
|
|
})
|
|
|
|
it('POST /api/sessions/:id/rewind should target the selected message id instead of a shifted visible index', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
|
|
const firstUserId = crypto.randomUUID()
|
|
const firstAssistantId = crypto.randomUUID()
|
|
const hiddenUserId = crypto.randomUUID()
|
|
const targetUserId = crypto.randomUUID()
|
|
const targetAssistantId = crypto.randomUUID()
|
|
|
|
await writeSessionFile('-tmp-api-rewind-id-target', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry('first prompt', firstUserId),
|
|
{
|
|
...makeAssistantEntry('first reply', firstUserId),
|
|
uuid: firstAssistantId,
|
|
},
|
|
makeUserEntry(
|
|
'<teammate-message teammate_id="reviewer">internal status that the main chat hides</teammate-message>',
|
|
hiddenUserId,
|
|
),
|
|
makeUserEntry('second visible prompt', targetUserId),
|
|
{
|
|
...makeAssistantEntry('second reply', targetUserId),
|
|
uuid: targetAssistantId,
|
|
},
|
|
])
|
|
|
|
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
userMessageIndex: 1,
|
|
targetUserMessageId: targetUserId,
|
|
expectedContent: 'second visible prompt',
|
|
}),
|
|
})
|
|
expect(executeRes.status).toBe(200)
|
|
|
|
const executeBody = await executeRes.json() as {
|
|
target: { targetUserMessageId: string; userMessageIndex: number }
|
|
conversation: { messagesRemoved: number; removedMessageIds: string[] }
|
|
}
|
|
expect(executeBody.target.targetUserMessageId).toBe(targetUserId)
|
|
expect(executeBody.target.userMessageIndex).toBe(2)
|
|
expect(executeBody.conversation.messagesRemoved).toBe(2)
|
|
expect(executeBody.conversation.removedMessageIds).toEqual([
|
|
targetUserId,
|
|
targetAssistantId,
|
|
])
|
|
|
|
const remainingMessages = await service.getSessionMessages(sessionId)
|
|
expect(remainingMessages.map((message) => message.id)).toEqual([
|
|
firstUserId,
|
|
firstAssistantId,
|
|
hiddenUserId,
|
|
])
|
|
})
|
|
|
|
it('POST /api/sessions/:id/rewind should reject an index fallback when the selected prompt no longer matches', async () => {
|
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-000000000000'
|
|
const firstUserId = crypto.randomUUID()
|
|
const hiddenUserId = crypto.randomUUID()
|
|
const targetUserId = crypto.randomUUID()
|
|
|
|
await writeSessionFile('-tmp-api-rewind-index-guard', sessionId, [
|
|
makeSnapshotEntry(),
|
|
makeUserEntry('first prompt', firstUserId),
|
|
makeUserEntry(
|
|
'<teammate-message teammate_id="reviewer">internal status that the main chat hides</teammate-message>',
|
|
hiddenUserId,
|
|
),
|
|
makeUserEntry('second visible prompt', targetUserId),
|
|
])
|
|
|
|
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
userMessageIndex: 1,
|
|
expectedContent: 'second visible prompt',
|
|
}),
|
|
})
|
|
expect(executeRes.status).toBe(400)
|
|
|
|
const body = await executeRes.json() as { message: string }
|
|
expect(body.message).toContain('does not match the selected prompt')
|
|
|
|
const remainingMessages = await service.getSessionMessages(sessionId)
|
|
expect(remainingMessages.map((message) => message.id)).toEqual([
|
|
firstUserId,
|
|
hiddenUserId,
|
|
targetUserId,
|
|
])
|
|
})
|
|
|
|
it('POST /api/sessions/:id/rewind should restore a single edited file', async () => {
|
|
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
const workDir = path.join(tmpDir, 'single-file-fixture')
|
|
const targetFile = path.join(workDir, 'src', 'app.js')
|
|
const userId = crypto.randomUUID()
|
|
const assistantId = crypto.randomUUID()
|
|
const backupName = 'single-file@v1'
|
|
|
|
await fs.mkdir(path.dirname(targetFile), { recursive: true })
|
|
await fs.writeFile(
|
|
targetFile,
|
|
"export const ORIGINAL_VALUE = 'after-rewind'\n",
|
|
'utf-8',
|
|
)
|
|
await writeFileHistoryBackup(
|
|
sessionId,
|
|
backupName,
|
|
"export const ORIGINAL_VALUE = 'before-rewind'\n",
|
|
)
|
|
|
|
await writeSessionFile('-tmp-api-single-file', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src/app.js': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('edit app.js', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantEntry('DONE', userId),
|
|
uuid: assistantId,
|
|
},
|
|
])
|
|
|
|
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userMessageIndex: 0, dryRun: true }),
|
|
})
|
|
expect(previewRes.status).toBe(200)
|
|
const preview = await previewRes.json() as {
|
|
code: { available: boolean; filesChanged: string[] }
|
|
}
|
|
expect(preview.code.available).toBe(true)
|
|
expect(preview.code.filesChanged).toEqual([targetFile])
|
|
|
|
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userMessageIndex: 0 }),
|
|
})
|
|
expect(executeRes.status).toBe(200)
|
|
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
|
"export const ORIGINAL_VALUE = 'before-rewind'\n",
|
|
)
|
|
|
|
const remainingMessages = await service.getSessionMessages(sessionId)
|
|
expect(remainingMessages).toHaveLength(0)
|
|
})
|
|
|
|
it('POST /api/sessions/:id/rewind should reject unsafe tracked paths before any partial restore', async () => {
|
|
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeef'
|
|
const workDir = path.join(tmpDir, 'linked-file-fixture')
|
|
const outsideDir = path.join(tmpDir, 'outside-linked-file-fixture')
|
|
const safeFile = path.join(workDir, 'safe.txt')
|
|
const missingFile = path.join(workDir, 'missing.txt')
|
|
const symlinkFile = path.join(workDir, 'symlink.txt')
|
|
const linkedDir = path.join(workDir, 'linked-dir')
|
|
const hardLinkFile = path.join(workDir, 'hard-link.txt')
|
|
const hardLinkDeleteFile = path.join(workDir, 'hard-link-delete.txt')
|
|
const outsideSafeFile = path.join(outsideDir, 'safe-absolute.txt')
|
|
const outsideRelativeFile = path.join(outsideDir, 'relative-target.txt')
|
|
const outsideSymlinkFile = path.join(outsideDir, 'symlink-target.txt')
|
|
const outsideDeleteFile = path.join(outsideDir, 'delete-target.txt')
|
|
const outsideHardLinkFile = path.join(outsideDir, 'hard-link-target.txt')
|
|
const outsideHardLinkDeleteFile = path.join(outsideDir, 'hard-link-delete-target.txt')
|
|
const userId = crypto.randomUUID()
|
|
const safeBackup = 'linked-safe@v1'
|
|
const missingBackup = 'linked-missing@v1'
|
|
const outsideSafeBackup = 'linked-safe-absolute@v1'
|
|
const outsideRelativeBackup = 'linked-relative@v1'
|
|
const symlinkBackup = 'linked-symlink@v1'
|
|
const hardLinkBackup = 'linked-hard-link@v1'
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.mkdir(outsideDir, { recursive: true })
|
|
await fs.writeFile(safeFile, 'safe after\n', 'utf-8')
|
|
await fs.writeFile(outsideSafeFile, 'safe absolute after\n', 'utf-8')
|
|
await fs.writeFile(outsideRelativeFile, 'relative outside after\n', 'utf-8')
|
|
await fs.writeFile(outsideSymlinkFile, 'symlink outside after\n', 'utf-8')
|
|
await fs.writeFile(outsideDeleteFile, 'delete outside after\n', 'utf-8')
|
|
await fs.writeFile(outsideHardLinkFile, 'hard link outside after\n', 'utf-8')
|
|
await fs.writeFile(outsideHardLinkDeleteFile, 'hard link delete outside after\n', 'utf-8')
|
|
await fs.symlink(outsideSymlinkFile, symlinkFile)
|
|
await fs.symlink(outsideDir, linkedDir)
|
|
await fs.link(outsideHardLinkFile, hardLinkFile)
|
|
await fs.link(outsideHardLinkDeleteFile, hardLinkDeleteFile)
|
|
await writeFileHistoryBackup(sessionId, safeBackup, 'safe before\n')
|
|
await writeFileHistoryBackup(sessionId, missingBackup, 'missing before\n')
|
|
await writeFileHistoryBackup(sessionId, outsideSafeBackup, 'safe absolute before\n')
|
|
await writeFileHistoryBackup(sessionId, outsideRelativeBackup, 'relative before\n')
|
|
await writeFileHistoryBackup(sessionId, symlinkBackup, 'symlink before\n')
|
|
await writeFileHistoryBackup(sessionId, hardLinkBackup, 'hard link before\n')
|
|
|
|
await writeSessionFile('-tmp-api-linked-file', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'safe.txt': {
|
|
backupFileName: safeBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'missing.txt': {
|
|
backupFileName: missingBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
[outsideSafeFile]: {
|
|
backupFileName: outsideSafeBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'../outside-linked-file-fixture/relative-target.txt': {
|
|
backupFileName: outsideRelativeBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'symlink.txt': {
|
|
backupFileName: symlinkBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'linked-dir/delete-target.txt': {
|
|
backupFileName: null,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'hard-link.txt': {
|
|
backupFileName: hardLinkBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'hard-link-delete.txt': {
|
|
backupFileName: null,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('edit linked 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[] }
|
|
restoreAvailable?: boolean
|
|
}
|
|
expect(preview.code.filesChanged).toEqual([
|
|
safeFile,
|
|
missingFile,
|
|
outsideSafeFile,
|
|
outsideRelativeFile,
|
|
symlinkFile,
|
|
path.join(linkedDir, 'delete-target.txt'),
|
|
hardLinkFile,
|
|
hardLinkDeleteFile,
|
|
])
|
|
expect(preview.restoreAvailable).toBe(false)
|
|
|
|
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(400)
|
|
|
|
expect(await fs.readFile(safeFile, 'utf-8')).toBe('safe after\n')
|
|
await expect(fs.stat(missingFile)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await fs.readFile(outsideSafeFile, 'utf-8')).toBe('safe absolute after\n')
|
|
expect(await fs.readFile(outsideRelativeFile, 'utf-8')).toBe(
|
|
'relative outside after\n',
|
|
)
|
|
expect(await fs.readFile(outsideSymlinkFile, 'utf-8')).toBe('symlink outside after\n')
|
|
expect(await fs.readFile(outsideDeleteFile, 'utf-8')).toBe('delete outside after\n')
|
|
expect(await fs.readFile(outsideHardLinkFile, 'utf-8')).toBe('hard link outside after\n')
|
|
expect(await fs.readFile(outsideHardLinkDeleteFile, 'utf-8')).toBe(
|
|
'hard link delete outside after\n',
|
|
)
|
|
expect((await fs.lstat(symlinkFile)).isSymbolicLink()).toBe(true)
|
|
expect((await fs.lstat(linkedDir)).isSymbolicLink()).toBe(true)
|
|
expect((await fs.stat(hardLinkFile)).nlink).toBe(2)
|
|
expect((await fs.stat(hardLinkDeleteFile)).nlink).toBe(2)
|
|
expect((await service.getSessionMessages(sessionId)).map((message) => message.id))
|
|
.toContain(userId)
|
|
})
|
|
|
|
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,
|
|
restoreAvailable: true,
|
|
unverifiedChangeSources: [],
|
|
},
|
|
{
|
|
target: {
|
|
targetUserMessageId: fixture.secondUserId,
|
|
userMessageIndex: 1,
|
|
userMessageCount: 3,
|
|
},
|
|
conversation: { messagesRemoved: 4 },
|
|
code: {
|
|
available: true,
|
|
filesChanged: [fixture.stepFile],
|
|
insertions: 1,
|
|
deletions: 1,
|
|
},
|
|
workDir: fixture.workDir,
|
|
restoreAvailable: true,
|
|
unverifiedChangeSources: [],
|
|
},
|
|
{
|
|
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,
|
|
restoreAvailable: true,
|
|
unverifiedChangeSources: [],
|
|
},
|
|
])
|
|
})
|
|
|
|
it.each(['legacy', 'completed'] as const)('does not repeat carried-forward %s file checkpoints on text and Read turns', async (kind) => {
|
|
const sessionId = crypto.randomUUID()
|
|
const workDir = path.join(tmpDir, `carried-forward-chat-checkpoints-${kind}`)
|
|
const filePath = path.join(workDir, 'created.txt')
|
|
const userIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(filePath, 'created in the first turn\n')
|
|
await writeFileHistoryBackup(sessionId, 'created-after', 'created in the first turn\n')
|
|
const entries: Record<string, unknown>[] = [makeSessionMetaEntry(workDir)]
|
|
for (const [index, userId] of userIds.entries()) {
|
|
const snapshot = makeFileHistorySnapshotEntry(userId, {
|
|
'created.txt': { backupFileName: null, version: 1, backupTime: `2026-01-01T00:0${index}:00.000Z` },
|
|
})
|
|
if (kind === 'completed') {
|
|
snapshot.snapshot = { ...(snapshot.snapshot as Record<string, unknown>), completedFileBackups: {
|
|
'created.txt': { backupFileName: 'created-after', version: 1, backupTime: `2026-01-01T00:0${index}:00.000Z` },
|
|
} }
|
|
}
|
|
entries.push(
|
|
snapshot,
|
|
{ ...makeUserEntry(['create a file', 'hello', 'read the file'][index]!, userId), cwd: workDir, sessionId },
|
|
)
|
|
if (index !== 1) {
|
|
const toolName = index === 0 ? 'Write' : 'Read'
|
|
const toolId = `${toolName}:carry-forward-${index}`
|
|
entries.push(
|
|
makeAssistantToolUseEntry([{
|
|
id: toolId,
|
|
name: toolName,
|
|
input: { file_path: filePath, ...(index === 0 ? { content: 'created in the first turn\n' } : {}) },
|
|
}], userId),
|
|
makeToolResultUserEntry(toolId, 'success', undefined, undefined, sessionId),
|
|
)
|
|
}
|
|
entries.push(makeAssistantEntry('Done.', userId))
|
|
}
|
|
await writeSessionFile('-tmp-carried-forward-chat-checkpoints', sessionId, entries)
|
|
|
|
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ code: { filesChanged: string[] } }> }
|
|
expect(body.checkpoints.map((checkpoint) => checkpoint.code.filesChanged)).toEqual([[filePath], [], []])
|
|
|
|
const rewindPreview = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userIds[1], dryRun: true }),
|
|
})
|
|
expect(rewindPreview.status).toBe(200)
|
|
expect(await rewindPreview.json()).toMatchObject({ restoreAvailable: true, code: { available: true, filesChanged: [] } })
|
|
const rewind = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userIds[1] }),
|
|
})
|
|
expect(rewind.status).toBe(200)
|
|
expect(await rewind.json()).toMatchObject({ mode: 'both', restoreAvailable: true })
|
|
expect(await fs.readFile(filePath, 'utf8')).toBe('created in the first turn\n')
|
|
})
|
|
|
|
it('does not report carried files as changed on the last turn when their backups are migrated hard links', async () => {
|
|
const sessionId = crypto.randomUUID()
|
|
const workDir = path.join(tmpDir, `migrated-link-checkpoints-${sessionId}`)
|
|
const filePath = path.join(workDir, 'doc.md')
|
|
const firstUserId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(filePath, 'turn one\n')
|
|
await writeFileHistoryBackup(sessionId, 'doc-before@v1', 'before turn one\n')
|
|
await writeFileHistoryBackup(sessionId, 'doc-after-first@v2', 'turn one\n')
|
|
// Resume migration hard-links backups from the previous session's directory
|
|
// into this one, leaving the carried backup with nlink > 1.
|
|
const migratedDir = path.join(tmpDir, 'file-history', crypto.randomUUID())
|
|
await fs.mkdir(migratedDir, { recursive: true })
|
|
await fs.link(
|
|
path.join(tmpDir, 'file-history', sessionId, 'doc-after-first@v2'),
|
|
path.join(migratedDir, 'doc-after-first@v2'),
|
|
)
|
|
|
|
await writeSessionFile('-tmp-migrated-link-checkpoints', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'doc.md': { backupFileName: 'doc-before@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('write the doc', firstUserId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Write:migrated-link-0',
|
|
name: 'Write',
|
|
input: { file_path: filePath, content: 'turn one\n' },
|
|
}], firstUserId),
|
|
makeToolResultUserEntry('Write:migrated-link-0', 'success', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', firstUserId),
|
|
makeFileHistorySnapshotEntry(secondUserId, {
|
|
'doc.md': { backupFileName: 'doc-after-first@v2', version: 2, backupTime: '2026-01-01T00:01:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('just talk about it', secondUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('No files this turn.', secondUserId),
|
|
])
|
|
|
|
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ code: { filesChanged: string[] } }> }
|
|
// The last (discussion-only) turn must not inherit the carried file. Before
|
|
// the heal, its unreadable linked before-backup diffed against live disk as
|
|
// "changed", and every carried file leaked into the last turn's list.
|
|
expect(body.checkpoints.map((checkpoint) => checkpoint.code.filesChanged)).toEqual([[filePath], []])
|
|
})
|
|
|
|
it('preserves fresh snapshot-only evidence after a recorded Write turn', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000001306'
|
|
const workDir = path.join(tmpDir, 'mixed-snapshot-only-checkpoints')
|
|
const filePath = path.join(workDir, 'created.txt')
|
|
const legacyPath = path.join(workDir, 'legacy.txt')
|
|
const firstUserId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const freshBackup = 'mixed-snapshot-created@v2'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(filePath, 'second version\n')
|
|
await fs.writeFile(legacyPath, 'legacy write\n')
|
|
await writeFileHistoryBackup(sessionId, freshBackup, 'first version\n')
|
|
await writeSessionFile('-tmp-mixed-snapshot-only-checkpoints', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'created.txt': { backupFileName: null, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('create a file', firstUserId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Write:mixed-snapshot', name: 'Write', input: { file_path: filePath, content: 'first version\n' },
|
|
}], firstUserId),
|
|
makeToolResultUserEntry('Write:mixed-snapshot', 'success', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', firstUserId),
|
|
makeFileHistorySnapshotEntry(secondUserId, {
|
|
'created.txt': { backupFileName: freshBackup, version: 2, backupTime: '2026-01-01T00:01:00.000Z' },
|
|
'legacy.txt': { backupFileName: null, version: 1, backupTime: '2026-01-01T00:01:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit using an older provider', secondUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', secondUserId),
|
|
])
|
|
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ code: { filesChanged: string[] } }> }
|
|
expect(body.checkpoints.map((checkpoint) => checkpoint.code.filesChanged)).toEqual([
|
|
[filePath], [filePath, legacyPath],
|
|
])
|
|
})
|
|
|
|
it('should expose authoritative conversation targets for text, provider errors, and repeated continues', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000001273'
|
|
const workDir = path.join(tmpDir, 'conversation-only-turn-targets')
|
|
const untouchedFile = path.join(workDir, 'untouched.txt')
|
|
const firstUserId = crypto.randomUUID()
|
|
const failedContinueUserId = crypto.randomUUID()
|
|
const recoveredContinueUserId = crypto.randomUUID()
|
|
const incompleteUserId = crypto.randomUUID()
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(untouchedFile, 'keep me\n', 'utf-8')
|
|
await writeSessionFile('-tmp-conversation-only-turn-targets', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{ ...makeUserEntry('Explain the current behavior', firstUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Plain text answer.', firstUserId),
|
|
{ ...makeUserEntry('continue', failedContinueUserId), cwd: workDir, sessionId },
|
|
{
|
|
...makeAssistantEntry('Provider request failed.', failedContinueUserId),
|
|
isApiErrorMessage: true,
|
|
error: 'API Error',
|
|
errorDetails: 'upstream provider returned an error',
|
|
},
|
|
{ ...makeUserEntry('continue', recoveredContinueUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Recovered on the next attempt.', recoveredContinueUserId),
|
|
{ ...makeUserEntry('still running', incompleteUserId), cwd: workDir, sessionId },
|
|
])
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(listRes.status).toBe(200)
|
|
const listBody = await listRes.json() as {
|
|
checkpoints: Array<{
|
|
target: {
|
|
targetUserMessageId: string
|
|
userMessageIndex: number
|
|
userMessageCount: number
|
|
}
|
|
code: { available: boolean; filesChanged: string[] }
|
|
}>
|
|
}
|
|
|
|
expect(listBody.checkpoints.map((checkpoint) => ({
|
|
target: checkpoint.target,
|
|
code: checkpoint.code,
|
|
}))).toEqual([
|
|
{
|
|
target: {
|
|
targetUserMessageId: firstUserId,
|
|
userMessageIndex: 0,
|
|
userMessageCount: 4,
|
|
},
|
|
code: expect.objectContaining({ available: false, filesChanged: [] }),
|
|
},
|
|
{
|
|
target: {
|
|
targetUserMessageId: failedContinueUserId,
|
|
userMessageIndex: 1,
|
|
userMessageCount: 4,
|
|
},
|
|
code: expect.objectContaining({ available: false, filesChanged: [] }),
|
|
},
|
|
{
|
|
target: {
|
|
targetUserMessageId: recoveredContinueUserId,
|
|
userMessageIndex: 2,
|
|
userMessageCount: 4,
|
|
},
|
|
code: expect.objectContaining({ available: false, filesChanged: [] }),
|
|
},
|
|
])
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
targetUserMessageId: failedContinueUserId,
|
|
expectedContent: 'continue',
|
|
mode: 'conversation',
|
|
}),
|
|
})
|
|
expect(rewindRes.status).toBe(200)
|
|
expect(await rewindRes.json()).toMatchObject({
|
|
target: {
|
|
targetUserMessageId: failedContinueUserId,
|
|
userMessageIndex: 1,
|
|
},
|
|
mode: 'conversation',
|
|
})
|
|
expect(await fs.readFile(untouchedFile, 'utf-8')).toBe('keep me\n')
|
|
|
|
const remainingMessages = await service.getSessionMessages(sessionId)
|
|
expect(remainingMessages.map((message) => message.id)).toEqual([
|
|
firstUserId,
|
|
expect.any(String),
|
|
])
|
|
expect(remainingMessages.some((message) => message.id === failedContinueUserId)).toBe(false)
|
|
expect(remainingMessages.some((message) => message.id === recoveredContinueUserId)).toBe(false)
|
|
expect(remainingMessages.some((message) => message.id === incompleteUserId)).toBe(false)
|
|
})
|
|
|
|
it('keeps a post-rewind Edit checkpoint scoped to the new turn', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000001274'
|
|
const workDir = path.join(tmpDir, 'conversation-rewind-checkpoint-boundary')
|
|
const largeFile = path.join(workDir, 'large-200.json')
|
|
const liveFile = path.join(workDir, 'live-run.json')
|
|
const largeContent = `${Array.from(
|
|
{ length: 206 },
|
|
(_, index) => JSON.stringify({ index }),
|
|
).join('\n')}\n`
|
|
const liveBefore = '{\n "status": "ready"\n}\n'
|
|
const liveAfter = '{\n "rewind_probe": true,\n "status": "ready"\n}\n'
|
|
const firstUserId = crypto.randomUUID()
|
|
const computerUserId = crypto.randomUUID()
|
|
const rewoundUserId = crypto.randomUUID()
|
|
const newUserId = crypto.randomUUID()
|
|
const liveBackup = 'post-rewind-live-before@v2'
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(largeFile, largeContent, 'utf-8')
|
|
await fs.writeFile(liveFile, liveBefore, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, liveBackup, liveBefore)
|
|
const transcriptPath = await writeSessionFile(
|
|
'-tmp-conversation-rewind-checkpoint-boundary',
|
|
sessionId,
|
|
[
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'large-200.json': {
|
|
backupFileName: null,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'live-run.json': {
|
|
backupFileName: null,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{ ...makeUserEntry('create both files', firstUserId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Write:historical-large',
|
|
name: 'Write',
|
|
input: { file_path: largeFile, content: largeContent },
|
|
},
|
|
{
|
|
id: 'Write:historical-live',
|
|
name: 'Write',
|
|
input: { file_path: liveFile, content: liveBefore },
|
|
},
|
|
], firstUserId),
|
|
makeToolResultUserEntry(
|
|
'Write:historical-large',
|
|
`The file ${largeFile} has been written successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeToolResultUserEntry(
|
|
'Write:historical-live',
|
|
`The file ${liveFile} has been written successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Both files created.', firstUserId),
|
|
{ ...makeUserEntry('inspect with computer use', computerUserId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Computer:inspect-only',
|
|
name: 'Computer',
|
|
input: { action: 'screenshot' },
|
|
}], computerUserId),
|
|
makeToolResultUserEntry(
|
|
'Computer:inspect-only',
|
|
'Screenshot captured.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Inspection complete.', computerUserId),
|
|
{ ...makeUserEntry('summarize only', rewoundUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Summary only.', rewoundUserId),
|
|
],
|
|
)
|
|
|
|
const conversationRewind = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
targetUserMessageId: rewoundUserId,
|
|
expectedContent: 'summarize only',
|
|
mode: 'conversation',
|
|
}),
|
|
})
|
|
expect(conversationRewind.status).toBe(200)
|
|
expect(await conversationRewind.json()).toMatchObject({ mode: 'conversation' })
|
|
|
|
await fs.writeFile(liveFile, liveAfter, 'utf-8')
|
|
const nextEntries = [
|
|
// A resumed provider can carry a cumulative snapshot entry forward. The
|
|
// large-file backup belongs to the old Write turn; only live-run.json is
|
|
// the before-state for this new Edit turn.
|
|
makeFileHistorySnapshotEntry(newUserId, {
|
|
'large-200.json': {
|
|
backupFileName: null,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:03:00.000Z',
|
|
},
|
|
'live-run.json': {
|
|
backupFileName: liveBackup,
|
|
version: 2,
|
|
backupTime: '2026-01-01T00:03:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('add rewind_probe and read it back', newUserId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:rewind-probe',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: liveFile,
|
|
old_string: ' "status": "ready"',
|
|
new_string: ' "rewind_probe": true,\n "status": "ready"',
|
|
},
|
|
}], newUserId),
|
|
makeToolResultUserEntry(
|
|
'Edit:rewind-probe',
|
|
`The file ${liveFile} has been updated successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Read:rewind-probe',
|
|
name: 'Read',
|
|
input: { file_path: liveFile },
|
|
}], newUserId),
|
|
makeToolResultUserEntry(
|
|
'Read:rewind-probe',
|
|
liveAfter,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Probe added and verified.', newUserId),
|
|
]
|
|
await fs.appendFile(
|
|
transcriptPath,
|
|
`${nextEntries.map((entry) => JSON.stringify(entry)).join('\n')}\n`,
|
|
'utf-8',
|
|
)
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(listRes.status).toBe(200)
|
|
const listBody = await listRes.json() as {
|
|
checkpoints: Array<{
|
|
target: { targetUserMessageId: string }
|
|
code: { filesChanged: string[] }
|
|
restoreAvailable: boolean
|
|
}>
|
|
}
|
|
const newTurnCheckpoint = listBody.checkpoints.find(
|
|
(checkpoint) => checkpoint.target.targetUserMessageId === newUserId,
|
|
)
|
|
expect(newTurnCheckpoint).toMatchObject({
|
|
code: { filesChanged: [liveFile] },
|
|
restoreAvailable: true,
|
|
})
|
|
|
|
const fileRewind = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
targetUserMessageId: newUserId,
|
|
expectedContent: 'add rewind_probe and read it back',
|
|
mode: 'both',
|
|
}),
|
|
})
|
|
expect(fileRewind.status).toBe(200)
|
|
expect(await fs.readFile(liveFile, 'utf-8')).toBe(liveBefore)
|
|
expect(await fs.readFile(largeFile, 'utf-8')).toBe(largeContent)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should reuse the loaded transcript cwd for every turn', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000021'
|
|
const projectDir = '-tmp-long-turn-checkpoint-session'
|
|
const workDir = path.join(tmpDir, 'long-turn-checkpoint-session')
|
|
const turnCount = 200
|
|
const entries: Record<string, unknown>[] = [makeSessionMetaEntry(workDir)]
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
for (let index = 0; index < turnCount; index += 1) {
|
|
const userId = crypto.randomUUID()
|
|
const toolUseId = `Write:long-turn-${index}`
|
|
entries.push(
|
|
{
|
|
...makeUserEntry(`write turn ${index}`, userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantToolUseEntry([{
|
|
id: toolUseId,
|
|
name: 'Write',
|
|
input: {
|
|
file_path: path.join(workDir, `turn-${index}.ts`),
|
|
content: `export const turn = ${index}\n`,
|
|
},
|
|
}], userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeToolResultUserEntry(
|
|
toolUseId,
|
|
'Written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
cwd: workDir,
|
|
},
|
|
)
|
|
}
|
|
await writeSessionFile(projectDir, sessionId, entries)
|
|
|
|
const messagesSpy = spyOn(sessionService, 'getSessionMessagesWithEvidence')
|
|
const messageCwdSpy = spyOn(sessionService, 'getSessionMessageCwd')
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as { checkpoints: unknown[] }
|
|
|
|
expect(body.checkpoints).toHaveLength(turnCount)
|
|
expect(messagesSpy).toHaveBeenCalledTimes(1)
|
|
expect(messageCwdSpy).not.toHaveBeenCalled()
|
|
} finally {
|
|
messagesSpy.mockRestore()
|
|
messageCwdSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should keep an available empty preview for an unchanged snapshot-backed turn', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000005'
|
|
const workDir = path.join(tmpDir, 'unchanged-snapshot-session')
|
|
const targetFile = path.join(workDir, 'src', 'unchanged.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'unchanged-snapshot@v1'
|
|
const content = 'export const unchanged = true\n'
|
|
|
|
await fs.mkdir(path.dirname(targetFile), { recursive: true })
|
|
await fs.writeFile(targetFile, content, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, backupName, content)
|
|
await writeSessionFile('-tmp-unchanged-snapshot-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src/unchanged.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('inspect the project', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantEntry('No files needed changes.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
target: { targetUserMessageId: string }
|
|
code: {
|
|
available: boolean
|
|
filesChanged: string[]
|
|
insertions: number
|
|
deletions: number
|
|
}
|
|
}>
|
|
}
|
|
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
target: { targetUserMessageId: userId },
|
|
code: {
|
|
available: true,
|
|
filesChanged: [],
|
|
insertions: 0,
|
|
deletions: 0,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should retain transcript changes when the snapshot diff is empty', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000006'
|
|
const workDir = path.join(tmpDir, 'empty-snapshot-transcript-session')
|
|
const unchangedFile = path.join(workDir, 'src', 'unchanged.ts')
|
|
const transcriptFile = path.join(workDir, 'test123.md')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'empty-snapshot-transcript@v1'
|
|
const content = 'export const unchanged = true\n'
|
|
|
|
await fs.mkdir(path.dirname(unchangedFile), { recursive: true })
|
|
await fs.writeFile(unchangedFile, content, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, backupName, content)
|
|
await writeSessionFile('-tmp-empty-snapshot-transcript-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src/unchanged.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('write a short note', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Write:empty-snapshot-fallback',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: transcriptFile,
|
|
content: '# Notes\n',
|
|
},
|
|
}], userId),
|
|
makeToolResultUserEntry(
|
|
'Write:empty-snapshot-fallback',
|
|
`The file ${transcriptFile} has been written successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Note written.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
code: {
|
|
available: boolean
|
|
filesChanged: string[]
|
|
insertions: number
|
|
deletions: number
|
|
}
|
|
}>
|
|
}
|
|
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]!.code).toEqual({
|
|
available: true,
|
|
filesChanged: [transcriptFile],
|
|
insertions: 1,
|
|
deletions: 0,
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should merge successful transcript edits missing from a partial snapshot', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000007'
|
|
const workDir = path.join(tmpDir, 'partial-snapshot-transcript-session')
|
|
const capturedFile = path.join(workDir, 'src', 'captured.ts')
|
|
const missedFile = path.join(workDir, 'src', 'missed.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'partial-snapshot-captured@v1'
|
|
const capturedBefore = "export const captured = 'before'\n"
|
|
const capturedAfter = "export const captured = 'after'\n"
|
|
const missedBefore = "export const missed = 'before'\n"
|
|
const missedAfter = "export const missed = 'after'\n"
|
|
|
|
await fs.mkdir(path.dirname(capturedFile), { recursive: true })
|
|
await fs.writeFile(capturedFile, capturedAfter, 'utf-8')
|
|
await fs.writeFile(missedFile, missedAfter, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, backupName, capturedBefore)
|
|
await writeSessionFile('-tmp-partial-snapshot-transcript-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src/captured.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('update both files', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Edit:captured-by-snapshot',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: capturedFile,
|
|
old_string: capturedBefore.trim(),
|
|
new_string: capturedAfter.trim(),
|
|
},
|
|
},
|
|
{
|
|
id: 'Edit:missing-from-snapshot',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: missedFile,
|
|
old_string: missedBefore.trim(),
|
|
new_string: missedAfter.trim(),
|
|
},
|
|
},
|
|
], userId),
|
|
makeToolResultUserEntry(
|
|
'Edit:captured-by-snapshot',
|
|
`The file ${capturedFile} has been updated successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeToolResultUserEntry(
|
|
'Edit:missing-from-snapshot',
|
|
`The file ${missedFile} has been updated successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Both files updated.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
code: {
|
|
available: boolean
|
|
filesChanged: string[]
|
|
insertions: number
|
|
deletions: number
|
|
}
|
|
}>
|
|
}
|
|
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]!.code).toEqual({
|
|
available: true,
|
|
filesChanged: [capturedFile, missedFile],
|
|
insertions: 2,
|
|
deletions: 2,
|
|
})
|
|
|
|
const dryRunRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, dryRun: true }),
|
|
})
|
|
expect(dryRunRes.status).toBe(200)
|
|
expect(await dryRunRes.json()).toMatchObject({
|
|
code: {
|
|
filesChanged: [capturedFile, missedFile],
|
|
insertions: 2,
|
|
deletions: 2,
|
|
},
|
|
restoreAvailable: false,
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should include a successful external edit missing from a partial snapshot', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000008'
|
|
const workDir = path.join(tmpDir, 'partial-snapshot-external-session')
|
|
const outsideDir = path.join(tmpDir, 'partial-snapshot-external-target')
|
|
const capturedFile = path.join(workDir, 'captured.ts')
|
|
const externalFile = path.join(outsideDir, 'external.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'partial-snapshot-external@v1'
|
|
const capturedBefore = "export const captured = 'before'\n"
|
|
const capturedAfter = "export const captured = 'after'\n"
|
|
const externalBefore = "export const external = 'before'\n"
|
|
const externalAfter = "export const external = 'after'\n"
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.mkdir(outsideDir, { recursive: true })
|
|
await fs.writeFile(capturedFile, capturedAfter, 'utf-8')
|
|
await fs.writeFile(externalFile, externalAfter, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, backupName, capturedBefore)
|
|
await writeSessionFile('-tmp-partial-snapshot-external-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'captured.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('update an internal and external file', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Edit:captured-external-case',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: capturedFile,
|
|
old_string: capturedBefore.trim(),
|
|
new_string: capturedAfter.trim(),
|
|
},
|
|
},
|
|
{
|
|
id: 'Edit:external-missing-from-snapshot',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: externalFile,
|
|
old_string: externalBefore.trim(),
|
|
new_string: externalAfter.trim(),
|
|
},
|
|
},
|
|
], userId),
|
|
makeToolResultUserEntry(
|
|
'Edit:captured-external-case',
|
|
`The file ${capturedFile} has been updated successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeToolResultUserEntry(
|
|
'Edit:external-missing-from-snapshot',
|
|
`The file ${externalFile} has been updated successfully.`,
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Both files updated.', userId),
|
|
])
|
|
|
|
const checkpointsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(checkpointsRes.status).toBe(200)
|
|
const checkpointsBody = await checkpointsRes.json() as {
|
|
checkpoints: Array<{
|
|
code: { filesChanged: string[]; insertions: number; deletions: number }
|
|
restoreAvailable?: boolean
|
|
}>
|
|
}
|
|
expect(checkpointsBody.checkpoints).toHaveLength(1)
|
|
expect(checkpointsBody.checkpoints[0]).toMatchObject({
|
|
code: {
|
|
filesChanged: [capturedFile, externalFile],
|
|
insertions: 2,
|
|
deletions: 2,
|
|
},
|
|
restoreAvailable: false,
|
|
})
|
|
|
|
const dryRunRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, dryRun: true }),
|
|
})
|
|
expect(dryRunRes.status).toBe(200)
|
|
expect(await dryRunRes.json()).toMatchObject({
|
|
code: { filesChanged: [capturedFile, externalFile] },
|
|
restoreAvailable: false,
|
|
})
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(capturedFile, 'utf-8')).toBe(capturedAfter)
|
|
expect(await fs.readFile(externalFile, 'utf-8')).toBe(externalAfter)
|
|
expect((await service.getSessionMessages(sessionId)).map((message) => message.id))
|
|
.toContain(userId)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should not treat a next-turn backup as a restorable target snapshot', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000015'
|
|
const workDir = path.join(tmpDir, 'historical-partial-snapshot-session')
|
|
const capturedFile = path.join(workDir, 'captured.ts')
|
|
const missedFile = path.join(workDir, 'missed.ts')
|
|
const firstUserId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const capturedBeforeBackup = 'historical-captured-before@v1'
|
|
const capturedAfterBackup = 'historical-captured-after@v2'
|
|
const missedAfterBackup = 'historical-missed-after@v1'
|
|
const before = "export const value = 'before'\n"
|
|
const after = "export const value = 'after'\n"
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(capturedFile, after, 'utf-8')
|
|
await fs.writeFile(missedFile, after, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, capturedBeforeBackup, before)
|
|
await writeFileHistoryBackup(sessionId, capturedAfterBackup, after)
|
|
await writeFileHistoryBackup(sessionId, missedAfterBackup, after)
|
|
await writeSessionFile('-tmp-historical-partial-snapshot-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'captured.ts': {
|
|
backupFileName: capturedBeforeBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('update both files', firstUserId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Edit:historical-captured',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: capturedFile,
|
|
old_string: before.trim(),
|
|
new_string: after.trim(),
|
|
},
|
|
},
|
|
{
|
|
id: 'Edit:historical-missed',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: missedFile,
|
|
old_string: before.trim(),
|
|
new_string: after.trim(),
|
|
},
|
|
},
|
|
], firstUserId),
|
|
makeToolResultUserEntry(
|
|
'Edit:historical-captured',
|
|
'Updated successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeToolResultUserEntry(
|
|
'Edit:historical-missed',
|
|
'Updated successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('First turn complete.', firstUserId),
|
|
makeFileHistorySnapshotEntry(secondUserId, {
|
|
'captured.ts': {
|
|
backupFileName: capturedAfterBackup,
|
|
version: 2,
|
|
backupTime: '2026-01-01T00:01:00.000Z',
|
|
},
|
|
'missed.ts': {
|
|
backupFileName: missedAfterBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:01:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('inspect the result', secondUserId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantEntry('Second turn complete.', secondUserId),
|
|
])
|
|
|
|
const checkpointsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(checkpointsRes.status).toBe(200)
|
|
const checkpointsBody = await checkpointsRes.json() as {
|
|
checkpoints: Array<{
|
|
target: { targetUserMessageId: string }
|
|
code: { filesChanged: string[] }
|
|
restoreAvailable?: boolean
|
|
}>
|
|
}
|
|
const firstCheckpoint = checkpointsBody.checkpoints.find(
|
|
(checkpoint) => checkpoint.target.targetUserMessageId === firstUserId,
|
|
)
|
|
expect(firstCheckpoint).toMatchObject({
|
|
code: { filesChanged: [capturedFile, missedFile] },
|
|
restoreAvailable: false,
|
|
})
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: firstUserId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(capturedFile, 'utf-8')).toBe(after)
|
|
expect(await fs.readFile(missedFile, 'utf-8')).toBe(after)
|
|
expect((await service.getSessionMessages(sessionId)).map((message) => message.id))
|
|
.toContain(firstUserId)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should not restore a snapshot-covered zero-net file from transcript edits', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000009'
|
|
const workDir = path.join(tmpDir, 'snapshot-covered-zero-net-session')
|
|
const changedFile = path.join(workDir, 'changed.ts')
|
|
const restoredFile = path.join(workDir, 'restored.ts')
|
|
const userId = crypto.randomUUID()
|
|
const chatUserId = crypto.randomUUID()
|
|
const changedBackup = 'snapshot-covered-changed@v1'
|
|
const restoredBackup = 'snapshot-covered-restored@v1'
|
|
const changedBefore = "export const changed = 'before'\n"
|
|
const changedAfter = "export const changed = 'after'\n"
|
|
const restoredContent = "export const restored = 'original'\n"
|
|
const restoredIntermediate = "export const restored = 'temporary'\n"
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(changedFile, changedAfter, 'utf-8')
|
|
await fs.writeFile(restoredFile, restoredContent, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, changedBackup, changedBefore)
|
|
await writeFileHistoryBackup(sessionId, restoredBackup, restoredContent)
|
|
await writeSessionFile('-tmp-snapshot-covered-zero-net-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'changed.ts': {
|
|
backupFileName: changedBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'restored.ts': {
|
|
backupFileName: restoredBackup,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('change one file and restore another', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Edit:changed-net',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: changedFile,
|
|
old_string: changedBefore.trim(),
|
|
new_string: changedAfter.trim(),
|
|
},
|
|
},
|
|
{
|
|
id: 'Edit:restored-forward',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: restoredFile,
|
|
old_string: restoredContent.trim(),
|
|
new_string: restoredIntermediate.trim(),
|
|
},
|
|
},
|
|
{
|
|
id: 'Edit:restored-back',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: restoredFile,
|
|
old_string: restoredIntermediate.trim(),
|
|
new_string: restoredContent.trim(),
|
|
},
|
|
},
|
|
], userId),
|
|
makeToolResultUserEntry('Edit:changed-net', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeToolResultUserEntry('Edit:restored-forward', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeToolResultUserEntry('Edit:restored-back', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Finished.', userId),
|
|
makeFileHistorySnapshotEntry(chatUserId, {
|
|
'changed.ts': { backupFileName: changedBackup, version: 1, backupTime: '2026-01-01T00:01:00.000Z' },
|
|
'restored.ts': { backupFileName: restoredBackup, version: 1, backupTime: '2026-01-01T00:01:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('say hello without tools', chatUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Hello.', chatUserId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
code: { filesChanged: string[]; insertions: number; deletions: number }
|
|
restoreAvailable?: boolean
|
|
}>
|
|
}
|
|
expect(body.checkpoints).toHaveLength(2)
|
|
expect(body.checkpoints[1]).toMatchObject({ code: { filesChanged: [], insertions: 0, deletions: 0 } })
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: {
|
|
filesChanged: [changedFile],
|
|
insertions: 1,
|
|
deletions: 1,
|
|
},
|
|
restoreAvailable: true,
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should ignore transcript edits without a completed tool result', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000010'
|
|
const workDir = path.join(tmpDir, 'partial-snapshot-incomplete-tool-session')
|
|
const capturedFile = path.join(workDir, 'captured.ts')
|
|
const interruptedFile = path.join(workDir, 'interrupted.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'partial-snapshot-incomplete-tool@v1'
|
|
const before = "export const value = 'before'\n"
|
|
const after = "export const value = 'after'\n"
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(capturedFile, after, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, backupName, before)
|
|
await writeSessionFile('-tmp-partial-snapshot-incomplete-tool-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'captured.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('update two files before interruption', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Edit:captured-complete',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: capturedFile,
|
|
old_string: before.trim(),
|
|
new_string: after.trim(),
|
|
},
|
|
},
|
|
{
|
|
id: 'Edit:interrupted-without-result',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: interruptedFile,
|
|
old_string: before.trim(),
|
|
new_string: after.trim(),
|
|
},
|
|
},
|
|
], userId),
|
|
makeToolResultUserEntry(
|
|
'Edit:captured-complete',
|
|
'Updated successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('The second edit was interrupted.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{ code: { filesChanged: string[] }; restoreAvailable?: boolean }>
|
|
}
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: { filesChanged: [capturedFile] },
|
|
restoreAvailable: false,
|
|
})
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(capturedFile, 'utf-8')).toBe(after)
|
|
expect((await service.getSessionMessages(sessionId)).map((message) => message.id))
|
|
.toContain(userId)
|
|
})
|
|
|
|
it('POST /api/sessions/:id/rewind should recheck late tool output after stopping the runtime', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000017'
|
|
const workDir = path.join(tmpDir, 'rewind-stop-recheck-session')
|
|
const capturedFile = path.join(workDir, 'captured.ts')
|
|
const lateFile = path.join(workDir, 'late.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'rewind-stop-recheck@v1'
|
|
const before = "export const value = 'before'\n"
|
|
const after = "export const value = 'after'\n"
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(capturedFile, after, 'utf-8')
|
|
await writeFileHistoryBackup(sessionId, backupName, before)
|
|
const transcriptPath = await writeSessionFile('-tmp-rewind-stop-recheck-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'captured.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('update files', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantEntry('Initial response complete.', userId),
|
|
])
|
|
|
|
const originalStopSessionAndWait = conversationService.stopSessionAndWait
|
|
conversationService.stopSessionAndWait = async (targetSessionId: string) => {
|
|
if (targetSessionId !== sessionId) return
|
|
await fs.writeFile(lateFile, after, 'utf-8')
|
|
const lateEntries = [
|
|
{
|
|
...makeAssistantToolUseEntry([{
|
|
id: 'Write:late-after-stop-started',
|
|
name: 'Write',
|
|
input: { file_path: lateFile, content: after },
|
|
}], userId),
|
|
cwd: workDir,
|
|
},
|
|
{
|
|
...makeToolResultUserEntry(
|
|
'Write:late-after-stop-started',
|
|
'Written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
cwd: workDir,
|
|
},
|
|
]
|
|
await fs.appendFile(
|
|
transcriptPath,
|
|
lateEntries.map((entry) => JSON.stringify(entry)).join('\n') + '\n',
|
|
'utf-8',
|
|
)
|
|
}
|
|
|
|
try {
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(capturedFile, 'utf-8')).toBe(after)
|
|
expect(await fs.readFile(lateFile, 'utf-8')).toBe(after)
|
|
expect((await service.getSessionMessages(sessionId)).map((message) => message.id))
|
|
.toContain(userId)
|
|
} finally {
|
|
conversationService.stopSessionAndWait = originalStopSessionAndWait
|
|
}
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should deduplicate snapshot and transcript aliases by canonical path', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000011'
|
|
const workDir = path.join(tmpDir, 'snapshot-transcript-alias-session')
|
|
const aliasDir = path.join(tmpDir, 'snapshot-transcript-alias-link')
|
|
const targetFile = path.join(workDir, 'src', 'aliased.ts')
|
|
const aliasFile = path.join(aliasDir, 'src', 'aliased.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'snapshot-transcript-alias@v1'
|
|
const before = "export const alias = 'before'\n"
|
|
const after = "export const alias = 'after'\n"
|
|
|
|
await fs.mkdir(path.dirname(targetFile), { recursive: true })
|
|
await fs.writeFile(targetFile, after, 'utf-8')
|
|
await fs.symlink(workDir, aliasDir, 'dir')
|
|
await writeFileHistoryBackup(sessionId, backupName, before)
|
|
await writeSessionFile('-tmp-snapshot-transcript-alias-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src/aliased.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
'src/../src/aliased.ts': {
|
|
backupFileName: backupName,
|
|
version: 1,
|
|
backupTime: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{
|
|
...makeUserEntry('edit a file through an alias', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:canonical-alias',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: aliasFile,
|
|
old_string: before.trim(),
|
|
new_string: after.trim(),
|
|
},
|
|
}], userId),
|
|
makeToolResultUserEntry(
|
|
'Edit:canonical-alias',
|
|
'Updated successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Finished.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
code: { filesChanged: string[]; insertions: number; deletions: number }
|
|
restoreAvailable?: boolean
|
|
}>
|
|
}
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: {
|
|
filesChanged: [targetFile],
|
|
insertions: 1,
|
|
deletions: 1,
|
|
},
|
|
restoreAvailable: true,
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should resolve relative tool paths from the tool message cwd', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000012'
|
|
const workDir = path.join(tmpDir, 'transcript-tool-cwd-session')
|
|
const toolCwd = path.join(workDir, 'MyDemo')
|
|
const targetFile = path.join(toolCwd, 'src', 'App.ts')
|
|
const userId = crypto.randomUUID()
|
|
|
|
await fs.mkdir(path.dirname(targetFile), { recursive: true })
|
|
await fs.writeFile(targetFile, 'export const app = true\n', 'utf-8')
|
|
await writeSessionFile('-tmp-transcript-tool-cwd-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('create a nested app file', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantToolUseEntry([{
|
|
id: 'Write:tool-message-cwd',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: 'src/App.ts',
|
|
content: 'export const app = true\n',
|
|
},
|
|
}], userId),
|
|
cwd: toolCwd,
|
|
},
|
|
makeToolResultUserEntry(
|
|
'Write:tool-message-cwd',
|
|
'Written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Finished.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
code: { filesChanged: string[] }
|
|
restoreAvailable?: boolean
|
|
}>
|
|
}
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: { filesChanged: [targetFile] },
|
|
restoreAvailable: false,
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should collect every supported successful file tool', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000014'
|
|
const workDir = path.join(tmpDir, 'transcript-supported-tools-session')
|
|
const writeFile = path.join(workDir, 'write.txt')
|
|
const multiEditFile = path.join(workDir, 'multi.ts')
|
|
const notebookFile = path.join(workDir, 'notes.ipynb')
|
|
const patchSourceFile = path.join(workDir, 'old-name.ts')
|
|
const patchTargetFile = path.join(workDir, 'new-name.ts')
|
|
const outsideFile = path.join(tmpDir, 'transcript-supported-tools-outside.txt')
|
|
const userId = crypto.randomUUID()
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(writeFile, 'written\n', 'utf-8')
|
|
await fs.writeFile(multiEditFile, 'const value = 2\n', 'utf-8')
|
|
await fs.writeFile(notebookFile, '{"cells": []}\n', 'utf-8')
|
|
await fs.writeFile(patchTargetFile, 'export const renamed = true\n', 'utf-8')
|
|
await fs.writeFile(outsideFile, 'outside\n', 'utf-8')
|
|
await writeSessionFile('-tmp-transcript-supported-tools-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('exercise every file editing tool', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
...makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Write:supported',
|
|
name: 'Write',
|
|
input: { file_path: writeFile, content: 'written\n' },
|
|
},
|
|
{
|
|
id: 'MultiEdit:supported',
|
|
name: 'MultiEdit',
|
|
input: {
|
|
file_path: multiEditFile,
|
|
edits: [{ old_string: 'const value = 1', new_string: 'const value = 2' }],
|
|
},
|
|
},
|
|
{
|
|
id: 'NotebookEdit:supported',
|
|
name: 'NotebookEdit',
|
|
input: { notebook_path: notebookFile, new_source: 'print("done")' },
|
|
},
|
|
{
|
|
id: 'ApplyPatch:supported',
|
|
name: 'apply_patch',
|
|
input: {
|
|
patch: [
|
|
'*** Begin Patch',
|
|
'*** Update File: old-name.ts',
|
|
'*** Move to: new-name.ts',
|
|
'*** End Patch',
|
|
].join('\n'),
|
|
},
|
|
},
|
|
{
|
|
id: 'Write:relative-outside',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: path.relative(workDir, outsideFile),
|
|
content: 'outside\n',
|
|
},
|
|
},
|
|
], userId),
|
|
cwd: workDir,
|
|
},
|
|
makeToolResultUserEntry('Write:supported', 'Written successfully.', undefined, undefined, sessionId),
|
|
makeToolResultUserEntry('MultiEdit:supported', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeToolResultUserEntry('NotebookEdit:supported', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeToolResultUserEntry('ApplyPatch:supported', 'Patched successfully.', undefined, undefined, sessionId),
|
|
makeToolResultUserEntry('Write:relative-outside', 'Written successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Finished.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{ code: { filesChanged: string[] }; restoreAvailable?: boolean }>
|
|
}
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]!.code.filesChanged.sort()).toEqual([
|
|
writeFile,
|
|
multiEditFile,
|
|
notebookFile,
|
|
patchSourceFile,
|
|
patchTargetFile,
|
|
outsideFile,
|
|
].sort())
|
|
expect(body.checkpoints[0]!.restoreAvailable).toBe(false)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints/diff should return target-bound checkpoint diffs', async () => {
|
|
const fixture = await createThreeTurnCheckpointFixture(
|
|
'99999999-bbbb-cccc-dddd-ffffffffffff',
|
|
)
|
|
|
|
const secondTurnRes = await fetch(
|
|
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.secondUserId}&path=src/step.js`,
|
|
)
|
|
expect(secondTurnRes.status).toBe(200)
|
|
const secondTurnBody = await secondTurnRes.json() as {
|
|
state: string
|
|
path: string
|
|
diff?: string
|
|
target: { targetUserMessageId: string }
|
|
}
|
|
expect(secondTurnBody.target.targetUserMessageId).toBe(fixture.secondUserId)
|
|
expect(secondTurnBody.state).toBe('ok')
|
|
expect(secondTurnBody.path).toBe('src/step.js')
|
|
expect(secondTurnBody.diff).toContain("export const STEP = 'v2'")
|
|
expect(secondTurnBody.diff).toContain("export const STEP = 'v1'")
|
|
expect(secondTurnBody.diff).not.toContain("export const STEP = 'v3'")
|
|
|
|
const thirdTurnRes = await fetch(
|
|
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`,
|
|
)
|
|
expect(thirdTurnRes.status).toBe(200)
|
|
const thirdTurnBody = await thirdTurnRes.json() as {
|
|
state: string
|
|
diff?: string
|
|
target: { targetUserMessageId: string }
|
|
}
|
|
expect(thirdTurnBody.target.targetUserMessageId).toBe(fixture.thirdUserId)
|
|
expect(thirdTurnBody.state).toBe('ok')
|
|
expect(thirdTurnBody.diff).toContain("export const STEP = 'v3'")
|
|
expect(thirdTurnBody.diff).toContain("export const STEP = 'v2'")
|
|
expect(thirdTurnBody.diff).not.toContain("export const STEP = 'v1'")
|
|
|
|
const createdFileRes = await fetch(
|
|
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=notes/generated.txt`,
|
|
)
|
|
expect(createdFileRes.status).toBe(200)
|
|
const createdFileBody = await createdFileRes.json() as {
|
|
state: string
|
|
diff?: string
|
|
}
|
|
expect(createdFileBody.state).toBe('ok')
|
|
expect(createdFileBody.diff).toContain('generated third turn')
|
|
expect(createdFileBody.diff).toContain('/dev/null')
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should fall back to transcript tool changes when file snapshots are missing', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000001'
|
|
const workDir = path.join(tmpDir, 'transcript-only-session')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true })
|
|
|
|
await writeSessionFile('-tmp-transcript-only-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('build a todo app', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Write:1',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: path.join(workDir, 'todo-app', 'src', 'App.tsx'),
|
|
content: 'export function App() {\n return <main>Todo</main>\n}\n',
|
|
},
|
|
},
|
|
{
|
|
id: 'Write:2',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: 'todo-app/vite.config.ts',
|
|
content: 'import { defineConfig } from "vite"\nexport default defineConfig({})\n',
|
|
},
|
|
},
|
|
], userId),
|
|
makeToolResultUserEntry(
|
|
'Write:1',
|
|
'The file App.tsx has been written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeToolResultUserEntry(
|
|
'Write:2',
|
|
'The file vite.config.ts has been written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Todo app created', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
target: { targetUserMessageId: string }
|
|
code: {
|
|
available: boolean
|
|
filesChanged: string[]
|
|
insertions: number
|
|
deletions: number
|
|
}
|
|
workDir: string
|
|
}>
|
|
}
|
|
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(userId)
|
|
expect(body.checkpoints[0]!.workDir).toBe(workDir)
|
|
expect(body.checkpoints[0]!.code.available).toBe(true)
|
|
expect(body.checkpoints[0]!.code.filesChanged.sort()).toEqual([
|
|
path.join(workDir, 'todo-app', 'src', 'App.tsx'),
|
|
path.join(workDir, 'todo-app', 'vite.config.ts'),
|
|
].sort())
|
|
expect(body.checkpoints[0]!.code.insertions).toBe(5)
|
|
expect(body.checkpoints[0]!.code.deletions).toBe(0)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should ignore rejected transcript tool changes', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000004'
|
|
const workDir = path.join(tmpDir, 'transcript-rejected-session')
|
|
const userId = crypto.randomUUID()
|
|
const toolUseId = 'Write:rejected'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
|
|
await writeSessionFile('-tmp-transcript-rejected-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('write a denied file', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([{
|
|
id: toolUseId,
|
|
name: 'Write',
|
|
input: {
|
|
file_path: path.join(workDir, 'permission-denial-test.txt'),
|
|
content: 'must not be written\n',
|
|
},
|
|
}], userId),
|
|
{
|
|
...makeToolResultUserEntry(
|
|
toolUseId,
|
|
'The user rejected this tool use.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
message: {
|
|
role: 'user',
|
|
content: [{
|
|
type: 'tool_result',
|
|
tool_use_id: toolUseId,
|
|
content: 'The user rejected this tool use.',
|
|
is_error: true,
|
|
}],
|
|
},
|
|
cwd: workDir,
|
|
},
|
|
makeAssistantEntry('The requested write was not completed.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
target: { targetUserMessageId: string }
|
|
code: { available: boolean; filesChanged: string[] }
|
|
}>
|
|
}
|
|
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
target: { targetUserMessageId: userId },
|
|
code: { available: false, filesChanged: [] },
|
|
})
|
|
await expect(fs.stat(path.join(workDir, 'permission-denial-test.txt')))
|
|
.rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints/diff should return transcript tool diffs when file snapshots are missing', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000002'
|
|
const workDir = path.join(tmpDir, 'transcript-only-diff-session')
|
|
const userId = crypto.randomUUID()
|
|
|
|
await writeSessionFile('-tmp-transcript-only-diff-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('edit config', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Edit:1',
|
|
name: 'Edit',
|
|
input: {
|
|
file_path: path.join(workDir, 'todo-app', 'vite.config.ts'),
|
|
old_string: 'plugins: [react()]',
|
|
new_string: 'plugins: [react(), tailwindcss()]',
|
|
},
|
|
},
|
|
], userId),
|
|
makeToolResultUserEntry(
|
|
'Edit:1',
|
|
'The file vite.config.ts has been updated successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Config updated', userId),
|
|
])
|
|
|
|
const res = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints/diff?targetUserMessageId=${userId}&path=${encodeURIComponent('todo-app/vite.config.ts')}`,
|
|
)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
state: string
|
|
path: string
|
|
diff?: string
|
|
target: { targetUserMessageId: string }
|
|
}
|
|
|
|
expect(body.target.targetUserMessageId).toBe(userId)
|
|
expect(body.state).toBe('ok')
|
|
expect(body.path).toBe('todo-app/vite.config.ts')
|
|
expect(body.diff).toContain('diff --session a/todo-app/vite.config.ts b/todo-app/vite.config.ts')
|
|
expect(body.diff).toContain('-plugins: [react()]')
|
|
expect(body.diff).toContain('+plugins: [react(), tailwindcss()]')
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should include subagent transcript file changes for the parent turn', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000003'
|
|
const workDir = path.join(tmpDir, 'transcript-subagent-session')
|
|
const firstUserId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const agentMessageId = crypto.randomUUID()
|
|
await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true })
|
|
|
|
await writeSessionFile('-tmp-transcript-subagent-session', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('build a todo app', firstUserId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
parentUuid: firstUserId,
|
|
isSidechain: false,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'tool_use',
|
|
id: 'Agent:todo',
|
|
name: 'Agent',
|
|
input: { description: 'Create todo app files' },
|
|
}],
|
|
},
|
|
uuid: agentMessageId,
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
},
|
|
{
|
|
...makeUserEntry('now explain it', secondUserId),
|
|
parentUuid: agentMessageId,
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
{
|
|
parentUuid: agentMessageId,
|
|
isSidechain: true,
|
|
type: 'assistant',
|
|
message: {
|
|
model: 'claude-opus-4-7',
|
|
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'tool_use',
|
|
id: 'Write:child',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: path.join(workDir, 'todo-app', 'src', 'Board.tsx'),
|
|
content: 'export function Board() {\n return null\n}\n',
|
|
},
|
|
}],
|
|
},
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-01-01T00:03:00.000Z',
|
|
},
|
|
{
|
|
...makeToolResultUserEntry(
|
|
'Write:child',
|
|
'The file Board.tsx has been written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
parent_tool_use_id: 'Agent:todo',
|
|
isSidechain: true,
|
|
cwd: workDir,
|
|
},
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
target: { targetUserMessageId: string }
|
|
code: { filesChanged: string[]; insertions: number; deletions: number }
|
|
}>
|
|
}
|
|
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(firstUserId)
|
|
expect(body.checkpoints[0]!.code.filesChanged).toEqual([
|
|
path.join(workDir, 'todo-app', 'src', 'Board.tsx'),
|
|
])
|
|
expect(body.checkpoints[0]!.code.insertions).toBe(3)
|
|
expect(body.checkpoints[0]!.code.deletions).toBe(0)
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should include nested subagent changes with their own cwd', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000013'
|
|
const projectDir = '-tmp-transcript-nested-subagent-session'
|
|
const workDir = path.join(tmpDir, 'transcript-nested-subagent-session')
|
|
const nestedCwd = path.join(workDir, 'nested-project')
|
|
const targetFile = path.join(nestedCwd, 'src', 'Deep.ts')
|
|
const userId = crypto.randomUUID()
|
|
|
|
await fs.mkdir(path.dirname(targetFile), { recursive: true })
|
|
await fs.writeFile(targetFile, 'export const deep = true\n', 'utf-8')
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('delegate a nested project change', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Agent:outer',
|
|
name: 'Agent',
|
|
input: { description: 'Run an outer agent' },
|
|
}], userId),
|
|
makeToolResultUserEntry(
|
|
'Agent:outer',
|
|
'Outer agent completed.\nagentId: outer',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Delegation completed.', userId),
|
|
])
|
|
await writeSubagentTranscriptFile(projectDir, sessionId, 'outer', [
|
|
{
|
|
...makeAssistantToolUseEntry([{
|
|
id: 'Agent:nested',
|
|
name: 'Agent',
|
|
input: { description: 'Run a nested agent' },
|
|
}]),
|
|
cwd: workDir,
|
|
},
|
|
{
|
|
...makeToolResultUserEntry(
|
|
'Agent:nested',
|
|
'Nested agent completed.\nagentId: nested',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
cwd: workDir,
|
|
},
|
|
])
|
|
await writeSubagentTranscriptFile(projectDir, sessionId, 'nested', [
|
|
{
|
|
...makeAssistantToolUseEntry([{
|
|
id: 'Write:deep-child',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: 'src/Deep.ts',
|
|
content: 'export const deep = true\n',
|
|
},
|
|
}]),
|
|
cwd: nestedCwd,
|
|
},
|
|
{
|
|
...makeToolResultUserEntry(
|
|
'Write:deep-child',
|
|
'Written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
cwd: nestedCwd,
|
|
},
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{
|
|
code: { filesChanged: string[]; insertions: number; deletions: number }
|
|
restoreAvailable?: boolean
|
|
}>
|
|
}
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: {
|
|
filesChanged: [targetFile],
|
|
insertions: 1,
|
|
deletions: 0,
|
|
},
|
|
restoreAvailable: false,
|
|
})
|
|
})
|
|
|
|
it('GET /api/sessions/:id/turn-checkpoints should bound cyclic subagent transcript links', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000016'
|
|
const projectDir = '-tmp-transcript-cyclic-subagent-session'
|
|
const workDir = path.join(tmpDir, 'transcript-cyclic-subagent-session')
|
|
const targetFile = path.join(workDir, 'once.ts')
|
|
const userId = crypto.randomUUID()
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'export const once = true\n', 'utf-8')
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{
|
|
...makeUserEntry('run a self-referencing agent', userId),
|
|
cwd: workDir,
|
|
sessionId,
|
|
},
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Agent:cycle-root',
|
|
name: 'Agent',
|
|
input: { description: 'Run the cyclic agent fixture' },
|
|
}], userId),
|
|
makeToolResultUserEntry(
|
|
'Agent:cycle-root',
|
|
'Agent completed.\nagentId: cycle',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
makeAssistantEntry('Delegation complete.', userId),
|
|
])
|
|
await writeSubagentTranscriptFile(projectDir, sessionId, 'cycle', [
|
|
{
|
|
...makeAssistantToolUseEntry([
|
|
{
|
|
id: 'Write:cycle-once',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: targetFile,
|
|
content: 'export const once = true\n',
|
|
},
|
|
},
|
|
{
|
|
id: 'Agent:cycle-self',
|
|
name: 'Agent',
|
|
input: { description: 'Reference the same transcript again' },
|
|
},
|
|
]),
|
|
cwd: workDir,
|
|
},
|
|
{
|
|
...makeToolResultUserEntry(
|
|
'Write:cycle-once',
|
|
'Written successfully.',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
cwd: workDir,
|
|
},
|
|
{
|
|
...makeToolResultUserEntry(
|
|
'Agent:cycle-self',
|
|
'Agent completed.\nagentId: cycle',
|
|
undefined,
|
|
undefined,
|
|
sessionId,
|
|
),
|
|
cwd: workDir,
|
|
},
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{ code: { filesChanged: string[] }; restoreAvailable?: boolean }>
|
|
}
|
|
expect(body.checkpoints).toHaveLength(1)
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: { filesChanged: [targetFile] },
|
|
restoreAvailable: false,
|
|
})
|
|
})
|
|
|
|
it('should fall back to transcript evidence when the next partial snapshot drops a tracked path', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000017'
|
|
const projectDir = '-tmp-next-partial-drops-path'
|
|
const workDir = path.join(tmpDir, 'next-partial-drops-path')
|
|
const keptFile = path.join(workDir, 'kept.ts')
|
|
const droppedFile = path.join(workDir, 'dropped.ts')
|
|
const firstUserId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const keptBackup = 'next-partial-kept@v1'
|
|
const droppedBackup = 'next-partial-dropped@v1'
|
|
const before = "export const value = 'before'\n"
|
|
const after = "export const value = 'after'\n"
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(keptFile, before)
|
|
await fs.writeFile(droppedFile, after)
|
|
await writeFileHistoryBackup(sessionId, keptBackup, before)
|
|
await writeFileHistoryBackup(sessionId, droppedBackup, before)
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'kept.ts': { backupFileName: keptBackup, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
'dropped.ts': { backupFileName: droppedBackup, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit dropped', firstUserId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:dropped-from-next',
|
|
name: 'Edit',
|
|
input: { file_path: droppedFile, old_string: before.trim(), new_string: after.trim() },
|
|
}], firstUserId),
|
|
makeToolResultUserEntry('Edit:dropped-from-next', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', firstUserId),
|
|
makeFileHistorySnapshotEntry(secondUserId, {
|
|
'kept.ts': { backupFileName: keptBackup, version: 1, backupTime: '2026-01-01T00:01:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('inspect', secondUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Inspected.', secondUserId),
|
|
])
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const listBody = await listRes.json() as {
|
|
checkpoints: Array<{ target: { targetUserMessageId: string }; code: { filesChanged: string[] }; restoreAvailable?: boolean }>
|
|
}
|
|
expect(listBody.checkpoints.find((item) =>
|
|
item.target.targetUserMessageId === firstUserId
|
|
)).toMatchObject({
|
|
code: { filesChanged: [droppedFile] },
|
|
restoreAvailable: true,
|
|
})
|
|
|
|
const diffRes = await fetch(
|
|
`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints/diff?targetUserMessageId=${firstUserId}&path=dropped.ts`,
|
|
)
|
|
expect(await diffRes.json()).toMatchObject({ state: 'ok', path: 'dropped.ts' })
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: firstUserId }),
|
|
})
|
|
expect(rewindRes.status).toBe(200)
|
|
expect(await fs.readFile(droppedFile, 'utf-8')).toBe(before)
|
|
})
|
|
|
|
it('should restore snapshot-covered files while reporting a writing shell command as unverified', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000018'
|
|
const workDir = path.join(tmpDir, 'unknown-write-tool')
|
|
const capturedFile = path.join(workDir, 'captured.txt')
|
|
const shellFile = path.join(workDir, 'shell.txt')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'unknown-tool-captured@v1'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(capturedFile, 'after\n')
|
|
await fs.writeFile(shellFile, 'written by shell\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
await writeSessionFile('-tmp-unknown-write-tool', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'captured.txt': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('write with shell', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Bash:write-file',
|
|
name: 'Bash',
|
|
input: { command: `printf written > ${shellFile}` },
|
|
}], userId),
|
|
makeToolResultUserEntry('Bash:write-file', 'Done.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await listRes.json() as {
|
|
checkpoints: Array<{ restoreAvailable?: boolean; unverifiedChangeSources?: string[] }>
|
|
}
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(true)
|
|
expect(body.checkpoints[0]?.unverifiedChangeSources).toEqual(['Bash'])
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(200)
|
|
expect(await rewindRes.json()).toMatchObject({ unverifiedChangeSources: ['Bash'] })
|
|
// The snapshot-covered file is undone; the shell-written file is untouched,
|
|
// which is exactly what the unverified source is warning about.
|
|
expect(await fs.readFile(capturedFile, 'utf-8')).toBe('before\n')
|
|
expect(await fs.readFile(shellFile, 'utf-8')).toBe('written by shell\n')
|
|
})
|
|
|
|
it('should keep a Bash-only first turn rewindable without touching the shell-written file', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000033'
|
|
const workDir = path.join(tmpDir, 'bash-only-empty-checkpoint')
|
|
const shellFile = path.join(workDir, 'shell-only.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(shellFile, 'written by shell\n')
|
|
await writeSessionFile('-tmp-bash-only-empty-checkpoint', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
// Real prompt submission records a snapshot even when no structured file
|
|
// tool has anything to track. That empty checkpoint must still carry the
|
|
// Bash coverage warning through to the desktop.
|
|
makeFileHistorySnapshotEntry(userId, {}),
|
|
{ ...makeUserEntry('write only with Bash', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Bash:write-only-file',
|
|
name: 'Bash',
|
|
input: { command: `printf 'written by shell\\n' > ${shellFile}` },
|
|
}], userId),
|
|
makeToolResultUserEntry('Bash:write-only-file', '', undefined, undefined, sessionId),
|
|
makeAssistantEntry('BASH_ONLY_DONE', userId),
|
|
])
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
expect(listRes.status).toBe(200)
|
|
const listBody = await listRes.json() as {
|
|
checkpoints: Array<{
|
|
target: { targetUserMessageId: string }
|
|
code: { available: boolean; filesChanged: string[] }
|
|
restoreAvailable?: boolean
|
|
unverifiedChangeSources?: string[]
|
|
}>
|
|
}
|
|
expect(listBody.checkpoints).toHaveLength(1)
|
|
expect(listBody.checkpoints[0]).toMatchObject({
|
|
target: { targetUserMessageId: userId },
|
|
code: { available: true, filesChanged: [] },
|
|
restoreAvailable: true,
|
|
unverifiedChangeSources: ['Bash'],
|
|
})
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, mode: 'conversation' }),
|
|
})
|
|
expect(rewindRes.status).toBe(200)
|
|
expect(await rewindRes.json()).toMatchObject({
|
|
mode: 'conversation',
|
|
unverifiedChangeSources: ['Bash'],
|
|
})
|
|
expect(await fs.readFile(shellFile, 'utf-8')).toBe('written by shell\n')
|
|
|
|
const messagesRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
|
const messagesBody = await messagesRes.json() as { messages: Array<{ id: string }> }
|
|
expect(messagesBody.messages.some((message) => message.id === userId)).toBe(false)
|
|
})
|
|
|
|
it('should keep restore available and unflagged when the turn only ran a read-only shell command', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-0000000f0001'
|
|
const workDir = path.join(tmpDir, 'repro-1192-readonly-bash')
|
|
const editedFile = path.join(workDir, 'src.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'repro-1192-src@v1'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(editedFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
await writeSessionFile('-tmp-repro-1192-readonly-bash', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src.ts': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit then check status', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:src',
|
|
name: 'Edit',
|
|
input: { file_path: editedFile, old_string: 'before', new_string: 'after' },
|
|
}], userId),
|
|
makeToolResultUserEntry('Edit:src', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Bash:git-status',
|
|
name: 'Bash',
|
|
input: { command: 'git status --short' },
|
|
}], userId),
|
|
makeToolResultUserEntry('Bash:git-status', ' M src.ts', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await listRes.json() as {
|
|
checkpoints: Array<{
|
|
code: { filesChanged: string[] }
|
|
restoreAvailable?: boolean
|
|
unverifiedChangeSources?: string[]
|
|
}>
|
|
}
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: { filesChanged: [editedFile] },
|
|
restoreAvailable: true,
|
|
// `git status` is provably read-only, so it must not even be reported.
|
|
unverifiedChangeSources: [],
|
|
})
|
|
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(200)
|
|
expect(await fs.readFile(editedFile, 'utf-8')).toBe('before\n')
|
|
})
|
|
|
|
it('should keep restore available when the turn ran a tool with no file-change extractor', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-0000000f0002'
|
|
const workDir = path.join(tmpDir, 'repro-1192-taskcreate')
|
|
const editedFile = path.join(workDir, 'src.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'repro-1192-task-src@v1'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(editedFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
await writeSessionFile('-tmp-repro-1192-taskcreate', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src.ts': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('track then edit', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'TaskCreate:1',
|
|
name: 'TaskCreate',
|
|
input: { tasks: [{ content: 'do it', activeForm: 'doing it' }] },
|
|
}], userId),
|
|
makeToolResultUserEntry('TaskCreate:1', 'Created.', undefined, undefined, sessionId),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'TaskUpdate:1',
|
|
name: 'TaskUpdate',
|
|
input: { taskId: 't1', status: 'completed' },
|
|
}], userId),
|
|
makeToolResultUserEntry('TaskUpdate:1', 'Updated.', undefined, undefined, sessionId),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:src',
|
|
name: 'Edit',
|
|
input: { file_path: editedFile, old_string: 'before', new_string: 'after' },
|
|
}], userId),
|
|
makeToolResultUserEntry('Edit:src', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await listRes.json() as {
|
|
checkpoints: Array<{ restoreAvailable?: boolean; unverifiedChangeSources?: string[] }>
|
|
}
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(true)
|
|
// TaskCreate can spawn shell/agent work that writes files, so it is reported;
|
|
// TaskUpdate only touches task metadata, so it must not add noise.
|
|
expect(body.checkpoints[0]?.unverifiedChangeSources).toEqual(['TaskCreate'])
|
|
})
|
|
|
|
it('should deduplicate and cap the reported unverified change sources', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-0000000f0003'
|
|
const workDir = path.join(tmpDir, 'repro-1192-many-sources')
|
|
const editedFile = path.join(workDir, 'src.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'repro-1192-many-src@v1'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(editedFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
// 10 distinct unknown tools plus a repeat, to prove both the dedup and the cap.
|
|
const unknownTools = [
|
|
'ToolA', 'ToolB', 'ToolC', 'ToolD', 'ToolE',
|
|
'ToolF', 'ToolG', 'ToolH', 'ToolI', 'ToolJ', 'ToolA',
|
|
]
|
|
await writeSessionFile('-tmp-repro-1192-many-sources', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src.ts': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('many tools', userId), cwd: workDir, sessionId },
|
|
...unknownTools.flatMap((name, index) => [
|
|
makeAssistantToolUseEntry([{ id: `${name}:${index}`, name, input: { x: 1 } }], userId),
|
|
makeToolResultUserEntry(`${name}:${index}`, 'ok', undefined, undefined, sessionId),
|
|
]),
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:src',
|
|
name: 'Edit',
|
|
input: { file_path: editedFile, old_string: 'before', new_string: 'after' },
|
|
}], userId),
|
|
makeToolResultUserEntry('Edit:src', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await listRes.json() as {
|
|
checkpoints: Array<{ restoreAvailable?: boolean; unverifiedChangeSources?: string[] }>
|
|
}
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(true)
|
|
expect(body.checkpoints[0]?.unverifiedChangeSources).toEqual([
|
|
'ToolA', 'ToolB', 'ToolC', 'ToolD', 'ToolE', 'ToolF', 'ToolG', 'ToolH',
|
|
])
|
|
})
|
|
|
|
it('should keep blocking restore when the transcript itself cannot be read', async () => {
|
|
// Guards the split introduced for issue #1192: an unknown *tool* only
|
|
// downgrades coverage, but an unreadable *transcript* still blocks, because
|
|
// then even the reported file list may be wrong.
|
|
const sessionId = '99999999-bbbb-cccc-dddd-0000000f0004'
|
|
const workDir = path.join(tmpDir, 'repro-1192-broken-transcript')
|
|
const editedFile = path.join(workDir, 'src.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'repro-1192-broken@v1'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(editedFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
const projectDir = '-tmp-repro-1192-broken-transcript'
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src.ts': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit src', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:src',
|
|
name: 'Edit',
|
|
input: { file_path: editedFile, old_string: 'before', new_string: 'after' },
|
|
}], userId),
|
|
makeToolResultUserEntry('Edit:src', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
const transcriptPath = path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`)
|
|
await fs.appendFile(transcriptPath, '{"type":"assistant","truncated\n')
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await listRes.json() as { checkpoints: Array<{ restoreAvailable?: boolean }> }
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(false)
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(editedFile, 'utf-8')).toBe('after\n')
|
|
})
|
|
|
|
it('should roll back the conversation alone when the files cannot be restored', async () => {
|
|
// The whole point of the mode: an unreadable transcript blocks the file
|
|
// restore, but the user must still be able to back out of their prompt.
|
|
const sessionId = '99999999-bbbb-cccc-dddd-0000000f0005'
|
|
const workDir = path.join(tmpDir, 'repro-1192-conversation-only')
|
|
const editedFile = path.join(workDir, 'src.ts')
|
|
const firstUserId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
const backupName = 'repro-1192-convonly@v1'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(editedFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
const projectDir = '-tmp-repro-1192-conversation-only'
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'src.ts': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('first prompt', firstUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('First done.', firstUserId),
|
|
{ ...makeUserEntry('second prompt', secondUserId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:src',
|
|
name: 'Edit',
|
|
input: { file_path: editedFile, old_string: 'before', new_string: 'after' },
|
|
}], secondUserId),
|
|
makeToolResultUserEntry('Edit:src', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Second done.', secondUserId),
|
|
])
|
|
const transcriptPath = path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`)
|
|
await fs.appendFile(transcriptPath, '{"type":"assistant","truncated\n')
|
|
|
|
// Default mode still refuses, and changes nothing.
|
|
const bothRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: secondUserId }),
|
|
})
|
|
expect(bothRes.status).toBe(400)
|
|
expect(await fs.readFile(editedFile, 'utf-8')).toBe('after\n')
|
|
|
|
const conversationRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: secondUserId, mode: 'conversation' }),
|
|
})
|
|
expect(conversationRes.status).toBe(200)
|
|
expect(await conversationRes.json()).toMatchObject({
|
|
mode: 'conversation',
|
|
// Reported honestly: the files were left alone because we could not
|
|
// restore them, not because the user declined.
|
|
restoreAvailable: false,
|
|
})
|
|
// Files untouched...
|
|
expect(await fs.readFile(editedFile, 'utf-8')).toBe('after\n')
|
|
// ...conversation actually trimmed back to before the second prompt.
|
|
const messagesRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
|
const messages = await messagesRes.json() as { messages: Array<{ id: string }> }
|
|
expect(messages.messages.some((message) => message.id === secondUserId)).toBe(false)
|
|
expect(messages.messages.some((message) => message.id === firstUserId)).toBe(true)
|
|
})
|
|
|
|
it('should leave files alone in conversation mode even when they are restorable', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-0000000f0006'
|
|
const workDir = path.join(tmpDir, 'repro-1192-conversation-opt-out')
|
|
const editedFile = path.join(workDir, 'src.ts')
|
|
const userId = crypto.randomUUID()
|
|
const backupName = 'repro-1192-optout@v1'
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(editedFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
await writeSessionFile('-tmp-repro-1192-conversation-opt-out', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'src.ts': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit src', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:src',
|
|
name: 'Edit',
|
|
input: { file_path: editedFile, old_string: 'before', new_string: 'after' },
|
|
}], userId),
|
|
makeToolResultUserEntry('Edit:src', 'Updated successfully.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, mode: 'conversation' }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toMatchObject({ mode: 'conversation', restoreAvailable: true })
|
|
expect(await fs.readFile(editedFile, 'utf-8')).toBe('after\n')
|
|
})
|
|
|
|
it('should echo the default mode and reject an unknown one', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-0000000f0007'
|
|
const workDir = path.join(tmpDir, 'repro-1192-mode-validation')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await writeSessionFile('-tmp-repro-1192-mode-validation', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{ ...makeUserEntry('hello', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Hi.', userId),
|
|
])
|
|
|
|
const badRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, mode: 'code' }),
|
|
})
|
|
expect(badRes.status).toBe(400)
|
|
|
|
const defaultRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(defaultRes.status).toBe(200)
|
|
expect(await defaultRes.json()).toMatchObject({ mode: 'both' })
|
|
})
|
|
|
|
it('should reject unsafe backup file names without reading outside file history', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000019'
|
|
const workDir = path.join(tmpDir, 'unsafe-backup-name')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await writeSessionFile('-tmp-unsafe-backup-name', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: '../../../../outside-secret', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit target', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
const dryRunRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, dryRun: true }),
|
|
})
|
|
expect(await dryRunRes.json()).toMatchObject({ restoreAvailable: false })
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(targetFile, 'utf-8')).toBe('after\n')
|
|
})
|
|
|
|
it('should reject conflicting snapshot aliases before presenting undo as available', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000020'
|
|
const workDir = path.join(tmpDir, 'conflicting-snapshot-aliases')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, 'alias-one@v1', 'before one\n')
|
|
await writeFileHistoryBackup(sessionId, 'alias-two@v1', 'before two\n')
|
|
await writeSessionFile('-tmp-conflicting-snapshot-aliases', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: 'alias-one@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
'nested/../target.txt': { backupFileName: 'alias-two@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit target', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await res.json() as { checkpoints: Array<{ restoreAvailable?: boolean }> }
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(false)
|
|
})
|
|
|
|
it('should preflight every restore target before changing the first file', async () => {
|
|
if (process.platform === 'win32') return
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000021'
|
|
const workDir = path.join(tmpDir, 'restore-preflight')
|
|
const firstFile = path.join(workDir, 'first.txt')
|
|
const secondFile = path.join(workDir, 'second.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(firstFile, 'first after\n')
|
|
await fs.writeFile(secondFile, 'second after\n')
|
|
await writeFileHistoryBackup(sessionId, 'preflight-first@v1', 'first before\n')
|
|
await writeFileHistoryBackup(sessionId, 'preflight-second@v1', 'second before\n')
|
|
await writeSessionFile('-tmp-restore-preflight', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'first.txt': { backupFileName: 'preflight-first@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
'second.txt': { backupFileName: 'preflight-second@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit both', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
await fs.chmod(secondFile, 0o400)
|
|
try {
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(firstFile, 'utf-8')).toBe('first after\n')
|
|
expect((await service.getSessionMessages(sessionId)).map((message) => message.id))
|
|
.toContain(userId)
|
|
} finally {
|
|
await fs.chmod(secondFile, 0o600)
|
|
}
|
|
})
|
|
|
|
it('should roll restored files forward again when transcript trimming fails', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000026'
|
|
const workDir = path.join(tmpDir, 'trim-failure-file-rollback')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, 'trim-failure@v1', 'before\n')
|
|
await writeSessionFile('-tmp-trim-failure-file-rollback', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: 'trim-failure@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit target', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
const originalTrim = sessionService.trimSessionMessagesFrom.bind(sessionService)
|
|
sessionService.trimSessionMessagesFrom = async () => {
|
|
throw new Error('simulated atomic transcript replacement failure')
|
|
}
|
|
try {
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(500)
|
|
expect(await fs.readFile(targetFile, 'utf-8')).toBe('after\n')
|
|
} finally {
|
|
sessionService.trimSessionMessagesFrom = originalTrim
|
|
}
|
|
expect((await service.getSessionMessages(sessionId)).map((message) => message.id))
|
|
.toContain(userId)
|
|
})
|
|
|
|
it('should not require write access for an unchanged tracked file', async () => {
|
|
if (process.platform === 'win32') return
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000027'
|
|
const workDir = path.join(tmpDir, 'unchanged-read-only-rewind')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'same\n')
|
|
await writeFileHistoryBackup(sessionId, 'unchanged-read-only@v1', 'same\n')
|
|
await writeSessionFile('-tmp-unchanged-read-only-rewind', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: 'unchanged-read-only@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('inspect only', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
await fs.chmod(targetFile, 0o400)
|
|
try {
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(200)
|
|
expect(await fs.readFile(targetFile, 'utf-8')).toBe('same\n')
|
|
} finally {
|
|
await fs.chmod(targetFile, 0o600)
|
|
}
|
|
})
|
|
|
|
it('should disable rewind when nested subagent evidence hits the depth bound', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000022'
|
|
const projectDir = '-tmp-subagent-depth-bound'
|
|
const workDir = path.join(tmpDir, 'subagent-depth-bound')
|
|
const capturedFile = path.join(workDir, 'captured.txt')
|
|
const omittedFile = path.join(workDir, 'omitted.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(capturedFile, 'same\n')
|
|
await fs.writeFile(omittedFile, 'deep write\n')
|
|
await writeFileHistoryBackup(sessionId, 'depth-captured@v1', 'same\n')
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'captured.txt': { backupFileName: 'depth-captured@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('delegate deeply', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{ id: 'Agent:depth-root', name: 'Agent', input: {} }], userId),
|
|
makeToolResultUserEntry('Agent:depth-root', 'agentId: depth-1', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
for (let depth = 1; depth <= 17; depth += 1) {
|
|
const entries = depth === 17
|
|
? [
|
|
{ ...makeAssistantToolUseEntry([{ id: 'Write:deep', name: 'Write', input: { file_path: omittedFile, content: 'deep write\n' } }]), cwd: workDir },
|
|
{ ...makeToolResultUserEntry('Write:deep', 'Written.', undefined, undefined, sessionId), cwd: workDir },
|
|
]
|
|
: [
|
|
{ ...makeAssistantToolUseEntry([{ id: `Agent:depth-${depth}`, name: 'Agent', input: {} }]), cwd: workDir },
|
|
{ ...makeToolResultUserEntry(`Agent:depth-${depth}`, `agentId: depth-${depth + 1}`, undefined, undefined, sessionId), cwd: workDir },
|
|
]
|
|
await writeSubagentTranscriptFile(projectDir, sessionId, `depth-${depth}`, entries)
|
|
}
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await res.json() as { checkpoints: Array<{ restoreAvailable?: boolean }> }
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(false)
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(omittedFile, 'utf-8')).toBe('deep write\n')
|
|
})
|
|
|
|
it('should disable rewind when the root transcript contains an incomplete JSONL entry', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000023'
|
|
const workDir = path.join(tmpDir, 'incomplete-root-transcript')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, 'incomplete-root@v1', 'before\n')
|
|
const transcriptPath = await writeSessionFile('-tmp-incomplete-root-transcript', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: 'incomplete-root@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit target', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
await fs.appendFile(transcriptPath, '{"type":"assistant","message":\n')
|
|
|
|
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await listRes.json() as { checkpoints: Array<{ restoreAvailable?: boolean }> }
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(false)
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(targetFile, 'utf-8')).toBe('after\n')
|
|
})
|
|
|
|
it('should disable rewind when an Agent result has no resolvable child transcript id', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000024'
|
|
const workDir = path.join(tmpDir, 'unresolved-agent-result')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, 'unresolved-agent@v1', 'before\n')
|
|
await writeSessionFile('-tmp-unresolved-agent-result', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: 'unresolved-agent@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('delegate edit', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{ id: 'Agent:missing-id', name: 'Agent', input: {} }], userId),
|
|
makeToolResultUserEntry('Agent:missing-id', 'Agent completed.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await res.json() as { checkpoints: Array<{ restoreAvailable?: boolean }> }
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(false)
|
|
})
|
|
|
|
it('should load Task subagent edits outside the prompt workdir', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000025'
|
|
const projectDir = '-tmp-task-subagent-external'
|
|
const workDir = path.join(tmpDir, 'task-subagent-workdir')
|
|
const outsideFile = path.join(tmpDir, 'task-subagent-outside', 'outside.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.mkdir(path.dirname(outsideFile), { recursive: true })
|
|
await fs.writeFile(outsideFile, 'outside\n')
|
|
await writeSessionFile(projectDir, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
{ ...makeUserEntry('delegate external edit', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{ id: 'Task:external', name: 'Task', input: {} }], userId),
|
|
makeToolResultUserEntry('Task:external', 'agentId: task-external', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
await writeSubagentTranscriptFile(projectDir, sessionId, 'task-external', [
|
|
{ ...makeAssistantToolUseEntry([{ id: 'Write:task-external', name: 'Write', input: { file_path: outsideFile, content: 'outside\n' } }]), cwd: workDir },
|
|
{ ...makeToolResultUserEntry('Write:task-external', 'Written.', undefined, undefined, sessionId), cwd: workDir },
|
|
])
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{ code: { filesChanged: string[] }; restoreAvailable?: boolean }>
|
|
}
|
|
expect(body.checkpoints[0]).toMatchObject({
|
|
code: { filesChanged: [outsideFile] },
|
|
restoreAvailable: false,
|
|
})
|
|
})
|
|
|
|
it('should validate transcript-only changes from every turn removed by rewind', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000028'
|
|
const workDir = path.join(tmpDir, 'later-turn-evidence')
|
|
const capturedFile = path.join(workDir, 'captured.txt')
|
|
const outsideFile = path.join(tmpDir, 'later-turn-outside.txt')
|
|
const firstUserId = crypto.randomUUID()
|
|
const secondUserId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(capturedFile, 'same\n')
|
|
await fs.writeFile(outsideFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, 'later-turn-captured@v1', 'same\n')
|
|
await writeSessionFile('-tmp-later-turn-evidence', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(firstUserId, {
|
|
'captured.txt': { backupFileName: 'later-turn-captured@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('first turn', firstUserId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', firstUserId),
|
|
{ ...makeUserEntry('edit outside', secondUserId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{
|
|
id: 'Edit:later-external', name: 'Edit',
|
|
input: { file_path: outsideFile, old_string: 'before', new_string: 'after' },
|
|
}], secondUserId),
|
|
makeToolResultUserEntry('Edit:later-external', 'Updated.', undefined, undefined, sessionId),
|
|
makeAssistantEntry('Done.', secondUserId),
|
|
])
|
|
const dryRunRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: firstUserId, dryRun: true }),
|
|
})
|
|
expect(await dryRunRes.json()).toMatchObject({
|
|
code: { filesChanged: [outsideFile] },
|
|
restoreAvailable: false,
|
|
})
|
|
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: firstUserId }),
|
|
})
|
|
expect(rewindRes.status).toBe(400)
|
|
expect(await fs.readFile(outsideFile, 'utf-8')).toBe('after\n')
|
|
})
|
|
|
|
it('should report errored write-capable tools as unverified without blocking restore', async () => {
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000029'
|
|
const workDir = path.join(tmpDir, 'errored-write-tool')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await writeFileHistoryBackup(sessionId, 'errored-write@v1', 'before\n')
|
|
await writeSessionFile('-tmp-errored-write-tool', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: 'errored-write@v1', version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('run a failing write', userId), cwd: workDir, sessionId },
|
|
makeAssistantToolUseEntry([{ id: 'Bash:error-after-write', name: 'Bash', input: { command: 'write then fail' } }], userId),
|
|
makeToolResultUserEntry('Bash:error-after-write', 'failed after write', true, undefined, sessionId),
|
|
makeAssistantEntry('Failed.', userId),
|
|
])
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
|
const body = await res.json() as {
|
|
checkpoints: Array<{ restoreAvailable?: boolean; unverifiedChangeSources?: string[] }>
|
|
}
|
|
expect(body.checkpoints[0]?.restoreAvailable).toBe(true)
|
|
expect(body.checkpoints[0]?.unverifiedChangeSources).toEqual(['Bash'])
|
|
})
|
|
|
|
it('should reject a symlinked file-history session directory', async () => {
|
|
if (process.platform === 'win32') return
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000030'
|
|
const workDir = path.join(tmpDir, 'symlinked-backup-directory')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const outsideBackupDir = path.join(tmpDir, 'outside-backup-directory')
|
|
const backupName = 'symlinked-directory@v1'
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.mkdir(outsideBackupDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await fs.writeFile(path.join(outsideBackupDir, backupName), 'outside secret\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
const backupDir = path.join(tmpDir, 'file-history', sessionId)
|
|
await fs.rm(backupDir, { recursive: true })
|
|
await fs.symlink(outsideBackupDir, backupDir, 'dir')
|
|
await writeSessionFile('-tmp-symlinked-backup-directory', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit target', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
const dryRunRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, dryRun: true }),
|
|
})
|
|
expect(await dryRunRes.json()).toMatchObject({ restoreAvailable: false })
|
|
expect(await fs.readFile(targetFile, 'utf-8')).toBe('after\n')
|
|
})
|
|
|
|
it('should reject a symlinked backup file', async () => {
|
|
if (process.platform === 'win32') return
|
|
const sessionId = '99999999-bbbb-cccc-dddd-000000000031'
|
|
const workDir = path.join(tmpDir, 'symlinked-backup-file')
|
|
const targetFile = path.join(workDir, 'target.txt')
|
|
const outsideBackup = path.join(tmpDir, 'outside-backup.txt')
|
|
const backupName = 'symlinked-file@v1'
|
|
const userId = crypto.randomUUID()
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.writeFile(targetFile, 'after\n')
|
|
await fs.writeFile(outsideBackup, 'outside secret\n')
|
|
await writeFileHistoryBackup(sessionId, backupName, 'before\n')
|
|
const backupPath = path.join(tmpDir, 'file-history', sessionId, backupName)
|
|
await fs.unlink(backupPath)
|
|
await fs.symlink(outsideBackup, backupPath)
|
|
await writeSessionFile('-tmp-symlinked-backup-file', sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
makeFileHistorySnapshotEntry(userId, {
|
|
'target.txt': { backupFileName: backupName, version: 1, backupTime: '2026-01-01T00:00:00.000Z' },
|
|
}),
|
|
{ ...makeUserEntry('edit target', userId), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', userId),
|
|
])
|
|
const dryRunRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userId, dryRun: true }),
|
|
})
|
|
expect(await dryRunRes.json()).toMatchObject({ restoreAvailable: false })
|
|
expect(await fs.readFile(targetFile, 'utf-8')).toBe('after\n')
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
// Files created by the FIRST turn (the mirror of the fixture above, which
|
|
// creates its file in the third turn). Rewinding back to turn 1 has to delete
|
|
// them, because "before turn 1" is a state in which they did not exist.
|
|
async function writeFirstTurnCreatesFilesFixture(sessionId: string) {
|
|
const workDir = path.join(tmpDir, `first-turn-creates-${sessionId}`)
|
|
const fileA = path.join(workDir, 'a.ts')
|
|
const fileB = path.join(workDir, 'b.ts')
|
|
const fileC = path.join(workDir, 'c.ts')
|
|
const ids = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
// Disk holds the third-turn content.
|
|
await fs.writeFile(fileA, 'a v3\n')
|
|
await fs.writeFile(fileB, 'b v3\n')
|
|
await fs.writeFile(fileC, 'c v3\n')
|
|
await writeFileHistoryBackup(sessionId, 'a@v2', 'a v1\n')
|
|
await writeFileHistoryBackup(sessionId, 'b@v2', 'b v1\n')
|
|
await writeFileHistoryBackup(sessionId, 'a@v3', 'a v2\n')
|
|
await writeFileHistoryBackup(sessionId, 'b@v3', 'b v2\n')
|
|
await writeFileHistoryBackup(sessionId, 'c@v2', 'c v2\n')
|
|
const stamp = '2026-01-01T00:00:00.000Z'
|
|
await writeSessionFile(`-tmp-first-turn-creates-${sessionId}`, sessionId, [
|
|
makeSessionMetaEntry(workDir),
|
|
// Turn 1 creates a.ts and b.ts — neither existed before it.
|
|
makeFileHistorySnapshotEntry(ids[0]!, {
|
|
'a.ts': { backupFileName: null, version: 1, backupTime: stamp },
|
|
'b.ts': { backupFileName: null, version: 1, backupTime: stamp },
|
|
}),
|
|
{ ...makeUserEntry('create a and b', ids[0]), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Created.', ids[0]),
|
|
// Turn 2 edits both and creates c.ts.
|
|
makeFileHistorySnapshotEntry(ids[1]!, {
|
|
'a.ts': { backupFileName: 'a@v2', version: 2, backupTime: stamp },
|
|
'b.ts': { backupFileName: 'b@v2', version: 2, backupTime: stamp },
|
|
'c.ts': { backupFileName: null, version: 1, backupTime: stamp },
|
|
}),
|
|
{ ...makeUserEntry('edit a, b and create c', ids[1]), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', ids[1]),
|
|
// Turn 3 edits a.ts, b.ts and c.ts again.
|
|
makeFileHistorySnapshotEntry(ids[2]!, {
|
|
'a.ts': { backupFileName: 'a@v3', version: 3, backupTime: stamp },
|
|
'b.ts': { backupFileName: 'b@v3', version: 3, backupTime: stamp },
|
|
'c.ts': { backupFileName: 'c@v2', version: 2, backupTime: stamp },
|
|
}),
|
|
{ ...makeUserEntry('edit again', ids[2]), cwd: workDir, sessionId },
|
|
makeAssistantEntry('Done.', ids[2]),
|
|
])
|
|
return { workDir, fileA, fileB, fileC, ids }
|
|
}
|
|
|
|
it('POST /api/sessions/:id/rewind should return to the start of the third turn without touching earlier turns', async () => {
|
|
const sessionId = 'aaaaaaaa-9999-1111-2222-333333333333'
|
|
const f = await writeFirstTurnCreatesFilesFixture(sessionId)
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: f.ids[2] }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
|
|
// Back to the state captured when turn 3's prompt was submitted.
|
|
expect(await fs.readFile(f.fileA, 'utf-8')).toBe('a v2\n')
|
|
expect(await fs.readFile(f.fileB, 'utf-8')).toBe('b v2\n')
|
|
// c.ts existed before turn 3, so it survives with its pre-turn-3 content.
|
|
expect(await fs.readFile(f.fileC, 'utf-8')).toBe('c v2\n')
|
|
})
|
|
|
|
it('POST /api/sessions/:id/rewind should delete first-turn-created files when rewinding all the way back', async () => {
|
|
const sessionId = 'aaaaaaaa-9999-4444-5555-666666666666'
|
|
const f = await writeFirstTurnCreatesFilesFixture(sessionId)
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: f.ids[0] }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
|
|
// a.ts and b.ts did not exist before turn 1 — undo removes them outright.
|
|
await expect(fs.stat(f.fileA)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
await expect(fs.stat(f.fileB)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
// c.ts was created two turns later and is removed as well: the restore plan
|
|
// spans every tracked path, not just the ones in the target snapshot.
|
|
await expect(fs.stat(f.fileC)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
|
|
expect(await service.getSessionMessages(sessionId)).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; activityState: string }
|
|
expect(body.state).toBe('idle')
|
|
expect(body.activityState).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()
|
|
|
|
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/status`)
|
|
const status = (await statusRes.json()) as { state: string; activityState: string }
|
|
expect(status.state).toBe('thinking')
|
|
expect(status.activityState).toBe('running')
|
|
|
|
await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/stop`, { method: 'POST' })
|
|
})
|
|
|
|
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; activityState: string }
|
|
expect(status.state).toBe('idle')
|
|
expect(status.activityState).toBe('idle')
|
|
})
|
|
|
|
it('frozen review never uses current bytes for the latest legacy turn', async () => {
|
|
const fixture = await createThreeTurnCheckpointFixture('ffffffff-bbbb-cccc-dddd-eeeeeeeeeee1')
|
|
await fs.writeFile(fixture.stepFile, 'unrelated after all turns\n')
|
|
for (const [target, before, after] of [
|
|
[fixture.firstUserId, 'base', 'v1'],
|
|
[fixture.secondUserId, 'v1', 'v2'],
|
|
]) {
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${target}&path=src/step.js`)
|
|
const diff = await response.json() as { state: string; diff?: string }
|
|
expect(diff.state).toBe('ok')
|
|
expect(diff.diff).toContain(`-export const STEP = '${before}'`)
|
|
expect(diff.diff).toContain(`+export const STEP = '${after}'`)
|
|
expect(diff.diff).not.toContain('unrelated')
|
|
}
|
|
const latest = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`)
|
|
expect((await latest.json() as { state: string }).state).toBe('missing')
|
|
})
|
|
|
|
it('frozen review uses a completed after-checkpoint even after later edits and reload', async () => {
|
|
const fixture = await createThreeTurnCheckpointFixture('ffffffff-bbbb-cccc-dddd-eeeeeeeeeee2')
|
|
const snapshotEntries = await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)
|
|
const latest = snapshotEntries.find(snapshot => snapshot.messageId === fixture.thirdUserId)!
|
|
await writeFileHistoryBackup(fixture.sessionId, 'completed-step', "export const STEP = 'v3'\n")
|
|
await writeFileHistoryBackup(fixture.sessionId, 'completed-created', 'generated third turn\n')
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: latest.messageId, isSnapshotUpdate: true, snapshot: {
|
|
...latest,
|
|
completedFileBackups: {
|
|
'src/step.js': { backupFileName: 'completed-step', version: 3, backupTime: new Date() },
|
|
'notes/generated.txt': { backupFileName: 'completed-created', version: 1, backupTime: new Date() },
|
|
},
|
|
} }) + '\n')
|
|
await fs.writeFile(fixture.stepFile, 'unrelated after complete\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&userMessageIndex=2&path=src/step.js`)
|
|
const diff = await response.json() as { state: string; diff?: string }
|
|
expect(diff.state).toBe('ok')
|
|
expect(diff.diff).toContain("+export const STEP = 'v3'")
|
|
expect(diff.diff).not.toContain('unrelated')
|
|
const status = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
const body = await status.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { insertions: number; deletions: number } }> }
|
|
expect(body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.thirdUserId)?.code).toMatchObject({ insertions: 2, deletions: 1 })
|
|
})
|
|
|
|
|
|
it('frozen review refuses mismatched IDs instead of falling through to another turn index', async () => {
|
|
const fixture = await createThreeTurnCheckpointFixture('ffffffff-bbbb-cccc-dddd-eeeeeeeeeee3')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=gone-message&userMessageIndex=1&path=src/step.js`)
|
|
expect(response.status).toBe(400)
|
|
const mismatch = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.firstUserId}&userMessageIndex=1&path=src/step.js`)
|
|
expect(mismatch.status).toBe(400)
|
|
})
|
|
|
|
it.each(['absent-map', 'empty-map', 'missing-entry', 'missing-file', 'directory', 'invalid-utf8', 'corrupt-descriptor'] as const)('frozen review marks the whole comparison unavailable for a %s after boundary', async (failure) => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const latest = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.thirdUserId)!
|
|
await writeFileHistoryBackup(fixture.sessionId, 'completed-step', "export const STEP = 'v3'\n")
|
|
const backupDirectory = path.join(tmpDir, 'file-history', fixture.sessionId)
|
|
const completed: Record<string, unknown> = {
|
|
'src/step.js': { backupFileName: 'completed-step', version: 3, backupTime: new Date() },
|
|
}
|
|
if (failure === 'empty-map') delete completed['src/step.js']
|
|
if (failure === 'directory') await fs.mkdir(path.join(backupDirectory, 'bad-after'))
|
|
if (failure === 'invalid-utf8') await fs.writeFile(path.join(backupDirectory, 'bad-after'), Buffer.from([0xc3, 0x28]))
|
|
if (['missing-file', 'directory', 'invalid-utf8'].includes(failure)) completed['notes/generated.txt'] = { backupFileName: 'bad-after', version: 1, backupTime: new Date() }
|
|
if (failure === 'corrupt-descriptor') completed['notes/generated.txt'] = { backupFileName: 123, version: 1 }
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: latest.messageId, isSnapshotUpdate: true, snapshot: {
|
|
...latest,
|
|
...(failure === 'absent-map' ? {} : { completedFileBackups: completed }),
|
|
} }) + '\n')
|
|
await fs.writeFile(fixture.stepFile, 'later live step\n')
|
|
await fs.writeFile(fixture.createdFile, 'later live generated\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean; reason?: string }; restoreAvailable: boolean }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.thirdUserId)!
|
|
expect(turn.code.available).toBe(false)
|
|
expect(turn.restoreAvailable).toBe(false)
|
|
expect(turn.code.reason).toContain('notes/generated.txt')
|
|
const missing = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&userMessageIndex=2&path=notes/generated.txt`)
|
|
const missingDiff = await missing.json() as { state: string; diff?: string }
|
|
expect(missingDiff.state).toBe('missing')
|
|
expect(missingDiff.diff).toBeUndefined()
|
|
if (!['absent-map', 'empty-map'].includes(failure)) {
|
|
const successful = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&userMessageIndex=2&path=src/step.js`)
|
|
const diff = await successful.json() as { state: string; diff?: string }
|
|
expect(diff.state).toBe('ok')
|
|
expect(diff.diff).toContain("+export const STEP = 'v3'")
|
|
expect(diff.diff).not.toContain('later live')
|
|
}
|
|
})
|
|
|
|
it('frozen review rejects incomplete history produced by the real completion writer', async () => {
|
|
const runtime = await import('../../bootstrap/state.js')
|
|
const historyApi = await import('../../utils/fileHistory.js')
|
|
const { flushSessionStorage } = await import('../../utils/sessionStorage.js')
|
|
const fixture = await createThreeTurnCheckpointFixture(runtime.getSessionId())
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
const original = { cwd: runtime.getOriginalCwd(), projectDir: runtime.getSessionProjectDir(), interactive: runtime.getIsInteractive() }
|
|
let history: import('../../utils/fileHistory.js').FileHistoryState = {
|
|
snapshots: [{ messageId: fixture.thirdUserId as ReturnType<typeof crypto.randomUUID>, trackedFileBackups: {}, timestamp: new Date() }],
|
|
trackedFiles: new Set(), snapshotSequence: 1,
|
|
}
|
|
const updateHistory = (updater: (state: typeof history) => typeof history) => { history = updater(history) }
|
|
try {
|
|
runtime.setOriginalCwd(fixture.workDir)
|
|
runtime.setIsInteractive(true)
|
|
runtime.switchSession(runtime.getSessionId(), path.dirname(found!.filePath))
|
|
await historyApi.fileHistoryTrackEdit(updateHistory, fixture.stepFile, history.snapshots[0]!.messageId)
|
|
await historyApi.fileHistoryTrackEdit(updateHistory, fixture.createdFile, history.snapshots[0]!.messageId)
|
|
await fs.writeFile(fixture.stepFile, 'captured successful after\n')
|
|
await fs.unlink(fixture.createdFile)
|
|
await fs.symlink(fixture.stepFile, fixture.createdFile)
|
|
await historyApi.fileHistoryCompleteSnapshot(updateHistory, history.snapshots[0]!.messageId)
|
|
await flushSessionStorage()
|
|
const completed = history.snapshots[0]!
|
|
expect(Object.keys(completed.trackedFileBackups)).toHaveLength(2)
|
|
expect(Object.keys(completed.completedFileBackups!)).toHaveLength(1)
|
|
// Consume the actual production writer output through the API fixture.
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: completed.messageId, isSnapshotUpdate: true, snapshot: completed }) + '\n')
|
|
await fs.writeFile(fixture.stepFile, 'later live edit\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean; reason?: string }; restoreAvailable: boolean }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.thirdUserId)!
|
|
expect(turn.code.available).toBe(false)
|
|
expect(turn.restoreAvailable).toBe(false)
|
|
expect(turn.code.reason).toContain('notes/generated.txt')
|
|
const successful = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`)
|
|
const diff = await successful.json() as { state: string; diff?: string }
|
|
expect(diff.state).toBe('ok')
|
|
expect(diff.diff).toContain('+captured successful after')
|
|
expect(diff.diff).not.toContain('later live edit')
|
|
const missing = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&path=notes/generated.txt`)
|
|
expect((await missing.json() as { state: string }).state).toBe('missing')
|
|
} finally {
|
|
await flushSessionStorage()
|
|
runtime.setOriginalCwd(original.cwd)
|
|
runtime.setIsInteractive(original.interactive)
|
|
runtime.switchSession(runtime.getSessionId(), original.projectDir)
|
|
}
|
|
})
|
|
|
|
it.each([{ completedFileBackups: null }, { completedFileBackups: 'invalid-map' }, { completedFileBackups: [] }])('frozen review keeps a malformed completed container unavailable instead of using the next turn: %j', async ({ completedFileBackups }) => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const second = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.secondUserId)!
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: second.messageId, isSnapshotUpdate: true, snapshot: { ...second, completedFileBackups } }) + '\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean; reason?: string }; restoreAvailable: boolean }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.secondUserId)!
|
|
expect(turn.code.available).toBe(false)
|
|
expect(turn.restoreAvailable).toBe(false)
|
|
expect(turn.code.reason).toContain('src/step.js')
|
|
const missing = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.secondUserId}&path=src/step.js`)
|
|
expect((await missing.json() as { state: string }).state).toBe('missing')
|
|
})
|
|
|
|
it.each(['missing', 'conflicting'] as const)('frozen review rejects a %s after boundary hidden behind a duplicate recorded path', async (failure) => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const second = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.secondUserId)!
|
|
await writeFileHistoryBackup(fixture.sessionId, 'after-step', 'one after\n')
|
|
await writeFileHistoryBackup(fixture.sessionId, 'other-after-step', 'different after\n')
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: second.messageId, isSnapshotUpdate: true, snapshot: {
|
|
...second,
|
|
trackedFileBackups: { ...second.trackedFileBackups, './src/step.js': second.trackedFileBackups['src/step.js'] },
|
|
completedFileBackups: {
|
|
'src/step.js': { backupFileName: 'after-step', version: 2, backupTime: new Date() },
|
|
...(failure === 'conflicting' ? { './src/step.js': { backupFileName: 'other-after-step', version: 2, backupTime: new Date() } } : {}),
|
|
},
|
|
} }) + '\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean }; restoreAvailable: boolean }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.secondUserId)!
|
|
expect(turn.code.available).toBe(false)
|
|
expect(turn.restoreAvailable).toBe(false)
|
|
for (const requestedPath of ['src/step.js', './src/step.js']) {
|
|
const missing = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.secondUserId}&path=${encodeURIComponent(requestedPath)}`)
|
|
expect((await missing.json() as { state: string }).state).toBe('missing')
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
{ boundary: 'target', descriptor: null },
|
|
{ boundary: 'target', descriptor: {} },
|
|
{ boundary: 'next', descriptor: null },
|
|
{ boundary: 'next', descriptor: {} },
|
|
])('frozen review reports a corrupt before descriptor as unavailable without a server error: %j', async ({ boundary, descriptor }) => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const second = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.secondUserId)!
|
|
const target = boundary === 'target' ? fixture.secondUserId : fixture.firstUserId
|
|
await writeFileHistoryBackup(fixture.sessionId, 'known-after', 'known after\n')
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: second.messageId, isSnapshotUpdate: true, snapshot: {
|
|
...second,
|
|
trackedFileBackups: { 'src/step.js': descriptor },
|
|
completedFileBackups: { 'src/step.js': { backupFileName: 'known-after', version: 2, backupTime: new Date() } },
|
|
} }) + '\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean; reason?: string }; restoreAvailable: boolean }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === target)!
|
|
expect(turn.code.available).toBe(false)
|
|
expect(turn.restoreAvailable).toBe(false)
|
|
expect(turn.code.reason).toContain('src/step.js')
|
|
const missing = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${target}&path=src/step.js`)
|
|
expect(missing.status).toBe(200)
|
|
expect((await missing.json() as { state: string }).state).toBe('missing')
|
|
})
|
|
|
|
it.each(['null', 'string', 'array', 'missing', 'partial', 'empty'] as const)('frozen review checks after-known paths when the before map is %s', async (kind) => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const latest = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.thirdUserId)!
|
|
await writeFileHistoryBackup(fixture.sessionId, 'known-step-after', 'frozen step after\n')
|
|
await writeFileHistoryBackup(fixture.sessionId, 'known-note-after', 'frozen note after\n')
|
|
const trackedFileBackups = kind === 'null' ? null : kind === 'string' ? 'damaged' : kind === 'array' ? []
|
|
: kind === 'partial' ? { 'src/step.js': latest.trackedFileBackups['src/step.js'] } : {}
|
|
const snapshot: Record<string, unknown> = { ...latest, trackedFileBackups, completedFileBackups: {
|
|
'src/step.js': { backupFileName: 'known-step-after', version: 3, backupTime: new Date() },
|
|
'notes/generated.txt': { backupFileName: 'known-note-after', version: 1, backupTime: new Date() },
|
|
} }
|
|
if (kind === 'missing') delete snapshot.trackedFileBackups
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: latest.messageId, isSnapshotUpdate: true, snapshot }) + '\n')
|
|
await fs.writeFile(fixture.stepFile, 'later live step\n')
|
|
await fs.writeFile(fixture.createdFile, 'later live note\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean; reason?: string }; restoreAvailable: boolean; unverifiedChangeSources: string[] }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.thirdUserId)!
|
|
expect(turn.code.available).toBe(false)
|
|
expect(turn.restoreAvailable).toBe(false)
|
|
expect(turn.code.reason).toContain('notes/generated.txt')
|
|
expect(turn.unverifiedChangeSources).toContain('file-history:notes/generated.txt')
|
|
if (kind !== 'partial') expect(turn.code.reason).toContain('src/step.js')
|
|
for (const requestedPath of ['src/step.js', 'notes/generated.txt']) {
|
|
const result = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&userMessageIndex=2&path=${requestedPath}`)
|
|
expect(result.status).toBe(200)
|
|
const diff = await result.json() as { state: string; diff?: string }
|
|
if (kind === 'partial' && requestedPath === 'src/step.js') {
|
|
expect(diff.state).toBe('ok')
|
|
expect(diff.diff).toContain('+frozen step after')
|
|
expect(diff.diff).not.toContain('later live')
|
|
} else {
|
|
expect(diff.state).toBe('missing')
|
|
expect(diff.diff).toBeUndefined()
|
|
}
|
|
}
|
|
})
|
|
|
|
it.each([{ before: null }, { before: 'damaged' }, { before: [] }])('frozen review preserves malformed-before evidence even when no paths survive: %j', async ({ before }) => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const latest = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.thirdUserId)!
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: latest.messageId, isSnapshotUpdate: true, snapshot: { ...latest, trackedFileBackups: before, completedFileBackups: {} } }) + '\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean; reason?: string }; restoreAvailable: boolean; unverifiedChangeSources: string[] }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.thirdUserId)!
|
|
expect(turn.code.available).toBe(false)
|
|
expect(turn.restoreAvailable).toBe(false)
|
|
expect(turn.code.reason).toContain('before')
|
|
expect(turn.unverifiedChangeSources.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it.each(['legacy', 'completed'] as const)('frozen review keeps a valid empty %s checkpoint complete', async (kind) => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const latest = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.thirdUserId)!
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: latest.messageId, isSnapshotUpdate: true, snapshot: {
|
|
...latest, trackedFileBackups: {}, ...(kind === 'completed' ? { completedFileBackups: {} } : {}),
|
|
} }) + '\n')
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints?frozen=true`)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { checkpoints: Array<{ target: { targetUserMessageId: string }; code: { available: boolean; filesChanged: string[] }; restoreAvailable: boolean; unverifiedChangeSources: string[] }> }
|
|
const turn = body.checkpoints.find(checkpoint => checkpoint.target.targetUserMessageId === fixture.thirdUserId)!
|
|
expect(turn.code).toMatchObject({ available: true, filesChanged: [], insertions: 0, deletions: 0 })
|
|
expect(turn.restoreAvailable).toBe(true)
|
|
expect(turn.unverifiedChangeSources).toEqual([])
|
|
const result = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?frozen=true&targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`)
|
|
expect((await result.json() as { state: string }).state).toBe('missing')
|
|
})
|
|
|
|
const rewindBeforeFailures = ['null', 'string', 'array', 'empty', 'partial', 'missing-map', 'null-descriptor', 'empty-descriptor', 'missing-backup', 'missing-backup-and-live'] as const
|
|
|
|
async function incompleteRewindFixture(kind: typeof rewindBeforeFailures[number], location: 'target' | 'later-middle' = 'target') {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const damagedId = location === 'target' ? fixture.thirdUserId : fixture.secondUserId
|
|
const targetId = location === 'target' ? fixture.thirdUserId : fixture.firstUserId
|
|
const damaged = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === damagedId)!
|
|
await writeFileHistoryBackup(fixture.sessionId, 'rewind-step-after', 'recorded step after\n')
|
|
await writeFileHistoryBackup(fixture.sessionId, 'rewind-note-after', 'recorded note after\n')
|
|
let before: unknown = damaged.trackedFileBackups
|
|
if (kind === 'null') before = null
|
|
if (kind === 'string') before = 'malformed-before'
|
|
if (kind === 'array') before = []
|
|
if (kind === 'empty') before = {}
|
|
if (kind === 'partial') before = { 'src/step.js': damaged.trackedFileBackups['src/step.js'] }
|
|
if (kind === 'null-descriptor') before = { ...damaged.trackedFileBackups, 'src/step.js': null }
|
|
if (kind === 'empty-descriptor') before = { ...damaged.trackedFileBackups, 'src/step.js': {} }
|
|
if (kind.startsWith('missing-backup')) {
|
|
await fs.unlink(path.join(tmpDir, 'file-history', fixture.sessionId, damaged.trackedFileBackups['src/step.js']!.backupFileName!))
|
|
if (kind === 'missing-backup-and-live') await fs.unlink(fixture.stepFile)
|
|
}
|
|
const snapshot: Record<string, unknown> = { ...damaged, trackedFileBackups: before, completedFileBackups: {
|
|
'src/step.js': { backupFileName: 'rewind-step-after', version: 3, backupTime: new Date() },
|
|
'notes/generated.txt': { backupFileName: 'rewind-note-after', version: 1, backupTime: new Date() },
|
|
} }
|
|
if (kind === 'missing-map') delete snapshot.trackedFileBackups
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: damaged.messageId, isSnapshotUpdate: true, snapshot }) + '\n')
|
|
return { ...fixture, targetId, transcriptPath: found!.filePath }
|
|
}
|
|
|
|
async function fileBytesOrMissing(filePath: string): Promise<Buffer | null> {
|
|
try { return await fs.readFile(filePath) }
|
|
catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
it.each(rewindBeforeFailures.flatMap(kind => [{ kind, location: 'target' as const }, { kind, location: 'later-middle' as const }]))('rewind safety refuses incomplete before history without changing bytes or messages: %j', async ({ kind, location }) => {
|
|
const fixture = await incompleteRewindFixture(kind, location)
|
|
const beforeStep = await fileBytesOrMissing(fixture.stepFile)
|
|
const beforeNote = await fs.readFile(fixture.createdFile)
|
|
const beforeMessages = await sessionService.getSessionMessages(fixture.sessionId)
|
|
const beforeTranscript = await fs.readFile(fixture.transcriptPath)
|
|
const request = (mode: 'both' | 'code', dryRun = false) => fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: fixture.targetId, mode, dryRun }),
|
|
})
|
|
const dryResponse = await request('both', true)
|
|
const preview = await dryResponse.json() as { restoreAvailable: boolean }
|
|
// Code-only is an unsupported existing mode, not a newly added action.
|
|
const codeResponse = await request('code')
|
|
expect(codeResponse.status).toBe(400)
|
|
expect(await fileBytesOrMissing(fixture.stepFile)).toEqual(beforeStep)
|
|
expect(await fs.readFile(fixture.createdFile)).toEqual(beforeNote)
|
|
expect(await fs.readFile(fixture.transcriptPath)).toEqual(beforeTranscript)
|
|
expect(await sessionService.getSessionMessages(fixture.sessionId)).toEqual(beforeMessages)
|
|
const execution = await request('both')
|
|
const afterStep = await fileBytesOrMissing(fixture.stepFile)
|
|
const afterNote = await fileBytesOrMissing(fixture.createdFile)
|
|
const afterMessages = await sessionService.getSessionMessages(fixture.sessionId)
|
|
const afterTranscript = await fs.readFile(fixture.transcriptPath)
|
|
console.log('REWIND_SAFETY', JSON.stringify({ kind, location, previewRestoreAvailable: preview.restoreAvailable, status: execution.status,
|
|
stepUnchanged: beforeStep === null ? afterStep === null : beforeStep.equals(afterStep ?? Buffer.alloc(0)),
|
|
noteUnchanged: beforeNote.equals(afterNote ?? Buffer.alloc(0)), messagesBefore: beforeMessages.length, messagesAfter: afterMessages.length,
|
|
transcriptUnchanged: beforeTranscript.equals(afterTranscript),
|
|
}))
|
|
expect(dryResponse.status).toBe(200)
|
|
expect(preview.restoreAvailable).toBe(false)
|
|
expect(execution.status).toBe(400)
|
|
expect(afterStep).toEqual(beforeStep)
|
|
expect(afterNote).toEqual(beforeNote)
|
|
expect(afterMessages).toEqual(beforeMessages)
|
|
expect(afterTranscript).toEqual(beforeTranscript)
|
|
})
|
|
|
|
it.each(['null', 'partial'] as const)('rewind safety permits conversation-only truncation with %s before history while preserving files', async kind => {
|
|
const fixture = await incompleteRewindFixture(kind)
|
|
const beforeStep = await fs.readFile(fixture.stepFile)
|
|
const beforeNote = await fs.readFile(fixture.createdFile)
|
|
const beforeMessages = await sessionService.getSessionMessages(fixture.sessionId)
|
|
const response = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: fixture.targetId, mode: 'conversation' }),
|
|
})
|
|
expect(response.status).toBe(200)
|
|
expect(await response.json()).toMatchObject({ mode: 'conversation', restoreAvailable: false })
|
|
expect(await fs.readFile(fixture.stepFile)).toEqual(beforeStep)
|
|
expect(await fs.readFile(fixture.createdFile)).toEqual(beforeNote)
|
|
const afterMessages = await sessionService.getSessionMessages(fixture.sessionId)
|
|
expect(afterMessages.map(message => message.id)).toEqual(beforeMessages.slice(0, 4).map(message => message.id))
|
|
expect(afterMessages.some(message => message.id === fixture.targetId)).toBe(false)
|
|
})
|
|
|
|
it.each(['legacy-no-after', 'corrupt-after-container', 'missing-after-file', 'binary-before'] as const)('rewind safety restores trustworthy before bytes despite %s', async kind => {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const latest = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.thirdUserId)!
|
|
const beforeBytes = kind === 'binary-before' ? Buffer.from([0x00, 0xff, 0xfe, 0x41, 0x0d, 0x0a]) : Buffer.from("export const STEP = 'v2'\n")
|
|
if (kind === 'binary-before') await fs.writeFile(path.join(tmpDir, 'file-history', fixture.sessionId, latest.trackedFileBackups['src/step.js']!.backupFileName!), beforeBytes)
|
|
if (kind === 'corrupt-after-container' || kind === 'missing-after-file') {
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: latest.messageId, isSnapshotUpdate: true, snapshot: {
|
|
...latest, completedFileBackups: kind === 'corrupt-after-container' ? 'malformed-after' : {
|
|
'src/step.js': { backupFileName: 'absent-after', version: 3, backupTime: new Date() },
|
|
'notes/generated.txt': { backupFileName: 'absent-note-after', version: 1, backupTime: new Date() },
|
|
},
|
|
} }) + '\n')
|
|
}
|
|
const beforeMessages = await sessionService.getSessionMessages(fixture.sessionId)
|
|
const request = (dryRun: boolean) => fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: fixture.thirdUserId, mode: 'both', dryRun }),
|
|
})
|
|
const preview = await request(true)
|
|
expect(preview.status).toBe(200)
|
|
expect(await preview.json()).toMatchObject({ restoreAvailable: true })
|
|
const execution = await request(false)
|
|
expect(execution.status).toBe(200)
|
|
expect(await execution.json()).toMatchObject({ mode: 'both', restoreAvailable: true })
|
|
expect(await fs.readFile(fixture.stepFile)).toEqual(beforeBytes)
|
|
// Explicit null is a trusted "did not exist" marker, unlike a null descriptor.
|
|
expect(latest.trackedFileBackups['notes/generated.txt']!.backupFileName).toBeNull()
|
|
expect(await fileBytesOrMissing(fixture.createdFile)).toBeNull()
|
|
expect((await sessionService.getSessionMessages(fixture.sessionId)).map(message => message.id)).toEqual(beforeMessages.slice(0, 4).map(message => message.id))
|
|
})
|
|
|
|
async function binaryRewindFixture(format: 'legacy' | 'completed', kind: 'same' | 'different' | 'mixed') {
|
|
const fixture = await createThreeTurnCheckpointFixture(crypto.randomUUID())
|
|
const latest = (await sessionService.getSessionFileHistorySnapshots(fixture.sessionId)).find(snapshot => snapshot.messageId === fixture.thirdUserId)!
|
|
const beforeBytes = Buffer.from([0xff])
|
|
const currentBytes = Buffer.from([kind === 'same' ? 0xff : 0xfe])
|
|
await fs.writeFile(path.join(tmpDir, 'file-history', fixture.sessionId, latest.trackedFileBackups['src/step.js']!.backupFileName!), beforeBytes)
|
|
await fs.writeFile(fixture.stepFile, currentBytes)
|
|
const trackedFileBackups: Record<string, unknown> = { 'src/step.js': latest.trackedFileBackups['src/step.js'] }
|
|
const completedFileBackups: Record<string, unknown> = {}
|
|
if (kind === 'mixed') {
|
|
await writeFileHistoryBackup(fixture.sessionId, 'text-before', 'before text\n')
|
|
trackedFileBackups['notes/generated.txt'] = { backupFileName: 'text-before', version: 1, backupTime: new Date() }
|
|
} else await fs.unlink(fixture.createdFile)
|
|
if (format === 'completed') {
|
|
await fs.writeFile(path.join(tmpDir, 'file-history', fixture.sessionId, 'binary-after'), currentBytes)
|
|
completedFileBackups['src/step.js'] = { backupFileName: 'binary-after', version: 3, backupTime: new Date() }
|
|
if (kind === 'mixed') {
|
|
await writeFileHistoryBackup(fixture.sessionId, 'text-after', 'generated third turn\n')
|
|
completedFileBackups['notes/generated.txt'] = { backupFileName: 'text-after', version: 1, backupTime: new Date() }
|
|
}
|
|
}
|
|
const found = await sessionService.findSessionFile(fixture.sessionId)
|
|
await fs.appendFile(found!.filePath, JSON.stringify({ type: 'file-history-snapshot', messageId: latest.messageId, isSnapshotUpdate: true,
|
|
snapshot: { ...latest, trackedFileBackups, ...(format === 'completed' ? { completedFileBackups } : {}) },
|
|
}) + '\n')
|
|
return { ...fixture, beforeBytes, currentBytes, transcriptPath: found!.filePath, binaryBackup: latest.trackedFileBackups['src/step.js']!.backupFileName! }
|
|
}
|
|
|
|
it.each((['legacy', 'completed'] as const).flatMap(format => (['same', 'different', 'mixed'] as const).map(kind => ({ format, kind }))))('binary rewind previews and restores exact bytes despite equal decoded text: %j', async ({ format, kind }) => {
|
|
const fixture = await binaryRewindFixture(format, kind)
|
|
expect(fixture.beforeBytes.toString('utf8')).toBe(fixture.currentBytes.toString('utf8'))
|
|
const beforeMessages = await sessionService.getSessionMessages(fixture.sessionId)
|
|
const beforeTranscript = await fs.readFile(fixture.transcriptPath)
|
|
const beforeNote = await fileBytesOrMissing(fixture.createdFile)
|
|
const request = (dryRun: boolean) => fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: fixture.thirdUserId, mode: 'both', dryRun }),
|
|
})
|
|
const dryResponse = await request(true)
|
|
const preview = await dryResponse.json() as { restoreAvailable: boolean; code: { available: boolean; filesChanged: string[]; insertions: number; deletions: number; reason?: string } }
|
|
const diffResponse = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`)
|
|
const diff = await diffResponse.json() as { state: string; diff?: string }
|
|
expect(await fs.readFile(fixture.stepFile)).toEqual(fixture.currentBytes)
|
|
expect(await fileBytesOrMissing(fixture.createdFile)).toEqual(beforeNote)
|
|
expect(await fs.readFile(fixture.transcriptPath)).toEqual(beforeTranscript)
|
|
expect(await sessionService.getSessionMessages(fixture.sessionId)).toEqual(beforeMessages)
|
|
// Execute before preview assertions so a red run also records the actual byte loss.
|
|
const response = await request(false)
|
|
const execution = await response.json() as typeof preview
|
|
const actual = await fs.readFile(fixture.stepFile)
|
|
console.log('BINARY_REWIND', JSON.stringify({ format, kind, previewPaths: preview.code.filesChanged, status: response.status, beforeHex: fixture.beforeBytes.toString('hex'), actualHex: actual.toString('hex') }))
|
|
const expectedPaths = kind === 'same' ? [] : kind === 'mixed' ? [fixture.stepFile, fixture.createdFile].sort() : [fixture.stepFile]
|
|
expect(dryResponse.status).toBe(200)
|
|
expect(preview.restoreAvailable).toBe(true)
|
|
expect(preview.code.available).toBe(true)
|
|
expect([...preview.code.filesChanged].sort()).toEqual(expectedPaths)
|
|
expect(response.status).toBe(200)
|
|
expect(execution.restoreAvailable).toBe(true)
|
|
expect([...execution.code.filesChanged].sort()).toEqual(expectedPaths)
|
|
if (kind !== 'mixed') expect(preview.code).toMatchObject({ insertions: 0, deletions: 0 })
|
|
if (kind !== 'same') expect(preview.code.reason).toBe('Some changed files cannot be compared as UTF-8 text.')
|
|
expect(diffResponse.status).toBe(200)
|
|
expect(['missing', 'error']).toContain(diff.state)
|
|
expect(diff.diff).toBeUndefined()
|
|
expect(actual).toEqual(fixture.beforeBytes)
|
|
expect(await fileBytesOrMissing(fixture.createdFile)).toEqual(kind === 'mixed' ? Buffer.from('before text\n') : null)
|
|
expect((await sessionService.getSessionMessages(fixture.sessionId)).map(message => message.id)).toEqual(beforeMessages.slice(0, 4).map(message => message.id))
|
|
})
|
|
|
|
it.each((['legacy', 'completed'] as const).flatMap(format => [
|
|
{ format, targetIndex: 0, settles: false, returnsToBefore: false }, { format, targetIndex: 1, settles: false, returnsToBefore: false },
|
|
{ format, targetIndex: 0, settles: true, returnsToBefore: false }, { format, targetIndex: 0, settles: false, returnsToBefore: true },
|
|
]))('binary rewind from an earlier turn covers later changes, creations and deletions: %j', async ({ format, targetIndex, settles, returnsToBefore }) => {
|
|
const sessionId = crypto.randomUUID()
|
|
const workDir = path.join(tmpDir, `binary-rewind-range-${sessionId}`)
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.mkdir(path.join(tmpDir, 'file-history', sessionId), { recursive: true })
|
|
const states: Array<Record<string, Buffer | null>> = [
|
|
{ 'changed.bin': Buffer.from([0xff]), 'deleted.bin': Buffer.from([0xf0]), 'created.bin': null },
|
|
{ 'changed.bin': Buffer.from([0xfe]), 'deleted.bin': Buffer.from([0xf0]), 'created.bin': null },
|
|
{ 'changed.bin': Buffer.from([settles ? 0xfe : 0xfd]), 'deleted.bin': null, 'created.bin': Buffer.from([0xfa]) },
|
|
{ 'changed.bin': Buffer.from([returnsToBefore ? 0xff : settles ? 0xfe : 0xfc]), 'deleted.bin': null, 'created.bin': Buffer.from([0xf9]) },
|
|
]
|
|
const maps: Array<Record<string, unknown>> = []
|
|
for (const [stateIndex, state] of states.entries()) {
|
|
const backups: Record<string, unknown> = {}
|
|
for (const [filePath, bytes] of Object.entries(state)) {
|
|
const backupFileName = bytes === null ? null : `state-${stateIndex}-${filePath}`
|
|
if (backupFileName && bytes) await fs.writeFile(path.join(tmpDir, 'file-history', sessionId, backupFileName), bytes)
|
|
backups[filePath] = { backupFileName, version: stateIndex + 1, backupTime: new Date() }
|
|
}
|
|
maps.push(backups)
|
|
}
|
|
const userIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]
|
|
const entries: Record<string, unknown>[] = [makeSessionMetaEntry(workDir)]
|
|
for (const [index, userId] of userIds.entries()) {
|
|
const entry = makeFileHistorySnapshotEntry(userId, maps[index]!)
|
|
if (format === 'completed') entry.snapshot = { ...(entry.snapshot as Record<string, unknown>), completedFileBackups: maps[index + 1] }
|
|
entries.push(entry, { ...makeUserEntry(`binary turn ${index}`, userId), cwd: workDir, sessionId }, makeAssistantEntry('Done.', userId))
|
|
}
|
|
const transcriptPath = await writeSessionFile('-tmp-binary-rewind-range', sessionId, entries)
|
|
for (const [filePath, bytes] of Object.entries(states[3]!)) if (bytes) await fs.writeFile(path.join(workDir, filePath), bytes)
|
|
const beforeMessages = await sessionService.getSessionMessages(sessionId)
|
|
const beforeTranscript = await fs.readFile(transcriptPath)
|
|
const request = (dryRun: boolean) => fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userIds[targetIndex], mode: 'both', dryRun }),
|
|
})
|
|
const dryResponse = await request(true)
|
|
const preview = await dryResponse.json() as { restoreAvailable: boolean; code: { filesChanged: string[] } }
|
|
for (const [filePath, bytes] of Object.entries(states[3]!)) expect(await fileBytesOrMissing(path.join(workDir, filePath))).toEqual(bytes)
|
|
expect(await fs.readFile(transcriptPath)).toEqual(beforeTranscript)
|
|
const response = await request(false)
|
|
const execution = await response.json() as typeof preview
|
|
const actual: Record<string, string | null> = {}
|
|
for (const filePath of Object.keys(states[0]!)) actual[filePath] = (await fileBytesOrMissing(path.join(workDir, filePath)))?.toString('hex') ?? null
|
|
console.log('BINARY_REWIND_RANGE', JSON.stringify({ format, targetIndex, settles, returnsToBefore, previewPaths: preview.code.filesChanged, status: response.status, actual }))
|
|
const expectedPaths = Object.keys(states[0]!).filter(filePath => !returnsToBefore || filePath !== 'changed.bin').map(filePath => path.join(workDir, filePath)).sort()
|
|
expect(dryResponse.status).toBe(200)
|
|
expect(preview.restoreAvailable).toBe(true)
|
|
expect([...preview.code.filesChanged].sort()).toEqual(expectedPaths)
|
|
expect(response.status).toBe(200)
|
|
expect(execution.restoreAvailable).toBe(true)
|
|
expect([...execution.code.filesChanged].sort()).toEqual(expectedPaths)
|
|
for (const [filePath, bytes] of Object.entries(states[targetIndex]!)) expect(await fileBytesOrMissing(path.join(workDir, filePath))).toEqual(bytes)
|
|
expect((await sessionService.getSessionMessages(sessionId)).map(message => message.id)).toEqual(beforeMessages.slice(0, targetIndex * 2).map(message => message.id))
|
|
})
|
|
|
|
it.each(['legacy', 'completed'] as const)('binary rewind refuses missing before bytes without partially restoring mixed files or trimming messages: %s', async format => {
|
|
const fixture = await binaryRewindFixture(format, 'mixed')
|
|
await fs.unlink(path.join(tmpDir, 'file-history', fixture.sessionId, fixture.binaryBackup))
|
|
const beforeStep = await fs.readFile(fixture.stepFile)
|
|
const beforeNote = await fs.readFile(fixture.createdFile)
|
|
const beforeMessages = await sessionService.getSessionMessages(fixture.sessionId)
|
|
const beforeTranscript = await fs.readFile(fixture.transcriptPath)
|
|
const request = (dryRun: boolean) => fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: fixture.thirdUserId, mode: 'both', dryRun }),
|
|
})
|
|
const dryResponse = await request(true)
|
|
const preview = await dryResponse.json() as { restoreAvailable: boolean }
|
|
const response = await request(false)
|
|
expect(dryResponse.status).toBe(200)
|
|
expect(preview.restoreAvailable).toBe(false)
|
|
expect(response.status).toBe(400)
|
|
expect(await fs.readFile(fixture.stepFile)).toEqual(beforeStep)
|
|
expect(await fs.readFile(fixture.createdFile)).toEqual(beforeNote)
|
|
expect(await sessionService.getSessionMessages(fixture.sessionId)).toEqual(beforeMessages)
|
|
expect(await fs.readFile(fixture.transcriptPath)).toEqual(beforeTranscript)
|
|
})
|
|
|
|
it.each((['legacy', 'completed'] as const).flatMap(format => [false, true].map(hasEarlierEdit => ({ format, hasEarlierEdit }))))('binary rewind scopes successful replacement Write bytes out of later chat and Read carries: %j', async ({ format, hasEarlierEdit }) => {
|
|
const sessionId = crypto.randomUUID()
|
|
const workDir = path.join(tmpDir, `binary-write-carry-${sessionId}`)
|
|
const filePath = path.join(workDir, 'replaced.bin')
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
await fs.mkdir(path.join(tmpDir, 'file-history', sessionId), { recursive: true })
|
|
await fs.writeFile(path.join(tmpDir, 'file-history', sessionId, 'raw-before'), Buffer.from([0xff]))
|
|
const writtenContent = hasEarlierEdit ? 'final known output' : '�'
|
|
await fs.writeFile(path.join(tmpDir, 'file-history', sessionId, 'write-after'), Buffer.from(writtenContent))
|
|
await fs.writeFile(filePath, Buffer.from(writtenContent))
|
|
const userIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]
|
|
const entries: Record<string, unknown>[] = [makeSessionMetaEntry(workDir)]
|
|
for (const [index, userId] of userIds.entries()) {
|
|
const entry = makeFileHistorySnapshotEntry(userId, { 'replaced.bin': { backupFileName: 'raw-before', version: 1, backupTime: new Date() } })
|
|
if (format === 'completed') entry.snapshot = { ...(entry.snapshot as Record<string, unknown>), completedFileBackups: {
|
|
'replaced.bin': { backupFileName: 'write-after', version: 1, backupTime: new Date() },
|
|
} }
|
|
entries.push(entry, { ...makeUserEntry(['replace bytes', 'hello', 'read it'][index]!, userId), cwd: workDir, sessionId })
|
|
if (index === 0 && hasEarlierEdit) {
|
|
entries.push(makeAssistantToolUseEntry([{ id: 'Edit:binary-carry', name: 'Edit', input: { file_path: filePath, old_string: '�', new_string: 'intermediate' } }], userId),
|
|
makeToolResultUserEntry('Edit:binary-carry', 'success', undefined, undefined, sessionId))
|
|
}
|
|
if (index !== 1) {
|
|
const name = index === 0 ? 'Write' : 'Read'
|
|
const toolId = `${name}:binary-carry-${index}`
|
|
entries.push(makeAssistantToolUseEntry([{ id: toolId, name, input: { file_path: filePath, ...(index === 0 ? { content: writtenContent } : {}) } }], userId),
|
|
makeToolResultUserEntry(toolId, 'success', undefined, undefined, sessionId))
|
|
}
|
|
entries.push(makeAssistantEntry('Done.', userId))
|
|
}
|
|
await writeSessionFile('-tmp-binary-write-carry', sessionId, entries)
|
|
const beforeMessages = await sessionService.getSessionMessages(sessionId)
|
|
const request = (dryRun: boolean) => fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ targetUserMessageId: userIds[1], mode: 'both', dryRun }),
|
|
})
|
|
const dryResponse = await request(true)
|
|
const preview = await dryResponse.json() as { restoreAvailable: boolean; code: { filesChanged: string[] } }
|
|
const response = await request(false)
|
|
const execution = await response.json() as typeof preview
|
|
expect(dryResponse.status).toBe(200)
|
|
expect(preview.restoreAvailable).toBe(true)
|
|
expect(preview.code.filesChanged).toEqual([])
|
|
expect(response.status).toBe(200)
|
|
expect(execution.code.filesChanged).toEqual([])
|
|
expect(await fs.readFile(filePath)).toEqual(Buffer.from(writtenContent))
|
|
const secondIndex = beforeMessages.findIndex(message => message.id === userIds[1])
|
|
expect((await sessionService.getSessionMessages(sessionId)).map(message => message.id)).toEqual(beforeMessages.slice(0, secondIndex).map(message => message.id))
|
|
})
|
|
|
|
})
|