Enable desktop branching from transcript messages

Desktop users need the same branch-from-here workflow that the CLI already exposed, so the branch creation logic now lives in a shared transcript utility and the desktop app routes completed message actions through the server API. The UI hydrates transcript ids after live completions so newly generated turns can be branched immediately without a refresh.

Constraint: Source sessions must remain unmodified while branch sessions inherit the active transcript chain and persistence metadata.
Rejected: Keep a desktop-only branch implementation | it would drift from CLI /branch semantics and duplicate transcript filtering rules
Confidence: high
Scope-risk: moderate
Directive: Do not remove the post-completion transcript hydration without a real-model desktop E2E for just-finished messages
Tested: bun run verify; Chrome Web UI E2E with real gpt-5.5 provider on ports 45678/45679
Not-tested: Provider-specific behavior beyond the configured Sub2API-ChatGPT route
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 18:31:24 +08:00
parent e1c2f54f14
commit 73e915ac6b
16 changed files with 1783 additions and 256 deletions

View File

@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setBaseUrl } from './client'
import { sessionsApi } from './sessions'
describe('sessionsApi', () => {
afterEach(() => {
setBaseUrl('http://127.0.0.1:3456')
vi.restoreAllMocks()
})
it('posts branch requests to the session branch endpoint', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
sessionId: 'branch-session',
title: 'Branch',
workDir: '/workspace/repo',
sourceSessionId: 'source-session',
targetMessageId: 'message-1',
}), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}))
setBaseUrl('http://127.0.0.1:49237')
const result = await sessionsApi.branch('source-session', {
targetMessageId: 'message-1',
title: 'Branch',
})
expect(result.sessionId).toBe('branch-session')
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('http://127.0.0.1:49237/api/sessions/source-session/branch')
expect(init).toMatchObject({
method: 'POST',
body: JSON.stringify({
targetMessageId: 'message-1',
title: 'Branch',
}),
})
})
})

View File

@ -40,6 +40,17 @@ export type CreateSessionRequest = {
workDir?: string
repository?: CreateSessionRepositoryOptions
}
export type BranchSessionRequest = {
targetMessageId: string
title?: string
}
export type BranchSessionResponse = {
sessionId: string
title: string
workDir: string | null
sourceSessionId: string
targetMessageId: string
}
export type RepositoryBranchInfo = {
name: string
current: boolean
@ -314,6 +325,10 @@ export const sessionsApi = {
return api.post<CreateSessionResponse>('/api/sessions', body)
},
branch(sessionId: string, body: BranchSessionRequest) {
return api.post<BranchSessionResponse>(`/api/sessions/${sessionId}/branch`, body)
},
delete(sessionId: string) {
return api.delete<{ ok: true }>(`/api/sessions/${sessionId}`)
},

View File

@ -6,6 +6,7 @@ import { sessionsApi } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import type { UIMessage } from '../../types/chat'
@ -105,6 +106,7 @@ describe('MessageList nested tool calls', () => {
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ pendingSettingsTab: null })
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
@ -2290,6 +2292,107 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull()
})
it('branches from completed transcript-backed chat messages using the original transcript id', async () => {
const branchSession = vi.fn().mockResolvedValue({
sessionId: 'branched-session-1',
title: 'Branched session',
workDir: '/tmp/branched-session-1',
})
const connectToSession = vi.fn()
useSessionStore.setState({
sessions: [{
id: ACTIVE_TAB,
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 2,
projectPath: '/tmp/source-project',
projectRoot: '/tmp/source-project',
workDir: '/tmp/source-project',
workDirExists: true,
}],
branchSession: branchSession as never,
})
useChatStore.setState({
connectToSession: connectToSession as never,
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'local-user-1',
transcriptMessageId: 'transcript-user-1',
type: 'user_text',
content: '从这里开始',
timestamp: 1,
},
{
id: 'local-assistant-1',
transcriptMessageId: 'transcript-assistant-1',
type: 'assistant_text',
content: '这是完成的答复。',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
const branchButtons = screen.getAllByRole('button', { name: 'Branch from here' })
expect(branchButtons).toHaveLength(2)
fireEvent.click(branchButtons[1]!)
await waitFor(() => {
expect(branchSession).toHaveBeenCalledWith(ACTIVE_TAB, 'transcript-assistant-1')
})
expect(connectToSession).toHaveBeenCalledWith('branched-session-1')
expect(useTabStore.getState().activeTabId).toBe('branched-session-1')
const tabs = useTabStore.getState().tabs
expect(tabs[tabs.length - 1]).toMatchObject({
sessionId: 'branched-session-1',
title: 'Branched session',
type: 'session',
})
const toasts = useUIStore.getState().toasts
expect(toasts[toasts.length - 1]).toMatchObject({
type: 'success',
message: 'Created branched session "Branched session".',
})
})
it('hides branch actions while the current session is still running', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'streaming',
streamingText: 'partial',
messages: [
{
id: 'local-user-1',
transcriptMessageId: 'transcript-user-1',
type: 'user_text',
content: '从这里开始',
timestamp: 1,
},
{
id: 'local-assistant-1',
transcriptMessageId: 'transcript-assistant-1',
type: 'assistant_text',
content: '这是完成的答复。',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
expect(screen.queryByRole('button', { name: 'Branch from here' })).toBeNull()
})
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({} as never)

View File

@ -1,8 +1,9 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, GitBranch, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
@ -48,6 +49,11 @@ type RewindTurnTarget = {
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
}
type BranchableMessageTarget = {
uiMessageId: string
transcriptMessageId: string
}
type TurnChangeCardModel = {
target: RewindTurnTarget
checkpoint: SessionTurnCheckpoint
@ -353,6 +359,37 @@ function SelectableChatMessage({
)
}
function MessageBranchAction({
align,
label,
loading,
onClick,
}: {
align: ChatMessageRole
label: string
loading?: boolean
onClick: () => void
}) {
return (
<div
className={`pointer-events-none relative z-10 -mt-3 h-0 w-full opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100 ${
align === 'user' ? 'flex justify-end pr-1' : 'flex justify-start pl-1'
}`}
>
<button
type="button"
onClick={onClick}
disabled={loading}
aria-label={label}
title={label}
className="pointer-events-auto inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-wait disabled:opacity-60"
>
<GitBranch size={13} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
)
}
function appendChildToolCall(
childToolCallsByParent: Map<string, ToolCall[]>,
parentToolUseId: string,
@ -450,6 +487,48 @@ function isTurnResponseMessage(message: UIMessage) {
)
}
function getBranchableMessageTargets(messages: UIMessage[]): Map<string, BranchableMessageTarget> {
const branchableTargets = new Map<string, BranchableMessageTarget>()
let currentTurnCandidates: Array<Extract<UIMessage, { type: 'user_text' | 'assistant_text' }>> = []
let hasResponseForCurrentTurn = false
const markCurrentTurnBranchable = () => {
if (!hasResponseForCurrentTurn) return
for (const candidate of currentTurnCandidates) {
if (!candidate.transcriptMessageId) continue
branchableTargets.set(candidate.id, {
uiMessageId: candidate.id,
transcriptMessageId: candidate.transcriptMessageId,
})
}
}
for (const message of messages) {
if (message.type === 'user_text') {
markCurrentTurnBranchable()
currentTurnCandidates = []
hasResponseForCurrentTurn = false
if (!message.pending && message.transcriptMessageId) {
currentTurnCandidates = [message]
}
continue
}
if (currentTurnCandidates.length === 0) continue
if (isTurnResponseMessage(message)) {
hasResponseForCurrentTurn = true
}
if (message.type === 'assistant_text' && message.transcriptMessageId) {
currentTurnCandidates.push(message)
}
}
markCurrentTurnBranchable()
return branchableTargets
}
export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] {
let userMessageIndex = -1
const completedTurns: RewindTurnTarget[] = []
@ -628,6 +707,7 @@ type MessageListProps = {
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
const MAX_SCROLL_SNAPSHOTS = 100
const EMPTY_MESSAGES: UIMessage[] = []
const CHAT_SCROLL_AREA_CLASS = [
'chat-scroll-area',
'[scrollbar-width:auto]',
@ -685,6 +765,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const sessionState = useChatStore((s) =>
resolvedSessionId ? s.sessions[resolvedSessionId] : undefined,
)
const branchSession = useSessionStore((s) => s.branchSession)
const stopGeneration = useChatStore((s) => s.stopGeneration)
const reloadHistory = useChatStore((s) => s.reloadHistory)
const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill)
@ -692,7 +773,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false,
)
const addToast = useUIStore((s) => s.addToast)
const messages = sessionState?.messages ?? []
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const chatState = sessionState?.chatState ?? 'idle'
const streamingText = sessionState?.streamingText ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
@ -713,9 +794,17 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
const [turnActionErrors, setTurnActionErrors] = useState<Record<string, string>>({})
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
const [branchingMessageId, setBranchingMessageId] = useState<string | null>(null)
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
const branchActionsDisabled =
isMemberSession ||
chatState !== 'idle' ||
streamingText.trim().length > 0 ||
Boolean(activeThinkingId) ||
Boolean(sessionState?.activeToolUseId) ||
Boolean(sessionState?.activeToolName)
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
shouldAutoScrollRef.current = true
@ -842,6 +931,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
() => buildRenderModel(messages),
[messages],
)
const branchableMessageTargets = useMemo(
() => branchActionsDisabled ? new Map<string, BranchableMessageTarget>() : getBranchableMessageTargets(messages),
[branchActionsDisabled, messages],
)
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
const latestCompletedTurnId =
completedTurnTargets.length > 0
@ -982,6 +1075,29 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
t,
])
const handleBranchMessage = useCallback(async (target: BranchableMessageTarget) => {
if (!resolvedSessionId || branchingMessageId) return
setBranchingMessageId(target.uiMessageId)
try {
const result = await branchSession(resolvedSessionId, target.transcriptMessageId)
const title = result.title.trim() || t('sidebar.newSession')
useTabStore.getState().openTab(result.sessionId, title)
useChatStore.getState().connectToSession(result.sessionId)
addToast({
type: 'success',
message: t('chat.branchSuccess', { title }),
})
} catch (error) {
addToast({
type: 'error',
message: t('chat.branchError', { detail: getApiErrorMessage(error) }),
})
} finally {
setBranchingMessageId(null)
}
}, [addToast, branchSession, branchingMessageId, resolvedSessionId, t])
return (
<div className="relative min-h-0 flex-1">
<div
@ -1024,6 +1140,17 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
})()
: null
}
branchAction={
(() => {
const branchTarget = branchableMessageTargets.get(item.message.id)
if (!branchTarget) return undefined
return {
label: t('chat.branchFromHere'),
loading: branchingMessageId === branchTarget.uiMessageId,
onBranch: () => { void handleBranchMessage(branchTarget) },
}
})()
}
/>
)}
@ -1112,12 +1239,18 @@ export const MessageBlock = memo(function MessageBlock({
activeThinkingId,
agentTaskNotifications,
toolResult,
branchAction,
}: {
sessionId?: string | null
message: UIMessage
activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification>
toolResult?: { content: unknown; isError: boolean } | null
branchAction?: {
label: string
loading?: boolean
onBranch: () => void
}
}) {
const t = useTranslation()
@ -1130,10 +1263,20 @@ export const MessageBlock = memo(function MessageBlock({
role="user"
content={message.content}
>
<UserMessage
content={message.content}
attachments={message.attachments}
/>
<div className="group">
<UserMessage
content={message.content}
attachments={message.attachments}
/>
{branchAction ? (
<MessageBranchAction
align="user"
label={branchAction.label}
loading={branchAction.loading}
onClick={branchAction.onBranch}
/>
) : null}
</div>
</SelectableChatMessage>
)
case 'assistant_text':
@ -1144,7 +1287,17 @@ export const MessageBlock = memo(function MessageBlock({
role="assistant"
content={message.content}
>
<AssistantMessage content={message.content} />
<div className="group">
<AssistantMessage content={message.content} />
{branchAction ? (
<MessageBranchAction
align="assistant"
label={branchAction.label}
loading={branchAction.loading}
onClick={branchAction.onBranch}
/>
) : null}
</div>
</SelectableChatMessage>
)
case 'thinking':

View File

@ -951,6 +951,9 @@ export const en = {
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
'chat.contextReferencesOnly': 'Added {count} references',
'chat.addSelectionToChat': 'Add to chat',
'chat.branchFromHere': 'Branch from here',
'chat.branchSuccess': 'Created branched session "{title}".',
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
'chat.userMessageReference': 'User message',
'chat.assistantMessageReference': 'Assistant message',
'chat.slashCommands': 'Slash commands',

View File

@ -953,6 +953,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
'chat.contextReferencesOnly': '已添加 {count} 个引用',
'chat.addSelectionToChat': '添加到对话',
'chat.branchFromHere': '从这里派生',
'chat.branchSuccess': '已创建派生会话“{title}”。',
'chat.branchError': '从该消息派生失败。详情:{detail}',
'chat.userMessageReference': '用户消息',
'chat.assistantMessageReference': 'AI 回复',
'chat.slashCommands': '斜杠命令',

View File

@ -172,6 +172,8 @@ describe('chatStore history mapping', () => {
updateTabTitleMock.mockReset()
updateTabStatusMock.mockReset()
updateSessionTitleMock.mockReset()
vi.mocked(sessionsApi.getMessages).mockReset()
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
sessionStoreSnapshot.sessions = []
cliTaskStoreSnapshot.tasks = []
cliTaskStoreSnapshot.sessionId = null
@ -251,6 +253,44 @@ describe('chatStore history mapping', () => {
])
})
it('preserves transcript message ids on natural-language history messages', () => {
const messages: MessageEntry[] = [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '请从这里继续',
},
{
id: 'transcript-assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
model: 'opus',
content: [
{ type: 'text', text: '这里是答复。' },
{ type: 'tool_use', name: 'Read', id: 'tool-1', input: { file_path: 'src/App.tsx' } },
],
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'transcript-user-1',
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
},
{
type: 'assistant_text',
transcriptMessageId: 'transcript-assistant-1',
},
{
type: 'tool_use',
},
])
})
it('restores /goal local command output from transcript history', () => {
const messages: MessageEntry[] = [
{
@ -458,6 +498,143 @@ describe('chatStore history mapping', () => {
})
})
it('hydrates transcript ids for a just-completed live turn', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: 'live prompt',
},
{
id: 'transcript-assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
content: 'live answer',
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [
{
id: 'live-user',
type: 'user_text',
content: 'live prompt',
timestamp: 1,
},
],
streamingText: 'live answer',
chatState: 'streaming',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
await vi.waitFor(() => {
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
},
{
type: 'assistant_text',
transcriptMessageId: 'transcript-assistant-1',
},
])
})
})
it('retries transcript id hydration after the assistant message is persisted', async () => {
vi.useFakeTimers()
vi.mocked(sessionsApi.getMessages)
.mockResolvedValueOnce({
messages: [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: 'live prompt',
},
],
})
.mockResolvedValueOnce({
messages: [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: 'live prompt',
},
{
id: 'transcript-assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
content: 'live answer',
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [
{
id: 'live-user',
type: 'user_text',
content: 'live prompt',
timestamp: 1,
},
],
streamingText: 'live answer',
chatState: 'streaming',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
await Promise.resolve()
await Promise.resolve()
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
const firstHydrationMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
expect(firstHydrationMessages[0]).toMatchObject({
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
})
expect(firstHydrationMessages[1]).toMatchObject({
type: 'assistant_text',
})
expect(firstHydrationMessages[1]).not.toHaveProperty('transcriptMessageId')
await vi.advanceTimersByTimeAsync(750)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)
const secondHydrationMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
expect(secondHydrationMessages[0]).toMatchObject({
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
})
expect(secondHydrationMessages[1]).toMatchObject({
type: 'assistant_text',
transcriptMessageId: 'transcript-assistant-1',
})
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('merges consecutive assistant text blocks when restoring transcript history', () => {
const messages: MessageEntry[] = [
{

View File

@ -233,15 +233,26 @@ function appendAssistantTextMessage(
content: string,
timestamp: number,
model?: string,
transcriptMessageId?: string,
): UIMessage[] {
if (!content.trim()) return messages
const last = messages[messages.length - 1]
if (last?.type === 'assistant_text') {
const canMergeIntoLast =
last?.type === 'assistant_text' &&
(
transcriptMessageId
? last.transcriptMessageId === transcriptMessageId
: !last.transcriptMessageId
)
if (canMergeIntoLast) {
const merged: UIMessage = {
...last,
content: last.content + content,
...(model ?? last.model ? { model: model ?? last.model } : {}),
...(transcriptMessageId ?? last.transcriptMessageId
? { transcriptMessageId: transcriptMessageId ?? last.transcriptMessageId }
: {}),
}
return [...messages.slice(0, -1), merged]
}
@ -253,6 +264,7 @@ function appendAssistantTextMessage(
type: 'assistant_text',
content,
timestamp,
...(transcriptMessageId ? { transcriptMessageId } : {}),
...(model ? { model } : {}),
},
]
@ -330,6 +342,91 @@ function mergeRestoredTerminalGoalEvents(
: messages
}
function mergeRestoredTranscriptMessageIds(
messages: UIMessage[],
restoredMessages: UIMessage[],
): UIMessage[] {
const restoredCandidates = restoredMessages.filter((
message,
): message is Extract<UIMessage, { type: 'user_text' | 'assistant_text' }> =>
(message.type === 'user_text' || message.type === 'assistant_text') &&
typeof message.transcriptMessageId === 'string' &&
message.transcriptMessageId.length > 0)
if (restoredCandidates.length === 0) return messages
let restoredCursor = 0
let changed = false
const merged = messages.map((message) => {
if (
(message.type !== 'user_text' && message.type !== 'assistant_text') ||
message.transcriptMessageId
) {
return message
}
const matchIndex = restoredCandidates.findIndex((candidate, index) =>
index >= restoredCursor &&
candidate.type === message.type &&
candidate.content.trim() === message.content.trim())
if (matchIndex === -1) return message
restoredCursor = matchIndex + 1
changed = true
return {
...message,
transcriptMessageId: restoredCandidates[matchIndex]!.transcriptMessageId,
}
})
return changed ? merged : messages
}
function mergeRestoredHistoryIntoLiveMessages(
messages: UIMessage[],
restoredMessages: UIMessage[],
): UIMessage[] {
return mergeRestoredTerminalGoalEvents(
mergeRestoredTranscriptMessageIds(messages, restoredMessages),
restoredMessages,
)
}
function needsTranscriptIdHydrationRetry(session: PerSessionState | undefined): boolean {
if (!session || session.chatState !== 'idle') return false
let currentTurnHasHydratedUser = false
for (const message of session.messages) {
if (message.type === 'user_text') {
currentTurnHasHydratedUser = Boolean(message.transcriptMessageId)
continue
}
if (
currentTurnHasHydratedUser &&
message.type === 'assistant_text' &&
!message.transcriptMessageId
) {
return true
}
}
return false
}
function refreshCompletedTranscriptHistory(
get: () => ChatStore,
sessionId: string,
): void {
void get().loadHistory(sessionId).then(() => {
if (!needsTranscriptIdHydrationRetry(get().sessions[sessionId])) return
setTimeout(() => {
if (!needsTranscriptIdHydrationRetry(get().sessions[sessionId])) return
void get().loadHistory(sessionId)
}, 750)
})
}
function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] {
if (!data || typeof data !== 'object') return []
const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths
@ -705,7 +802,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
s.backgroundAgentTasks ?? {},
restoredBackgroundTasks,
),
messages: mergeRestoredTerminalGoalEvents(
messages: mergeRestoredHistoryIntoLiveMessages(
mergeBackgroundTaskMessages(s.messages, restoredBackgroundTasks),
uiMessages,
),
@ -1072,6 +1169,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
target: { type: 'session', sessionId },
})
}
refreshCompletedTranscriptHistory(get, sessionId)
break
}
@ -1666,13 +1764,24 @@ function pushAssistantHistoryText(
content: string,
timestamp: number,
model?: string,
transcriptMessageId?: string,
): void {
if (!content.trim()) return
const last = messages[messages.length - 1]
if (last?.type === 'assistant_text') {
const canMergeIntoLast =
last?.type === 'assistant_text' &&
(
transcriptMessageId
? last.transcriptMessageId === transcriptMessageId
: !last.transcriptMessageId
)
if (canMergeIntoLast) {
last.content += content
if (model && !last.model) last.model = model
if (transcriptMessageId && !last.transcriptMessageId) {
last.transcriptMessageId = transcriptMessageId
}
return
}
@ -1681,6 +1790,7 @@ function pushAssistantHistoryText(
type: 'assistant_text',
content,
timestamp,
...(transcriptMessageId ? { transcriptMessageId } : {}),
...(model ? { model } : {}),
})
}
@ -1881,6 +1991,7 @@ export function mapHistoryMessagesToUiMessages(
id: msg.id || nextId(),
type: 'user_text',
content: teammateContents.join('\n\n'),
...(msg.id ? { transcriptMessageId: msg.id } : {}),
timestamp,
})
continue
@ -1890,6 +2001,7 @@ export function mapHistoryMessagesToUiMessages(
id: msg.id || nextId(),
type: 'user_text',
content: parsed.content,
...(msg.id ? { transcriptMessageId: msg.id } : {}),
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
...(parsed.attachments ? { attachments: parsed.attachments } : {}),
timestamp,
@ -1898,13 +2010,22 @@ export function mapHistoryMessagesToUiMessages(
}
if (msg.type === 'assistant' && typeof msg.content === 'string') {
if (!msg.content.trim()) continue
uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
uiMessages.push({
id: msg.id || nextId(),
type: 'assistant_text',
content: msg.content,
...(msg.id ? { transcriptMessageId: msg.id } : {}),
timestamp,
model: msg.model,
})
continue
}
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
for (const block of msg.content as AssistantHistoryBlock[]) {
if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
else if (block.type === 'text' && block.text) pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model)
else if (block.type === 'text' && block.text) {
pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model, msg.id || undefined)
}
else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
}
continue
@ -1930,6 +2051,7 @@ export function mapHistoryMessagesToUiMessages(
id: msg.id || nextId(),
type: 'user_text',
content: parsed.content,
...(msg.id ? { transcriptMessageId: msg.id } : {}),
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
attachments: allAttachments.length > 0 ? allAttachments : undefined,
timestamp,

View File

@ -1,12 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createMock, listMock } = vi.hoisted(() => ({
const { branchMock, createMock, listMock } = vi.hoisted(() => ({
branchMock: vi.fn(),
createMock: vi.fn(),
listMock: vi.fn(),
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
branch: branchMock,
create: createMock,
list: listMock,
delete: vi.fn(),
@ -33,6 +35,7 @@ function createDeferred<T>() {
describe('sessionStore', () => {
beforeEach(() => {
branchMock.mockReset()
createMock.mockReset()
listMock.mockReset()
useSessionStore.setState({
@ -135,7 +138,7 @@ describe('sessionStore', () => {
it('forwards direct branch switch repository options when creating a session', async () => {
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
listMock.mockResolvedValue({ sessions: [], total: 0 })
listMock.mockImplementation(() => new Promise(() => {}))
await useSessionStore.getState().createSession('/workspace/repo', {
repository: { branch: 'feature/rail', worktree: false },
@ -165,4 +168,100 @@ describe('sessionStore', () => {
expect(useSessionStore.getState().sessions[0]?.workDir)
.toBe('/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678')
})
it('returns the branched session before the background refresh completes', async () => {
branchMock.mockResolvedValue({
sessionId: 'session-branch-1',
title: 'Branch from here',
workDir: '/workspace/repo/branches/session-branch-1',
sourceSessionId: 'session-source-1',
targetMessageId: 'transcript-message-1',
})
listMock.mockImplementation(() => new Promise(() => {}))
useSessionStore.setState({
sessions: [{
id: 'session-source-1',
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 4,
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo',
workDirExists: true,
}],
})
const result = await Promise.race([
useSessionStore.getState().branchSession('session-source-1', 'transcript-message-1'),
delay(100).then(() => 'timed-out'),
])
expect(result).toMatchObject({
sessionId: 'session-branch-1',
title: 'Branch from here',
workDir: '/workspace/repo/branches/session-branch-1',
})
expect(branchMock).toHaveBeenCalledWith('session-source-1', {
targetMessageId: 'transcript-message-1',
})
expect(useSessionStore.getState().activeSessionId).toBe('session-branch-1')
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-branch-1',
title: 'Branch from here',
projectPath: '/workspace/repo',
workDir: '/workspace/repo/branches/session-branch-1',
projectRoot: '/workspace/repo',
workDirExists: true,
})
expect(listMock).toHaveBeenCalledOnce()
})
it('updates an existing optimistic branch row when the branch session id is already present', async () => {
branchMock.mockResolvedValue({
sessionId: 'session-branch-existing',
title: 'Updated branch',
workDir: '/workspace/repo/branches/session-branch-existing',
sourceSessionId: 'session-source-1',
targetMessageId: 'transcript-message-2',
})
listMock.mockImplementation(() => new Promise(() => {}))
useSessionStore.setState({
sessions: [
{
id: 'session-branch-existing',
title: 'Old branch title',
createdAt: '2026-05-18T00:00:00.000Z',
modifiedAt: '2026-05-18T00:00:00.000Z',
messageCount: 3,
projectPath: '/workspace/old',
projectRoot: '/workspace/old',
workDir: '/workspace/old',
workDirExists: true,
},
{
id: 'session-source-1',
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 4,
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo',
workDirExists: true,
},
],
})
await useSessionStore.getState().branchSession('session-source-1', 'transcript-message-2')
expect(useSessionStore.getState().sessions).toHaveLength(2)
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-branch-existing',
title: 'Updated branch',
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo/branches/session-branch-existing',
})
})
})

View File

@ -1,5 +1,10 @@
import { create } from 'zustand'
import { sessionsApi, type BatchDeleteSessionsResponse, type CreateSessionRepositoryOptions } from '../api/sessions'
import {
sessionsApi,
type BatchDeleteSessionsResponse,
type BranchSessionResponse,
type CreateSessionRepositoryOptions,
} from '../api/sessions'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useTabStore } from './tabStore'
import type { SessionListItem } from '../types/session'
@ -9,6 +14,8 @@ type CreateSessionOptions = {
repository?: CreateSessionRepositoryOptions
}
type BranchSessionResult = Pick<BranchSessionResponse, 'sessionId' | 'title' | 'workDir'>
type SessionStore = {
sessions: SessionListItem[]
activeSessionId: string | null
@ -19,6 +26,11 @@ type SessionStore = {
fetchSessions: (project?: string) => Promise<void>
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
branchSession: (
sourceSessionId: string,
targetMessageId: string,
options?: { title?: string },
) => Promise<BranchSessionResult>
deleteSession: (id: string) => Promise<void>
deleteSessions: (ids: string[]) => Promise<BatchDeleteSessionsResponse>
enterBatchMode: () => void
@ -96,6 +108,43 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
return id
},
branchSession: async (sourceSessionId, targetMessageId, options) => {
const result = await sessionsApi.branch(sourceSessionId, {
targetMessageId,
...(options?.title ? { title: options.title } : {}),
})
const sourceSession = get().sessions.find((session) => session.id === sourceSessionId)
const now = new Date().toISOString()
const optimisticSession: SessionListItem = {
id: result.sessionId,
title: result.title || 'New Session',
createdAt: now,
modifiedAt: now,
messageCount: 0,
projectPath: sourceSession?.projectPath ?? '',
projectRoot: sourceSession?.projectRoot ?? sourceSession?.workDir ?? result.workDir ?? null,
workDir: result.workDir ?? sourceSession?.workDir ?? null,
workDirExists: true,
}
set((state) => ({
sessions: state.sessions.some((session) => session.id === result.sessionId)
? state.sessions.map((session) =>
session.id === result.sessionId
? { ...session, ...optimisticSession }
: session)
: [optimisticSession, ...state.sessions],
activeSessionId: result.sessionId,
}))
void get().fetchSessions()
return {
sessionId: result.sessionId,
title: result.title,
workDir: result.workDir,
}
},
deleteSession: async (id: string) => {
await sessionsApi.delete(id)
useSessionRuntimeStore.getState().clearSelection(id)

View File

@ -212,8 +212,8 @@ export type TaskSummaryItem = {
}
export type UIMessage =
| { id: string; type: 'user_text'; content: string; modelContent?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
| { id: string; type: 'user_text'; content: string; modelContent?: string; transcriptMessageId?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'assistant_text'; content: string; transcriptMessageId?: string; timestamp: number; model?: string }
| { id: string; type: 'thinking'; content: string; timestamp: number }
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }

View File

@ -1,223 +1,13 @@
import { randomUUID, type UUID } from 'crypto'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { getOriginalCwd, 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,
LogOption,
SerializedMessage,
TranscriptMessage,
} from '../../types/logs.js'
import { parseJSONL } from '../../utils/json.js'
import type { LogOption } from '../../types/logs.js'
import { getTranscriptPath } from '../../utils/sessionStorage.js'
import {
getProjectDir,
getTranscriptPath,
getTranscriptPathForSession,
isTranscriptMessage,
saveCustomTitle,
searchSessionsByCustomTitle,
} from '../../utils/sessionStorage.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { escapeRegExp } from '../../utils/stringUtils.js'
type TranscriptEntry = TranscriptMessage & {
forkedFrom?: {
sessionId: string
messageUuid: UUID
}
}
/**
* Derive a single-line title base from the first user message.
* Collapses whitespace multiline first messages (pasted stacks, code)
* otherwise flow into the saved title and break the resume hint.
*/
export function deriveFirstPrompt(
firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
): string {
const content = firstUserMessage?.message?.content
if (!content) return 'Branched conversation'
const raw =
typeof content === 'string'
? content
: content.find(
(block): block is { type: 'text'; text: string } =>
block.type === 'text',
)?.text
if (!raw) return 'Branched conversation'
return (
raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
)
}
/**
* 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.
*/
async function createFork(customTitle?: string): Promise<{
sessionId: UUID
title: string | undefined
forkPath: string
serializedMessages: SerializedMessage[]
contentReplacementRecords: ContentReplacementEntry['replacements']
}> {
const forkSessionId = randomUUID() as UUID
const originalSessionId = getSessionId()
const projectDir = getProjectDir(getOriginalCwd())
const forkSessionPath = getTranscriptPathForSession(forkSessionId)
const currentTranscriptPath = getTranscriptPath()
// Ensure project directory exists
await mkdir(projectDir, { recursive: true, mode: 0o700 })
// Read current transcript file
let transcriptContent: Buffer
try {
transcriptContent = await readFile(currentTranscriptPath)
} catch {
throw new Error('No conversation to branch')
}
if (transcriptContent.length === 0) {
throw new Error('No conversation to branch')
}
// Parse all transcript entries (messages + metadata entries like content-replacement)
const entries = parseJSONL<Entry>(transcriptContent)
// Filter to only main conversation messages (exclude sidechains and non-message entries)
const mainConversationEntries = entries.filter(
(entry): entry is TranscriptMessage =>
isTranscriptMessage(entry) && !entry.isSidechain,
)
// Content-replacement entries for the original session. These record which
// tool_result blocks were replaced with previews by the per-message budget.
// Without them in the fork JSONL, `claude -r {forkId}` reconstructs state
// with an empty replacements Map → previously-replaced results are classified
// 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)
if (mainConversationEntries.length === 0) {
throw new Error('No messages to branch')
}
// Build forked entries with new sessionId and preserved metadata
let parentUuid: UUID | null = null
const lines: string[] = []
const serializedMessages: SerializedMessage[] = []
for (const entry of mainConversationEntries) {
// Create forked transcript entry preserving all original metadata
const forkedEntry: TranscriptEntry = {
...entry,
sessionId: forkSessionId,
parentUuid,
isSidechain: false,
forkedFrom: {
sessionId: originalSessionId,
messageUuid: entry.uuid,
},
}
// Build serialized message for LogOption
const serialized: SerializedMessage = {
...entry,
sessionId: forkSessionId,
}
serializedMessages.push(serialized)
lines.push(jsonStringify(forkedEntry))
if (entry.type !== 'progress') {
parentUuid = entry.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.
if (contentReplacementRecords.length > 0) {
const forkedReplacementEntry: ContentReplacementEntry = {
type: 'content-replacement',
sessionId: forkSessionId,
replacements: contentReplacementRecords,
}
lines.push(jsonStringify(forkedReplacementEntry))
}
// Write the fork session file
await writeFile(forkSessionPath, lines.join('\n') + '\n', {
encoding: 'utf8',
mode: 0o600,
})
return {
sessionId: forkSessionId,
title: customTitle,
forkPath: forkSessionPath,
serializedMessages,
contentReplacementRecords,
}
}
/**
* 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.
*/
async function getUniqueForkName(baseName: string): Promise<string> {
const candidateName = `${baseName} (Branch)`
// Check if this exact name already exists
const existingWithExactName = await searchSessionsByCustomTitle(
candidateName,
{ exact: true },
)
if (existingWithExactName.length === 0) {
return candidateName
}
// Name collision - find a unique numbered suffix
// Search for all sessions that start with the base pattern
const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`)
// Extract existing fork numbers to find the next available
const usedNumbers = new Set<number>([1]) // Consider " (Branch)" as number 1
const forkNumberPattern = new RegExp(
`^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
)
for (const session of existingForks) {
const match = session.customTitle?.match(forkNumberPattern)
if (match) {
if (match[1]) {
usedNumbers.add(parseInt(match[1], 10))
} else {
usedNumbers.add(1) // " (Branch)" without number is treated as 1
}
}
}
// Find the next available number
let nextNumber = 2
while (usedNumbers.has(nextNumber)) {
nextNumber++
}
return `${baseName} (Branch ${nextNumber})`
}
createSessionBranch,
deriveFirstPrompt,
} from '../../utils/sessionBranching.js'
export async function call(
onDone: LocalJSXCommandOnDone,
@ -225,7 +15,6 @@ export async function call(
args: string,
): Promise<React.ReactNode> {
const customTitle = args?.trim() || undefined
const originalSessionId = getSessionId()
try {
@ -233,29 +22,23 @@ export async function call(
sessionId,
title,
forkPath,
workDir,
serializedMessages,
contentReplacementRecords,
} = await createFork(customTitle)
// Build LogOption for resume
const now = new Date()
const firstPrompt = deriveFirstPrompt(
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,
} = await createSessionBranch({
sourceSessionId: originalSessionId,
sourceTranscriptPath: getTranscriptPath(),
title: customTitle,
sourceWorkDir: getOriginalCwd(),
})
const now = new Date()
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(
(message): message is Extract<typeof serializedMessages[number], { type: 'user' }> =>
message.type === 'user',
),
)
const forkLog: LogOption = {
date: now.toISOString().split('T')[0]!,
messages: serializedMessages,
@ -267,12 +50,16 @@ export async function call(
messageCount: serializedMessages.length,
isSidechain: false,
sessionId,
customTitle: effectiveTitle,
customTitle: title,
contentReplacements: contentReplacementRecords,
}
// Resume into the fork
const titleInfo = title ? ` "${title}"` : ''
logEvent('tengu_conversation_forked', {
message_count: serializedMessages.length,
has_custom_title: !!customTitle,
})
const titleInfo = customTitle ? ` "${customTitle}"` : ''
const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}`
const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}`
@ -280,7 +67,6 @@ export async function call(
await context.resume(sessionId, forkLog, 'fork')
onDone(successMessage, { display: 'system' })
} else {
// Fallback if resume not available
onDone(
`Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`,
)

View File

@ -14,6 +14,8 @@ import {
} from '../services/repositoryLaunchService.js'
import { conversationService } from '../services/conversationService.js'
import { clearCommandsCache } from '../../commands.js'
import { parseJSONL } from '../../utils/json.js'
import { createSessionBranch } from '../../utils/sessionBranching.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js'
import { clearPluginCache } from '../../utils/plugins/pluginLoader.js'
@ -265,6 +267,33 @@ function makeAssistantToolUseEntry(
}
}
function makeToolResultUserEntry(
toolUseId: string,
content: string,
uuid?: string,
parentUuid?: string,
sessionId = 'test-session',
): Record<string, unknown> {
return {
parentUuid: parentUuid || null,
isSidechain: false,
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content,
}],
},
uuid: uuid || crypto.randomUUID(),
timestamp: '2026-01-01T00:02:30.000Z',
userType: 'external',
cwd: '/tmp/test',
sessionId,
}
}
function makeMetaUserEntry(): Record<string, unknown> {
return {
parentUuid: null,
@ -306,6 +335,17 @@ function makeWorktreeStateEntry(
}
}
function makeContentReplacementEntry(
sessionId: string,
replacements: Array<{ kind: 'tool-result'; toolUseId: string; replacement: string }>,
): Record<string, unknown> {
return {
type: 'content-replacement',
sessionId,
replacements,
}
}
async function writeFileHistoryBackup(
sessionId: string,
backupFileName: string,
@ -1564,6 +1604,154 @@ describe('SessionService', () => {
expect(launchInfo!.workDir).toBe(expectedWorkDir)
expect(launchInfo!.transcriptMessageCount).toBe(2)
})
it('createSessionBranch should preserve branch metadata, copied snapshots, and filtered replacements', async () => {
const sessionId = 'branch-source-session'
const workDir = path.join(tmpDir, 'branch-source')
const worktreePath = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678')
const firstUserId = crypto.randomUUID()
const firstAssistantId = crypto.randomUUID()
const firstToolResultId = crypto.randomUUID()
const laterUserId = crypto.randomUUID()
const laterAssistantId = crypto.randomUUID()
const repository = {
branch: 'feature/rail',
worktree: true,
baseRef: 'feature/rail',
repoRoot: workDir,
}
const sourceProjectDir = sanitizePath(workDir)
const sourcePath = await writeSessionFile(sourceProjectDir, sessionId, [
makeSessionMetaEntry(workDir),
{
type: 'session-meta',
isMeta: true,
workDir,
repository,
timestamp: '2026-01-01T00:00:00.000Z',
},
makeWorktreeStateEntry(sessionId, worktreePath, {
originalCwd: workDir,
}),
makeFileHistorySnapshotEntry(firstUserId, {
'src/step.js': {
backupFileName: 'branch-source-step@v1',
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('branch this conversation', firstUserId),
cwd: workDir,
sessionId,
},
{
...makeAssistantToolUseEntry([
{ id: 'tool-1', name: 'Read', input: { path: 'src/step.js' } },
], firstUserId),
uuid: firstAssistantId,
cwd: workDir,
sessionId,
},
{
...makeToolResultUserEntry('tool-1', 'first tool result', firstToolResultId, firstAssistantId, sessionId),
cwd: workDir,
},
makeContentReplacementEntry(sessionId, [
{ kind: 'tool-result', toolUseId: 'tool-1', replacement: 'preview-1' },
{ kind: 'tool-result', toolUseId: 'tool-2', replacement: 'preview-2' },
]),
makeFileHistorySnapshotEntry(laterUserId, {
'src/step.js': {
backupFileName: 'branch-source-step@v2',
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('later prompt', laterUserId),
parentUuid: firstToolResultId,
cwd: workDir,
sessionId,
},
{
...makeAssistantEntry('later reply', laterUserId),
uuid: laterAssistantId,
cwd: workDir,
sessionId,
},
])
const sourceBefore = await fs.readFile(sourcePath, 'utf-8')
const branch = await createSessionBranch({
sourceSessionId: sessionId,
sourceTranscriptPath: sourcePath,
targetMessageId: firstToolResultId,
title: 'Desktop branch',
sourceWorkDir: workDir,
sourceRepository: repository,
sourceWorktreeSession: {
originalCwd: workDir,
worktreePath,
worktreeName: 'desktop-main-12345678',
worktreeBranch: 'worktree-desktop-main-12345678',
originalBranch: 'main',
sessionId,
},
})
const branchMessages = await service.getSessionMessages(branch.sessionId)
expect(branchMessages.map((message) => message.id)).toEqual([
firstUserId,
firstAssistantId,
firstToolResultId,
])
expect(branch.title).toBe('Desktop branch (Branch)')
const launchInfo = await service.getSessionLaunchInfo(branch.sessionId)
expect(launchInfo).toMatchObject({
workDir,
repository,
worktreeSession: {
originalCwd: workDir,
worktreePath,
},
})
const branchEntries = parseJSONL<Record<string, unknown>>(await fs.readFile(branch.forkPath))
expect(branchEntries.some((entry) => (
entry.type === 'content-replacement' &&
entry.sessionId === branch.sessionId &&
Array.isArray(entry.replacements) &&
entry.replacements.length === 1 &&
(entry.replacements[0] as { toolUseId?: string }).toolUseId === 'tool-1'
))).toBe(true)
expect(branchEntries.some((entry) => (
entry.type === 'file-history-snapshot' &&
typeof (entry.snapshot as { messageId?: string } | undefined)?.messageId === 'string' &&
(entry.snapshot as { messageId?: string }).messageId === firstUserId
))).toBe(true)
expect(branchEntries.some((entry) => (
entry.type === 'file-history-snapshot' &&
typeof (entry.snapshot as { messageId?: string } | undefined)?.messageId === 'string' &&
(entry.snapshot as { messageId?: string }).messageId === laterUserId
))).toBe(false)
expect(branchEntries.some((entry) => (
entry.type === 'custom-title' &&
entry.customTitle === 'Desktop branch (Branch)'
))).toBe(true)
expect(branchEntries.filter((entry) => (
entry.type === 'user' ||
entry.type === 'assistant'
)).every((entry) => (
entry.sessionId === branch.sessionId &&
typeof (entry.forkedFrom as { sessionId?: string } | undefined)?.sessionId === 'string'
))).toBe(true)
const sourceAfter = await fs.readFile(sourcePath, 'utf-8')
expect(sourceAfter).toBe(sourceBefore)
})
})
// ============================================================================
@ -2731,6 +2919,147 @@ describe('Sessions API', () => {
})
})
it('POST /api/sessions/:id/branch should create a branched session up to the target message', async () => {
const sessionId = '11111111-1111-4111-8111-111111111111'
const workDir = path.join(tmpDir, 'branch-api-workdir')
const firstUserId = crypto.randomUUID()
const firstAssistantId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const secondAssistantId = crypto.randomUUID()
await writeSessionFile(sanitizePath(workDir), sessionId, [
{
type: 'session-meta',
isMeta: true,
workDir,
timestamp: '2026-01-01T00:00:00.000Z',
},
{
...makeUserEntry('first prompt', firstUserId),
cwd: workDir,
sessionId,
},
{
...makeAssistantEntry('first reply', firstUserId),
uuid: firstAssistantId,
cwd: workDir,
sessionId,
},
{
...makeUserEntry('second prompt', secondUserId),
parentUuid: firstAssistantId,
cwd: workDir,
sessionId,
},
{
...makeAssistantEntry('second reply', secondUserId),
uuid: secondAssistantId,
cwd: workDir,
sessionId,
},
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetMessageId: firstAssistantId,
title: 'API branch',
}),
})
expect(res.status).toBe(201)
const body = await res.json() as {
sessionId: string
title: string
workDir: string
sourceSessionId: string
targetMessageId: string
}
expect(body).toMatchObject({
title: 'API branch (Branch)',
workDir,
sourceSessionId: sessionId,
targetMessageId: firstAssistantId,
})
const branchMessages = await service.getSessionMessages(body.sessionId)
expect(branchMessages.map((message) => message.id)).toEqual([
firstUserId,
firstAssistantId,
])
})
it('POST /api/sessions/:id/branch should reject sidechain targets', async () => {
const sessionId = '22222222-2222-4222-8222-222222222222'
const rootUserId = crypto.randomUUID()
const rootAssistantId = crypto.randomUUID()
const sidechainId = crypto.randomUUID()
await writeSessionFile('-tmp-api-branch-sidechain', sessionId, [
makeSnapshotEntry(),
{
...makeUserEntry('root prompt', rootUserId),
sessionId,
},
{
...makeAssistantEntry('root reply', rootUserId),
uuid: rootAssistantId,
sessionId,
},
{
...makeUserEntry('side question', sidechainId),
parentUuid: rootAssistantId,
isSidechain: true,
sessionId,
},
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetMessageId: sidechainId }),
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchObject({
error: 'BAD_REQUEST',
})
})
it('POST /api/sessions/:id/branch should validate request bodies and missing sessions', async () => {
const methodNotAllowedRes = await fetch(`${baseUrl}/api/sessions/33333333-3333-4333-8333-333333333333/branch`)
expect(methodNotAllowedRes.status).toBe(405)
const missingTargetRes = await fetch(`${baseUrl}/api/sessions/branch-missing-target/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(missingTargetRes.status).toBe(400)
const invalidJsonRes = await fetch(`${baseUrl}/api/sessions/branch-invalid-json/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{',
})
expect(invalidJsonRes.status).toBe(400)
const invalidTitleRes = await fetch(`${baseUrl}/api/sessions/44444444-4444-4444-8444-444444444444/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetMessageId: 'message-1', title: 123 }),
})
expect(invalidTitleRes.status).toBe(400)
const missingSessionRes = await fetch(`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetMessageId: 'missing-target' }),
})
expect(missingSessionRes.status).toBe(404)
})
it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const firstUserId = crypto.randomUUID()

View File

@ -33,6 +33,10 @@ import {
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
import { SessionStore } from '../../../adapters/common/session-store.js'
import {
createSessionBranch,
SessionBranchingError,
} from '../../utils/sessionBranching.js'
const workspaceService = new WorkspaceService(
async (sessionId) => (
@ -124,6 +128,16 @@ export async function handleSessionsApi(
return await rewindSession(req, sessionId)
}
if (subResource === 'branch') {
if (req.method !== 'POST') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await branchSession(req, sessionId)
}
if (subResource === 'turn-checkpoints') {
if (req.method !== 'GET') {
return Response.json(
@ -710,6 +724,56 @@ async function rewindSession(req: Request, sessionId: string): Promise<Response>
return Response.json(result)
}
async function branchSession(req: Request, sessionId: string): Promise<Response> {
let body: { targetMessageId?: unknown; title?: unknown }
try {
body = (await req.json()) as { targetMessageId?: unknown; title?: unknown }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (typeof body.targetMessageId !== 'string' || body.targetMessageId.trim().length === 0) {
throw ApiError.badRequest('targetMessageId (string) is required in request body')
}
if (body.title !== undefined && typeof body.title !== 'string') {
throw ApiError.badRequest('title must be a string')
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
if (!launchInfo) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
try {
const result = await createSessionBranch({
sourceSessionId: sessionId,
sourceTranscriptPath: launchInfo.filePath,
targetMessageId: body.targetMessageId.trim(),
title: body.title?.trim() || undefined,
sourceWorkDir: launchInfo.workDir,
sourceRepository: launchInfo.repository,
sourceWorktreeSession: launchInfo.worktreeSession,
})
return Response.json({
sessionId: result.sessionId,
title: result.title,
workDir: result.workDir ?? launchInfo.workDir,
sourceSessionId: sessionId,
targetMessageId: body.targetMessageId.trim(),
}, { status: 201 })
} catch (error) {
if (error instanceof SessionBranchingError) {
if (error.code === 'SOURCE_NOT_FOUND') {
throw ApiError.notFound(error.message)
}
throw ApiError.badRequest(error.message)
}
throw error
}
}
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
const checkpoints = await listSessionTurnCheckpoints(sessionId)
return Response.json({ checkpoints })

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'bun:test'
import type { SerializedMessage } from '../../types/logs.js'
import { deriveFirstPrompt, SessionBranchingError } from '../sessionBranching.js'
describe('sessionBranching', () => {
it('derives a compact branch title from multiline user text', () => {
const message = {
type: 'user',
message: {
role: 'user',
content: ' first line\n\nsecond line ',
},
} as SerializedMessage
expect(deriveFirstPrompt(message)).toBe('first line second line')
})
it('keeps branching errors identifiable by code', () => {
const error = new SessionBranchingError(
'INVALID_TARGET',
'targetMessageId must reference a main conversation message',
)
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('SessionBranchingError')
expect(error.code).toBe('INVALID_TARGET')
})
})

View File

@ -0,0 +1,554 @@
import { randomUUID, type UUID } from 'crypto'
import { mkdir, readFile, readdir, writeFile } from 'fs/promises'
import * as path from 'node:path'
import type {
AttributionSnapshotMessage,
PersistedWorktreeSession,
SerializedMessage,
TranscriptMessage,
} from '../types/logs.js'
import type { ContentReplacementRecord } from './toolResultStorage.js'
import { parseJSONL } from './json.js'
import { buildConversationChain, loadTranscriptFile } from './sessionStorage.js'
import { jsonStringify } from './slowOperations.js'
import { escapeRegExp } from './stringUtils.js'
type SessionMetaEntry = {
type: 'session-meta'
isMeta?: boolean
workDir?: string
repository?: unknown
timestamp?: string
[key: string]: unknown
}
type WorktreeStateEntry = {
type: 'worktree-state'
sessionId: string
worktreeSession: PersistedWorktreeSession | null
[key: string]: unknown
}
type ModeEntry = {
type: 'mode'
sessionId: string
mode: string
[key: string]: unknown
}
type PrLinkEntry = {
type: 'pr-link'
sessionId: string
prNumber: number
prUrl: string
prRepository: string
timestamp: string
[key: string]: unknown
}
type FileHistorySnapshotEntry = {
type: 'file-history-snapshot'
messageId: string
snapshot?: {
messageId?: string
trackedFileBackups?: Record<string, unknown>
timestamp?: string
}
[key: string]: unknown
}
type ContentReplacementEntry = {
type: 'content-replacement'
sessionId: string
replacements: ContentReplacementRecord[]
[key: string]: unknown
}
type TranscriptEntry = TranscriptMessage & {
forkedFrom?: {
sessionId: string
messageUuid: UUID
}
}
type RawEntry =
| SessionMetaEntry
| WorktreeStateEntry
| ModeEntry
| PrLinkEntry
| FileHistorySnapshotEntry
| AttributionSnapshotMessage
| ContentReplacementEntry
| Record<string, unknown>
export type SessionBranchResult = {
sessionId: UUID
title: string
forkPath: string
workDir: string | null
serializedMessages: SerializedMessage[]
contentReplacementRecords: ContentReplacementRecord[]
}
export type CreateSessionBranchOptions = {
sourceSessionId: string
sourceTranscriptPath: string
title?: string
targetMessageId?: string
sourceWorkDir?: string | null
sourceRepository?: unknown
sourceWorktreeSession?: PersistedWorktreeSession | null
}
export class SessionBranchingError extends Error {
constructor(
readonly code:
| 'SOURCE_NOT_FOUND'
| 'INVALID_TARGET'
| 'NO_BRANCHABLE_MESSAGES',
message: string,
) {
super(message)
this.name = 'SessionBranchingError'
}
}
function isTranscriptEntry(entry: RawEntry): entry is TranscriptMessage {
return (
typeof entry === 'object' &&
entry !== null &&
(entry.type === 'user' ||
entry.type === 'assistant' ||
entry.type === 'attachment' ||
entry.type === 'system') &&
typeof (entry as TranscriptMessage).uuid === 'string'
)
}
function isSessionMetaEntry(entry: RawEntry): entry is SessionMetaEntry {
return entry.type === 'session-meta'
}
function isWorktreeStateEntry(entry: RawEntry): entry is WorktreeStateEntry {
return entry.type === 'worktree-state'
}
function isModeEntry(entry: RawEntry): entry is ModeEntry {
return entry.type === 'mode'
}
function isPrLinkEntry(entry: RawEntry): entry is PrLinkEntry {
return entry.type === 'pr-link'
}
function isFileHistorySnapshotEntry(entry: RawEntry): entry is FileHistorySnapshotEntry {
return entry.type === 'file-history-snapshot'
}
function isAttributionSnapshotEntry(entry: RawEntry): entry is AttributionSnapshotMessage {
return entry.type === 'attribution-snapshot'
}
function getSnapshotMessageId(entry: FileHistorySnapshotEntry): string | null {
if (typeof entry.snapshot?.messageId === 'string') return entry.snapshot.messageId
if (typeof entry.messageId === 'string') return entry.messageId
return null
}
function getAttributionMessageId(entry: AttributionSnapshotMessage): string | null {
return typeof entry.messageId === 'string' ? entry.messageId : null
}
function findLatestActiveLeaf(
messages: Map<UUID, TranscriptMessage>,
leafUuids: Set<UUID>,
): TranscriptMessage | undefined {
let latest: TranscriptMessage | undefined
let latestTimestamp = -Infinity
const candidates = leafUuids.size > 0
? [...leafUuids]
.map((uuid) => messages.get(uuid))
.filter((message): message is TranscriptMessage => !!message)
: [...messages.values()]
for (const message of candidates) {
if (message.isSidechain) continue
const timestamp = Date.parse(message.timestamp)
if (timestamp >= latestTimestamp) {
latest = message
latestTimestamp = timestamp
}
}
return latest
}
function extractToolResultIds(message: TranscriptMessage): string[] {
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
return []
}
return message.message.content.flatMap((block) => (
block.type === 'tool_result' && typeof block.tool_use_id === 'string'
? [block.tool_use_id]
: []
))
}
async function readRawEntries(filePath: string): Promise<RawEntry[]> {
const content = await readFile(filePath)
return parseJSONL<RawEntry>(content)
}
async function listExistingTitles(projectDirPath: string): Promise<string[]> {
let dirents: import('fs').Dirent[]
try {
dirents = await readdir(projectDirPath, { withFileTypes: true })
} catch {
return []
}
const titles: string[] = []
for (const dirent of dirents) {
if (!dirent.isFile() || !dirent.name.endsWith('.jsonl')) continue
try {
const entries = await readRawEntries(path.join(projectDirPath, dirent.name))
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (
entry?.type === 'custom-title' &&
typeof (entry as Record<string, unknown>).customTitle === 'string'
) {
titles.push((entry as Record<string, unknown>).customTitle as string)
break
}
}
} catch {
continue
}
}
return titles
}
export function deriveFirstPrompt(
firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
): string {
const content = firstUserMessage?.message?.content
if (!content) return 'Branched conversation'
const raw =
typeof content === 'string'
? content
: content.find(
(block): block is { type: 'text'; text: string } =>
block.type === 'text',
)?.text
if (!raw) return 'Branched conversation'
return (
raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
)
}
export async function getUniqueForkName(
baseName: string,
projectDirPath: string,
): Promise<string> {
const existingTitles = new Set(
(await listExistingTitles(projectDirPath))
.map((title) => title.trim())
.filter(Boolean),
)
const candidateName = `${baseName} (Branch)`
if (!existingTitles.has(candidateName)) {
return candidateName
}
const usedNumbers = new Set<number>([1])
const forkNumberPattern = new RegExp(
`^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
)
for (const title of existingTitles) {
const match = title.match(forkNumberPattern)
if (!match) continue
usedNumbers.add(match[1] ? parseInt(match[1], 10) : 1)
}
let nextNumber = 2
while (usedNumbers.has(nextNumber)) {
nextNumber++
}
return `${baseName} (Branch ${nextNumber})`
}
function buildPreservedMetadataEntries(
sourceEntries: RawEntry[],
copiedMessageIds: Set<string>,
forkSessionId: UUID,
): RawEntry[] {
return sourceEntries.flatMap((entry) => {
if (isSessionMetaEntry(entry)) {
return [entry]
}
if (isWorktreeStateEntry(entry) && entry.worktreeSession) {
return [{
...entry,
sessionId: forkSessionId,
worktreeSession: {
...entry.worktreeSession,
sessionId: forkSessionId,
},
}]
}
if (isModeEntry(entry)) {
return [{ ...entry, sessionId: forkSessionId }]
}
if (isPrLinkEntry(entry)) {
return [{ ...entry, sessionId: forkSessionId }]
}
if (isFileHistorySnapshotEntry(entry)) {
const messageId = getSnapshotMessageId(entry)
return messageId && copiedMessageIds.has(messageId) ? [entry] : []
}
if (isAttributionSnapshotEntry(entry)) {
const messageId = getAttributionMessageId(entry)
return messageId && copiedMessageIds.has(messageId) ? [entry] : []
}
return []
})
}
function ensureSyntheticSessionMeta(
preservedMetadataEntries: RawEntry[],
sourceWorkDir: string | null | undefined,
sourceRepository: unknown,
): RawEntry[] {
const hasSessionMeta = preservedMetadataEntries.some(isSessionMetaEntry)
if (hasSessionMeta || (!sourceWorkDir && sourceRepository === undefined)) {
return preservedMetadataEntries
}
return [{
type: 'session-meta',
isMeta: true,
...(sourceWorkDir ? { workDir: sourceWorkDir } : {}),
...(sourceRepository !== undefined ? { repository: sourceRepository } : {}),
timestamp: new Date().toISOString(),
}, ...preservedMetadataEntries]
}
function ensureSyntheticWorktreeState(
preservedMetadataEntries: RawEntry[],
forkSessionId: UUID,
sourceWorktreeSession: PersistedWorktreeSession | null | undefined,
): RawEntry[] {
const hasWorktreeState = preservedMetadataEntries.some(isWorktreeStateEntry)
if (hasWorktreeState || !sourceWorktreeSession) {
return preservedMetadataEntries
}
return [{
type: 'worktree-state',
sessionId: forkSessionId,
worktreeSession: {
...sourceWorktreeSession,
sessionId: forkSessionId,
},
}, ...preservedMetadataEntries]
}
export async function createSessionBranch(
options: CreateSessionBranchOptions,
): Promise<SessionBranchResult> {
const {
sourceSessionId,
sourceTranscriptPath,
title,
targetMessageId,
sourceWorkDir,
sourceRepository,
sourceWorktreeSession,
} = options
const projectDirPath = path.dirname(sourceTranscriptPath)
const sourceEntries = await readRawEntries(sourceTranscriptPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new SessionBranchingError(
'SOURCE_NOT_FOUND',
`Session not found: ${sourceSessionId}`,
)
}
throw error
})
const transcript = await loadTranscriptFile(sourceTranscriptPath, {
keepAllLeaves: true,
})
const activeLeaf = findLatestActiveLeaf(transcript.messages, transcript.leafUuids)
if (!activeLeaf) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No conversation to branch',
)
}
const activeChain = buildConversationChain(transcript.messages, activeLeaf)
.filter((message) => !message.isSidechain)
if (activeChain.length === 0) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No conversation to branch',
)
}
const targetIndex = targetMessageId
? activeChain.findIndex((message) => message.uuid === targetMessageId)
: activeChain.length - 1
if (targetIndex < 0) {
throw new SessionBranchingError(
'INVALID_TARGET',
'targetMessageId must reference a main conversation message in the active chain',
)
}
const copiedMessages = activeChain.slice(0, targetIndex + 1)
if (copiedMessages.length === 0) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No messages to branch',
)
}
const copiedMessageIds = new Set(copiedMessages.map((message) => message.uuid))
const sourceMessageEntriesById = new Map(
sourceEntries
.filter(
(entry): entry is TranscriptMessage =>
isTranscriptEntry(entry) &&
entry.isSidechain !== true &&
copiedMessageIds.has(entry.uuid),
)
.map((entry) => [entry.uuid, entry]),
)
const branchMessageEntries = copiedMessages.flatMap((message) => {
const entry = sourceMessageEntriesById.get(message.uuid)
return entry ? [entry] : []
})
if (branchMessageEntries.length === 0) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No messages to branch',
)
}
const copiedToolResultIds = new Set(
copiedMessages.flatMap((message) => extractToolResultIds(message)),
)
const contentReplacementRecords = (
transcript.contentReplacements.get(sourceSessionId as UUID) ?? []
).filter((record) => copiedToolResultIds.has(record.toolUseId))
const forkSessionId = randomUUID() as UUID
const forkPath = path.join(projectDirPath, `${forkSessionId}.jsonl`)
await mkdir(projectDirPath, { recursive: true, mode: 0o700 })
const serializedMessages: SerializedMessage[] = []
const messageLines: string[] = []
let parentUuid: UUID | null = null
for (const entry of branchMessageEntries) {
const forkedEntry: TranscriptEntry = {
...entry,
sessionId: forkSessionId,
parentUuid,
isSidechain: false,
forkedFrom: {
sessionId: sourceSessionId,
messageUuid: entry.uuid,
},
}
serializedMessages.push({
...entry,
sessionId: forkSessionId,
})
messageLines.push(jsonStringify(forkedEntry))
parentUuid = entry.uuid
}
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(
(message): message is Extract<SerializedMessage, { type: 'user' }> =>
message.type === 'user',
),
)
const effectiveTitle = await getUniqueForkName(title ?? firstPrompt, projectDirPath)
let metadataEntries = buildPreservedMetadataEntries(
sourceEntries,
copiedMessageIds,
forkSessionId,
)
metadataEntries = ensureSyntheticSessionMeta(
metadataEntries,
sourceWorkDir ?? null,
sourceRepository,
)
metadataEntries = ensureSyntheticWorktreeState(
metadataEntries,
forkSessionId,
sourceWorktreeSession,
)
const lines = [
...metadataEntries.map((entry) => jsonStringify(entry)),
...messageLines,
]
if (contentReplacementRecords.length > 0) {
lines.push(jsonStringify({
type: 'content-replacement',
sessionId: forkSessionId,
replacements: contentReplacementRecords,
} satisfies ContentReplacementEntry))
}
lines.push(jsonStringify({
type: 'custom-title',
sessionId: forkSessionId,
customTitle: effectiveTitle,
}))
await writeFile(forkPath, `${lines.join('\n')}\n`, {
encoding: 'utf8',
mode: 0o600,
})
const workDirFromMetadata =
(
metadataEntries.find(isSessionMetaEntry)?.workDir as string | undefined
) ??
sourceWorkDir ??
branchMessageEntries.findLast((entry) => typeof entry.cwd === 'string')?.cwd ??
null
return {
sessionId: forkSessionId,
title: effectiveTitle,
forkPath,
workDir: workDirFromMetadata,
serializedMessages,
contentReplacementRecords,
}
}