fix(desktop): bot tile transcripts read from their owner backend

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 <stods21@users.noreply.github.com>
This commit is contained in:
Teknium
2026-09-02 04:11:07 -07:00
parent 51953a302f
commit a53286999b
2 changed files with 62 additions and 7 deletions

View File

@@ -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<typeof reconcileTileTranscriptsForTest>[0]['updateSessionState'] = vi.fn(
(_sessionId, updater) => updater({} as Parameters<typeof updater>[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<string, string>() },
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)

View File

@@ -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<number>
signatureRef: MutableRefObject<Map<string, string>>
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<void> {
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) {