Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 104 additions & 4 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1112,19 +1112,30 @@ function BranchHarness({
navigate = vi.fn(),
onCurrentReady,
onReady,
requestGateway
onRefs,
requestGateway,
selectedStoredSessionId = null
}: {
activeSessionId?: string | null
navigate?: ReturnType<typeof vi.fn>
onCurrentReady?: (branchCurrentSession: (messageId?: string) => Promise<boolean>) => void
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
onRefs?: (refs: {
activeSessionIdRef: MutableRefObject<string | null>
selectedStoredSessionIdRef: MutableRefObject<string | null>
}) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionId?: string | null
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const activeSessionIdRef = ref<string | null>(activeSessionId)
const selectedStoredSessionIdRef = ref<string | null>(selectedStoredSessionId)

onRefs?.({ activeSessionIdRef, selectedStoredSessionIdRef })

const actions = useSessionActions({
activeSessionId,
activeSessionIdRef: ref<string | null>(activeSessionId),
activeSessionIdRef,
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
Expand All @@ -1134,8 +1145,8 @@ function BranchHarness({
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
Expand Down Expand Up @@ -1276,6 +1287,95 @@ describe('branchStoredSession desktop source tagging', () => {
expect(branchParams).toEqual({ session_id: 'live-parent', count: 2 })
})

it('hydrates the complete persisted display transcript before branching a compacted live chat', async () => {
let branchParams: Record<string, unknown> | undefined

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.branch') {
branchParams = params

return {
session_id: 'branch-runtime',
stored_session_id: 'branch-stored',
title: 'Branch',
message_count: 4,
messages: [],
info: {}
} as never
}

return {} as never
})

setSessions([storedSession({ id: 'stored-parent', message_count: 4 })])
setMessages([
{ id: 'summary', role: 'assistant', parts: [{ type: 'text', text: 'compact summary' }] },
{ id: 'tail-user', role: 'user', parts: [{ type: 'text', text: 'second question' }] },
{ id: 'tail-assistant', role: 'assistant', parts: [{ type: 'text', text: 'second answer' }] }
])
vi.mocked(getAllSessionMessages).mockResolvedValue({
messages: [
{ content: 'first question', role: 'user', timestamp: 1 },
{ content: 'first answer', role: 'assistant', timestamp: 2 },
{ content: 'second question', role: 'user', timestamp: 3 },
{ content: 'second answer', role: 'assistant', timestamp: 4 }
],
session_id: 'stored-parent'
} as never)

let branchCurrentSession: ((messageId?: string) => Promise<boolean>) | null = null
render(
<BranchHarness
activeSessionId="live-parent"
onCurrentReady={branch => (branchCurrentSession = branch)}
onReady={() => undefined}
requestGateway={requestGateway}
selectedStoredSessionId="stored-parent"
/>
)
await waitFor(() => expect(branchCurrentSession).not.toBeNull())

await expect(branchCurrentSession!()).resolves.toBe(true)

expect(getAllSessionMessages).toHaveBeenCalledWith('stored-parent', undefined)
expect(branchParams).toEqual({ session_id: 'live-parent' })
})

it('aborts if the active runtime changes while the branch transcript is hydrating', async () => {
let refs: {
activeSessionIdRef: MutableRefObject<string | null>
selectedStoredSessionIdRef: MutableRefObject<string | null>
} | null = null

const requestGateway = vi.fn(async () => ({}) as never)

setMessages([{ id: 'q1', role: 'user', parts: [{ type: 'text', text: 'question' }] }])
vi.mocked(getAllSessionMessages).mockImplementation(async () => {
refs!.activeSessionIdRef.current = 'live-other'

return {
messages: [{ content: 'question', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never
})

let branchCurrentSession: ((messageId?: string) => Promise<boolean>) | null = null
render(
<BranchHarness
activeSessionId="live-parent"
onCurrentReady={branch => (branchCurrentSession = branch)}
onReady={() => undefined}
onRefs={value => (refs = value)}
requestGateway={requestGateway}
selectedStoredSessionId="stored-parent"
/>
)
await waitFor(() => expect(branchCurrentSession).not.toBeNull())

await expect(branchCurrentSession!()).resolves.toBe(false)
expect(requestGateway).not.toHaveBeenCalledWith('session.branch', expect.anything())
})

// #67603: right-clicking a session outside the paginated sidebar window is a
// cache miss. Resolve its owning profile (cache → active → cross-profile) and
// swap to it before reading the transcript / creating the branch, so the fork
Expand Down
76 changes: 59 additions & 17 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
resolveResumedBusy,
resolveSessionProfile,
resolveStoredSession,
selectBranchMessages,
sessionMatchesStoredId,
sessionShouldHaveTranscript,
toBranchMessages,
Expand Down Expand Up @@ -1335,7 +1336,8 @@ export function useSessionActions({
sourceSessionId: null | string,
parentStoredId: null | string,
cwd?: string,
profile?: null | string
profile?: null | string,
branchCount?: number
): Promise<boolean> => {
creatingSessionRef.current = true

Expand All @@ -1353,7 +1355,7 @@ export function useSessionActions({
const branched = sourceSessionId
? await requestGateway<SessionCreateResponse>('session.branch', {
session_id: sourceSessionId,
count: branchMessages.length
...(branchCount !== undefined ? { count: branchCount } : {})
})
: await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
Expand All @@ -1364,8 +1366,12 @@ export function useSessionActions({
...(parentStoredId && { parent_session_id: parentStoredId })
})

const responseBranchMessages =
sourceSessionId && branched.messages?.length ? toBranchMessages(toChatMessages(branched.messages)) : []

const effectiveBranchMessages = responseBranchMessages.length ? responseBranchMessages : branchMessages
const routedSessionId = branched.stored_session_id ?? branched.session_id
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
const preview = effectiveBranchMessages.map(({ content }) => content).find(Boolean) ?? null
// Draft until submit: nest under the parent at the parent's recency so it
// doesn't bubble to the top until a real message lands (backend persists
// + auto-names it then). The selected row survives refreshes (sessionsToKeep).
Expand All @@ -1390,7 +1396,7 @@ export function useSessionActions({
branched.session_id,
state => ({
...state,
messages: branchMessages.map(({ source }) => source),
messages: effectiveBranchMessages.map(({ source }) => source),
busy: false,
awaitingResponse: false
}),
Expand Down Expand Up @@ -1444,15 +1450,52 @@ export function useSessionActions({
return false
}

const startingActiveSessionId = activeSessionIdRef.current
const messages = $messages.get()
const storedSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
const startingCwd = $currentCwd.get().trim()

// The live atom may be a compacted model projection. Read the durable
// display projection before choosing the branch prefix so a whole-chat
// branch does not inherit only the summary/tail. If the backend is
// temporarily unavailable, retain the local snapshot and let the branch
// RPC make its own authoritative read.
let authoritativeMessages: ChatMessage[] | null = null
const profile = await resolveSessionProfile(storedSessionId)

if (storedSessionId) {
try {
const persisted = await getAllSessionMessages(storedSessionId, profile)
const hydrated = toChatMessages(persisted.messages)

if (hydrated.length) {
authoritativeMessages = hydrated
}
} catch {
// The branch RPC has a backend-side display projection fallback.
}
}

const at = messageId
? messages.findIndex(message => message.id === messageId)
: messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
const drift = sessionContextDrift({
startRouteToken: startingRouteToken,
nowRouteToken: getRouteToken(),
startSelectedStoredId: storedSessionId,
nowSelectedStoredId: selectedStoredSessionIdRef.current
})

const start = 0
const end = at >= 0 ? at + 1 : messages.length
const branchMessages = toBranchMessages(messages.slice(start, end))
const runtimeChanged = activeSessionIdRef.current !== startingActiveSessionId
const selectionChanged = selectedStoredSessionIdRef.current !== storedSessionId

if (drift || runtimeChanged || selectionChanged) {
console.warn('[branch-drift-abort]', drift ?? 'runtime-or-selection-changed', {
phase: 'transcript-hydration'
})

return false
}

const branchMessages = selectBranchMessages(messages, authoritativeMessages, messageId)

if (!branchMessages.length) {
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })
Expand All @@ -1465,17 +1508,16 @@ export function useSessionActions({
// The open chat's owning profile, NOT the picker's / launch profile —
// /profile only retargets new chats, so a branch of an existing thread
// must stay on that thread's backend (cache hit for an open session).
const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current)

return forkBranch(
branchMessages,
activeSessionIdRef.current,
selectedStoredSessionIdRef.current,
$currentCwd.get().trim(),
profile
startingActiveSessionId,
storedSessionId,
startingCwd,
profile,
messageId ? branchMessages.length : undefined
)
},
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
[activeSessionIdRef, busyRef, copy, forkBranch, getRouteToken, selectedStoredSessionIdRef]
)

// Branch any listed session, not just the open one. Reads the target's stored
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
reconcileResumeMessages,
removeRepresentedLocalLiveProjection,
resolveResumedBusy,
selectBranchMessages,
sessionMatchesStoredId,
sessionShouldHaveTranscript,
toBranchMessages
Expand Down Expand Up @@ -250,6 +251,47 @@ describe('toBranchMessages', () => {
})
})

describe('selectBranchMessages', () => {
it('uses the complete authoritative transcript for a whole-chat branch', () => {
const local = [msg('summary', 'assistant', 'compact summary'), msg('tail', 'assistant', 'latest answer')]

const authoritative = [
msg('old-user', 'user', 'first question', { rowId: 11 }),
msg('old-assistant', 'assistant', 'first answer', { rowId: 12 }),
msg('tail-user', 'user', 'latest question', { rowId: 13 }),
msg('tail-assistant', 'assistant', 'latest answer', { rowId: 14 })
]

expect(selectBranchMessages(local, authoritative).map(message => message.content)).toEqual([
'first question',
'first answer',
'latest question',
'latest answer'
])
})

it('maps a clicked local bubble to the authoritative row before slicing', () => {
const local = [
msg('tail-user', 'user', 'latest question', { rowId: 13 }),
msg('tail-assistant', 'assistant', 'latest answer', { rowId: 14 })
]

const authoritative = [
msg('old-user', 'user', 'first question', { rowId: 11 }),
msg('old-assistant', 'assistant', 'first answer', { rowId: 12 }),
msg('tail-user', 'user', 'latest question', { rowId: 13 }),
msg('tail-assistant', 'assistant', 'latest answer', { rowId: 14 })
]

expect(selectBranchMessages(local, authoritative, 'tail-assistant').map(message => message.content)).toEqual([
'first question',
'first answer',
'latest question',
'latest answer'
])
})
})

describe('chatPartsEquivalent', () => {
it('returns true for identical text parts', () => {
const partA = { type: 'text' as const, text: 'Hello world' }
Expand Down
Loading
Loading