mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user