diff --git a/apps/desktop/src/app/chat/session-tile-owner-route.test.ts b/apps/desktop/src/app/chat/session-tile-owner-route.test.ts index 31058c578d..0decaa2994 100644 --- a/apps/desktop/src/app/chat/session-tile-owner-route.test.ts +++ b/apps/desktop/src/app/chat/session-tile-owner-route.test.ts @@ -11,3 +11,28 @@ describe('SessionTilePane owner-scoped listing', () => { expect(source).not.toMatch(/void resolveStoredSession\(storedSessionId\)\s*\n/) }) }) + +describe('SessionTileChrome owner ladder', () => { + // A tile opened without an explicit route (openSessionTile with no + // workspaceScope — how a branch child is opened) has no tile ownerRoute, so + // the tile route ALONE leaves ownerRoute undefined and requestForSessionProfile + // falls back to the ambient socket. The session's own row/hint rung is what + // keeps its model + composer RPCs on the backend that owns it. + it('falls back to the session row owner when the tile carries no route', () => { + expect(source).toContain( + 'sessionTileOwnerRoute(storedSessionId) ?? knownSessionOwner(ownerLookupSessionRows(), storedSessionId)' + ) + }) + + it('does not resolve the chrome owner from the tile route alone', () => { + // The pre-fix shape: `const ownerRoute = sessionTileOwnerRoute(storedSessionId)` + // with no fallback rung. + expect(source).not.toMatch(/const ownerRoute = sessionTileOwnerRoute\(storedSessionId\)\s*\n/) + }) + + it('narrows a bare profile owner to an object route', () => { + // knownSessionOwner may return a bare profile string, which carries no + // connection and must not be handed to requestForSessionProfile as a route. + expect(source).toContain("typeof resolvedOwner === 'object'") + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 773263e3ff..22e0bf21f0 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -44,10 +44,12 @@ import { $gatewayState, $selectedStoredSessionId, $sessions, + knownSessionOwner, + ownerLookupSessionRows, sessionMatchesStoredId, sessionPinId } from '@/store/session' -import { requestForSessionProfile } from '@/store/session-request-router' +import { requestForSessionProfile, type SessionOwnerRoute } from '@/store/session-request-router' import { $sessionStates, $sessionTileDelegateRevision, @@ -157,7 +159,38 @@ function TileChat({ }) { const { gateway, requestGateway } = useGatewayRequest() const queryClient = useQueryClient() - const ownerRoute = sessionTileOwnerRoute(storedSessionId) + + // Owner ladder, same as useSessionTileActions (session-tile-actions.ts:99-103): + // this tile's explicit route first, then the session row's own + // (connection, profile) tag — knownSessionOwner also folds in the owner hint. + // A tile opened without an explicit route — e.g. a branch child, which + // openSessionTile creates with no workspaceScope — has no tile route, so the + // row/hint rung is the only thing keeping this tile's model + composer RPCs + // on the backend that owns the session instead of the ambient one. + // + // Resolved on every render (cheap id lookups) so it cannot go stale against + // the tile store, the recents/cron/messaging rows, or the hint map. Only the + // resulting IDENTITY is memoised, on primitives, because knownSessionOwner + // mints a fresh object per call and requestTileGateway below is keyed on it. + const resolvedOwner = + sessionTileOwnerRoute(storedSessionId) ?? knownSessionOwner(ownerLookupSessionRows(), storedSessionId) + + const ownerConnectionId = resolvedOwner && typeof resolvedOwner === 'object' ? resolvedOwner.connectionId : '' + const ownerProfile = resolvedOwner && typeof resolvedOwner === 'object' ? resolvedOwner.profile : '' + const ownerTargetProfile = resolvedOwner && typeof resolvedOwner === 'object' ? resolvedOwner.targetProfile : undefined + + // A bare profile string carries no connection and is not a usable route here. + const ownerRoute = useMemo( + () => + ownerConnectionId + ? { + connectionId: ownerConnectionId, + profile: ownerProfile, + ...(ownerTargetProfile ? { targetProfile: ownerTargetProfile } : {}) + } + : undefined, + [ownerConnectionId, ownerProfile, ownerTargetProfile] + ) const requestTileGateway = useCallback( (method: string, params?: Record, timeoutMs?: number, signal?: AbortSignal): Promise => 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 bdf9ae8eca..4071ac1f31 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 @@ -1886,6 +1886,46 @@ describe('branchStoredSession desktop source tagging', () => { }) }) + // 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 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. + it('tags the optimistic branch row with the parent connection owner', async () => { + const ambientRequest = vi.fn(async () => ({}) as never) + + vi.mocked(requestGatewayForAgent).mockImplementation((async ( + _connectionId: null | string, + _profile: string, + method: string + ) => { + if (method === 'session.create') { + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) as never) + + 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) + + const row = $sessions.get().find(session => session.id === 'branch-stored') + expect(row).toBeDefined() + expect(row!.connection_id).toBe('pandora') + expect(row!.profile).toBe('default') + }) + // An untagged row (single-backend users, the overwhelmingly common case) // must keep the ambient path exactly as before — no behaviour change. it('keeps an untagged parent branch on the ambient socket', 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 f659d4c0d6..8699682830 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 @@ -2023,13 +2023,22 @@ export function useSessionActions({ : 0 setFreshDraftReady(false) + // Stamp the optimistic row with the branch's EXACT owner. Without it the + // row inherits $activeGatewayProfile and carries no connection_id, so a + // child correctly created on the parent's remote backend is listed as + // belonging to whichever backend happens to be active. Every later + // owner lookup off that row (resume, hydrate, prompt) then routes to the + // wrong machine and the chat pane spins on a session that backend never + // had — the create is right, the row is a lie. Mirrors the routed + // creates at the top of this file, which already pass their route here. upsertOptimisticSession( branched, routedSessionId, copy.branchTitle(siblings + 1).toLowerCase(), preview, parentStoredId, - parent ? parent.last_active || parent.started_at : undefined + parent ? parent.last_active || parent.started_at : undefined, + ownerRoute ?? null ) ensureSessionState(branched.session_id, routedSessionId) updateSessionState(