From a53286999b17bf17843e40d9a207c430edbac75e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:11:07 -0700 Subject: [PATCH] fix(desktop): bot tile transcripts read from their owner backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvages the client half of PR #99333. A workspace tile pinned to an exact owner (connection + target profile) was reconciled via a bare getLatestSessionMessages(storedId) — the foreground profile's backend — so a bot tile on another profile never saw its new turns (or saw the wrong session's). reconcileTileTranscripts now derives a ProfileScope from tile.ownerRoute and keys the per-tile signature by that route; route-less tiles keep the legacy local read. The tui_gateway/server.py `_sessions_sig` cross-profile scan from #99333 is intentionally not taken: the desktop runs one backend per profile, each with its own watcher, so scanning sibling profiles would misattribute ticks. Co-authored-by: stods21 --- .../contrib/hooks/use-background-sync.test.ts | 41 ++++++++++++++++++- .../app/contrib/hooks/use-background-sync.ts | 28 ++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts index e42d95011d..7d1bd2cc64 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts @@ -260,7 +260,7 @@ describe('active transcript refresh', () => { // Behavior assertions: expect(updaterCallCount).toBeGreaterThan(0) - expect(getLatestSessionMessages).toHaveBeenCalledWith(TILE_STORED_ID) + expect(getLatestSessionMessages).toHaveBeenCalledWith(TILE_STORED_ID, undefined) }) it('reconciles an idle tile while the main pane is busy', async () => { @@ -285,7 +285,7 @@ describe('active transcript refresh', () => { updateSessionState }) - expect(getLatestSessionMessages).toHaveBeenCalledWith(storedId) + expect(getLatestSessionMessages).toHaveBeenCalledWith(storedId, undefined) expect(updateSessionState).toHaveBeenCalledTimes(1) }) @@ -347,6 +347,43 @@ describe('active transcript refresh', () => { expect(updateSessionState).not.toHaveBeenCalled() }) + it('isolates tile transcript reads by connection and profile while preserving the legacy local path', async () => { + vi.mocked(getLatestSessionMessages).mockImplementation(async storedId => transcript(storedId, storedId) as never) + + const updateSessionState: Parameters[0]['updateSessionState'] = vi.fn( + (_sessionId, updater) => updater({} as Parameters[0]) + ) + + await reconcileTileTranscriptsForTest({ + tiles: [ + { + ownerRoute: { connectionId: 'connection-a', mode: 'remote', profile: 'shared-profile', targetProfile: 'target-a' }, + runtimeId: 'runtime-a', + storedSessionId: 'stored-a' + }, + { + ownerRoute: { connectionId: 'connection-b', mode: 'remote', profile: 'shared-profile' }, + runtimeId: 'runtime-b', + storedSessionId: 'stored-b' + }, + { runtimeId: 'runtime-local', storedSessionId: 'stored-local' } + ], + requestSequenceRef: { current: 0 }, + signatureRef: { current: new Map() }, + updateSessionState + }) + + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-a', { connectionId: 'connection-a', profile: 'target-a' }) + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-b', { + connectionId: 'connection-b', + profile: 'shared-profile' + }) + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-local', undefined) + expect(updateSessionState).toHaveBeenCalledWith('runtime-a', expect.any(Function), 'stored-a') + expect(updateSessionState).toHaveBeenCalledWith('runtime-b', expect.any(Function), 'stored-b') + expect(updateSessionState).toHaveBeenCalledWith('runtime-local', expect.any(Function), 'stored-local') + }) + it('skips the tile fetch entirely when nothing changed (signature-gated)', async () => { $changeEventsAvailable.set(true) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index cddb12649e..663f190a51 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -72,6 +72,16 @@ function tileRuntimeOwnsLiveState(runtimeId: string): boolean { return Boolean(state && (state.busy || state.awaitingResponse || state.needsInput || state.turnLive)) } +type TileTranscriptTarget = { ownerRoute?: SessionProfileRoute; storedSessionId: string; runtimeId?: string } + +/** Signature key per tile — carries the owner route so two connections/profiles + * sharing a stored id (or a tile re-homed to another owner) never alias. */ +function tileTranscriptSignatureKey(tile: TileTranscriptTarget): string { + const route = tile.ownerRoute + + return `tile:${route ? `${route.connectionId}:${route.targetProfile ?? route.profile}:` : ''}${tile.storedSessionId}` +} + /** * Reconcile the persisted transcripts of every open WORKSPACE TILE (#93942 * slice 1). Bot canonical chats live here — never in $sessions / @@ -96,7 +106,7 @@ export async function reconcileTileTranscripts({ }: { requestSequenceRef: MutableRefObject signatureRef: MutableRefObject> - tiles?: Array<{ storedSessionId: string; runtimeId?: string }> + tiles?: TileTranscriptTarget[] updateSessionState: ( sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, @@ -104,7 +114,7 @@ export async function reconcileTileTranscripts({ ) => ClientSessionState }): Promise { const tiles = tilesOverride ?? $sessionTiles.get() - const openSignatureKeys = new Set(tiles.map(tile => `tile:${tile.storedSessionId}`)) + const openSignatureKeys = new Set(tiles.map(tileTranscriptSignatureKey)) for (const signatureKey of signatureRef.current.keys()) { if (!openSignatureKeys.has(signatureKey)) { @@ -139,19 +149,27 @@ export async function reconcileTileTranscripts({ ? tilesOverride.some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId) : $sessionTiles.get().some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId) + // Bot tiles are pinned to an exact owner (connection + target profile); + // read from that backend, not whichever profile is foreground. Tiles + // without a route keep the legacy local read. + const profileScope: ProfileScope = tile.ownerRoute + ? { connectionId: tile.ownerRoute.connectionId, profile: tile.ownerRoute.targetProfile ?? tile.ownerRoute.profile } + : undefined + + const signatureKey = tileTranscriptSignatureKey(tile) + try { - const latest = await getLatestSessionMessages(storedSessionId) + const latest = await getLatestSessionMessages(storedSessionId, profileScope) if (requestId !== requestSequenceRef.current || tileRuntimeOwnsLiveState(runtimeSessionId) || !tileStillPresent()) { // Tile closed or superseded mid-read — discard AND prune its // signature so the map doesn't grow one entry per ever-opened tile // for the app's lifetime (#94255 review point 3). - signatureRef.current.delete(`tile:${storedSessionId}`) + signatureRef.current.delete(signatureKey) continue } - const signatureKey = `tile:${storedSessionId}` const signature = sessionMessagesSignature(latest.messages) if (signatureRef.current.get(signatureKey) === signature) {