feat(session): add branch command for conversation forks (#1808)

* feat(session): add branch command for conversation forks

* test(session): harden branch test cache cleanup

* test(session): isolate branch loader checks

* test(session): guard branch project cache cleanup
This commit is contained in:
Bogdan
2026-06-29 18:06:28 +08:00
committed by GitHub
parent 259c7ec27a
commit 9bf6aa2308
5 changed files with 1383 additions and 77 deletions
+989
View File
@@ -0,0 +1,989 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import type { UUID } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import {
getOriginalCwd,
getSessionId,
getSessionProjectDir,
isSessionPersistenceDisabled,
setOriginalCwd,
setSessionPersistenceDisabled,
switchSession,
} from '../../bootstrap/state.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import * as analyticsNs from '../../services/analytics/index.js'
import type {
LocalJSXCommandContext,
ResumeEntrypoint,
} from '../../types/command.js'
import type {
LogOption,
SessionBranchEntry,
TranscriptMessage,
} from '../../types/logs.js'
import type { Message } from '../../types/message.js'
import {
getClaudeConfigHomeDir,
getClaudeConfigHomeDirOverrideForTesting,
setClaudeConfigHomeDirForTesting,
} from '../../utils/envUtils.js'
import {
getProjectDir,
getTranscriptPath,
loadTranscriptFromFile,
loadTranscriptFile,
recordTranscript,
resetProjectForTesting,
} from '../../utils/sessionStorage.js'
import type { ContentReplacementRecord } from '../../utils/toolResultStorage.js'
import { createAssistantMessage, createUserMessage } from '../../utils/messages.js'
const tempDirs: string[] = []
const ts = '2026-06-28T09:00:00.000Z'
const sourceSessionId = '00000000-0000-4000-8000-000000000111' as UUID
const parentSessionId = '00000000-0000-4000-8000-000000000222' as UUID
const rootSessionId = '00000000-0000-4000-8000-000000000333' as UUID
const realAnalytics = { ...analyticsNs }
let originalNodeEnv: string | undefined
let originalTestPersistence: string | undefined
let originalPersistence: string | undefined
let originalCwd: string
let originalSessionId: string
let originalSessionProjectDir: string | null
let originalPersistenceDisabled: boolean
let originalClaudeConfigHomeDirOverride: string | undefined
let analyticsEvents: Array<{
name: string
metadata: Record<string, unknown>
}> = []
function id(n: number): UUID {
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` as UUID
}
function userMessage(
uuid: UUID,
parentUuid: UUID | null,
content: string,
timestamp = ts,
) {
return {
uuid,
parentUuid,
timestamp,
type: 'user',
isMeta: false,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: sourceSessionId,
version: 'test',
message: {
role: 'user',
content,
},
} as unknown as TranscriptMessage
}
function assistantMessage(
uuid: UUID,
parentUuid: UUID | null,
content: string,
timestamp = ts,
) {
return {
uuid,
parentUuid,
timestamp,
type: 'assistant',
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: sourceSessionId,
version: 'test',
message: {
id: uuid,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: content }],
model: 'test-model',
stop_reason: 'end_turn',
usage: {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
} as unknown as TranscriptMessage
}
function userToolResultMessage(
uuid: UUID,
parentUuid: UUID | null,
toolUseId: string,
timestamp = ts,
) {
return {
...userMessage(uuid, parentUuid, '', timestamp),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: toolUseId,
content: 'large retained output',
is_error: false,
},
],
},
} as unknown as TranscriptMessage
}
function systemMessage(uuid: UUID, parentUuid: UUID | null, content: string) {
return {
uuid,
parentUuid,
timestamp: ts,
type: 'system',
content,
isMeta: false,
isSidechain: false,
cwd: '/tmp/project',
sessionId: sourceSessionId,
version: 'test',
} as unknown as TranscriptMessage
}
function contextMessageFrom(entry: TranscriptMessage): Message {
if (entry.type === 'user') {
const message = createUserMessage({
content: entry.message.content,
uuid: entry.uuid,
timestamp: entry.timestamp,
isMeta: entry.isMeta,
})
return {
...message,
isSidechain: entry.isSidechain,
}
}
if (entry.type === 'assistant') {
const message = createAssistantMessage({
content: entry.message.content,
usage: entry.message.usage,
})
return {
...message,
uuid: entry.uuid,
timestamp: entry.timestamp,
isSidechain: entry.isSidechain,
message: {
...message.message,
...entry.message,
},
}
}
throw new Error(`Unsupported context fixture message type: ${entry.type}`)
}
function sessionBranchEntry(
overrides: Partial<SessionBranchEntry> = {},
): SessionBranchEntry {
return {
type: 'session-branch',
sessionId: sourceSessionId,
parentSessionId,
rootSessionId,
branchedFromSessionId: parentSessionId,
branchName: 'first branch',
branchedAt: ts,
branchedAtMessageId: id(2),
...overrides,
}
}
async function readEntries(path: string): Promise<Record<string, unknown>[]> {
return (await readFile(path, 'utf8'))
.split('\n')
.filter(Boolean)
.map(line => JSON.parse(line) as Record<string, unknown>)
}
async function loadSessionStorageFromRealModule(): Promise<
typeof import('../../utils/sessionStorage.js')
> {
const unique = `${Date.now()}-${Math.random()}`
return import(`../../utils/sessionStorage.ts?${unique}`) as Promise<
typeof import('../../utils/sessionStorage.js')
>
}
async function loadFullLogFromRealModule(log: LogOption): Promise<LogOption> {
const { loadFullLog } = await loadSessionStorageFromRealModule()
return loadFullLog(log)
}
async function loadAllLogsFromSessionFileFromRealModule(
path: string,
): Promise<LogOption[]> {
const { loadAllLogsFromSessionFile } = await loadSessionStorageFromRealModule()
return loadAllLogsFromSessionFile(path)
}
async function setupSourceTranscript(
entries: Record<string, unknown>[],
options: { separateSessionProjectDir?: boolean } = {},
): Promise<string> {
const projectCwd = await mkdtemp(join(tmpdir(), 'openclaude-branch-cwd-'))
const configDir = await mkdtemp(join(tmpdir(), 'openclaude-branch-config-'))
const sessionProjectDir = options.separateSessionProjectDir
? await mkdtemp(join(tmpdir(), 'openclaude-branch-session-dir-'))
: null
tempDirs.push(projectCwd, configDir)
if (sessionProjectDir) tempDirs.push(sessionProjectDir)
setClaudeConfigHomeDirForTesting(configDir)
getClaudeConfigHomeDir.cache?.clear?.()
getProjectDir.cache?.clear?.()
setOriginalCwd(projectCwd)
switchSession(sourceSessionId as never, sessionProjectDir)
resetProjectForTesting()
const sourcePath = getTranscriptPath()
await mkdir(dirname(sourcePath), { recursive: true })
await writeFile(
sourcePath,
`${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`,
'utf8',
)
return sourcePath
}
async function runBranch(
args: string,
messages: Message[] = [],
contextOverrides: Partial<LocalJSXCommandContext> & {
omitResume?: boolean
} = {},
) {
const { call } = await import('./branch.js')
const onDone = mock(() => {})
const {
omitResume,
resume: resumeOverride,
...remainingContextOverrides
} = contextOverrides
const resume = omitResume
? undefined
: mock(
async (
sessionId: UUID,
log: LogOption,
entrypoint: ResumeEntrypoint,
) => {
await resumeOverride?.(sessionId, log, entrypoint)
},
)
const result = await call(
onDone,
{
setMessages: () => {},
options: { tools: [] },
messages,
...remainingContextOverrides,
...(resume ? { resume } : {}),
} as unknown as LocalJSXCommandContext,
args,
)
return { result, onDone, resume: resume as NonNullable<typeof resume> }
}
beforeEach(async () => {
await acquireSharedMutationLock('commands/branch/branch.test.ts')
originalNodeEnv = process.env.NODE_ENV
originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE
originalPersistence = process.env.ENABLE_SESSION_PERSISTENCE
originalCwd = getOriginalCwd()
originalSessionId = getSessionId()
originalSessionProjectDir = getSessionProjectDir()
originalPersistenceDisabled = isSessionPersistenceDisabled()
originalClaudeConfigHomeDirOverride =
getClaudeConfigHomeDirOverrideForTesting()
process.env.NODE_ENV = 'development'
process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true'
process.env.ENABLE_SESSION_PERSISTENCE = 'true'
setSessionPersistenceDisabled(false)
analyticsEvents = []
mock.module('../../services/analytics/index.js', () => ({
...realAnalytics,
logEvent: (name: string, metadata?: Record<string, unknown>) => {
analyticsEvents.push({ name, metadata: metadata ?? {} })
},
}))
})
afterEach(async () => {
try {
if (originalNodeEnv === undefined) delete process.env.NODE_ENV
else process.env.NODE_ENV = originalNodeEnv
if (originalTestPersistence === undefined) {
delete process.env.TEST_ENABLE_SESSION_PERSISTENCE
} else {
process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersistence
}
if (originalPersistence === undefined) {
delete process.env.ENABLE_SESSION_PERSISTENCE
} else {
process.env.ENABLE_SESSION_PERSISTENCE = originalPersistence
}
setSessionPersistenceDisabled(originalPersistenceDisabled)
setClaudeConfigHomeDirForTesting(originalClaudeConfigHomeDirOverride)
getClaudeConfigHomeDir.cache?.clear?.()
getProjectDir.cache?.clear?.()
setOriginalCwd(originalCwd)
switchSession(originalSessionId as never, originalSessionProjectDir)
resetProjectForTesting()
mock.module('../../services/analytics/index.js', () => realAnalytics)
await Promise.all(
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
)
} finally {
releaseSharedMutationLock()
}
})
test('/branch creates a new session, copies messages, keeps the source transcript unchanged, and confirms the switch', async () => {
const keptReplacement: ContentReplacementRecord = {
kind: 'tool-result',
toolUseId: 'tool-use-1',
replacement: '[retained preview]',
}
const droppedReplacement: ContentReplacementRecord = {
kind: 'tool-result',
toolUseId: 'tool-use-2',
replacement: '[dropped preview]',
}
const sourceGoal = {
id: 'goal-1',
condition: 'finish the safe approach',
status: 'active',
createdAt: ts,
updatedAt: ts,
startedAt: ts,
turnCount: 1,
maxTurns: 3,
evaluatorFailures: 0,
} as const
const firstSourceMessage = {
...userMessage(id(1), null, 'try the safe approach'),
forkedFrom: {
sessionId: sourceSessionId,
messageUuid: id(99),
},
}
const sourcePath = await setupSourceTranscript([
firstSourceMessage,
assistantMessage(id(2), id(1), 'safe response'),
userToolResultMessage(id(3), id(2), 'tool-use-1'),
{
type: 'content-replacement',
sessionId: sourceSessionId,
replacements: [keptReplacement, droppedReplacement],
},
{ type: 'tag', sessionId: sourceSessionId, tag: 'research' },
{ type: 'agent-name', sessionId: sourceSessionId, agentName: 'Ada' },
{ type: 'agent-color', sessionId: sourceSessionId, agentColor: 'cyan' },
{
type: 'agent-setting',
sessionId: sourceSessionId,
agentSetting: 'planner',
},
{ type: 'mode', sessionId: sourceSessionId, mode: 'coordinator' },
{ type: 'goal-state', sessionId: sourceSessionId, goal: sourceGoal },
])
const originalTranscript = await readFile(sourcePath, 'utf8')
const { result, onDone, resume } = await runBranch('experiment')
expect(result).toBeNull()
expect(resume).toHaveBeenCalledTimes(1)
const [newSessionId, forkLog, entrypoint] = resume.mock.calls[0] as unknown as [
UUID,
LogOption,
string,
]
expect(newSessionId).not.toBe(sourceSessionId)
expect(entrypoint).toBe('fork')
expect(forkLog.sessionId).toBe(newSessionId)
const branchedAt = forkLog.sessionBranch?.branchedAt
expect(typeof branchedAt).toBe('string')
expect(branchedAt).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
)
expect(forkLog.sessionBranch).toMatchObject({
sessionId: newSessionId,
parentSessionId: sourceSessionId,
rootSessionId: sourceSessionId,
branchedFromSessionId: sourceSessionId,
branchName: 'experiment',
branchedAt,
branchedAtMessageId: id(3),
})
expect(forkLog.messages.map(message => message.sessionId)).toEqual([
newSessionId,
newSessionId,
newSessionId,
])
expect(JSON.stringify(forkLog.messages)).not.toContain('forkedFrom')
expect(forkLog.messages[0]?.type).toBe('user')
expect(
forkLog.messages[0]?.type === 'user'
? forkLog.messages[0].message.content
: undefined,
).toBe('try the safe approach')
expect(await readFile(sourcePath, 'utf8')).toBe(originalTranscript)
const forkPath = forkLog.fullPath
expect(forkPath).toBeString()
expect((await stat(forkPath!)).mode & 0o777).toBe(0o600)
const entries = await readEntries(forkPath!)
const persistedMessages = entries.filter(
entry => entry.type === 'user' || entry.type === 'assistant',
)
expect(persistedMessages.map(entry => entry.sessionId)).toEqual([
newSessionId,
newSessionId,
newSessionId,
])
expect(JSON.stringify(persistedMessages)).not.toContain('forkedFrom')
expect(forkLog.contentReplacements).toEqual([keptReplacement])
expect(forkLog).toMatchObject({
tag: 'research',
agentName: 'Ada',
agentColor: 'cyan',
agentSetting: 'planner',
mode: 'coordinator',
goal: sourceGoal,
})
const replacementEntry = entries.find(
entry => entry.type === 'content-replacement',
) as
| { sessionId?: UUID; replacements?: ContentReplacementRecord[] }
| undefined
expect(replacementEntry).toMatchObject({
sessionId: newSessionId,
replacements: [keptReplacement],
})
expect(JSON.stringify(entries)).not.toContain('tool-use-2')
expect(
entries.find(entry => entry.type === 'custom-title')?.customTitle,
).toBe('experiment')
expect(entries.find(entry => entry.type === 'tag')?.tag).toBe('research')
expect(entries.find(entry => entry.type === 'agent-name')?.agentName).toBe(
'Ada',
)
expect(entries.find(entry => entry.type === 'agent-color')?.agentColor).toBe(
'cyan',
)
expect(
entries.find(entry => entry.type === 'agent-setting')?.agentSetting,
).toBe('planner')
expect(entries.find(entry => entry.type === 'mode')?.mode).toBe(
'coordinator',
)
expect(entries.find(entry => entry.type === 'goal-state')?.goal).toEqual(
sourceGoal,
)
const branchEntry = entries.find(
entry => entry.type === 'session-branch',
) as SessionBranchEntry | undefined
expect(branchEntry).toMatchObject({
sessionId: newSessionId,
parentSessionId: sourceSessionId,
rootSessionId: sourceSessionId,
branchedFromSessionId: sourceSessionId,
branchName: 'experiment',
branchedAt,
branchedAtMessageId: id(3),
})
const loaded = (await loadTranscriptFile(forkPath!)) as {
messages?: Map<UUID, TranscriptMessage>
sessionBranches?: Map<UUID, SessionBranchEntry>
}
expect(JSON.stringify(Array.from(loaded.messages?.values() ?? []))).not.toContain(
'forkedFrom',
)
expect(loaded.sessionBranches?.get(newSessionId)).toMatchObject({
branchName: 'experiment',
rootSessionId: sourceSessionId,
branchedAt,
})
const loadedLog = await loadTranscriptFromFile(forkPath!)
expect(JSON.stringify(loadedLog.messages)).not.toContain('forkedFrom')
expect(loadedLog.sessionBranch).toMatchObject({
branchName: 'experiment',
rootSessionId: sourceSessionId,
branchedAt,
})
const fullLog = await loadFullLogFromRealModule({
...forkLog,
messages: [],
sessionBranch: undefined,
})
expect(fullLog.sessionBranch).toMatchObject({
branchName: 'experiment',
rootSessionId: sourceSessionId,
branchedAt,
})
const allForkLogs = await loadAllLogsFromSessionFileFromRealModule(forkPath!)
expect(allForkLogs).toHaveLength(1)
expect(JSON.stringify(allForkLogs[0]?.messages)).not.toContain('forkedFrom')
expect(allForkLogs[0]?.sessionBranch).toMatchObject({
branchName: 'experiment',
rootSessionId: sourceSessionId,
branchedAt,
})
const forkEvent = analyticsEvents.findLast(
event => event.name === 'tengu_conversation_forked',
)
expect(forkEvent?.metadata).toMatchObject({
message_count: 3,
has_custom_title: true,
})
expect(onDone).toHaveBeenCalledTimes(1)
const [message, options] = onDone.mock.calls[0] as unknown as [
string,
{ display: string },
]
expect(options).toEqual({ display: 'system' })
expect(message).toContain(sourceSessionId)
expect(message).toContain(newSessionId)
expect(message).toContain('experiment')
expect(message).toContain('same working tree')
expect(message).toContain('not filesystem isolation')
expect(message).toContain(`To resume the original: claude -r ${sourceSessionId}`)
})
test('/branch without a name auto-titles the fork and reports non-custom title metadata', async () => {
await setupSourceTranscript([
userMessage(id(1), null, 'explore another approach'),
assistantMessage(id(2), id(1), 'another response'),
])
const { onDone, resume } = await runBranch('')
expect(resume).toHaveBeenCalledTimes(1)
const [newSessionId, forkLog] = resume.mock.calls[0] as unknown as [
UUID,
LogOption,
]
expect(forkLog.customTitle).toBe('explore another approach (Branch)')
expect(forkLog.sessionBranch).toMatchObject({
sessionId: newSessionId,
branchName: 'explore another approach (Branch)',
branchedAtMessageId: id(2),
})
const entries = await readEntries(forkLog.fullPath!)
expect(
entries.find(entry => entry.type === 'custom-title')?.customTitle,
).toBe('explore another approach (Branch)')
expect(
entries.find(entry => entry.type === 'session-branch')?.branchName,
).toBe('explore another approach (Branch)')
const forkEvent = analyticsEvents.findLast(
event => event.name === 'tengu_conversation_forked',
)
expect(forkEvent?.metadata).toMatchObject({
message_count: 2,
has_custom_title: false,
})
const [message] = onDone.mock.calls[0] as unknown as [string]
expect(message).toContain('explore another approach (Branch)')
})
test('/branch reports a clear no-op for an empty session', async () => {
await setupSourceTranscript([])
const { result, onDone, resume } = await runBranch('')
expect(result).toBeNull()
expect(resume).not.toHaveBeenCalled()
expect(onDone).toHaveBeenCalledTimes(1)
const [message] = onDone.mock.calls[0] as unknown as [string]
expect(message).toContain('No conversation messages to branch yet')
expect(message).not.toContain('Failed')
})
test('/branch reports a clear no-op when a transcript has no conversation turns', async () => {
await setupSourceTranscript([systemMessage(id(1), null, 'session notice')])
const { result, onDone, resume } = await runBranch('')
expect(result).toBeNull()
expect(resume).not.toHaveBeenCalled()
expect(onDone).toHaveBeenCalledTimes(1)
const [message] = onDone.mock.calls[0] as unknown as [string]
expect(message).toContain('No conversation messages to branch yet')
expect(message).not.toContain('Failed')
})
test('/branch flushes live messages before copying the fork transcript', async () => {
const sourceUser = userMessage(id(1), null, 'prompt already on disk')
const liveAssistant = assistantMessage(
id(2),
id(1),
'latest live answer',
'2026-06-28T09:01:00.000Z',
)
await setupSourceTranscript([sourceUser])
await recordTranscript([
contextMessageFrom(sourceUser),
contextMessageFrom(liveAssistant),
])
const { resume } = await runBranch('live branch', [
contextMessageFrom(sourceUser),
contextMessageFrom(liveAssistant),
])
expect(resume).toHaveBeenCalledTimes(1)
const [, forkLog] = resume.mock.calls[0] as unknown as [UUID, LogOption]
expect(forkLog.messages.map(message => message.uuid)).toEqual([id(1), id(2)])
expect(JSON.stringify(forkLog.messages)).toContain('latest live answer')
expect(forkLog.sessionBranch).toMatchObject({
branchedAtMessageId: id(2),
})
const entries = await readEntries(forkLog.fullPath!)
expect(JSON.stringify(entries)).toContain('latest live answer')
})
test('/branch keeps a created branch recoverable if switching into it fails', async () => {
await setupSourceTranscript([
userMessage(id(1), null, 'prompt before failed switch'),
assistantMessage(id(2), id(1), 'response before failed switch'),
])
const resume = mock(async () => {
throw new Error('resume rejected')
})
const { result, onDone } = await runBranch('failed switch', [], { resume })
expect(result).toBeNull()
expect(resume).toHaveBeenCalledTimes(1)
expect(onDone).toHaveBeenCalledTimes(1)
const [newSessionId, forkLog] = resume.mock.calls[0] as unknown as [
UUID,
LogOption,
]
expect(await readEntries(forkLog.fullPath!)).toContainEqual(
expect.objectContaining({
type: 'session-branch',
sessionId: newSessionId,
branchName: 'failed switch',
}),
)
const [message, options] = onDone.mock.calls[0] as unknown as [
string,
{ display: string },
]
expect(options).toEqual({ display: 'system' })
expect(message).toContain(
`Branched conversation "failed switch" from ${sourceSessionId} to ${newSessionId}.`,
)
expect(message).toContain(
'Created the branch file, but failed to switch sessions: resume rejected',
)
expect(message).toContain(`Resume this branch with: /resume ${newSessionId}`)
expect(message).toContain('same working tree')
expect(
analyticsEvents.find(event => event.name === 'tengu_conversation_forked'),
).toBeUndefined()
expect(
analyticsEvents.find(
event => event.name === 'tengu_conversation_fork_switch_failed',
)?.metadata,
).toMatchObject({
message_count: 2,
has_custom_title: true,
})
})
test('/branch shows a manual resume hint when automatic switching is unavailable', async () => {
await setupSourceTranscript([
userMessage(id(1), null, 'prompt before manual switch'),
assistantMessage(id(2), id(1), 'response before manual switch'),
])
const { result, onDone } = await runBranch('manual switch', [], {
omitResume: true,
})
expect(result).toBeNull()
expect(onDone).toHaveBeenCalledTimes(1)
const [message] = onDone.mock.calls[0] as unknown as [string]
expect(message).toContain('manual switch')
expect(message).toContain(sourceSessionId)
expect(message).toContain('Automatic session switching is unavailable')
expect(message).toContain('Resume this branch with: /resume ')
expect(message).toContain('same working tree')
expect(message).toContain('not filesystem isolation')
expect(message).toContain(`To resume the original: claude -r ${sourceSessionId}`)
expect(message).not.toContain('You are now in the branch')
})
test('/branch from a branch records immediate parent and original root metadata', async () => {
await setupSourceTranscript([
userMessage(id(1), null, 'first branch prompt'),
assistantMessage(id(2), id(1), 'first branch response'),
sessionBranchEntry(),
])
const { resume } = await runBranch('second branch')
expect(resume).toHaveBeenCalledTimes(1)
const [newSessionId, forkLog] = resume.mock.calls[0] as unknown as [
UUID,
LogOption,
]
const entries = await readEntries(forkLog.fullPath!)
const branchEntry = entries.find(
entry => entry.type === 'session-branch',
) as SessionBranchEntry | undefined
expect(branchEntry).toMatchObject({
sessionId: newSessionId,
parentSessionId: sourceSessionId,
rootSessionId,
branchedFromSessionId: sourceSessionId,
branchName: 'second branch',
branchedAtMessageId: id(2),
})
})
test('/branch migrates legacy per-message fork lineage and strips it from copied messages', async () => {
await setupSourceTranscript([
{
...userMessage(id(1), null, 'legacy branch prompt'),
forkedFrom: {
sessionId: rootSessionId,
messageUuid: id(101),
},
},
{
...assistantMessage(id(2), id(1), 'legacy branch response'),
forkedFrom: {
sessionId: rootSessionId,
messageUuid: id(102),
},
},
])
const { resume } = await runBranch('legacy child')
expect(resume).toHaveBeenCalledTimes(1)
const [newSessionId, forkLog] = resume.mock.calls[0] as unknown as [
UUID,
LogOption,
]
const entries = await readEntries(forkLog.fullPath!)
const persistedMessages = entries.filter(
entry => entry.type === 'user' || entry.type === 'assistant',
)
expect(JSON.stringify(persistedMessages)).not.toContain('forkedFrom')
expect(
entries.find(entry => entry.type === 'session-branch'),
).toMatchObject({
sessionId: newSessionId,
parentSessionId: sourceSessionId,
rootSessionId,
branchedFromSessionId: sourceSessionId,
branchName: 'legacy child',
})
})
test('/branch writes the fork next to the active resumed transcript', async () => {
const sourcePath = await setupSourceTranscript(
[
userMessage(id(1), null, 'resumed session prompt'),
assistantMessage(id(2), id(1), 'resumed response'),
],
{ separateSessionProjectDir: true },
)
const { resume } = await runBranch('resumed branch')
expect(resume).toHaveBeenCalledTimes(1)
const [, forkLog] = resume.mock.calls[0] as unknown as [UUID, LogOption]
expect(dirname(forkLog.fullPath!)).toBe(dirname(sourcePath))
})
test('/branch copies only the active conversation chain', async () => {
const activeRoot = userMessage(
id(1),
null,
'root prompt',
'2026-06-28T09:00:00.000Z',
)
const activeBase = assistantMessage(
id(2),
id(1),
'root response',
'2026-06-28T09:01:00.000Z',
)
const activeUser = userMessage(
id(3),
id(2),
'active branch prompt',
'2026-06-28T09:02:00.000Z',
)
const activeAssistant = assistantMessage(
id(4),
id(3),
`active branch response ${'x'.repeat(6 * 1024 * 1024)}`,
'2026-06-28T09:03:00.000Z',
)
const sidechainAssistant = {
...assistantMessage(
id(7),
id(4),
'sidechain-only response',
'2026-06-28T09:06:00.000Z',
),
isSidechain: true,
} as TranscriptMessage
await setupSourceTranscript([
activeRoot,
activeBase,
activeUser,
activeAssistant,
userMessage(
id(5),
id(2),
'stale alternate prompt',
'2026-06-28T09:04:00.000Z',
),
assistantMessage(
id(6),
id(5),
'stale alternate response',
'2026-06-28T09:05:00.000Z',
),
sidechainAssistant,
])
const { resume } = await runBranch('active chain', [
contextMessageFrom(activeRoot),
contextMessageFrom(activeBase),
contextMessageFrom(activeUser),
contextMessageFrom(activeAssistant),
contextMessageFrom(sidechainAssistant),
])
expect(resume).toHaveBeenCalledTimes(1)
const [, forkLog] = resume.mock.calls[0] as unknown as [UUID, LogOption]
const entries = await readEntries(forkLog.fullPath!)
const persistedMessages = entries.filter(
entry => entry.type === 'user' || entry.type === 'assistant',
)
const branchEntry = entries.find(
entry => entry.type === 'session-branch',
) as SessionBranchEntry | undefined
expect(
persistedMessages.map(entry =>
JSON.stringify(entry.message ?? entry.content ?? ''),
),
).toEqual([
expect.stringContaining('root prompt'),
expect.stringContaining('root response'),
expect.stringContaining('active branch prompt'),
expect.stringContaining('active branch response'),
])
expect(branchEntry).toMatchObject({
branchedAtMessageId: id(4),
})
expect(JSON.stringify(persistedMessages)).not.toContain(
'stale alternate prompt',
)
expect(JSON.stringify(persistedMessages)).not.toContain(
'stale alternate response',
)
expect(JSON.stringify(persistedMessages)).not.toContain(
'sidechain-only response',
)
})
test('/branch fallback leaf selection stays scoped to the active session id', async () => {
const otherSessionId =
'00000000-0000-4000-8000-000000000999' as UUID
await setupSourceTranscript([
userMessage(
id(1),
null,
'active session prompt',
'2026-06-28T09:00:00.000Z',
),
assistantMessage(
id(2),
id(1),
'active session response',
'2026-06-28T09:01:00.000Z',
),
{
...userMessage(
id(3),
null,
'other session prompt',
'2026-06-28T09:02:00.000Z',
),
sessionId: otherSessionId,
},
{
...assistantMessage(
id(4),
id(3),
'other session response',
'2026-06-28T09:03:00.000Z',
),
sessionId: otherSessionId,
},
])
const { resume } = await runBranch('active only')
expect(resume).toHaveBeenCalledTimes(1)
const [, forkLog] = resume.mock.calls[0] as unknown as [UUID, LogOption]
expect(JSON.stringify(forkLog.messages)).toContain('active session response')
expect(JSON.stringify(forkLog.messages)).not.toContain(
'other session response',
)
expect(forkLog.sessionBranch).toMatchObject({
branchedAtMessageId: id(2),
})
})
+338 -75
View File
@@ -1,35 +1,166 @@
import { randomUUID, type UUID } from 'crypto'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
import { mkdir, stat, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import { getSessionId } from '../../bootstrap/state.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import { logEvent } from '../../services/analytics/index.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type {
ContentReplacementEntry,
Entry,
GoalStateEntry,
LogOption,
SerializedMessage,
SessionBranchEntry,
TranscriptMessage,
} from '../../types/logs.js'
import { parseJSONL } from '../../utils/json.js'
import type { Message } from '../../types/message.js'
import {
getProjectDir,
buildConversationChain,
flushSessionStorage,
getTranscriptPath,
getTranscriptPathForSession,
isTranscriptMessage,
loadTranscriptFile,
saveCustomTitle,
searchSessionsByCustomTitle,
} from '../../utils/sessionStorage.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { escapeRegExp } from '../../utils/stringUtils.js'
import { filterContentReplacementsForMessages } from '../../utils/toolResultStorage.js'
type TranscriptEntry = TranscriptMessage & {
const NO_BRANCHABLE_MESSAGES =
'No conversation messages to branch yet. Send a message first, then run /branch [name].'
type CopiedForkMetadata = Pick<
LogOption,
'tag' | 'agentName' | 'agentColor' | 'agentSetting' | 'mode' | 'goal'
>
type LegacyForkedTranscriptMessage = TranscriptMessage & {
forkedFrom?: {
sessionId: string
messageUuid: UUID
sessionId?: unknown
}
}
function findLegacyForkRootSessionId(
entries: TranscriptMessage[],
): UUID | undefined {
const legacyFork = entries.findLast(entry => {
const forkedFrom = (entry as LegacyForkedTranscriptMessage).forkedFrom
return typeof forkedFrom?.sessionId === 'string'
}) as LegacyForkedTranscriptMessage | undefined
return typeof legacyFork?.forkedFrom?.sessionId === 'string'
? (legacyFork.forkedFrom.sessionId as UUID)
: undefined
}
function findLatestBranchLeaf(
messages: Iterable<TranscriptMessage>,
leafUuids: Set<UUID>,
sessionId: UUID,
): TranscriptMessage | undefined {
let latest: TranscriptMessage | undefined
let maxTime = -Infinity
for (const message of messages) {
if (
message.sessionId !== sessionId ||
!leafUuids.has(message.uuid) ||
message.isSidechain ||
(message.type !== 'user' && message.type !== 'assistant')
) {
continue
}
const time = Date.parse(message.timestamp)
if (time > maxTime) {
maxTime = time
latest = message
}
}
return latest
}
function findCurrentBranchLeaf(
transcriptMessages: Map<UUID, TranscriptMessage>,
currentMessages: Message[],
sessionId: UUID,
): TranscriptMessage | undefined {
for (let i = currentMessages.length - 1; i >= 0; i--) {
const message = currentMessages[i]
if (message?.type !== 'user' && message?.type !== 'assistant') continue
const transcriptMessage = transcriptMessages.get(message.uuid)
if (
transcriptMessage &&
transcriptMessage.sessionId === sessionId &&
!transcriptMessage.isSidechain
) {
return transcriptMessage
}
}
return undefined
}
function normalizeMode(mode: string | undefined): LogOption['mode'] {
// Keep this in sync with LogOption['mode']; loadTranscriptFile returns the
// raw persisted string because older transcripts can carry unknown values.
return mode === 'coordinator' || mode === 'normal' ? mode : undefined
}
function buildCopiedMetadataEntries(
sessionId: UUID,
metadata: CopiedForkMetadata,
): Entry[] {
const entries: Entry[] = []
if (metadata.tag) {
entries.push({
type: 'tag',
sessionId,
tag: metadata.tag,
})
}
if (metadata.agentName) {
entries.push({
type: 'agent-name',
sessionId,
agentName: metadata.agentName,
})
}
if (metadata.agentColor) {
entries.push({
type: 'agent-color',
sessionId,
agentColor: metadata.agentColor,
})
}
if (metadata.agentSetting) {
entries.push({
type: 'agent-setting',
sessionId,
agentSetting: metadata.agentSetting,
})
}
if (metadata.mode) {
entries.push({
type: 'mode',
sessionId,
mode: metadata.mode,
})
}
if (metadata.goal !== undefined) {
// Goal progress is part of the copied conversation state. Do not reset it
// here; a branch starts from the same history point as its parent.
entries.push({
type: 'goal-state',
sessionId,
goal: metadata.goal,
} satisfies GoalStateEntry)
}
return entries
}
/**
* Derive a single-line title base from the first user message.
* Collapses whitespace — multiline first messages (pasted stacks, code)
@@ -54,46 +185,79 @@ export function deriveFirstPrompt(
}
/**
* Creates a fork of the current conversation by copying from the transcript file.
* Preserves all original metadata (timestamps, gitBranch, etc.) while updating
* sessionId and adding forkedFrom traceability.
* Creates a branch of the current conversation by copying the active
* transcript chain into a new session file. Preserves original per-message
* metadata (timestamps, gitBranch, etc.) while updating sessionId and writing
* session-level branch metadata.
*/
async function createFork(customTitle?: string): Promise<{
async function createFork(
customTitle: string | undefined,
currentMessages: Message[],
): Promise<{
sessionId: UUID
title: string | undefined
title: string
usedCustomTitle: boolean
forkPath: string
serializedMessages: SerializedMessage[]
contentReplacementRecords: ContentReplacementEntry['replacements']
sourceSessionId: UUID
branchMetadata: SessionBranchEntry
copiedMetadata: CopiedForkMetadata
}> {
const forkSessionId = randomUUID() as UUID
const originalSessionId = getSessionId()
const projectDir = getProjectDir(getOriginalCwd())
const forkSessionPath = getTranscriptPathForSession(forkSessionId)
const originalSessionId = getSessionId() as UUID
const currentTranscriptPath = getTranscriptPath()
const transcriptDir = dirname(currentTranscriptPath)
const forkSessionPath = join(transcriptDir, `${forkSessionId}.jsonl`)
// Ensure project directory exists
await mkdir(projectDir, { recursive: true, mode: 0o700 })
// Ensure the current session directory exists. For resumed sessions this may
// differ from the launch cwd's project directory.
await mkdir(transcriptDir, { recursive: true, mode: 0o700 })
// Read current transcript file
let transcriptContent: Buffer
await flushSessionStorage()
// Avoid a preflight full-file read; loadTranscriptFile below has the
// large-transcript optimized path.
let transcriptSize: number
try {
transcriptContent = await readFile(currentTranscriptPath)
} catch {
throw new Error('No conversation to branch')
transcriptSize = (await stat(currentTranscriptPath)).size
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
) {
throw new Error(NO_BRANCHABLE_MESSAGES)
}
throw error
}
if (transcriptContent.length === 0) {
throw new Error('No conversation to branch')
if (transcriptSize === 0) {
throw new Error(NO_BRANCHABLE_MESSAGES)
}
// Parse all transcript entries (messages + metadata entries like content-replacement)
const entries = parseJSONL<Entry>(transcriptContent)
const {
messages,
leafUuids,
tags,
agentNames,
agentColors,
agentSettings,
modes,
goalStates,
contentReplacements,
sessionBranches,
} = await loadTranscriptFile(currentTranscriptPath, { keepAllLeaves: true })
const leafMessage =
findCurrentBranchLeaf(messages, currentMessages, originalSessionId) ??
findLatestBranchLeaf(messages.values(), leafUuids, originalSessionId)
// Filter to only main conversation messages (exclude sidechains and non-message entries)
const mainConversationEntries = entries.filter(
(entry): entry is TranscriptMessage =>
isTranscriptMessage(entry) && !entry.isSidechain,
)
if (!leafMessage) {
throw new Error(NO_BRANCHABLE_MESSAGES)
}
const mainConversationEntries = buildConversationChain(messages, leafMessage)
// Content-replacement entries for the original session. These record which
// tool_result blocks were replaced with previews by the per-message budget.
@@ -102,16 +266,24 @@ async function createFork(customTitle?: string): Promise<{
// as FROZEN and sent as full content (prompt cache miss + permanent overage).
// sessionId must be rewritten since loadTranscriptFile keys lookup by the
// session's messages' sessionId.
const contentReplacementRecords = entries
.filter(
(entry): entry is ContentReplacementEntry =>
entry.type === 'content-replacement' &&
entry.sessionId === originalSessionId,
)
.flatMap(entry => entry.replacements)
const contentReplacementRecords = filterContentReplacementsForMessages(
mainConversationEntries,
contentReplacements.get(originalSessionId) ?? [],
)
if (mainConversationEntries.length === 0) {
throw new Error('No messages to branch')
throw new Error(NO_BRANCHABLE_MESSAGES)
}
const copiedMetadata: CopiedForkMetadata = {
tag: tags.get(originalSessionId),
agentName: agentNames.get(originalSessionId),
agentColor: agentColors.get(originalSessionId),
agentSetting: agentSettings.get(originalSessionId),
mode: normalizeMode(modes.get(originalSessionId)),
goal: goalStates.has(originalSessionId)
? goalStates.get(originalSessionId)
: undefined,
}
// Build forked entries with new sessionId and preserved metadata
@@ -120,21 +292,25 @@ async function createFork(customTitle?: string): Promise<{
const serializedMessages: SerializedMessage[] = []
for (const entry of mainConversationEntries) {
const { forkedFrom: _legacyForkedFrom, ...entryWithoutLegacyFork } =
entry as LegacyForkedTranscriptMessage
// Create forked transcript entry preserving all original metadata
const forkedEntry: TranscriptEntry = {
...entry,
const forkedEntry: TranscriptMessage = {
...entryWithoutLegacyFork,
sessionId: forkSessionId,
parentUuid,
isSidechain: false,
forkedFrom: {
sessionId: originalSessionId,
messageUuid: entry.uuid,
},
}
// Build serialized message for LogOption
const {
parentUuid: _serializedParentUuid,
isSidechain: _serializedIsSidechain,
...serializedBase
} = entryWithoutLegacyFork
const serialized: SerializedMessage = {
...entry,
...serializedBase,
sessionId: forkSessionId,
}
@@ -145,6 +321,40 @@ async function createFork(customTitle?: string): Promise<{
}
}
for (const entry of buildCopiedMetadataEntries(
forkSessionId,
copiedMetadata,
)) {
lines.push(jsonStringify(entry))
}
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(m => m.type === 'user'),
)
const effectiveTitle =
customTitle?.trim() || (await getUniqueForkName(firstPrompt))
const sourceBranchMetadata = sessionBranches.get(originalSessionId)
const legacyForkRootSessionId =
findLegacyForkRootSessionId(mainConversationEntries)
const branchedAtMessage = mainConversationEntries.findLast(
entry => entry.type === 'user' || entry.type === 'assistant',
)
const branchMetadata: SessionBranchEntry = {
type: 'session-branch',
sessionId: forkSessionId,
parentSessionId: originalSessionId,
rootSessionId:
sourceBranchMetadata?.rootSessionId ??
legacyForkRootSessionId ??
originalSessionId,
branchedFromSessionId: originalSessionId,
branchName: effectiveTitle,
branchedAt: new Date().toISOString(),
...(branchedAtMessage
? { branchedAtMessageId: branchedAtMessage.uuid }
: {}),
}
// Append content-replacement entry (if any) with the fork's sessionId.
// Written as a SINGLE entry (same shape as insertContentReplacement) so
// loadTranscriptFile's content-replacement branch picks it up.
@@ -157,24 +367,38 @@ async function createFork(customTitle?: string): Promise<{
lines.push(jsonStringify(forkedReplacementEntry))
}
lines.push(jsonStringify(branchMetadata))
// Write the fork session file
await writeFile(forkSessionPath, lines.join('\n') + '\n', {
encoding: 'utf8',
mode: 0o600,
})
// Append the title after the raw copy so saveCustomTitle remains the single
// path for title provenance analytics and current-session title cache updates.
await saveCustomTitle(
forkSessionId,
effectiveTitle,
forkSessionPath,
customTitle?.trim() ? 'user' : 'auto',
)
return {
sessionId: forkSessionId,
title: customTitle,
title: effectiveTitle,
usedCustomTitle: !!customTitle?.trim(),
forkPath: forkSessionPath,
serializedMessages,
contentReplacementRecords,
sourceSessionId: originalSessionId,
branchMetadata,
copiedMetadata,
}
}
/**
* Generates a unique fork name by checking for collisions with existing session names.
* If "baseName (Branch)" already exists, tries "baseName (Branch 2)", "baseName (Branch 3)", etc.
* If "baseName (Branch)" already exists, tries "baseName (Branch 2)", etc.
*/
async function getUniqueForkName(baseName: string): Promise<string> {
const candidateName = `${baseName} (Branch)`
@@ -226,16 +450,19 @@ export async function call(
): Promise<React.ReactNode> {
const customTitle = args?.trim() || undefined
const originalSessionId = getSessionId()
try {
const fork = await createFork(customTitle, context.messages ?? [])
const {
sessionId,
title,
usedCustomTitle,
forkPath,
serializedMessages,
contentReplacementRecords,
} = await createFork(customTitle)
sourceSessionId,
branchMetadata,
copiedMetadata,
} = fork
// Build LogOption for resume
const now = new Date()
@@ -243,19 +470,6 @@ export async function call(
serializedMessages.find(m => m.type === 'user'),
)
// Save custom title - use provided title or firstPrompt as default
// This ensures /status and /resume show the same session name
// Always add " (Branch)" suffix to make it clear this is a branched session
// Handle collisions by adding a number suffix (e.g., " (Branch 2)", " (Branch 3)")
const baseName = title ?? firstPrompt
const effectiveTitle = await getUniqueForkName(baseName)
await saveCustomTitle(sessionId, effectiveTitle, forkPath)
logEvent('tengu_conversation_forked', {
message_count: serializedMessages.length,
has_custom_title: !!title,
})
const forkLog: LogOption = {
date: now.toISOString().split('T')[0]!,
messages: serializedMessages,
@@ -267,22 +481,67 @@ export async function call(
messageCount: serializedMessages.length,
isSidechain: false,
sessionId,
customTitle: effectiveTitle,
customTitle: title,
contentReplacements: contentReplacementRecords,
sessionBranch: branchMetadata,
...copiedMetadata,
}
// Resume into the fork
const titleInfo = title ? ` "${title}"` : ''
const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}`
const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}`
const branchConfirmation = `Branched conversation "${title}" from ${sourceSessionId} to ${sessionId}.`
const filesystemCaveat =
'Files remain in the same working tree; this is conversation branching, not filesystem isolation.'
const originalResumeHint = `To resume the original: claude -r ${sourceSessionId}`
if (context.resume) {
await context.resume(sessionId, forkLog, 'fork')
onDone(successMessage, { display: 'system' })
try {
await context.resume(sessionId, forkLog, 'fork')
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error occurred'
logEvent('tengu_conversation_fork_switch_failed', {
message_count: serializedMessages.length,
has_custom_title: usedCustomTitle,
})
onDone(
[
branchConfirmation,
`Created the branch file, but failed to switch sessions: ${message}`,
filesystemCaveat,
`Resume this branch with: /resume ${sessionId}`,
originalResumeHint,
].join('\n'),
{ display: 'system' },
)
return null
}
logEvent('tengu_conversation_forked', {
message_count: serializedMessages.length,
has_custom_title: usedCustomTitle,
})
onDone(
[
branchConfirmation,
'You are now in the branch.',
filesystemCaveat,
originalResumeHint,
].join('\n'),
{ display: 'system' },
)
} else {
// Fallback if resume not available
logEvent('tengu_conversation_forked', {
message_count: serializedMessages.length,
has_custom_title: usedCustomTitle,
})
onDone(
`Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`,
[
branchConfirmation,
'Automatic session switching is unavailable in this context.',
filesystemCaveat,
`Resume this branch with: /resume ${sessionId}`,
originalResumeHint,
].join('\n'),
)
}
@@ -290,7 +549,11 @@ export async function call(
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error occurred'
onDone(`Failed to branch conversation: ${message}`)
onDone(
message === NO_BRANCHABLE_MESSAGES
? message
: `Failed to branch conversation: ${message}`,
)
return null
}
}
+21
View File
@@ -75,6 +75,7 @@ export type LogOption = {
worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered)
contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction
goal?: GoalState | null // Last session goal state, if any
sessionBranch?: SessionBranchEntry // Conversation-branch lineage metadata, if this session is a branch
}
export type SummaryMessage = {
@@ -216,6 +217,25 @@ export type GoalStateEntry = {
goal: GoalState | null
}
export type SessionBranchEntry = {
type: 'session-branch'
sessionId: UUID
/**
* Immediate conversation-lineage parent. Branches of branches point to the
* source branch here, while rootSessionId keeps the first ancestor.
*/
parentSessionId: UUID
rootSessionId: UUID
/**
* Session whose current transcript tail was copied for this branch. Today it
* matches parentSessionId; future rewind/checkpoint branches may diverge.
*/
branchedFromSessionId: UUID
branchName?: string
branchedAt: string
branchedAtMessageId?: UUID
}
export type FileHistorySnapshotMessage = {
type: 'file-history-snapshot'
messageId: UUID
@@ -351,6 +371,7 @@ export type Entry =
| WorktreeStateEntry
| ContentReplacementEntry
| GoalStateEntry
| SessionBranchEntry
| ContextCollapseCommitEntry
| ContextCollapseSnapshotEntry
+4
View File
@@ -198,6 +198,10 @@ export function setClaudeConfigHomeDirForTesting(
claudeConfigHomeDirOverride = configDir?.normalize('NFC')
}
export function getClaudeConfigHomeDirOverrideForTesting(): string | undefined {
return claudeConfigHomeDirOverride
}
// Memoized: 150+ callers, many on hot paths. Keyed off both override env
// vars so tests that change either get a fresh value without explicit
// cache.clear.
+31 -2
View File
@@ -51,6 +51,7 @@ import {
type LogOption,
type PersistedWorktreeSession,
type SerializedMessage,
type SessionBranchEntry,
sortLogs,
type TranscriptMessage,
} from '../types/logs.js'
@@ -1290,6 +1291,8 @@ class Project {
void this.enqueueWrite(targetFile, entry)
} else if (entry.type === 'goal-state') {
await this.appendToFile(sessionFile, jsonStringify(entry) + '\n')
} else if (entry.type === 'session-branch') {
void this.enqueueWrite(sessionFile, entry)
} else if (entry.type === 'marble-origami-commit') {
// Always append. Commit order matters for restore (later commits may
// reference earlier commits' summary messages), so these must be
@@ -1303,7 +1306,7 @@ class Project {
if (entry.type === 'queue-operation') {
// Queue operations are always appended to the session file
void this.enqueueWrite(sessionFile, entry)
} else {
} else if (isTranscriptMessage(entry)) {
// At this point, entry must be a TranscriptMessage (user/assistant/attachment/system)
// All other entry types have been handled above
const isAgentSidechain =
@@ -1345,6 +1348,13 @@ class Project {
}
}
}
} else {
const entryType = (entry as { type?: string }).type ?? 'unknown'
// Exhaustiveness guard: entry is never here when every Entry variant
// has an append policy above.
const _exhaustive: never = entry
void _exhaustive
throw new Error(`Unhandled session storage entry type: ${entryType}`)
}
}
}
@@ -2653,6 +2663,7 @@ export async function loadTranscriptFromFile(
contentReplacements,
worktreeStates,
goalStates,
sessionBranches,
} = await loadTranscriptFile(filePath)
if (messages.size === 0) {
@@ -2699,6 +2710,7 @@ export async function loadTranscriptFromFile(
? worktreeStates.get(sessionId)
: undefined,
goal: goalStates.get(sessionId),
sessionBranch: sessionBranches.get(sessionId),
}
}
@@ -3329,6 +3341,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
attributionSnapshots,
contentReplacements,
goalStates,
sessionBranches,
contextCollapseCommits,
contextCollapseSnapshot,
leafUuids,
@@ -3393,6 +3406,9 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
? (contentReplacements.get(sessionId) ?? [])
: log.contentReplacements,
goal: sessionId ? goalStates.get(sessionId) : log.goal,
sessionBranch: sessionId
? (sessionBranches.get(sessionId) ?? log.sessionBranch)
: log.sessionBranch,
// Filter to the resumed session's entries. loadTranscriptFile reads
// the file sequentially so the array is already in commit order;
// filter preserves that.
@@ -3476,9 +3492,10 @@ const METADATA_TYPE_MARKERS = [
'"type":"worktree-state"',
'"type":"pr-link"',
'"type":"goal-state"',
'"type":"session-branch"',
]
const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
// Longest marker is 22 bytes; +1 for leading `{` = 23.
// Longest marker plus the leading `{` fits within this bound.
const METADATA_PREFIX_BOUND = 25
// null = carry spans whole chunk. Skips concat when carry provably isn't
@@ -3846,6 +3863,7 @@ export async function loadTranscriptFile(
contentReplacements: Map<UUID, ContentReplacementRecord[]>
agentContentReplacements: Map<AgentId, ContentReplacementRecord[]>
goalStates: Map<UUID, GoalStateEntry['goal']>
sessionBranches: Map<UUID, SessionBranchEntry>
contextCollapseCommits: ContextCollapseCommitEntry[]
contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
leafUuids: Set<UUID>
@@ -3870,6 +3888,7 @@ export async function loadTranscriptFile(
ContentReplacementRecord[]
>()
const goalStates = new Map<UUID, GoalStateEntry['goal']>()
const sessionBranches = new Map<UUID, SessionBranchEntry>()
// Array, not Map — commit order matters (nested collapses).
const contextCollapseCommits: ContextCollapseCommitEntry[] = []
// Last-wins — later entries supersede.
@@ -3971,6 +3990,8 @@ export async function loadTranscriptFile(
prRepositories.set(entry.sessionId, entry.prRepository)
} else if (entry.type === 'goal-state' && entry.sessionId) {
goalStates.set(entry.sessionId, entry.goal)
} else if (entry.type === 'session-branch' && entry.sessionId) {
sessionBranches.set(entry.sessionId, entry)
}
})
}
@@ -4037,6 +4058,8 @@ export async function loadTranscriptFile(
prRepositories.set(entry.sessionId, entry.prRepository)
} else if (entry.type === 'goal-state' && entry.sessionId) {
goalStates.set(entry.sessionId, entry.goal)
} else if (entry.type === 'session-branch' && entry.sessionId) {
sessionBranches.set(entry.sessionId, entry)
} else if (entry.type === 'file-history-snapshot') {
fileHistorySnapshots.set(entry.messageId, entry)
} else if (entry.type === 'attribution-snapshot') {
@@ -4174,6 +4197,7 @@ export async function loadTranscriptFile(
contentReplacements,
agentContentReplacements,
goalStates,
sessionBranches,
contextCollapseCommits,
contextCollapseSnapshot,
leafUuids,
@@ -4194,6 +4218,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
contentReplacements: Map<UUID, ContentReplacementRecord[]>
goalStates: Map<UUID, GoalStateEntry['goal']>
sessionBranches: Map<UUID, SessionBranchEntry>
contextCollapseCommits: ContextCollapseCommitEntry[]
contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
}> {
@@ -4250,6 +4275,7 @@ export async function getLastSessionLog(
attributionSnapshots,
contentReplacements,
goalStates,
sessionBranches,
contextCollapseCommits,
contextCollapseSnapshot,
} = await loadSessionFile(sessionId)
@@ -4292,6 +4318,7 @@ export async function getLastSessionLog(
),
worktreeSession: worktreeStates.get(sessionId),
goal: goalStates.get(sessionId),
sessionBranch: sessionBranches.get(sessionId),
contextCollapseCommits: contextCollapseCommits.filter(
e => e.sessionId === sessionId,
),
@@ -4986,6 +5013,7 @@ export async function loadAllLogsFromSessionFile(
attributionSnapshots,
contentReplacements,
goalStates,
sessionBranches,
leafUuids,
} = await loadTranscriptFile(sessionFile, { keepAllLeaves: true })
@@ -5060,6 +5088,7 @@ export async function loadAllLogsFromSessionFile(
),
contentReplacements: contentReplacements.get(sessionId) ?? [],
goal: goalStates.get(sessionId),
sessionBranch: sessionBranches.get(sessionId),
})
}