From 7bd734989bf24ae449de817eb2179199bed07f67 Mon Sep 17 00:00:00 2001 From: Benjamin Brumbaugh Date: Fri, 4 Sep 2026 12:27:00 -0700 Subject: [PATCH 1/3] fix(desktop): branch long sessions without renderer transcript load --- .../hooks/use-session-actions.test.tsx | 70 ++++++++----------- .../hooks/use-session-actions/index.ts | 37 ++++------ apps/desktop/src/types/hermes.ts | 1 + tests/test_tui_gateway_server.py | 61 +++++++++++++++- tui_gateway/methods_session.py | 30 ++++++-- 5 files changed, 131 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index e8550299c1ee6..47020bf0d3cbd 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -238,14 +238,14 @@ describe('desktop branch creation idempotency', () => { expect(requestGateway).toHaveBeenCalledWith( 'session.create', expect.objectContaining({ - messages: [ - { content: 'question', role: 'user' }, - { content: 'answer', role: 'assistant' } - ], + copy_parent_history: true, + omit_messages: true, parent_session_id: 'parent', source: 'desktop' }) ) + expect(requestGateway.mock.calls[0]?.[1]).not.toHaveProperty('messages') + expect(getAllSessionMessages).not.toHaveBeenCalled() expect($sessions.get().filter(session => session.id === 'stored-branch')).toHaveLength(1) }) @@ -2030,10 +2030,7 @@ describe('branchStoredSession desktop source tagging', () => { expect(ambientRequest).not.toHaveBeenCalledWith('session.create', expect.anything()) }) - // The parent transcript read must land on the owning backend too: reading it - // from the ambient socket returns nothing for a foreign-owned parent, which - // aborts the branch as "nothing to branch" before any create is attempted. - it('reads a connection-tagged parent transcript from its owning connection', async () => { + it('does not materialize a connection-tagged parent transcript in the renderer', async () => { const ambientRequest = vi.fn(async () => ({}) as never) vi.mocked(requestGatewayForAgent).mockImplementation((async ( @@ -2051,25 +2048,26 @@ describe('branchStoredSession desktop source tagging', () => { setSessions([ storedSession({ connection_id: 'pandora', id: 'stored-parent', message_count: 1, profile: 'default' }) ]) - vi.mocked(getAllSessionMessages).mockResolvedValue({ - messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], - session_id: 'stored-parent' - } as never) - let branchStoredSession: ((storedSessionId: string) => Promise) | null = null render( (branchStoredSession = branch)} requestGateway={ambientRequest} />) await waitFor(() => expect(branchStoredSession).not.toBeNull()) await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) - expect(getAllSessionMessages).toHaveBeenCalledWith('stored-parent', { - connectionId: 'pandora', - profile: 'default' - }) + expect(requestGatewayForAgent).toHaveBeenCalledWith( + 'pandora', + 'default', + 'session.create', + expect.objectContaining({ + copy_parent_history: true, + omit_messages: true, + parent_session_id: 'stored-parent', + source: 'desktop' + }) + ) + expect(getAllSessionMessages).not.toHaveBeenCalled() }) - - // The create landing on the right backend is only half the job: the sidebar - // row must be TAGGED with that owner too. An untagged optimistic row inherits + // The row must be TAGGED with that owner too. An untagged optimistic row inherits // the ambient profile, so every later owner lookup (resume/hydrate/prompt) // routes to the wrong backend and the chat pane spins forever on a session // that backend never had. @@ -2185,12 +2183,13 @@ describe('branchStoredSession desktop source tagging', () => { expect(requestGateway).toHaveBeenCalledWith('session.branch', { session_id: 'live-parent', - count: 2 + count: 2, + omit_messages: true }) - expect(branchParams).toEqual({ session_id: 'live-parent', count: 2 }) + expect(branchParams).toEqual({ session_id: 'live-parent', count: 2, omit_messages: true }) }) - it('hydrates the complete persisted display transcript before branching a compacted live chat', async () => { + it('branches a compacted live chat without hydrating its transcript in the renderer', async () => { let branchParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { @@ -2202,7 +2201,7 @@ describe('branchStoredSession desktop source tagging', () => { stored_session_id: 'branch-stored', title: 'Branch', message_count: 4, - messages: [], + messages_omitted: true, info: {} } as never } @@ -2216,16 +2215,6 @@ describe('branchStoredSession desktop source tagging', () => { { 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) | null = null render( { await expect(branchCurrentSession!()).resolves.toBe(true) - expect(getAllSessionMessages).toHaveBeenCalledWith('stored-parent', undefined) - expect(branchParams).toEqual({ session_id: 'live-parent' }) + expect(getAllSessionMessages).not.toHaveBeenCalled() + expect(branchParams).toEqual({ session_id: 'live-parent', omit_messages: true }) }) it('aborts if the active runtime changes while the branch transcript is hydrating', async () => { @@ -2275,16 +2264,17 @@ describe('branchStoredSession desktop source tagging', () => { ) await waitFor(() => expect(branchCurrentSession).not.toBeNull()) - await expect(branchCurrentSession!()).resolves.toBe(false) + await expect(branchCurrentSession!('q1')).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 - // is not created on whichever profile happens to be live. + // swap to it before asking the backend to copy the transcript, so the fork is + // not created on whichever profile happens to be live. it('resolves and swaps to the parent profile when the branched session is not cached', async () => { setSessions([]) + vi.mocked(getAllSessionMessages).mockClear() vi.mocked(getSession).mockResolvedValue(storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })) vi.mocked(getAllSessionMessages).mockResolvedValue({ messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], @@ -2312,7 +2302,7 @@ describe('branchStoredSession desktop source tagging', () => { await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) expect(ensureGatewayProfile).toHaveBeenCalledWith('work') - expect(getAllSessionMessages).toHaveBeenCalledWith('stored-parent', 'work') + expect(getAllSessionMessages).not.toHaveBeenCalled() // The create itself must carry the owning profile: in app-global remote // mode the soft gateway swap alone is not enough — an omitted profile // lands the branch on the launch (default) profile's state.db. diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index d70a6ef326d24..6c3b2822e6fcf 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -2101,6 +2101,7 @@ export function useSessionActions({ sourceSessionId ? requestBranchGateway('session.branch', { session_id: sourceSessionId, + omit_messages: true, ...(branchCount !== undefined ? { count: branchCount } : {}) }) : requestBranchGateway('session.create', { @@ -2108,7 +2109,9 @@ export function useSessionActions({ source: 'desktop', ...(cwd && { cwd }), ...(profile ? { profile } : {}), - messages: branchMessages.map(({ content, role }) => ({ content, role })), + ...(branchMessages.length + ? { messages: branchMessages.map(({ content, role }) => ({ content, role })) } + : { copy_parent_history: true, omit_messages: true }), ...(parentStoredId && { parent_session_id: parentStoredId }) }) ).catch(err => { @@ -2260,11 +2263,10 @@ export function useSessionActions({ 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. + // Message-level branches still need the local message id to choose their + // prefix. Whole-chat branches send only the parent identity below; the + // backend reads the durable display projection without materializing it in + // the renderer. let authoritativeMessages: ChatMessage[] | null = null const profile = await resolveSessionProfile(storedSessionId) @@ -2274,7 +2276,7 @@ export function useSessionActions({ // whichever socket is active. const ownerRoute = storedSessionId ? sessionOwnerRouteFromRow(cachedSessionRow(storedSessionId)) : undefined - if (storedSessionId) { + if (messageId && storedSessionId) { try { const persisted = await getAllSessionMessages(storedSessionId, ownerRoute ?? profile) const hydrated = toChatMessages(persisted.messages) @@ -2305,9 +2307,9 @@ export function useSessionActions({ return false } - const branchMessages = selectBranchMessages(messages, authoritativeMessages, messageId) + const branchMessages = messageId ? selectBranchMessages(messages, authoritativeMessages, messageId) : [] - if (!branchMessages.length) { + if (messageId && !branchMessages.length) { notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText }) return false @@ -2360,21 +2362,10 @@ export function useSessionActions({ await ensureGatewayProfile(profile) } - // Read the parent transcript from the backend that OWNS it. A bare - // profile scope resolves against the active connection, which for a - // foreign-owned parent holds no such session: the read comes back empty - // and the branch aborts as "nothing to branch" before any create. - const { messages } = await getAllSessionMessages(storedSessionId, ownerRoute ?? profile) - const branchMessages = toBranchMessages(toChatMessages(messages)) - - if (!branchMessages.length) { - notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText }) - - return false - } - + // Ask the owning backend to read and copy the parent transcript. The + // renderer deliberately does not materialize the complete history. return await forkBranch( - branchMessages, + [], null, stored?.id ?? storedSessionId, stored?.cwd?.trim(), diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 5402baa746268..767058c74b3a7 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -485,6 +485,7 @@ export interface SessionCreateResponse { info?: SessionRuntimeInfo message_count?: number messages?: SessionMessage[] + messages_omitted?: boolean session_id: string stored_session_id?: string } diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ec28bc1191f88..84e6624be33c8 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -15533,7 +15533,7 @@ def _fake_make_agent(*a, **k): { "id": "1", "method": "session.branch", - "params": {"session_id": "parent", "name": "forked"}, + "params": {"session_id": "parent", "name": "forked", "omit_messages": True}, } ) assert "result" in resp, resp @@ -15542,6 +15542,8 @@ def _fake_make_agent(*a, **k): # The branch row is self-describing: stamped with the parent's owning # profile, not left NULL for aggregators to mis-tag as "default". assert seen.get("profile_name") == "mlperf" + assert resp["result"].get("messages_omitted") is True + assert "messages" not in resp["result"] assert seen.get("title") == (seen["created"], "forked") assert len(seen["msgs"]) == 1 assert seen.get("launch") is None @@ -15646,6 +15648,63 @@ def set_session_title(self, key, title): server._sessions.pop(runtime_sid, None) +def test_session_create_copies_parent_history_without_returning_transcript(monkeypatch): + """Whole-session desktop branches read the parent in the gateway, not in the renderer.""" + + class _Scope: + def __init__(self, db): + self.db = db + + def __enter__(self): + return self.db + + def __exit__(self, *_args): + return False + + class _FakeDB: + def get_resume_conversations(self, key): + assert key == "parent" + return [], [ + {"role": "user", "content": "first question", "timestamp": 1}, + {"role": "assistant", "content": "first answer", "timestamp": 2}, + ] + + class _FakeAgent: + model = "test-model" + + seen: dict = {} + db = _FakeDB() + + monkeypatch.setattr(server, "_profile_db", lambda _params: _Scope(db)) + monkeypatch.setattr( + server, + "_seed_branch_row", + lambda _record, _key, _parent, history, *_args: seen.update(history=list(history)), + ) + monkeypatch.setattr(server, "_make_agent", lambda *_args, **_kwargs: _FakeAgent()) + monkeypatch.setattr(server, "_session_info", lambda *_args: {"model": "test-model"}) + + resp = server.handle_request( + { + "id": "1", + "method": "session.create", + "params": { + "cols": 96, + "copy_parent_history": True, + "omit_messages": True, + "parent_session_id": "parent", + "source": "desktop", + }, + } + ) + + assert "result" in resp, resp + assert [message["content"] for message in seen["history"]] == ["first question", "first answer"] + assert resp["result"]["message_count"] == 2 + assert resp["result"].get("messages_omitted") is True + assert "messages" not in resp["result"] + + def test_session_create_branch_seed_failure_does_not_break_create(monkeypatch): """Best-effort persistence: a broken DB must not fail session.create.""" diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 1c2e13efdf38e..1dcc0b3b1ddbe 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -299,6 +299,24 @@ def _(rid, params: dict) -> dict: history = _coerce_seed_history(params.get("messages")) # Branch: links back so list_sessions_rich keeps it visible and the sidebar nests it. parent_session_id = _str_param(params, "parent_session_id") or None + copy_parent_history = _flag(params, "copy_parent_history") + omit_messages = _flag(params, "omit_messages") + if copy_parent_history: + if not parent_session_id: + return _err(rid, 4008, "parent_session_id is required when copying parent history") + # Whole-session desktop branches must not serialize the parent's transcript + # through the renderer. Read the durable display projection here, where the + # owning state.db already lives, and keep the full copy server-side. + with _profile_db(params) as db: + if db is None: + return _db_unavailable_error(rid, code=5008) + try: + _, display_history = db.get_resume_conversations(parent_session_id) + except Exception as exc: + return _err(rid, 4008, f"nothing to branch — {exc}") + history = _visible_branch_history(display_history) + if not history: + return _err(rid, 4008, "nothing to branch — send a message first") # Only an explicitly chosen existing workspace persists as cwd; the launch-dir fallback is "No workspace". explicit_cwd = False raw_cwd = _str_param(params, "cwd") # unguarded, as on BASE: only the path check is best-effort @@ -351,7 +369,7 @@ def _(rid, params: dict) -> dict: override = session_model_override or {} return _ok(rid, { "session_id": sid, "stored_session_id": key, "message_count": len(history), - "messages": _history_to_messages(history), + **({"messages": _history_to_messages(history)} if not omit_messages else {"messages_omitted": True}), # Reflect the override now so the client doesn't clobber its sticky pick. "info": {"model": override.get("model") if override else _resolve_model(), **({"provider": override["provider"]} if override.get("provider") else {}), @@ -1913,9 +1931,13 @@ def _(rid, params: dict, session: dict) -> dict: agent = _build_branch_agent(session, new_sid, new_key, history, source) except Exception as e: return _err(rid, 5000, f"agent init failed on branch: {e}") - return _ok(rid, {"session_id": new_sid, "stored_session_id": new_key, "title": title, "parent": old_key, - "message_count": len(history), "messages": _history_to_messages(history), - "info": _session_info(agent, _sessions.get(new_sid))}) + response = {"session_id": new_sid, "stored_session_id": new_key, "title": title, "parent": old_key, + "message_count": len(history), "info": _session_info(agent, _sessions.get(new_sid))} + if _flag(params, "omit_messages"): + response["messages_omitted"] = True + else: + response["messages"] = _history_to_messages(history) + return _ok(rid, response) # ── interrupt / steer / redirect ───────────────────────────────────── From bae4518495a2b2a2d8b234481c9c1ab5de20c9cf Mon Sep 17 00:00:00 2001 From: Benjamin Brumbaugh Date: Fri, 4 Sep 2026 12:50:21 -0700 Subject: [PATCH 2/3] fix(desktop): preserve branch compatibility with older gateways --- .../hooks/use-session-actions.test.tsx | 79 +++++++++++++------ .../hooks/use-session-actions/index.ts | 62 +++++++++++---- tui_gateway/methods_session.py | 39 ++++++++- 3 files changed, 139 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 47020bf0d3cbd..37b52fe7039b2 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -196,7 +196,7 @@ describe('desktop branch creation idempotency', () => { const createReady = deferred<{ session_id: string; stored_session_id: string }>() const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.create') { + if (method === 'session.branch_stored') { return createReady.promise as never } @@ -226,7 +226,7 @@ describe('desktop branch creation idempotency', () => { }) await waitFor(() => - expect(requestGateway.mock.calls.filter(([method]) => method === 'session.create')).toHaveLength(1) + expect(requestGateway.mock.calls.filter(([method]) => method === 'session.branch_stored')).toHaveLength(1) ) await act(async () => { @@ -234,9 +234,9 @@ describe('desktop branch creation idempotency', () => { await expect(Promise.all([first, second])).resolves.toEqual([true, true]) }) - expect(requestGateway.mock.calls.filter(([method]) => method === 'session.create')).toHaveLength(1) + expect(requestGateway.mock.calls.filter(([method]) => method === 'session.branch_stored')).toHaveLength(1) expect(requestGateway).toHaveBeenCalledWith( - 'session.create', + 'session.branch_stored', expect.objectContaining({ copy_parent_history: true, omit_messages: true, @@ -260,7 +260,7 @@ describe('desktop branch creation idempotency', () => { const otherCreate = deferred<{ session_id: string; stored_session_id: string }>() routedCreate.mockImplementation((async (connectionId: string, _profile: string, method: string) => { - if (method !== 'session.create') { + if (method !== 'session.branch_stored') { return {} as never } @@ -294,7 +294,7 @@ describe('desktop branch creation idempotency', () => { await act(async () => { second = actions!.branchStoredSession('parent') await waitFor(() => - expect(routedCreate.mock.calls.filter(([, , method]) => method === 'session.create')).toHaveLength(2) + expect(routedCreate.mock.calls.filter(([, , method]) => method === 'session.branch_stored')).toHaveLength(2) ) }) @@ -304,7 +304,7 @@ describe('desktop branch creation idempotency', () => { await expect(Promise.all([first, second])).resolves.toEqual([true, true]) }) - const creates = routedCreate.mock.calls.filter(([, , method]) => method === 'session.create') + const creates = routedCreate.mock.calls.filter(([, , method]) => method === 'session.branch_stored') expect(creates.map(([connectionId]) => connectionId)).toEqual(['pandora', 'other-box']) // Two distinct children, not one child claimed twice. @@ -649,7 +649,7 @@ async function createWith( let createParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { createParams = params return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never @@ -1851,7 +1851,7 @@ describe('branchStoredSession desktop source tagging', () => { it('opens the branch as the primary session in the main workspace (#93444)', async () => { const requestGateway = vi.fn(async (method: string) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } @@ -1904,7 +1904,7 @@ describe('branchStoredSession desktop source tagging', () => { it('keeps the current view when branching a different session from the sidebar (does not reintroduce #69750)', async () => { const requestGateway = vi.fn(async (method: string) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } @@ -1957,7 +1957,7 @@ describe('branchStoredSession desktop source tagging', () => { let createParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { createParams = params return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never @@ -1998,7 +1998,7 @@ describe('branchStoredSession desktop source tagging', () => { _profile: string, method: string ) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } @@ -2020,10 +2020,10 @@ describe('branchStoredSession desktop source tagging', () => { await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) // The create must ride the parent's own (connection, profile) socket... - expect(requestGatewayForAgent).toHaveBeenCalledWith( + expect(requestGatewayForAgent).toHaveBeenLastCalledWith( 'pandora', 'default', - 'session.create', + 'session.branch_stored', expect.objectContaining({ parent_session_id: 'stored-parent', source: 'desktop' }) ) // ...and never the ambient socket, which may serve a different machine. @@ -2038,7 +2038,7 @@ describe('branchStoredSession desktop source tagging', () => { _profile: string, method: string ) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } @@ -2054,10 +2054,10 @@ describe('branchStoredSession desktop source tagging', () => { await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) - expect(requestGatewayForAgent).toHaveBeenCalledWith( + expect(requestGatewayForAgent).toHaveBeenLastCalledWith( 'pandora', 'default', - 'session.create', + 'session.branch_stored', expect.objectContaining({ copy_parent_history: true, omit_messages: true, @@ -2079,7 +2079,7 @@ describe('branchStoredSession desktop source tagging', () => { _profile: string, method: string ) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } @@ -2112,7 +2112,7 @@ describe('branchStoredSession desktop source tagging', () => { let createParams: Record | undefined const ambientRequest = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { createParams = params return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never @@ -2193,7 +2193,7 @@ describe('branchStoredSession desktop source tagging', () => { let branchParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.branch') { + if (method === 'session.branch_whole') { branchParams = params return { @@ -2284,7 +2284,7 @@ describe('branchStoredSession desktop source tagging', () => { let createParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { createParams = params return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never @@ -2321,7 +2321,7 @@ describe('branchStoredSession desktop source tagging', () => { let createParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { createParams = params return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never @@ -2350,7 +2350,7 @@ describe('branchStoredSession desktop source tagging', () => { let createParams: Record | undefined const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.create') { + if (method === 'session.create' || method === 'session.branch_stored') { createParams = params return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never @@ -2368,6 +2368,39 @@ describe('branchStoredSession desktop source tagging', () => { expect(createParams).toBeDefined() expect(createParams).not.toHaveProperty('profile') }) + + it('falls back without creating an empty branch against an older backend', async () => { + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.branch_stored') { + throw new Error('unknown method: session.branch_stored') + } + + if (method === 'session.create') { + expect(params?.messages).toEqual([{ content: 'persisted context', role: 'user' }]) + + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + vi.mocked(getAllSessionMessages).mockResolvedValue({ + messages: [{ content: 'persisted context', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( (branchStoredSession = branch)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + expect(requestGateway).toHaveBeenCalledWith( + 'session.create', + expect.objectContaining({ + parent_session_id: 'stored-parent' + }) + ) + }) }) // ── Main/tile dedup (the "same session open in main AND its own tab" bug) ───── diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 6c3b2822e6fcf..cb9377d2200af 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -2097,23 +2097,57 @@ export function useSessionActions({ // No title: the backend auto-names the branch from its parent's lineage. if (!createFlight) { + const branchParams = { + session_id: sourceSessionId, + omit_messages: true, + ...(branchCount !== undefined ? { count: branchCount } : {}) + } + + const createParams = { + cols: 96, + copy_parent_history: true, + omit_messages: true, + source: 'desktop', + ...(cwd && { cwd }), + ...(profile ? { profile } : {}), + ...(parentStoredId && { parent_session_id: parentStoredId }) + } + createFlight = ( sourceSessionId - ? requestBranchGateway('session.branch', { - session_id: sourceSessionId, - omit_messages: true, - ...(branchCount !== undefined ? { count: branchCount } : {}) - }) - : requestBranchGateway('session.create', { - cols: 96, - source: 'desktop', - ...(cwd && { cwd }), - ...(profile ? { profile } : {}), - ...(branchMessages.length - ? { messages: branchMessages.map(({ content, role }) => ({ content, role })) } - : { copy_parent_history: true, omit_messages: true }), - ...(parentStoredId && { parent_session_id: parentStoredId }) + ? requestBranchGateway( + branchCount === undefined ? 'session.branch_whole' : 'session.branch', + branchParams + ).catch(err => { + if (!isMissingRpcMethod(err)) { + throw err + } + + return requestBranchGateway('session.branch', branchParams) }) + : branchMessages.length + ? requestBranchGateway('session.create', { + ...createParams, + messages: branchMessages.map(({ content, role }) => ({ content, role })) + }) + : requestBranchGateway('session.branch_stored', createParams).catch( + async err => { + if (!isMissingRpcMethod(err)) { + throw err + } + + const { messages } = await getAllSessionMessages(parentStoredId ?? '', ownerRoute ?? profile) + + if (!messages.length) { + throw new Error('nothing to branch — send a message first') + } + + return requestBranchGateway('session.create', { + ...createParams, + messages: messages.map(({ content, role }) => ({ content, role })) + }) + } + ) ).catch(err => { // Drop the flight so a genuine retry re-issues the create; a // resolved flight is cleared once the child is fully published. diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 1dcc0b3b1ddbe..b976514e810ce 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -293,8 +293,7 @@ def _create_overrides(params: dict) -> tuple: return model_override, reasoning_override, service_tier_override -@method("session.create") -def _(rid, params: dict) -> dict: +def _create_session(rid, params: dict) -> dict: (sid, source), key = _new_runtime_ids(params), _new_session_key() history = _coerce_seed_history(params.get("messages")) # Branch: links back so list_sessions_rich keeps it visible and the sidebar nests it. @@ -378,6 +377,25 @@ def _(rid, params: dict) -> dict: "profile_name": _response_profile_name(profile)}}) +@method("session.create") +def _(rid, params: dict) -> dict: + return _create_session(rid, params) + + +@method("session.branch_stored") +def _(rid, params: dict) -> dict: + """Create a whole-session branch from a stored parent without routing its transcript through the client. + + This is a distinct method, rather than an overloaded ``session.create`` flag, so older Desktop clients can + safely detect the capability and older gateways cannot silently create an empty branch when they ignore an + unknown parameter. + """ + branch_params = dict(params) + branch_params["copy_parent_history"] = True + branch_params["omit_messages"] = True + return _create_session(rid, branch_params) + + def _session_list_by_title(rid, db, title_lookup: str) -> dict: """EXACT-title lookup (title as identity), window-free on purpose (a busy profile's windowed listing can push the row out). Hidden rows resolve (canonical chats are born hidden); archived / deny-listed do not; @@ -1906,8 +1924,7 @@ def _branch_source_history(db, session: dict, old_key: str) -> list: return history or _visible_branch_history(in_memory_history) -@_session_method("session.branch", live=True) -def _(rid, params: dict, session: dict) -> dict: +def _branch_live(rid, params: dict, session: dict) -> dict: # Write into the parent's profile-scoped state.db; the launch handle would orphan rows. with _session_db(session) as db: if db is None: @@ -1940,6 +1957,20 @@ def _(rid, params: dict, session: dict) -> dict: return _ok(rid, response) +@_session_method("session.branch", live=True) +def _(rid, params: dict, session: dict) -> dict: + return _branch_live(rid, params, session) + + +@_session_method("session.branch_whole", live=True) +def _(rid, params: dict, session: dict) -> dict: + """Whole-session branch response seam for Desktop clients that do not need the copied transcript echoed back.""" + branch_params = dict(params) + branch_params.pop("count", None) + branch_params["omit_messages"] = True + return _branch_live(rid, branch_params, session) + + # ── interrupt / steer / redirect ───────────────────────────────────── @method("session.interrupt") def _(rid, params: dict) -> dict: From c12ae626d07a2654f7774523651043fe7f79de24 Mon Sep 17 00:00:00 2001 From: Benjamin Brumbaugh Date: Fri, 4 Sep 2026 13:52:10 -0700 Subject: [PATCH 3/3] fix(desktop): preserve message-level branch responses --- .../src/app/session/hooks/use-session-actions.test.tsx | 5 ++--- .../src/app/session/hooks/use-session-actions/index.ts | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 37b52fe7039b2..69299ba911c7d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -2183,10 +2183,9 @@ describe('branchStoredSession desktop source tagging', () => { expect(requestGateway).toHaveBeenCalledWith('session.branch', { session_id: 'live-parent', - count: 2, - omit_messages: true + count: 2 }) - expect(branchParams).toEqual({ session_id: 'live-parent', count: 2, omit_messages: true }) + expect(branchParams).toEqual({ session_id: 'live-parent', count: 2 }) }) it('branches a compacted live chat without hydrating its transcript in the renderer', async () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index cb9377d2200af..fb7cb0165603c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -2099,7 +2099,6 @@ export function useSessionActions({ if (!createFlight) { const branchParams = { session_id: sourceSessionId, - omit_messages: true, ...(branchCount !== undefined ? { count: branchCount } : {}) } @@ -2117,7 +2116,7 @@ export function useSessionActions({ sourceSessionId ? requestBranchGateway( branchCount === undefined ? 'session.branch_whole' : 'session.branch', - branchParams + branchCount === undefined ? { ...branchParams, omit_messages: true } : branchParams ).catch(err => { if (!isMissingRpcMethod(err)) { throw err