Skip to content
Closed
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
78 changes: 78 additions & 0 deletions apps/desktop/src/app/contrib/wiring-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'

import { findStoredIdForRuntimeId, resolveRoutingSessionId } from './wiring-routing'

describe('findStoredIdForRuntimeId', () => {
it('reverse-resolves a runtime id to its stored id', () => {
const bindings = new Map([
['stored-a', 'runtime-a'],
['stored-b', 'runtime-b']
])

expect(findStoredIdForRuntimeId(bindings, 'runtime-b')).toBe('stored-b')
})

it('returns undefined for an unknown runtime id', () => {
expect(findStoredIdForRuntimeId(new Map([['stored-a', 'runtime-a']]), 'runtime-x')).toBeUndefined()
expect(findStoredIdForRuntimeId(new Map(), 'anything')).toBeUndefined()
})
})

describe('resolveRoutingSessionId', () => {
const never = (): string | undefined => undefined

it('routes by the RPC target session, not the focused tile (the Bot Mode misroute)', () => {
// A bot chat is a background tile: focused/selected point at the DEFAULT
// chat, but the RPC targets the bot. Routing must follow the RPC's target.
const routing = resolveRoutingSessionId({
focusedStoredSessionId: 'default-chat',
paramSessionId: 'runtime-bot',
selectedStoredSessionId: 'default-chat',
storedIdForRuntime: runtimeId => (runtimeId === 'runtime-bot' ? 'stored-bot' : undefined)
})

expect(routing).toBe('stored-bot')
})

it('treats an unresolved session_id as already a stored id', () => {
// Several RPCs pass stored ids directly; a runtime miss must not drop back
// to the focused tile (that reintroduces the misroute).
const routing = resolveRoutingSessionId({
focusedStoredSessionId: 'default-chat',
paramSessionId: 'stored-bot-direct',
selectedStoredSessionId: 'default-chat',
storedIdForRuntime: never
})

expect(routing).toBe('stored-bot-direct')
})

it('falls back to focused then selected when the RPC carries no session_id', () => {
expect(
resolveRoutingSessionId({
focusedStoredSessionId: 'focused',
paramSessionId: undefined,
selectedStoredSessionId: 'selected',
storedIdForRuntime: never
})
).toBe('focused')

expect(
resolveRoutingSessionId({
focusedStoredSessionId: null,
paramSessionId: undefined,
selectedStoredSessionId: 'selected',
storedIdForRuntime: never
})
).toBe('selected')

expect(
resolveRoutingSessionId({
focusedStoredSessionId: null,
paramSessionId: undefined,
selectedStoredSessionId: null,
storedIdForRuntime: never
})
).toBeNull()
})
})
50 changes: 50 additions & 0 deletions apps/desktop/src/app/contrib/wiring-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Pure routing helpers for the contrib wiring controller.
*
* Kept out of wiring.tsx so they can be unit-tested without importing the whole
* React/Electron controller module.
*/

/**
* Resolve a runtime session id back to its stored id by reverse-scanning the
* stored->runtime binding map — the same ladder use-session-tile-delegate's
* `storedSessionIdForRuntime` uses. Returns undefined when the id isn't a known
* runtime id, so the caller can treat it as already a stored id.
*/
export function findStoredIdForRuntimeId(bindings: Map<string, string>, runtimeId: string): string | undefined {
for (const [storedId, mapped] of bindings) {
if (mapped === runtimeId) {
return storedId
}
}

return undefined
}

/**
* The stored session id a session-scoped RPC should route by.
*
* Route by the session the RPC TARGETS (its `session_id` param), not by the
* window's focused tile: `requestGateway` is one shared closure for every
* session RPC, so keying off the focused tile sent a non-focused tile's RPC
* (a bot chat while another pane is active) to the focused tile's backend — the
* Bot Mode misroute. `session_id` is a RUNTIME id while tiles/rows key on the
* STORED id, so translate via the state cache, then the reverse binding scan;
* an unknown id is already a stored id (several RPCs pass stored ids directly).
* With no `session_id` at all (ambient/config calls) fall back to the focused
* then selected tile.
*/
export function resolveRoutingSessionId(args: {
paramSessionId: string | undefined
storedIdForRuntime: (runtimeId: string) => string | undefined
focusedStoredSessionId: null | string
selectedStoredSessionId: null | string
}): null | string {
const { focusedStoredSessionId, paramSessionId, selectedStoredSessionId, storedIdForRuntime } = args

if (paramSessionId) {
return storedIdForRuntime(paramSessionId) ?? paramSessionId
}

return focusedStoredSessionId ?? selectedStoredSessionId
}
30 changes: 28 additions & 2 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ import { McpInstallDeepLinkDialog } from './mcp-install-deeplink-dialog'
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
import type { WiringActions, WiringApi } from './types'
import { findStoredIdForRuntimeId, resolveRoutingSessionId } from './wiring-routing'

// Overlay views the controller mounts over the shell — lazy, load on demand.
// The workspace-route full-page views (skills/messaging/artifacts) are the
Expand Down Expand Up @@ -309,15 +310,40 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// the same focused id) only when no tile route exists.
const requestGateway = useCallback(
<T,>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal) => {
const routingSessionId = $focusedStoredSessionId.get() ?? selectedStoredSessionIdRef.current
// Route each RPC by the session IT targets, not by whatever tile is
// focused. `requestGateway` is one shared closure used for every session
// RPC in the window; keying the owner off $focusedStoredSessionId sent a
// NON-focused tile's RPC (any bot chat while another pane is active) to
// the focused tile's backend. That is the Bot Mode bug: a bot's
// prompt.submit carried its own session_id but ran on the default backend
// (served via ?profile= from the default's state.db), or 4001'd when the
// default backend didn't hold the runtime session.
//
// params.session_id is a RUNTIME id, while tiles and session rows key on
// the STORED id, so translate first (state cache, then a reverse scan of
// the stored->runtime map — the same ladder use-session-tile-delegate
// uses). A miss on both means the id is already a stored id (several RPCs
// pass stored ids directly), so use it as-is. Only an RPC with no
// session_id at all (ambient/config calls) keeps the focused-tile route.
const paramSessionId =
typeof params?.session_id === 'string' && params.session_id ? params.session_id : undefined

const routingSessionId = resolveRoutingSessionId({
focusedStoredSessionId: $focusedStoredSessionId.get(),
paramSessionId,
selectedStoredSessionId: selectedStoredSessionIdRef.current,
storedIdForRuntime: runtimeId =>
sessionStateByRuntimeIdRef.current.get(runtimeId)?.storedSessionId ??
findStoredIdForRuntimeId(runtimeIdByStoredSessionIdRef.current, runtimeId)
})

const owner =
(routingSessionId ? sessionTileOwnerRoute(routingSessionId) : undefined) ??
rememberedSessionProfile($sessions.get(), routingSessionId, $activeGatewayProfile.get())

return requestForSessionProfile<T>(owner, ambientRequestGateway, method, params ?? {}, timeoutMs, signal)
},
[ambientRequestGateway]
[ambientRequestGateway, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef]
)

const { loadMoreMessagingForPlatform, loadMoreSessions, refreshCronJobs, refreshMessagingSessions, refreshSessions } =
Expand Down
Loading