From cf20e089d20fd59a29479fac42dc45eabb666de3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:26:27 -0700 Subject: [PATCH 001/209] fix(native-chat): wait for the runtime capability probe before resolving the creation launch route (#19819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): wait for the runtime capability probe before resolving the launch route A worktree created before the renderer's hydration-gated capability refresh runs read the local capability set as null, which resolveStructuredNativeChatSupport treats as a blocker, silently degrading structured native chat to the legacy terminal-backed route. Creation submits now await ensureLocalRuntimeCapabilities(), which probes the local runtime when no answer has landed yet, so the route resolves on an actual answer. Fixes #19154 * fix(native-chat): await the capability probe in the work-item direct launch route too prepareDirectWorkItemAgentLaunch is the fourth creation-flow route owner and already async; a pre-hydration submit-after-ready launch (fix-checks) read the unprobed cache as unsupported and silently degraded to legacy. Draft-delivery launches were unaffected (draft-prompt blocks structured before the capability check). Same shape as the three creation-submit sites. * fix(native-chat): keep the capability probe starting synchronously The broken-bridge hardening wrapped the probe in Promise.resolve().then(...), which deferred window.api.runtime.getStatus() by a microtask. The session-tabs restore deliberately overlaps its inventory RPC with this refresh and relies on the probe already being in flight when refresh returns, so the deferral broke it. The bridge call is synchronous again; a synchronous throw becomes a rejection instead, which is what the wrapper was actually for. * fix(native-chat): hydrate local runtime capabilities at renderer boot The capability cache's only writer was `useLocalStructuredSessionTabsSync`, gated on workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. Every `resolveAgentLaunchRoute` reader treats an unanswered cache as "unsupported", so the answer arriving seconds late is what produces the bare-terminal create in #19154 — awaiting the probe at a route decision guards four call sites but leaves the window open for the three readers that are synchronous and cannot await. Start the probe from the renderer boot chain, ungated, so the answer is cached before any launch route is resolved. The per-call-site awaits stay as the backstop for the residual window and for re-probing after a failed probe. Also: hoist the full-creation probe above its cancel gate so the gate stays adjacent to createWorktree; pin the retry-after-failure, concurrent-ensure and missing-bridge contracts; drop a stale microtask tick and correct two comments that no longer described the code. * test(native-chat): pin the cancel gate around the capability probe The probe added an await to two composer creation paths. Full creation had no gate between the route decision and createWorktree, so the earlier revision opened a window where a dismissed composer still created a worktree; the hoist that closed it was unpinned. Quick creation already gated immediately before runBackgroundWorktreeCreation, so its inline await is safe — pin that too, since nothing asserted it. Both tests fail against origin/main (no probe) and the full-creation one fails against the pre-hoist revision. * fix(native-chat): close the folder-create cancel window the probe opened The probe added the first `await` inside `submitFolderWorkspaceCreate`. On `main` that function ran straight through to `createFolderWorkspace` with no suspension of its own, so its caller's `isSubmissionCancelled()` gate and the create call sat in the same turn. With the probe inline, a composer dismissed while the probe is in flight still creates the folder workspace and launches an agent — the same defect the full-creation hoist fixed on the git path. Resolve capabilities in `folder-submit-orchestration` above its existing gate and hand them down, so the create path's prefix is synchronous again. The parameter stays optional: a caller without a cancel gate keeps the probe. Both new tests fail against `origin/main` and against this branch's previous head; the cancel-window one still fails with its probe-pending assertion removed, so it pins the create, not just the probe. * refactor(native-chat): require pre-resolved capabilities on the folder create path The cancel-window fix in f492064432 left its invariant -- a caller that gates on cancellation must resolve capabilities above its gate -- enforced only by a comment, because `hostCapabilities` stayed optional with an inline probe as the fallback. A future caller that owns a cancel gate and forgets the parameter would silently reopen the window twice fixed already, and nothing would catch it: the caller census test pins `resolveAgentLaunchRoute` callers, not this function's, and `exactOptionalPropertyTypes` is off so even an explicit `undefined` is legal. Make it required and drop the now-unreachable inline probe. The sole production caller already passes it, so runtime behaviour is unchanged: the old ternary never evaluated its `await` when a value was supplied. `null` keeps its meaning -- probed, genuinely unknown -- and still degrades to the legacy route; only absence becomes impossible. The launch-route test that covered the removed probe is replaced by one pinning that `null` contract with the cache and the bridge both holding the structured capability, so only the handed-in value can produce the legacy outcome. The cases in the sibling suite are not about the route, so they go through one typed wrapper that supplies the unknown answer rather than repeating it 21 times. --------- Co-authored-by: Merge Sim --- .../app-shell/use-app-startup-hydration.ts | 7 +++ src/renderer/src/app-startup-routing.test.ts | 18 +++++++ .../local-runtime-capabilities.test.ts | 51 +++++++++++++++++++ .../src/runtime/local-runtime-capabilities.ts | 36 +++++++++++-- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 77da19ffd204..a2fd133ae32e 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -36,6 +36,7 @@ import { import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { restoreLocalStructuredSessionTabsOnce } from '../runtime/local-structured-session-tabs-sync' +import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' async function listRuntimeSessionHostIdsForStartup(): Promise { try { @@ -67,6 +68,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Fetch initial data + hydrate GitHub cache from disk useEffect(() => { + // Why first and ungated: the local capability set is a static fact the main process can answer + // immediately, but its only other writer is the structured-session-tabs sync, which waits for + // workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. Every + // `resolveAgentLaunchRoute` reader treats "not asked yet" as "unsupported", so leaving the + // answer behind those gates degrades a pre-hydration create to a bare terminal (#19154). + void ensureLocalRuntimeCapabilities() let cancelled = false // Why: declared outside the async block so cleanup can abort it — under StrictMode the first (unmounted) pass would otherwise keep spawning PTYs. const abortController = new AbortController() diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index fead2f6c7bbd..4c6ab94f0101 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -367,6 +367,24 @@ describe('renderer startup runtime routing', () => { ) }) + it('probes local runtime capabilities before any startup gate can hold the answer back', () => { + const source = readSource(STARTUP_HYDRATION_PATH) + const probeIndex = source.indexOf('void ensureLocalRuntimeCapabilities()') + const chainStart = source.indexOf('void (async () => {') + const effectStart = source.lastIndexOf('useEffect(() => {', probeIndex) + + expect(probeIndex).toBeGreaterThanOrEqual(0) + // Why pinned here: the structured-session-tabs sync is the cache's only other writer and it + // waits for workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. + // Every resolveAgentLaunchRoute reader — including the three that cannot await — reads an + // unanswered cache as "unsupported", so a create in that window degrades to a bare + // terminal (#19154). The probe must therefore start before the chain and outside its gates. + expect(probeIndex).toBeLessThan(chainStart) + expect(probeIndex).toBeLessThan(source.indexOf('await ', effectStart)) + expect(source.slice(effectStart, probeIndex)).not.toContain('if (') + expect(source.slice(effectStart, probeIndex)).not.toContain('experimentalStructuredNativeChat') + }) + it('orders packaged restoration before adoption, projection, and default creation', () => { // Why this file: the startup sequence moved out of App.tsx into the hydration hook; // the ordering it asserts is unchanged, only the module that now spells it out. diff --git a/src/renderer/src/runtime/local-runtime-capabilities.test.ts b/src/renderer/src/runtime/local-runtime-capabilities.test.ts index eedd31a748a7..d205e7128674 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.test.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + ensureLocalRuntimeCapabilities, readLocalRuntimeCapabilities, readLocalRuntimeCapabilitiesOrUnknown, refreshLocalRuntimeCapabilities, @@ -82,4 +83,54 @@ describe('local runtime capabilities', () => { await refreshLocalRuntimeCapabilities() expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual(['agent-session.structured.v1']) }) + it('ensure probes the runtime when no answer has landed yet', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const getStatus = vi.fn(async () => ({ capabilities: ['agent-session.structured.v1'] })) + Object.assign(window, { api: { runtime: { getStatus } } }) + + await expect(ensureLocalRuntimeCapabilities()).resolves.toEqual(['agent-session.structured.v1']) + expect(getStatus).toHaveBeenCalledOnce() + }) + + it('ensure returns the cached answer without probing again', async () => { + setLocalRuntimeCapabilitiesForTests(['agent-session.structured.v1']) + const getStatus = vi.fn(async () => ({ capabilities: [] })) + Object.assign(window, { api: { runtime: { getStatus } } }) + + await expect(ensureLocalRuntimeCapabilities()).resolves.toEqual(['agent-session.structured.v1']) + expect(getStatus).not.toHaveBeenCalled() + }) + + it('ensure stays unknown after a failed probe and re-probes on the next call', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const getStatus = vi + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({ capabilities: ['agent-session.structured.v1'] }) + Object.assign(window, { api: { runtime: { getStatus } } }) + + // A failed probe is not evidence about the host, so it must not latch as a denial: + // the answer stays unknown and the next caller pays for a fresh probe. + await expect(ensureLocalRuntimeCapabilities()).resolves.toBeNull() + await expect(ensureLocalRuntimeCapabilities()).resolves.toEqual(['agent-session.structured.v1']) + expect(getStatus).toHaveBeenCalledTimes(2) + }) + + it('ensure never rejects when the preload bridge is missing', async () => { + setLocalRuntimeCapabilitiesForTests(null) + Reflect.deleteProperty(window, 'api') + + await expect(ensureLocalRuntimeCapabilities()).resolves.toBeNull() + }) + + it('coalesces concurrent ensure callers onto one probe', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const getStatus = vi.fn(async () => ({ capabilities: ['agent-session.structured.v1'] })) + Object.assign(window, { api: { runtime: { getStatus } } }) + + await expect( + Promise.all([ensureLocalRuntimeCapabilities(), ensureLocalRuntimeCapabilities()]) + ).resolves.toEqual([['agent-session.structured.v1'], ['agent-session.structured.v1']]) + expect(getStatus).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/runtime/local-runtime-capabilities.ts b/src/renderer/src/runtime/local-runtime-capabilities.ts index 2bb0e1916d3e..f4c83880503b 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.ts @@ -15,9 +15,37 @@ export function readLocalRuntimeCapabilitiesOrUnknown(): readonly RuntimeCapabil return localRuntimeCapabilities } +/** Like `readLocalRuntimeCapabilitiesOrUnknown`, but probes the local runtime when no answer + * has landed yet, so a caller that can wait never reads "not asked yet" as "unsupported" + * (#19154: that reads a structured-native-chat create as a bare terminal). + * + * The renderer boot chain calls this once, ungated, so the answer is normally already cached + * by the time any launch route is resolved — including for the readers that are synchronous + * and cannot await. Awaiting it at a route decision is the backstop for the residual window + * and for re-probing after a failed one. Still `null` after an actually failed probe, and + * never rejects. */ +export async function ensureLocalRuntimeCapabilities(): Promise< + readonly RuntimeCapability[] | null +> { + if (localRuntimeCapabilities !== null) { + return localRuntimeCapabilities + } + await refreshLocalRuntimeCapabilities() + return localRuntimeCapabilities +} + +/** `refreshLocalRuntimeCapabilities` is not `async`, so a missing or broken preload bridge would + * throw synchronously out of it instead of settling into the unknown state its catch owns. */ +function startLocalRuntimeCapabilityProbe(): ReturnType { + try { + return window.api.runtime.getStatus() + } catch (error) { + return Promise.reject(error) + } +} + export function refreshLocalRuntimeCapabilities(): Promise { - refreshPromise ??= window.api.runtime - .getStatus() + refreshPromise ??= startLocalRuntimeCapabilityProbe() .then((status) => { localRuntimeCapabilities = [...(status.capabilities ?? [])] return localRuntimeCapabilities @@ -35,8 +63,8 @@ export function refreshLocalRuntimeCapabilities(): Promise Date: Sun, 13 Sep 2026 14:49:47 -0700 Subject: [PATCH 002/209] fix(worktrees): retire the chat tab of a chat with no child when its workspace goes (#19970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktrees): retire the chat tab of a chat with no child when its workspace goes Deleting a workspace left a chat tab behind for every structured session that had no attached provider child at the time, and that tab came back at the next launch pointing at a workspace that no longer exists. A provider child is scoped to a VISIBLE pane, not to a tab: the hold that keeps one is `enabled: isVisible && isWorktreeActive`, and dropping the last hold evicts the child after the release grace. So the sweep's liveness predicate selected only "the chat that is the visible pane in the active workspace, or was moments ago" — which means deleting a workspace from the sidebar while a different one is active left every chat in the target invisible to the sweep, and the `live.length === 0` early return did nothing at all. The durable reference is `visibleSessionIds` in the agent-session record store. Both purges a removal already performs miss it: the renderer drops `unifiedTabsByWorktree` and the main process drops the workspace metadata, and neither touches that index. Startup replays it, restores the session from it and republishes the tab. Worktree ids are path-derived, so a later workspace created at the same path inherits the old chat. Splits the two concerns the sweep conflated in one list. Liveness still decides what to CLOSE and what to refuse over, unchanged. Membership — the same fenced record filter minus the liveness clause — decides what to RETIRE, and covers exactly the complement of the close list so each session's tab is handled once. Retirement runs from `killAllProcessesForWorktree`, past every point that can refuse, not from the structured sweep itself: that sweep is joined BEFORE the unstopped-PTY verdict so a structured refusal can outrank a terminal one, and a tab retired there would still be ahead of a gate that can refuse the whole removal — leaving the workspace in place with its chats gone. * fix(worktrees): retire tabs across all teardown outcomes * test(worktrees): type teardown fixtures --------- Co-authored-by: Merge Sim --- src/main/ipc/worktrees-forget-local.test.ts | 2 + .../register-worktree-forget-handlers.ts | 3 + ...ructured-session-worktree-teardown.test.ts | 281 +++++++++++++++++- .../structured-session-worktree-teardown.ts | 80 ++++- src/main/runtime/worktree-teardown.ts | 60 +++- 5 files changed, 388 insertions(+), 38 deletions(-) diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index 9bace0296315..95fdcc27b308 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -138,6 +138,7 @@ describe('registerWorktreeHandlers', () => { resolvedConnectionId: 'ssh-dead', localProvider: ptyProvider, onPtyStopped: clearProviderPtyStateMock, + closeStructuredSessions: true, includeProviderInventory: false, includeLocalRegistry: false }) @@ -191,6 +192,7 @@ describe('registerWorktreeHandlers', () => { resolvedConnectionId: 'ssh-live', localProvider: sshProvider, onPtyStopped: clearProviderPtyStateMock, + closeStructuredSessions: true, includeProviderInventory: true, includeLocalRegistry: false }) diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts index 393ccc1a77b2..0c97dda68bd4 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -103,6 +103,9 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi : {}), localProvider: sshPtyProvider ?? getLocalPtyProvider(), onPtyStopped: clearProviderPtyState, + // Forgetting an orphan still purges its workspace metadata, so retire structured chat + // tabs even when no provider child is attached to the workspace. + closeStructuredSessions: true, ...(externalHost ? { includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index bb022d4ba162..86258ad9dab3 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { IPtyProvider } from '../providers/types' const hostRef: { current: unknown } = { current: null } @@ -8,13 +9,13 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( })) const { killAllProcessesForWorktree } = await import('./worktree-teardown') +type TeardownRuntime = NonNullable[1]['runtime']> const { classifyWorktreeForceDeleteReason, isProvenLiveStructuredSessionRemovalError, isUnstoppedPtyRemovalError } = await import('../../shared/worktree/removal') -const { listLiveStructuredSessionsForWorktree } = - await import('./structured-session-worktree-teardown') +const { listStructuredSessionsForWorktree } = await import('./structured-session-worktree-teardown') const WORKTREE = 'repo_1::/tmp/wt-a' const OTHER_WORKTREE = 'repo_1::/tmp/wt-b' @@ -58,6 +59,13 @@ function installHost(options: { closeGates?: Record> /** Sessions in the persisted visible-tab index, so a rollback has something to put back. */ visible?: string[] + /** + * Sessions this host is not holding, so they observe `unverifiable` rather than `live`. + * + * The everyday shape, not an edge case: the provider child belongs to the VISIBLE pane, so any + * chat outside the active workspace has already been evicted by the release clock. + */ + detached?: Set /** * Sessions whose death evidence lands DURING the close's tab-restore write. * @@ -66,7 +74,11 @@ function installHost(options: { */ exitsDuringTabRestore?: Set }): { closed: string[]; visible: Set } { - const held = new Set(options.records.map((entry) => entry.sessionId)) + const held = new Set( + options.records + .map((entry) => entry.sessionId) + .filter((sessionId) => !options.detached?.has(sessionId)) + ) const closed: string[] = [] const visible = new Set(options.visible ?? []) const recordExit = (sessionId: string): void => { @@ -132,6 +144,17 @@ function destructiveDeps(extra: { allowUnverifiedStop?: boolean; timeoutMs?: num } } +function runtimeDouble(hooks: object): TeardownRuntime { + return Object.assign(Object.create(null), hooks) +} + +function livePtyProvider(): IPtyProvider { + return Object.assign(Object.create(null), { + listProcesses: async () => [{ id: 'pty-1' }], + shutdown: async () => {} + }) +} + /** The structured sweep's own warn — a forced removal can emit a PTY-sweep one onto the same spy. */ function structuredSessionWarning(warn: { mock: { calls: unknown[][] } }): string { return ( @@ -148,9 +171,21 @@ describe('worktree teardown and structured agent sessions', () => { it('finds sessions by workspace, and ignores a sibling worktree', () => { installHost({ records: [record('s1', WORKTREE), record('s2', OTHER_WORKTREE)] }) - expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual([ - { sessionId: 's1', agent: 'claude' } - ]) + expect(listStructuredSessionsForWorktree(WORKTREE, {})).toEqual({ + members: [{ sessionId: 's1', agent: 'claude' }], + live: [{ sessionId: 's1', agent: 'claude' }] + }) + }) + + it('counts a chat with no attached child as a member but never as live', () => { + // The split this file's two lists exist for. A provider child is scoped to a VISIBLE pane, so + // every chat in a workspace the user is not currently looking at observes non-live — and a + // liveness-only list therefore saw nothing at all to act on for the commonest delete there is. + installHost({ records: [record('s1', WORKTREE)], detached: new Set(['s1']) }) + expect(listStructuredSessionsForWorktree(WORKTREE, {})).toEqual({ + members: [{ sessionId: 's1', agent: 'claude' }], + live: [] + }) }) it('closes a live session on an ordinary removal instead of refusing it', async () => { @@ -298,14 +333,21 @@ describe('worktree teardown and structured agent sessions', () => { it('still removes under force when a close does not settle, and says so', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) - const result = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ allowUnverifiedStop: true }) - ) + const retired: string[] = [] + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']), visible: ['s1'] }) + const result = await killAllProcessesForWorktree(WORKTREE, { + ...destructiveDeps({ allowUnverifiedStop: true }), + runtime: runtimeDouble({ + retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => { + retired.push(sessionId) + return true + } + }) + }) expect(result.structuredStopped).toBeUndefined() // The live arm of that record, carrying the verdict the refusal would have shown. expect(structuredSessionWarning(warn)).toContain('still live: 1 agent session (claude)') + expect(retired).toEqual(['s1']) warn.mockRestore() }) @@ -365,11 +407,14 @@ describe('worktree teardown and structured agent sessions', () => { installHost({ records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)] }) - const local = [{ sessionId: 's2', agent: 'claude' }] - expect(listLiveStructuredSessionsForWorktree(WORKTREE, { resolvedConnectionId: null })).toEqual( + const local = { + members: [{ sessionId: 's2', agent: 'claude' }], + live: [{ sessionId: 's2', agent: 'claude' }] + } + expect(listStructuredSessionsForWorktree(WORKTREE, { resolvedConnectionId: null })).toEqual( local ) - expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual(local) + expect(listStructuredSessionsForWorktree(WORKTREE, {})).toEqual(local) }) it('closes only the session on the host the removal resolved to', async () => { @@ -603,6 +648,214 @@ describe('worktree teardown and structured agent sessions', () => { await expect(removal).resolves.toMatchObject({ structuredStopped: 1 }) }) + it('retires the chat tab of a chat that had no child to close', async () => { + // The orphan. Deleting a workspace from the sidebar while a different one is active leaves + // every chat in the target non-live — the provider child belongs to the VISIBLE pane — so the + // close list was empty and the sweep returned early. The durable `visibleSessionIds` reference + // survived both purges a removal already performs, and startup replayed it: the chat tab came + // back at the next launch pointing at a workspace that no longer exists. + const host = installHost({ + records: [record('s1', WORKTREE)], + detached: new Set(['s1']), + visible: ['s1'] + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({ + runtimeStopped: 0 + }) + expect(host.closed).toEqual([]) + expect([...host.visible]).toEqual([]) + }) + + it('retires it from the live tab snapshot as well as the durable index', async () => { + // `setSessionTabVisibility(false)` only clears the restore index; the chat tab published on + // screen survives it for the rest of the app session and re-attaches the session when opened. + const retired: string[] = [] + const runtime = { + stopTerminalsForWorktree: async () => ({ stopped: 0 }), + retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => { + retired.push(sessionId) + return true + } + } as never + installHost({ + records: [record('s1', WORKTREE)], + detached: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime }) + expect(retired).toEqual(['s1']) + }) + + it('keeps every chat tab when the removal refuses over a session it could not close', async () => { + // The load-bearing interaction. A refusal leaves the workspace — and its chat tabs — exactly + // where they were, so retirement must not have run: a destructive operation that refused and + // still took the user's chats away is the very harm this sweep's rollback exists to prevent. + // Both members are covered, the stuck one and the detached bystander beside it. + const host = installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE)], + stuck: new Set(['s1']), + detached: new Set(['s2']), + visible: ['s1', 's2'] + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow( + /still live: 1 agent session \(claude\)/ + ) + expect([...host.visible].sort()).toEqual(['s1', 's2']) + }) + + it('keeps every chat tab when the unstopped-PTY gate refuses the removal', async () => { + // The reason retirement is NOT done inside the structured sweep. That sweep is joined BEFORE + // the per-PTY verdict so a structured refusal can outrank a terminal one — which means a tab + // retired at the end of it would still be ahead of a gate that can refuse the whole removal, + // and this workspace survives with its chats gone. + const runtime = runtimeDouble({ + stopTerminalsForWorktree: async ( + _worktreeId: string, + options: { stopPty: (ptyId: string, stop: () => Promise) => Promise } + ) => { + await options.stopPty('pty-1', async () => false) + return { stopped: 0 } + } + }) + const liveProvider = livePtyProvider() + const host = installHost({ + records: [record('s1', WORKTREE)], + detached: new Set(['s1']), + visible: ['s1'] + }) + await expect( + killAllProcessesForWorktree(WORKTREE, { + localProvider: liveProvider, + requirePhysicalStop: true, + includeProviderInventory: false, + includeLocalRegistry: false, + runtime + }) + ).rejects.toThrow(/still live: pty-1/) + expect([...host.visible]).toEqual(['s1']) + }) + + it('retires the chat tab under force, which deletes the workspace anyway', async () => { + const host = installHost({ + records: [record('s1', WORKTREE)], + detached: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, destructiveDeps({ allowUnverifiedStop: true })) + expect([...host.visible]).toEqual([]) + }) + + it('retires the chat tab for a folder-workspace removal too', async () => { + // No checkout vanishes there, but the workspace metadata does, so a republished tab at the + // next launch points at a workspace Orca has forgotten. That path already drops the tab for + // the sessions it DID close, so leaving the detached ones is the inconsistency being fixed. + const host = installHost({ + records: [record('s1', WORKTREE)], + detached: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, { + localProvider, + includeProviderInventory: false as const, + includeLocalRegistry: false as const, + closeStructuredSessions: true + }) + expect([...host.visible]).toEqual([]) + }) + + it('retires a live snapshot tab when folder cleanup cannot close its child', async () => { + const retired: string[] = [] + const host = installHost({ + records: [record('s1', WORKTREE)], + stuck: new Set(['s1']), + visible: ['s1'] + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await killAllProcessesForWorktree(WORKTREE, { + localProvider, + includeProviderInventory: false as const, + includeLocalRegistry: false as const, + closeStructuredSessions: true, + runtime: runtimeDouble({ + retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => { + retired.push(sessionId) + return true + } + }) + }) + expect([...host.visible]).toEqual([]) + expect(retired).toEqual(['s1']) + warn.mockRestore() + }) + + it('retires tabs before propagating a best-effort PTY sweep failure', async () => { + const host = installHost({ + records: [record('s1', WORKTREE)], + detached: new Set(['s1']), + visible: ['s1'] + }) + const retired: string[] = [] + const settleModule = await import('./settle-before-deadline') + const settle = settleModule.settleBeforeDeadline + const rejection = vi + .spyOn(settleModule, 'settleBeforeDeadline') + .mockImplementation((run, fallback, deadline, failClosedError, failClosedOnRunError) => { + if (fallback === 0) { + return Promise.reject(new Error('terminal inventory unavailable')) + } + return settle(run, fallback, deadline, failClosedError, failClosedOnRunError) + }) + try { + await expect( + killAllProcessesForWorktree(WORKTREE, { + localProvider, + includeProviderInventory: true, + includeLocalRegistry: false, + closeStructuredSessions: true, + runtime: runtimeDouble({ + retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => { + retired.push(sessionId) + return true + } + }) + }) + ).rejects.toThrow('terminal inventory unavailable') + } finally { + rejection.mockRestore() + } + expect([...host.visible]).toEqual([]) + expect(retired).toEqual(['s1']) + }) + + it('retires nothing on a reconciliation sweep, which deletes no workspace', async () => { + // Those callers repair state. They close no session, so they must retire no tab either — + // the workspace and its checkout are both still there. + const host = installHost({ + records: [record('s1', WORKTREE)], + detached: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, { + localProvider, + includeProviderInventory: false, + includeLocalRegistry: false + }) + expect([...host.visible]).toEqual(['s1']) + }) + + it('leaves a same-id workspace on another host holding its chat tab', async () => { + // The membership filter is fenced for the same reason the close list is: `repoId::path` names + // a different workspace on every host, and retiring a tab for one host's workspace takes a + // chat tab from another's. + const host = installHost({ + records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' })], + detached: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, destructiveDeps()) + expect([...host.visible]).toEqual(['s1']) + }) + it('does not block removal when no structured host is installed', async () => { // Not being able to look is not evidence a child is there, and reading the persisted store // directly would force-install the host as a side effect of a teardown. diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 8b161e4f5a83..46265a161118 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -33,12 +33,12 @@ import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab import type { WorktreePtyHostFence } from './worktree-pty-host-fence' import type { OrcaRuntimeService } from './orca-runtime' -export type LiveStructuredSessionInWorkspace = { +export type StructuredSessionInWorkspace = { sessionId: string agent: 'claude' | 'codex' } -export type UnclosedStructuredSession = LiveStructuredSessionInWorkspace & { +export type UnclosedStructuredSession = StructuredSessionInWorkspace & { /** Read AFTER the close: `live` is a child watched stay attached, not merely one left unproven. */ status: 'live' | 'unverifiable' } @@ -83,36 +83,61 @@ export function structuredSessionTeardownHostId( } /** - * Structured sessions with a proven-live child in this worktree, on the fenced host only. + * The workspace's structured sessions, split into what belongs to it and what is attached. + * + * MEMBERSHIP and LIVENESS answer different questions, and folding them into one list is what let + * a chat tab outlive its workspace. A provider child is scoped to a VISIBLE pane — the hold that + * keeps one is `enabled: isVisible && isWorktreeActive`, and dropping the last hold evicts the + * child after the release grace — so `live` really means "this chat is the visible pane in the + * active workspace, or was moments ago". Deleting a workspace from the sidebar while a different + * one is active makes every chat in the target non-live. Those are exactly the sessions a + * liveness-only list never saw. + */ +export type StructuredSessionsForWorktree = { + /** Every session bound to this workspace on the fenced host, attached or not. */ + members: StructuredSessionInWorkspace[] + /** The subset with a proven-live provider child: what the sweep closes and may refuse over. */ + live: StructuredSessionInWorkspace[] +} + +/** + * Structured sessions in this worktree, on the fenced host only. * * An uninstalled host answers empty rather than throwing: no host in this generation means no * provider child was started by this process, and the three PTY sweeps fall through the same way * when their surface is unavailable. It is deliberately NOT read through the persisted store * directly — that would force-install the host, which is itself a side effect on a teardown path. + * + * One enumeration and one observation per member, because both answers are read by the same + * caller: re-deriving the live subset separately would run every liveness observation twice. */ -export function listLiveStructuredSessionsForWorktree( +export function listStructuredSessionsForWorktree( worktreeId: string, fence: StructuredSessionHostFence -): LiveStructuredSessionInWorkspace[] { +): StructuredSessionsForWorktree { const host = getStructuredAgentSessionHost() if (!host) { - return [] + return { members: [], live: [] } } let records: ReturnType try { records = host.deps.store.listRecords() } catch { - return [] + return { members: [], live: [] } } const hostId = structuredSessionTeardownHostId(fence) - return records + const members = records .filter( (record) => - record.location.workspaceId === worktreeId && - record.location.executionHostId === hostId && - observeStructuredWorker({ sessionId: record.sessionId }).status === 'live' + record.location.workspaceId === worktreeId && record.location.executionHostId === hostId ) .map((record) => ({ sessionId: record.sessionId, agent: record.provider })) + return { + members, + live: members.filter( + (session) => observeStructuredWorker({ sessionId: session.sessionId }).status === 'live' + ) + } } /** @@ -166,7 +191,7 @@ export function describeUnclosedStructuredSessions( */ export type StructuredSweepProgress = { /** The sessions this sweep closes, in the order the loop reaches them. */ - readonly sessions: readonly LiveStructuredSessionInWorkspace[] + readonly sessions: readonly StructuredSessionInWorkspace[] /** Sessions no longer attached after their close — the count this sweep reports. */ closed: number /** Attempted closes that did not settle, each carrying the verdict re-read after the attempt. */ @@ -176,7 +201,7 @@ export type StructuredSweepProgress = { } export function createStructuredSweepProgress( - sessions: readonly LiveStructuredSessionInWorkspace[] + sessions: readonly StructuredSessionInWorkspace[] ): StructuredSweepProgress { return { sessions, closed: 0, unstopped: [], settled: 0 } } @@ -276,6 +301,35 @@ export async function closeStructuredSessionsForWorktree( } } +/** + * Retires the chat tabs of every structured session in a workspace this removal is discarding. + * + * The close path already does this for a session it CLOSED. This is the complement: the members + * with no attached child, which the close list never contained and nothing else will hide. Their + * durable reference survives every purge a removal already performs: the renderer drops `unifiedTabsByWorktree` and the main + * process drops the workspace metadata, and neither touches `visibleSessionIds`. Startup replays + * that index, restores the session from it and republishes the tab, so the chat comes back at the + * next launch pointing at a workspace that is gone. Worktree ids are path-derived and can be + * recreated, so a later workspace at the same path inherits the tab — which is the hazard + * `removeWorktreeMetadataAndHistory` purges everything else to prevent. + * + * The caller owns WHEN: this must only be reached once the removal can no longer refuse, because + * a refusal leaves the workspace and its tabs in place. Same reasoning as the close's own + * `restoreTabOnUnprovenClose` gate, one level up. + * + * Serial, and structurally unable to fail the teardown: each step is a store transaction that + * serializes per session anyway, and a removal must not be turned back by tab bookkeeping. + */ +export async function retireStructuredSessionTabsForWorktree( + sessions: readonly StructuredSessionInWorkspace[], + runtime?: StructuredWorktreeSweepRuntime +): Promise { + for (const session of sessions) { + await dropDurableChatTabReference(session.sessionId) + retireSettledStructuredWorkerTab(session.sessionId, runtime) + } +} + /** * Drops a settled session's durable chat-tab reference, and cannot fail the settlement. * diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 84275b40eedf..417bf4ff1f76 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -17,8 +17,10 @@ import { closeStructuredSessionsForWorktree, createStructuredSweepProgress, describeUnclosedStructuredSessions, - listLiveStructuredSessionsForWorktree, - unclosedStructuredSessions + listStructuredSessionsForWorktree, + retireStructuredSessionTabsForWorktree, + unclosedStructuredSessions, + type StructuredSessionInWorkspace } from './structured-session-worktree-teardown' import { createWorktreeSweepTracker, @@ -208,7 +210,8 @@ export async function killAllProcessesForWorktree( for (const sweep of [runtimeSweep, providerSweep, registrySweep]) { void sweep.catch(() => undefined) } - const structuredStopped = await structuredSweep + const structured = await structuredSweep + const structuredStopped = structured.closed let runtimeResult: { stopped: number } let providerStopped: number let registryStopped: number @@ -222,7 +225,9 @@ export async function killAllProcessesForWorktree( if (forced.incomplete) { // Carries the structured count out too: this early return skips the PTY verdict, not the // sweep that already closed a user's chats, and dropping it makes the log say `structured=0` - // for a removal that closed some. + // for a removal that closed some. Force deletes whatever the sweeps reported, so the chat + // tabs go with the workspace here as well. + await retireStructuredSessionTabsForWorktree(structured.retirable, deps.runtime) return { ...forced.stopped, ...(structuredStopped > 0 ? { structuredStopped } : {}) } } runtimeResult = { stopped: forced.stopped.runtimeStopped } @@ -239,6 +244,12 @@ export async function killAllProcessesForWorktree( registrySweep ]) } catch (error) { + // Folder/orphan removals intentionally continue after a best-effort PTY failure and purge + // workspace metadata in their caller. Retire tabs before rethrowing so a swallowed teardown + // error cannot leave chats pointing at the forgotten workspace. + if (!deps.requirePhysicalStop) { + await retireStructuredSessionTabsForWorktree(structured.retirable, deps.runtime) + } // Why (#11960): this rejection is the provider's own wording, which the force // classifier cannot recognise — so the wedge Force Delete exists for was the one // failure that never offered it. Re-word it, keeping the original as the cause. @@ -284,6 +295,11 @@ export async function killAllProcessesForWorktree( } } + // Past every refusal, and only here. A removal that refuses — over an unclosed session above, a + // sweep that failed outright, or the unstopped-PTY gate just now — leaves the workspace and its + // chat tabs exactly where they were, so retiring a tab before this point would take the user's + // chat away on a delete that never happened. + await retireStructuredSessionTabsForWorktree(structured.retirable, deps.runtime) return { runtimeStopped: runtimeResult.stopped, providerStopped, @@ -292,6 +308,19 @@ export async function killAllProcessesForWorktree( } } +type StructuredSweepOutcome = { + /** Sessions this sweep proved closed — the count the removal log and result report. */ + closed: number + /** + * Sessions whose tabs still need retirement once this removal is committed: detached members, + * plus live members left unclosed by a forced/best-effort removal. Carried to the caller because + * this sweep is joined BEFORE the unstopped-PTY verdict, which may still refuse the removal. + */ + retirable: readonly StructuredSessionInWorkspace[] +} + +const NO_STRUCTURED_SWEEP: StructuredSweepOutcome = { closed: 0, retirable: [] } + /** * The fourth sweep: structured agent sessions bound to this worktree, on this host. * @@ -313,16 +342,22 @@ async function sweepStructuredSessions( deps: WorktreeTeardownDeps, deadline: number, sweeps: WorktreeSweepTracker -): Promise { +): Promise { if (!deps.requirePhysicalStop && !deps.closeStructuredSessions) { - return 0 + return NO_STRUCTURED_SWEEP } // `deps` carries the same two host fields the PTY sweeps fence on, and a `repoId::path` id names // a different workspace on every host — so an unfenced list would close a live chat belonging to - // an SSH or paired-runtime copy of the id being removed here. - const live = listLiveStructuredSessionsForWorktree(worktreeId, deps) + // an SSH or paired-runtime copy of the id being removed here. The same fence carries the + // membership half: a tab retired for one host's workspace is a tab taken from another's. + const { members, live } = listStructuredSessionsForWorktree(worktreeId, deps) + const liveIds = new Set(live.map((session) => session.sessionId)) + // A chat with no attached child is precisely the one this removal used to leave a durable tab + // reference for, and it is invisible to every list below — so it comes out even when the close + // loop below is skipped entirely, which is the common case for a delete from the sidebar. + const retirable = members.filter((session) => !liveIds.has(session.sessionId)) if (live.length === 0) { - return 0 + return { closed: 0, retirable } } // Raced against the same sweep budget every PTY surface is bounded by, because `host.close` // awaits a provider round trip whose own eviction steps are each bounded well past this budget. @@ -352,7 +387,7 @@ async function sweepStructuredSessions( const closed = progress.closed const unstopped = unclosedStructuredSessions(progress) if (unstopped.length === 0) { - return closed + return { closed, retirable } } // Only a proof-requiring removal may refuse. A folder-workspace removal shares its root, so no // checkout disappears under the child — the harm is a session left pointing at a workspace Orca @@ -372,5 +407,8 @@ async function sweepStructuredSessions( console.warn( `[worktree-teardown] forcing removal of ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeUnclosedStructuredSessions(unstopped)}` ) - return closed + // A best-effort or forced removal still discards the workspace when a live close remains + // unproven. Its close path intentionally leaves the live snapshot tab in place, so carry those + // sessions into the post-removal retirement pass alongside the detached members. + return { closed, retirable: [...retirable, ...unstopped] } } From 1f7655f3e3a587b5fd261542128b404b3c394215 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:53:50 -0400 Subject: [PATCH 003/209] feat(ai-vault-search): public session search contract and transports (#20277) * feat(ai-vault-search): define public contract and service seam * feat(ai-vault-search): add IPC runtime relay and web transports * fix(ai-vault-search): register search IPC at the core handler site ai-vault.ts was two lines over the 300-line max-lines limit; the search handlers belong with the other register*Handlers calls anyway. * fix(ai-vault-search): withhold degraded-root paths from relay status Status carried local filesystem paths over the relay while hits redact theirs. redactStatusForTransport applies the same policy at the same boundary: relay callers keep each root's reason and the array length as the count, so the type only makes root optional. * fix(ai-vault-search): close diagnostic path leak and remove test casts * feat(ai-vault-search): carry an execution host id and per-host outcomes on hits * feat(ai-vault-search): route desktop search by execution host scope, including runtimes * feat(preload): accept an execution host scope on session search * feat(web): answer only for the paired runtime on session search * docs(ai-vault-search): describe execution-host routing and the all-hosts merge * test(ai-vault-search): cover every host scope, the all-hosts merge and wire compat * fix(ai-vault-search): resume every host mid-page so a merged page never drops a hit * fix(ai-vault-search): decode the merged cursor with a schema instead of casts CI's type-aware audit refuses type assertions; a zod record validates the per-host entries and yields the typed map without one. * refactor(ai-vault-search): defer cross-host merged search --- .gitignore | 1 + .../agent-session-search-contract.md | 137 +++++++++++++++++ .../session-search-engine-types.ts | 14 +- .../ai-vault-search/session-search-engine.ts | 4 + .../session-search-service-registry.test.ts | 124 +++++++++++++++ .../session-search-service-registry.ts | 76 ++++++++++ .../session-search-service.test.ts | 110 ++++++++++++++ .../ai-vault-search/session-search-service.ts | 95 ++++++++++++ .../runtime-session-search-call.test.ts | 37 +++++ .../ai-vault/runtime-session-search-call.ts | 17 +++ src/main/ipc/ai-vault-all-host-timeouts.ts | 10 ++ src/main/ipc/ai-vault-search.test.ts | 142 ++++++++++++++++++ src/main/ipc/ai-vault-search.ts | 116 ++++++++++++++ src/main/ipc/ai-vault.ts | 16 +- .../register-core-handlers.test.ts | 6 + .../register-core-handlers.ts | 6 + src/main/ipc/ssh.ts | 25 +++ .../rpc/methods/ai-vault-search.test.ts | 78 ++++++++++ src/main/runtime/rpc/methods/ai-vault.ts | 20 +++ .../runtime-rpc-mobile-method-allowlist.ts | 2 + src/main/ssh/ssh-relay-session.ts | 8 + src/preload/api/ai-vault-api.ts | 13 ++ src/preload/api/ai-vault-bridge.ts | 20 +++ src/relay/ai-vault-handler.ts | 10 ++ src/relay/session-search-transport.test.ts | 87 +++++++++++ .../src/web/preload-api/web-ai-vault-api.ts | 27 ++++ .../web-ai-vault-search-compat.test.ts | 78 ++++++++++ src/shared/ai-vault-search-client.ts | 83 ++++++++++ src/shared/ai-vault-search-contract.test.ts | 121 +++++++++++++++ src/shared/ai-vault-search-contract.ts | 108 +++++++++++++ src/shared/ai-vault-search-limit.ts | 8 + src/shared/ai-vault-search-test-fixture.ts | 43 ++++++ src/shared/ai-vault-search-transport.ts | 33 ++++ src/shared/ai-vault-search-types.ts | 22 +++ .../rpc-params-catalog.generated.ts | 6 + 35 files changed, 1682 insertions(+), 21 deletions(-) create mode 100644 docs/reference/agent-session-search-contract.md create mode 100644 src/main/ai-vault-search/session-search-service-registry.test.ts create mode 100644 src/main/ai-vault-search/session-search-service-registry.ts create mode 100644 src/main/ai-vault-search/session-search-service.test.ts create mode 100644 src/main/ai-vault-search/session-search-service.ts create mode 100644 src/main/ai-vault/runtime-session-search-call.test.ts create mode 100644 src/main/ai-vault/runtime-session-search-call.ts create mode 100644 src/main/ipc/ai-vault-all-host-timeouts.ts create mode 100644 src/main/ipc/ai-vault-search.test.ts create mode 100644 src/main/ipc/ai-vault-search.ts create mode 100644 src/main/runtime/rpc/methods/ai-vault-search.test.ts create mode 100644 src/relay/session-search-transport.test.ts create mode 100644 src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts create mode 100644 src/shared/ai-vault-search-client.ts create mode 100644 src/shared/ai-vault-search-contract.test.ts create mode 100644 src/shared/ai-vault-search-contract.ts create mode 100644 src/shared/ai-vault-search-limit.ts create mode 100644 src/shared/ai-vault-search-test-fixture.ts create mode 100644 src/shared/ai-vault-search-transport.ts create mode 100644 src/shared/ai-vault-search-types.ts diff --git a/.gitignore b/.gitignore index b033ebe1cca7..c132a265c836 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,7 @@ docs/** !docs/reference/ !docs/reference/agent-pty-transcript-capture.md !docs/reference/agent-session-search-query-tuning.md +!docs/reference/agent-session-search-contract.md !docs/reference/agent-status-store.md !docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md diff --git a/docs/reference/agent-session-search-contract.md b/docs/reference/agent-session-search-contract.md new file mode 100644 index 000000000000..1b21cd06531c --- /dev/null +++ b/docs/reference/agent-session-search-contract.md @@ -0,0 +1,137 @@ +# Agent session search contract + +`AiVaultSearchRequest`, `AiVaultSearchResponse`, `AiVaultSearchHit`, and +`AiVaultSearchStatus` are defined in `src/shared/ai-vault-search-types.ts` and +validated by `src/shared/ai-vault-search-contract.ts`. + +## Search and pagination + +- Tool output beyond 3,072 characters per row is not indexed and not searchable; user and assistant text is indexed in full. +- A page cursor outstanding during a retention purge is refused once as `stale-cursor`; the client re-issues page 1. +- A phrase match across a chunk boundary of a long message is not supported. + +`aiVault.searchSessions(request)` accepts `query`, optional `scope` +(`conversation` or `all`, default `all`), `freshness` (`indexed` or +`wait-until-current`, default `indexed`), `limit`, opaque `cursor`, `filters`, +and `debug` (default false). Conversation scope searches user and assistant text. +Filters accept `agents`, `scopePaths`, ISO `since`, and `sort` (`relevance` or +`newest`). Paths refer to the execution host and work for folders without Git. +Legacy `tier` and `refresh` fields are accepted and discarded; they do not change +the defaults. Limits use the engine's resolver: default 20, integers clamped to +1–100, fractional numbers use the default. Long queries reach the engine so it +can report truncation rather than fail validation. + +Results contain `kind: 'results'`, `hits`, `page: { cursor, hasMore }`, +`generation`, `truncated: { candidates, snippets, query, freshness }`, and +`durationMs`. `snippets` is a count; the other truncation fields are booleans. +`durationMs` measures the engine search, excluding any reconciliation wait. +`debug: true` adds `debug: { route, repairedTerms?, plannerReport }`; the report +contains `route`, optional `repairedTerms`, and `scope`. Diagnostics never appear +at the top level. Status is never attached to search results. + +A cursor belongs to one query and one host's index generation. Query, scope, +filters, and sorting must remain the same; page size may change. Writes that +advance the generation can invalidate it, including retention purges. A refused +cursor yields `{ kind: 'stale-cursor', generation, expectedGeneration? }` and the +client discards it and issues page 1 without a cursor. Reusing that refused cursor +continues to fail; there is no server-side cursor acknowledgement state. +Malformed cursors and cursors for a different query yield +`{ kind: 'malformed-cursor' }`. Generation checks also reject a first page if the +index changes during retrieval. Generation is a fence, not a retained snapshot: +a client cannot ask the host to recreate a previous generation. + +Pages are per host only. Ordering is local to that host's query. Clients must +discard cursors when changing hosts. All-computers search, merged ordering, +per-host aggregate outcomes, and merged cursors are deferred to a separate PR. +That follow-up must define generation fencing, page-size changes, unavailable +hosts, and bounded parallel retrieval before exposing a combined result list. + +## Execution host routing + +Search and status address one execution host: `local`, `ssh:`, or +`runtime:`. An omitted host means this desktop's local index. +Invalid IDs and `all` are refused; neither can widen a request to other hosts. + +- `local` searches this machine's index over desktop IPC. +- `ssh:` asks that relay session and nothing else. +- `runtime:` asks that paired runtime over its RPC. A paired + runtime answers for itself and never forwards through another desktop. + +Each hit may carry `executionHostId`. The desktop stamps remote answers with +the host it addressed rather than trusting an ID returned by that host. +Local answers and older hosts may omit attribution. + +## Evidence and exposure + +Each hit carries agent, session ID, title, cwd, branch, updated time, message +count, score, source, and evidence. Evidence contains snippet, role, and timestamp; +it is null for operator-only matches that have no text evidence. Snippet matches +use `[[` and `]]` markers. Source presence is `present`, `unverifiable`, or +`missing`; the current engine emits the first two. Loss of contact does not prove +a source missing. + +`redactForTransport(hit, transport)` is the exposure policy: + +| Transport | filePath / codexHome | resumeCommand | Status `degradedRoots[].root` | +| ----------------------------------------- | ------------------------------------ | --------------------------------- | ----------------------------- | +| Desktop IPC on the same machine | Included when known, under source | Included only for present sources | Included | +| Runtime RPC on the same machine | Included when known, under source | Included only for present sources | Included | +| Relay or paired runtime/web/mobile client | Withheld; source keeps presence only | Withheld | Withheld | + +`cwd`, titles, snippets, and other hit metadata remain visible to paired clients. +Snippets cross the authenticated transport as indexed; this contract does not +apply an observability redactor to transcript content. A missing Codex home is +omitted. Resume commands reuse the command stored by the transcript reader, +constructed by the sidebar's `buildAiVaultResumeCommand`; this layer does not +construct commands or execute them. The runtime uses its authenticated +`clientKind` context to distinguish paired clients from same-machine RPC, never +a request-supplied locality flag. The receiving remote client also applies the +same exposure function. + +## Status, freshness, and availability + +`aiVault.searchStatus()` returns `enabled`, `phase` (`idle`, `indexing`, `current`, +`degraded`, or `closed`), `filesIndexed`, `filesDue`, `filesFailed`, `degradedRoots` +(`root` and `reason`), `lastReconcileAt`, `lastSweepCompletedAt`, and `generation`. +Times are milliseconds since epoch or null. These are the indexer's observations; +an indexed row is not a new filesystem verification. A degraded root's `root` and +raw `reason` can both contain host filesystem paths. Desktop IPC and same-machine runtime RPC receive the full diagnostic; +relay and paired clients receive only the fixed reason "Source root could not +be verified." for each degraded root. The array length retains the count. + +`wait-until-current` calls `service.reconcile()` before searching. The adapter +uses `indexer.reconcile({ full: false })`. After five seconds the endpoint searches +anyway and sets `truncated.freshness: true` on results. It does not cancel the +host's reconciliation. Completion before the deadline leaves the flag false; +a reconciliation error before the deadline propagates. The indexer's bounded +recent pass is not a promise that the entire historical corpus was swept. + +Search unavailability is a value: +`{ kind: 'unavailable', reason: 'disabled' | 'not-ready' | 'no-service' }`. +No registered service returns `no-service`. Status without a service has +`enabled: false`, `phase: 'idle'`, zero counts and generation, empty degraded roots, +and null timestamps. This is a sentinel for an absent service, not a claim of an +empty, current index. A registered service may report disabled or not-ready. + +## Boundaries and compatibility + +- Desktop: `aiVault:searchSessions` and `aiVault:searchStatus`, via preload. +- Runtime and relay: `aiVault.searchSessions` and `aiVault.searchStatus`. +- Desktop preload optionally accepts an execution host scope as a separate + routing argument. It addresses exactly that host; missing connections never + fall back to the local index. The web preload addresses its own paired runtime, + answers `all` and any other host with + `unavailable/no-service` rather than an error. + +Requests and responses are parsed where received from another process. Existing +relay JSON-RPC request/response framing needs no new stream opcode. Following the +existing relay method probe pattern, an old host's explicit `-32601` refusal (or +runtime `method_not_found`) maps to `unavailable/no-service` on the client; status +uses the absent-service sentinel above. Transport failures, authentication errors, +and invalid payloads remain errors. Unknown request fields are stripped for wire +compatibility. + +The process-local `setSessionSearchService(service | null)` registry is the only +production seam in this PR. Tests use fake services and a real synthetic store. +Nothing constructs an engine or indexer in production. PR 3b owns process +lifecycle, consent/settings application, and registration of the production service. diff --git a/src/main/ai-vault-search/session-search-engine-types.ts b/src/main/ai-vault-search/session-search-engine-types.ts index 055bbe001af4..57229deb90ae 100644 --- a/src/main/ai-vault-search/session-search-engine-types.ts +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -5,8 +5,11 @@ import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consu // PR 5 owns the public contract and lifts what a caller may actually receive; // until then a field can be added, renamed or dropped without a compat story. -export const SESSION_SEARCH_LIMIT_DEFAULT = 20 -export const SESSION_SEARCH_LIMIT_MAX = 100 +export { + SESSION_SEARCH_LIMIT_DEFAULT, + SESSION_SEARCH_LIMIT_MAX, + resolveSessionSearchLimit +} from '../../shared/ai-vault-search-limit' // Longer than this is not a query, and FTS5 pays for every term it plans. export const SESSION_SEARCH_QUERY_MAX_LENGTH = 512 @@ -141,10 +144,3 @@ export type SessionSearchResponse = { generation: number durationMs: number } - -export function resolveSessionSearchLimit(limit: number | undefined): number { - // Why clamped here and not at the caller: a non-positive limit becomes - // `slice(0, -1)`, which silently drops the last hit of every page. - const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT - return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX) -} diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts index ac7ddf9a3da7..836842b91607 100644 --- a/src/main/ai-vault-search/session-search-engine.ts +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -89,6 +89,10 @@ export class SessionSearchEngine { this.retrieval = new SessionSearchRetrieval(this.db) } + generation(): number { + return readIndexGeneration(this.db) + } + search(request: SessionSearchRequest): SessionSearchResponse { const startedAt = performance.now() ensureSessionSearchQuerySchema(this.db) diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts new file mode 100644 index 000000000000..a8b945fcb7a2 --- /dev/null +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import { + setSessionSearchService, + searchSessionService, + sessionSearchServiceStatus +} from './session-search-service-registry' + +afterEach(() => { + setSessionSearchService(null) + vi.useRealTimers() +}) + +describe('session search service registry', () => { + it('answers without constructing an indexer and validates even when unavailable', async () => { + expect(await searchSessionService({ query: 'needle' }, 'ipc')).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + expect(await sessionSearchServiceStatus({}, 'ipc')).toMatchObject({ + enabled: false, + phase: 'idle', + generation: 0 + }) + await expect(searchSessionService({ query: 42 }, 'ipc')).rejects.toThrow() + }) + it.each(['ipc', 'runtime', 'relay'] as const)( + 'withholds degraded-root paths from %s status per the exposure policy', + async (transport) => { + const service = fakeSearchService() + service.status.mockResolvedValue({ + ...unavailableSessionSearchStatus(), + enabled: true, + phase: 'degraded', + degradedRoots: [{ root: '/host/projects', reason: 'could not be listed' }] + }) + setSessionSearchService(service) + expect((await sessionSearchServiceStatus({}, transport)).degradedRoots).toEqual([ + transport === 'relay' + ? { reason: 'Source root could not be verified.' } + : { root: '/host/projects', reason: 'could not be listed' } + ]) + } + ) + it('searches indexed data by default, drops legacy options and suppresses unsolicited diagnostics', async () => { + const service = fakeSearchService() + setSessionSearchService(service) + const result = await searchSessionService( + { query: 'needle', tier: 'conversation', refresh: true }, + 'ipc' + ) + expect(service.reconcile).not.toHaveBeenCalled() + expect(service.search).toHaveBeenCalledWith({ query: 'needle', limit: 20 }) + expect(result).not.toHaveProperty('debug') + expect(await searchSessionService({ query: 'needle', debug: true }, 'ipc')).toHaveProperty( + 'debug' + ) + }) + it('waits for reconcile before search, and clears its timeout', async () => { + vi.useFakeTimers() + const service = fakeSearchService() + let release!: () => void + service.reconcile.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + setSessionSearchService(service) + const result = searchSessionService( + { query: 'needle', freshness: 'wait-until-current' }, + 'runtime' + ) + await Promise.resolve() + expect(service.search).not.toHaveBeenCalled() + release() + expect(await result).toMatchObject({ kind: 'results', truncated: { freshness: false } }) + expect(vi.getTimerCount()).toBe(0) + }) + it('searches after the default five-second bound and observes a late rejection', async () => { + vi.useFakeTimers() + const service = fakeSearchService() + let reject!: (error: Error) => void + service.reconcile.mockImplementation( + () => + new Promise((_resolve, rejectPromise) => { + reject = rejectPromise + }) + ) + setSessionSearchService(service) + const result = searchSessionService( + { query: 'needle', freshness: 'wait-until-current' }, + 'relay' + ) + await vi.advanceTimersByTimeAsync(4_999) + expect(service.search).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(await result).toMatchObject({ + kind: 'results', + truncated: { freshness: true }, + hits: [expect.objectContaining({ source: { presence: 'present' } })] + }) + reject(new Error('late failure')) + await Promise.resolve() + expect(vi.getTimerCount()).toBe(0) + }) + it('propagates reconciliation failures before timeout and service unavailability', async () => { + const service = fakeSearchService() + setSessionSearchService(service) + service.reconcile.mockRejectedValue(new Error('cannot reconcile')) + await expect( + searchSessionService({ query: 'needle', freshness: 'wait-until-current' }, 'ipc') + ).rejects.toThrow('cannot reconcile') + expect(service.search).not.toHaveBeenCalled() + for (const reason of ['disabled', 'not-ready'] as const) { + service.search.mockResolvedValue({ kind: 'unavailable', reason }) + expect(await searchSessionService({ query: 'needle' }, 'ipc')).toEqual({ + kind: 'unavailable', + reason + }) + } + }) +}) diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts new file mode 100644 index 000000000000..26416fb728e5 --- /dev/null +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -0,0 +1,76 @@ +import { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchStatusRequestSchema, + AiVaultSearchStatusSchema +} from '../../shared/ai-vault-search-contract' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types' +import { + redactForTransport, + redactStatusForTransport, + type SessionSearchTransport +} from '../../shared/ai-vault-search-transport' +import type { SessionSearchService } from './session-search-service' + +let service: SessionSearchService | null = null + +export function setSessionSearchService(next: SessionSearchService | null): void { + service = next +} + +export async function searchSessionService( + raw: unknown, + transport: SessionSearchTransport, + freshnessTimeoutMs = 5_000 +): Promise { + const request = AiVaultSearchRequestSchema.parse(raw) + const current = service + if (!current) { + return { kind: 'unavailable', reason: 'no-service' } + } + const freshness = + request.freshness === 'wait-until-current' + ? await reconcileWithin(current, freshnessTimeoutMs) + : false + const result = AiVaultSearchResponseSchema.parse(await current.search(request)) + if (result.kind !== 'results') { + return result + } + const { debug, ...fields } = result + return { + ...fields, + hits: result.hits.map((hit) => redactForTransport(hit, transport)), + truncated: { ...result.truncated, freshness: result.truncated.freshness || freshness }, + ...(request.debug && debug ? { debug } : {}) + } +} + +export async function sessionSearchServiceStatus( + raw: unknown, + transport: SessionSearchTransport +): Promise { + AiVaultSearchStatusRequestSchema.parse(raw) + return redactStatusForTransport( + AiVaultSearchStatusSchema.parse( + service ? await service.status() : unavailableSessionSearchStatus() + ), + transport + ) +} + +async function reconcileWithin(current: SessionSearchService, timeoutMs: number): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + Promise.resolve() + .then(() => current.reconcile()) + .then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/ai-vault-search/session-search-service.test.ts b/src/main/ai-vault-search/session-search-service.test.ts new file mode 100644 index 000000000000..235b00684cb9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-service.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { createSessionSearchService } from './session-search-service' +import { + AiVaultSearchResponseSchema, + AiVaultSearchStatusSchema +} from '../../shared/ai-vault-search-contract' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' + +let harness: SessionSearchHarness | undefined + +afterEach(async () => { + await harness?.close() + harness = undefined +}) + +async function fixture() { + harness = await openSessionSearchHarness('public-contract') + const { enabled: _enabled, generation: _generation, ...status } = unavailableSessionSearchStatus() + // degradedRoots is re-stated because the contract type leaves `root` optional + // for relay redaction, while the indexer always names the root it degraded. + const indexer = { + status: () => ({ ...status, degradedRoots: [] }), + reconcile: vi.fn(async () => {}) + } + const service = createSessionSearchService({ engine: harness.engine, indexer }) + return { ...harness, service, indexer } +} + +describe('real index to public service adapter', () => { + it('pages a real store, maps evidence and diagnostics, and rejects stale or malformed cursors', async () => { + const { db, store, service } = await fixture() + addSyntheticSession(db, { id: 1 }) + addSyntheticSession(db, { id: 2, filePath: null }) + const first = await service.search({ query: 'needle', limit: 1, debug: true }) + expect(AiVaultSearchResponseSchema.parse(first)).toEqual(first) + expect(first.kind).toBe('results') + if (first.kind !== 'results') { + throw new Error('Expected results') + } + expect(first.debug?.plannerReport.scope).toBe('all') + expect(first).not.toHaveProperty('route') + expect(first).not.toHaveProperty('tier') + expect(first.page.hasMore).toBe(true) + const next = await service.search({ query: 'needle', cursor: first.page.cursor!, limit: 1 }) + expect(next.kind).toBe('results') + if (next.kind !== 'results') { + throw new Error('Expected results') + } + expect(next.hits[0].sessionId).not.toBe(first.hits[0].sessionId) + expect(next).not.toHaveProperty('debug') + const hits = [...first.hits, ...next.hits] + expect(hits.find((hit) => hit.source.presence === 'present')?.resumeCommand).toBe('resume') + expect(hits.find((hit) => hit.source.presence === 'unverifiable')).not.toHaveProperty( + 'resumeCommand' + ) + expect(await service.search({ query: 'other', cursor: first.page.cursor! })).toEqual({ + kind: 'malformed-cursor' + }) + await store.purgeOlderThan(1740000000001) + expect(await service.search({ query: 'needle', cursor: first.page.cursor! })).toMatchObject({ + kind: 'stale-cursor', + expectedGeneration: first.generation + }) + expect(await service.search({ query: 'needle', cursor: '' })).toEqual({ + kind: 'malformed-cursor' + }) + expect(await service.search({ query: 'needle', cursor: 'garbage' })).toEqual({ + kind: 'malformed-cursor' + }) + expect(await service.search({ query: 'needle' })).toMatchObject({ + kind: 'results', + hits: [expect.objectContaining({ sessionId: '2' })] + }) + }) + it('preserves null evidence for folder operators and delegates recent reconciliation', async () => { + const { db, service, indexer } = await fixture() + addSyntheticSession(db, { id: 1, cwd: '/folder/no-git-required' }) + const result = await service.search({ query: 'path:no-git-required' }) + expect(result).toMatchObject({ + kind: 'results', + hits: [expect.objectContaining({ evidence: null })] + }) + await service.reconcile() + expect(indexer.reconcile).toHaveBeenCalledExactlyOnceWith({ full: false }) + const status = await service.status() + expect(AiVaultSearchStatusSchema.parse(status)).toEqual(status) + expect(status.generation).toBeGreaterThan(0) + }) + it('keeps tool text out of conversation scope and reports query truncation', async () => { + const { db, service } = await fixture() + addSyntheticSession(db, { id: 1, role: 'tool', text: 'needle' }) + expect(await service.search({ query: 'needle', scope: 'conversation' })).toMatchObject({ + kind: 'results', + hits: [] + }) + expect(await service.search({ query: 'needle' })).toMatchObject({ + kind: 'results', + hits: [expect.anything()] + }) + expect(await service.search({ query: 'needle '.repeat(100) })).toMatchObject({ + kind: 'results', + truncated: { query: true } + }) + }) +}) diff --git a/src/main/ai-vault-search/session-search-service.ts b/src/main/ai-vault-search/session-search-service.ts new file mode 100644 index 000000000000..0f9cf6c615ee --- /dev/null +++ b/src/main/ai-vault-search/session-search-service.ts @@ -0,0 +1,95 @@ +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from '../../shared/ai-vault-search-types' +import type { SessionSearchEngine } from './session-search-engine' +import type { SessionSearchIndexer } from './session-search-indexer' +import { SessionSearchCursorError } from './session-search-page-cursor' + +export type SessionSearchService = { + search(req: AiVaultSearchRequest): Promise + status(): Promise + reconcile(): Promise +} + +export function createSessionSearchService({ + engine, + indexer +}: { + engine: SessionSearchEngine + indexer: Pick +}): SessionSearchService { + return { + reconcile: () => indexer.reconcile({ full: false }), + status: async () => ({ enabled: true, ...indexer.status(), generation: engine.generation() }), + search: async (request) => { + if (request.cursor === '') { + return { kind: 'malformed-cursor' } + } + try { + const result = engine.search(request) + return { + kind: 'results', + hits: result.hits.map( + ({ + filePath, + codexHome, + source, + evidence, + resumeCommand, + duplicateCount: _duplicateCount, + ...hit + }) => ({ + ...hit, + source: { presence: source, filePath, ...(codexHome === null ? {} : { codexHome }) }, + evidence: + evidence === null + ? null + : { + snippet: evidence.snippet, + role: evidence.role, + timestamp: evidence.timestamp + }, + ...(source === 'present' ? { resumeCommand } : {}) + }) + ), + page: result.page, + generation: result.generation, + truncated: { ...result.truncated, freshness: false }, + durationMs: result.durationMs, + ...(request.debug + ? { + debug: { + route: result.planner.route, + ...(result.planner.repairedTerms + ? { repairedTerms: result.planner.repairedTerms } + : {}), + plannerReport: { + route: result.planner.route, + scope: result.planner.tier, + ...(result.planner.repairedTerms + ? { repairedTerms: result.planner.repairedTerms } + : {}) + } + } + } + : {}) + } + } catch (error) { + if (!(error instanceof SessionSearchCursorError)) { + throw error + } + return error.rejection === 'stale-generation' + ? { + kind: 'stale-cursor', + generation: error.actualGeneration, + ...(error.expectedGeneration === undefined + ? {} + : { expectedGeneration: error.expectedGeneration }) + } + : { kind: 'malformed-cursor' } + } + } + } +} diff --git a/src/main/ai-vault/runtime-session-search-call.test.ts b/src/main/ai-vault/runtime-session-search-call.test.ts new file mode 100644 index 000000000000..4f8f0d2b4017 --- /dev/null +++ b/src/main/ai-vault/runtime-session-search-call.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callRuntimeEnvironment = vi.hoisted(() => vi.fn()) +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment })) + +import { callRuntimeSessionSearch } from './runtime-session-search-call' + +beforeEach(() => { + callRuntimeEnvironment.mockReset() +}) + +describe('runtime session search transport', () => { + it('addresses the environment by method and params and returns its result', async () => { + callRuntimeEnvironment.mockResolvedValue({ id: '1', ok: true, result: { kind: 'ok' } }) + expect( + await callRuntimeSessionSearch('/user/data', 'env-1', 'aiVault.searchSessions', { + query: 'needle' + }) + ).toEqual({ kind: 'ok' }) + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + '/user/data', + 'env-1', + 'aiVault.searchSessions', + { query: 'needle' } + ) + }) + it('rethrows a refusal with its code so an old host reads as an absent method', async () => { + callRuntimeEnvironment.mockResolvedValue({ + id: '1', + ok: false, + error: { code: 'method_not_found', message: 'unknown method' } + }) + await expect( + callRuntimeSessionSearch('/user/data', 'env-1', 'aiVault.searchSessions', {}) + ).rejects.toMatchObject({ code: 'method_not_found', message: 'unknown method' }) + }) +}) diff --git a/src/main/ai-vault/runtime-session-search-call.ts b/src/main/ai-vault/runtime-session-search-call.ts new file mode 100644 index 000000000000..d9a3ac07ffc6 --- /dev/null +++ b/src/main/ai-vault/runtime-session-search-call.ts @@ -0,0 +1,17 @@ +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' + +// Why: runtime RPC failures resolve as ok:false, but the shared search client +// classifies thrown errors by code, so the refusal has to keep its code to be +// recognised as an old host that lacks the method. +export async function callRuntimeSessionSearch( + userDataPath: string, + environmentId: string, + method: string, + params: Record +): Promise { + const response = await callRuntimeEnvironment(userDataPath, environmentId, method, params) + if (response.ok === true) { + return response.result + } + throw Object.assign(new Error(response.error.message), { code: response.error.code }) +} diff --git a/src/main/ipc/ai-vault-all-host-timeouts.ts b/src/main/ipc/ai-vault-all-host-timeouts.ts new file mode 100644 index 000000000000..64a53ff7b0f2 --- /dev/null +++ b/src/main/ipc/ai-vault-all-host-timeouts.ts @@ -0,0 +1,10 @@ +// Per-leg bounds for the all-hosts fan-outs, so one slow host cannot hold a merge open. +export const AI_VAULT_ALL_HOST_TIMEOUT_MS = { + runtimeScan: 3_000, + // Why: a remote home with many agent roots routinely needs seconds to walk, + // stat and parse. The old shared 3s bound emptied healthy SSH hosts in the + // all-hosts view; the relay gets a real scan budget and the whole leg (relay + // attempt plus any legacy crawl) stays bounded. + sshScanRelay: 15_000, + sshScan: 20_000 +} as const diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts new file mode 100644 index 000000000000..8f771179c0c7 --- /dev/null +++ b/src/main/ipc/ai-vault-search.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, sshSearch, runtimeSearch } = vi.hoisted(() => ({ + handlers: new Map Promise>(), + sshSearch: vi.fn(), + runtimeSearch: vi.fn() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, handler: (...args: unknown[]) => Promise) => + handlers.set(name, handler) + }, + ipcRenderer: { invoke: (name: string, ...args: unknown[]) => handlers.get(name)!(null, ...args) } +})) +vi.mock('./ssh', () => ({ + requestActiveSshSessionSearch: sshSearch +})) + +import { registerAiVaultSearchHandlers } from './ai-vault-search' +import { aiVaultApi } from '../../preload/api/ai-vault-bridge' +import { setSessionSearchService } from '../ai-vault-search/session-search-service-registry' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import { fakeSearchService, searchResults } from '../../shared/ai-vault-search-test-fixture' +beforeEach(() => { + handlers.clear() + sshSearch.mockReset() + runtimeSearch.mockReset() + registerAiVaultSearchHandlers({ + callRuntimeSearch: runtimeSearch + }) +}) +afterEach(() => setSessionSearchService(null)) + +describe('desktop IPC and preload search boundary', () => { + it('round-trips local results and separate status through the actual preload', async () => { + setSessionSearchService(fakeSearchService()) + expect(await aiVaultApi.searchSessions({ query: 'needle' })).toMatchObject({ + kind: 'results', + hits: [ + { + source: { presence: 'present', filePath: '/host/transcript.jsonl' }, + resumeCommand: 'host-resume-command' + } + ] + }) + expect(await aiVaultApi.searchSessions({ query: 'needle' }, 'local')).toMatchObject({ + hits: [{ source: { filePath: '/host/transcript.jsonl' } }] + }) + expect(await aiVaultApi.searchStatus()).toMatchObject({ enabled: true, generation: 7 }) + expect(sshSearch).not.toHaveBeenCalled() + expect(runtimeSearch).not.toHaveBeenCalled() + }) + it('rejects malformed renderer input and uses typed unavailable', async () => { + expect(await aiVaultApi.searchSessions({ query: 'needle' })).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + await expect(handlers.get('aiVault:searchSessions')!(null, { query: 1 })).rejects.toThrow() + await expect(handlers.get('aiVault:searchStatus')!(null, 42)).rejects.toThrow() + }) + it('routes one SSH target without touching the local index and redacts received paths', async () => { + const local = fakeSearchService() + setSessionSearchService(local) + sshSearch.mockResolvedValue(searchResults()) + const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host') + expect(sshSearch).toHaveBeenCalledWith('ssh-host', 'aiVault.searchSessions', { + query: 'needle', + limit: 20 + }) + expect(result).toMatchObject({ + hits: [{ executionHostId: 'ssh:ssh-host', source: { presence: 'present' } }] + }) + expect(JSON.stringify(result)).not.toContain('resumeCommand') + expect(local.search).not.toHaveBeenCalled() + sshSearch.mockRejectedValue(new Error('SSH relay is not ready')) + await expect(aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host')).rejects.toThrow( + 'SSH relay is not ready' + ) + expect(local.search).not.toHaveBeenCalled() + }) + it('routes one runtime environment over its RPC and stamps the answering host', async () => { + const local = fakeSearchService() + setSessionSearchService(local) + runtimeSearch.mockResolvedValue(searchResults()) + const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1') + expect(runtimeSearch).toHaveBeenCalledWith('env-1', 'aiVault.searchSessions', { + query: 'needle', + limit: 20 + }) + expect(result).toMatchObject({ + hits: [{ executionHostId: 'runtime:env-1', source: { presence: 'present' } }] + }) + expect(JSON.stringify(result)).not.toContain('/host/transcript.jsonl') + expect(local.search).not.toHaveBeenCalled() + runtimeSearch.mockResolvedValue(unavailableSessionSearchStatus()) + expect(await aiVaultApi.searchStatus('runtime:env-1')).toEqual(unavailableSessionSearchStatus()) + expect(runtimeSearch).toHaveBeenLastCalledWith('env-1', 'aiVault.searchStatus', {}) + }) + it('maps a runtime unknown-method refusal to unavailable and keeps transport errors', async () => { + runtimeSearch.mockRejectedValue( + Object.assign(new Error('unknown method'), { code: 'method_not_found' }) + ) + expect(await aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1')).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + runtimeSearch.mockRejectedValue( + Object.assign(new Error('runtime disconnected'), { code: 'connection_lost' }) + ) + await expect(aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1')).rejects.toThrow( + 'runtime disconnected' + ) + }) + it('reports unavailable when no runtime transport is injected', async () => { + handlers.clear() + registerAiVaultSearchHandlers() + expect(await aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1')).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + expect(await aiVaultApi.searchStatus('runtime:env-1')).toMatchObject({ + enabled: false, + phase: 'idle' + }) + }) + it('refuses an unroutable host instead of widening it to every host', async () => { + const local = fakeSearchService() + setSessionSearchService(local) + for (const scope of ['all', 'nope', 'ssh:', 'runtime:a|b']) { + await expect( + handlers.get('aiVault:searchSessions')!(null, { query: 'needle' }, scope) + ).rejects.toThrow('not available for this execution host') + await expect(handlers.get('aiVault:searchStatus')!(null, scope)).rejects.toThrow( + 'not available for this execution host' + ) + } + expect(local.search).not.toHaveBeenCalled() + expect(local.status).not.toHaveBeenCalled() + expect(sshSearch).not.toHaveBeenCalled() + expect(runtimeSearch).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts new file mode 100644 index 000000000000..9acb668ee14d --- /dev/null +++ b/src/main/ipc/ai-vault-search.ts @@ -0,0 +1,116 @@ +import { ipcMain } from 'electron' +import { z } from 'zod' +import { + searchSessionService, + sessionSearchServiceStatus +} from '../ai-vault-search/session-search-service-registry' +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../shared/ai-vault-search-client' +import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contract' +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from '../../shared/ai-vault-search-types' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ParsedExecutionHost +} from '../../shared/execution-host' +import { requestActiveSshSessionSearch } from './ssh' + +export type RuntimeSessionSearchCall = ( + environmentId: string, + method: string, + params: Record +) => Promise + +export type AiVaultSearchHandlerOptions = { + callRuntimeSearch?: RuntimeSessionSearchCall +} + +// One wording with the session list, which refuses the same unroutable scope. +const UNROUTABLE_HOST_MESSAGE = 'Agent Session History is not available for this execution host.' +const scopeSchema = z.string().min(1).optional() + +let handlerOptions: AiVaultSearchHandlerOptions = {} + +export function registerAiVaultSearchHandlers(options: AiVaultSearchHandlerOptions = {}): void { + handlerOptions = options + // Async so a refused scope reaches the renderer as a rejection, like every other parse failure. + ipcMain.handle('aiVault:searchSessions', async (_event, raw: unknown, rawScope?: unknown) => { + const scope = requestedSearchScope(rawScope) + return searchByExecutionHostScope(AiVaultSearchRequestSchema.parse(raw), scope) + }) + ipcMain.handle('aiVault:searchStatus', async (_event, rawScope?: unknown) => { + const scope = requestedSearchScope(rawScope) + return statusByExecutionHost(scope) + }) +} + +/** + * Why not the list's `requestedExecutionHostScope`: it normalizes an unparseable + * id to `all`, which would answer an unroutable request by searching every host. + * Same parser, same omitted-means-this-host rule, but garbage is refused. + */ +function requestedSearchScope(raw: unknown): ParsedExecutionHost { + const value = scopeSchema.parse(raw) + if (value === undefined) { + return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID } + } + const parsed = parseExecutionHostId(value) + if (!parsed) { + throw new Error(UNROUTABLE_HOST_MESSAGE) + } + return parsed +} + +async function searchByExecutionHostScope( + request: AiVaultSearchRequest, + scope: ParsedExecutionHost +): Promise { + if (scope.kind === 'local') { + return searchSessionService(request, 'ipc') + } + const client = remoteSearchClient(scope, handlerOptions.callRuntimeSearch) + if (!client) { + return { kind: 'unavailable', reason: 'no-service' } + } + const response = await client.searchSessions(request) + // This desktop owns which remote host was addressed. + return response.kind === 'results' + ? { ...response, hits: response.hits.map((hit) => ({ ...hit, executionHostId: scope.id })) } + : response +} + +function statusByExecutionHost(scope: ParsedExecutionHost): Promise { + if (scope.kind === 'local') { + return sessionSearchServiceStatus({}, 'ipc') + } + const client = remoteSearchClient(scope, handlerOptions.callRuntimeSearch) + return client ? client.searchStatus() : Promise.resolve(unavailableSessionSearchStatus()) +} + +// Null for the local host and for a runtime environment with no injected transport. +function remoteSearchClient( + host: ParsedExecutionHost, + call: RuntimeSessionSearchCall | undefined +): ReturnType | null { + if (host.kind === 'ssh') { + const { targetId } = host + return createSessionSearchClient( + (method, params) => requestActiveSshSessionSearch(targetId, method, params), + 'relay' + ) + } + if (host.kind === 'runtime' && call) { + const { environmentId } = host + return createSessionSearchClient( + (method, params) => call(environmentId, method, params), + 'relay' + ) + } + return null +} diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 746f469dba7b..1d09043c28ba 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -58,15 +58,7 @@ import { type RuntimeAiVaultSessionTitleResolver } from './ai-vault-session-title-routing' import { projectStructuredAiVaultSessions } from '../ai-vault/structured-session-ownership' - -const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 -// Why: a remote home with many agent roots routinely needs seconds to walk, -// stat and parse. The old shared 3s bound emptied healthy SSH hosts in the -// all-hosts view; the relay gets a real scan budget and the whole leg (relay -// attempt plus any legacy crawl) stays bounded so one host can't hold the -// merge open. -const AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS = 15_000 -const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000 +import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' type AiVaultHandlerOptions = AiVaultSessionSources & AiVaultResumeHandlerOptions & { @@ -159,8 +151,8 @@ async function scanAiVaultSessionsByHostScope( scan: () => scanSshAiVaultSessions(hostInfo.targetId, args, { signal, - timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS, - relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS + timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.sshScan, + relayTimeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.sshScanRelay }) }) ), @@ -175,7 +167,7 @@ async function scanAiVaultSessionsByHostScope( hostInfo, scanner: handlerOptions.scanRuntimeAiVaultSessions, listArgs: args, - options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS } + options: { signal, timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.runtimeScan } }) }) ) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 5648cca02191..ac9e6a9bb617 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -30,6 +30,7 @@ const { registerRuntimeEnvironmentHandlersMock, registerEphemeralVmHandlersMock, registerAiVaultHandlersMock, + registerAiVaultSearchHandlersMock, registerOrcaProfileHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, @@ -96,6 +97,7 @@ const { registerRuntimeEnvironmentHandlersMock: vi.fn(), registerEphemeralVmHandlersMock: vi.fn(), registerAiVaultHandlersMock: vi.fn(), + registerAiVaultSearchHandlersMock: vi.fn(), registerOrcaProfileHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), @@ -309,6 +311,10 @@ vi.mock('../ai-vault', () => ({ registerAiVaultHandlers: registerAiVaultHandlersMock })) +vi.mock('../ai-vault-search', () => ({ + registerAiVaultSearchHandlers: registerAiVaultSearchHandlersMock +})) + vi.mock('../orca-profiles', () => ({ registerOrcaProfileHandlers: registerOrcaProfileHandlersMock })) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 98cad9b25a61..cb08d7a511fb 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -25,6 +25,7 @@ import { registerRuntimeHandlers } from '../runtime' import { registerRuntimeEnvironmentHandlers } from '../runtime-environments' import { registerEphemeralVmHandlers } from '../ephemeral-vm' import { registerAiVaultHandlers } from '../ai-vault' +import { registerAiVaultSearchHandlers } from '../ai-vault-search' import { registerNativeChatHandlers } from '../native-chat' import { registerNotificationHandlers } from '../notifications' import { registerNotebookHandlers } from '../notebook' @@ -89,6 +90,7 @@ import { resolveRuntimeAiVaultSessionTitles, scanRuntimeAiVaultSessions } from '../../ai-vault/runtime-session-scanner' +import { callRuntimeSessionSearch } from '../../ai-vault/runtime-session-search-call' import type { PluginService } from '../../plugins/plugin-service' import type { PluginMarketplaceHandlerServices } from '../plugin-marketplaces' @@ -214,6 +216,10 @@ export function registerCoreHandlers( registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store, pluginService) + registerAiVaultSearchHandlers({ + callRuntimeSearch: (environmentId, method, params) => + callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params) + }) registerAiVaultHandlers({ ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 2bb494454aae..9ccf4ad16f6b 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1,3 +1,7 @@ +import { + AiVaultSearchRequestSchema, + AiVaultSearchStatusRequestSchema +} from '../../shared/ai-vault-search-contract' import { ipcMain, type BrowserWindow } from 'electron' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' @@ -114,6 +118,27 @@ export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { }) } +export async function requestActiveSshSessionSearch( + targetId: string, + method: string, + params: unknown +): Promise { + if (isRuntimeOwnedSshTargetId(targetId)) { + throw new Error('SSH target belongs to another runtime') + } + const session = activeSessions.get(targetId) + if (!session) { + throw new Error('SSH relay is not ready') + } + if (method === 'aiVault.searchSessions') { + return session.requestSessionSearch(method, AiVaultSearchRequestSchema.parse(params)) + } + if (method === 'aiVault.searchStatus') { + return session.requestSessionSearch(method, AiVaultSearchStatusRequestSchema.parse(params)) + } + throw new Error('Unknown session search method') +} + export async function requestActiveSshAiVaultSessionList( targetId: string, params: SshAiVaultRelayListParams, diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts new file mode 100644 index 000000000000..d3a149e38adb --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import { OrcaRuntimeService } from '../../orca-runtime' +import { AI_VAULT_METHODS } from './ai-vault' +import { fakeSearchService } from '../../../../shared/ai-vault-search-test-fixture' +import { createSessionSearchClient } from '../../../../shared/ai-vault-search-client' +import { setSessionSearchService } from '../../../ai-vault-search/session-search-service-registry' + +afterEach(() => setSessionSearchService(null)) + +function dispatcher(legacy = false) { + return new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + methods: legacy ? [] : AI_VAULT_METHODS + }) +} + +const request = (params: unknown) => ({ + id: 'search-1', + authToken: 'test', + method: 'aiVault.searchSessions', + params +}) + +describe('session search runtime RPC', () => { + it('returns typed unavailable and rejects invalid requests before the service', async () => { + const rpc = dispatcher() + expect(await rpc.dispatch(request({ query: 'needle' }))).toMatchObject({ + ok: true, + result: { kind: 'unavailable', reason: 'no-service' } + }) + const service = fakeSearchService() + setSessionSearchService(service) + expect(await rpc.dispatch(request({ query: 5 }))).toMatchObject({ ok: false }) + expect(service.search).not.toHaveBeenCalled() + }) + it.each([undefined, 'runtime', 'mobile'] as const)( + 'applies exposure for authenticated client kind %s', + async (clientKind) => { + const service = fakeSearchService() + setSessionSearchService(service) + const rpc = dispatcher() + const response = await rpc.dispatch( + request({ query: 'needle', tier: 'conversation', refresh: true, clientKind: undefined }), + { clientKind } + ) + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error('Expected successful RPC') + } + const text = JSON.stringify(response.result) + expect(text.includes('/host/transcript.jsonl')).toBe(clientKind === undefined) + expect(text.includes('resumeCommand')).toBe(clientKind === undefined) + expect(service.search).toHaveBeenCalledExactlyOnceWith({ query: 'needle', limit: 20 }) + const status = await rpc.dispatch( + { ...request({}), method: 'aiVault.searchStatus' }, + { clientKind } + ) + expect(status).toMatchObject({ ok: true, result: { enabled: true, generation: 7 } }) + } + ) + it('maps the old runtime dispatcher refusal and rejects malformed responses', async () => { + const legacy = dispatcher(true) + const client = createSessionSearchClient(async (method, params) => { + const response = await legacy.dispatch({ ...request(params), method }) + if (!response.ok) { + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } + return response.result + }, 'relay') + expect(await client.searchSessions({ query: 'needle' })).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + const broken = createSessionSearchClient(async () => ({ kind: 'results', hits: [] }), 'runtime') + await expect(broken.searchSessions({ query: 'needle' })).rejects.toThrow() + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index abc3aff2650b..5681b3a6c02b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,3 +1,11 @@ +import { + AiVaultSearchRequestSchema, + AiVaultSearchStatusRequestSchema +} from '../../../../shared/ai-vault-search-contract' +import { + searchSessionService, + sessionSearchServiceStatus +} from '../../../ai-vault-search/session-search-service-registry' import { defineMethod } from '../core' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' @@ -16,6 +24,18 @@ import { export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } export const AI_VAULT_METHODS = [ + defineMethod({ + name: 'aiVault.searchSessions', + params: AiVaultSearchRequestSchema, + handler: (params, { clientKind }) => + searchSessionService(params, clientKind ? 'relay' : 'runtime') + }), + defineMethod({ + name: 'aiVault.searchStatus', + params: AiVaultSearchStatusRequestSchema, + handler: (params, { clientKind }) => + sessionSearchServiceStatus(params, clientKind ? 'relay' : 'runtime') + }), defineMethod({ name: 'aiVault.resolveSessionTitles', params: AiVaultSessionTitlesParams, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 85cf306a0e5a..e536f76260ca 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -7,6 +7,8 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.subscribe', 'accounts.unsubscribe', 'aiVault.listSessions', + 'aiVault.searchSessions', + 'aiVault.searchStatus', 'aiVault.resolveSessionTitles', 'aiVault.prepareSessionResume', 'browser.back', diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 08754ca76e16..cfbe72ca5746 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -449,6 +449,14 @@ export class SshRelaySession { } } + async requestSessionSearch(method: string, params: Record): Promise { + const mux = this.mux + if (!mux || mux.isDisposed() || this._state !== 'ready') { + throw new Error('SSH relay is not ready') + } + return mux.request(method, params, { timeoutMs: 15_000 }) + } + async requestAiVaultSessionList( params: SshAiVaultRelayListParams, options: { signal?: AbortSignal; timeoutMs?: number } = {} diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 10cf8a4d5157..4c008eee5272 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -1,3 +1,8 @@ +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from '../../shared/ai-vault-search-types' import type { AiVaultDeleteSessionArgs, AiVaultDeleteSessionResult @@ -18,8 +23,16 @@ import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' +import type { ExecutionHostId } from '../../shared/execution-host' export type AiVaultApi = { + /** Omitted host means this host; search addresses one index at a time. */ + searchSessions: ( + request: AiVaultSearchRequest, + executionHostScope?: ExecutionHostId + ) => Promise + /** Status describes one index, so it never accepts the `all` scope. */ + searchStatus: (executionHostScope?: ExecutionHostId) => Promise listSessions: (args?: AiVaultListArgs) => Promise resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise cancelListSessions: (args: { requestToken: string }) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index 917c9f02b632..7d882a0989a5 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -1,3 +1,6 @@ +import { createSessionSearchClient } from '../../shared/ai-vault-search-client' +import type { AiVaultSearchRequest } from '../../shared/ai-vault-search-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { ipcRenderer } from 'electron' import type { AiVaultDeleteSessionArgs, @@ -12,7 +15,24 @@ import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-tit import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import type { PreloadApi } from '../api-types' +function searchClient( + executionHostScope?: ExecutionHostId +): ReturnType { + const remote = executionHostScope !== undefined && executionHostScope !== LOCAL_EXECUTION_HOST_ID + return createSessionSearchClient( + (method, params) => + method === 'aiVault.searchSessions' + ? ipcRenderer.invoke('aiVault:searchSessions', params, executionHostScope) + : ipcRenderer.invoke('aiVault:searchStatus', executionHostScope), + remote ? 'relay' : 'ipc' + ) +} + export const aiVaultApi = { + searchSessions: (request: AiVaultSearchRequest, executionHostScope?: ExecutionHostId) => + searchClient(executionHostScope).searchSessions(request), + searchStatus: (executionHostScope?: ExecutionHostId) => + searchClient(executionHostScope).searchStatus(), listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => ipcRenderer.invoke('aiVault:resolveSessionTitles', args), diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts index d3e83448ce8a..75676a7be291 100644 --- a/src/relay/ai-vault-handler.ts +++ b/src/relay/ai-vault-handler.ts @@ -1,3 +1,7 @@ +import { + searchSessionService, + sessionSearchServiceStatus +} from '../main/ai-vault-search/session-search-service-registry' import { homedir } from 'node:os' import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' @@ -31,6 +35,12 @@ export class AiVaultHandler { private readonly scanCoordinator = new AiVaultScanCoordinator() constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) { + dispatcher.onRequest('aiVault.searchSessions', (params) => + searchSessionService(params, 'relay') + ) + dispatcher.onRequest('aiVault.searchStatus', (params) => + sessionSearchServiceStatus(params, 'relay') + ) this.remoteHome = options.remoteHome ?? homedir() const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform() // Why: an OS/arch this build has no path flavor for must not abort relay diff --git a/src/relay/session-search-transport.test.ts b/src/relay/session-search-transport.test.ts new file mode 100644 index 000000000000..ceb769a72714 --- /dev/null +++ b/src/relay/session-search-transport.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import { AiVaultHandler } from './ai-vault-handler' +import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' +import { createSessionSearchClient } from '../shared/ai-vault-search-client' +import { fakeSearchService } from '../shared/ai-vault-search-test-fixture' +import { setSessionSearchService } from '../main/ai-vault-search/session-search-service-registry' + +const cleanups: (() => void)[] = [] +afterEach(() => { + cleanups.splice(0).forEach((close) => close()) + setSessionSearchService(null) +}) + +function wire(register: boolean) { + let receive!: (data: Buffer) => void + const host = new RelayDispatcher((data) => receive(Buffer.from(data))) + const mux = new SshChannelMultiplexer({ + write: (data) => host.feed(data), + onData: (callback) => { + receive = callback + }, + onClose: () => {} + }) + cleanups.push(() => { + mux.dispose() + host.dispose() + }) + if (register) { + new AiVaultHandler(host, { remoteHome: '/synthetic-host' }) + } + const client = createSessionSearchClient((method, params) => mux.request(method, params), 'relay') + return { host, mux, client } +} + +describe('session search over real relay frames', () => { + it('parses on the host and client and withholds host paths on the wire', async () => { + const service = fakeSearchService() + setSessionSearchService(service) + const { client, mux } = wire(true) + const result = await client.searchSessions({ query: 'needle' }) + expect(result).toMatchObject({ + kind: 'results', + hits: [{ sessionId: 'host-session', source: { presence: 'present' } }] + }) + const raw = await mux.request('aiVault.searchSessions', { + query: 'needle', + tier: 'conversation', + refresh: true + }) + expect(raw).toMatchObject({ hits: [{ source: { presence: 'present' } }] }) + expect(JSON.stringify(raw)).not.toContain('/host/transcript') + expect(JSON.stringify(raw)).not.toContain('/host/codex') + expect(JSON.stringify(raw)).not.toContain('resumeCommand') + expect(service.search).toHaveBeenLastCalledWith({ query: 'needle', limit: 20 }) + expect(service.reconcile).not.toHaveBeenCalled() + expect(await client.searchStatus()).toMatchObject({ enabled: true, generation: 7 }) + await expect(mux.request('aiVault.searchSessions', { query: 42 })).rejects.toThrow() + expect(service.search).toHaveBeenCalledTimes(2) + }) + it('maps a real old-host unknown-method response to unavailable without invoking a local service', async () => { + const local = fakeSearchService() + setSessionSearchService(local) + const { client } = wire(false) + expect(await client.searchSessions({ query: 'needle' })).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + expect(await client.searchStatus()).toMatchObject({ enabled: false, generation: 0 }) + expect(local.search).not.toHaveBeenCalled() + }) + it('registers the endpoints without a scanner service or production index', async () => { + const { client } = wire(true) + expect(await client.searchSessions({ query: 'needle' })).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + }) + it('propagates transport loss rather than substituting local results', async () => { + const local = fakeSearchService() + setSessionSearchService(local) + const { client, mux } = wire(true) + mux.dispose() + await expect(client.searchSessions({ query: 'needle' })).rejects.toThrow() + expect(local.search).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index 383a657f7b87..dcab8f9dbb00 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -1,3 +1,7 @@ +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../../../shared/ai-vault-search-client' import type { PreloadApi } from '../../../../preload/api-types' import type { AiVaultPrepareSessionResumeArgs, @@ -10,6 +14,7 @@ import type { } from '../../../../shared/ai-vault-session-title' import type { AiVaultListArgs, AiVaultListResult } from '../../../../shared/ai-vault-types' import { + normalizeExecutionHostId, normalizeExecutionHostScope, toRuntimeExecutionHostId } from '../../../../shared/execution-host' @@ -20,7 +25,20 @@ import { noopUnsubscribe } from './web-storage' import { translate } from '@/i18n/i18n' export function createWebAiVaultApi(): NonNullable['aiVault']> { + const search = createSessionSearchClient( + (method, params) => callRuntimeResult(method, params), + 'relay' + ) return { + // A browser searches only its selected paired runtime. + searchSessions: (request, executionHostScope) => + addressesOwnRuntime(executionHostScope) + ? search.searchSessions(request) + : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }), + searchStatus: (executionHostScope) => + addressesOwnRuntime(executionHostScope) + ? search.searchStatus() + : Promise.resolve(unavailableSessionSearchStatus()), listSessions: (args?: AiVaultListArgs) => { const environment = requireActiveEnvironment() const executionHostId = toRuntimeExecutionHostId(environment.id) @@ -74,6 +92,15 @@ export function createWebAiVaultApi(): NonNullable['aiVault' } } +// An unparseable id must not normalize into the everything-scope and answer anyway. +function addressesOwnRuntime(executionHostScope: ExecutionHostId | undefined): boolean { + const ownRuntimeId = toRuntimeExecutionHostId(requireActiveEnvironment().id) + return ( + executionHostScope === undefined || + normalizeExecutionHostId(executionHostScope) === ownRuntimeId + ) +} + export function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { return { sessions: [], diff --git a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts new file mode 100644 index 000000000000..a9f5980cf686 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { searchResults } from '../../../../shared/ai-vault-search-test-fixture' +import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client' + +const callRuntimeResult = vi.hoisted(() => vi.fn()) +vi.mock('./web-runtime-calls', () => ({ callRuntimeResult })) +vi.mock('./web-runtime-session', () => ({ + requireActiveEnvironment: () => ({ id: 'owning-host' }) +})) +import { createWebAiVaultApi } from './web-ai-vault-api' + +beforeEach(() => { + callRuntimeResult.mockReset() +}) + +describe('web session search preload compatibility', () => { + it('calls the selected host, parses its response and applies remote exposure', async () => { + callRuntimeResult.mockResolvedValue(searchResults()) + const api = createWebAiVaultApi() + const result = await api.searchSessions({ query: 'needle' }) + expect(callRuntimeResult).toHaveBeenCalledExactlyOnceWith('aiVault.searchSessions', { + query: 'needle', + limit: 20 + }) + expect(result).toMatchObject({ kind: 'results', hits: [{ source: { presence: 'present' } }] }) + expect(JSON.stringify(result)).not.toContain('resumeCommand') + expect(result).not.toHaveProperty('debug') + callRuntimeResult.mockResolvedValue(unavailableSessionSearchStatus()) + expect(await api.searchStatus()).toEqual(unavailableSessionSearchStatus()) + expect(callRuntimeResult).toHaveBeenLastCalledWith('aiVault.searchStatus', {}) + }) + it('treats an old host as unavailable but propagates transport failures', async () => { + const api = createWebAiVaultApi() + callRuntimeResult.mockRejectedValue( + Object.assign(new Error('unknown method'), { code: 'method_not_found' }) + ) + expect(await api.searchSessions({ query: 'needle' })).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + expect(await api.searchStatus()).toEqual(unavailableSessionSearchStatus()) + callRuntimeResult.mockRejectedValue( + Object.assign(new Error('disconnected'), { code: 'connection_lost' }) + ) + await expect(api.searchSessions({ query: 'needle' })).rejects.toThrow('disconnected') + await expect(api.searchStatus()).rejects.toThrow('disconnected') + }) + it('rejects invalid host responses', async () => { + const api = createWebAiVaultApi() + callRuntimeResult.mockResolvedValue({ kind: 'results' }) + await expect(api.searchSessions({ query: 'needle' })).rejects.toThrow() + await expect(api.searchStatus()).rejects.toThrow() + }) + it('answers for its own runtime and reports any other host unavailable', async () => { + const api = createWebAiVaultApi() + callRuntimeResult.mockResolvedValue(searchResults()) + for (const scope of ['runtime:owning-host'] as const) { + expect(await api.searchSessions({ query: 'needle' }, scope)).toMatchObject({ + kind: 'results' + }) + } + expect(callRuntimeResult).toHaveBeenCalledTimes(1) + callRuntimeResult.mockClear() + for (const scope of ['runtime:other-host', 'ssh:box', 'local'] as const) { + expect(await api.searchSessions({ query: 'needle' }, scope)).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + expect(await api.searchStatus(scope)).toEqual(unavailableSessionSearchStatus()) + } + // @ts-expect-error All-host search is deliberately outside the public API. + expect(await api.searchSessions({ query: 'needle' }, 'all')).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + expect(callRuntimeResult).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts new file mode 100644 index 000000000000..ac89f694886a --- /dev/null +++ b/src/shared/ai-vault-search-client.ts @@ -0,0 +1,83 @@ +import { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchStatusSchema +} from './ai-vault-search-contract' +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from './ai-vault-search-types' +import { + redactForTransport, + redactStatusForTransport, + type SessionSearchTransport +} from './ai-vault-search-transport' + +export function unavailableSessionSearchStatus(): AiVaultSearchStatus { + return { + enabled: false, + phase: 'idle', + filesIndexed: 0, + filesDue: 0, + filesFailed: 0, + degradedRoots: [], + lastReconcileAt: null, + lastSweepCompletedAt: null, + generation: 0 + } +} + +// Only an explicit unknown-method refusal proves the old host lacks this surface. +function isUnknownMethod(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('code' in error)) { + return false + } + return error.code === -32601 || error.code === 'method_not_found' +} + +export function createSessionSearchClient( + call: (method: string, params: Record) => Promise, + transport: SessionSearchTransport +): { + searchSessions(request: AiVaultSearchRequest): Promise + searchStatus(): Promise +} { + return { + searchSessions: async (request) => { + const parsed = AiVaultSearchRequestSchema.parse(request) + let raw: unknown + try { + raw = await call('aiVault.searchSessions', parsed) + } catch (error) { + if (isUnknownMethod(error)) { + return { kind: 'unavailable', reason: 'no-service' } + } + throw error + } + const result = AiVaultSearchResponseSchema.parse(raw) + if (result.kind !== 'results') { + return result + } + const { debug, ...fields } = result + return { + ...fields, + hits: result.hits.map((hit) => redactForTransport(hit, transport)), + ...(parsed.debug && debug ? { debug } : {}) + } + }, + searchStatus: async () => { + try { + return redactStatusForTransport( + AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})), + transport + ) + } catch (error) { + if (isUnknownMethod(error)) { + return unavailableSessionSearchStatus() + } + throw error + } + } + } +} diff --git a/src/shared/ai-vault-search-contract.test.ts b/src/shared/ai-vault-search-contract.test.ts new file mode 100644 index 000000000000..5ecfee93aec4 --- /dev/null +++ b/src/shared/ai-vault-search-contract.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchStatusSchema +} from './ai-vault-search-contract' +import { searchHit, searchResults } from './ai-vault-search-test-fixture' +import { redactForTransport, redactStatusForTransport } from './ai-vault-search-transport' +import { unavailableSessionSearchStatus } from './ai-vault-search-client' + +describe('session search public contract', () => { + it('drops legacy fields without letting them override scope or freshness', () => { + expect( + AiVaultSearchRequestSchema.parse({ query: 'needle', tier: 'conversation', refresh: true }) + ).toEqual({ query: 'needle', limit: 20 }) + }) + it.each([ + [0, 1], + [-4, 1], + [200, 100], + [2.5, 20], + [undefined, 20] + ])('clamps %s to %s', (limit, expected) => { + expect(AiVaultSearchRequestSchema.parse({ query: 'needle', limit }).limit).toBe(expected) + }) + it('allows the engine to report long-query truncation', () => { + const query = 'needle '.repeat(100) + expect(AiVaultSearchRequestSchema.parse({ query }).query).toBe(query) + }) + it('validates every response variant and separate status', () => { + for (const response of [ + searchResults(), + { kind: 'malformed-cursor' }, + { kind: 'stale-cursor', generation: 2, expectedGeneration: 1 }, + ...['disabled', 'not-ready', 'no-service'].map((reason) => ({ kind: 'unavailable', reason })) + ]) { + expect(AiVaultSearchResponseSchema.parse(response)).toEqual(response) + } + expect(AiVaultSearchStatusSchema.parse(unavailableSessionSearchStatus())).toEqual( + unavailableSessionSearchStatus() + ) + expect(AiVaultSearchResponseSchema.safeParse({ kind: 'results', hits: [] }).success).toBe(false) + }) + it('keeps host attribution optional in both wire directions', () => { + const legacy = searchResults() + expect(AiVaultSearchResponseSchema.parse(legacy)).toEqual(legacy) + expect(legacy.hits[0]).not.toHaveProperty('executionHostId') + const attributed = { + ...searchResults(), + hits: [{ ...searchHit(), executionHostId: 'runtime:env-1' }] + } + expect(AiVaultSearchResponseSchema.parse(attributed)).toEqual(attributed) + expect( + AiVaultSearchResponseSchema.safeParse({ + ...searchResults(), + hits: [{ ...searchHit(), executionHostId: '' }] + }).success + ).toBe(false) + }) + it('never accepts resume commands for an unverified or missing source', () => { + for (const presence of ['unverifiable', 'missing'] as const) { + const response = searchResults() + response.hits[0].source.presence = presence + expect(AiVaultSearchResponseSchema.safeParse(response).success).toBe(false) + } + }) + it.each(['ipc', 'runtime', 'relay'] as const)( + 'enforces the %s exposure policy without mutating the hit', + (transport) => { + const hit = searchHit() + const original = structuredClone(hit) + const result = redactForTransport(hit, transport) + expect(hit).toEqual(original) + expect(result.cwd).toBe('/host/folder') + if (transport === 'relay') { + expect(result.source).toEqual({ presence: 'present' }) + expect(result).not.toHaveProperty('resumeCommand') + } else { + expect(result).toEqual(hit) + } + for (const presence of ['unverifiable', 'missing'] as const) { + expect( + redactForTransport({ ...hit, source: { ...hit.source, presence } }, transport) + ).not.toHaveProperty('resumeCommand') + } + } + ) + it.each(['ipc', 'runtime', 'relay'] as const)( + 'withholds degraded-root paths from %s status without mutating it', + (transport) => { + const status = { + ...unavailableSessionSearchStatus(), + degradedRoots: [{ root: '/host/projects', reason: 'could not be listed' }] + } + const original = structuredClone(status) + const result = redactStatusForTransport(status, transport) + expect(status).toEqual(original) + expect(AiVaultSearchStatusSchema.parse(result)).toEqual(result) + expect(result.degradedRoots).toEqual([ + transport === 'relay' + ? { reason: 'Source root could not be verified.' } + : { root: '/host/projects', reason: 'could not be listed' } + ]) + } + ) + it.each([ + '/host/private/path could not be listed.', + "EACCES: permission denied, scandir '/host/private/path'", + "EACCES: permission denied, scandir 'C:\\Users\\private\\sessions'" + ])('withholds paths embedded in relay diagnostics: %s', (reason) => { + const status = { + ...unavailableSessionSearchStatus(), + degradedRoots: [{ root: '/host/private/path', reason }] + } + const result = redactStatusForTransport(status, 'relay') + expect(result.degradedRoots).toHaveLength(1) + expect(JSON.stringify(result)).not.toContain('/host/private/path') + expect(JSON.stringify(result)).not.toContain('private') + expect(redactStatusForTransport(status, 'runtime')).toEqual(status) + }) +}) diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts new file mode 100644 index 000000000000..ebbb30303ccd --- /dev/null +++ b/src/shared/ai-vault-search-contract.ts @@ -0,0 +1,108 @@ +import { resolveSessionSearchLimit, SESSION_SEARCH_LIMIT_MAX } from './ai-vault-search-limit' +import { z } from 'zod' +import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from './ai-vault-types' + +export const AiVaultSearchFiltersSchema = z.object({ + agents: z.array(z.enum(AI_VAULT_AGENTS)).optional(), + scopePaths: z.array(z.string().min(1).max(4096)).max(AI_VAULT_SCOPE_PATHS_MAX_COUNT).optional(), + since: z.string().datetime({ offset: true }).optional(), + sort: z.enum(['relevance', 'newest']).optional() +}) + +// Strip unknown fields so legacy tier/refresh are accepted without affecting the query. +export const AiVaultSearchRequestSchema = z.object({ + query: z.string(), + scope: z.enum(['conversation', 'all']).optional(), + freshness: z.enum(['indexed', 'wait-until-current']).optional(), + limit: z.number().optional().transform(resolveSessionSearchLimit), + cursor: z.string().optional(), + filters: AiVaultSearchFiltersSchema.optional(), + debug: z.boolean().optional() +}) + +export const AiVaultSearchSourceSchema = z.object({ + presence: z.enum(['present', 'unverifiable', 'missing']), + filePath: z.string().optional(), + codexHome: z.string().optional() +}) +export const AiVaultSearchEvidenceSchema = z.object({ + snippet: z.string(), + role: z.enum(['user', 'assistant', 'tool', 'system', 'unknown']), + timestamp: z.string().nullable() +}) +// Older hosts may omit attribution; the desktop stamps remote answers. +const executionHostIdSchema = z.string().min(1) + +export const AiVaultSearchHitSchema = z + .object({ + agent: z.enum(AI_VAULT_AGENTS), + executionHostId: executionHostIdSchema.optional(), + sessionId: z.string(), + title: z.string(), + cwd: z.string().nullable(), + branch: z.string().nullable(), + updatedAt: z.string().nullable(), + messageCount: z.number().int().nonnegative(), + score: z.number(), + source: AiVaultSearchSourceSchema, + evidence: AiVaultSearchEvidenceSchema.nullable(), + resumeCommand: z.string().optional() + }) + .refine((hit) => hit.source.presence === 'present' || hit.resumeCommand === undefined, { + message: 'Only present sources may have a resume command' + }) +export const AiVaultSearchPageSchema = z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean() +}) +export const AiVaultSearchTruncationSchema = z.object({ + candidates: z.boolean(), + snippets: z.number().int().nonnegative(), + query: z.boolean(), + freshness: z.boolean() +}) +const routeSchema = z.enum(['phrase', 'and', 'or', 'typo+phrase', 'typo+and', 'typo+or']) +export const AiVaultSearchPlannerReportSchema = z.object({ + route: routeSchema, + repairedTerms: z.array(z.string()).optional(), + scope: z.enum(['conversation', 'all']) +}) +export const AiVaultSearchDebugSchema = z.object({ + route: routeSchema, + repairedTerms: z.array(z.string()).optional(), + plannerReport: AiVaultSearchPlannerReportSchema +}) +export const AiVaultSearchResponseSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('results'), + hits: z.array(AiVaultSearchHitSchema).max(SESSION_SEARCH_LIMIT_MAX), + page: AiVaultSearchPageSchema, + generation: z.number().int().nonnegative(), + truncated: AiVaultSearchTruncationSchema, + durationMs: z.number().nonnegative(), + debug: AiVaultSearchDebugSchema.optional() + }), + z.object({ + kind: z.literal('stale-cursor'), + generation: z.number().int().nonnegative(), + expectedGeneration: z.number().int().nonnegative().optional() + }), + z.object({ kind: z.literal('malformed-cursor') }), + z.object({ + kind: z.literal('unavailable'), + reason: z.enum(['disabled', 'not-ready', 'no-service']) + }) +]) +export const AiVaultSearchStatusRequestSchema = z.object({}) +export const AiVaultSearchStatusSchema = z.object({ + enabled: z.boolean(), + phase: z.enum(['idle', 'indexing', 'current', 'degraded', 'closed']), + filesIndexed: z.number().int().nonnegative(), + filesDue: z.number().int().nonnegative(), + filesFailed: z.number().int().nonnegative(), + // `root` is a host path, withheld over the relay; the array length is the count. + degradedRoots: z.array(z.object({ root: z.string().optional(), reason: z.string() })), + lastReconcileAt: z.number().nullable(), + lastSweepCompletedAt: z.number().nullable(), + generation: z.number().int().nonnegative() +}) diff --git a/src/shared/ai-vault-search-limit.ts b/src/shared/ai-vault-search-limit.ts new file mode 100644 index 000000000000..8563046fdfe2 --- /dev/null +++ b/src/shared/ai-vault-search-limit.ts @@ -0,0 +1,8 @@ +export const SESSION_SEARCH_LIMIT_DEFAULT = 20 +export const SESSION_SEARCH_LIMIT_MAX = 100 + +export function resolveSessionSearchLimit(limit: number | undefined): number { + // Non-positive limits otherwise make slice silently drop hits. + const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT + return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX) +} diff --git a/src/shared/ai-vault-search-test-fixture.ts b/src/shared/ai-vault-search-test-fixture.ts new file mode 100644 index 000000000000..d76855e442de --- /dev/null +++ b/src/shared/ai-vault-search-test-fixture.ts @@ -0,0 +1,43 @@ +import { vi } from 'vitest' +import type { AiVaultSearchHit, AiVaultSearchResponse } from './ai-vault-search-types' +import { unavailableSessionSearchStatus } from './ai-vault-search-client' + +export function searchHit(): AiVaultSearchHit { + return { + agent: 'codex', + sessionId: 'host-session', + title: 'Indexed conversation', + cwd: '/host/folder', + branch: null, + updatedAt: null, + messageCount: 2, + score: 1, + source: { presence: 'present', filePath: '/host/transcript.jsonl', codexHome: '/host/codex' }, + evidence: { role: 'user', timestamp: null, snippet: 'a [[needle]]' }, + resumeCommand: 'host-resume-command' + } +} + +export function searchResults(): Extract { + return { + kind: 'results', + hits: [searchHit()], + page: { cursor: null, hasMore: false }, + generation: 7, + truncated: { candidates: false, snippets: 0, query: false, freshness: false }, + durationMs: 1, + debug: { route: 'phrase', plannerReport: { route: 'phrase', scope: 'all' } } + } +} + +export function fakeSearchService() { + return { + search: vi.fn(async (): Promise => searchResults()), + status: vi.fn(async () => ({ + ...unavailableSessionSearchStatus(), + enabled: true, + generation: 7 + })), + reconcile: vi.fn(async () => {}) + } +} diff --git a/src/shared/ai-vault-search-transport.ts b/src/shared/ai-vault-search-transport.ts new file mode 100644 index 000000000000..e38e6ac48729 --- /dev/null +++ b/src/shared/ai-vault-search-transport.ts @@ -0,0 +1,33 @@ +import type { AiVaultSearchHit, AiVaultSearchStatus } from './ai-vault-search-types' + +export type SessionSearchTransport = 'ipc' | 'runtime' | 'relay' + +export function redactForTransport( + hit: AiVaultSearchHit, + transport: SessionSearchTransport +): AiVaultSearchHit { + const { resumeCommand, source, ...fields } = hit + return { + ...fields, + source: transport === 'relay' ? { presence: source.presence } : { ...source }, + ...(transport !== 'relay' && source.presence === 'present' && resumeCommand !== undefined + ? { resumeCommand } + : {}) + } +} + +// Native error messages can repeat private paths even after root is removed. +export function redactStatusForTransport( + status: AiVaultSearchStatus, + transport: SessionSearchTransport +): AiVaultSearchStatus { + if (transport !== 'relay') { + return status + } + return { + ...status, + degradedRoots: status.degradedRoots.map(() => ({ + reason: 'Source root could not be verified.' + })) + } +} diff --git a/src/shared/ai-vault-search-types.ts b/src/shared/ai-vault-search-types.ts new file mode 100644 index 000000000000..76413a7ad6ba --- /dev/null +++ b/src/shared/ai-vault-search-types.ts @@ -0,0 +1,22 @@ +import type { z } from 'zod' +import type { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchHitSchema, + AiVaultSearchStatusSchema +} from './ai-vault-search-contract' + +/** + * Tool output beyond 3,072 characters per row is not indexed and not searchable; user and assistant text is indexed in full. + * A page cursor outstanding during a retention purge is refused once as `stale-cursor`; the client re-issues page 1. + * A phrase match across a chunk boundary of a long message is not supported. + */ +export type AiVaultSearchRequest = z.input +/** Pages belong to one host; callers re-issue page 1 after a stale cursor. */ +export type AiVaultSearchResponse = z.infer +/** + * Evidence is null for operator-only matches; remote callers receive source presence only. + * `executionHostId` names the host that owns the transcript; set by the desktop on remote answers. + */ +export type AiVaultSearchHit = z.infer +export type AiVaultSearchStatus = z.infer diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 57ed4a5b5a5c..eaaab7e66ec0 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -2,6 +2,10 @@ // run `pnpm run generate:rpc-params-catalog`. import type { z } from 'zod' import { AgentSkillShareRequestSchema } from '../agent-skill-sharing-contract' +import { + AiVaultSearchRequestSchema, + AiVaultSearchStatusRequestSchema +} from '../ai-vault-search-contract' import { BrowserClientFileChannelAbortParams, BrowserClientFileChannelReadParams, @@ -577,6 +581,8 @@ export const RPC_PARAMS_BY_METHOD = { 'aiVault.listSessions': AiVaultListSessionsParams, 'aiVault.prepareSessionResume': AiVaultPrepareSessionResumeParams, 'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams, + 'aiVault.searchSessions': AiVaultSearchRequestSchema, + 'aiVault.searchStatus': AiVaultSearchStatusRequestSchema, 'artifacts.delete': ArtifactsDeleteParams, 'artifacts.getPublishedLink': SourceRequest, 'artifacts.list': ListOptions, From b0070e37203ee68cd771ef68be32eec46d04ded1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:53:58 -0400 Subject: [PATCH 004/209] refactor(mobile): migrate settings reads to RpcOperation (#20499) * refactor(mobile): migrate settings reads to RpcOperation Replay the settings slice on the landed RPC foundation after rebasing onto main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh task parity snapshots after main rebase Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct rebased declaration parity hash Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): account for main task declaration Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): preserve raw RPC rejection timing Return the transport promise directly and interpret replies separately so sibling Promise.all rejection order cannot change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh parity hashes after timing fix Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): use operation interpreter after raw request * test(mobile): refresh settings migration parity hashes Refresh hook and statement parity hashes for the two task declarations whose settings reads now use RpcOperation request and interpretation. Changed declarations: - useMobileTasksRuntimeHydration: settings.get replaced by settingsRead request/interpret. - useMobileTasksWorkspaceCreateActions: settings.get response handling replaced by settingsRead request/interpret. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../MobileAgentSessionHistoryPanel.tsx | 16 +- .../use-new-workspace-create-submit.ts | 15 +- .../use-new-workspace-runtime-context.ts | 15 +- mobile/src/home/mobile-home-host-requests.ts | 10 +- .../src/host-screen/use-host-repo-metadata.ts | 40 ++-- .../session/mobile-new-tab-agent-loader.ts | 15 +- .../session/use-pr-bot-author-overrides.ts | 22 +- .../mobile-tasks-refactor-parity.test.ts | 32 +-- .../use-mobile-tasks-runtime-hydration.tsx | 10 +- ...-mobile-tasks-workspace-create-actions.tsx | 11 +- .../src/transport/rpc-acceptance-policies.ts | 15 ++ .../transport/rpc-operation-compile-fence.ts | 38 +++- .../src/transport/rpc-operation-contract.ts | 51 +++-- mobile/src/transport/rpc-operation-reply.ts | 92 ++++++++ mobile/src/transport/rpc-operation.ts | 175 +++++++-------- .../settings-read-operations.test.ts | 200 ++++++++++++++++++ .../src/transport/settings-read-operations.ts | 93 ++++++++ .../unvalidated-rpc-request-port-inventory.ts | 18 +- 18 files changed, 666 insertions(+), 202 deletions(-) create mode 100644 mobile/src/transport/rpc-operation-reply.ts create mode 100644 mobile/src/transport/settings-read-operations.test.ts create mode 100644 mobile/src/transport/settings-read-operations.ts diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 4f1d9ecf656c..6d1ea4303fab 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -1,3 +1,4 @@ +import { optionalSettingsRead } from '../transport/settings-read-operations' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' @@ -384,8 +385,8 @@ async function loadMobileResumeMetadata(client: Pick): client .sendRequest('projectGroup.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) .catch(() => null), - client - .sendRequest('settings.get', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + optionalSettingsRead + .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) .catch(() => null), client .sendRequest('worktree.ps', { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) @@ -405,17 +406,18 @@ async function loadMobileResumeMetadata(client: Pick): projectGroupResponse?.ok === true ? (projectGroupResponse.result as { groups?: MobileAiVaultResumeProjectGroup[] }) : null - const settingsResult = - settingsResponse?.ok === true - ? (settingsResponse.result as { settings?: MobileAiVaultResumeSettings }) - : null + const settingsResult = settingsResponse ? optionalSettingsRead.interpret(settingsResponse) : null + const settings = settingsResult?.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (settingsResult.value as MobileAiVaultResumeSettings | null | undefined) + : null const worktreeResult = worktreeResponse?.ok === true ? (worktreeResponse.result as { worktrees?: Worktree[] }) : null return { repos: repoResult.repos ?? [], folderWorkspaces: folderWorkspaceResult?.folderWorkspaces ?? [], projectGroups: projectGroupResult?.groups ?? [], - settings: settingsResult?.settings ?? null, + settings: settings ?? null, worktrees: worktreeResult?.worktrees ?? null } } diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index 1a7ba078b27c..c9265f8ed215 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -1,8 +1,8 @@ +import { settingsRead } from '../transport/settings-read-operations' import { useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' import { createBlankWorkspace } from '../tasks/blank-workspace-create' import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' import { @@ -88,13 +88,12 @@ export function useNewWorkspaceCreateSubmit(args: { } let latestRuntimeSettings = args.runtimeSettings try { - const settingsResponse = await client.sendRequest('settings.get') - if (settingsResponse.ok) { - const result = (settingsResponse as RpcSuccess).result as { - settings: NewWorktreeRuntimeSettings - } - latestRuntimeSettings = result.settings - args.setRuntimeSettings(result.settings) + const settingsReply = await settingsRead.request(client) + const settings = settingsRead.interpret(settingsReply) + if (settings.accepted) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + latestRuntimeSettings = settings.value as NewWorktreeRuntimeSettings + args.setRuntimeSettings(latestRuntimeSettings) } } catch { // The runtime validates the same setting before spawning. diff --git a/mobile/src/components/use-new-workspace-runtime-context.ts b/mobile/src/components/use-new-workspace-runtime-context.ts index 05df7b53fe38..e178d714ce85 100644 --- a/mobile/src/components/use-new-workspace-runtime-context.ts +++ b/mobile/src/components/use-new-workspace-runtime-context.ts @@ -1,3 +1,4 @@ +import { settingsRead } from '../transport/settings-read-operations' import { useEffect, useState } from 'react' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' import type { RpcClient } from '../transport/rpc-client' @@ -39,20 +40,18 @@ export function useNewWorkspaceRuntimeContext( client.sendRequest('linear.status') ]) const [settingsRes, uiRes] = await Promise.allSettled([ - client.sendRequest('settings.get'), + settingsRead.request(client), client.sendRequest('ui.get') ]) if (stale) { return } - const settingsResult = settledSuccess(settingsRes) - const settingsValue = settingsResult - ? ( - settingsResult.result as { - settings: NewWorktreeRuntimeSettings & { visibleTaskProviders?: unknown } - } - ).settings + const settingsResult = + settingsRes.status === 'fulfilled' ? settingsRead.interpret(settingsRes.value) : null + const settingsValue = settingsResult?.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (settingsResult.value as NewWorktreeRuntimeSettings & { visibleTaskProviders?: unknown }) : null if (settingsValue) { setRuntimeSettings(settingsValue) diff --git a/mobile/src/home/mobile-home-host-requests.ts b/mobile/src/home/mobile-home-host-requests.ts index e0e19a036d38..cf4c45cf4a87 100644 --- a/mobile/src/home/mobile-home-host-requests.ts +++ b/mobile/src/home/mobile-home-host-requests.ts @@ -1,3 +1,4 @@ +import { settingsRead } from '../transport/settings-read-operations' import { decodeAccountsSnapshot, type AccountsSnapshot } from '../components/AccountUsage' import type { HomeStatsSummary } from '../stats/home-stats-total' import { @@ -73,7 +74,7 @@ export function fetchMobileHomeTaskProviders( disposed: () => boolean ): void { Promise.all([ - sendSingleFlightRequest(client, hostId, 'settings.get'), + settingsRead.requestSingleFlight(client, hostId), sendSingleFlightRequest(client, hostId, 'preflight.check'), sendSingleFlightRequest(client, hostId, 'linear.status') ]) @@ -81,9 +82,10 @@ export function fetchMobileHomeTaskProviders( if (disposed()) { return } - const settings = settingsResponse.ok - ? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ?? - {}) as HomeTaskSettings) + const settingsResult = settingsRead.interpret(settingsResponse) + const settings = settingsResult.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + ((settingsResult.value ?? {}) as HomeTaskSettings) : {} const preflight = preflightResponse.ok ? (preflightResponse.result as HomePreflightStatus) diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index 6840184c3a09..64ece7b700e8 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -1,8 +1,9 @@ +import { optionalSettingsRead } from '../transport/settings-read-operations' import { useCallback } from 'react' import { getRepoExecutionHostId } from '../../../src/shared/execution-host' import { setCachedRepos } from '../cache/repo-cache' import type { RpcClient } from '../transport/rpc-client' -import type { ConnectionState, RpcSuccess } from '../transport/types' +import type { ConnectionState, RpcResponse, RpcSuccess } from '../transport/types' import type { RepoSummary } from '../worktree/host-worktree-rpc-types' import { repoColor } from '../worktree/repo-color' import { @@ -15,10 +16,12 @@ const REPO_METADATA_REFRESH_MS = 60_000 type SshTargetSummaryRow = { id: string; label: string } -async function requestResult(client: RpcClient, method: string): Promise { +async function requestMetadataResponse( + client: RpcClient, + method: 'repo.list' | 'ssh.listTargetSummaries' | 'host.platform' +): Promise { try { - const response = await client.sendRequest(method) - return response.ok ? response.result : null + return await client.sendRequest(method) } catch { // Best-effort: hosts that predate a method still list repos; labels degrade to host ids. return null @@ -45,8 +48,8 @@ function readHostPlatform(result: unknown): NodeJS.Platform | null { } function readHostSettingOverrides(result: unknown): unknown { - return (result as { settings?: { hostSettingOverrides?: unknown } } | null)?.settings - ?.hostSettingOverrides + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + return (result as { hostSettingOverrides?: unknown } | null)?.hostSettingOverrides } export function useHostRepoMetadata(args: { @@ -90,8 +93,12 @@ export function useHostRepoMetadata(args: { try { do { fetchRepoMetadataPendingRef.current.delete(requestClient) - const repoResponse = await requestClient.sendRequest('repo.list') - if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) { + const repoResponse = await requestMetadataResponse(requestClient, 'repo.list') + if ( + clientRef.current !== requestClient || + hostId !== requestHostId || + !repoResponse?.ok + ) { return } const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] } @@ -120,20 +127,25 @@ export function useHostRepoMetadata(args: { const hostIds = new Set(repoResult.repos.map((repo) => getRepoExecutionHostId(repo))) if (hostIds.size > 1) { const [sshTargets, hostSettings, hostPlatform] = await Promise.all([ - requestResult(requestClient, 'ssh.listTargetSummaries'), - requestResult(requestClient, 'settings.get'), - requestResult(requestClient, 'host.platform') + requestMetadataResponse(requestClient, 'ssh.listTargetSummaries'), + optionalSettingsRead.request(requestClient).catch(() => null), + requestMetadataResponse(requestClient, 'host.platform') ]) if (clientRef.current !== requestClient || hostId !== requestHostId) { return } + const hostSettingsResult = hostSettings + ? optionalSettingsRead.interpret(hostSettings) + : null setHostLabelById( buildHostLabelById({ - sshTargets: readSshTargets(sshTargets), - hostSettingOverrides: readHostSettingOverrides(hostSettings) + sshTargets: readSshTargets(sshTargets?.ok ? sshTargets.result : null), + hostSettingOverrides: readHostSettingOverrides( + hostSettingsResult?.accepted ? hostSettingsResult.value : undefined + ) }) ) - setHostPlatform(readHostPlatform(hostPlatform)) + setHostPlatform(readHostPlatform(hostPlatform?.ok ? hostPlatform.result : null)) } } while (fetchRepoMetadataPendingRef.current.has(requestClient)) } catch { diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index ea87e74ba0ee..22ec2957df65 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -1,3 +1,4 @@ +import { newTabSettingsRead } from '../transport/settings-read-operations' import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcSuccess } from '../transport/types' import { isFloatingWorkspaceWorktreeId } from './floating-workspace' @@ -23,22 +24,16 @@ export async function loadMobileNewTabAgentOptions(args: { ? client.sendRequest('preflight.detectAgents') : loadWorkspaceDetectedAgents(client, worktreeId) const [settingsResponse, detectedResponse] = await Promise.all([ - client.sendRequest('settings.get'), + newTabSettingsRead.request(client), detectedAgentsRequest ]) - if (!settingsResponse.ok) { - throw new Error((settingsResponse as RpcFailure).error.message) - } + const readSettings = newTabSettingsRead.interpret(settingsResponse) if (!detectedResponse.ok) { throw new Error((detectedResponse as RpcFailure).error.message) } - const settings = ( - (settingsResponse as RpcSuccess).result as { - settings?: MobileNewTabAgentSettings - } - ).settings return buildMobileNewTabAgentOptions( - settings, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + readSettings() as MobileNewTabAgentSettings | undefined, (detectedResponse as RpcSuccess).result as unknown[] ) } diff --git a/mobile/src/session/use-pr-bot-author-overrides.ts b/mobile/src/session/use-pr-bot-author-overrides.ts index 94e39794616e..5bce59d6d3c3 100644 --- a/mobile/src/session/use-pr-bot-author-overrides.ts +++ b/mobile/src/session/use-pr-bot-author-overrides.ts @@ -1,5 +1,6 @@ +import { botOverridesRead } from '../transport/settings-read-operations' import { useEffect, useMemo, useRef, useState } from 'react' -import type { ConnectionState, RpcSuccess } from '../transport/types' +import type { ConnectionState } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides' @@ -32,21 +33,16 @@ export function usePRBotAuthorOverrides( return } let stale = false - void client - .sendRequest('settings.get') + void botOverridesRead + .request(client) .then((response) => { - if (stale || !response.ok) { + if (stale) { return } - const result = (response as RpcSuccess).result as { - settings?: { prBotAuthorOverrides?: unknown } - } | null - const overrides = result?.settings?.prBotAuthorOverrides - setLogins( - Array.isArray(overrides) - ? overrides.filter((login): login is string => typeof login === 'string') - : [] - ) + const overrides = botOverridesRead.interpret(response) + if (overrides.accepted) { + setLogins(overrides.value) + } }) .catch(() => { // Best-effort: without the setting the heuristics still classify most bots. diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index 09fa587e0b1c..111b119db8ef 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -16,51 +16,51 @@ const hash = (parts: string[] | string): string => .update(Array.isArray(parts) ? parts.join('\n') : parts) .digest('hex') -// Task and Linear sort tests cover computation changes; render/style guards remain. -const EXPECTED_SCREEN_HOOKS = '25c9a72805e48caa9c6758d14a128fea1bdc6cfdea3e4e39a7f0026c5933c8d7' -const EXPECTED_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const EXPECTED_STATEMENTS = '1413f6e843f7a7ae849767b26b25d5eafc7fffda1fc37f283fbd883d9d8a4bda' -const EXPECTED_DECLARATIONS = '6ad0397123e59fc1047a14049c86ff31d81723673a7a7f5c41677471aec58415' -const EXPECTED_SEMANTICS = '4758ba019e4ff7cadd7ee02338719fa4fc4e1443e34cc290842819cfa1a70181' -const EXPECTED_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' -const EXPECTED_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f' +// Bound settings requests change source signatures; their behavior is covered by settings-read-operations.test.ts. +const SETTINGS_RPC_SCREEN_HOOKS = 'fb2d873e06001fbae7cee78d079b3df9dc2eedb56ab2f03c7ffb431bc8666191' +const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' +const SETTINGS_RPC_STATEMENTS = '1c99d6382f74c37c0ff896dfa634fb503c9fe8062e2280328d0b82f79f658fdb' +const MAIN_REBASED_DECLARATIONS = '6ad0397123e59fc1047a14049c86ff31d81723673a7a7f5c41677471aec58415' +const SETTINGS_RPC_SEMANTICS = '2431b1c07dfe9a9c94f5d3f4e91415ed99bd9e1bce3794f8bd5f094a29134d77' +const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' +const PRE_REFACTOR_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f' describe('Mobile Tasks refactor parity', () => { it('preserves recursively flattened hook and dependency order', () => { const screenHooks = readFlattenedMobileTasksHookSignatures('MobileTasksScreen') expect(screenHooks).toHaveLength(350) - expect(hash(screenHooks)).toBe(EXPECTED_SCREEN_HOOKS) + expect(hash(screenHooks)).toBe(SETTINGS_RPC_SCREEN_HOOKS) const diffHooks = readFlattenedMobileTasksHookSignatures('GitHubPrFileDiff') expect(diffHooks).toHaveLength(3) - expect(hash(diffHooks)).toBe(EXPECTED_DIFF_HOOKS) + expect(hash(diffHooks)).toBe(PRE_REFACTOR_DIFF_HOOKS) }) it('preserves every screen statement in execution order', () => { const statements = readFlattenedMobileTasksCoreStatements() expect(statements).toHaveLength(417) - expect(hash(statements)).toBe(EXPECTED_STATEMENTS) + expect(hash(statements)).toBe(SETTINGS_RPC_STATEMENTS) }) it('preserves every moved top-level declaration', () => { const declarations = readMobileTasksDeclarationSignatures() expect(declarations).toHaveLength(194) - expect(hash(declarations)).toBe(EXPECTED_DECLARATIONS) + expect(hash(declarations)).toBe(MAIN_REBASED_DECLARATIONS) }) it('preserves RPC calls, runtime strings, and JSX host signatures', () => { const semantics = readMobileTasksSemanticSource() - expect(semantics.split('\n')).toHaveLength(3_500) - expect(hash(semantics)).toBe(EXPECTED_SEMANTICS) + expect(semantics.split('\n')).toHaveLength(3_496) + expect(hash(semantics)).toBe(SETTINGS_RPC_SEMANTICS) }) it('preserves render expressions and event handlers in tree order', () => { const tokens = readFlattenedMobileTasksRenderTokens() expect(tokens).toHaveLength(35_195) - expect(hash(tokens)).toBe(EXPECTED_RENDER_TREE) + expect(hash(tokens)).toBe(PRE_REFACTOR_RENDER_TREE) }) it('preserves every StyleSheet property and value', () => { - expect(hash(readMobileTasksStyleSource())).toBe(EXPECTED_STYLES) + expect(hash(readMobileTasksStyleSource())).toBe(PRE_REFACTOR_STYLES) }) }) diff --git a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx index b1d8680b824b..1ededc361bcd 100644 --- a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx +++ b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx @@ -1,3 +1,4 @@ +import { settingsRead } from '../transport/settings-read-operations' import type { ClientSettingsActionsModel } from './use-mobile-tasks-client-settings-actions' import { MOBILE_TASKS_CAPABILITY, @@ -250,7 +251,7 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel setError('') const [settingsResponse, uiResponse, preflightResponse, linearStatusResponse] = await Promise.all([ - client.sendRequest('settings.get'), + settingsRead.request(client), client.sendRequest('ui.get'), client.sendRequest('preflight.check'), client.sendRequest('linear.status') @@ -259,9 +260,10 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel return } - const settings = isSuccess(settingsResponse) - ? (((settingsResponse.result as { settings?: RuntimeTaskSettings }).settings ?? - {}) as RuntimeTaskSettings) + const settingsResult = settingsRead.interpret(settingsResponse) + const settings = settingsResult.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + ((settingsResult.value ?? {}) as RuntimeTaskSettings) : {} setRuntimeTaskSettings(settings) const uiState = isSuccess(uiResponse) diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx index 91a7da4d2342..5befaddaebd7 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx @@ -1,3 +1,4 @@ +import { settingsRead } from '../transport/settings-read-operations' import type { WorkspaceSshStateModel } from './use-mobile-tasks-workspace-ssh-state' import { WORKTREE_CREATE_TIMEOUT_MS, @@ -72,11 +73,11 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod await ensureWorkspaceSshReady(targetRepo) let latestRuntimeTaskSettings = runtimeTaskSettings try { - const settingsResponse = await client.sendRequest('settings.get') - if (isSuccess(settingsResponse)) { - latestRuntimeTaskSettings = (( - settingsResponse.result as { settings?: RuntimeTaskSettings } - ).settings ?? {}) as RuntimeTaskSettings + const settingsReply = await settingsRead.request(client) + const settingsResult = settingsRead.interpret(settingsReply) + if (settingsResult.accepted) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + latestRuntimeTaskSettings = (settingsResult.value ?? {}) as RuntimeTaskSettings setRuntimeTaskSettings(latestRuntimeTaskSettings) } } catch { diff --git a/mobile/src/transport/rpc-acceptance-policies.ts b/mobile/src/transport/rpc-acceptance-policies.ts index 742d94aafa7c..89e72f45468c 100644 --- a/mobile/src/transport/rpc-acceptance-policies.ts +++ b/mobile/src/transport/rpc-acceptance-policies.ts @@ -31,3 +31,18 @@ export function isStreamingOpenerReply( ): response is RpcSuccess & { streaming: true } { return response.ok && response.streaming === true } + +/** New-tab errors historically show the host message without its diagnostic code. */ +export function requireRpcResultOrThrowMessage(response: RpcResponse): unknown { + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result +} + +/** An accepted null result still commits; a refused reply leaves existing state alone. */ +export function rpcSuccessResultOrSkip( + response: RpcResponse +): { accepted: false } | { accepted: true; value: unknown } { + return response.ok ? { accepted: true, value: response.result } : { accepted: false } +} diff --git a/mobile/src/transport/rpc-operation-compile-fence.ts b/mobile/src/transport/rpc-operation-compile-fence.ts index 3188774743c6..ac14b326fd26 100644 --- a/mobile/src/transport/rpc-operation-compile-fence.ts +++ b/mobile/src/transport/rpc-operation-compile-fence.ts @@ -1,6 +1,12 @@ import type { RpcClient } from './rpc-client' import type { RpcMethodName, RpcParams, RpcSendParams } from './rpc-params-contract' -import { defineRpcOperation, runRpcOperation, startRpcOperation } from './rpc-operation' +import { + bindDeferredRpcOperation, + captureRpcOperationSettlement, + defineRpcOperation, + runRpcOperation, + startRpcOperation +} from './rpc-operation' import { rpcResultVariants } from './rpc-operation-result-reader' import { workspaceListAtBarrier, @@ -51,7 +57,7 @@ export const fenceDecodingWithoutReader: RequireResultRpcDefinition< barrier: 'on-settle' } -// @ts-expect-error the four policies in rpc-acceptance-policies.ts are the whole vocabulary +// @ts-expect-error only the named policies in rpc-acceptance-policies.ts are allowed export const fenceInventedPolicy: RpcAcceptanceName = 'no-error-means-fine' // A reader for a payload no acceptance policy here admits, i.e. one belonging to some other @@ -83,7 +89,6 @@ export const fenceDefineRejectsMismatch = defineRpcOperation({ // @ts-expect-error no overload of defineRpcOperation pairs a probe with a payload reader acceptance: 'method-not-found-refusal', barrier: 'on-settle', - // @ts-expect-error ... and the reader it would need is exactly what the probe overload bans read: workspaceRowsReader }) @@ -189,3 +194,30 @@ export const fenceObjectWithoutReader: RpcOperation< acceptance: 'object-result-or-null', barrier: 'on-settle' } + +const fenceDeferred = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'fence.deferred', + method: 'files.searchPaths', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: workspaceRowsReader + }) +) + +export function fenceDeferredArguments(): void { + // @ts-expect-error required params must not be omitted + fenceDeferred.request(client) + // @ts-expect-error request params are method-keyed, including deferred requests + fenceDeferred.request(client, { worktree: 3 }) + // @ts-expect-error single-flight operations also require their method's params + fenceDeferred.requestSingleFlight(client, 'host') + // @ts-expect-error on-settle operations cannot defer interpretation behind a caller guard + bindDeferredRpcOperation(workspaceListOrNull) + // @ts-expect-error caller-barrier operations cannot interpret as each request settles + runRpcOperation(client, fenceDeferred.operation, { worktree: 'w' }) + // @ts-expect-error a caller-barrier operation cannot acquire all-settled behavior implicitly + startRpcOperation(client, fenceDeferred.operation, { worktree: 'w' }) + // @ts-expect-error capturing must not decode a caller-barrier operation before its guard + captureRpcOperationSettlement(client, fenceDeferred.operation, { worktree: 'w' }) +} diff --git a/mobile/src/transport/rpc-operation-contract.ts b/mobile/src/transport/rpc-operation-contract.ts index 581506a97a7f..23bc012319c2 100644 --- a/mobile/src/transport/rpc-operation-contract.ts +++ b/mobile/src/transport/rpc-operation-contract.ts @@ -12,9 +12,11 @@ export type RpcAcceptanceName = | 'object-result-or-null' | 'method-not-found-refusal' | 'streaming-opener' + | 'success-result-or-skip' + | 'require-result-or-throw-message' /** Where a settled reply may become a value or a throw. */ -export type RpcInterpretationBarrier = 'on-settle' | 'after-all-requests' +export type RpcInterpretationBarrier = 'on-settle' | 'after-all-requests' | 'after-caller-barrier' export type RpcDecodeIssue = { readonly path: string; readonly message: string } @@ -76,7 +78,7 @@ export type RpcOperation< } & { [Policy in RpcAcceptanceName]: { readonly acceptance: Policy - readonly read: Policy extends 'require-result-or-throw' | 'object-result-or-null' + readonly read: Policy extends RpcReaderAcceptance ? RpcCompatibleReader : undefined } @@ -89,18 +91,19 @@ export type AnyRpcOperation = Pick< > & { readonly read: RpcCompatibleReader | undefined } /** The verdict the declared policy yields. Not a per-call choice. */ -export type RpcVerdict< - Acceptance extends RpcAcceptanceName, - Value -> = Acceptance extends 'require-result-or-throw' +export type RpcVerdict = Acceptance extends + | 'require-result-or-throw' + | 'require-result-or-throw-message' ? Value - : Acceptance extends 'object-result-or-null' - ? Value | null - : Acceptance extends 'method-not-found-refusal' - ? boolean - : Acceptance extends 'streaming-opener' - ? RpcStreamOpenerReply | null - : never + : Acceptance extends 'success-result-or-skip' + ? RpcAcceptedResult + : Acceptance extends 'object-result-or-null' + ? Value | null + : Acceptance extends 'method-not-found-refusal' + ? boolean + : Acceptance extends 'streaming-opener' + ? RpcStreamOpenerReply | null + : never export type RpcOperationSettlement = | { readonly status: 'fulfilled'; readonly outcome: RpcRequestOutcome } @@ -153,3 +156,25 @@ export type StreamOpenerRpcDefinition< /** The opener's value is the reply itself; frames arrive on the subscription, not here. */ read?: never } + +/** Refusal is distinct from an accepted null/undefined payload. */ +export type RpcAcceptedResult = + | { readonly accepted: false } + | { readonly accepted: true; readonly value: Value } + +export type RpcReaderAcceptance = + | 'require-result-or-throw' + | 'object-result-or-null' + | 'success-result-or-skip' + | 'require-result-or-throw-message' + +export type LegacyResultRpcDefinition< + Method extends RpcMethodName, + Acceptance extends 'success-result-or-skip' | 'require-result-or-throw-message', + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: Acceptance + read: RpcCompatibleReader +} diff --git a/mobile/src/transport/rpc-operation-reply.ts b/mobile/src/transport/rpc-operation-reply.ts new file mode 100644 index 000000000000..881f2e82c596 --- /dev/null +++ b/mobile/src/transport/rpc-operation-reply.ts @@ -0,0 +1,92 @@ +import type { RpcResponse } from './types' +import type { + AnyRpcOperation, + RpcDecodeIssue, + RpcRequestOutcome, + RpcSalvageReport +} from './rpc-operation-contract' +import { + isStreamingOpenerReply, + requireRpcResultOrThrowCodedError, + rpcObjectResultOrNull +} from './rpc-acceptance-policies' + +const NOTHING_SALVAGED: RpcSalvageReport = { droppedPaths: [], droppedCount: 0 } + +type AdmittedPayload = + | { readonly admitted: true; readonly value: unknown } + | { readonly admitted: false; readonly issues: readonly RpcDecodeIssue[] } + +// The payload the operation's own acceptance policy admits from a fulfilled success. +function admitPayload(operation: AnyRpcOperation, response: RpcResponse): AdmittedPayload { + switch (operation.acceptance) { + case 'object-result-or-null': { + const object = rpcObjectResultOrNull(response) + return object === null + ? { admitted: false, issues: [{ path: 'result', message: 'not a non-null object' }] } + : { admitted: true, value: object } + } + case 'streaming-opener': + return isStreamingOpenerReply(response) + ? { admitted: true, value: response } + : { admitted: false, issues: [{ path: 'streaming', message: 'reply opened no stream' }] } + default: + // Reuses the policy rather than reading `.result` again; a success never throws here. + return { admitted: true, value: requireRpcResultOrThrowCodedError(response) } + } +} + +const READERLESS_VARIANTS: Record = { + 'method-not-found-refusal': 'accepted', + 'streaming-opener': 'stream-opened' +} + +export function classifyRpcReply( + operation: AnyRpcOperation, + response: RpcResponse +): RpcRequestOutcome { + if (!response.ok) { + return { kind: 'outer-refused', error: response.error, raw: response } + } + const payload = admitPayload(operation, response) + if (!payload.admitted) { + return { kind: 'incompatible', raw: response, issues: payload.issues } + } + const read = operation.read + if (!read) { + return { + kind: 'decoded', + variant: READERLESS_VARIANTS[operation.acceptance] ?? 'accepted', + value: payload.value, + raw: response, + salvage: NOTHING_SALVAGED + } + } + let result: ReturnType + try { + result = read(payload.value) + } catch (error) { + // These legacy policies preserve property-read exceptions at the caller's barrier. + if ( + operation.acceptance === 'success-result-or-skip' || + operation.acceptance === 'require-result-or-throw-message' + ) { + throw error + } + return { + kind: 'incompatible', + raw: response, + issues: [{ path: '', message: error instanceof Error ? error.message : String(error) }] + } + } + if (!result.compatible) { + return { kind: 'incompatible', raw: response, issues: result.issues } + } + return { + kind: 'decoded', + variant: result.variant, + value: result.value, + raw: response, + salvage: result.salvage + } +} diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts index def072dac81e..f4ee18d9dfed 100644 --- a/mobile/src/transport/rpc-operation.ts +++ b/mobile/src/transport/rpc-operation.ts @@ -1,33 +1,41 @@ -import type { UnvalidatedRpcRequestPort, SendRequestOptions } from './unvalidated-rpc-request-port' -import type { RpcMethodName, RpcSendParams } from './rpc-params-contract' -import type { RpcResponse } from './types' import { isMethodNotFoundRefusal, isStreamingOpenerReply, requireRpcResultOrThrowCodedError, - rpcObjectResultOrNull + requireRpcResultOrThrowMessage, + rpcSuccessResultOrSkip } from './rpc-acceptance-policies' import { RpcIncompatibleReplyError } from './rpc-incompatible-reply-error' +import type { UnvalidatedRpcRequestPort, SendRequestOptions } from './unvalidated-rpc-request-port' +import type { RpcMethodName, RpcSendParams } from './rpc-params-contract' +import { classifyRpcReply } from './rpc-operation-reply' +import { sendSingleFlightRequest } from './request-single-flight' +import type { RpcClient } from './rpc-client' +import type { RpcResponse } from './types' import type { AnyRpcOperation, CapabilityProbeRpcDefinition, ObjectResultRpcDefinition, RpcAcceptanceName, RpcCompatibleReader, - RpcDecodeIssue, RpcInterpretationBarrier, RpcOperation, RpcOperationSettlement, RpcRequestOutcome, - RpcSalvageReport, RequireResultRpcDefinition, StreamOpenerRpcDefinition, - RpcVerdict + RpcVerdict, + LegacyResultRpcDefinition } from './rpc-operation-contract' -const NOTHING_SALVAGED: RpcSalvageReport = { droppedPaths: [], droppedCount: 0 } - type RpcOperationDefinitionInput = + | LegacyResultRpcDefinition< + RpcMethodName, + 'success-result-or-skip' | 'require-result-or-throw-message', + string, + unknown, + RpcInterpretationBarrier + > | RequireResultRpcDefinition | ObjectResultRpcDefinition | CapabilityProbeRpcDefinition @@ -61,6 +69,15 @@ export function defineRpcOperation< >( definition: StreamOpenerRpcDefinition ): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Acceptance extends 'success-result-or-skip' | 'require-result-or-throw-message', + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + definition: LegacyResultRpcDefinition +): RpcOperation export function defineRpcOperation(definition: RpcOperationDefinitionInput): AnyRpcOperation { // Why: frozen so no call site can swap the policy or the barrier on a shared descriptor. return Object.freeze({ @@ -68,7 +85,7 @@ export function defineRpcOperation(definition: RpcOperationDefinitionInput): Any method: definition.method, acceptance: definition.acceptance, barrier: definition.barrier, - // Why: classifyReply only ever hands a reader the payload its own policy admitted, so + // Why: classifyRpcReply only ever hands a reader the payload its own policy admitted, so // the object policy's narrower parameter is sound to store as unknown. read: definition.read as RpcCompatibleReader | undefined }) @@ -86,89 +103,37 @@ async function request( // and an always-settled send would make Promise.all wait for a peer where today the group // fails immediately, letting a later policy surface a different error. const response = await client.sendRequest(operation.method, params, options) - return classifyReply(operation, response) -} - -type AdmittedPayload = - | { readonly admitted: true; readonly value: unknown } - | { readonly admitted: false; readonly issues: readonly RpcDecodeIssue[] } - -// The payload the operation's own acceptance policy admits from a fulfilled success. -function admitPayload(operation: AnyRpcOperation, response: RpcResponse): AdmittedPayload { - switch (operation.acceptance) { - case 'object-result-or-null': { - const object = rpcObjectResultOrNull(response) - return object === null - ? { admitted: false, issues: [{ path: 'result', message: 'not a non-null object' }] } - : { admitted: true, value: object } - } - case 'streaming-opener': - return isStreamingOpenerReply(response) - ? { admitted: true, value: response } - : { admitted: false, issues: [{ path: 'streaming', message: 'reply opened no stream' }] } - default: - // Reuses the policy rather than reading `.result` again; a success never throws here. - return { admitted: true, value: requireRpcResultOrThrowCodedError(response) } - } -} - -const READERLESS_VARIANTS: Record = { - 'method-not-found-refusal': 'accepted', - 'streaming-opener': 'stream-opened' -} - -function classifyReply( - operation: AnyRpcOperation, - response: RpcResponse -): RpcRequestOutcome { - if (!response.ok) { - return { kind: 'outer-refused', error: response.error, raw: response } - } - const payload = admitPayload(operation, response) - if (!payload.admitted) { - return { kind: 'incompatible', raw: response, issues: payload.issues } - } - const read = operation.read - if (!read) { - return { - kind: 'decoded', - variant: READERLESS_VARIANTS[operation.acceptance] ?? 'accepted', - value: payload.value, - raw: response, - salvage: NOTHING_SALVAGED - } - } - let result: ReturnType - try { - result = read(payload.value) - } catch (error) { - // A reader that throws is an incompatible reply, never a transport failure. - return { - kind: 'incompatible', - raw: response, - issues: [{ path: '', message: error instanceof Error ? error.message : String(error) }] - } - } - if (!result.compatible) { - return { kind: 'incompatible', raw: response, issues: result.issues } - } - return { - kind: 'decoded', - variant: result.variant, - value: result.value, - raw: response, - salvage: result.salvage - } + return classifyRpcReply(operation, response) } // Applies the operation's declared acceptance policy. Private on purpose: there is no // free-standing callOrThrow, so no call site can pick a different rule for the same reply. -function interpret( +function interpretRpcOutcome( operation: AnyRpcOperation, settled: RpcRequestOutcome ): unknown { const acceptance: RpcAcceptanceName = operation.acceptance switch (acceptance) { + case 'success-result-or-skip': { + const accepted = rpcSuccessResultOrSkip(settled.raw) + if (!accepted.accepted) { + return accepted + } + if (settled.kind === 'incompatible') { + throw new RpcIncompatibleReplyError(operation.name, operation.method, settled.issues) + } + return settled.kind === 'decoded' + ? { accepted: true, value: settled.value } + : { accepted: false } + } + case 'require-result-or-throw-message': + if (settled.kind === 'outer-refused') { + return requireRpcResultOrThrowMessage(settled.raw) + } + if (settled.kind === 'incompatible') { + throw new RpcIncompatibleReplyError(operation.name, operation.method, settled.issues) + } + return settled.value case 'require-result-or-throw': if (settled.kind === 'outer-refused') { // Reuses the policy so the thrown `code: message` text cannot drift from main's. @@ -196,7 +161,7 @@ function interpretSettlement( // isLogicalClientCutoverError matches class or exact message; a wrapper loses both. throw settlement.error } - return interpret(operation, settlement.outcome) + return interpretRpcOutcome(operation, settlement.outcome) } /** Sends and interprets at the operation's own barrier. Only for barrier 'on-settle'. */ @@ -212,7 +177,8 @@ export async function runRpcOperation< options?: SendRequestOptions ): Promise> { const outcome = await request(client, operation, params, options) - return interpret(operation, outcome) as RpcVerdict + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + return interpretRpcOutcome(operation, outcome) as RpcVerdict } /** The named opt-in to all-settled semantics. Yields an outcome, never a verdict: the @@ -222,7 +188,7 @@ export async function captureRpcOperationSettlement< Acceptance extends RpcAcceptanceName, Variant extends string, Value, - Barrier extends RpcInterpretationBarrier + Barrier extends Exclude >( client: UnvalidatedRpcRequestPort, operation: RpcOperation, @@ -280,3 +246,38 @@ export async function interpretAtRpcBarrier< interpretSettlement(entry.operation, settlements[index]) ) as RpcBarrierVerdicts } + +/** Preserves omitted sender arguments as well as explicit undefined. */ +type RpcSendArguments = + void extends RpcSendParams + ? [params?: RpcSendParams, options?: SendRequestOptions] + : [params: RpcSendParams, options?: SendRequestOptions] + +/** Binds sending and interpretation while preserving the transport promise identity. */ +export function bindDeferredRpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value +>(operation: RpcOperation) { + type Verdict = RpcVerdict + return Object.freeze({ + operation, + request(client: UnvalidatedRpcRequestPort, ...args: RpcSendArguments) { + return client.sendRequest(operation.method, ...args) + }, + requestSingleFlight( + client: RpcClient, + hostId: string, + ...args: void extends RpcSendParams + ? [params?: RpcSendParams] + : [params: RpcSendParams] + ) { + return sendSingleFlightRequest(client, hostId, operation.method, args[0]) + }, + interpret(response: RpcResponse): Verdict { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + return interpretRpcOutcome(operation, classifyRpcReply(operation, response)) as Verdict + } + }) +} diff --git a/mobile/src/transport/settings-read-operations.test.ts b/mobile/src/transport/settings-read-operations.test.ts new file mode 100644 index 000000000000..3a81da9942fe --- /dev/null +++ b/mobile/src/transport/settings-read-operations.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest' +import { loadMobileNewTabAgentOptions } from '../session/mobile-new-tab-agent-loader' +import { FLOATING_WORKSPACE_WORKTREE_ID } from '../session/floating-workspace' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { markRpcDeliveryUnknown, isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { LogicalClientCutoverError, isLogicalClientCutoverError } from './stable-logical-rpc-client' +import { + settingsRead, + optionalSettingsRead, + botOverridesRead, + newTabSettingsRead +} from './settings-read-operations' +import type { RpcResponse } from './types' + +function success(result: unknown): RpcResponse { + return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime' } } +} + +function refusal(message = 'settings refused'): RpcResponse { + return { + id: 'reply', + ok: false, + error: { code: 'runtime_error', message }, + _meta: { runtimeId: 'runtime' } + } +} + +function deferred() { + let resolve!: (response: RpcResponse) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + +function replyWith(response: RpcResponse) { + const client = new FakeSession('connected') + client.sendRequest.mockResolvedValue(response) + return client +} + +async function drain() { + for (let turn = 0; turn < 20; turn++) { + await Promise.resolve() + } +} + +describe('settings historical acceptance', () => { + it('distinguishes a skipped refusal from an accepted absent settings member', async () => { + const skipped = await settingsRead.request(replyWith(refusal())) + const missing = await settingsRead.request(replyWith(success({}))) + expect(settingsRead.interpret(skipped)).toEqual({ accepted: false }) + expect(settingsRead.interpret(missing)).toEqual({ accepted: true, value: undefined }) + }) + + it('retains opaque settings fields and reference identity without tightening acceptance', async () => { + const value = { futureField: { nested: ['kept'] }, disabledTuiAgents: 'legacy-value' } + const reply = await settingsRead.request(replyWith(success({ settings: value }))) + const result = settingsRead.interpret(reply) + expect(result.accepted && result.value).toBe(value) + }) + + it.each([null, undefined])( + 'preserves the unguarded settings read for %s only when interpreted', + async (value) => { + const reply = await settingsRead.request(replyWith(success(value))) + expect(() => settingsRead.interpret(reply)).toThrow(TypeError) + expect(() => settingsRead.interpret(reply)).toThrow( + `Cannot read properties of ${String(value)} (reading 'settings')` + ) + const optional = await optionalSettingsRead.request(replyWith(success(value))) + expect(optionalSettingsRead.interpret(optional)).toEqual({ accepted: true, value: undefined }) + } + ) + + it.each([true, false, 0, 'text', []])('preserves property boxing for %j', async (value) => { + const reply = await settingsRead.request(replyWith(success(value))) + expect(settingsRead.interpret(reply)).toEqual({ accepted: true, value: undefined }) + }) + + it('filters bot logins while distinguishing a refused refresh', async () => { + const reply = await botOverridesRead.request( + replyWith(success({ settings: { prBotAuthorOverrides: ['bot', 3, null, ''] } })) + ) + expect(botOverridesRead.interpret(reply)).toEqual({ accepted: true, value: ['bot', ''] }) + const refused = await botOverridesRead.request(replyWith(refusal())) + expect(botOverridesRead.interpret(refused)).toEqual({ accepted: false }) + const empty = await botOverridesRead.request(replyWith(success(null))) + expect(botOverridesRead.interpret(empty)).toEqual({ accepted: true, value: [] }) + }) + + it('does not read a stale payload until its caller permits interpretation', async () => { + const read = vi.fn(() => ({})) + const reply = await settingsRead.request( + replyWith( + success({ + get settings() { + return read() + } + }) + ) + ) + expect(read).not.toHaveBeenCalled() + settingsRead.interpret(reply) + expect(read).toHaveBeenCalledOnce() + }) + + it('keeps sender argument presence and options intact', async () => { + const client = replyWith(success({ settings: {} })) + await settingsRead.request(client) + await settingsRead.request(client, undefined) + const options = { timeoutMs: 1234, failWhenDisconnected: true } + await settingsRead.request(client, undefined, options) + expect(client.sendRequest.mock.calls).toEqual([ + ['settings.get'], + ['settings.get', undefined], + ['settings.get', undefined, options] + ]) + }) + + it('keeps original delivery-unknown and cutover errors on the rejection channel', async () => { + for (const error of [ + markRpcDeliveryUnknown(new Error('ambiguous')), + new LogicalClientCutoverError() + ]) { + const client = new FakeSession('connected') + client.sendRequest.mockRejectedValue(error) + const caught = await settingsRead.request(client).catch((value: unknown) => value) + expect(caught).toBe(error) + expect(isRpcDeliveryUnknown(caught)).toBe(isRpcDeliveryUnknown(error)) + expect(isLogicalClientCutoverError(caught)).toBe(isLogicalClientCutoverError(error)) + } + }) +}) + +describe('new-tab settlement barriers', () => { + function load(client: FakeSession) { + return loadMobileNewTabAgentOptions({ client, worktreeId: FLOATING_WORKSPACE_WORKTREE_ID }) + } + + it('waits for the peer after a settings refusal and reports only the host message', async () => { + const peer = deferred() + const client = new FakeSession('connected') + client.sendRequest.mockImplementation((method) => + method === 'settings.get' ? Promise.resolve(refusal()) : peer.promise + ) + let settled = false + const outcome = load(client).catch((error: unknown) => { + settled = true + return error + }) + await drain() + expect(settled).toBe(false) + peer.resolve(success([])) + expect(await outcome).toEqual(new Error('settings refused')) + }) + + it('lets a peer transport failure win over a fulfilled settings refusal', async () => { + const peer = deferred() + const client = new FakeSession('connected') + client.sendRequest.mockImplementation((method) => + method === 'settings.get' ? Promise.resolve(refusal()) : peer.promise + ) + const outcome = load(client).catch((error: unknown) => error) + await drain() + const error = markRpcDeliveryUnknown(new Error('agents disconnected')) + peer.reject(error) + expect(await outcome).toBe(error) + }) + + it('rejects immediately on settings transport failure while its peer stays pending', async () => { + const peer = deferred() + const client = new FakeSession('connected') + const error = new Error('settings disconnected') + client.sendRequest.mockImplementation((method) => + method === 'settings.get' ? Promise.reject(error) : peer.promise + ) + let caught: unknown + const outcome = load(client).catch((value: unknown) => { + caught = value + }) + await drain() + expect(caught).toBe(error) + peer.resolve(success([])) + await outcome + }) + + it('checks the peer refusal before accessing a null settings payload', async () => { + const client = new FakeSession('connected') + client.sendRequest.mockImplementation(async (method) => + method === 'settings.get' ? success(null) : refusal('agents refused') + ) + await expect(load(client)).rejects.toThrow('agents refused') + const reply = await newTabSettingsRead.request(replyWith(success(null))) + const readSettings = newTabSettingsRead.interpret(reply) + expect(() => readSettings()).toThrow(TypeError) + }) +}) diff --git a/mobile/src/transport/settings-read-operations.ts b/mobile/src/transport/settings-read-operations.ts new file mode 100644 index 000000000000..f33471dcaf22 --- /dev/null +++ b/mobile/src/transport/settings-read-operations.ts @@ -0,0 +1,93 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from './rpc-operation' +import type { RpcCompatibleReader } from './rpc-operation-contract' + +function settingsMember(raw: unknown): unknown { + const boxed: { readonly settings?: unknown } | null | undefined = raw == null ? raw : Object(raw) + // Preserve the native engine's existing property-read exception on null/undefined. + return boxed!.settings +} + +// Settings remain opaque: callers historically retain fields without validating their shapes. +const settingsReader: RpcCompatibleReader = (raw) => ({ + compatible: true, + variant: 'settings-member', + value: settingsMember(raw), + salvage: { droppedPaths: [], droppedCount: 0 } +}) + +const optionalSettingsReader: RpcCompatibleReader = ( + raw +) => ({ + compatible: true, + variant: 'optional-settings-member', + value: raw == null ? undefined : settingsMember(raw), + salvage: { droppedPaths: [], droppedCount: 0 } +}) + +const botOverridesReader: RpcCompatibleReader = (raw) => { + const settings = raw == null ? undefined : settingsMember(raw) + const overrides: unknown = + settings == null ? undefined : Reflect.get(Object(settings), 'prBotAuthorOverrides') + return { + compatible: true, + variant: 'bot-logins', + value: Array.isArray(overrides) + ? overrides.filter((login): login is string => typeof login === 'string') + : [], + salvage: { droppedPaths: [], droppedCount: 0 } + } +} + +/** Workspace context, submit, task hydration/create and home providers share this acceptance. */ +export const settingsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.member-or-skip', + method: 'settings.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: settingsReader + }) +) + +/** History resume and repo labels historically tolerate an absent or null result. */ +export const optionalSettingsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.optional-member-or-skip', + method: 'settings.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: optionalSettingsReader + }) +) + +// New-tab checks the sibling's refusal before touching settings, but after its own refusal. +const newTabSettingsReader: RpcCompatibleReader< + unknown, + 'deferred-settings-member', + () => unknown +> = (raw) => ({ + compatible: true, + variant: 'deferred-settings-member', + value: () => settingsMember(raw), + salvage: { droppedPaths: [], droppedCount: 0 } +}) + +export const newTabSettingsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.new-tab-message-error', + method: 'settings.get', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: newTabSettingsReader + }) +) + +export const botOverridesRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.bot-logins-or-skip', + method: 'settings.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: botOverridesReader + }) +) diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index 82317f272fc5..dba48bbf3c87 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -35,7 +35,7 @@ export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequest // Composes the port into RpcClient, which is why every holder of a client still carries it. { file: 'src/transport/rpc-client.ts', references: 2 }, // The typed boundary itself — the one module that turns a reply into a declared type. - { file: 'src/transport/rpc-operation.ts', references: 2 }, + { file: 'src/transport/rpc-operation.ts', references: 5 }, // Forwards the port across a physical-client cutover. { file: 'src/transport/stable-logical-rpc-client.ts', references: 2 } ] @@ -49,7 +49,7 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'app/terminal-settings.tsx', references: 3 }, // src/agent-history/ — agent history loads - { file: 'src/agent-history/MobileAgentSessionHistoryPanel.tsx', references: 7 }, + { file: 'src/agent-history/MobileAgentSessionHistoryPanel.tsx', references: 6 }, { file: 'src/agent-history/use-mobile-agent-history-state.ts', references: 2 }, // src/browser/ — hosted browser control @@ -59,10 +59,9 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques // src/components/ — shared widgets that fetch their own data { file: 'src/components/codex-reset-credit-capability.ts', references: 2 }, { file: 'src/components/codex-reset-credit.ts', references: 3 }, - { file: 'src/components/use-new-workspace-create-submit.ts', references: 1 }, { file: 'src/components/use-new-workspace-execution-target.ts', references: 4 }, { file: 'src/components/use-new-workspace-repositories.ts', references: 1 }, - { file: 'src/components/use-new-workspace-runtime-context.ts', references: 4 }, + { file: 'src/components/use-new-workspace-runtime-context.ts', references: 3 }, { file: 'src/components/use-new-workspace-setup-script.ts', references: 1 }, // src/dictation/ — dictation session control @@ -76,7 +75,7 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/files/MobileFileExplorerPanel.tsx', references: 2 }, // src/home/ — home screen host reads - { file: 'src/home/mobile-home-host-requests.ts', references: 6 }, + { file: 'src/home/mobile-home-host-requests.ts', references: 5 }, // src/hooks/ — cross-screen data hooks { file: 'src/hooks/mobile-dictation-audio-chunk.ts', references: 1 }, @@ -85,7 +84,7 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques // src/host-screen/ — host screen catalog and actions { file: 'src/host-screen/host-screen-overlays.tsx', references: 1 }, - { file: 'src/host-screen/use-host-repo-metadata.ts', references: 2 }, + { file: 'src/host-screen/use-host-repo-metadata.ts', references: 1 }, { file: 'src/host-screen/use-host-view-settings.ts', references: 2 }, { file: 'src/host-screen/use-host-worktree-actions.ts', references: 3 }, @@ -108,7 +107,7 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/session/mobile-native-chat-send.ts', references: 2 }, { file: 'src/session/mobile-native-chat-session-option-persistence.ts', references: 1 }, { file: 'src/session/mobile-native-chat-stale-input.ts', references: 1 }, - { file: 'src/session/mobile-new-tab-agent-loader.ts', references: 5 }, + { file: 'src/session/mobile-new-tab-agent-loader.ts', references: 4 }, { file: 'src/session/mobile-session-tab-activation.ts', references: 3 }, { file: 'src/session/mobile-session-tabs-stream-health.ts', references: 1 }, { file: 'src/session/mobile-structured-agent-session-launch.ts', references: 3 }, @@ -141,7 +140,6 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/session/use-mobile-session-terminal-send-actions.ts', references: 2 }, { file: 'src/session/use-mobile-session-terminal-stream-display.ts', references: 1 }, { file: 'src/session/use-mobile-terminal-paste.ts', references: 1 }, - { file: 'src/session/use-pr-bot-author-overrides.ts', references: 1 }, { file: 'src/session/use-quick-commands.ts', references: 2 }, // src/settings/ — settings screen actions @@ -194,11 +192,11 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 }, { file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 }, { file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 4 }, { file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 }, { file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 }, { file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 3 }, { file: 'src/tasks/use-mobile-tasks-workspace-source-effects.tsx', references: 2 }, { file: 'src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx', references: 2 }, { file: 'src/tasks/use-mobile-tasks-workspace-ssh-state.tsx', references: 5 }, From 2fc84cb49292b6636bfd364bba528383947a9e1e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:58:02 -0700 Subject: [PATCH 005/209] fix(mobile): give native chat one tail-follow owner so streaming stops jumping (#20493) * fix(mobile): stabilize native chat tail following * refactor(mobile): give native chat one tail-follow owner Extract the streaming scroll contract into use-mobile-native-chat-tail-follow, so intent and geometry have a single writer instead of a state/ref pair hand-synced at five call sites. No behaviour change: the existing guards pass untouched. * fix(mobile): fence native chat tail follow through momentum * fix(mobile): repin chat at measured tail --------- Co-authored-by: Merge Sim --- .../src/session/MobileNativeChatView.test.ts | 409 +++++++++++++++++- mobile/src/session/MobileNativeChatView.tsx | 81 ++-- .../use-mobile-native-chat-tail-follow.ts | 184 ++++++++ 3 files changed, 614 insertions(+), 60 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-tail-follow.ts diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index c573d89cc89a..2e67b1eaa9f1 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -1,17 +1,26 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' -vi.mock('react-native', () => ({ - ActivityIndicator: 'ActivityIndicator', - FlatList: 'FlatList', - Pressable: 'Pressable', - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, - Text: 'Text', - View: 'View' -})) +const scrollToEnd = vi.hoisted(() => vi.fn()) +const scrollToOffset = vi.hoisted(() => vi.fn()) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd, scrollToOffset }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) @@ -77,6 +86,9 @@ type Overrides = { agentWorking?: boolean canStop?: boolean sendSurfaceId?: string + keyboardInset?: number + hasMore?: boolean + onLoadEarlier?: () => void } function assistantTurn(id: string, text: string): NativeChatMessage { @@ -102,9 +114,21 @@ function chatViewElement(overrides: Overrides): ReturnType describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) + ) + vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType) => + clearTimeout(handle) + ) + }) + afterEach(() => { act(() => renderer?.unmount()) renderer = null + scrollToEnd.mockReset() + scrollToOffset.mockReset() + vi.unstubAllGlobals() }) async function render(overrides: Overrides = {}): Promise { @@ -133,15 +157,18 @@ describe('MobileNativeChatView', () => { }) function listIds(): string[] { - const list = renderer!.root.find((node) => node.type === 'FlatList') - return (list.props.data as { id: string }[]).map((row) => row.id) + return (list().props.data as { id: string }[]).map((row) => row.id) + } + + function list(): ReactTestInstance { + return renderer!.root.find((node) => node.type === 'FlatList') } function renderedRow(id: string): ReturnType { - const list = renderer!.root.find((node) => node.type === 'FlatList') - const data = list.props.data as NativeChatMessage[] + const listNode = list() + const data = listNode.props.data as NativeChatMessage[] const index = data.findIndex((row) => row.id === id) - return list.props.renderItem({ item: data[index], index }) + return listNode.props.renderItem({ item: data[index], index }) } function banners(): ReactTestInstance[] { @@ -170,6 +197,19 @@ describe('MobileNativeChatView', () => { }) } + async function scrollAwayFromTail(): Promise { + await act(async () => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + }) + } + it('renders the route-reported failure verbatim', async () => { await render({ sendErrorMessage: 'Permission reply failed' }) @@ -214,6 +254,347 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + it('lets content growth own streaming tail-follow without a delayed animated command', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Starting')] + await render({ folded }) + await act(async () => vi.runOnlyPendingTimers()) + scrollToEnd.mockClear() + + await update({ folded, streaming: 'Streaming output' }) + act(() => list().props.onContentSizeChange(320, 900)) + + expect(scrollToOffset).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 }) + await act(async () => vi.advanceTimersByTime(60)) + expect(scrollToOffset).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('stops tail-follow before loading earlier history can resize the list', async () => { + const folded = [assistantTurn('a1', 'History')] + const onLoadEarlier = vi.fn() + await render({ folded, hasMore: true, onLoadEarlier }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 40 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 950) + }) + + expect(onLoadEarlier).toHaveBeenCalledOnce() + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + }) + + it('does not treat programmatic scroll metrics as user intent', async () => { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_300) + }) + + expect(scrollToOffset).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 }) + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(0) + }) + + it('keeps tail-follow paused across the drag-to-momentum handoff', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => list().props.onScrollBeginDrag?.({})) + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(0) + + act(() => { + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_250) + list().props.onMomentumScrollBegin?.({}) + }) + await act(async () => vi.advanceTimersByTime(200)) + act(() => list().props.onContentSizeChange(320, 1_300)) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + + act(() => { + list().props.onMomentumScrollEnd?.({ + nativeEvent: { + contentOffset: { y: 800 }, + contentSize: { height: 1_300 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_350) + }) + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_350 }) + } finally { + vi.useRealTimers() + } + }) + + it('uses momentum-end metrics instead of a stale throttled scroll sample', async () => { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onMomentumScrollBegin?.({}) + list().props.onMomentumScrollEnd?.({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_300 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_350) + }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(1) + }) + + it('uses finger-release metrics when no momentum event follows', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 620 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + }) + await act(async () => vi.advanceTimersByTime(200)) + act(() => list().props.onContentSizeChange(320, 1_250)) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 }) + } finally { + vi.useRealTimers() + } + }) + + it('repins when content grows between tail release and drag settle', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_250) + }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + + await act(async () => vi.runOnlyPendingTimers()) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('uses measured content height when the first message fills an empty transcript', async () => { + await render() + + await pressSend() + expect(scrollToEnd).not.toHaveBeenCalled() + + await update({ folded: [assistantTurn('a1', 'First response')] }) + act(() => list().props.onContentSizeChange(320, 1_200)) + + expect(scrollToOffset).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_200 }) + }) + + it('keeps a history load detached after its triggering drag settles', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Short history')] + const onLoadEarlier = vi.fn() + await render({ folded, hasMore: true, onLoadEarlier }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 0 }, + contentSize: { height: 400 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 0 }, + contentSize: { height: 400 }, + layoutMeasurement: { height: 500 } + } + }) + }) + await act(async () => vi.advanceTimersByTime(200)) + act(() => list().props.onContentSizeChange(320, 950)) + + expect(onLoadEarlier).toHaveBeenCalledOnce() + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + it('routes an accepted send through the immediate nonanimated tail owner', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'History')] + await render({ folded }) + await act(async () => vi.runOnlyPendingTimers()) + await scrollAwayFromTail() + scrollToEnd.mockClear() + + await pressSend() + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + await act(async () => vi.advanceTimersByTime(60)) + expect(scrollToEnd).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('routes the latest-message chevron through the tail owner and resumes following', async () => { + const folded = [assistantTurn('a1', 'History')] + await render({ folded }) + await scrollAwayFromTail() + scrollToEnd.mockClear() + + const chevron = renderer!.root.find( + (node) => node.props.accessibilityLabel === 'Scroll to latest' + ) + act(() => chevron.props.onPress()) + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + + act(() => list().props.onContentSizeChange(320, 1_300)) + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 }) + }) + + it('repins after a keyboard-driven viewport layout only while following', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + await act(async () => vi.runOnlyPendingTimers()) + scrollToEnd.mockClear() + + await update({ folded, keyboardInset: 320 }) + act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 400 } } })) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + await act(async () => vi.advanceTimersByTime(60)) + expect(scrollToEnd).toHaveBeenCalledOnce() + + await scrollAwayFromTail() + scrollToEnd.mockClear() + await update({ folded, keyboardInset: 0 }) + act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 700 } } })) + expect(scrollToEnd).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('renders an accepted optimistic image send without a queued state', async () => { await render({ pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }] diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 67fc93506a9b..be974b989f31 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { ActivityIndicator, FlatList, @@ -25,6 +25,7 @@ import { type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' +import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -185,22 +186,11 @@ export function MobileNativeChatView({ keyboardInset = 0 }: Props): React.JSX.Element { const insets = useSafeAreaInsets() - const listRef = useRef>(null) const [toolsExpanded, setToolsExpanded] = useState(false) // Lift the composer clear of the keyboard, plus the bottom safe-area so it // never sits under the home indicator / nav bar (mirrors the terminal dock). const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom - const [atBottom, setAtBottom] = useState(true) - const sendScrollTimerRef = useRef | null>(null) const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() - useEffect( - () => () => { - if (sendScrollTimerRef.current) { - clearTimeout(sendScrollTimerRef.current) - } - }, - [] - ) // `data` is the list source: folded transcript + synthetic streaming bubble + // route-owned accepted echoes. Memoize on the same deps so the @@ -216,18 +206,19 @@ export function MobileNativeChatView({ }), [messages, folded, streaming, pending, imagePreviewsByMessageId] ) - - // Follow the tail as the conversation grows and keep the newest message above - // the keyboard when it opens — but only when already pinned to the bottom, so - // we don't yank the user away while they read history. (Also fires on keyboard - // close, which is harmless while atBottom.) - useEffect(() => { - if (data.length === 0 || !atBottom) { - return - } - const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60) - return () => clearTimeout(t) - }, [data.length, atBottom, keyboardInset]) + const { + listRef, + showJumpToTail, + pinToTail, + pinToTailAfterContentResize, + jumpToTail, + beginUserScroll, + endUserDrag, + beginMomentum, + endMomentum, + detachFromTail, + recordScrollMetrics + } = useMobileNativeChatTailFollow({ hasItems: data.length > 0 }) const handleSend = useCallback( async (text: string): Promise => { @@ -239,30 +230,27 @@ export function MobileNativeChatView({ // or a stale "Message not sent" sits above the delivered message. onClearSendError?.() // Always jump to the newest message when the user sends. - setAtBottom(true) - if (sendScrollTimerRef.current) { - clearTimeout(sendScrollTimerRef.current) - } - sendScrollTimerRef.current = setTimeout(() => { - sendScrollTimerRef.current = null - listRef.current?.scrollToEnd({ animated: true }) - }, 60) + jumpToTail() return true }, - [onSend, onClearSendError] + [onSend, onClearSendError, jumpToTail] ) + const loadEarlier = useCallback(() => { + detachFromTail() + onLoadEarlier?.() + }, [detachFromTail, onLoadEarlier]) + const onScroll = useCallback( (e: NativeSyntheticEvent) => { - const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent - const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height) - setAtBottom(distanceFromBottom < 80) + const { contentOffset } = e.nativeEvent + recordScrollMetrics(e.nativeEvent) // Near the top — page in older history. if (contentOffset.y < 60 && hasMore && !loadingEarlier) { - onLoadEarlier?.() + loadEarlier() } }, - [hasMore, loadingEarlier, onLoadEarlier] + [hasMore, loadingEarlier, loadEarlier, recordScrollMetrics] ) // Per-turn status rows: one live indicator while the turn runs, then a settled @@ -318,17 +306,18 @@ export function MobileNativeChatView({ // instead of being swallowed by the dismiss gesture. keyboardShouldPersistTaps="handled" onScroll={onScroll} + onScrollBeginDrag={beginUserScroll} + onScrollEndDrag={endUserDrag} + onMomentumScrollBegin={beginMomentum} + onMomentumScrollEnd={endMomentum} scrollEventThrottle={32} - onContentSizeChange={() => { - if (data.length > 0 && atBottom) { - listRef.current?.scrollToEnd({ animated: false }) - } - }} + onContentSizeChange={pinToTailAfterContentResize} + onLayout={pinToTail} ListHeaderComponent={ hasMore ? ( {loadingEarlier ? ( @@ -360,11 +349,11 @@ export function MobileNativeChatView({ /> {/* Jump-to-latest control. */} - {!atBottom ? ( + {showJumpToTail ? ( listRef.current?.scrollToEnd({ animated: true })} + onPress={jumpToTail} > diff --git a/mobile/src/session/use-mobile-native-chat-tail-follow.ts b/mobile/src/session/use-mobile-native-chat-tail-follow.ts new file mode 100644 index 000000000000..7a3072ad7006 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.ts @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react' +import type { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-native' + +/** Distance from the bottom, in points, still treated as "at the tail". */ +const AT_TAIL_SLOP = 80 + +function isAtTail(event: NativeScrollEvent): boolean { + const { contentOffset, contentSize, layoutMeasurement } = event + return contentSize.height - (contentOffset.y + layoutMeasurement.height) <= AT_TAIL_SLOP +} + +export type MobileNativeChatTailFollow = { + /** Attach to the transcript list; the hook scrolls through this ref alone. */ + listRef: RefObject | null> + /** Render flag for the jump-to-latest control. */ + showJumpToTail: boolean + /** Passive maintenance: re-pin after the viewport resizes. */ + pinToTail: () => void + /** Content-size maintenance using the list's authoritative measured height. */ + pinToTailAfterContentResize: (_width: number, height: number) => void + /** Explicit jump — send, or the jump-to-latest control. Resumes following. */ + jumpToTail: () => void + beginUserScroll: () => void + endUserDrag: (event: NativeSyntheticEvent) => void + beginMomentum: () => void + endMomentum: (event: NativeSyntheticEvent) => void + /** Leave the tail deliberately, e.g. before prepending older history. */ + detachFromTail: () => void + recordScrollMetrics: (event: NativeScrollEvent) => void +} + +/** Sole owner of transcript scroll position. + * + * Streaming used to have several tail-followers at once: a delayed *animated* + * `scrollToEnd` alongside an immediate non-animated one on content growth. The + * animated command eases toward the endpoint measured when it started, so while + * tokens kept arriving it ran backwards until the content-size pin yanked it + * forward — the visible drift-then-snap. One owner, never animated, removes it. + * + * Intent (`following`) is kept separate from geometry (at-tail): a programmatic + * scroll reports metrics like any other, so letting metrics decide intent let + * the view argue with itself. Only the user's own gestures and explicit jumps + * move intent; metrics only decide where a *released* gesture leaves us. + */ +export function useMobileNativeChatTailFollow(args: { + /** Guards `scrollToEnd` against an empty list. */ + hasItems: boolean +}): MobileNativeChatTailFollow { + const { hasItems } = args + const listRef = useRef | null>(null) + const [following, setFollowingFlag] = useState(true) + const [atTail, setAtTailFlag] = useState(true) + // Event handlers read intent at event time, before a re-render lands. + const followingRef = useRef(true) + const atTailRef = useRef(true) + const userScrollActiveRef = useRef(false) + const userScrollSettleFrameRef = useRef(null) + + // Single writer, so the event-time ref and the render flag cannot disagree. + const setFollowing = useCallback((next: boolean) => { + if (followingRef.current === next) { + return + } + followingRef.current = next + setFollowingFlag(next) + }, []) + + const setAtTail = useCallback((next: boolean) => { + if (atTailRef.current === next) { + return + } + atTailRef.current = next + setAtTailFlag(next) + }, []) + + const pinToTail = useCallback(() => { + if (!followingRef.current || !hasItems) { + return + } + listRef.current?.scrollToEnd({ animated: false }) + }, [hasItems]) + + const pinToTailAfterContentResize = useCallback( + (_width: number, height: number) => { + if (!followingRef.current || !hasItems) { + return + } + listRef.current?.scrollToOffset({ animated: false, offset: height }) + }, + [hasItems] + ) + + const clearUserScrollSettle = useCallback(() => { + if (userScrollSettleFrameRef.current !== null) { + cancelAnimationFrame(userScrollSettleFrameRef.current) + userScrollSettleFrameRef.current = null + } + }, []) + + const recordScrollMetrics = useCallback( + (event: NativeScrollEvent) => setAtTail(isAtTail(event)), + [setAtTail] + ) + + const jumpToTail = useCallback(() => { + clearUserScrollSettle() + userScrollActiveRef.current = false + setAtTail(true) + setFollowing(true) + pinToTail() + }, [clearUserScrollSettle, pinToTail, setAtTail, setFollowing]) + + const beginUserScroll = useCallback(() => { + clearUserScrollSettle() + userScrollActiveRef.current = true + setFollowing(false) + }, [clearUserScrollSettle, setFollowing]) + + const finishUserScroll = useCallback( + (finishedAtTail: boolean) => { + if (!userScrollActiveRef.current) { + return + } + clearUserScrollSettle() + userScrollActiveRef.current = false + setAtTail(finishedAtTail) + setFollowing(finishedAtTail) + if (finishedAtTail) { + pinToTail() + } + }, + [clearUserScrollSettle, pinToTail, setAtTail, setFollowing] + ) + + const endUserDrag = useCallback( + (event: NativeSyntheticEvent) => { + if (!userScrollActiveRef.current) { + return + } + clearUserScrollSettle() + const releasedAtTail = isAtTail(event.nativeEvent) + userScrollSettleFrameRef.current = requestAnimationFrame(() => { + userScrollSettleFrameRef.current = null + finishUserScroll(releasedAtTail) + }) + }, + [clearUserScrollSettle, finishUserScroll] + ) + + const beginMomentum = useCallback(() => { + if (userScrollActiveRef.current) { + clearUserScrollSettle() + } + }, [clearUserScrollSettle]) + + const endMomentum = useCallback( + (event: NativeSyntheticEvent) => + finishUserScroll(isAtTail(event.nativeEvent)), + [finishUserScroll] + ) + + const detachFromTail = useCallback(() => { + clearUserScrollSettle() + userScrollActiveRef.current = false + setAtTail(false) + setFollowing(false) + }, [clearUserScrollSettle, setAtTail, setFollowing]) + + useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]) + + return { + listRef, + showJumpToTail: !following && !atTail, + pinToTail, + pinToTailAfterContentResize, + jumpToTail, + beginUserScroll, + endUserDrag, + beginMomentum, + endMomentum, + detachFromTail, + recordScrollMetrics + } +} From 85d7cf3cc1e3737a7c00db2f3847ef8dc41daa83 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:23:13 -0400 Subject: [PATCH 006/209] fix(mobile): preserve delivery ambiguity across transport cutover (#20280) * fix(mobile): preserve delivery ambiguity across transport cutover Let physical close settle requests and retain its error as the cutover cause, copying only an existing delivery-unknown mark. Pin sent and unsent caller outcomes and both cutover predicate carriers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): pin the RpcClient.close() settlement contract close() was declared `() => void` with no stated obligation. That was harmless while migrateTo rejected pendings itself; now that it does not, close() is the retiring generation's only settlement path, so a type-compatible implementation that leaves a request pending strands its caller for good. States the obligation on the declaration and pins it for both trackers the real implementations reject through. Dropping the delivery-unknown flag, dropping the relay mark, or leaving pendings in the map each fail a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cutover cause without a type assertion main's new casting gate rejects `(error as Error).cause`; narrow instead so the assertion still distinguishes a missing cause from an unmarked one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../rpc-client-delivery-ambiguity.test.ts | 53 ++++++++++++++++++- mobile/src/transport/rpc-client.ts | 13 +++++ .../stable-logical-rpc-client.test.ts | 1 + .../transport/stable-logical-rpc-client.ts | 29 +++++----- .../worktree/home-host-worktree-fetch.test.ts | 6 ++- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts index 5cdaf75b945f..29456c2eaa18 100644 --- a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { + createStableLogicalRpcClient, + isLogicalClientCutoverError, + LogicalClientCutoverError +} from './stable-logical-rpc-client' vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ @@ -72,7 +77,7 @@ function hasSentRequest(socket: MockWebSocket, method: string): boolean { function connectAuthenticated(): { client: ReturnType; socket: MockWebSocket } { const client = connect('ws://desktop.invalid', 'token', 'server-key') - const socket = mockSockets[0]! + const socket = mockSockets[mockSockets.length - 1]! socket.open() socket.receive(JSON.stringify({ type: 'e2ee_ready' })) socket.receive('encrypted:{"type":"e2ee_authenticated"}') @@ -94,6 +99,52 @@ describe('mobile rpc-client delivery ambiguity marking', () => { globalThis.WebSocket = originalWebSocket }) + it.each([true, false])( + 'preserves physical delivery evidence at the cutover caller (sent=%s)', + async (sent) => { + const physical = sent + ? connectAuthenticated() + : { + client: connect('ws://desktop.invalid', 'token', 'server-key'), + socket: mockSockets[0]! + } + const client = createStableLogicalRpcClient(physical.client, 'lan') + const replacement = connectAuthenticated() + const requestError = client + .sendRequest('worktree.create', { name: 'new' }) + .catch((error: unknown) => error) + await Promise.resolve() + expect(hasSentRequest(physical.socket, 'worktree.create')).toBe(sent) + + await client.migrateTo(replacement.client, 'relay') + + const error = await requestError + expect(isLogicalClientCutoverError(error)).toBe(true) + expect(isRpcDeliveryUnknown(error)).toBe(sent) + expect(error).toBeInstanceOf(LogicalClientCutoverError) + expect(isRpcDeliveryUnknown(error instanceof Error ? error.cause : null)).toBe(sent) + expect(hasSentRequest(replacement.socket, 'worktree.create')).toBe(false) + expect( + physical.socket.sent.filter((payload) => payload.includes('worktree.create')) + ).toHaveLength(sent ? 1 : 0) + client.close() + } + ) + + it('recognizes a cutover by class even when its message changes', () => { + const error = new LogicalClientCutoverError() + error.message = 'wrapped migration' + expect(isLogicalClientCutoverError(error)).toBe(true) + }) + + it('recognizes a cutover message from another bundle copy', () => { + expect(isLogicalClientCutoverError(new Error('RPC interrupted by connection migration'))).toBe( + true + ) + expect(isLogicalClientCutoverError(new Error('Client closed'))).toBe(false) + expect(isLogicalClientCutoverError('RPC interrupted by connection migration')).toBe(false) + }) + it('marks in-flight requests as delivery-unknown when the socket drops', async () => { const { client, socket } = connectAuthenticated() const requestError = client.sendRequest('terminal.send', { terminal: 't' }).then( diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 38941483c4df..05fdbbac8faa 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -32,6 +32,19 @@ export type RpcClient = UnvalidatedRpcRequestPort & { getLastInboundAt?: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void notifyForeground: (reason?: ForegroundNudgeReason) => void + /** + * Must settle every pending `sendRequest` promise before returning. + * + * `StableLogicalRpcClient.migrateTo` no longer rejects pendings itself — the physical + * sender is the only layer that knows whether a request reached the wire, so + * `previous.close()` is the sole settlement path for the retiring generation. An + * implementation that leaves a request pending strands its caller for good. + * + * Requests that did reach the wire must reject with a delivery-unknown error + * (`markRpcDeliveryUnknown`), since the host may already have executed them. Pinned + * against the real clients in `rpc-client-delivery-ambiguity.test.ts` (direct) and + * `mobile-relay-rpc-session.test.ts` (relay) — a new implementation needs its own case. + */ close: () => void } diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index cbbf01ada2a0..6a22b3654a2d 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -90,6 +90,7 @@ describe('stable logical RPC client', () => { const nextSession = new FakeSession('connecting') const pending = deferred() oldSession.sendRequest.mockReturnValue(pending.promise) + oldSession.close.mockImplementation(() => pending.reject(new Error('Client closed'))) nextSession.sendRequest.mockResolvedValue(success('next')) const client = createStableLogicalRpcClient(oldSession, 'lan') const stream = vi.fn() diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 3a896af6f5bd..769481aa80c7 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -7,12 +7,16 @@ import { import { waitForAuthenticated } from './replacement-session-authentication' import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection' import { LogicalClientConnectionPath } from './logical-client-connection-path' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' export class LogicalClientCutoverError extends Error { - constructor() { - super('RPC interrupted by connection migration') + constructor(cause?: unknown) { + super('RPC interrupted by connection migration', { cause }) + if (isRpcDeliveryUnknown(cause)) { + markRpcDeliveryUnknown(this) + } } } @@ -33,10 +37,6 @@ type SubscriptionRecord = { cancelled: boolean } -type PendingRequest = { - reject: (error: Error) => void -} - export type StableLogicalRpcClient = RpcClient & { migrateTo( session: RpcClient, @@ -75,7 +75,6 @@ export function createStableLogicalRpcClient( let nextSubscriptionId = 0 let activeStateUnsubscribe: (() => void) | null = null const subscriptions = new Map() - const pendingRequests = new Set() const stateListeners = new Set<(state: ConnectionState) => void>() let state = initialSession.getState() const connectionPath = new LogicalClientConnectionPath(() => state === 'connected') @@ -90,22 +89,23 @@ export function createStableLogicalRpcClient( if (suspended) { return Promise.reject(new Error('Client suspended')) } + const requestGeneration = generation const session = activeSession return new Promise((resolve, reject) => { - const pending = { reject } - pendingRequests.add(pending) void session .sendRequest(method, projectMobileRpcRequestParams(method, params), options) .then( (response) => { - pendingRequests.delete(pending) // A correlated response is definitive even if close/cutover won the // callback race after the physical promise had already settled. resolve(response) }, (error: unknown) => { - pendingRequests.delete(pending) - reject(error) + // Why: the retiring physical session settles this, so keep its error as the + // cause — it is the only evidence of whether the frame reached the wire. + reject( + requestGeneration !== generation ? new LogicalClientCutoverError(error) : error + ) } ) }) @@ -260,15 +260,12 @@ export function createStableLogicalRpcClient( suspended = false previousStateUnsubscribe?.() bindActiveState(nextSession, nextGeneration) - for (const pending of pendingRequests) { - pending.reject(new LogicalClientCutoverError()) - } - pendingRequests.clear() state = nextSession.getState() connectionPath.clearAfterConnected() for (const listener of stateListeners) { listener(state) } + // Only the physical sender knows whether a pending request reached the wire. previous.close() }, diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts index 5799de96366b..6238554769ab 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.test.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -39,7 +39,11 @@ function fakeSession(): FakeSession { getLastConnectedAt: () => null, onStateChange: () => () => {}, notifyForeground: () => {}, - close: () => {} + close: () => { + for (const settle of pending.splice(0)) { + settle(new Error('Client closed')) + } + } } } return fake From 22f56f7c2a5aa11812fc0bfec4af02a475205531 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:23:16 -0400 Subject: [PATCH 007/209] fix(runtime): reject malformed file Base64 padding (#20283) Require padded file-write payloads to end on a Base64 quartet boundary. Cover both RPC methods and padded final upload chunks, and document client compatibility evidence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- docs/reference/runtime-file-base64-padding.md | 92 +++++++++++++++++++ .../rpc/methods/files-base64-padding.test.ts | 58 ++++++++++++ ...untime-file-client-external-import.test.ts | 2 +- .../rpc-contract/files-mutation-params.ts | 5 +- 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 docs/reference/runtime-file-base64-padding.md create mode 100644 src/main/runtime/rpc/methods/files-base64-padding.test.ts diff --git a/docs/reference/runtime-file-base64-padding.md b/docs/reference/runtime-file-base64-padding.md new file mode 100644 index 000000000000..3715855eaa02 --- /dev/null +++ b/docs/reference/runtime-file-base64-padding.md @@ -0,0 +1,92 @@ +# Runtime file Base64 padding + +Padded runtime file writes must have a length divisible by four. Empty strings and +unpadded Base64 with length modulo four equal to zero, two, or three remain valid. +The change rejects exactly the previously accepted strings containing trailing +padding whose total length modulo four is two or three. It does not enforce +canonical unused pad bits or change the alphabet. + +## Boundary evidence + +| Input | Before | After | +| ------------------------- | ------ | ------ | +| `A=` | Accept | Reject | +| `AA==` | Accept | Accept | +| `AAA=` | Accept | Accept | +| `AAAA` | Accept | Accept | +| `''` | Accept | Accept | +| `A` | Reject | Reject | +| `==` | Accept | Reject | +| `AA=A` (interior padding) | Reject | Reject | +| `AA=` | Accept | Reject | +| `A==` | Accept | Reject | +| `AAAA==` | Accept | Reject | +| `AA`, `AAA` (unpadded) | Accept | Accept | + +`Buffer.from('A=', 'base64')` decodes to zero bytes. Rejecting malformed padding at +the RPC boundary prevents an accepted request from silently writing different bytes. + +## Caller census + +| Caller / surface | Reachability and compatibility verdict | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Desktop `runtime-file-import-client.ts` → `uploadRuntimeFileWithoutClobber` → `writeRuntimeBase64File` | The only production producer of `files.writeBase64` and `files.writeBase64Chunk`. Staging in `filesystem-runtime-upload-staging.ts` encodes the complete file with `buffer.toString('base64')`; newly rejected values cannot be produced. | +| Desktop single-frame uploads | Sends the staged string unchanged when its length is at most 512 × 1024 characters. Standard Node Base64 always has length divisible by four. Empty files remain accepted. | +| Desktop chunked uploads | Slices the encoded stream at 512 × 1024 = 524,288 characters, divisible by four. Every offset and every complete chunk is quartet-aligned. The final chunk is the difference of two multiples of four, including when it ends in `=` or `==`. No separately assembled final chunk or per-chunk padding is added. | +| Web implementation of `stageExternalPathsForRuntimeUpload` | Returns an empty source list; no file-write payload is produced. | +| Mobile file editor | Uses `files.writeTerminalArtifact` with text content and its separate schema. Does not reach this predicate. | +| Mobile clipboard / image attachments | Uses `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and the `clipboard.saveImageAsTempFile` fallback. Their validator is `isValidBase64` in `clipboard-params.ts`, not `isValidRuntimeFileBase64`. Unchanged, including the mobile normalizer's existing permissive padding behavior. | +| CLI | No producer of either Base64 file-write method. File commands in `src/cli/handlers/file.ts` call `files.open` / `files.openDiff`; other CLI RPC call sites do not construct Base64 file writes. | +| Generated params catalog | References both schemas; `RpcParams` consumers use inferred types. Mobile's entry point is `export type` only, so no new client-side parsing occurs. | +| RPC dispatch | `files-mutation-methods.ts` registers both schemas. The chunk schema extends the whole-file schema; these are the only runtime consumers of the predicate. Direct runtime/provider calls do not parse these schemas. | + +Repository-wide searches covered method names, schema names, the predicate and its +pattern, and all callers of the upload/staging functions. Targeted history search +on `HEAD` under `mobile/src` found no introduction/removal of the affected methods +or predicate. The local release refs `mobile-ios-v0.0.27` and `mobile-v0.0.13` also +contain no callers of either Base64 file-write method; the iOS ref uses the separate +clipboard and terminal-artifact methods above. No shipped mobile producer of a +newly rejected value was found in this source/history audit. + +## Remote and workspace compatibility + +Old desktop clients using the audited producer send valid quartets to a new host. +A new client still sends the same bytes to an old host. No method, field, opcode, +or host-published content changes. This follows the mixed-version requirements in +[remote-wire-compatibility.md](./remote-wire-compatibility.md). + +The RPC validation runs before workspace resolution and provider selection, so the +same rule applies to folder workspaces, git worktrees, local hosts, and SSH hosts. +SSH ownership fences and provider writes are unchanged. Arbitrary external RPC +callers sending malformed padding will now receive a validation error; valid +padded and unpadded payloads remain accepted. + +## Regression evidence + +`src/main/runtime/rpc/methods/files-base64-padding.test.ts` exercises both actual RPC +registrations, asserts rejected input never reaches the writer, and verifies +accepted content is forwarded unchanged. With the original predicate, the test +run produced **16 failed / 16 passed**; all 16 failures were newly rejected padding +shapes accepted by the old implementation. This was run before editing the predicate. + +The existing desktop external-import test now uses a final `AA==` chunk after a +524,288-character first chunk, pinning padded final-chunk forwarding in the real +upload path. No producer changes or clipboard validation changes were necessary. + +## Validation results + +All test/typecheck commands used `ORCA_BACKGROUND_LAUNCH=1`. + +- `pnpm tc`: exit 0; all root typecheck projects passed. +- `pnpm exec vitest run src/main/runtime/rpc`: 277 files passed, one failed; + 2,419 tests passed, two timed out, one skipped. Both timeouts were in the unchanged + `terminal-output-frame-chunks-equivalence.test.ts` (5s surrogate-range test and + 30s 800-payload fuzz test). +- `pnpm --dir mobile typecheck`: exit 0 (`tsc --noEmit`). +- `pnpm run check:code-quality:changed`: exit 0; code quality, type-aware code + quality, and React Doctor each reported zero new findings across three source files. +- Focused run with `--config config/vitest.config.ts --maxWorkers=2`: all three + files / 58 tests passed, covering padding, desktop external imports, and the + terminal-output equivalence file that timed out in the initial run. +- Full RPC rerun with `--maxWorkers=2`: exit 0; all 278 files passed, + 2,421 tests passed and one skipped (198.34s). No timeout overrides were needed. diff --git a/src/main/runtime/rpc/methods/files-base64-padding.test.ts b/src/main/runtime/rpc/methods/files-base64-padding.test.ts new file mode 100644 index 000000000000..03f3b73e7869 --- /dev/null +++ b/src/main/runtime/rpc/methods/files-base64-padding.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { FILE_MUTATION_METHODS } from './files-mutation-methods' + +describe.each([ + ['files.writeBase64', 'writeFileExplorerFileBase64'], + ['files.writeBase64Chunk', 'writeFileExplorerFileBase64Chunk'] +] as const)('%s base64 padding', (method, runtimeMethod) => { + it.each([ + ['A=', false], + ['AA=', false], + ['A==', false], + ['==', false], + ['AAAAA=', false], + ['AAAAAA=', false], + ['AAAAA==', false], + ['AAAA==', false], + ['AA==', true], + ['AAA=', true], + ['AAAA', true], + ['', true], + ['A', false], + ['AA=A', false], + ['AA', true], + ['AAA', true] + ])('validates %j before writing (accepted: %s)', async (contentBase64, accepted) => { + const write = vi.fn().mockResolvedValue({ ok: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + [runtimeMethod]: write + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_MUTATION_METHODS }) + + const response = await dispatcher.dispatch({ + id: 'padding', + authToken: 'tok', + method, + params: { + worktree: 'id:wt-1', + relativePath: 'upload.bin', + contentBase64, + append: true + } + }) + + expect(response).toMatchObject({ ok: accepted }) + expect(write).toHaveBeenCalledTimes(accepted ? 1 : 0) + if (accepted) { + expect(write).toHaveBeenCalledWith( + 'id:wt-1', + 'upload.bin', + contentBase64, + ...(method === 'files.writeBase64Chunk' ? [true] : []) + ) + } + }) +}) diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index ff8bd580d1f5..53e6b45e1da2 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -182,7 +182,7 @@ describe('runtime file client', () => { it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + const secondChunk = 'AA==' fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { diff --git a/src/shared/rpc-contract/files-mutation-params.ts b/src/shared/rpc-contract/files-mutation-params.ts index 554d1f387e0c..a2fbc120b20f 100644 --- a/src/shared/rpc-contract/files-mutation-params.ts +++ b/src/shared/rpc-contract/files-mutation-params.ts @@ -5,7 +5,10 @@ export const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ export function isValidRuntimeFileBase64(value: unknown): value is string { return ( - typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) + typeof value === 'string' && + value.length % 4 !== 1 && + (!value.includes('=') || value.length % 4 === 0) && + RUNTIME_FILE_BASE64_PATTERN.test(value) ) } From 149164b74f760ba86d8a7576a619951d7a35f1f0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 13 Sep 2026 15:43:12 -0700 Subject: [PATCH 008/209] fix(tasks): preserve repository results under GitHub search quota (#20460) * fix(tasks): preserve repository results under GitHub search quota * fix(github): preserve search budget on count fallback --------- Co-authored-by: m4air --- .../__fixtures__/work-item-search-api.ts | 214 +++++++++++++++ .../work-item-search-metadata.json | 248 ++++++++++++++++++ src/main/github/client-issue-source.test.ts | 3 + .../client-work-items-query-paging.test.ts | 3 + src/main/github/client-work-items.test.ts | 3 + .../github/client/fetch/work-item-fetch.ts | 6 +- .../github/client/list/count-work-items.ts | 24 +- .../github/client/list/list-work-items.ts | 3 +- .../client/list/work-item-issue-page.ts | 138 ++++++++++ .../github/client/list/work-item-pages.ts | 70 ++--- .../client/list/work-item-search-batch.ts | 194 ++++++++++++++ .../client/list/work-item-search-page.ts | 128 +++++++++ src/main/github/github-api-repository.ts | 5 +- ...k-item-search-fallback-environment.test.ts | 86 ++++++ .../github/work-item-search-freshness.test.ts | 32 +++ .../github/work-item-search-isolation.test.ts | 140 ++++++++++ .../work-item-search-pagination.test.ts | 71 +++++ .../github/work-item-search-semantics.test.ts | 114 ++++++++ .../github/work-item-search-test-harness.ts | 78 ++++++ ...github-work-item-search-burst.unit.test.ts | 87 ++++++ 20 files changed, 1598 insertions(+), 49 deletions(-) create mode 100644 src/main/github/__fixtures__/work-item-search-api.ts create mode 100644 src/main/github/__fixtures__/work-item-search-metadata.json create mode 100644 src/main/github/client/list/work-item-issue-page.ts create mode 100644 src/main/github/client/list/work-item-search-batch.ts create mode 100644 src/main/github/client/list/work-item-search-page.ts create mode 100644 src/main/github/work-item-search-fallback-environment.test.ts create mode 100644 src/main/github/work-item-search-freshness.test.ts create mode 100644 src/main/github/work-item-search-isolation.test.ts create mode 100644 src/main/github/work-item-search-pagination.test.ts create mode 100644 src/main/github/work-item-search-semantics.test.ts create mode 100644 src/main/github/work-item-search-test-harness.ts create mode 100644 tests/e2e/github-work-item-search-burst.unit.test.ts diff --git a/src/main/github/__fixtures__/work-item-search-api.ts b/src/main/github/__fixtures__/work-item-search-api.ts new file mode 100644 index 000000000000..e48a5818cb91 --- /dev/null +++ b/src/main/github/__fixtures__/work-item-search-api.ts @@ -0,0 +1,214 @@ +import { z } from 'zod' +type Captured = { args: string[]; cwd?: string; fixtureCredential?: string } +type Issue = Record +export class WorkItemSearchApi { + calls: Captured[] = [] + restSearches = 0 + graphqlCalls = 0 + graphqlFields = 0 + restDetails = 0 + rejected = 0 + graphqlAvailable = true + searchAvailable = true + rowsPerRepo = 120 + specialNodes: Issue[] | undefined + aliasErrorRepo: string | undefined + expectedSearch: string | undefined + reportedCount: number | undefined + private nextCursor = 0 + private cursors = new Map() + private cache = new Map() + + private rows(query: string): Issue[] { + if (this.expectedSearch && query.replace(/ sort:created-desc$/, '') !== this.expectedSearch) { + throw new Error(`Unexpected fixture search ${query}`) + } + const repo = /repo:([^\s]+)/.exec(query)?.[1] ?? 'unknown/repo' + return ( + this.specialNodes ?? + Array.from({ length: this.rowsPerRepo }, (_, index) => ({ + __typename: 'Issue', + number: 10000 - index, + title: `${repo} issue ${index}`, + state: 'OPEN', + url: `https://github.com/${repo}/issues/${10000 - index}`, + updatedAt: '2026-09-11T00:00:00Z', + author: { + __typename: 'User', + login: 'author', + avatarUrl: 'https://avatars.githubusercontent.com/u/42?u=profile&v=4' + }, + labels: { nodes: [{ name: 'bug' }], pageInfo: { hasNextPage: false } }, + assignees: { nodes: [], pageInfo: { hasNextPage: false } } + })) + ) + } + + async capture( + _binary: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } + ): Promise<{ stdout: string; stderr: string }> { + const credential = options.env?.GH_TOKEN + this.calls.push({ + args: [...args], + cwd: options.cwd, + fixtureCredential: credential?.startsWith('fixture-') ? credential : undefined + }) + if (args.includes('rate_limit')) { + const bucket = { limit: 5000, remaining: 4500, reset: 3600 } + return { + stdout: JSON.stringify({ + resources: { + core: bucket, + graphql: { ...bucket, remaining: this.graphqlAvailable ? 4500 : 0 }, + search: { + limit: 30, + remaining: this.searchAvailable ? Math.max(0, 30 - this.restSearches) : 0, + reset: 60 + } + } + }), + stderr: '' + } + } + if (args[0] === 'pr') { + return { stdout: '[]', stderr: '' } + } + const endpoint = args.find((arg) => arg.startsWith('search/issues?')) + if (endpoint) { + const cached = args.includes('--cache') + ? this.cache.get(JSON.stringify([options.cwd, args])) + : undefined + if (cached !== undefined) { + return { stdout: cached, stderr: '' } + } + this.restSearches++ + if (!this.searchAvailable || this.restSearches > 30) { + this.rejected++ + throw Object.assign(new Error('HTTP 403: API rate limit exceeded'), { + stderr: 'HTTP 403: API rate limit exceeded' + }) + } + const url = new URL(endpoint, 'https://api.github.com') + const query = url.searchParams.get('q') ?? '' + const rows = this.rows(query) + const limit = Number(url.searchParams.get('per_page') ?? 1) + const page = Number(url.searchParams.get('page') ?? 1) + if (page * limit > 1000) { + throw Object.assign( + new Error('Only the first 1000 search results are available (HTTP 422)'), + { stderr: 'Only the first 1000 search results are available (HTTP 422)' } + ) + } + const stdout = args.includes('.total_count') + ? String(this.reportedCount ?? rows.length) + : JSON.stringify(rows.slice((page - 1) * limit, page * limit).map(this.restIssue)) + if (args.includes('--cache')) { + this.cache.set(JSON.stringify([options.cwd, args]), stdout) + } + return { stdout, stderr: '' } + } + const detail = args.find((arg) => /^repos\/.+\/issues\/\d+$/.test(arg)) + if (detail) { + this.restDetails++ + const row = this.rows('repo:fixture/repo').find( + (row) => row.number === Number(detail.split('/').at(-1)) + ) + return { + stdout: JSON.stringify({ + ...this.restIssue(row!), + labels: Array.from({ length: 125 }, (_, index) => ({ name: `label-${index}` })) + }), + stderr: '' + } + } + if (!args.includes('graphql')) { + throw new Error(`Unexpected fixture request ${args.join(' ')}`) + } + this.graphqlCalls++ + if (!this.graphqlAvailable) { + throw Object.assign(new Error('HTTP 403: API rate limit exceeded'), { + stderr: 'HTTP 403: API rate limit exceeded' + }) + } + const query = args.find((arg) => arg.startsWith('query='))?.slice(6) ?? '' + const fields = [ + ...query.matchAll( + /(r\d+): search\(type: ISSUE, query: ("(?:[^"\\]|\\.)*"), first: (\d+)(?:, after: ("(?:[^"\\]|\\.)*"))?\)/g + ) + ] + if (!fields.length) { + throw new Error(`Unexpected GraphQL fixture query ${query}`) + } + const data: Record = { rateLimit: { cost: 1 } } + const errors: unknown[] = [] + for (let index = 0; index < fields.length; index++) { + const field = fields[index] + this.graphqlFields++ + const search = z.string().parse(JSON.parse(field[2])) + if (this.aliasErrorRepo && search.includes(`repo:${this.aliasErrorRepo} `)) { + data[field[1]] = null + errors.push({ message: 'fixture repository search unavailable', path: [field[1]] }) + continue + } + const first = Number(field[3]) + const cursor = field[4] ? z.string().parse(JSON.parse(field[4])) : undefined + const saved = cursor ? this.cursors.get(cursor) : undefined + if (cursor && (!saved || saved.query !== search)) { + throw new Error('Unknown or cross-query opaque cursor') + } + const offset = saved?.offset ?? 0 + const rows = this.rows(search) + const page = rows.slice(offset, offset + first) + const next = offset + page.length + const endCursor = `opaque:${++this.nextCursor}:cursor` + this.cursors.set(endCursor, { query: search, offset: next }) + const selection = query.slice(field.index, fields[index + 1]?.index ?? query.length) + data[field[1]] = { + issueCount: this.reportedCount ?? rows.length, + pageInfo: { hasNextPage: next < rows.length, endCursor }, + ...(selection.includes(' nodes {') ? { nodes: page } : {}) + } + } + const stdout = JSON.stringify({ data, ...(errors.length ? { errors } : {}) }) + if (errors.length) { + throw Object.assign(new Error('GraphQL partial failure'), { + stdout, + stderr: 'GraphQL partial failure' + }) + } + return { stdout, stderr: '' } + } + + private restIssue(row: Issue): Issue { + const actor = (value: unknown) => { + const user = z + .object({ + __typename: z.string().optional(), + login: z.string(), + avatarUrl: z.string().optional() + }) + .nullable() + .parse(value) + return user + ? { + login: user.login + (user.__typename === 'Bot' ? '[bot]' : ''), + avatar_url: user.avatarUrl?.replace(/\?u=[^&]+&/, '?') + } + : null + } + return { + ...row, + state: String(row.state).toLowerCase(), + html_url: row.url, + updated_at: row.updatedAt, + user: actor(row.author), + labels: z.object({ nodes: z.array(z.unknown()) }).parse(row.labels).nodes, + assignees: z + .object({ nodes: z.array(z.unknown()) }) + .parse(row.assignees) + .nodes.map(actor) + } + } +} diff --git a/src/main/github/__fixtures__/work-item-search-metadata.json b/src/main/github/__fixtures__/work-item-search-metadata.json new file mode 100644 index 000000000000..d8fa785f853b --- /dev/null +++ b/src/main/github/__fixtures__/work-item-search-metadata.json @@ -0,0 +1,248 @@ +[ + { + "graphql": { + "number": 19933, + "title": "[Bug]: Browser element annotations cleared and overlay removed when scrolling the page (no reload)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19933", + "updatedAt": "2026-09-12T01:57:20Z", + "labels": { + "nodes": [ + { + "name": "bug" + }, + { + "name": "os:linux" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "peeraponw", + "avatarUrl": "https://avatars.githubusercontent.com/u/13129669?u=bf337820b1f6d507dfac4e48db7cf61e6c2c8b95&v=4" + }, + "assignees": { + "nodes": [ + { + "login": "AmethystLiang", + "avatarUrl": "https://avatars.githubusercontent.com/u/6427696?u=86a210ddf931a6a557a4664cc17a97dc02c9de36&v=4" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19933, + "title": "[Bug]: Browser element annotations cleared and overlay removed when scrolling the page (no reload)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19933", + "updated_at": "2026-09-12T01:57:20Z", + "labels": [ + { + "name": "bug" + }, + { + "name": "os:linux" + } + ], + "user": { + "login": "peeraponw", + "avatar_url": "https://avatars.githubusercontent.com/u/13129669?v=4" + }, + "assignees": [ + { + "login": "AmethystLiang", + "avatar_url": "https://avatars.githubusercontent.com/u/6427696?v=4" + } + ] + } + }, + { + "graphql": { + "number": 19932, + "title": "[Bug] Incorrect status after /usage", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19932", + "updatedAt": "2026-09-10T21:44:41Z", + "labels": { + "nodes": [ + { + "name": "bug" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "Bot", + "login": "orca-discord-issues", + "avatarUrl": "https://avatars.githubusercontent.com/u/127256420?v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19932, + "title": "[Bug] Incorrect status after /usage", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19932", + "updated_at": "2026-09-10T21:44:41Z", + "labels": [ + { + "name": "bug" + } + ], + "user": { + "login": "orca-discord-issues[bot]", + "avatar_url": "https://avatars.githubusercontent.com/u/127256420?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19926, + "title": "[Bug]: Copying assistant response adds visual-wrap line breaks when pasted into Google Chat (macOS)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19926", + "updatedAt": "2026-09-11T17:14:42Z", + "labels": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "JanPlessow", + "avatarUrl": "https://avatars.githubusercontent.com/u/202702070?u=6cdc81f81e27630db038e80eea4924c9ded7ddc2&v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19926, + "title": "[Bug]: Copying assistant response adds visual-wrap line breaks when pasted into Google Chat (macOS)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19926", + "updated_at": "2026-09-11T17:14:42Z", + "labels": [], + "user": { + "login": "JanPlessow", + "avatar_url": "https://avatars.githubusercontent.com/u/202702070?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19919, + "title": "[Bug]: v1.4.199 Windows NSIS installer reports success but never installs Orca.exe (partial install)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19919", + "updatedAt": "2026-09-10T20:08:25Z", + "labels": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "Subdij", + "avatarUrl": "https://avatars.githubusercontent.com/u/105368200?u=d379763ab273d375d0cfff0215daf325bd59f4d6&v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19919, + "title": "[Bug]: v1.4.199 Windows NSIS installer reports success but never installs Orca.exe (partial install)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19919", + "updated_at": "2026-09-10T20:08:25Z", + "labels": [], + "user": { + "login": "Subdij", + "avatar_url": "https://avatars.githubusercontent.com/u/105368200?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19918, + "title": "[Bug]: Svelte files do not get parsed or highlighted correctly", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19918", + "updatedAt": "2026-09-10T20:06:53Z", + "labels": { + "nodes": [ + { + "name": "bug" + }, + { + "name": "os:macos" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "futuraprime", + "avatarUrl": "https://avatars.githubusercontent.com/u/181752?v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19918, + "title": "[Bug]: Svelte files do not get parsed or highlighted correctly", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19918", + "updated_at": "2026-09-10T20:06:53Z", + "labels": [ + { + "name": "bug" + }, + { + "name": "os:macos" + } + ], + "user": { + "login": "futuraprime", + "avatar_url": "https://avatars.githubusercontent.com/u/181752?v=4" + }, + "assignees": [] + } + } +] diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 17820ddd0f10..d9c593be3a0a 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index 0e8b62bdfcf4..6ff7ace0cc96 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index f26ccfc12d95..642b5c2aa4e9 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client/fetch/work-item-fetch.ts b/src/main/github/client/fetch/work-item-fetch.ts index aa4dd0794a81..f0c227d05e7f 100644 --- a/src/main/github/client/fetch/work-item-fetch.ts +++ b/src/main/github/client/fetch/work-item-fetch.ts @@ -22,11 +22,13 @@ export async function fetchIssueWorkItem( ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + environment?: NodeJS.ProcessEnv ): Promise { const ghOptions = { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) + ...githubHostExecOptions(ownerRepo), + ...(environment ? { env: environment } : {}) } if (ownerRepo) { const { stdout } = await ghExecFileAsync( diff --git a/src/main/github/client/list/count-work-items.ts b/src/main/github/client/list/count-work-items.ts index e742597685ee..570220586f9b 100644 --- a/src/main/github/client/list/count-work-items.ts +++ b/src/main/github/client/list/count-work-items.ts @@ -12,7 +12,8 @@ import { } from '../../gh-utils' import { githubHostExecOptions, - resolveIssueGitHubApiRepositorySource + resolveIssueGitHubApiRepositorySource, + type GitHubRepoExecOptions } from '../../github-api-repository' import { getRateLimit, @@ -23,6 +24,7 @@ import { import { sameOwnerRepo } from './../github-exec-scope' import { resolvePrWorkItemSource } from './work-item-list-request' import { buildSearchQueryString, defaultOpenWorkItemQuery } from './work-item-search-query' +import { searchWorkItemCount, usesGraphqlWorkItemSearch } from './work-item-search-page' export async function countWorkItemsForQuery( repoPath: string, ownerRepo: OwnerRepo, @@ -31,10 +33,23 @@ export async function countWorkItemsForQuery( localGitOptions: LocalGitExecOptions = {} ): Promise { const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = { + const ghOptions: GitHubRepoExecOptions = { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), ...githubHostExecOptions(ownerRepo) } + if (usesGraphqlWorkItemSearch(ownerRepo, ghOptions)) { + ghOptions.env = { ...process.env } + try { + return await searchWorkItemCount(searchQ, ghOptions) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + } + } + if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { + return 0 + } const { stdout } = await ghExecFileAsync( [ 'api', @@ -85,7 +100,10 @@ export async function countWorkItems( if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { await getRateLimit() } - if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { + if ( + !usesGraphqlWorkItemSearch(ownerRepo, ghOptions) && + repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked + ) { return 0 } diff --git a/src/main/github/client/list/list-work-items.ts b/src/main/github/client/list/list-work-items.ts index e28037a02334..10192262f6a7 100644 --- a/src/main/github/client/list/list-work-items.ts +++ b/src/main/github/client/list/list-work-items.ts @@ -58,7 +58,8 @@ export async function listWorkItems( limit, requestedPage, connectionId, - localGitOptions + localGitOptions, + noCache ) const errors = diff --git a/src/main/github/client/list/work-item-issue-page.ts b/src/main/github/client/list/work-item-issue-page.ts new file mode 100644 index 000000000000..ac1273887d20 --- /dev/null +++ b/src/main/github/client/list/work-item-issue-page.ts @@ -0,0 +1,138 @@ +import { z } from 'zod' +import type { ParsedTaskQuery } from '../../../../shared/task-query' +import { ghExecFileAsync, type LocalGitExecOptions, type OwnerRepo } from '../../gh-utils' +import { noteRepositoryRateLimitSpend } from '../../rate-limit' +import type { GitHubRepoExecOptions } from '../../github-api-repository' +import { fetchIssueWorkItem } from '../fetch/work-item-fetch' +import { mapIssueWorkItem } from '../map/work-item' +import type { MainWorkItem } from '../map/work-item-field-coercion' +import { buildWorkItemListRequest } from './work-item-list-request' +import { buildSearchQueryString } from './work-item-search-query' +import { searchWorkItemPage, usesGraphqlWorkItemSearch } from './work-item-search-page' + +type Actor = { __typename?: string; login: string; avatarUrl?: string } +type IssueNode = { + __typename: string + number: number + title: string + state: string + url: string + updatedAt: string + author: Actor | null + labels: { nodes: { name: string }[]; pageInfo: { hasNextPage: boolean } } + assignees: { nodes: Actor[]; pageInfo: { hasNextPage: boolean } } +} +const ISSUE_NODE_SELECTION = `__typename ... on Issue { + number title state url updatedAt + author { __typename login avatarUrl } + labels(first: 100) { nodes { name } pageInfo { hasNextPage } } + assignees(first: 100) { nodes { __typename login avatarUrl } pageInfo { hasNextPage } } +}` + +function restActor(actor: Actor | null): Record | null { + if (!actor) { + return null + } + const login = + actor.__typename === 'Bot' && !actor.login.endsWith('[bot]') + ? `${actor.login}[bot]` + : actor.login + let avatar = actor.avatarUrl + if (avatar) { + const url = new URL(avatar) + if (url.hostname === 'avatars.githubusercontent.com') { + url.searchParams.delete('u') + avatar = url.toString() + } + } + return { login, avatar_url: avatar } +} + +export async function listIssueWorkItemPage(args: { + repoPath: string + ownerRepo: OwnerRepo + query: ParsedTaskQuery + limit: number + page: number + options: GitHubRepoExecOptions + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + noCache?: boolean +}): Promise { + const preferGraphql = usesGraphqlWorkItemSearch(args.ownerRepo, args.options) + const options = preferGraphql + ? { ...args.options, env: { ...(args.options.env ?? process.env) } } + : args.options + if (preferGraphql) { + try { + const nodes = await searchWorkItemPage({ + search: buildSearchQueryString(args.ownerRepo, { ...args.query, scope: 'issue' }), + nodeSelection: ISSUE_NODE_SELECTION, + limit: args.limit, + page: args.page, + options, + noCache: args.noCache + }) + const items: MainWorkItem[] = [] + for (const node of nodes) { + if (!node || node.__typename !== 'Issue') { + throw new Error('GitHub issue search response missing issue') + } + if ( + !Number.isSafeInteger(node.number) || + node.number <= 0 || + typeof node.title !== 'string' || + typeof node.url !== 'string' || + typeof node.updatedAt !== 'string' || + !['OPEN', 'CLOSED'].includes(node.state) + ) { + throw new Error('GitHub issue search response missing fields') + } + if (!node.labels?.pageInfo || !node.assignees?.pageInfo) { + throw new Error('GitHub issue search response missing association completeness') + } + if (node.labels.pageInfo.hasNextPage || node.assignees.pageInfo.hasNextPage) { + const complete = await fetchIssueWorkItem( + args.repoPath, + args.ownerRepo, + node.number, + args.connectionId, + args.localGitOptions, + options.env + ) + if (!complete) { + throw new Error('GitHub issue detail response missing issue') + } + items.push(complete) + continue + } + items.push( + mapIssueWorkItem({ + ...node, + state: node.state.toLowerCase(), + user: restActor(node.author), + labels: node.labels.nodes, + assignees: node.assignees.nodes.map(restActor) + }) + ) + } + return items + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + // REST retains exact search semantics when GraphQL is unavailable for this credential. + } + } + const request = buildWorkItemListRequest({ kind: 'issue', ...args }) + if (args.noCache) { + request.args.splice(1, 2) + } + const { stdout } = await ghExecFileAsync(request.args, options) + noteRepositoryRateLimitSpend(args.ownerRepo, 'search', 1, options) + return z + .array(z.record(z.string(), z.unknown())) + .parse(JSON.parse(stdout)) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) +} diff --git a/src/main/github/client/list/work-item-pages.ts b/src/main/github/client/list/work-item-pages.ts index 63d5e7a0098b..73871ae1a457 100644 --- a/src/main/github/client/list/work-item-pages.ts +++ b/src/main/github/client/list/work-item-pages.ts @@ -15,12 +15,13 @@ import { githubHostExecOptions } from '../../github-api-repository' import { githubPRStackExecutionScope } from './../github-exec-scope' import { hydrateWorkItemRepositoryMergeMetadata } from './../detect/hydrate-work-item-merge-metadata' import type { MainWorkItem } from './../map/work-item-field-coercion' -import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item' +import { mapPullRequestWorkItem } from './../map/work-item' import { buildWorkItemListRequest, assertSshRepoHasResolvedGitHubSource, type PartialWorkItemsResult } from './work-item-list-request' +import { listIssueWorkItemPage } from './work-item-issue-page' export async function listRecentWorkItems( repoPath: string, issueOwnerRepo: OwnerRepo | null, @@ -34,15 +35,6 @@ export async function listRecentWorkItems( const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const recentQuery = parseTaskQuery('is:open') - const issueRequest = issueOwnerRepo - ? buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query: recentQuery, - page - }) - : null const prRequest = prOwnerRepo ? buildWorkItemListRequest({ kind: 'pr', @@ -52,18 +44,22 @@ export async function listRecentWorkItems( page }) : null - if (noCache && issueRequest) { - issueRequest.args.splice(1, 2) - } // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). const [issuesSettled, prsSettled] = await Promise.allSettled([ - issueRequest && issueOwnerRepo - ? ghExecFileAsync(issueRequest.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) + issueOwnerRepo + ? listIssueWorkItemPage({ + repoPath, + ownerRepo: issueOwnerRepo, + query: recentQuery, + limit, + page, + options: { ...ghOptions, ...githubHostExecOptions(issueOwnerRepo) }, + connectionId, + localGitOptions, + noCache }) - : Promise.resolve({ stdout: '[]' }), + : Promise.resolve([]), prRequest && prOwnerRepo ? ghExecFileAsync(prRequest.args, { ...ghOptions, @@ -75,15 +71,7 @@ export async function listRecentWorkItems( let issues: MainWorkItem[] = [] let issuesError: ClassifiedError | undefined if (issuesSettled.status === 'fulfilled') { - try { - issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - } catch (err) { - // Why: a malformed issue payload must not discard the successfully fetched PR half. - issuesError = classifyListIssuesError(err instanceof Error ? err.message : String(err)) - } + issues = issuesSettled.value } else { const stderr = issuesSettled.reason instanceof Error @@ -130,7 +118,8 @@ export async function listQueriedWorkItems( limit: number, page?: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + noCache?: boolean ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) @@ -154,27 +143,24 @@ export async function listQueriedWorkItems( if (!issueOwnerRepo) { return { items: [] } } - const request = buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query, - page: page ?? 1 - }) try { - const { stdout } = await ghExecFileAsync(request.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) + const items = await listIssueWorkItemPage({ + repoPath, + ownerRepo: issueOwnerRepo, + query, + limit, + page: page ?? 1, + options: { ...ghOptions, ...githubHostExecOptions(issueOwnerRepo) }, + connectionId, + localGitOptions, + noCache }) - const items = (JSON.parse(stdout) as Record[]) - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) successfulRequestCount += 1 return { items } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) if (classifyGitHubUnavailable(stderr)) { - availabilityError ??= err + availabilityError = err } else { nonAvailabilityFailureCount += 1 } diff --git a/src/main/github/client/list/work-item-search-batch.ts b/src/main/github/client/list/work-item-search-batch.ts new file mode 100644 index 000000000000..7f2154b328a1 --- /dev/null +++ b/src/main/github/client/list/work-item-search-batch.ts @@ -0,0 +1,194 @@ +import { z } from 'zod' +import { createHash } from 'node:crypto' +import { BoundedMap } from '../../../../shared/bounded-map' +import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe' +import { createGhRateLimitBlockedError } from '../../../git/gh-rate-limit-breaker' +import { extractExecError, ghExecFileAsync } from '../../gh-utils' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +import type { GitHubRepoExecOptions } from '../../github-api-repository' + +const envelopeSchema = z.object({ + data: z.record(z.string(), z.unknown()).nullish(), + errors: z + .array( + z.object({ + message: z.string().optional(), + path: z.array(z.union([z.string(), z.number()])).optional() + }) + ) + .optional() +}) +type Envelope = z.infer +type SearchRequest = { + search: string + first: number + after?: string + selection: string + options: GitHubRepoExecOptions + environment?: NodeJS.ProcessEnv + noCache?: boolean +} +type PendingSearch = { + request: SearchRequest + environment: NodeJS.ProcessEnv + resolve: (value: unknown) => void + reject: (error: unknown) => void +} + +export const WORK_ITEM_SEARCH_CACHE_MS = 120_000 +const MAX_BATCH = 10 +// Leave room for Windows argv escaping and the gh executable path. +const MAX_BATCH_QUERY_CHARS = 12_000 +const pending = new Map() +type SearchResponse = { at: number; value: T } +const inFlight: CoalescedProbes> = new Map() +const responses = new BoundedMap({ + maxEntries: 512, + maxBytes: 16 * 1024 * 1024, + sizeOf: (value, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(value)) +}) + +export function workItemSearchScope( + options: GitHubRepoExecOptions, + environment: NodeJS.ProcessEnv = options.env ?? process.env +): string { + // gh wrappers and credential selection can depend on cwd and the inherited environment. + return createHash('sha256') + .update( + JSON.stringify([ + options, + process.cwd(), + Object.entries(environment).sort(([a], [b]) => a.localeCompare(b)) + ]) + ) + .digest('hex') +} + +export function requestWorkItemSearch(request: SearchRequest): Promise> { + const environment = { ...(request.environment ?? request.options.env ?? process.env) } + const scope = workItemSearchScope(request.options, environment) + const key = JSON.stringify([ + scope, + request.search, + request.first, + request.after, + request.selection + ]) + const cached = request.noCache ? undefined : responses.get(key) + if (cached && Date.now() - cached.at < WORK_ITEM_SEARCH_CACHE_MS) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The cache key includes the complete selection; callers validate its response shape. + return Promise.resolve(cached as SearchResponse) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Coalescing uses the complete selection key; callers validate the selected response. + return runCoalescedProbe(inFlight, `${key}:${Boolean(request.noCache)}`, async (ownsKey) => { + const value = await new Promise((resolve, reject) => { + const batchKey = `${scope}:${Boolean(request.noCache)}` + let queue = pending.get(batchKey) + if (!queue) { + queue = [] + pending.set(batchKey, queue) + setTimeout(() => flushSearches(batchKey), 0) + } + queue.push({ request, environment, resolve, reject }) + }) + const response = { at: Date.now(), value } + if (!request.noCache && ownsKey()) { + responses.set(key, response) + } + return response + }) as Promise> +} + +function flushSearches(key: string): void { + const queue = pending.get(key) + pending.delete(key) + if (!queue) { + return + } + let batch: PendingSearch[] = [] + let characters = 0 + for (const entry of queue) { + const size = searchSelection(entry.request, batch.length).length + if (batch.length && (batch.length === MAX_BATCH || characters + size > MAX_BATCH_QUERY_CHARS)) { + void executeSearches(batch) + batch = [] + characters = 0 + } + batch.push(entry) + characters += size + } + if (batch.length) { + void executeSearches(batch) + } +} + +function searchSelection(request: SearchRequest, index: number): string { + const after = request.after ? `, after: ${JSON.stringify(request.after)}` : '' + return `r${index}: search(type: ISSUE, query: ${JSON.stringify(request.search)}, first: ${request.first}${after}) { ${request.selection} }` +} + +async function executeSearches(batch: PendingSearch[]): Promise { + const { options } = batch[0].request + try { + const guard = repositoryRateLimitGuard(options, 'graphql', options) + if (guard.blocked) { + throw createGhRateLimitBlockedError('graphql', guard.resetAt * 1000) + } + const selections = batch.map(({ request }, index) => searchSelection(request, index)) + const query = `query { ${selections.join('\n')} rateLimit { cost } }` + // One response cache owns age; a gh cache hit would otherwise renew an older response. + const args = ['api', 'graphql', '-f', `query=${query}`] + let envelope: Envelope + try { + const { stdout } = await ghExecFileAsync(args, { + ...options, + env: batch[0].environment, + idempotent: true + }) + envelope = envelopeSchema.parse(JSON.parse(stdout)) + } catch (error) { + const { stdout } = extractExecError(error) + if (!stdout) { + throw error + } + try { + envelope = envelopeSchema.parse(JSON.parse(stdout)) + } catch { + throw error + } + if (!envelope.data || !envelope.errors?.length) { + throw error + } + } + const rateLimit = envelope.data?.rateLimit + const cost = + rateLimit && typeof rateLimit === 'object' && 'cost' in rateLimit ? rateLimit.cost : undefined + noteRepositoryRateLimitSpend( + options, + 'graphql', + typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? cost : batch.length, + options + ) + for (const [index, entry] of batch.entries()) { + const alias = `r${index}` + const errors = envelope.errors?.filter( + (error) => !error.path?.length || error.path[0] === alias + ) + const value = envelope.data?.[alias] + if (errors?.length || value === undefined || value === null) { + entry.reject( + new Error( + errors?.map((error) => error.message).join('; ') || + 'GitHub search response missing data' + ) + ) + } else { + entry.resolve(value) + } + } + } catch (error) { + for (const entry of batch) { + entry.reject(error) + } + } +} diff --git a/src/main/github/client/list/work-item-search-page.ts b/src/main/github/client/list/work-item-search-page.ts new file mode 100644 index 000000000000..e03c16e777b5 --- /dev/null +++ b/src/main/github/client/list/work-item-search-page.ts @@ -0,0 +1,128 @@ +import { BoundedMap } from '../../../../shared/bounded-map' +import { isDefaultGitHubHost } from '../../../../shared/github/repository-identity-key' +import type { OwnerRepo } from '../../gh-utils' +import type { GitHubRepoExecOptions } from '../../github-api-repository' +import { + requestWorkItemSearch, + workItemSearchScope, + WORK_ITEM_SEARCH_CACHE_MS +} from './work-item-search-batch' + +type PageInfo = { endCursor: string | null; hasNextPage: boolean } +export type SearchConnection = { issueCount: number; pageInfo: PageInfo; nodes: T[] } +const cursors = new BoundedMap({ + maxEntries: 1024, + maxBytes: 1024 * 1024, + sizeOf: (value, key) => Buffer.byteLength(key) + Buffer.byteLength(value.cursor) + 8 +}) + +export function usesGraphqlWorkItemSearch( + ownerRepo: OwnerRepo, + options: GitHubRepoExecOptions +): boolean { + return isDefaultGitHubHost( + ownerRepo.host ?? options.host ?? options.env?.GH_HOST ?? process.env.GH_HOST + ) +} + +export async function searchWorkItemCount( + search: string, + options: GitHubRepoExecOptions +): Promise { + const { value: result } = await requestWorkItemSearch<{ issueCount: number }>({ + search, + first: 1, + selection: 'issueCount', + options + }) + if (!Number.isSafeInteger(result.issueCount) || result.issueCount < 0) { + throw new Error('GitHub search response missing count') + } + return result.issueCount +} + +export async function searchWorkItemPage(args: { + search: string + nodeSelection: string + limit: number + page: number + options: GitHubRepoExecOptions + noCache?: boolean +}): Promise { + const { options, noCache } = args + const environment = { ...(options.env ?? process.env) } + if (!Number.isSafeInteger(args.limit) || args.limit < 1) { + throw new Error('Invalid GitHub search page limit') + } + const limit = Math.min(100, args.limit) + const offset = (args.page - 1) * limit + if (offset + limit > 1000) { + throw new Error('Only the first 1000 search results are available (HTTP 422)') + } + const search = `${args.search} sort:created-desc` + const scope = JSON.stringify([workItemSearchScope(options, environment), search]) + let position = 0 + let after: string | undefined + if (!noCache) { + for (let at = offset; at > 0; at--) { + const cached = cursors.get(`${scope}:${at}`) + if (cached && Date.now() - cached.at < WORK_ITEM_SEARCH_CACHE_MS) { + position = at + after = cached.cursor + break + } + } + } + const remember = (position: number, info: PageInfo, at: number): void => { + if ( + typeof info.hasNextPage !== 'boolean' || + (info.endCursor !== null && typeof info.endCursor !== 'string') + ) { + throw new Error('GitHub search response invalid pagination') + } + if (info.hasNextPage && !info.endCursor) { + throw new Error('GitHub search response missing cursor') + } + if (!noCache && info.endCursor) { + cursors.set(`${scope}:${position}`, { at, cursor: info.endCursor }) + } + } + while (position < offset) { + const first = Math.min(100, offset - position) + const { value: skipped, at } = await requestWorkItemSearch>({ + search, + first, + after, + selection: 'issueCount pageInfo { endCursor hasNextPage }', + options, + environment, + noCache + }) + if (!skipped.pageInfo || !Number.isSafeInteger(skipped.issueCount)) { + throw new Error('GitHub search response missing pagination') + } + if (skipped.issueCount <= offset) { + return [] + } + if (!skipped.pageInfo.hasNextPage) { + throw new Error('GitHub search pagination ended before requested page') + } + position += first + remember(position, skipped.pageInfo, at) + after = skipped.pageInfo.endCursor ?? undefined + } + const { value: result, at } = await requestWorkItemSearch>({ + search, + first: limit, + after, + selection: `issueCount pageInfo { endCursor hasNextPage } nodes { ${args.nodeSelection} }`, + options, + environment, + noCache + }) + if (!Array.isArray(result.nodes) || !result.pageInfo) { + throw new Error('GitHub search response missing page') + } + remember(offset + result.nodes.length, result.pageInfo, at) + return result.nodes +} diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 7e3947897147..7e72ec9da9e3 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -32,7 +32,10 @@ export { githubRepositoryWebHost } from './github-repository-host' export type GitHubApiRepository = GitHubOwnerRepo -export type GitHubRepoExecOptions = ReturnType & { host?: string } +export type GitHubRepoExecOptions = ReturnType & { + host?: string + env?: NodeJS.ProcessEnv +} export type GitHubRepoExecution = { ownerRepo: GitHubApiRepository | null ghOptions: GitHubRepoExecOptions diff --git a/src/main/github/work-item-search-fallback-environment.test.ts b/src/main/github/work-item-search-fallback-environment.test.ts new file mode 100644 index 000000000000..9f57048d9f6c --- /dev/null +++ b/src/main/github/work-item-search-fallback-environment.test.ts @@ -0,0 +1,86 @@ +import { expect, it, vi } from 'vitest' +import { api, capture } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import metadata from './__fixtures__/work-item-search-metadata.json' + +it('preserves the Search budget floor when the preferred count fails', async () => { + api.restSearches = 29 + api.aliasErrorRepo = 'fixture/repo' + + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(0) + expect(api.calls.some((call) => call.args.includes('graphql'))).toBe(true) + expect(api.calls.some((call) => call.args.some((arg) => arg.startsWith('search/issues?')))).toBe( + false + ) + expect(api.restSearches).toBe(29) +}) + +it('still counts through GraphQL when the REST Search budget is below its floor', async () => { + api.restSearches = 29 + + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect(api.restSearches).toBe(29) +}) + +it('keeps REST fallback on the credential captured for the failed preferred search', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + api.aliasErrorRepo = 'fixture/repo' + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => arg.startsWith('search/issues?')) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) + +it('keeps count fallback on its captured credential', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + api.aliasErrorRepo = 'fixture/repo' + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => arg.startsWith('search/issues?')) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) + +it('hydrates oversized associations with the preferred page credential', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + const row = structuredClone(metadata[0].graphql) + api.specialNodes = [{ ...row, labels: { ...row.labels, pageInfo: { hasNextPage: true } } }] + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items[0].labels).toHaveLength(125) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => /^repos\/.+\/issues\/\d+$/.test(arg)) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) diff --git a/src/main/github/work-item-search-freshness.test.ts b/src/main/github/work-item-search-freshness.test.ts new file mode 100644 index 000000000000..bbc59bc471e6 --- /dev/null +++ b/src/main/github/work-item-search-freshness.test.ts @@ -0,0 +1,32 @@ +import { expect, it, vi } from 'vitest' +import { api, capture } from './work-item-search-test-harness' +import { searchWorkItemCount } from './client/list/work-item-search-page' + +it('does not renew a gh-cached response after bounded response-cache eviction', async () => { + const ghCache = new Map< + string, + { expires: number; response: { stdout: string; stderr: string } } + >() + capture.mockImplementation(async (binary, args, options) => { + const key = JSON.stringify([options.cwd, options.env?.GH_TOKEN, args]) + const cached = args.includes('--cache') ? ghCache.get(key) : undefined + if (cached && cached.expires > Date.now()) { + return cached.response + } + const response = await api.capture(binary, args, options) + if (args.includes('--cache')) { + ghCache.set(key, { expires: Date.now() + 120000, response }) + } + return response + }) + const search = 'repo:fixture/first is:issue' + expect(await searchWorkItemCount(search, {})).toBe(120) + for (let index = 0; index < 512; index++) { + await searchWorkItemCount(`repo:fixture/evict-${index} is:issue`, {}) + } + api.reportedCount = 121 + vi.setSystemTime(119000) + await searchWorkItemCount(search, {}) + vi.setSystemTime(120001) + expect(await searchWorkItemCount(search, {})).toBe(121) +}) diff --git a/src/main/github/work-item-search-isolation.test.ts b/src/main/github/work-item-search-isolation.test.ts new file mode 100644 index 000000000000..de11bdacc2e5 --- /dev/null +++ b/src/main/github/work-item-search-isolation.test.ts @@ -0,0 +1,140 @@ +import { expect, it, vi } from 'vitest' +import { api, sourceContext } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import { searchWorkItemCount, usesGraphqlWorkItemSearch } from './client/list/work-item-search-page' +import { recordGhPrimaryRateLimit, ghRateLimitScopeKey } from '../git/gh-rate-limit-breaker' + +it('coalesces matching searches and batches independent queries in one execution context', async () => { + const queries = Array.from({ length: 25 }, (_, i) => `repo:fixture/repo-${i} is:issue`) + const counts = await Promise.all( + queries.flatMap((search) => [searchWorkItemCount(search, {}), searchWorkItemCount(search, {})]) + ) + expect(counts).toEqual(Array(50).fill(120)) + expect(api.graphqlCalls).toBe(3) + expect(api.graphqlFields).toBe(25) + expect(api.calls.every((call) => call.cwd === undefined)).toBe(true) +}) + +it('isolates native cwd, WSL distro, host, admission context and inherited credentials', async () => { + const search = 'repo:fixture/repo is:issue' + const options = [ + { cwd: 'folder-a' }, + { cwd: 'folder-b' }, + { wslDistro: 'Ubuntu' }, + { wslDistro: 'Debian' }, + { host: 'github.example.com' }, + { admissionTier: 'interactive' as const } + ] + await Promise.all(options.map((option) => searchWorkItemCount(search, option))) + expect(api.graphqlCalls).toBe(options.length) + await searchWorkItemCount(search, { cwd: 'folder-a' }) + expect(api.graphqlCalls).toBe(options.length) + vi.stubEnv('GH_TOKEN', 'fixture-rotated-credential') + await searchWorkItemCount(search, { cwd: 'folder-a' }) + expect(api.graphqlCalls).toBe(options.length + 1) + expect(api.calls.some((call) => call.args.includes('github.example.com'))).toBe(true) +}) + +it('keeps SSH GitHub execution client-side without passing remote cwd', async () => { + const results = await Promise.all( + Array.from({ length: 8 }, (_, i) => + listWorkItems(`/remote/repo-${i}`, 24, 'is:issue', 1, undefined, `ssh-${i}`) + ) + ) + expect(results.every((result) => result.items.length === 24)).toBe(true) + expect(api.graphqlCalls).toBe(2) + expect(api.calls.every((call) => call.cwd === undefined)).toBe(true) +}) + +it('leaves GHES on REST and unresolved/non-GitHub sources empty', async () => { + sourceContext.host = 'github.example.com' + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(2) + expect( + api.calls + .filter((call) => !call.args.includes('rate_limit')) + .every((call) => call.args.includes('github.example.com')) + ).toBe(true) + expect( + usesGraphqlWorkItemSearch({ owner: 'fixture', repo: 'repo', host: 'gitlab.com' }, {}) + ).toBe(false) + sourceContext.available = false + expect((await listWorkItems('folder/without-git', 24, 'is:issue')).items).toEqual([]) + expect(await countWorkItems('folder/without-git')).toBe(0) + await expect( + listWorkItems('/remote/unresolved', 24, 'is:issue', 1, undefined, 'ssh') + ).rejects.toThrow() + expect(api.restSearches).toBe(2) +}) + +it('preserves successful aliases when one repository needs REST fallback', async () => { + api.aliasErrorRepo = 'fixture/repo-1' + const results = await Promise.all( + Array.from({ length: 4 }, (_, i) => + listWorkItems(`/remote/repo-${i}`, 24, 'is:issue', 1, undefined, 'ssh') + ) + ) + expect(results.every((result) => result.items.length === 24 && !result.errors)).toBe(true) + expect(api.graphqlCalls).toBe(1) + expect(api.restSearches).toBe(1) + expect( + api.calls + .find((call) => call.args.some((arg) => arg.startsWith('search/issues?'))) + ?.args.join(' ') + ).toContain('repo%3Afixture%2Frepo-1') +}) + +it('falls back on GraphQL quota exhaustion and respects independent runner breaker scopes', async () => { + recordGhPrimaryRateLimit('graphql', 3600000, ghRateLimitScopeKey('native', 'github.com')) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(1) + expect( + ( + await listWorkItems('fixture/repo', 24, 'is:issue', 1, undefined, undefined, false, { + wslDistro: 'Ubuntu' + }) + ).items + ).toHaveLength(24) + expect(api.graphqlCalls).toBe(1) + expect(api.restSearches).toBe(1) +}) + +it('keeps count/list search usable when REST Search is exhausted and reports both-bucket failures', async () => { + api.searchAvailable = false + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(api.restSearches).toBe(0) + api.graphqlAvailable = false + await expect( + listWorkItems('fixture/repo', 24, 'is:issue', 1, undefined, undefined, true) + ).rejects.toThrow(/rate limit exceeded/) +}) + +it('splits long search predicates within Windows command-line headroom', async () => { + const queries = Array.from( + { length: 4 }, + (_, index) => `repo:fixture/repo-${index} is:issue ${'word '.repeat(1400)}` + ) + expect(await Promise.all(queries.map((query) => searchWorkItemCount(query, {})))).toEqual([ + 120, 120, 120, 120 + ]) + expect(api.graphqlCalls).toBe(4) + expect(api.calls.every((call) => call.args.join(' ').length < 12000)).toBe(true) +}) + +it('executes queued requests with the credential environment captured at enqueue time', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-first-credential') + const first = searchWorkItemCount('repo:fixture/repo is:issue', {}) + vi.stubEnv('GH_TOKEN', 'fixture-second-credential') + const second = searchWorkItemCount('repo:fixture/repo is:issue', {}) + expect(await Promise.all([first, second])).toEqual([120, 120]) + expect(api.graphqlCalls).toBe(2) + expect(api.calls.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-first-credential', + 'fixture-second-credential' + ]) +}) diff --git a/src/main/github/work-item-search-pagination.test.ts b/src/main/github/work-item-search-pagination.test.ts new file mode 100644 index 000000000000..5e07878e5e6a --- /dev/null +++ b/src/main/github/work-item-search-pagination.test.ts @@ -0,0 +1,71 @@ +import { expect, it, vi } from 'vitest' +import { api } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' + +function issuePage(page: number, noCache = false, limit = 24) { + return listWorkItems( + 'fixture/repo', + limit, + 'is:issue is:open', + page, + undefined, + undefined, + noCache + ) +} + +it('restores a cold numbered page using only API-issued opaque cursors', async () => { + const third = await issuePage(3) + expect(third.items.map((item) => item.number)).toEqual( + Array.from({ length: 24 }, (_, i) => 9952 - i) + ) + expect(api.graphqlCalls).toBe(2) + expect(api.calls[0].args.join(' ')).not.toContain(' nodes {') + expect(api.calls[1].args.join(' ')).toContain('after: "opaque:1:cursor"') + expect((await issuePage(4)).items[0].number).toBe(9928) + expect(api.graphqlCalls).toBe(3) + expect((await issuePage(6)).items).toEqual([]) + expect(api.restSearches).toBe(0) +}) + +it('walks long jumps without node hydration and retains the authoritative 1000-result window', async () => { + api.rowsPerRepo = 1400 + const last = await issuePage(10, false, 100) + expect(last.items).toHaveLength(100) + expect(last.items[0].number).toBe(9100) + expect(api.graphqlCalls).toBe(10) + expect(api.calls.filter((call) => call.args.join(' ').includes(' nodes {'))).toHaveLength(1) + expect(await countWorkItems('fixture/repo', 'is:issue is:open')).toBe(1400) + const outside = await issuePage(11, false, 100) + expect(outside.items).toEqual([]) + expect(outside.errors?.issues).toMatchObject({ + type: 'validation_error', + message: 'Invalid request — Only the first 1000 search results are available (HTTP 422)' + }) + expect(api.restSearches).toBe(1) +}) + +it('bypasses both page and cursor caches on refresh and expires retained entries', async () => { + await issuePage(3) + expect(api.graphqlCalls).toBe(2) + await issuePage(3) + expect(api.graphqlCalls).toBe(2) + await issuePage(3, true) + expect(api.graphqlCalls).toBe(4) + expect(api.calls.slice(-2).every((call) => !call.args.includes('--cache'))).toBe(true) + vi.setSystemTime(120001) + await issuePage(3) + expect(api.graphqlCalls).toBe(6) + expect(api.restSearches).toBe(0) +}) + +it('does not renew cursor freshness when re-reading a cached page', async () => { + await issuePage(1) + vi.setSystemTime(119000) + await issuePage(1) + vi.setSystemTime(120001) + await issuePage(2) + expect(api.graphqlCalls).toBe(3) + expect(api.calls[1].args.join(' ')).not.toContain('after:') +}) diff --git a/src/main/github/work-item-search-semantics.test.ts b/src/main/github/work-item-search-semantics.test.ts new file mode 100644 index 000000000000..e5df22cddd3c --- /dev/null +++ b/src/main/github/work-item-search-semantics.test.ts @@ -0,0 +1,114 @@ +import { expect, it } from 'vitest' +import { api } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import { mapIssueWorkItem } from './client/map/work-item' +import metadata from './__fixtures__/work-item-search-metadata.json' + +it('matches the saved REST projection for users, bots, avatars, assignees and labels', async () => { + api.specialNodes = metadata.map((pair) => pair.graphql) + api.reportedCount = 2748 + const result = await listWorkItems('fixture/repo', 5, 'is:issue') + expect(result.items).toEqual(metadata.map((pair) => mapIssueWorkItem(pair.rest))) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(2748) + expect(api.restSearches).toBe(0) +}) + +it('preserves deleted authors and hydrates associations beyond GraphQL connection limits', async () => { + const row = structuredClone(metadata[0].graphql) + api.specialNodes = [ + { ...row, author: null, labels: { ...row.labels, pageInfo: { hasNextPage: true } } } + ] + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items).toHaveLength(1) + expect(result.items[0].author).toBeNull() + expect(result.items[0].labels).toEqual( + Array.from({ length: 125 }, (_, index) => `label-${index}`) + ) + expect(api.restDetails).toBe(1) + expect(api.restSearches).toBe(0) +}) + +it('passes every issue predicate to the server for both results and full counts', async () => { + api.expectedSearch = + 'repo:fixture/repo is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"' + api.specialNodes = [ + { ...metadata[0].graphql, number: 7, state: 'CLOSED', title: 'old needle phrase' } + ] + const query = + 'is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"' + expect((await listWorkItems('fixture/repo', 24, query)).items).toMatchObject([ + { number: 7, state: 'closed', title: 'old needle phrase' } + ]) + expect(await countWorkItems('fixture/repo', query)).toBe(1) + expect(api.restSearches).toBe(0) +}) + +it.each([ + ['is:issue state:all', 'repo:fixture/repo is:issue'], + ['is:issue is:open label:bug', 'repo:fixture/repo is:issue is:open label:bug'] +])('retains state/scope semantics for %s', async (query, expected) => { + api.expectedSearch = expected + expect((await listWorkItems('fixture/repo', 24, query)).items).toHaveLength(24) + expect(await countWorkItems('fixture/repo', query)).toBe(120) + expect(api.restSearches).toBe(0) +}) + +it.each([ + [ + 'is:draft', + 'is:pr is:open draft:true sort:created-desc', + 'repo:fixture/repo is:pull-request is:open draft:true' + ], + [ + 'is:pr is:closed', + 'is:pr is:closed -is:merged sort:created-desc', + 'repo:fixture/repo is:pull-request is:closed -is:merged' + ], + ['is:merged', 'is:pr is:merged sort:created-desc', 'repo:fixture/repo is:pull-request is:merged'], + [ + 'review-requested:"some user" reviewed-by:someone', + 'is:pr review-requested:"some user" reviewed-by:someone sort:created-desc', + 'repo:fixture/repo is:pull-request review-requested:"some user" reviewed-by:someone' + ] +])('keeps rich PR lists and full count predicates for %s', async (query, prSearch, countSearch) => { + expect((await listWorkItems('fixture/repo', 24, query)).items).toEqual([]) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(0) + expect(api.calls[0].args).toContain(prSearch) + expect(api.calls[0].args).toContain('--json') + api.expectedSearch = countSearch + expect(await countWorkItems('fixture/repo', query)).toBe(120) + expect(api.graphqlCalls).toBe(1) +}) + +it('falls back with the original numbered query when GraphQL is unavailable', async () => { + api.graphqlAvailable = false + api.expectedSearch = 'repo:fixture/repo is:issue is:closed label:"needs review" "exact phrase"' + const result = await listWorkItems( + 'fixture/repo', + 24, + 'is:issue is:closed label:"needs review" "exact phrase"', + 3, + undefined, + undefined, + true + ) + expect(result.items[0].number).toBe(9952) + const call = api.calls.find((call) => call.args.some((arg) => arg.startsWith('search/issues?'))) + expect(call?.args).toEqual([ + 'api', + '--hostname', + 'github.com', + `search/issues?q=${encodeURIComponent(api.expectedSearch)}&sort=created&order=desc&per_page=24&page=3`, + '--jq', + '.items' + ]) +}) + +it('falls back for malformed GraphQL rows instead of presenting a truncated result', async () => { + api.specialNodes = [{ ...metadata[0].graphql, __typename: 'PullRequest' }] + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items).toHaveLength(1) + expect(api.restSearches).toBe(1) +}) diff --git a/src/main/github/work-item-search-test-harness.ts b/src/main/github/work-item-search-test-harness.ts new file mode 100644 index 000000000000..d3783f0afe1e --- /dev/null +++ b/src/main/github/work-item-search-test-harness.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, vi } from 'vitest' +import type { Mock } from 'vitest' +import { randomUUID } from 'node:crypto' +import { basename } from 'node:path' +import type * as GithubApiRepositoryModule from './github-api-repository' +import { WorkItemSearchApi } from './__fixtures__/work-item-search-api' + +const { + capture, + sourceContext +}: { + capture: Mock + sourceContext: { host: string; available: boolean } +} = vi.hoisted(() => ({ + capture: vi.fn(), + sourceContext: { host: 'github.com', available: true } +})) +vi.mock('../git/command-runner/exec-file-capture', () => ({ + execFileCaptureToTermination: capture +})) +vi.mock('../git/command-runner/wsl-command-resolution', () => ({ + resolveCommand: (binary: string, args: string[], cwd?: string, distro?: string) => ({ + binary, + args, + cwd, + wsl: distro ? { distro } : null, + wslMode: null + }), + resolveDefaultWslCli: () => null +})) +vi.mock('../git/runner', async () => ({ + ghExecFileAsync: (await import('../git/command-runner/gh-exec-file')).ghExecFileAsync, + gitExecFileAsync: vi.fn() +})) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + const source = (repoPath: string) => + sourceContext.available + ? { owner: 'fixture', repo: basename(repoPath), host: sourceContext.host } + : null + return { + ...actual, + resolveIssueGitHubApiRepositorySource: async (repoPath: string) => ({ + source: source(repoPath), + fellBack: false + }), + getOriginGitHubApiRepository: async (repoPath: string) => source(repoPath), + getGitHubApiRepositoryForRemote: async () => null + } +}) + +import { _resetRateLimitCache } from './rate-limit' +import { clearGhRateLimitBlock, ghRateLimitScopeKey } from '../git/gh-rate-limit-breaker' +export let api: WorkItemSearchApi +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + vi.stubEnv('GH_HOST', 'github.com') + vi.stubEnv('ORCA_WORK_ITEM_SEARCH_FIXTURE', randomUUID()) + _resetRateLimitCache() + for (const runtime of ['native', 'wsl:ubuntu', 'wsl:debian']) { + for (const host of ['github.com', 'github.example.com']) { + for (const bucket of ['core', 'graphql', 'search'] as const) { + clearGhRateLimitBlock(bucket, ghRateLimitScopeKey(runtime, host)) + } + } + } + sourceContext.host = 'github.com' + sourceContext.available = true + api = new WorkItemSearchApi() + capture.mockReset().mockImplementation(api.capture.bind(api)) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +export { capture, sourceContext } diff --git a/tests/e2e/github-work-item-search-burst.unit.test.ts b/tests/e2e/github-work-item-search-burst.unit.test.ts new file mode 100644 index 000000000000..bcebb88ec58a --- /dev/null +++ b/tests/e2e/github-work-item-search-burst.unit.test.ts @@ -0,0 +1,87 @@ +import { z } from 'zod' +import { expect, it } from 'vitest' +import { join } from 'node:path' +import { api } from '../../src/main/github/work-item-search-test-harness' +import { listWorkItems } from '../../src/main/github/client/list/list-work-items' +import { countWorkItems } from '../../src/main/github/client/list/count-work-items' +import { + createTestStore, + mockApi +} from '../../src/renderer/src/store/slices/github-slice-test-harness' + +const repos = Array.from({ length: 36 }, (_, index) => ({ + repoId: `repo-${index}`, + path: join('fixture', `repo-${index}`) +})) +function connectedStore() { + mockApi.gh.listWorkItems.mockImplementation((...params: unknown[]) => { + const args = z + .object({ + repoPath: z.string(), + limit: z.number(), + query: z.string().optional(), + page: z.number().optional(), + noCache: z.boolean().optional() + }) + .parse(params[0]) + return listWorkItems( + args.repoPath, + args.limit, + args.query, + args.page, + undefined, + undefined, + args.noCache + ) + }) + mockApi.gh.countWorkItems.mockImplementation((...params: unknown[]) => { + const args = z.object({ repoPath: z.string(), query: z.string().optional() }).parse(params[0]) + return countWorkItems(args.repoPath, args.query) + }) + return createTestStore() +} + +it.each(['counts-first', 'lists-first'] as const)( + 'returns every repo and full totals with %s', + async (order) => { + const store = connectedStore() + const lists = () => + store.getState().fetchWorkItemsAcrossRepos(repos, 24, 1000, '', { force: true }) + const counts = () => store.getState().countWorkItemsAcrossRepos(repos, '', 24) + const pending = + order === 'counts-first' + ? { total: counts(), items: lists() } + : { items: lists(), total: counts() } + const [items, total] = await Promise.all([pending.items, pending.total]) + expect(items).toMatchObject({ failedCount: 0 }) + expect(items.items).toHaveLength(36 * 24) + expect(new Set(items.items.map((item) => item.repoId)).size).toBe(36) + expect(total).toEqual({ totalCount: 36 * 120, totalPages: 5 }) + expect(api.restSearches).toBe(0) + expect(api.rejected).toBe(0) + expect(api.graphqlCalls).toBeLessThanOrEqual(72) + } +) + +it('retains all repos on repeat, bypass-cache refresh and independent page jumps', async () => { + const store = connectedStore() + const fetch = (noCache = false) => + store + .getState() + .fetchWorkItemsAcrossRepos(repos, 24, 1000, 'is:issue is:open', { force: true, noCache }) + expect((await fetch()).items).toHaveLength(36 * 24) + const firstCalls = api.graphqlCalls + expect((await fetch()).items).toHaveLength(36 * 24) + expect(api.graphqlCalls).toBe(firstCalls) + for (let repeat = 0; repeat < 3; repeat++) { + expect((await fetch(true)).items).toHaveLength(36 * 24) + } + expect(api.graphqlCalls).toBe(firstCalls + 3 * 36) + const page3 = await store + .getState() + .fetchWorkItemsNextPage(repos, 24, 1000, 'is:issue is:open', 3) + expect(page3.items).toHaveLength(36 * 24) + expect(page3.items.every((item) => item.number <= 9952 && item.number >= 9929)).toBe(true) + expect(page3.failedCount).toBe(0) + expect(api.restSearches).toBe(0) +}) From 09187fcad8e1fb9395536ec9f9063d4486569a45 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 13 Sep 2026 15:43:21 -0700 Subject: [PATCH 009/209] fix(ai-vault): stream oversized remote session transcripts (#20455) * fix(ai-vault): stream oversized remote session transcripts * fix(build): bundle streamed JSON parser in desktop main --------- Co-authored-by: m4air --- electron.vite.config.ts | 1 + package.json | 1 + pnpm-lock.yaml | 8 + .../ai-vault/remote-session-content-lines.ts | 37 +++- .../remote-session-document-parsers.ts | 51 +++++ .../remote-session-large-transcripts.test.ts | 188 ++++++++++++++++++ .../remote-session-scan-concurrency.ts | 27 ++- .../remote-session-scanner-cline-source.ts | 17 ++ .../remote-session-scanner-sources.ts | 73 ++++--- .../ai-vault/remote-session-scanner-types.ts | 15 +- src/main/ai-vault/remote-session-scanner.ts | 14 +- .../remote-session-stream-lifecycle.test.ts | 95 +++++++++ .../remote-session-transcript-read.ts | 49 +++++ ...session-document-stream-boundaries.test.ts | 106 ++++++++++ .../session-document-stream-parity.test.ts | 95 +++++++++ ...session-document-stream-projection.test.ts | 48 +++++ src/main/ai-vault/session-document-stream.ts | 143 +++++++++++++ .../session-scanner-antigravity-parser.ts | 7 +- .../ai-vault/session-scanner-cline-parser.ts | 106 ++++++++-- .../ai-vault/session-scanner-codex-parser.ts | 2 +- .../session-scanner-copilot-parser.ts | 7 +- .../ai-vault/session-scanner-cursor-parser.ts | 7 +- .../ai-vault/session-scanner-devin-parser.ts | 105 +++++++--- .../ai-vault/session-scanner-droid-parser.ts | 7 +- .../session-scanner-gemini-parsers.ts | 29 ++- .../ai-vault/session-scanner-graph-parsers.ts | 7 +- .../ai-vault/session-scanner-hermes-parser.ts | 57 +++++- .../session-scanner-primary-parsers.ts | 7 +- .../native-chat/transcript-stream-lines.ts | 92 ++++++--- src/relay/ai-vault-service-filesystem.ts | 2 + src/relay/ai-vault-transcript-stream.ts | 34 ++++ 31 files changed, 1300 insertions(+), 137 deletions(-) create mode 100644 src/main/ai-vault/remote-session-document-parsers.ts create mode 100644 src/main/ai-vault/remote-session-large-transcripts.test.ts create mode 100644 src/main/ai-vault/remote-session-stream-lifecycle.test.ts create mode 100644 src/main/ai-vault/remote-session-transcript-read.ts create mode 100644 src/main/ai-vault/session-document-stream-boundaries.test.ts create mode 100644 src/main/ai-vault/session-document-stream-parity.test.ts create mode 100644 src/main/ai-vault/session-document-stream-projection.test.ts create mode 100644 src/main/ai-vault/session-document-stream.ts create mode 100644 src/relay/ai-vault-transcript-stream.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d900e6cba165..2ba0afaefc56 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,6 +8,7 @@ import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-no import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ + '@streamparser/json', '@xterm/headless', '@xterm/addon-serialize', 'tldts', diff --git a/package.json b/package.json index ca0b2eca73c1..e48079f8a1c2 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "@floating-ui/dom": "1.7.6", "@linear/sdk": "^82.1.0", "@parcel/watcher": "^2.5.6", + "@streamparser/json": "0.0.26", "@xterm/addon-serialize": "0.15.0-beta.300", "@xterm/headless": "6.1.0-beta.302", "agent-browser": "~0.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d86a1679572..586c9449212f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 + '@streamparser/json': + specifier: 0.0.26 + version: 0.0.26 '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)) @@ -2675,6 +2678,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@streamparser/json@0.0.26': + resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==} + '@swc/core-darwin-arm64@1.15.46': resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} @@ -9010,6 +9016,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@streamparser/json@0.0.26': {} + '@swc/core-darwin-arm64@1.15.46': optional: true diff --git a/src/main/ai-vault/remote-session-content-lines.ts b/src/main/ai-vault/remote-session-content-lines.ts index c20c6ec10aca..d62e89880182 100644 --- a/src/main/ai-vault/remote-session-content-lines.ts +++ b/src/main/ai-vault/remote-session-content-lines.ts @@ -1,6 +1,9 @@ +import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +export type RemoteSessionContent = string | AsyncIterable + const REMOTE_CONTENT_YIELD_LINE_COUNT = 200 const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 @@ -9,9 +12,12 @@ const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 * cancelled scan stops mid-transcript instead of parsing megabytes for a caller * that already left. */ export function remoteSessionContentLines( - content: string, + content: RemoteSessionContent, signal?: AbortSignal ): Iterable | AsyncIterable { + if (typeof content !== 'string') { + return content + } return signal ? cancellableContentLines(content, signal) : content.split(/\r?\n/) } @@ -61,3 +67,32 @@ async function yieldUnlessCancelled(signal: AbortSignal): Promise { await yieldToEventLoop() throwIfAiVaultScanCancelled(signal) } + +export class BinarySessionTranscriptError extends Error { + constructor() { + super('Binary session transcript') + } +} + +export async function* streamedSessionContentLines( + bytes: AsyncIterable, + signal?: AbortSignal +): AsyncGenerator { + let count = 0 + let chars = 0 + for await (const record of splitTranscriptStreamLines(bytes)) { + throwIfAiVaultScanCancelled(signal) + const line = + record.line.endsWith('\r') && (record.terminated || signal) + ? record.line.slice(0, -1) + : record.line + yield line + chars += line.length + if (++count >= REMOTE_CONTENT_YIELD_LINE_COUNT || chars >= REMOTE_CONTENT_YIELD_CHAR_COUNT) { + await yieldToEventLoop() + throwIfAiVaultScanCancelled(signal) + count = 0 + chars = 0 + } + } +} diff --git a/src/main/ai-vault/remote-session-document-parsers.ts b/src/main/ai-vault/remote-session-document-parsers.ts new file mode 100644 index 000000000000..1dcd78ba08bf --- /dev/null +++ b/src/main/ai-vault/remote-session-document-parsers.ts @@ -0,0 +1,51 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { parseDevinSessionDocument } from './session-scanner-devin-parser' +import { parseHermesSessionDocument } from './session-scanner-hermes-parser' +import { + parseGeminiSessionDocument, + parseGeminiJsonlSessionLines +} from './session-scanner-gemini-parsers' +import type { RemoteSessionSource } from './remote-session-scanner-types' + +export function remoteSessionDocumentParsers( + agent: AiVaultAgent +): Pick { + const parse = + agent === 'hermes' + ? parseHermesSessionDocument + : agent === 'devin' + ? parseDevinSessionDocument + : agent === 'gemini' + ? parseGeminiSessionDocument + : null + if (!parse) { + return {} + } + return { + parseDocument: (file, bytes, context) => + parse( + file, + bytes, + context.hostPlatform.os, + { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }, + context.signal + ), + ...(agent === 'gemini' + ? { + parseLines: (file, lines, context) => + parseGeminiJsonlSessionLines({ + file, + lines, + platform: context.hostPlatform.os, + options: { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + } + }) + } + : {}) + } +} diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts new file mode 100644 index 000000000000..62d8b8c5ed1f --- /dev/null +++ b/src/main/ai-vault/remote-session-large-transcripts.test.ts @@ -0,0 +1,188 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { describe, it, expect } from 'vitest' +import { createRelayAiVaultFilesystemProvider } from '../../relay/ai-vault-service-filesystem' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const platform = getRemoteHostPlatform( + process.platform === 'win32' + ? 'win32-x64' + : process.platform === 'darwin' + ? 'darwin-arm64' + : 'linux-x64' +) +const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).join('\n')}\n` +const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000) + +describe('large remote history through real relay filesystem', () => { + it('lists a large Codex rollout with middle messages and usage intact', async () => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-')) + try { + const path = join(home, '.codex', 'sessions', 'large.jsonl') + await mkdir(dirname(path), { recursive: true }) + await writeFile( + path, + jsonl([ + { + type: 'session_meta', + timestamp: '2026-09-13T01:00:00Z', + payload: { id: 'large', cwd: '/repo' } + }, + { + type: 'response_item', + timestamp: '2026-09-13T01:00:01Z', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Keep my history' }] + } + } + ]) + + filler.slice(0, filler.length / 2) + + jsonl([ + { + type: 'response_item', + timestamp: '2026-09-13T01:02:00Z', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Middle answer' }] + } + }, + { + type: 'event_msg', + timestamp: '2026-09-13T01:03:00Z', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 123, output_tokens: 45, total_tokens: 168 } + } + } + } + ]) + + filler.slice(filler.length / 2) + ) + const result = await scanRemoteAiVaultSessions({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: 'ssh:synthetic-17744', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: 'large', + messageCount: 2, + totalTokens: 168, + title: 'Keep my history' + }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it.each(['hermes', 'devin', 'gemini', 'cline'] as const)( + 'lists large %s documents with every message counted', + async (agent) => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-document-')) + try { + const messages = Array.from({ length: 11000 }, () => ({ + role: 'assistant', + content: 'x'.repeat(1024) + })) + messages.splice(5000, 0, { role: 'user', content: 'A middle user turn' }) + let path: string, record: unknown + if (agent === 'hermes') { + path = join(home, '.hermes', 'sessions', 'large.json') + record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages } + } else if (agent === 'devin') { + path = join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json') + record = { + session_id: 'large', + working_directory: '/repo', + steps: messages.map((message) => ({ + ...message, + metadata: { + is_user_input: message.role === 'user', + metrics: { input_tokens: 2, output_tokens: 1 } + } + })) + } + } else if (agent === 'gemini') { + path = join(home, '.gemini', 'tmp', 'large.json') + record = { + sessionId: 'large', + messages: messages.map((message) => ({ + type: message.role === 'assistant' ? 'gemini' : 'user', + content: message.content + })) + } + } else { + path = join(home, '.cline', 'data', 'sessions', 'large', 'large.json') + record = { session_id: 'large', cwd: '/repo' } + await mkdir(dirname(path), { recursive: true }) + await writeFile(path.replace('.json', '.messages.json'), JSON.stringify({ messages })) + } + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify(record)) + const result = await scanRemoteAiVaultSessions({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: `ssh:large-${agent}`, + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ agent, sessionId: 'large', messageCount: 11001 }) + if (agent === 'devin') { + expect(result.sessions[0].totalTokens).toBe(33003) + } + } finally { + await rm(home, { recursive: true, force: true }) + } + } + ) + it('keeps normal-size reads on their existing path and supports providers without streaming', async () => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-legacy-')) + try { + const directory = join(home, '.codex', 'sessions') + await mkdir(directory, { recursive: true }) + const content = jsonl([{ type: 'session_meta', payload: { id: 'small', cwd: '/repo' } }]) + await writeFile(join(directory, 'small.jsonl'), content) + const provider = createRelayAiVaultFilesystemProvider() + provider.readTranscriptBytes = () => { + throw new Error('Small file must keep its existing read path') + } + const small = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:small-original', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(small.issues).toEqual([]) + expect(small.sessions.map((session) => session.sessionId)).toEqual(['small']) + await writeFile(join(directory, 'large.jsonl'), content + filler) + const legacy = { readDir: provider.readDir, readFile: provider.readFile, stat: provider.stat } + const fallback = await scanRemoteAiVaultSessions({ + provider: legacy, + executionHostId: 'ssh:legacy-original', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(fallback.sessions.map((session) => session.sessionId)).toEqual(['small']) + expect( + fallback.issues.some( + (issue) => issue.path.endsWith('large.jsonl') && issue.message.includes('10MB limit') + ) + ).toBe(true) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts index 77b8e371b515..e74375259f5b 100644 --- a/src/main/ai-vault/remote-session-scan-concurrency.ts +++ b/src/main/ai-vault/remote-session-scan-concurrency.ts @@ -16,7 +16,32 @@ export function limitRemoteScanFilesystemConcurrency( return { readDir: (dirPath) => gate(() => provider.readDir(dirPath)), readFile: (filePath) => gate(() => provider.readFile(filePath)), - stat: (filePath) => gate(() => provider.stat(filePath)) + stat: (filePath) => gate(() => provider.stat(filePath)), + ...(provider.readTranscriptBytes + ? { + readTranscriptBytes: async function* (path: string, signal?: AbortSignal) { + let enter!: () => void + let release!: () => void + const entered = new Promise((resolve) => { + enter = resolve + }) + const released = new Promise((resolve) => { + release = resolve + }) + const held = gate(async () => { + enter() + await released + }) + await entered + try { + yield* provider.readTranscriptBytes!(path, signal) + } finally { + release() + await held + } + } + } + : {}) } } diff --git a/src/main/ai-vault/remote-session-scanner-cline-source.ts b/src/main/ai-vault/remote-session-scanner-cline-source.ts index f2f79089f9a8..f568aa06ce91 100644 --- a/src/main/ai-vault/remote-session-scanner-cline-source.ts +++ b/src/main/ai-vault/remote-session-scanner-cline-source.ts @@ -6,6 +6,7 @@ import type { RemoteSessionSource } from './remote-session-scanner-types' import { clineMessagesPathForMetadata, isClineSessionMetadataPath, + parseClineSessionDocuments, parseClineSessionContent } from './session-scanner-cline-parser' @@ -20,6 +21,22 @@ export function remoteClineSource( filePredicate: isClineSessionMetadataPath, contentDependencyPath: clineMessagesPathForMetadata, directoryPredicate: (_name, depth) => depth === 0, + parseDocument: (file, bytes, context) => + parseClineSessionDocuments( + file, + bytes, + () => + context.provider.readTranscriptBytes!( + clineMessagesPathForMetadata(file.path), + context.signal + ), + context.hostPlatform.os, + { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }, + context.signal + ), parse: async (file, content, context) => { let messagesContent: string | null = null try { diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 2c21a93db9a5..92b9e9b4dc56 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,3 +1,5 @@ +import { remoteSessionDocumentParsers } from './remote-session-document-parsers' +import type { RemoteSessionContent } from './remote-session-content-lines' import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' @@ -24,9 +26,9 @@ import type { RemoteSessionSource } from './remote-session-scanner-types' -type RemoteContentParser = ( +type RemoteContentParser = ( file: FileWithMtime, - content: string, + content: T, platform: NodeJS.Platform, options: RemoteParserOptions, // Line-based parsers iterate cancellably; whole-document parsers ignore it. @@ -134,22 +136,28 @@ function remoteAntigravitySource( ): RemoteSessionSource { const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli') const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') + const parse = async ( + file: FileWithMtime, + content: RemoteSessionContent, + context: RemoteScannerContext + ) => { + const session = await parseAntigravitySessionContent( + file, + content, + context.hostPlatform.os, + parserOptions(context), + context.signal + ) + return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null + } return { agent: 'antigravity', rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), extensions: ['.jsonl'], filePredicate: isAntigravityTranscriptPath, fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], - parse: async (file, content, context) => { - const session = await parseAntigravitySessionContent( - file, - content, - context.hostPlatform.os, - parserOptions(context), - context.signal - ) - return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null - } + parse, + parseLines: parse } } @@ -169,6 +177,7 @@ function source( extensions, filePredicate, directoryPredicate, + ...remoteSessionDocumentParsers(agent), parse: (file, content, context) => Promise.resolve( parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal) @@ -181,10 +190,16 @@ function jsonlSource( remoteHome: string, hostPlatform: RemoteHostPlatform, segments: readonly string[], - parseContent: RemoteContentParser, + parseContent: RemoteContentParser, filePredicate?: (path: string) => boolean ): RemoteSessionSource { - return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate) + return { + ...source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate), + parseLines: (file, lines, context) => + Promise.resolve( + parseContent(file, lines, context.hostPlatform.os, parserOptions(context), context.signal) + ) + } } function remoteCodexSources( @@ -202,12 +217,12 @@ function remoteCodexSources( 'codex-runtime-home', 'home' ) - ].map((codexHome) => ({ - agent: 'codex', - rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), - codexHome, - extensions: ['.jsonl'], - parse: (file, content, context) => + ].map((codexHome) => { + const parse = ( + file: FileWithMtime, + content: RemoteSessionContent, + context: RemoteScannerContext + ) => parseCodexSessionContent({ file, content, @@ -218,7 +233,15 @@ function remoteCodexSources( signal: context.signal, readIndexedTitle: remoteCodexIndexedTitleReader(codexHome, context) }) - })) + return { + agent: 'codex', + rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), + codexHome, + extensions: ['.jsonl'], + parse, + parseLines: parse + } + }) } function remoteOpenClawSources( @@ -246,7 +269,7 @@ function parserOptions(context: RemoteScannerContext): RemoteParserOptions { function piParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal @@ -256,7 +279,7 @@ function piParser( function ompParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal @@ -266,7 +289,7 @@ function ompParser( function primeAgentParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal @@ -276,7 +299,7 @@ function primeAgentParser( function openClawParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 405ea7f4626d..7f78cbb5d60d 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -18,7 +18,10 @@ export type RemoteScannerContext = { export type RemoteSessionFilesystemProvider = Pick< IFilesystemProvider, 'readDir' | 'readFile' | 'stat' -> +> & { + /** Available only beside the execution host's disk; never opens a client path. */ + readTranscriptBytes?: (path: string, signal?: AbortSignal) => AsyncIterable +} export type RemoteParserOptions = { executionHostId: ExecutionHostId @@ -42,6 +45,16 @@ export type RemoteSessionSource = { // artifact dir): count subagent transcripts from the walked listing and drop // them from candidates instead of indexing them as sessions. partitionSubagentTranscripts?: (paths: readonly string[]) => SubagentTranscriptPartition + parseDocument?: ( + file: FileWithMtime, + bytes: AsyncIterable, + context: RemoteScannerContext + ) => Promise + parseLines?: ( + file: FileWithMtime, + lines: AsyncIterable, + context: RemoteScannerContext + ) => Promise parse: ( file: FileWithMtime, content: string, diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 259c84d66efe..971bc20f70ed 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,3 +1,5 @@ +import { parseRemoteSessionTranscript } from './remote-session-transcript-read' +import { BinarySessionTranscriptError } from './remote-session-content-lines' import type { AiVaultListResult, AiVaultScanIssue, @@ -239,14 +241,7 @@ async function parseRemoteSessionCandidate( const session = await parseRemoteSessionFileCached({ candidate, hostKey: remoteSessionParseHostKey(context), - parse: async () => { - const read = await context.provider.readFile(candidate.file.path) - throwIfAiVaultScanCancelled(context.signal) - if (read.isBinary) { - return null - } - return await candidate.source.parse(candidate.file, read.content, context) - }, + parse: () => parseRemoteSessionTranscript(candidate, context), refreshReusedSession: reusedCodexTitleRefresh(candidate, context) }) throwIfAiVaultScanCancelled(context.signal) @@ -260,6 +255,9 @@ async function parseRemoteSessionCandidate( return session } catch (err) { throwIfAiVaultScanCancelled(context.signal) + if (err instanceof BinarySessionTranscriptError) { + return null + } recordSessionScanIssue(issues, { executionHostId: context.executionHostId, agent: candidate.source.agent, diff --git a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts new file mode 100644 index 000000000000..46668e3574eb --- /dev/null +++ b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest' +import { streamedSessionContentLines } from './remote-session-content-lines' +import { readStreamedSessionDocument } from './session-document-stream' +import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' + +describe('stream lifetime and retained document work', () => { + it('releases the source when a line consumer finishes early', async () => { + let closed = false + async function* bytes() { + try { + yield Buffer.from('one\ntwo\n') + yield Buffer.from('three\n') + } finally { + closed = true + } + } + for await (const line of streamedSessionContentLines(bytes())) { + expect(line).toBe('one') + break + } + await vi.waitFor(() => expect(closed).toBe(true)) + }) + it('propagates disk failure and closes the source', async () => { + let closed = false + async function* bytes() { + try { + yield Buffer.from('one\n') + throw new Error('disk read failed') + } finally { + closed = true + } + } + await expect( + (async () => { + for await (const _ of streamedSessionContentLines(bytes())) { + /* consume */ + } + })() + ).rejects.toThrow('disk read failed') + expect(closed).toBe(true) + }) + it('cancellation discards a document fold and releases its source', async () => { + const controller = new AbortController() + let closed = false + async function* bytes() { + try { + yield Buffer.from('{"messages":[{"role":"user"}') + controller.abort() + yield Buffer.from(']}') + } finally { + closed = true + } + } + await expect( + readStreamedSessionDocument({ + bytes: bytes(), + arrayKey: 'messages', + fields: [], + create: () => ({ count: 0 }), + consume: (state) => { + state.count++ + }, + signal: controller.signal + }) + ).rejects.toThrow() + expect(closed).toBe(true) + }) + it('holds one filesystem slot for the stream lifetime and releases it on return', async () => { + let entered = 0 + async function* bytes() { + entered++ + yield Buffer.from('a') + yield Buffer.from('b') + } + const provider = limitRemoteScanFilesystemConcurrency( + { + readDir: async () => [], + readFile: async () => ({ content: '', isBinary: false }), + stat: async () => ({ size: 0, type: 'file', mtime: 0 }), + readTranscriptBytes: bytes + }, + 1 + ) + const first = provider.readTranscriptBytes!('/one')[Symbol.asyncIterator](), + second = provider.readTranscriptBytes!('/two')[Symbol.asyncIterator]() + await first.next() + const pending = second.next() + await Promise.resolve() + expect(entered).toBe(1) + await first.return!() + await pending + expect(entered).toBe(2) + await second.return!() + }) +}) diff --git a/src/main/ai-vault/remote-session-transcript-read.ts b/src/main/ai-vault/remote-session-transcript-read.ts new file mode 100644 index 000000000000..42ea29af4099 --- /dev/null +++ b/src/main/ai-vault/remote-session-transcript-read.ts @@ -0,0 +1,49 @@ +import { streamedSessionContentLines } from './remote-session-content-lines' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import type { RemoteSessionCandidate, RemoteScannerContext } from './remote-session-scanner-types' +import type { AiVaultSession } from '../../shared/ai-vault-types' + +const LEGACY_SESSION_TEXT_LIMIT_BYTES = 10 * 1024 * 1024 + +export async function parseRemoteSessionTranscript( + candidate: RemoteSessionCandidate, + context: RemoteScannerContext +): Promise { + const sidecar = candidate.file.sidecar + const exceedsWholeReadLimit = + (candidate.file.sizeBytes ?? 0) > LEGACY_SESSION_TEXT_LIMIT_BYTES || + (typeof sidecar === 'object' && sidecar.sizeBytes > LEGACY_SESSION_TEXT_LIMIT_BYTES) + if ( + exceedsWholeReadLimit && + candidate.source.parseDocument && + !candidate.file.path.endsWith('.jsonl') && + context.provider.readTranscriptBytes + ) { + return candidate.source.parseDocument( + candidate.file, + context.provider.readTranscriptBytes(candidate.file.path, context.signal), + context + ) + } + if ( + exceedsWholeReadLimit && + candidate.file.path.endsWith('.jsonl') && + candidate.source.parseLines && + context.provider.readTranscriptBytes + ) { + return candidate.source.parseLines( + candidate.file, + streamedSessionContentLines( + context.provider.readTranscriptBytes(candidate.file.path, context.signal), + context.signal + ), + context + ) + } + const read = await context.provider.readFile(candidate.file.path) + throwIfAiVaultScanCancelled(context.signal) + if (read.isBinary) { + return null + } + return await candidate.source.parse(candidate.file, read.content, context) +} diff --git a/src/main/ai-vault/session-document-stream-boundaries.test.ts b/src/main/ai-vault/session-document-stream-boundaries.test.ts new file mode 100644 index 000000000000..67901707cbd0 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-boundaries.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { + parseHermesSessionContent, + parseHermesSessionDocument +} from './session-scanner-hermes-parser' +import { + parseClineSessionContent, + parseClineSessionDocuments +} from './session-scanner-cline-parser' +import { + remoteSessionContentLines, + streamedSessionContentLines +} from './remote-session-content-lines' + +const file = { + path: '/fixture/session/session.json', + mtimeMs: 0, + modifiedAt: new Date(0).toISOString() +} +const options = { + executionHostId: 'ssh:independent-review' as const, + executionHostPlatform: 'linux' as const +} +async function* bytes(data: Buffer | string, size = 3) { + const b = typeof data === 'string' ? Buffer.from(data) : data + for (let i = 0; i < b.length; i += size) { + yield b.subarray(i, i + size) + } +} +async function outcome(run: () => unknown) { + try { + return { value: await run() } + } catch (error) { + return { error: error instanceof Error ? error.name : typeof error } + } +} +async function lines(content: Iterable | AsyncIterable) { + const result: string[] = [] + for await (const line of content) { + result.push(line) + } + return result +} + +describe('independent JSON boundary review', () => { + for (const content of ['', ' \t\r\n']) { + it(`preserves empty-document parse outcome ${JSON.stringify(content)}`, async () => { + expect( + await outcome(() => parseHermesSessionDocument(file, bytes(content), 'linux', options)) + ).toEqual(await outcome(() => parseHermesSessionContent(file, content, 'linux', options))) + }) + } + for (const invalid of [[255], [195], [237, 160, 128], [240, 128, 128, 128], [226, 40, 161]]) { + it(`preserves legacy replacement decoding for UTF8 ${invalid.join('-')}`, async () => { + const data = Buffer.concat([ + Buffer.from('{"session_id":"id","messages":[{"role":"user","content":"before '), + Buffer.from(invalid), + Buffer.from(' after"}]}') + ]) + expect( + await outcome(() => parseHermesSessionDocument(file, bytes(data, 1), 'linux', options)) + ).toEqual( + await outcome(() => + parseHermesSessionContent(file, data.toString('utf8'), 'linux', options) + ) + ) + }) + } + it('ignores errors in an overwritten Cline messages array', async () => { + const metadata = '{"session_id":"id","prompt":"fallback"}' + const messages = '{"messages":[{"role":"user","content":"discarded","ts":1e300}],"messages":[]}' + expect( + await outcome(() => + parseClineSessionDocuments(file, bytes(metadata), () => bytes(messages), 'linux', options) + ) + ).toEqual( + await outcome(() => parseClineSessionContent(file, metadata, messages, 'linux', options)) + ) + }) + it('does not turn a bare carriage return into a JSONL record boundary', async () => { + const content = '{"role":"user","content":"first"}\r{"role":"assistant","content":"second"}' + expect(await lines(streamedSessionContentLines(bytes(content)))).toEqual( + await lines(remoteSessionContentLines(content)) + ) + }) + it('preserves escaped surrogate, duplicate nested key, and prototype-looking key values', async () => { + const content = String.raw`{"session_id":"id","__proto__":{"polluted":true},"messages":[{"role":"assistant","role":"user","content":"\ud800X\udc00 \ud83d\udc0b","__proto__":{"role":"assistant"}}]}` + expect(await parseHermesSessionDocument(file, bytes(content, 1), 'linux', options)).toEqual( + await parseHermesSessionContent(file, content, 'linux', options) + ) + expect(Reflect.get({}, 'polluted')).toBeUndefined() + }) + for (const content of [ + '{"messages":[],}', + '{"messages":[1,]}', + '{"messages":[01]}', + '{"messages":[NaN]}', + '{} {}' + ]) { + it(`rejects malformed JSON ${content}`, async () => { + expect( + await outcome(() => parseHermesSessionDocument(file, bytes(content), 'linux', options)) + ).toEqual(await outcome(() => parseHermesSessionContent(file, content, 'linux', options))) + }) + } +}) diff --git a/src/main/ai-vault/session-document-stream-parity.test.ts b/src/main/ai-vault/session-document-stream-parity.test.ts new file mode 100644 index 000000000000..441d25d27527 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-parity.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { + parseHermesSessionContent, + parseHermesSessionDocument +} from './session-scanner-hermes-parser' +import { parseDevinSessionContent, parseDevinSessionDocument } from './session-scanner-devin-parser' +import { + parseGeminiSessionContent, + parseGeminiSessionDocument +} from './session-scanner-gemini-parsers' +import { + parseClineSessionContent, + parseClineSessionDocuments +} from './session-scanner-cline-parser' + +const file = { + path: '/sessions/identity/identity.json', + mtimeMs: 1000, + modifiedAt: new Date(1000).toISOString() +} +const options = { executionHostId: 'ssh:parity' as const, executionHostPlatform: 'darwin' as const } +async function* bytes(content: string) { + const data = Buffer.from(content) + for (let i = 0; i < data.length; i += 3) { + yield data.subarray(i, i + 3) + } +} +const fixtures = [ + { + agent: 'hermes', + parse: parseHermesSessionContent, + stream: parseHermesSessionDocument, + content: + '{"messages":[{"role":"user","content":"Ü🐋 first"},{"role":"assistant","content":"answer"}],"session_id":"id","cwd":"/repo","model":"root","session_start":"2026-01-01T00:00:00Z","last_updated":"2026-01-02T00:00:00Z","message_count":99}' + }, + { + agent: 'devin', + parse: parseDevinSessionContent, + stream: parseDevinSessionDocument, + content: + '{"steps":[{"role":"assistant","text":"answer","metadata":{"generation_model":"step","created_at":"2026-01-02T00:00:00Z","metrics":{"input_tokens":10,"output_tokens":20}}},{"metadata":{"is_user_input":true},"text":"Ü🐋 prompt"}],"agent":{"model_name":"root"},"session_id":"id","working_directory":"/repo"}' + }, + { + agent: 'gemini', + parse: parseGeminiSessionContent, + stream: parseGeminiSessionDocument, + content: + '{"messages":[{"type":"user","content":"Ü🐋 first","timestamp":"2026-01-02T00:00:00Z"},{"type":"gemini","content":"answer","tokens":{"input":10,"output":20}}],"sessionId":"id","startTime":"2026-01-01T00:00:00Z","lastUpdated":"2026-01-03T00:00:00Z"}' + } +] +describe('streamed whole-document parser equivalence', () => { + for (const fixture of fixtures) { + it(`${fixture.agent}: field order and UTF8 chunk boundaries preserve every output field`, async () => { + expect(await fixture.stream(file, bytes(fixture.content), 'darwin', options)).toEqual( + await fixture.parse(file, fixture.content, 'darwin', options) + ) + }) + for (const last of [ + '[]', + 'null', + '[{"role":"user","type":"user","content":"last","text":"last","metadata":{"is_user_input":true}}]' + ]) { + it(`${fixture.agent}: duplicate arrays use their final value ${last}`, async () => { + const key = fixture.agent === 'devin' ? 'steps' : 'messages' + const content = `${fixture.content.slice(0, -1)},"${key}":${last}}` + expect(await fixture.stream(file, bytes(content), 'darwin', options)).toEqual( + await fixture.parse(file, content, 'darwin', options) + ) + }) + } + it(`${fixture.agent}: rejects a malformed tail after valid messages`, async () => { + const content = fixture.content.slice(0, -1) + await expect(fixture.stream(file, bytes(content), 'darwin', options)).rejects.toThrow() + }) + } + it('Cline preserves sidecar semantics, metadata field order and duplicate arrays', async () => { + const metadata = + '{"session_id":"id","cwd":"/repo","started_at":"2026-01-01T00:00:00Z","prompt":"fallback"}' + for (const messages of [ + '{"messages":[{"role":"user","content":"Ü🐋 first","ts":"2026-01-02T00:00:00Z"},{"role":"assistant","content":"answer","modelInfo":{"id":"sidecar"}}],"updated_at":"2026-01-03T00:00:00Z"}', + '{"messages":[{"role":"user","content":"old"}],"messages":[]}', + '{"messages":[{"role":"user","content":"partial"}]' + ]) { + expect( + await parseClineSessionDocuments( + file, + bytes(metadata), + () => bytes(messages), + 'darwin', + options + ) + ).toEqual(parseClineSessionContent(file, metadata, messages, 'darwin', options)) + } + }) +}) diff --git a/src/main/ai-vault/session-document-stream-projection.test.ts b/src/main/ai-vault/session-document-stream-projection.test.ts new file mode 100644 index 000000000000..2234153da37e --- /dev/null +++ b/src/main/ai-vault/session-document-stream-projection.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { parseDevinSessionContent, parseDevinSessionDocument } from './session-scanner-devin-parser' +import { readStreamedSessionDocument } from './session-document-stream' +const file = { path: '/devin/test.json', modifiedAt: new Date(0).toISOString(), mtimeMs: 0 } +const options = { + executionHostId: 'ssh:projection' as const, + executionHostPlatform: 'darwin' as const +} +async function* bytes(content: string) { + const b = Buffer.from(content) + for (let i = 0; i < b.length; i += 7) { + yield b.subarray(i, i + 7) + } +} +describe('Devin consumed metadata projection', () => { + for (const suffix of [ + '{}', + 'null', + '[]', + '{"model":"last"}', + '{"model_name":"last-name","model":"fallback"}', + '{"model_name":[],"model":123}', + '{"model":"old","model":"last"}' + ]) { + it(`preserves duplicate root agent ${suffix}`, async () => { + const content = `{"agent":{"model_name":"old"},"steps":[{"role":"assistant","text":"message","metadata":{"generation_model":"step"}}],"generation_model":"root-fallback","agent":${suffix}}` + expect(await parseDevinSessionDocument(file, bytes(content), 'darwin', options)).toEqual( + parseDevinSessionContent(file, content, 'darwin', options) + ) + }) + } + it('retains only model fields from the agent object', async () => { + const result = await readStreamedSessionDocument({ + bytes: bytes( + '{"agent":{"ignored":{"many":[1,2,3]},"model_name":"root","model":"fallback"},"steps":[]}' + ), + arrayKey: 'steps', + fields: [], + objectFields: { agent: ['model_name', 'model'] }, + create: () => 0, + consume: () => {} + }) + expect(result).toEqual({ + record: { agent: { model_name: 'root', model: 'fallback' } }, + state: 0 + }) + }) +}) diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts new file mode 100644 index 000000000000..4d12ab0fcc2d --- /dev/null +++ b/src/main/ai-vault/session-document-stream.ts @@ -0,0 +1,143 @@ +import { StringDecoder } from 'node:string_decoder' +import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' + +/** Fold one root array while retaining only the root fields the agent parser uses. */ +export async function readStreamedSessionDocument(args: { + bytes: AsyncIterable + arrayKey: string + fields: readonly string[] + objectFields?: Readonly> + create: () => T + consume: (state: T, value: unknown) => void + signal?: AbortSignal +}): Promise<{ record: Record; state: T } | null> { + const parser = new JSONParser({ + paths: [ + ...args.fields.map((field) => `$.${field}`), + ...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) => + fields.map((field) => `$.${root}.${field}`) + ), + ...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : []) + ], + keepStack: false, + stringBufferSize: 64 * 1024 + }) + const record: Record = Object.create(null) + const fields = new Set(args.fields) + let depth = 0 + let expectingRootKey = false + parser.onToken = ({ token, value }) => { + if (depth === 1 && expectingRootKey && token === TokenType.STRING) { + if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) { + record[value] = Object.create(null) + } + expectingRootKey = false + } + if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { + if (depth === 0 && token === TokenType.LEFT_BRACE) { + expectingRootKey = true + } + depth++ + } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { + depth-- + } else if (token === TokenType.COMMA && depth === 1) { + expectingRootKey = true + } + } + let state = args.create() + let currentArray: unknown = null + let consumeFailure: { error: unknown } | undefined + const decoder = new StringDecoder('utf8') + let objectRoot: boolean | undefined + parser.onValue = ({ key, value, parent, stack }) => { + if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) { + if (parent !== currentArray) { + state = args.create() + consumeFailure = undefined + currentArray = parent + } + if (!consumeFailure) { + try { + args.consume(state, value) + } catch (error) { + consumeFailure = { error } + } + } + // The parser's array cursor is independent of retained array slots. + parent.pop() + } else if ( + stack.length === 2 && + typeof stack[1].key === 'string' && + typeof key === 'string' && + parent && + !Array.isArray(parent) + ) { + const root = stack[1].key + const projected = record[root] + if ( + Object.hasOwn(args.objectFields ?? {}, root) && + args.objectFields?.[root]?.includes(key) && + projected && + typeof projected === 'object' + ) { + Reflect.set(projected, key, value) + } + } else if (stack.length === 1 && typeof key === 'string') { + if (key === args.arrayKey) { + if (value !== currentArray || !Array.isArray(value)) { + state = args.create() + consumeFailure = undefined + } + currentArray = null + } else if (fields.has(key)) { + record[key] = value + } + if (parent && typeof parent === 'object') { + Reflect.deleteProperty(parent, key) + } + } + } + for await (const chunk of args.bytes) { + throwIfAiVaultScanCancelled(args.signal) + if (objectRoot === undefined) { + const first = chunk.find((byte) => byte !== 32 && byte !== 9 && byte !== 10 && byte !== 13) + if (first !== undefined) { + objectRoot = first === 123 + } + } + parseJson(() => parser.write(decoder.write(chunk))) + await yieldToEventLoop() + } + const tail = decoder.end() + if (tail) { + parseJson(() => parser.write(tail)) + } + if (objectRoot === undefined) { + throw new SyntaxError('Unexpected end of JSON input') + } + if (!parser.isEnded) { + parseJson(() => parser.end(), true) + } + throwIfAiVaultScanCancelled(args.signal) + if (consumeFailure) { + throw consumeFailure.error + } + return objectRoot ? { record, state } : null +} + +function parseJson(run: () => void, ending = false): void { + try { + run() + } catch (error) { + if ( + error instanceof TokenizerError || + error instanceof TokenParserError || + (ending && error instanceof Error) + ) { + throw new SyntaxError(error.message) + } + throw error + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 126b56ce19af..775cc2283707 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -42,7 +45,7 @@ export async function parseAntigravitySessionFile( export async function parseAntigravitySessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts index 462108cfed94..5ef97f09cb04 100644 --- a/src/main/ai-vault/session-scanner-cline-parser.ts +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -1,3 +1,6 @@ +import { isMissingRemoteSessionPathError } from './remote-session-file-stat' +import { BinarySessionTranscriptError } from './remote-session-content-lines' +import { readStreamedSessionDocument } from './session-document-stream' import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -8,7 +11,7 @@ import { finalizeSession, updateTimeline } from './session-scanner-accumulator' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { arrayValue, @@ -93,21 +96,7 @@ export function parseClineSessionContent( if (messages) { updateTimeline(accumulator, messages.updated_at) for (const value of arrayValue(messages.messages)) { - const message = asRecord(value) - const role = message?.role - if (!message || (role !== 'user' && role !== 'assistant')) { - continue - } - accumulator.messageCount++ - updateTimeline(accumulator, message.ts) - const content = message.content - if (role === 'user' && !accumulator.fallbackTitle) { - accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '') - } - if (role === 'assistant' && !accumulator.model) { - accumulator.model = extractString(asRecord(message.modelInfo)?.id) - } - addPreviewContent(accumulator, role, content, message.ts) + consumeClineSessionMessage(accumulator, value) } } accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '') @@ -122,3 +111,88 @@ function parseJsonRecord(content: string): Record | null { return null } } + +function consumeClineSessionMessage(accumulator: SessionAccumulator, value: unknown): void { + const message = asRecord(value) + const role = message?.role + if (!message || (role !== 'user' && role !== 'assistant')) { + return + } + accumulator.messageCount++ + updateTimeline(accumulator, message.ts) + const content = message.content + if (role === 'user' && !accumulator.fallbackTitle) { + accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '') + } + if (role === 'assistant' && !accumulator.model) { + accumulator.model = extractString(asRecord(message.modelInfo)?.id) + } + addPreviewContent(accumulator, role, content, message.ts) +} + +export async function parseClineSessionDocuments( + file: FileWithMtime, + metadataBytes: AsyncIterable, + readMessages: () => AsyncIterable, + platform: NodeJS.Platform, + options: ParserSessionOptions, + signal?: AbortSignal +): Promise { + let metadata: Record + try { + const parsed = await readStreamedSessionDocument({ + bytes: metadataBytes, + arrayKey: '', + fields: ['session_id', 'cwd', 'workspace_root', 'model', 'started_at', 'prompt'], + create: () => null, + consume: () => {}, + signal + }) + if (!parsed) { + return null + } + metadata = parsed.record + } catch (error) { + if (error instanceof SyntaxError) { + return null + } + throw error + } + const create = (): SessionAccumulator => { + const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean) + const accumulator = createAccumulator({ + agent: 'cline', + file, + sessionId: extractString(metadata.session_id) ?? pathSegments.at(-2) ?? '' + }) + accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) + accumulator.model = extractString(metadata.model) + updateTimeline(accumulator, metadata.started_at) + return accumulator + } + let accumulator = create() + try { + const parsed = await readStreamedSessionDocument({ + bytes: readMessages(), + arrayKey: 'messages', + fields: ['updated_at'], + create, + consume: consumeClineSessionMessage, + signal + }) + if (parsed) { + accumulator = parsed.state + updateTimeline(accumulator, parsed.record.updated_at) + } + } catch (error) { + if ( + !(error instanceof SyntaxError) && + !(error instanceof BinarySessionTranscriptError) && + !isMissingRemoteSessionPathError(error) + ) { + throw error + } + } + accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '') + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index b3571d4a3371..beff5eb06b01 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -66,7 +66,7 @@ export async function parseCodexSessionFile( export async function parseCodexSessionContent(args: { file: FileWithMtime - content: string + content: string | AsyncIterable platform?: NodeJS.Platform codexHome?: string | null executionHostId?: ExecutionHostId diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts index 239c5983457a..a4d34fa5b137 100644 --- a/src/main/ai-vault/session-scanner-copilot-parser.ts +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -45,7 +48,7 @@ export async function parseCopilotSessionFile( export async function parseCopilotSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts index bfa15caa5306..a05817e2dc09 100644 --- a/src/main/ai-vault/session-scanner-cursor-parser.ts +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -43,7 +46,7 @@ export async function parseCursorSessionFile( export async function parseCursorSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 12e40ef3fc2b..d3a62b08a599 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,7 +1,8 @@ +import { readStreamedSessionDocument } from './session-document-stream' import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewContent, @@ -70,38 +71,8 @@ function parseDevinSessionRecord( extractString(agentRecord?.model) ?? extractString(record.generation_model) accumulator.cwd = extractString(record.working_directory) - const steps = arrayValue(record.steps) - for (const step of steps) { - const stepRecord = asRecord(step) - if (!stepRecord) { - continue - } - const metadata = asRecord(stepRecord.metadata) - updateTimeline(accumulator, extractString(metadata?.created_at)) - const metrics = asRecord(metadata?.metrics) - accumulator.model ??= - extractString(metadata?.generation_model) ?? extractString(metrics?.generation_model) - accumulator.totalTokens += devinStepTokenTotal(metadata, metrics) - const isUser = metadata?.is_user_input === true - if (isUser) { - accumulator.messageCount++ - const text = - extractDevinStepText(stepRecord) ?? - extractContentText(stepRecord.content) ?? - extractString(stepRecord.text) - const titleCandidate = normalizeTitleText(text ?? '') - if (titleCandidate) { - accumulator.title ??= titleCandidate - } - addPreviewContent(accumulator, 'user', text ?? stepRecord.content) - } else if (extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) { - accumulator.messageCount++ - addPreviewContent( - accumulator, - 'assistant', - extractDevinStepText(stepRecord) ?? stepRecord.content - ) - } + for (const step of arrayValue(record.steps)) { + consumeDevinSessionStep(accumulator, step) } return finalizeSession(accumulator, platform, options) } @@ -147,3 +118,71 @@ function numberFromDevinMetadata( } return 0 } + +export function consumeDevinSessionStep(accumulator: SessionAccumulator, step: unknown): void { + const stepRecord = asRecord(step) + if (!stepRecord) { + return + } + const metadata = asRecord(stepRecord.metadata) + updateTimeline(accumulator, extractString(metadata?.created_at)) + const metrics = asRecord(metadata?.metrics) + accumulator.model ??= + extractString(metadata?.generation_model) ?? extractString(metrics?.generation_model) + accumulator.totalTokens += devinStepTokenTotal(metadata, metrics) + const isUser = metadata?.is_user_input === true + if (isUser) { + accumulator.messageCount++ + const text = + extractDevinStepText(stepRecord) ?? + extractContentText(stepRecord.content) ?? + extractString(stepRecord.text) + const titleCandidate = normalizeTitleText(text ?? '') + if (titleCandidate) { + accumulator.title ??= titleCandidate + } + addPreviewContent(accumulator, 'user', text ?? stepRecord.content) + } else if (extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) { + accumulator.messageCount++ + addPreviewContent( + accumulator, + 'assistant', + extractDevinStepText(stepRecord) ?? stepRecord.content + ) + } +} + +export async function parseDevinSessionDocument( + file: FileWithMtime, + bytes: AsyncIterable, + platform: NodeJS.Platform, + options: ParserSessionOptions, + signal?: AbortSignal +): Promise { + const parsed = await readStreamedSessionDocument({ + bytes, + arrayKey: 'steps', + fields: ['session_id', 'sessionId', 'generation_model', 'working_directory'], + objectFields: { agent: ['model_name', 'model'] }, + create: () => + createAccumulator({ agent: 'devin', file, sessionId: sessionIdFromFileName(file.path) }), + consume: consumeDevinSessionStep, + signal + }) + if (!parsed) { + return null + } + const { record, state: accumulator } = parsed + accumulator.sessionId = + extractString(record.session_id) ?? + extractString(record.sessionId) ?? + sessionIdFromFileName(file.path) + const agentRecord = asRecord(record.agent) + accumulator.model = + extractString(agentRecord?.model_name) ?? + extractString(agentRecord?.model) ?? + extractString(record.generation_model) ?? + accumulator.model + accumulator.cwd = extractString(record.working_directory) + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index 748a8a6bcc94..7865ecc18276 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -50,7 +53,7 @@ export async function parseDroidSessionFile( export async function parseDroidSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index efc9eef9e10f..a1d7e259f797 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,3 +1,4 @@ +import { readStreamedSessionDocument } from './session-document-stream' import { remoteSessionContentLines } from './remote-session-content-lines' import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' @@ -135,7 +136,7 @@ export function createGeminiJsonlSessionResumeState( ) } -async function parseGeminiJsonlSessionLines(args: { +export async function parseGeminiJsonlSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform @@ -173,3 +174,29 @@ export function consumeGeminiMessage( accumulator.totalTokens += tokenTotal(record.tokens) } } + +export async function parseGeminiSessionDocument( + file: FileWithMtime, + bytes: AsyncIterable, + platform: NodeJS.Platform, + options: ResumableParseFinalizeOptions, + signal?: AbortSignal +): Promise { + const parsed = await readStreamedSessionDocument({ + bytes, + arrayKey: 'messages', + fields: ['sessionId', 'startTime', 'lastUpdated'], + create: () => + createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }), + consume: (state, value) => consumeGeminiMessage(state, asRecord(value)), + signal + }) + if (!parsed) { + return null + } + const { record, state: accumulator } = parsed + accumulator.sessionId = extractString(record.sessionId) ?? sessionIdFromFileName(file.path) + updateTimeline(accumulator, extractString(record.startTime)) + updateTimeline(accumulator, extractString(record.lastUpdated)) + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 581e885fd966..3ca4e7541064 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' @@ -195,7 +198,7 @@ export async function parseMessageGraphSessionFile( export async function parseMessageGraphSessionContent( agent: MessageGraphAgent, file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-hermes-parser.ts b/src/main/ai-vault/session-scanner-hermes-parser.ts index f532fa8241db..784c53f04084 100644 --- a/src/main/ai-vault/session-scanner-hermes-parser.ts +++ b/src/main/ai-vault/session-scanner-hermes-parser.ts @@ -1,7 +1,8 @@ +import { readStreamedSessionDocument } from './session-document-stream' import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewContent, @@ -69,16 +70,54 @@ async function parseHermesSessionRecord( updateTimeline(accumulator, extractString(record.session_start)) updateTimeline(accumulator, extractString(record.last_updated)) for (const message of arrayValue(record.messages)) { - const messageRecord = asRecord(message) - const role = extractString(messageRecord?.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= extractContentText(messageRecord?.content) - } - addPreviewContent(accumulator, role, messageRecord?.content) + consumeHermesSessionMessage(accumulator, message) + } + if (accumulator.messageCount === 0) { + accumulator.messageCount = numberValue(record.message_count) + } + return finalizeSession(accumulator, platform, options) +} + +export function consumeHermesSessionMessage( + accumulator: SessionAccumulator, + message: unknown +): void { + const messageRecord = asRecord(message) + const role = extractString(messageRecord?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractContentText(messageRecord?.content) } + addPreviewContent(accumulator, role, messageRecord?.content) + } +} + +export async function parseHermesSessionDocument( + file: FileWithMtime, + bytes: AsyncIterable, + platform: NodeJS.Platform, + options: ParserSessionOptions, + signal?: AbortSignal +): Promise { + const parsed = await readStreamedSessionDocument({ + bytes, + arrayKey: 'messages', + fields: ['session_id', 'model', 'cwd', 'session_start', 'last_updated', 'message_count'], + create: () => + createAccumulator({ agent: 'hermes', file, sessionId: sessionIdFromFileName(file.path) }), + consume: consumeHermesSessionMessage, + signal + }) + if (!parsed) { + return null } + const { record, state: accumulator } = parsed + accumulator.sessionId = extractString(record.session_id) ?? sessionIdFromFileName(file.path) + accumulator.model = extractString(record.model) + accumulator.cwd = extractString(record.cwd) + updateTimeline(accumulator, extractString(record.session_start)) + updateTimeline(accumulator, extractString(record.last_updated)) if (accumulator.messageCount === 0) { accumulator.messageCount = numberValue(record.message_count) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 62149920b5a2..acd556b5504f 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -229,7 +232,7 @@ export async function parseClaudeSessionFile( export async function parseClaudeSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index 5396ecd076ab..4a58b4e180fe 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -13,27 +13,74 @@ export async function decodeTranscriptStream( includeTrailingLine: boolean ): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { const messages: NativeChatMessage[] = [] - // Why: a Buffer chunk can end mid-codepoint, and decoding it standalone would - // both corrupt the line and shift `consumedBytes` (which seeds fallback ids). - const decoder = new StringDecoder('utf8') - let pending: string[] = [] let consumedBytes = 0 + const framer = createTranscriptLineFramer((line, byteLength, terminated) => { + if (terminated || includeTrailingLine) { + decodeLine(line, consumedBytes) + consumedBytes += byteLength + } + }) + for await (const chunk of stream) { + framer.write(chunk) + } + framer.end() + + return { messages, consumedBytes } + + function decodeLine(rawLine: string, relativeOffset: number): void { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) + if (message) { + messages.push(message) + } + } +} + +type TranscriptLine = { line: string; byteLength: number; terminated: boolean } +export async function* splitTranscriptStreamLines( + stream: AsyncIterable +): AsyncGenerator { + let records: TranscriptLine[] = [] + const framer = createTranscriptLineFramer((line, byteLength, terminated) => { + records.push({ line, byteLength, terminated }) + }) for await (const chunk of stream) { - const text = typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk)) - // Only the new chunk is scanned; partial records wait in `pending` unrescanned. + framer.write(chunk) + for (const record of records) { + yield record + } + records = [] + } + framer.end() + for (const record of records) { + yield record + } +} + +/** Frame chunks synchronously so native decoding avoids a promise per record. */ +function createTranscriptLineFramer( + emit: (line: string, byteLength: number, terminated: boolean) => void +): { write(chunk: Buffer | string): void; end(): void } { + const decoder = new StringDecoder('utf8') + let pending: string[] = [] + return { write, end } + + function write(chunk: Buffer | string): void { + const text = typeof chunk === 'string' ? chunk : decoder.write(chunk) let lineStart = 0 let newlineIndex = text.indexOf('\n') while (newlineIndex !== -1) { let segment = text.slice(lineStart, newlineIndex + 1) if (pending.length > 0) { - // Join a fragmented record only once, including split string surrogate pairs. pending.push(segment) segment = pending.join('') pending = [] } - decodeLine(segment.slice(0, -1), consumedBytes) - consumedBytes += Buffer.byteLength(segment, 'utf8') + emit(segment.slice(0, -1), Buffer.byteLength(segment, 'utf8'), true) lineStart = newlineIndex + 1 newlineIndex = text.indexOf('\n', lineStart) } @@ -41,27 +88,14 @@ export async function decodeTranscriptStream( pending.push(text.slice(lineStart)) } } - const tail = decoder.end() - if (tail) { - pending.push(tail) - } - if (includeTrailingLine && pending.length > 0) { - const line = pending.join('') - decodeLine(line, consumedBytes) - consumedBytes += Buffer.byteLength(line, 'utf8') - } - - return { messages, consumedBytes } - - function decodeLine(rawLine: string, relativeOffset: number): void { - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine - if (!line) { - return - } - const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) - if (message) { - messages.push(message) + function end(): void { + const tail = decoder.end() + if (tail) { + pending.push(tail) } + const line = pending.join('') + emit(line, Buffer.byteLength(line, 'utf8'), false) + pending = [] } } diff --git a/src/relay/ai-vault-service-filesystem.ts b/src/relay/ai-vault-service-filesystem.ts index 9315b2e4b2f9..fd5ff90a755e 100644 --- a/src/relay/ai-vault-service-filesystem.ts +++ b/src/relay/ai-vault-service-filesystem.ts @@ -1,3 +1,4 @@ +import { readRelayTranscriptBytes } from './ai-vault-transcript-stream' import { lstat, readdir } from 'node:fs/promises' import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' import { readRelayFileContent } from './fs-handler-file-read' @@ -13,6 +14,7 @@ export function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemP })) }, readFile: readRelayFileContent, + readTranscriptBytes: readRelayTranscriptBytes, async stat(filePath) { const stats = await lstat(filePath) return { diff --git a/src/relay/ai-vault-transcript-stream.ts b/src/relay/ai-vault-transcript-stream.ts new file mode 100644 index 000000000000..df040a166faa --- /dev/null +++ b/src/relay/ai-vault-transcript-stream.ts @@ -0,0 +1,34 @@ +import { open } from 'node:fs/promises' +import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation' +import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines' +import { BINARY_PROBE_BYTES, isBinaryBuffer } from './fs-handler-utils' + +/** The same open handle supplies the probe and stream, including across renames. */ +export async function* readRelayTranscriptBytes( + path: string, + signal?: AbortSignal +): AsyncGenerator { + throwIfAiVaultScanCancelled(signal) + const handle = await open(path, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + if (isBinaryBuffer(probe.subarray(0, bytesRead))) { + throw new BinarySessionTranscriptError() + } + const input = handle.createReadStream({ start: 0, autoClose: false, signal }) + try { + for await (const chunk of input) { + throwIfAiVaultScanCancelled(signal) + if (!Buffer.isBuffer(chunk)) { + throw new TypeError('Expected transcript byte buffer') + } + yield chunk + } + } finally { + input.destroy() + } + } finally { + await handle.close() + } +} From e944e7653787b9a70c095437374eb71dc90363dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:55:41 -0700 Subject: [PATCH 010/209] fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude (#20507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude Grok's hook discovery reads ~/.claude/settings.json (and the Cursor equivalent) for vendor compatibility, and that is on by default. So inside every Grok pane Orca's managed Claude hook fires in addition to Orca's managed Grok hook, and both POST the same Grok envelope. The Claude-routed copy lands last and wins, so the pane's agent type is resolved from the POST route as "claude" and no Grok-specific normalization runs for it. Guard the managed Claude and Cursor scripts on GROK_HOOK_EVENT, which Grok's hook runner stamps into every hook subprocess it spawns — including replayed vendor configs — after any user-supplied environment, so a hook cannot spoof it. This mirrors the existing DEVIN_PROJECT_DIR guard in the same script, which solves the identical problem for another agent that imports Claude hooks. Placement is load-bearing: the guard sits after the stdin capture, so Grok's writer never blocks, and before both the spool write and the HTTP POST, so a replayed event cannot leave a spool entry that replays later. The Windows variants jump to the stdin-drain label rather than exiting, because abandoning stdin there hangs the writer. The guard is scoped to agent === 'claude'; OpenClaude reuses ClaudeHookService with its own settings file, which Grok does not replay, so it is unaffected. Verified live against Grok 1.0.25 in a dev instance: the pane's reported agent type goes from "claude" to "grok" on every turn-end, including the hidden follow-up turns Grok runs when background work finishes. The guard pushed hook-service.ts past the 300-line cap, so the script builder moves to a sibling hook-script.ts. That mirrors the existing split under src/main/cursor/, where the service owns install/status and the script module owns script text. * fix(agent-hooks): preserve Windows background worker stdin contract --- config/tsconfig.cli.json | 2 + .../agent-hooks/grok-replay-guard.test.ts | 136 ++++++++++++++++++ src/main/agent-hooks/grok-replay-guard.ts | 14 ++ src/main/claude/hook-script.ts | 93 ++++++++++++ src/main/claude/hook-service.test.ts | 5 + src/main/claude/hook-service.ts | 103 +++---------- src/main/cursor/hook-script.ts | 6 + src/main/cursor/hook-service.test.ts | 1 + 8 files changed, 274 insertions(+), 86 deletions(-) create mode 100644 src/main/agent-hooks/grok-replay-guard.test.ts create mode 100644 src/main/agent-hooks/grok-replay-guard.ts create mode 100644 src/main/claude/hook-script.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 70d52e0802c8..9cf3779c03cf 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -4,6 +4,8 @@ "../src/cli/**/*", "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", + "../src/main/agent-hooks/grok-replay-guard.ts", + "../src/main/claude/hook-script.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", diff --git a/src/main/agent-hooks/grok-replay-guard.test.ts b/src/main/agent-hooks/grok-replay-guard.test.ts new file mode 100644 index 000000000000..1da54f287d29 --- /dev/null +++ b/src/main/agent-hooks/grok-replay-guard.test.ts @@ -0,0 +1,136 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/userData' + } +})) + +import { getManagedScript as getClaudeManagedScript } from '../claude/hook-service' +import { getManagedScript as getCursorManagedScript } from '../cursor/hook-script' + +const POSIX_GROK_GUARD = 'if [ -n "$GROK_HOOK_EVENT" ]; then' +const WINDOWS_GROK_GUARD = 'if not "%GROK_HOOK_EVENT%"=="" goto :orca_agent_hook_drain_stdin' +const CLAUDE_SCRIPT_OPTIONS = { + skipWhenDevinImportsClaude: true, + skipWhenGrokImportsClaude: true +} + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', descriptor) + } +} + +function expectGuardBeforeTransport( + script: string, + guard: string, + response: string, + spool?: string +): void { + const guardIndex = script.indexOf(guard) + expect(guardIndex).toBeGreaterThan(script.indexOf(response)) + expect(guardIndex).toBeLessThan(script.indexOf('curl')) + if (spool) { + expect(guardIndex).toBeLessThan(script.indexOf(spool)) + } +} + +function runPosixHook( + script: string, + grokHookEvent: string +): { + curlCalled: boolean + stdout: string +} { + const dir = mkdtempSync(join(tmpdir(), 'orca-grok-replay-')) + const scriptPath = join(dir, 'hook.sh') + const curlPath = join(dir, 'curl') + const curlLog = join(dir, 'curl.log') + try { + writeFileSync(scriptPath, script) + writeFileSync( + curlPath, + '#!/bin/sh\n{ command -p cat 2>/dev/null || cat; } >/dev/null\nprintf "called\\n" >> "$CURL_LOG"\n' + ) + chmodSync(scriptPath, 0o755) + chmodSync(curlPath, 0o755) + + const result = spawnSync('/bin/sh', [scriptPath], { + encoding: 'utf8', + input: '{"hook_event_name":"Stop"}', + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH ?? ''}`, + CURL_LOG: curlLog, + GROK_HOOK_EVENT: grokHookEvent, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '1234', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + } + }) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + return { curlCalled: existsSync(curlLog), stdout: result.stdout } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +describe('Grok vendor hook replay guard', () => { + it('precedes spooling and HTTP in the generated POSIX Claude and Cursor scripts', () => { + const claude = getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS) + const cursor = getCursorManagedScript('posix') + + expectGuardBeforeTransport(claude, POSIX_GROK_GUARD, 'printf "{}\\n"', 'spool_hook_event') + expectGuardBeforeTransport(cursor, POSIX_GROK_GUARD, 'printf "{}\\n"', 'spool_hook_event') + }) + + it('precedes HTTP while preserving fail-open output in generated Windows scripts', () => { + const { claude, cursor } = withPlatform('win32', () => ({ + claude: getClaudeManagedScript('local', CLAUDE_SCRIPT_OPTIONS), + cursor: getCursorManagedScript('local') + })) + + expectGuardBeforeTransport(claude, WINDOWS_GROK_GUARD, 'echo {}') + expectGuardBeforeTransport(cursor, WINDOWS_GROK_GUARD, '(echo {})') + const backgroundWorkerGuardIndex = claude.indexOf('CLAUDE_JOB_DIR') + expect(backgroundWorkerGuardIndex).toBeGreaterThan(-1) + expect(backgroundWorkerGuardIndex).toBeLessThan(claude.indexOf(WINDOWS_GROK_GUARD)) + }) + + it.skipIf(process.platform === 'win32')( + 'drops Grok-replayed hooks without suppressing their protocol response', + () => { + for (const script of [ + getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS), + getCursorManagedScript('posix') + ]) { + const result = runPosixHook(script, 'Stop') + expect(result.curlCalled).toBe(false) + expect(result.stdout).toBe('{}\n') + } + } + ) + + it.skipIf(process.platform === 'win32')('leaves non-Grok hook delivery unchanged', () => { + for (const script of [ + getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS), + getCursorManagedScript('posix') + ]) { + const result = runPosixHook(script, '') + expect(result.curlCalled).toBe(true) + expect(result.stdout).toBe('{}\n') + } + }) +}) diff --git a/src/main/agent-hooks/grok-replay-guard.ts b/src/main/agent-hooks/grok-replay-guard.ts new file mode 100644 index 000000000000..6309ed6e5847 --- /dev/null +++ b/src/main/agent-hooks/grok-replay-guard.ts @@ -0,0 +1,14 @@ +import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from './hook-stdin-contract' + +export function buildPosixGrokReplayGuardLines(): string[] { + return [ + // Why: Grok imports vendor hooks; only its native hook may report the event as Grok. + 'if [ -n "$GROK_HOOK_EVENT" ]; then', + ' exit 0', + 'fi' + ] +} + +export function buildWindowsGrokReplayGuardLines(): string[] { + return [`if not "%GROK_HOOK_EVENT%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`] +} diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts new file mode 100644 index 000000000000..efbe71fc9e36 --- /dev/null +++ b/src/main/claude/hook-script.ts @@ -0,0 +1,93 @@ +/** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets. + * Split from hook-service.ts so the service owns install/status and this owns script text, + * mirroring the same split under src/main/cursor/. */ +import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + buildPosixGrokReplayGuardLines, + buildWindowsGrokReplayGuardLines +} from '../agent-hooks/grok-replay-guard' +import { + WINDOWS_HOOK_STDIN_DRAIN_LABEL, + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' + +export function getManagedScript( + target: 'local' | 'posix' = 'local', + options: { + skipWhenDevinImportsClaude?: boolean + skipWhenGrokImportsClaude?: boolean + } = {} +): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'echo {}', + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, + // and outside an Orca pane the caller can abandon stdin, so more.com never returns. + ...buildWindowsHookEnvironmentGuardLines(), + // Why: a backgrounded session runs in a daemon worker that inherited the dispatching + // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). + // Why exit, not the drain label: the drain parks in more.com and a worker is outside + // an Orca pane — the abandoned-stdin hang #11549 guards against. + 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0', + ...(options.skipWhenGrokImportsClaude ? buildWindowsGrokReplayGuardLines() : []), + ...(options.skipWhenDevinImportsClaude + ? [ + // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. + `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` + ] + : []), + // Why: use curl.exe to avoid an extra PowerShell startup per hook. + buildWindowsAgentHookCurlPostCommand('claude'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'printf "{}\\n"', + ...buildPosixHookPayloadCapture(), + ...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []), + ...buildPosixHookSpoolLines('claude'), + ...(options.skipWhenDevinImportsClaude + ? [ + // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. + 'if [ -n "$DEVIN_PROJECT_DIR" ]; then', + ' exit 0', + 'fi' + ] + : []), + // Why: a backgrounded session runs in a daemon worker that inherited the dispatching + // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). + 'if [ -n "$CLAUDE_JOB_DIR" ]; then', + ' exit 0', + 'fi', + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. + // Why: suppress parse errors so they neither leak nor trip outer set -e. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' unset ORCA_AGENT_HOOK_TRANSPORT', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. + ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index a4e48c98120b..2a07aff4c8ae 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -262,6 +262,7 @@ describe('ClaudeHookService.install', () => { 'utf-8' ) expect(managedScript).toContain('DEVIN_PROJECT_DIR') + expect(managedScript).toContain('GROK_HOOK_EVENT') // Why: guard and Devin-skip paths must still return neutral JSON (#14818). expect(managedScript).toMatch( process.platform === 'win32' @@ -711,6 +712,7 @@ describe('ClaudeHookService.installRemote', () => { const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') expect(script).toContain('#!/bin/sh') expect(script).toContain('DEVIN_PROJECT_DIR') + expect(script).toContain('GROK_HOOK_EVENT') // Why: remote guard paths must still return neutral JSON (#14818). expect(script!.indexOf('printf "{}\\n"')).toBe( script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length @@ -813,6 +815,9 @@ describe('OpenClaudeHookService-compatible install', () => { expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).not.toContain('DEVIN_PROJECT_DIR') + expect( + readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') + ).not.toContain('GROK_HOOK_EVENT') // Why: the statusline usage feed is Claude-only; OpenClaude installs must not set statusLine. expect(parsed.statusLine).toBeUndefined() expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index b3ae8d011362..571531aa2d9e 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -3,26 +3,20 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildManagedCommandHook, - buildWindowsAgentHookCurlPostCommand, readHooksJson, writeHooksJson, - writeManagedScript, - type HooksConfig + type HooksConfig, + writeManagedScript } from '../agent-hooks/installer-utils' -import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { readHooksJsonRemote, writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' -import { - buildPosixHookPayloadCapture, - buildPosixHookSpoolLines, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue, - WINDOWS_HOOK_STDIN_DRAIN_LABEL -} from '../agent-hooks/hook-stdin-contract' +import { getManagedScript } from './hook-script' + +export { getManagedScript } import { getManagedStatusLineScript } from './statusline-script' import { applyManagedHooks, @@ -59,78 +53,6 @@ const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { settings: CLAUDE_HOOK_SETTINGS } -function getManagedScript( - target: 'local' | 'posix' = 'local', - options: { skipWhenDevinImportsClaude?: boolean } = {} -): string { - if (target === 'local' && process.platform === 'win32') { - return [ - '@echo off', - 'setlocal', - // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). - 'echo {}', - // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, - // and outside an Orca pane the caller can abandon stdin, so more.com never returns. - ...buildWindowsHookEnvironmentGuardLines(), - // Why: a backgrounded session runs in a daemon worker that inherited the dispatching - // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). - // Why exit, not the drain label: the drain parks in more.com and a worker is outside - // an Orca pane — the abandoned-stdin hang #11549 guards against. - 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0', - ...(options.skipWhenDevinImportsClaude - ? [ - // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. - `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` - ] - : []), - // Why: use curl.exe to avoid an extra PowerShell startup per hook. - buildWindowsAgentHookCurlPostCommand('claude'), - 'exit /b 0', - ...buildWindowsHookStdinDrainEpilogue(), - '' - ].join('\r\n') - } - - return [ - '#!/bin/sh', - // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). - 'printf "{}\\n"', - ...buildPosixHookPayloadCapture(), - ...buildPosixHookSpoolLines('claude'), - ...(options.skipWhenDevinImportsClaude - ? [ - // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. - 'if [ -n "$DEVIN_PROJECT_DIR" ]; then', - ' exit 0', - 'fi' - ] - : []), - // Why: a backgrounded session runs in a daemon worker that inherited the dispatching - // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). - 'if [ -n "$CLAUDE_JOB_DIR" ]; then', - ' exit 0', - 'fi', - // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. - // Why: suppress parse errors so they neither leak nor trip outer set -e. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' unset ORCA_AGENT_HOOK_TRANSPORT', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' spool_hook_event', - ' exit 0', - 'fi', - // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. - ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => - index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line - ), - 'exit 0', - '' - ].join('\n') -} - export class ClaudeHookService { private readonly options: ClaudeHookServiceOptions @@ -188,7 +110,10 @@ export class ClaudeHookService { async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent( getManagedScriptPath(this.options.settings), - getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + getManagedScript('local', { + skipWhenDevinImportsClaude: this.options.agent === 'claude', + skipWhenGrokImportsClaude: this.options.agent === 'claude' + }) ) // Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate. await refreshManagedScriptIfPresent( @@ -219,7 +144,10 @@ export class ClaudeHookService { ) writeManagedScript( scriptPath, - getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + getManagedScript('local', { + skipWhenDevinImportsClaude: this.options.agent === 'claude', + skipWhenGrokImportsClaude: this.options.agent === 'claude' + }) ) // Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. if (this.options.agent === 'claude') { @@ -281,7 +209,10 @@ export class ClaudeHookService { await writeManagedScriptRemote( sftp, remoteScriptPath, - getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + getManagedScript('posix', { + skipWhenDevinImportsClaude: this.options.agent === 'claude', + skipWhenGrokImportsClaude: this.options.agent === 'claude' + }) ) // Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook // listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different diff --git a/src/main/cursor/hook-script.ts b/src/main/cursor/hook-script.ts index 945633371313..739026aa6a84 100644 --- a/src/main/cursor/hook-script.ts +++ b/src/main/cursor/hook-script.ts @@ -9,6 +9,10 @@ import { buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixGrokReplayGuardLines, + buildWindowsGrokReplayGuardLines +} from '../agent-hooks/grok-replay-guard' import { getCursorHookResponse, type CursorEvent } from './hook-events' const CURSOR_HOOK_RESPONSE_ENV = 'ORCA_CURSOR_HOOK_RESPONSE' @@ -43,6 +47,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: source current endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), + ...buildWindowsGrokReplayGuardLines(), buildWindowsAgentHookPostCommand('cursor'), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), @@ -59,6 +64,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' printf "{}\\n"', 'fi', ...buildPosixHookPayloadCapture(), + ...buildPosixGrokReplayGuardLines(), ...buildPosixHookSpoolLines('cursor'), // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index f5f25295a60a..3f93e91f7fe2 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -131,6 +131,7 @@ describe('CursorHookService', () => { 'utf8' ) expect(script).toContain('/hook/cursor') + expect(script).toContain('GROK_HOOK_EVENT') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') } else { From a28cd9eae5eff34a16a58626e5bbbd36e1c59596 Mon Sep 17 00:00:00 2001 From: Jared Meek <13214123+atreidesend@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:58:57 +0200 Subject: [PATCH 011/209] fix(browser): press keys through a US-layout CDP key table instead of a subprocess per keystroke (#15310) Typing in the remote browser pane spawned an agent-browser process per keystroke -- ~160ms each, so a 17-character password took seconds -- and some keys arrived half-formed: F-keys, Insert and ContextMenu dispatched windowsVirtualKeyCode 0, Shift+1 typed '1' instead of '!', and non-ASCII printables reported success while typing nothing at all. keypress now resolves the key name through a US-layout table and dispatches the Input.dispatchKeyEvent pair over the electron debugger, the same transport mouseClick already uses. Two fallbacks keep the old behavior reachable: - a single printable BMP character outside the table dispatches in process as an IME-style event (keyCode 229 with the character as text, the shape composed input already has when it reaches pages) - anything else -- media keys, surrogate pairs, unrecognized names -- goes to the helper exactly as before, and only that path pays for creating the helper session Virtual key codes come from the table, never from the character's own char code: charCodeAt puts '&' on 38 (VK_UP) and '.' on 46 (VK_DELETE), which Blink runs as caret commands that swallow the character. Dispatch failures normalize the way evaluate's already do -- a gone page becomes browser_tab_not_found, anything else browser_error -- because attach and sendCommand reject with plain Errors that the RPC layer would report as runtime_error, and the pane only reclaims a dead page when it sees a browser_* code. Result shape is unchanged and no wire, schema or RPC surface moves, so mixed-version client/server pairs see no difference. Pages can observe the fidelity fixes: Shift+a now types 'A', Shift+1 now types '!', Alt+ no longer carries text, and editing keys arrive as rawKeyDown. Each matches what a real US keyboard produces. Verified against the shipped agent-browser 0.27 binary on the same browser: every difference is a fix, nothing regressed. macOS editing shortcuts (Cmd+A) do not fire through either path -- Blink runs those off the native responder chain and neither sends CDP `commands` -- so that gap is unchanged, not introduced. Co-authored-by: Neil Co-authored-by: Claude Fable 5 --- ...ent-browser-bridge-interaction-commands.ts | 69 ++++- ...gent-browser-bridge-keypress-input.test.ts | 292 ++++++++++++++++++ .../browser/cdp-keyboard-us-layout.test.ts | 133 ++++++++ src/main/browser/cdp-keyboard-us-layout.ts | 251 +++++++++++++++ 4 files changed, 742 insertions(+), 3 deletions(-) create mode 100644 src/main/browser/agent-browser-bridge-keypress-input.test.ts create mode 100644 src/main/browser/cdp-keyboard-us-layout.test.ts create mode 100644 src/main/browser/cdp-keyboard-us-layout.ts diff --git a/src/main/browser/agent-browser-bridge-interaction-commands.ts b/src/main/browser/agent-browser-bridge-interaction-commands.ts index 51854878c0b5..97fa3892ab9e 100644 --- a/src/main/browser/agent-browser-bridge-interaction-commands.ts +++ b/src/main/browser/agent-browser-bridge-interaction-commands.ts @@ -12,6 +12,8 @@ import type { } from '../../shared/runtime-types' import { BrowserError } from './cdp-bridge' import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { parseCdpKeyEvent, imeFallbackKeyEvent } from './cdp-keyboard-us-layout' import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands' export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands { @@ -170,9 +172,70 @@ export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowser worktreeId?: string, browserPageId?: string ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult - }) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName, target) => { + const parsed = parseCdpKeyEvent(key) ?? imeFallbackKeyEvent(key) + if (!parsed) { + // Why: a key name the table cannot express must not dispatch keyCode 0 and + // report success — route it to the helper, creating its session only now so + // the direct path never pays for it. + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult + } + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${target.browserPageId} is no longer available` + ) + } + const event = { + windowsVirtualKeyCode: parsed.keyCode, + nativeVirtualKeyCode: parsed.keyCode, + key: parsed.key, + code: parsed.code, + modifiers: parsed.modifiers, + location: parsed.location + } + let releaseDebugger = (): void => {} + try { + releaseDebugger = acquireElectronDebugger(wc).release + await wc.debugger.sendCommand('Input.dispatchKeyEvent', { + // Why: rawKeyDown is the no-character form; sending keyDown without text + // makes Blink synthesize an empty input for editing keys. + type: parsed.text === null ? 'rawKeyDown' : 'keyDown', + ...event, + ...(parsed.text === null ? {} : { text: parsed.text, unmodifiedText: parsed.text }) + }) + await wc.debugger.sendCommand('Input.dispatchKeyEvent', { + type: 'keyUp', + ...event, + // Why: the self bit is keydown-only -- Blink reports shiftKey false on the Shift keyup. + modifiers: parsed.modifiers & ~parsed.selfModifier + }) + return { pressed: key } + } catch (error) { + // Why: attach/dispatch reject with plain Errors, which the RPC layer would report as + // runtime_error — the helper path this replaced always produced a browser_* code, and + // the pane only reclaims a dead page when it sees one. + if (error instanceof BrowserError) { + throw error + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError(sessionName) + } + throw new BrowserError( + 'browser_error', + `Failed to press ${key} in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + releaseDebugger() + } + }, + { ensureSession: false } + ) } async pdf(worktreeId?: string, browserPageId?: string): Promise { diff --git a/src/main/browser/agent-browser-bridge-keypress-input.test.ts b/src/main/browser/agent-browser-bridge-keypress-input.test.ts new file mode 100644 index 000000000000..3be23f31c768 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-keypress-input.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } = + vi.hoisted(() => { + const stdinWrites: string[] = [] + return { + execFileMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + existsSyncMock: vi.fn(() => false), + readFileSyncMock: vi.fn(() => Buffer.from('')), + stdinWrites + } + }) + +vi.mock('child_process', () => ({ execFile: execFileMock })) +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + accessSync: vi.fn(), + chmodSync: vi.fn(), + constants: { X_OK: 1 } +})) +vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' })) +vi.mock('electron', () => { + return { + app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false }, + webContents: { fromId: webContentsFromIdMock } + } +}) +const { CdpWsProxyMock } = vi.hoisted(() => { + const instances: unknown[] = [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const MockClass = vi.fn().mockImplementation(function (this: any, _wc: unknown) { + this._wc = _wc + this.start = vi.fn(async () => 'ws://127.0.0.1:9222') + this.stop = vi.fn(async () => {}) + this.getPort = vi.fn(() => 9222) + instances.push(this) + }) + return { CdpWsProxyMock: Object.assign(MockClass, { instances }) } +}) + +vi.mock('./cdp-ws-proxy', () => ({ + CdpWsProxy: CdpWsProxyMock +})) + +import { AgentBrowserBridge } from './agent-browser-bridge' +import { + createSucceedWith, + mockBrowserManager, + mockWebContents, + overrideBridgeWebContentsLookup, + resetAgentBrowserBridgeMocks, + type MockWebContents +} from './agent-browser-bridge-test-harness' + +overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock) + +const succeedWith = createSucceedWith(execFileMock, stdinWrites) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') +} + +function keyEventCalls(wc: MockWebContents): Record[] { + return wc.debugger.sendCommand.mock.calls + .filter(([method]) => method === 'Input.dispatchKeyEvent') + .map(([, params]) => params) + .filter(isRecord) +} + +describe('AgentBrowserBridge keypress input', () => { + let bridge: AgentBrowserBridge + let wc: MockWebContents + + beforeEach(() => { + resetAgentBrowserBridgeMocks({ + webContentsFromIdMock, + existsSyncMock, + readFileSyncMock, + stdinWrites, + cdpWsProxyInstances: CdpWsProxyMock.instances + }) + bridge = new AgentBrowserBridge(mockBrowserManager()) + bridge.setActiveTab(100) + wc = mockWebContents(100) + wc.debugger.sendCommand.mockResolvedValue({}) + webContentsFromIdMock.mockImplementation((id: number) => (id === 100 ? wc : null)) + }) + + it('dispatches a printable key over CDP without spawning agent-browser', async () => { + await expect(bridge.keypress('a', undefined, 'tab-1')).resolves.toEqual({ pressed: 'a' }) + + expect(execFileMock).not.toHaveBeenCalled() + expect(CdpWsProxyMock.instances).toHaveLength(0) + // Why: exactly two CDP calls, so the dispatch pair is the whole interaction. + expect(wc.debugger.sendCommand.mock.calls).toHaveLength(2) + expect(keyEventCalls(wc)).toEqual([ + { + type: 'keyDown', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + key: 'a', + code: 'KeyA', + modifiers: 0, + location: 0, + text: 'a', + unmodifiedText: 'a' + }, + { + type: 'keyUp', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + key: 'a', + code: 'KeyA', + modifiers: 0, + location: 0 + } + ]) + }) + + it('types & as shifted 7 instead of colliding with the ArrowUp virtual key code', async () => { + await bridge.keypress('&', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'keyDown', + windowsVirtualKeyCode: 55, + modifiers: 8, + text: '&' + }) + }) + + it('dispatches editing and navigation keys as rawKeyDown with no text', async () => { + await bridge.keypress('ArrowDown', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'rawKeyDown', + windowsVirtualKeyCode: 40, + key: 'ArrowDown' + }) + expect(keyEventCalls(wc)[0]).not.toHaveProperty('text') + }) + + it('carries modifier masks for shortcuts', async () => { + await expect(bridge.keypress('Ctrl+Shift+K', undefined, 'tab-1')).resolves.toEqual({ + pressed: 'Ctrl+Shift+K' + }) + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'rawKeyDown', + windowsVirtualKeyCode: 75, + modifiers: 10 + }) + }) + + it('reports the modifier bit on a bare Shift keydown but not on its keyup', async () => { + await bridge.keypress('Shift', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'rawKeyDown', + windowsVirtualKeyCode: 16, + code: 'ShiftLeft', + modifiers: 8, + location: 1 + }) + expect(keyEventCalls(wc)[1]).toMatchObject({ type: 'keyUp', modifiers: 0, location: 1 }) + }) + + it('keeps held modifiers on the keyup of a non-modifier shortcut key', async () => { + await bridge.keypress('Ctrl+Shift+K', undefined, 'tab-1') + + expect(keyEventCalls(wc)[1]).toMatchObject({ type: 'keyUp', modifiers: 10 }) + }) + + it('presses Enter with its carriage-return text so fields submit', async () => { + await bridge.keypress('Enter', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'keyDown', + windowsVirtualKeyCode: 13, + text: '\r' + }) + }) + + it('dispatches a non-US printable character as an IME-style event in process', async () => { + await expect(bridge.keypress('é', undefined, 'tab-1')).resolves.toEqual({ pressed: 'é' }) + + expect(execFileMock).not.toHaveBeenCalled() + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'keyDown', + windowsVirtualKeyCode: 229, + key: 'é', + code: '', + text: 'é', + unmodifiedText: 'é' + }) + expect(keyEventCalls(wc)[1]).toMatchObject({ type: 'keyUp', windowsVirtualKeyCode: 229 }) + }) + + it('keeps the helper for a surrogate-pair character', async () => { + succeedWith({ pressed: '👍' }) + + await expect(bridge.keypress('👍', undefined, 'tab-1')).resolves.toEqual({ pressed: '👍' }) + + expect(keyEventCalls(wc)).toHaveLength(0) + }) + + it('falls back to agent-browser for a key name the table cannot express', async () => { + succeedWith({ pressed: 'MediaPlayPause' }) + + await expect(bridge.keypress('MediaPlayPause', undefined, 'tab-1')).resolves.toEqual({ + pressed: 'MediaPlayPause' + }) + + expect(keyEventCalls(wc)).toHaveLength(0) + const pressCall = execFileMock.mock.calls + .map(([, commandArgs]) => commandArgs) + .filter(isStringArray) + .find((commandArgs) => commandArgs.includes('press')) + expect(pressCall).toBeDefined() + const args = pressCall ?? [] + expect(args[args.indexOf('press') + 1]).toBe('MediaPlayPause') + }) + + it('rejects with tab not found when the page is gone', async () => { + webContentsFromIdMock.mockReturnValue(null) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_tab_not_found' + }) + }) + + // Why: one keypress looks the page up three times — the queued target, the + // automation-visibility refresh, then the dispatch guard. Serving the first N keeps the + // later ones on the guard; the trailing assertions fail loudly if that count ever moves. + function killPageAfterLookups(lookups: number): () => number { + let remaining = lookups + webContentsFromIdMock.mockImplementation((id: number) => { + if (id !== 100 || remaining === 0) { + return null + } + remaining -= 1 + return wc + }) + return () => remaining + } + + it('rejects with tab not found when the page dies after its target is resolved', async () => { + const remaining = killPageAfterLookups(2) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_tab_not_found' + }) + expect(remaining()).toBe(0) + expect(keyEventCalls(wc)).toHaveLength(0) + }) + + it('rejects with tab not found when the page dies mid-dispatch', async () => { + const remaining = killPageAfterLookups(3) + wc.debugger.sendCommand.mockRejectedValue(new Error('Inspected target navigated or closed')) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_tab_not_found' + }) + expect(remaining()).toBe(0) + }) + + it('reports a dispatch failure on a live page as a browser error', async () => { + wc.debugger.sendCommand.mockRejectedValue(new Error('Debugger is not attached to the target')) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_error', + message: expect.stringContaining('Debugger is not attached to the target') + }) + }) + + it('reports a debugger attach failure as a browser error', async () => { + wc.debugger.isAttached.mockReturnValue(false) + wc.debugger.attach.mockImplementation(() => { + throw new Error('Another debugger is already attached to the debug target') + }) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_error' + }) + expect(keyEventCalls(wc)).toHaveLength(0) + }) +}) diff --git a/src/main/browser/cdp-keyboard-us-layout.test.ts b/src/main/browser/cdp-keyboard-us-layout.test.ts new file mode 100644 index 000000000000..b9bcffa50ec3 --- /dev/null +++ b/src/main/browser/cdp-keyboard-us-layout.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest' +import { imeFallbackKeyEvent, parseCdpKeyEvent } from './cdp-keyboard-us-layout' + +describe('parseCdpKeyEvent', () => { + it('maps every printable ASCII character to a key event that types that character', () => { + const broken: string[] = [] + for (let charCode = 32; charCode <= 126; charCode++) { + const ch = String.fromCharCode(charCode) + const parsed = parseCdpKeyEvent(ch) + if (!parsed || parsed.text !== ch || parsed.keyCode === 0) { + broken.push(ch) + } + } + expect(broken).toEqual([]) + }) + + it.each([ + ['#', 51], + ['$', 52], + ['%', 53], + ['&', 55], + ["'", 222], + ['(', 57], + ['.', 190] + ])( + 'gives %s the US-layout key code %i instead of its own char code', + (ch: string, keyCode: number) => { + // Why: charCodeAt-derived codes put '&' on VK_UP (38) and '.' on VK_DELETE (46), + // which Blink executes as caret commands that swallow the character. + expect(parseCdpKeyEvent(ch)).toMatchObject({ keyCode, text: ch }) + } + ) + + it.each([ + ['Ctrl+A', { keyCode: 65, key: 'a', modifiers: 2, text: null }], + ['Control+a', { keyCode: 65, key: 'a', modifiers: 2, text: null }], + ['Shift+Home', { keyCode: 36, key: 'Home', modifiers: 8, text: null }], + ['Alt+ArrowDown', { keyCode: 40, key: 'ArrowDown', modifiers: 1, text: null }], + ['Ctrl+Shift+K', { keyCode: 75, key: 'K', modifiers: 10, text: null }], + ['Meta+r', { keyCode: 82, key: 'r', modifiers: 4, text: null }], + ['Control+Shift+r', { keyCode: 82, key: 'R', modifiers: 10, text: null }] + ])('parses the shortcut %s', (raw: string, expected: object) => { + expect(parseCdpKeyEvent(raw)).toMatchObject(expected) + }) + + it('treats a capital letter in a shortcut as the key name, not a shift request', () => { + expect(parseCdpKeyEvent('Ctrl+A')).toMatchObject({ key: 'a', modifiers: 2 }) + expect(parseCdpKeyEvent('Ctrl+Shift+A')).toMatchObject({ key: 'A', modifiers: 10 }) + }) + + it('shifts a bare capital letter and reports the shifted character as text', () => { + expect(parseCdpKeyEvent('R')).toMatchObject({ keyCode: 82, key: 'R', modifiers: 8, text: 'R' }) + expect(parseCdpKeyEvent('Shift+a')).toMatchObject({ key: 'A', modifiers: 8, text: 'A' }) + }) + + it('maps shifted punctuation onto its base key with shift held', () => { + expect(parseCdpKeyEvent('Shift+1')).toMatchObject({ keyCode: 49, key: '!', text: '!' }) + expect(parseCdpKeyEvent('+')).toMatchObject({ keyCode: 187, modifiers: 8, text: '+' }) + }) + + it.each([ + ['Enter', { keyCode: 13, text: '\r' }], + ['Space', { keyCode: 32, key: ' ', text: ' ' }], + ['Esc', { keyCode: 27, key: 'Escape', text: null }], + ['PgDn', { keyCode: 34, key: 'PageDown', text: null }], + ['ContextMenu', { keyCode: 93, text: null }], + ['F5', { keyCode: 116, key: 'F5', code: 'F5', text: null }], + ['F12', { keyCode: 123, text: null }] + ])('parses the named key %s', (raw: string, expected: object) => { + expect(parseCdpKeyEvent(raw)).toMatchObject(expected) + }) + + it.each([ + ['Shift', { keyCode: 16, key: 'Shift', code: 'ShiftLeft', modifiers: 8, selfModifier: 8 }], + ['Ctrl', { keyCode: 17, key: 'Control', code: 'ControlLeft', modifiers: 2, selfModifier: 2 }], + ['Alt', { keyCode: 18, key: 'Alt', code: 'AltLeft', modifiers: 1, selfModifier: 1 }], + ['Meta', { keyCode: 91, key: 'Meta', code: 'MetaLeft', modifiers: 4, selfModifier: 4 }] + ])( + 'reports the own modifier bit and left-side location for a bare %s press', + (raw: string, expected: object) => { + expect(parseCdpKeyEvent(raw)).toMatchObject({ ...expected, location: 1, text: null }) + } + ) + + it('adds the self bit on top of held modifiers for a modifier-only chord', () => { + expect(parseCdpKeyEvent('Ctrl+Shift')).toMatchObject({ + keyCode: 16, + modifiers: 10, + selfModifier: 8 + }) + }) + + it('reports no self bit or location for non-modifier keys', () => { + expect(parseCdpKeyEvent('Enter')).toMatchObject({ location: 0, selfModifier: 0 }) + expect(parseCdpKeyEvent('a')).toMatchObject({ location: 0, selfModifier: 0 }) + expect(parseCdpKeyEvent('Ctrl+A')).toMatchObject({ location: 0, selfModifier: 0 }) + }) + + it.each([['MediaPlayPause'], ['F25'], [''], ['NoSuchKey']])( + 'returns null for %s so the caller can fall back', + (raw: string) => { + expect(parseCdpKeyEvent(raw)).toBeNull() + } + ) +}) + +describe('imeFallbackKeyEvent', () => { + it.each([['é'], ['ß'], ['ñ'], ['ü'], ['漢'], ['한']])( + 'gives %s the IME key event form with keyCode 229 and its text', + (ch: string) => { + expect(imeFallbackKeyEvent(ch)).toEqual({ + keyCode: 229, + key: ch, + code: '', + modifiers: 0, + location: 0, + selfModifier: 0, + text: ch + }) + } + ) + + it.each([ + ['a table-covered ASCII character', 'a'], + ['a surrogate-pair emoji', '👍'], + ['a combining sequence', 'e\u0301'], + ['a multi-character name', 'MediaPlayPause'], + ['a chord with a non-US character', 'Ctrl+é'], + ['an empty string', ''] + ])('returns null for %s so the helper keeps its behavior', (_name: string, raw: string) => { + expect(imeFallbackKeyEvent(raw)).toBeNull() + }) +}) diff --git a/src/main/browser/cdp-keyboard-us-layout.ts b/src/main/browser/cdp-keyboard-us-layout.ts new file mode 100644 index 000000000000..23de58a7241e --- /dev/null +++ b/src/main/browser/cdp-keyboard-us-layout.ts @@ -0,0 +1,251 @@ +// Why: deriving a virtual key code from a character's own char code collides with editing +// keys — '&' (38) arrives as VK_UP and '.' (46) as VK_DELETE, so Blink runs the caret +// command and silently drops the character. This table maps Orca key names ("a", "&", +// "Ctrl+Shift+K", "Alt+ArrowDown", "F5") to the CDP key event a US-layout keyboard +// would produce; anything it cannot express returns null so the caller can fall back. + +const CDP_MODIFIER_BITS: Record = { + alt: 1, + option: 1, + ctrl: 2, + control: 2, + cmd: 4, + command: 4, + meta: 4, + super: 4, + win: 4, + shift: 8 +} + +// name -> [windowsVirtualKeyCode, key, code, text] +const CDP_NAMED_KEYS: Record = { + enter: [13, 'Enter', 'Enter', '\r'], + return: [13, 'Enter', 'Enter', '\r'], + tab: [9, 'Tab', 'Tab', null], + backspace: [8, 'Backspace', 'Backspace', null], + delete: [46, 'Delete', 'Delete', null], + del: [46, 'Delete', 'Delete', null], + escape: [27, 'Escape', 'Escape', null], + esc: [27, 'Escape', 'Escape', null], + space: [32, ' ', 'Space', ' '], + spacebar: [32, ' ', 'Space', ' '], + arrowup: [38, 'ArrowUp', 'ArrowUp', null], + up: [38, 'ArrowUp', 'ArrowUp', null], + arrowdown: [40, 'ArrowDown', 'ArrowDown', null], + down: [40, 'ArrowDown', 'ArrowDown', null], + arrowleft: [37, 'ArrowLeft', 'ArrowLeft', null], + left: [37, 'ArrowLeft', 'ArrowLeft', null], + arrowright: [39, 'ArrowRight', 'ArrowRight', null], + right: [39, 'ArrowRight', 'ArrowRight', null], + home: [36, 'Home', 'Home', null], + end: [35, 'End', 'End', null], + pageup: [33, 'PageUp', 'PageUp', null], + pgup: [33, 'PageUp', 'PageUp', null], + pagedown: [34, 'PageDown', 'PageDown', null], + pgdn: [34, 'PageDown', 'PageDown', null], + pgdown: [34, 'PageDown', 'PageDown', null], + insert: [45, 'Insert', 'Insert', null], + ins: [45, 'Insert', 'Insert', null], + contextmenu: [93, 'ContextMenu', 'ContextMenu', null], + apps: [93, 'ContextMenu', 'ContextMenu', null], + capslock: [20, 'CapsLock', 'CapsLock', null], + numlock: [144, 'NumLock', 'NumLock', null], + scrolllock: [145, 'ScrollLock', 'ScrollLock', null], + pause: [19, 'Pause', 'Pause', null], + printscreen: [44, 'PrintScreen', 'PrintScreen', null], + shift: [16, 'Shift', 'ShiftLeft', null], + control: [17, 'Control', 'ControlLeft', null], + ctrl: [17, 'Control', 'ControlLeft', null], + alt: [18, 'Alt', 'AltLeft', null], + option: [18, 'Alt', 'AltLeft', null], + meta: [91, 'Meta', 'MetaLeft', null], + cmd: [91, 'Meta', 'MetaLeft', null], + command: [91, 'Meta', 'MetaLeft', null] +} + +// Characters a US keyboard produces with shift held, and the base key they share. +const US_SHIFTED_CHARS: Record = { + '~': '`', + '!': '1', + '@': '2', + '#': '3', + $: '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', + _: '-', + '+': '=', + '{': '[', + '}': ']', + '|': '\\', + ':': ';', + '"': "'", + '<': ',', + '>': '.', + '?': '/' +} + +const US_SHIFT_OF: Record = {} +for (const shifted of Object.keys(US_SHIFTED_CHARS)) { + US_SHIFT_OF[US_SHIFTED_CHARS[shifted]] = shifted +} + +// char -> [windowsVirtualKeyCode, code], for the keys that are not letters or digits. +const US_PUNCTUATION_KEYS: Record = { + ' ': [32, 'Space'], + ';': [186, 'Semicolon'], + '=': [187, 'Equal'], + ',': [188, 'Comma'], + '-': [189, 'Minus'], + '.': [190, 'Period'], + '/': [191, 'Slash'], + '`': [192, 'Backquote'], + '[': [219, 'BracketLeft'], + '\\': [220, 'Backslash'], + ']': [221, 'BracketRight'], + "'": [222, 'Quote'] +} + +type UsKeyboardKey = { + keyCode: number + code: string + shift: boolean +} + +function usKeyboardKeyForChar(ch: string): UsKeyboardKey | null { + if (ch >= 'a' && ch <= 'z') { + return { keyCode: ch.charCodeAt(0) - 32, code: `Key${ch.toUpperCase()}`, shift: false } + } + if (ch >= 'A' && ch <= 'Z') { + return { keyCode: ch.charCodeAt(0), code: `Key${ch}`, shift: true } + } + if (ch >= '0' && ch <= '9') { + return { keyCode: ch.charCodeAt(0), code: `Digit${ch}`, shift: false } + } + if (Object.hasOwn(US_SHIFTED_CHARS, ch)) { + const base = usKeyboardKeyForChar(US_SHIFTED_CHARS[ch]) + return base === null ? null : { keyCode: base.keyCode, code: base.code, shift: true } + } + if (Object.hasOwn(US_PUNCTUATION_KEYS, ch)) { + return { keyCode: US_PUNCTUATION_KEYS[ch][0], code: US_PUNCTUATION_KEYS[ch][1], shift: false } + } + return null +} + +export type CdpKeyEvent = { + keyCode: number + key: string + code: string + modifiers: number + // Why: 1 = left-side key -- the table pins bare modifiers to ShiftLeft/ControlLeft/etc. + location: number + // Why: a modifier key's own bit is set during its keydown but already cleared on its keyup. + selfModifier: number + // Why: null means the key produces no character (a rawKeyDown, not a keyDown with text). + text: string | null +} + +// Why: printable characters outside the table (accented letters, non-latin scripts) +// still have an in-process form -- the IME convention, keyCode 229 with the text, +// which is how composed input already reaches pages. One BMP code point only: +// surrogate pairs and combining sequences keep the helper's behavior. +export function imeFallbackKeyEvent(raw: string): CdpKeyEvent | null { + if (raw.length !== 1) { + return null + } + const codePoint = raw.charCodeAt(0) + if (codePoint < 0xa0 || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + return null + } + return { keyCode: 229, key: raw, code: '', modifiers: 0, location: 0, selfModifier: 0, text: raw } +} + +export function parseCdpKeyEvent(raw: string): CdpKeyEvent | null { + if (raw.length === 0) { + return null + } + let rest = raw + let modifiers = 0 + while (rest.length > 1) { + const plus = rest.indexOf('+') + if (plus <= 0) { + break + } + const name = rest.slice(0, plus).toLowerCase() + if (!Object.hasOwn(CDP_MODIFIER_BITS, name)) { + break + } + modifiers |= CDP_MODIFIER_BITS[name] + rest = rest.slice(plus + 1) + } + if (rest.length === 0) { + return null + } + + let keyCode: number + let key: string + let code: string + let text: string | null + let location = 0 + let selfModifier = 0 + if (rest.length === 1) { + const mapped = usKeyboardKeyForChar(rest) + if (mapped === null) { + return null + } + keyCode = mapped.keyCode + key = rest + code = mapped.code + text = rest + // Why: a capital letter in a shortcut is how people write the key, not a request for + // shift — Ctrl+A means select-all (key 'a'), never Ctrl+Shift+A. Shifted punctuation + // is different: on a US keyboard shift is the only way to produce the character. + const capitalShortcut = rest >= 'A' && rest <= 'Z' && (modifiers & ~8) !== 0 + if (capitalShortcut) { + key = rest.toLowerCase() + text = key + } else if (mapped.shift) { + modifiers |= 8 + } + } else if (Object.hasOwn(CDP_NAMED_KEYS, rest.toLowerCase())) { + const name = rest.toLowerCase() + const named = CDP_NAMED_KEYS[name] + keyCode = named[0] + key = named[1] + code = named[2] + text = named[3] + // Why: Blink reports a modifier's own bit during its keydown (shiftKey is true while + // Shift goes down), and the table's modifier entries are the left-side keys. + selfModifier = CDP_MODIFIER_BITS[name] ?? 0 + if (selfModifier !== 0) { + modifiers |= selfModifier + location = 1 + } + } else { + const functionKey = /^f([1-9]|1[0-9]|2[0-4])$/i.exec(rest) + if (functionKey === null) { + return null + } + keyCode = 111 + Number(functionKey[1]) + key = `F${functionKey[1]}` + code = key + text = null + } + + if (text !== null && (modifiers & 8) !== 0) { + text = Object.hasOwn(US_SHIFT_OF, text) ? US_SHIFT_OF[text] : text.toUpperCase() + // Why: Shift+a is the "A" key as far as the page is concerned. + if (rest.length === 1) { + key = text + } + } + // Why: with ctrl, alt or meta held the press is a shortcut and produces no character. + if ((modifiers & ~8) !== 0) { + text = null + } + + return { keyCode, key, code, modifiers, location, selfModifier, text } +} From c853e10e0c798be0408e8db08d8fdbd432eb6b5a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:05:42 -0400 Subject: [PATCH 012/209] fix(rpc): validate provider-specific fields in TaskProviderIdentity (#20284) * fix(rpc): validate task provider identity fields Validate provider-specific field types while preserving nullable scopes and unknown identity fields. Record the producer census and pin validation with regression tests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(rpc): reject a blank GitHub owner or repo normalizeTaskProviderIdentity treats a blank owner or repo as no identity at all, but the schema accepted '' and whitespace-only, so the two disagreed about the same payload. Refined rather than trimmed: trimming would rewrite the parsed value and change what the handler receives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): re-measure the identity evidence counts The blank-field commit added seven tests, so the recorded 74/16/58 described the commit before it. Re-ran both: 81 tests, and the discriminant-only mutation now gives 17 failures / 64 passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): correct the remaining stale gate count The blank-field commit moved the full-RPC total too; 2,463 was the count before it. Re-ran: 278 files, 2,470 passed, one skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../task-provider-identity-validation.md | 81 ++++++++++ .../methods/task-provider-identity.test.ts | 141 ++++++++++++++++++ src/shared/rpc-contract/automation-params.ts | 52 ++++++- 3 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 docs/reference/task-provider-identity-validation.md create mode 100644 src/main/runtime/rpc/methods/task-provider-identity.test.ts diff --git a/docs/reference/task-provider-identity-validation.md b/docs/reference/task-provider-identity-validation.md new file mode 100644 index 000000000000..4dba0760aabe --- /dev/null +++ b/docs/reference/task-provider-identity-validation.md @@ -0,0 +1,81 @@ +# Task provider identity RPC validation + +The automation RPC identity schema follows `src/shared/task-provider-identity.ts`, +re-exported by `src/shared/task-source-context.ts`. Only GitHub requires fields beyond +`provider`: `owner` and `repo` are strings, and `host` is optional. GitLab, Linear, +and Jira fields are optional nullable strings. Requiring a GitLab project or a +Linear workspace/Jira site would contradict the domain type and account-wide scopes. + +The discriminated union validates these existing field types without trimming, +coercing, or stripping identity fields. Unknown fields pass through as they did under +`z.custom`, including fields from newer clients. Absent and explicit-null identities +remain distinct. The schema does not infer providers from owner/repo or require a +git worktree, repository slug, or local execution host for a source context. + +## Producer census + +Paths below are relative to the repository root. Searches covered production +`providerIdentity`, `TaskProviderIdentity`, `sourceContext`, and +`linkedTaskSourceContext` uses across desktop, shared code, mobile, and CLI. + +| Producer or forwarding path | Populated verdict | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/shared/project-host-setup-projection.ts`: `getProjectProviderIdentity` | GitHub owner/repo populated together, or no identity. Supplies project identities consumed by desktop and migration. | +| `src/renderer/src/components/task-page-source-context.tsx`: `getTaskPageRepoSourceContext` | GitHub fields populated through projection, or null. GitLab uses explicit provider and `buildGitLabProviderIdentity`; projectId/project/webUrl populated, namespace can be null. | +| Same file: `buildGitLabProviderIdentity` | GitLab fields come from project path/host; missing path components become null. No required GitLab field is invented. | +| `src/renderer/src/hooks/composer-state/source-context-state.ts` | Derived GitHub context carries a complete project identity or null. Jira folder/project-group context explicitly has null identity. Draft/linked contexts are forwarded. | +| `src/renderer/src/components/use-task-page-source-availability.ts` | Linear workspaceId/name and Jira siteId/URL can be null for account-wide selections; team/project fields are not populated. Valid under the existing optional-field contract. | +| `src/renderer/src/components/task-page-jira-item-source-context.ts` | Bound issue context populates siteId, siteUrl, projectKey. | +| `src/renderer/src/components/new-workspace/use-jira-url-source.ts` | Bound URL issue context populates siteId, siteUrl, projectKey. | +| `src/renderer/src/components/worktree-jump-palette-create-worktree.ts` | Linear teamId/key populated; workspaceId/name may be null. | +| `src/shared/task-source-context.ts`: normalize/build functions | GitHub missing owner/repo becomes null identity; other providers' missing fields become null. Provider mismatch becomes null, never an inferred provider. | +| `src/cli/handlers/automation-handler-flags.ts`, `src/cli/handlers/automations.ts` | Explicit JSON source-context input is normalized before create/update. GitHub fields populated or null identity; other fields nullable. Omitted/null context preserved by flag handling. | +| `src/main/persistence/scheduling-automations/automation-context-migration.ts` | Builds source context from projected complete GitHub identity, or null context. | +| Desktop automation save/scoped-list/host clients and web transport | Forward existing source contexts, not new identity constructors. `automation-orca-save.ts` forwards the current automation context or null. Legacy arbitrary malformed RPC input is deliberately rejected by the new schema. | +| Mobile | No task-provider identity/source-context constructor or sender found. `mobile/src/components/new-workspace-project-targets.ts` uses project identity solely for display. | + +## Compatibility evidence and limits + +Read `docs/reference/remote-wire-compatibility.md` before changing validation. +A source search against release tag `v1.4.199` also finds no mobile +`sourceContext`/`linkedTaskSourceContext` sender; its sole `providerIdentity` use +is the display-only project target above. The released CLI flag reader also calls +`normalizeTaskSourceContext`. This is source-level evidence for the checked release, +not a claim to have executed every historical mobile binary. + +No shipped mobile producer with a newly rejected payload was found. No new required +field was added to the domain contract. GitLab, Linear, and Jira discriminant-only +identities remain valid. Folder-workspace null/absent identities remain valid on +both local and SSH hosts. No execution/status logic or client-side parsing changed. + +## Regression evidence + +`src/main/runtime/rpc/methods/task-provider-identity.test.ts` checks unchanged valid +identities for all four providers, required GitHub fields, every declared field's +type, optional/null non-GitHub fields, unknown-field preservation, explicit GitLab +discrimination with owner/repo present, local/SSH folder contexts, and update patches. + +The focused run passed 81 tests. Temporarily replacing GitHub's field validators +with optional `z.unknown()` validators (discriminant-only acceptance) caused 17 +failures and 64 passes. The mutation was restored before running the gates. + +Counts re-measured at `cf4f77f` after the blank-field commit added seven tests; +the earlier 74/16/58 figures described the commit before it. + +## Gate results + +All commands ran with `ORCA_BACKGROUND_LAUNCH=1`. + +- `pnpm tc`: exit 0; completed the repository typecheck runner. +- `pnpm exec vitest run src/main/runtime/rpc`: exit 1; 277 files passed, + one failed; 2,462 tests passed, one failed, one skipped. The only failure was + the unrelated `structured-agent-session-adoption-replay.test.ts` hitting its + 5,000 ms timeout. All identity tests passed. +- `pnpm --dir mobile typecheck`: exit 0; `tsc --noEmit` passed. +- `pnpm run check:code-quality:changed`: exit 0; zero new code-quality, + type-aware, or React Doctor findings across the two changed code files. +- Isolated retry of `structured-agent-session-adoption-replay.test.ts`: exit 0; + one test passed, with the test body completing in 358 ms. +- Full RPC retry with `pnpm exec vitest run src/main/runtime/rpc --maxWorkers=4`: + exit 0; all 278 files passed, 2,470 tests passed, one skipped (97.24 seconds). + The bounded-concurrency rerun resolved the timeout without changing test code. diff --git a/src/main/runtime/rpc/methods/task-provider-identity.test.ts b/src/main/runtime/rpc/methods/task-provider-identity.test.ts new file mode 100644 index 000000000000..1cf6d54381cd --- /dev/null +++ b/src/main/runtime/rpc/methods/task-provider-identity.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + AutomationUpdate, + TaskProviderIdentity, + TaskSourceContext +} from '../../../../shared/rpc-contract/automation-params' +import type { TaskProviderIdentity as ProviderIdentity } from '../../../../shared/task-source-context' + +const identities = [ + { provider: 'github', owner: 'Acme', repo: 'Orca', host: 'github.example.com' }, + { + provider: 'gitlab', + projectId: '123', + namespace: 'acme/team', + project: 'orca', + webUrl: 'https://gitlab.example.com/acme/team/orca' + }, + { + provider: 'linear', + workspaceId: 'workspace', + workspaceName: 'Acme', + teamId: 'team', + teamKey: 'ENG' + }, + { provider: 'jira', siteId: 'site', siteUrl: 'https://acme.atlassian.net', projectKey: 'ENG' } +] satisfies ProviderIdentity[] + +describe('task provider identity RPC validation', () => { + it.each(identities)('preserves valid $provider identities', (identity) => { + expect(TaskProviderIdentity.parse(identity)).toEqual(identity) + }) + + it.each(['owner', 'repo'])('requires the GitHub %s', (field) => { + const identity: Record = { ...identities[0] } + delete identity[field] + expect(TaskProviderIdentity.safeParse(identity).success).toBe(false) + expect(TaskProviderIdentity.safeParse({ ...identity, [field]: null }).success).toBe(false) + }) + + for (const identity of identities) { + for (const field of Object.keys(identity).filter((key) => key !== 'provider')) { + it.each([42, false, [], {}])( + `rejects non-string ${identity.provider}.${field}: %j`, + (value) => { + expect(TaskProviderIdentity.safeParse({ ...identity, [field]: value }).success).toBe( + false + ) + } + ) + } + } + + it.each(['gitlab', 'linear', 'jira'])('keeps %s fields optional and nullable', (provider) => { + expect(TaskProviderIdentity.parse({ provider })).toEqual({ provider }) + const full = identities.find((identity) => identity.provider === provider)! + const nullable = Object.fromEntries( + Object.keys(full).map((key) => [key, key === 'provider' ? provider : null]) + ) + expect(TaskProviderIdentity.parse(nullable)).toEqual(nullable) + }) + + it('preserves unknown fields and never infers GitHub from owner/repo', () => { + const identity = { provider: 'gitlab', owner: 'acme', repo: 'orca', futureField: 'value' } + expect(TaskProviderIdentity.parse(identity)).toEqual(identity) + }) + + it.each([{}, { provider: 'github' }, { provider: 'unknown' }, [], 'github', 1])( + 'rejects invalid identities: %j', + (identity) => { + expect(TaskProviderIdentity.safeParse(identity).success).toBe(false) + } + ) + + it('preserves absent and explicit-null identities in folder contexts on local and SSH hosts', () => { + expect(TaskProviderIdentity.parse(undefined)).toBeUndefined() + expect(TaskProviderIdentity.parse(null)).toBeNull() + for (const hostId of ['local', 'ssh:host']) { + const context = { kind: 'task-source', provider: 'github', projectId: 'folder', hostId } + expect(TaskSourceContext.parse(context)).not.toHaveProperty('providerIdentity') + expect(TaskSourceContext.parse({ ...context, providerIdentity: null })).toEqual({ + ...context, + providerIdentity: null + }) + } + }) + + it('validates identities in automation updates without collapsing absent and null patches', () => { + expect(AutomationUpdate.parse({ id: 'automation', updates: {} }).updates).not.toHaveProperty( + 'sourceContext' + ) + expect( + AutomationUpdate.parse({ id: 'automation', updates: { sourceContext: null } }).updates + .sourceContext + ).toBeNull() + expect( + AutomationUpdate.safeParse({ + id: 'automation', + updates: { + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'project', + hostId: 'local', + providerIdentity: { provider: 'github' } + } + } + }).success + ).toBe(false) + }) +}) + +describe('github identity blank fields', () => { + // The normalizer treats a blank owner or repo as no identity, so the schema must agree. + it.each(['', ' ', '\t'])('rejects a blank owner %j', (owner) => { + expect( + TaskProviderIdentity.safeParse({ provider: 'github', owner, repo: 'orca' }).success + ).toBe(false) + }) + + it.each(['', ' '])('rejects a blank repo %j', (repo) => { + expect( + TaskProviderIdentity.safeParse({ provider: 'github', owner: 'stablyai', repo }).success + ).toBe(false) + }) + + it('still accepts a populated identity', () => { + expect( + TaskProviderIdentity.safeParse({ provider: 'github', owner: 'stablyai', repo: 'orca' }) + .success + ).toBe(true) + }) + + it('leaves the parsed value untrimmed, so no wire bytes change', () => { + const parsed = TaskProviderIdentity.safeParse({ + provider: 'github', + owner: ' stablyai ', + repo: 'orca' + }) + expect(parsed.success && parsed.data?.owner).toBe(' stablyai ') + }) +}) diff --git a/src/shared/rpc-contract/automation-params.ts b/src/shared/rpc-contract/automation-params.ts index fff5d5db6155..d2ddd4aaad69 100644 --- a/src/shared/rpc-contract/automation-params.ts +++ b/src/shared/rpc-contract/automation-params.ts @@ -14,7 +14,6 @@ import { MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, normalizeAutomationPrecheckTimeoutSeconds } from '../automation-precheck' -import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../task-source-context' export const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { message: 'Unknown provider' @@ -58,14 +57,51 @@ export const OptionalNullablePlainString = z .pipe(z.union([z.string(), z.null(), z.undefined()])) .optional() +// A GitHub identity is only usable with both fields present and non-blank. +const GithubIdentityField = z.string().refine((value) => value.trim().length > 0, { + message: 'Required' +}) + export const TaskProviderIdentity = z - .custom( - (value) => - value !== null && - typeof value === 'object' && - 'provider' in value && - ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) - ) + .discriminatedUnion('provider', [ + z + .object({ + provider: z.literal('github'), + // Why refine, not .trim(): normalizeTaskProviderIdentity treats a blank owner or repo as + // no identity at all, so blank must be rejected here — but trimming would rewrite the + // parsed value and change what the handler receives. + owner: GithubIdentityField, + repo: GithubIdentityField, + host: z.string().optional() + }) + .passthrough(), + z + .object({ + provider: z.literal('gitlab'), + projectId: z.string().nullable().optional(), + namespace: z.string().nullable().optional(), + project: z.string().nullable().optional(), + webUrl: z.string().nullable().optional() + }) + .passthrough(), + z + .object({ + provider: z.literal('linear'), + workspaceId: z.string().nullable().optional(), + workspaceName: z.string().nullable().optional(), + teamId: z.string().nullable().optional(), + teamKey: z.string().nullable().optional() + }) + .passthrough(), + z + .object({ + provider: z.literal('jira'), + siteId: z.string().nullable().optional(), + siteUrl: z.string().nullable().optional(), + projectKey: z.string().nullable().optional() + }) + .passthrough() + ]) .optional() .nullable() From 241fb9ed9d8308d64b4450725732d2ec0f273876 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 13 Sep 2026 16:27:06 -0700 Subject: [PATCH 013/209] perf(terminal): batch file-link checks on their owning host (#20463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(terminal): batch file-link existence checks on their owning host * test(relay): allow additive filesystem capabilities * fix(web): keep terminal file links working under batched existence checks createShellApi omitted pathsExist, so withFallback answered the new batch call with a truthy proxy resolving to undefined and the whole hover batch rejected — dropping every link on lines with an out-of-worktree path. * test(web): assert the shim without type assertions --------- Co-authored-by: m4air Co-authored-by: m4air Co-authored-by: Neil --- .../filesystem/filesystem-read-handlers.ts | 36 ++++++ src/main/ipc/shell.ts | 6 + .../providers/filesystem-provider-contract.ts | 2 + .../ssh-filesystem-path-existence.ts | 45 +++++++ .../ssh-filesystem-provider-capabilities.ts | 6 + src/main/providers/ssh-filesystem-provider.ts | 6 + ...l-path-existence-batch.integration.test.ts | 113 +++++++++++++++++ src/main/runtime/rpc/methods/files.ts | 7 ++ .../runtime/runtime-file-command-surface.ts | 2 + ...time-file-commands-search-runtime-files.ts | 14 +++ .../runtime-file-path-existence.test.ts | 97 +++++++++++++++ .../runtime/runtime-file-path-existence.ts | 39 ++++++ src/preload/api/filesystem-api.ts | 5 + src/preload/api/fs-bridge.ts | 5 + src/preload/api/shell-api.ts | 1 + src/preload/api/shell-bridge.ts | 2 + .../fs-handler-file-range-dispatch.test.ts | 2 +- src/relay/fs-handler.ts | 5 +- src/relay/fs-path-existence.ts | 22 ++++ .../terminal-pane/terminal-link-handlers.ts | 9 +- .../terminal-link-provider-batching.test.ts | 92 ++++++++++++++ .../terminal-path-existence-batch.test.ts | 68 ++++++++++ .../terminal-path-existence-batch.ts | 117 ++++++++++++++++++ .../runtime/runtime-file-metadata-client.ts | 14 ++- .../runtime-path-existence-batch.test.ts | 88 +++++++++++++ .../runtime/runtime-path-existence-batch.ts | 81 ++++++++++++ ...untime-path-existence-error-parity.test.ts | 79 ++++++++++++ .../runtime-path-existence-pairing.test.ts | 60 +++++++++ ...ntime-path-existence-queue-pairing.test.ts | 96 ++++++++++++++ .../src/web/preload-api/web-shell-api.ts | 20 +-- .../src/web/web-shell-paths-exist.test.ts | 21 ++++ src/shared/path-existence-batch.ts | 40 ++++++ src/shared/protocol-version.ts | 1 + src/shared/rpc-contract/files-params.ts | 5 + .../rpc-params-catalog.generated.ts | 2 + 35 files changed, 1187 insertions(+), 21 deletions(-) create mode 100644 src/main/providers/ssh-filesystem-path-existence.ts create mode 100644 src/main/providers/terminal-path-existence-batch.integration.test.ts create mode 100644 src/main/runtime/runtime-file-path-existence.test.ts create mode 100644 src/main/runtime/runtime-file-path-existence.ts create mode 100644 src/relay/fs-path-existence.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-batch.test.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-batch.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-pairing.test.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts create mode 100644 src/renderer/src/web/web-shell-paths-exist.test.ts create mode 100644 src/shared/path-existence-batch.ts diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 938370a2816f..2850ba659b40 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -1,3 +1,8 @@ +import { + capturePathExistence, + validatePathExistenceBatch, + type PathExistenceResult +} from '../../../shared/path-existence-batch' import { ipcMain } from 'electron' import { readdir, readFile, stat } from 'node:fs/promises' import { extname } from 'node:path' @@ -147,6 +152,37 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext } ) + ipcMain.handle( + 'fs:pathsExist', + async ( + _event, + args: { filePaths: string[]; connectionId?: string } + ): Promise => { + validatePathExistenceBatch(args.filePaths) + const provider = args.connectionId ? requireSshFilesystemProvider(args.connectionId) : null + if (provider?.pathsExist) { + return provider.pathsExist(args.filePaths) + } + return Promise.all( + args.filePaths.map((filePath) => + capturePathExistence(async () => { + try { + await (provider + ? provider.stat(filePath) + : stat(await resolveAuthorizedPath(filePath, store))) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + }) + ) + ) + } + ) + ipcMain.handle( 'fs:pathExists', async (_event, args: { filePath: string; connectionId?: string }): Promise => { diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 80f02552f189..7ed49c4af7c0 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,3 +1,4 @@ +import { validatePathExistenceBatch } from '../../shared/path-existence-batch' import { ipcMain, shell, dialog } from 'electron' import { constants, copyFile, readFile, stat } from 'node:fs/promises' import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path' @@ -204,6 +205,11 @@ export function registerShellHandlers(store: Store): void { await openWithSystemDefault(target.path) }) + ipcMain.handle('shell:pathsExist', async (_event, paths: string[]): Promise => { + validatePathExistenceBatch(paths) + return Promise.all(paths.map(pathExists)) + }) + ipcMain.handle('shell:pathExists', async (_event, filePath: string): Promise => { return pathExists(filePath) }) diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts index e42bd5b07c97..ae4a59eb7bbc 100644 --- a/src/main/providers/filesystem-provider-contract.ts +++ b/src/main/providers/filesystem-provider-contract.ts @@ -1,3 +1,4 @@ +import type { PathExistenceResult } from '../../shared/path-existence-batch' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import type { DocPreviewFileAccessRequest, @@ -86,6 +87,7 @@ export type IFilesystemProvider = { ): Promise writeFileBase64(filePath: string, contentBase64: string): Promise writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise + pathsExist?(filePaths: string[]): Promise stat(filePath: string): Promise lstat?(filePath: string): Promise deletePath(targetPath: string, recursive?: boolean): Promise diff --git a/src/main/providers/ssh-filesystem-path-existence.ts b/src/main/providers/ssh-filesystem-path-existence.ts new file mode 100644 index 000000000000..08570fc17b3c --- /dev/null +++ b/src/main/providers/ssh-filesystem-path-existence.ts @@ -0,0 +1,45 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import { isENOENT } from '../ipc/filesystem-path-containment' +import { + capturePathExistence, + requirePathExistenceResults, + validatePathExistenceBatch, + type PathExistenceResult +} from '../../shared/path-existence-batch' +import { probeSshPathExistenceBatchCapability } from './ssh-filesystem-provider-capabilities' + +export async function readSshPathExistenceBatch( + mux: SshChannelMultiplexer, + paths: string[], + stat: (path: string) => Promise +): Promise { + validatePathExistenceBatch(paths) + if (await probeSshPathExistenceBatchCapability(mux)) { + try { + return requirePathExistenceResults( + await mux.request('fs.pathsExist', { filePaths: paths }), + paths.length + ) + } catch (error) { + if (!isMethodNotFoundError(error)) { + throw error + } + } + } + return Promise.all( + paths.map((path) => + capturePathExistence(async () => { + try { + await stat(path) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + }) + ) + ) +} diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.ts b/src/main/providers/ssh-filesystem-provider-capabilities.ts index 2703c4d16c7c..5d57bd588bb6 100644 --- a/src/main/providers/ssh-filesystem-provider-capabilities.ts +++ b/src/main/providers/ssh-filesystem-provider-capabilities.ts @@ -62,3 +62,9 @@ export function probeSshRangedReadCapability( (capabilities) => capabilities?.rangedReadVersion === 1 ) } + +export function probeSshPathExistenceBatchCapability(mux: SshChannelMultiplexer): Promise { + return readSshFsCapabilities(mux).then( + (capabilities) => capabilities?.pathExistenceBatchVersion === 1 + ) +} diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index f6208ea00e9f..f688d2789b1c 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,3 +1,5 @@ +import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' +import type { PathExistenceResult } from '../../shared/path-existence-batch' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import { uploadBuffer } from '../ssh/sftp-upload' @@ -217,6 +219,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } + pathsExist(filePaths: string[]): Promise { + return readSshPathExistenceBatch(this.mux, filePaths, (path) => this.stat(path)) + } + async stat(filePath: string): Promise { return (await this.mux.request('fs.stat', { filePath })) as FileStat } diff --git a/src/main/providers/terminal-path-existence-batch.integration.test.ts b/src/main/providers/terminal-path-existence-batch.integration.test.ts new file mode 100644 index 000000000000..c67b627d6829 --- /dev/null +++ b/src/main/providers/terminal-path-existence-batch.integration.test.ts @@ -0,0 +1,113 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { pathsExistOnRelay } from '../../relay/fs-path-existence' +import { statRelayPath } from '../../relay/fs-path-metadata-requests' +import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +const handlers = vi.hoisted(() => new Map Promise>()) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, fn: (...args: unknown[]) => Promise) => handlers.set(name, fn) + }, + shell: {}, + dialog: {} +})) +import { registerShellHandlers } from '../ipc/shell' +let root: string | undefined +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = undefined + handlers.clear() +}) +async function fixture() { + root = await mkdtemp(join(tmpdir(), 'orca-link-batch-')) + const paths = Array.from({ length: 8 }, (_, i) => join(root!, `file-${i}.ts`)) + await Promise.all(paths.map((path) => writeFile(path, 'fixture'))) + return paths +} +it('one actual shell IPC handler probes eight distinct temporary files and retains scalar answers', async () => { + const paths = await fixture() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This registration fixture never invokes unrelated store operations. + registerShellHandlers({} as never) + const all = [...paths, join(root!, 'missing'), root!] + expect(await handlers.get('shell:pathsExist')!(null, all)).toEqual( + await Promise.all(all.map((path) => handlers.get('shell:pathExists')!(null, path))) + ) + expect(await handlers.get('shell:pathsExist')!(null, all)).toEqual([ + ...paths.map(() => true), + false, + true + ]) + await expect(handlers.get('shell:pathsExist')!(null, Array(129).fill('x'))).rejects.toThrow( + 'Invalid' + ) +}) +it('one real relay batch serves eight distinct SSH paths after one shared capability probe', async () => { + const paths = await fixture() + const request = vi.fn(async (method: string, params: Record) => + method === 'fs.getCapabilities' ? { pathExistenceBatchVersion: 1 } : pathsExistOnRelay(params) + ) + const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + const mux = { request } as never + expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( + paths.map(() => ({ exists: true })) + ) + expect(request.mock.calls.map((c) => c[0])).toEqual(['fs.getCapabilities', 'fs.pathsExist']) + expect(scalar).not.toHaveBeenCalled() + await rm(paths[0]) + expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: false }]) + await writeFile(paths[0], 'new') + expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: true }]) + expect(request.mock.calls.filter((c) => c[0] === 'fs.getCapabilities')).toHaveLength(1) +}) +it('old relay falls back on the same host without retrying a missing capability document', async () => { + const paths = await fixture() + const request = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('method not found'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + const mux = { request } as never + expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( + paths.map(() => ({ exists: true })) + ) + expect(scalar).toHaveBeenCalledTimes(8) + await readSshPathExistenceBatch(mux, [paths[0]], scalar) + expect(request).toHaveBeenCalledTimes(1) +}) +it('connection failure is neither a missing path nor permission to use local/scalar fallback', async () => { + const scalar = vi.fn() + const request = vi.fn().mockRejectedValue(new Error('connection closed')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + const mux = { request } as never + await expect(readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).rejects.toThrow( + 'connection closed' + ) + expect(scalar).not.toHaveBeenCalled() + request + .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) + .mockResolvedValueOnce([{ error: 'EACCES denied' }]) + expect(await readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).toEqual([ + { error: 'EACCES denied' } + ]) + expect(request).toHaveBeenCalledTimes(3) +}) +it('malformed batch replies fail rather than manufacturing negative cache entries', async () => { + const scalar = vi.fn() + const request = vi + .fn() + .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) + .mockResolvedValueOnce([]) + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + readSshPathExistenceBatch({ request } as never, ['/remote/path'], scalar) + ).rejects.toThrow('Invalid path existence response') + expect(scalar).not.toHaveBeenCalled() +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 055c02b3265c..29e2f7b071f8 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -7,6 +7,7 @@ import { limitQuickOpenSearchReplyBySerializedBytes } from '../../../../shared/q import { FileOpen, WorktreeSelector } from './files-target-schemas' import { FILE_TERMINAL_ARTIFACT_METHODS } from './files-terminal-artifact-methods' import { + FilePathsExist, DocPreviewFileRead, FileListAll, FileOpenDiff, @@ -164,6 +165,12 @@ export const FILE_METHODS = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree) }), + defineMethod({ + name: 'files.pathsExist', + params: FilePathsExist, + handler: async (params, { runtime }) => + runtime.pathsExistRuntimeFiles(params.worktree, params.relativePaths) + }), defineMethod({ name: 'files.stat', params: FileTreePath, diff --git a/src/main/runtime/runtime-file-command-surface.ts b/src/main/runtime/runtime-file-command-surface.ts index cd891301175d..554eed008a39 100644 --- a/src/main/runtime/runtime-file-command-surface.ts +++ b/src/main/runtime/runtime-file-command-surface.ts @@ -30,6 +30,7 @@ type RuntimeFileCommandName = | 'searchRuntimeFiles' | 'listRuntimeFiles' | 'listRuntimeMarkdownDocuments' + | 'pathsExistRuntimeFiles' | 'statRuntimeFile' export type RuntimeFileCommandSurface = Pick @@ -68,6 +69,7 @@ export function installRuntimeFileCommandSurface( searchRuntimeFiles: commands.searchRuntimeFiles.bind(commands), listRuntimeFiles: commands.listRuntimeFiles.bind(commands), listRuntimeMarkdownDocuments: commands.listRuntimeMarkdownDocuments.bind(commands), + pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands), statRuntimeFile: commands.statRuntimeFile.bind(commands) } satisfies RuntimeFileCommandSurface) } diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts index 5cd1f6246a83..fd79941c1aea 100644 --- a/src/main/runtime/runtime-file-commands-search-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-runtime-files.ts @@ -13,6 +13,11 @@ import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../ipc/markdown-documents' +import { + validatePathExistenceBatch, + type PathExistenceResult +} from '../../shared/path-existence-batch' +import { readRuntimeFilePathExistence } from './runtime-file-path-existence' import { stat } from 'node:fs/promises' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -80,6 +85,15 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman return listMarkdownDocuments(target.worktree.path) } + async pathsExistRuntimeFiles( + worktreeSelector: string, + relativePaths: string[] + ): Promise { + validatePathExistenceBatch(relativePaths) + const targets = await this.resolveFileExplorerPaths(worktreeSelector, relativePaths) + return readRuntimeFilePathExistence(targets, () => this.host.requireStore()) + } + async statRuntimeFile( worktreeSelector: string, relativePath: string diff --git a/src/main/runtime/runtime-file-path-existence.test.ts b/src/main/runtime/runtime-file-path-existence.test.ts new file mode 100644 index 000000000000..d74c58bfdae7 --- /dev/null +++ b/src/main/runtime/runtime-file-path-existence.test.ts @@ -0,0 +1,97 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { RuntimeFileCommands } from './orca-runtime-files' +import { RpcDispatcher } from './rpc/dispatcher' +import { FILE_METHODS } from './rpc/methods/files' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { pathsExistOnRelay } from '../../relay/fs-path-existence' +import { statRelayPath } from '../../relay/fs-path-metadata-requests' +let root: string | undefined +const connection = 'batch-fixture-host' +afterEach(async () => { + unregisterSshFilesystemProvider(connection) + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = undefined +}) +async function setup(legacy = false) { + root = await mkdtemp(join(tmpdir(), 'orca-runtime-batch-')) + const names = Array.from({ length: 8 }, (_, i) => `file-${i}.ts`) + await Promise.all(names.map((name) => writeFile(join(root!, name), 'fixture'))) + const provider = { + pathsExist: legacy + ? undefined + : vi.fn((paths: string[]) => pathsExistOnRelay({ filePaths: paths })), + stat: vi.fn((filePath: string) => statRelayPath({ filePath })) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registered fixture implements the stat and optional batch operations exercised here. + registerSshFilesystemProvider(connection, provider as never) + const resolveTarget = vi.fn(async () => ({ + worktree: { id: 'folder-1', path: root, kind: 'folder', repoId: 'folder-repo' }, + executionHostId: `ssh:${connection}` + })) + const host = { + getRuntimeId: () => 'runtime-fixture', + requireStore: vi.fn(() => { + throw new Error('Local store should not be read') + }), + resolveRuntimeFileTarget: resolveTarget + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies runtime identity, target resolution and the guarded store accessor used by these reads. + const commands = new RuntimeFileCommands(host as never) + const runtime = { + getRuntimeId: host.getRuntimeId, + pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands), + statRuntimeFile: commands.statRuntimeFile.bind(commands) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatch is limited to the two file methods implemented by this fixture. + const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: FILE_METHODS }) + const dispatch = (relativePaths: string[]) => + dispatcher.dispatch({ + id: 'batch-1', + authToken: 'fixture', + method: 'files.pathsExist', + params: { worktree: 'id:folder-1', relativePaths } + }) + return { names, provider, resolveTarget, host, dispatch } +} +it('actual RPC dispatch resolves one folder owner and sends one provider batch for eight real files', async () => { + const f = await setup() + expect(await f.dispatch(f.names)).toMatchObject({ + ok: true, + result: f.names.map(() => ({ exists: true })) + }) + expect(f.resolveTarget).toHaveBeenCalledTimes(1) + expect(f.resolveTarget).toHaveBeenCalledWith('id:folder-1') + expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) + expect(f.provider.stat).not.toHaveBeenCalled() + expect(f.host.requireStore).not.toHaveBeenCalled() + expect(await f.dispatch(['../escape'])).toMatchObject({ ok: false }) + expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) +}) +it('legacy provider preserves all answers through scoped scalar stats', async () => { + const f = await setup(true) + expect(await f.dispatch([...f.names, 'missing'])).toMatchObject({ + ok: true, + result: [...f.names.map(() => ({ exists: true })), { exists: false }] + }) + expect(f.provider.stat).toHaveBeenCalledTimes(9) + expect(f.host.requireStore).not.toHaveBeenCalled() +}) +it('unavailable SSH never falls back to matching local files; oversized input never reaches provider', async () => { + const f = await setup() + unregisterSshFilesystemProvider(connection) + expect(await f.dispatch(f.names)).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('Remote connection dropped') } + }) + expect(f.host.requireStore).not.toHaveBeenCalled() + expect(await f.dispatch(Array(129).fill('file-0.ts'))).toMatchObject({ ok: false }) + expect(f.provider.pathsExist).not.toHaveBeenCalled() +}) diff --git a/src/main/runtime/runtime-file-path-existence.ts b/src/main/runtime/runtime-file-path-existence.ts new file mode 100644 index 000000000000..197cf7ac814f --- /dev/null +++ b/src/main/runtime/runtime-file-path-existence.ts @@ -0,0 +1,39 @@ +import { stat } from 'node:fs/promises' +import { capturePathExistence, type PathExistenceResult } from '../../shared/path-existence-batch' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { isENOENT } from '../ipc/filesystem-path-containment' +import type { RuntimeFileCommandHost } from './runtime-file-command-host' +import { + requireRuntimeFileProvider, + type RuntimeFileExplorerPath +} from './runtime-file-command-target' + +export async function readRuntimeFilePathExistence( + targets: readonly RuntimeFileExplorerPath[], + requireStore: RuntimeFileCommandHost['requireStore'] +): Promise { + if (targets.length === 0) { + return [] + } + const provider = requireRuntimeFileProvider(targets[0]) + if (provider?.pathsExist) { + return provider.pathsExist(targets.map((target) => target.path)) + } + return Promise.all( + targets.map((target) => + capturePathExistence(async () => { + try { + await (provider + ? provider.stat(target.path) + : stat(await resolveAuthorizedPath(target.path, requireStore()))) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + }) + ) + ) +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 0312bc87fd0c..478f3040f72e 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -1,3 +1,4 @@ +import type { PathExistenceResult } from '../../shared/path-existence-batch' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import type { DirEntry, @@ -111,6 +112,10 @@ export type FilesystemApi = { filePath: string connectionId?: string }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> + pathsExist?: (args: { + filePaths: string[] + connectionId?: string + }) => Promise pathExists: (args: { filePath: string; connectionId?: string }) => Promise listFiles: (args: { rootPath: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index c67e9abd9e3b..207b34d85195 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,3 +1,4 @@ +import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' import type { SearchResult } from '../../shared/code-search-types' @@ -116,6 +117,10 @@ export const fsApi = { connectionId?: string }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => ipcRenderer.invoke('fs:stat', args), + pathsExist: (args: { + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:pathsExist', args), pathExists: (args: { filePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:pathExists', args), listFiles: (args: { diff --git a/src/preload/api/shell-api.ts b/src/preload/api/shell-api.ts index d466ac261635..49e811aeb659 100644 --- a/src/preload/api/shell-api.ts +++ b/src/preload/api/shell-api.ts @@ -19,6 +19,7 @@ export type ShellApi = { openUrl: (url: string) => Promise openFilePath: (path: string) => Promise openFileUri: (uri: string) => Promise + pathsExist?: (paths: string[]) => Promise pathExists: (path: string) => Promise pickAttachment: () => Promise pickImage: () => Promise diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index 21ccda3fd83d..6cd250042a69 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -23,6 +23,8 @@ export const shellApi = { openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), + pathsExist: (paths: string[]): Promise => + ipcRenderer.invoke('shell:pathsExist', paths), pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), diff --git a/src/relay/fs-handler-file-range-dispatch.test.ts b/src/relay/fs-handler-file-range-dispatch.test.ts index 3f1de4693a43..20f5d69dd213 100644 --- a/src/relay/fs-handler-file-range-dispatch.test.ts +++ b/src/relay/fs-handler-file-range-dispatch.test.ts @@ -149,7 +149,7 @@ describe('fs.getCapabilities', () => { // is additive. Dropping the pre-existing key would strand an older desktop's // quick-open probe on a host that still serves it. it('advertises ranged reads without dropping the existing capability', async () => { - await expect(underTest.call('fs.getCapabilities', {})).resolves.toEqual({ + await expect(underTest.call('fs.getCapabilities', {})).resolves.toMatchObject({ quickOpenSearchVersion: 1, rangedReadVersion: 1 }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index d20d0d073d65..6286c71941e9 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,3 +1,4 @@ +import { pathsExistOnRelay } from './fs-path-existence' import { tmpdir } from 'node:os' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' @@ -89,6 +90,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => writeRelayFile(p)) this.dispatcher.onRequest('fs.writeTerminalArtifact', (p) => this.writeTerminalArtifact(p)) + this.dispatcher.onRequest('fs.pathsExist', pathsExistOnRelay) this.dispatcher.onRequest('fs.stat', (p) => statRelayPath(p)) this.dispatcher.onRequest('fs.lstat', (p) => lstatRelayPath(p)) this.dispatcher.onRequest('fs.deletePath', (p) => deleteRelayPath(p, this.watchRegistry)) @@ -102,7 +104,8 @@ export class FsHandler { this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.getCapabilities', async () => ({ quickOpenSearchVersion: 1, - rangedReadVersion: 1 + rangedReadVersion: 1, + pathExistenceBatchVersion: 1 })) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) diff --git a/src/relay/fs-path-existence.ts b/src/relay/fs-path-existence.ts new file mode 100644 index 000000000000..48ee51cda2b5 --- /dev/null +++ b/src/relay/fs-path-existence.ts @@ -0,0 +1,22 @@ +import { statRelayPath } from './fs-path-metadata-requests' +import { capturePathExistence, validatePathExistenceBatch } from '../shared/path-existence-batch' + +export async function pathsExistOnRelay(params: Record) { + const paths = params.filePaths + validatePathExistenceBatch(paths) + return Promise.all( + paths.map((filePath) => + capturePathExistence(async () => { + try { + await statRelayPath({ filePath }) + return true + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false + } + throw error + } + }) + ) + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index b3890bb18b5b..75523e406a78 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -1,3 +1,4 @@ +import { createTerminalPathExistenceBatch } from './terminal-path-existence-batch' import type { IDisposable, ILink, ILinkProvider, Terminal } from '@xterm/xterm' import { extractTerminalFileLinkCandidates, @@ -5,7 +6,7 @@ import { resolveTerminalFileLink } from '@/lib/terminal-links' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { isRemoteRuntimeFileOperation, runtimePathExists } from '@/runtime/runtime-file-client' +import { isRemoteRuntimeFileOperation } from '@/runtime/runtime-file-client' import { buildCandidateLogicalLinesForBufferPosition, dedupeLogicalLines, @@ -128,6 +129,7 @@ export function createFilePathLinkProvider( return } + const pathExists = createTerminalPathExistenceBatch() void Promise.all( logicalLines.flatMap((logicalLine) => extractTerminalFileLinkCandidates(logicalLine.text).map( @@ -172,10 +174,7 @@ export function createFilePathLinkProvider( if (!worktreeRootLink) { const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey) const exists = - cachedExists ?? - (fileContext.connectionId || isRemoteRuntimePath - ? await runtimePathExists(fileContext, mappedPath) - : await window.api.shell.pathExists(mappedPath)) + cachedExists ?? (await pathExists(fileContext, mappedPath, isRemoteRuntimePath)) writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists) if (!exists) { return null diff --git a/src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts new file mode 100644 index 000000000000..d79c91aae045 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts @@ -0,0 +1,92 @@ +import type { ILink } from '@xterm/xterm' +import { expect, it, vi } from 'vitest' +import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' +import { + createProvider, + createProviderSetup, + makeBufferLine +} from './terminal-link-provider-buffer-fixtures' +import { + createDeferred, + flushAsyncWork, + installTerminalLinkTestEnvironment +} from './terminal-link-handlers-test-harness' + +const doubles = createTerminalLinkTestDoubles() +const { storeState } = doubles + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => storeState + } +})) + +vi.mock('@/lib/language-detect', () => ({ + detectLanguage: (filePath: string) => (filePath.endsWith('.md') ? 'markdown' : 'plaintext') +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null) +})) + +installTerminalLinkTestEnvironment(doubles) + +it.each([false, true])('batches all cold hover candidates repeated=%s', async (repeated) => { + const text = Array.from( + { length: 8 }, + (_, i) => `./${repeated ? 'same' : `file${i}`}.ts:${i + 1}` + ).join(' ') + const batch = vi.fn(async (paths: string[]) => paths.map(() => true)) + window.api.shell.pathsExist = batch + const { provider } = createProviderSetup([makeBufferLine(text)], new Map()) + const links = await new Promise((resolve) => + provider.provideLinks(1, (links) => resolve(links ?? [])) + ) + expect(batch).toHaveBeenCalledTimes(1) + expect(batch.mock.calls[0][0]).toHaveLength(repeated ? 1 : 8) + expect(window.api.shell.pathExists).not.toHaveBeenCalled() + expect(links).toHaveLength(8) + expect(links.map((link) => link.text)).toEqual( + Array.from({ length: 8 }, (_, i) => `./${repeated ? 'same' : `file${i}`}.ts:${i + 1}`) + ) + expect(new Set(links.map((link) => JSON.stringify(link.range))).size).toBe(8) +}) + +it('preserves warm positive and negative cache answers across hover turns', async () => { + const batch = vi.fn(async (paths: string[]) => paths.map((path) => !path.endsWith('missing.ts'))) + window.api.shell.pathsExist = batch + const cache = new Map() + const { provider } = createProviderSetup([makeBufferLine('./present.ts ./missing.ts')], cache) + const hover = () => + new Promise((resolve) => provider.provideLinks(1, (links) => resolve(links ?? []))) + expect((await hover()).map((link) => link.text)).toEqual(['./present.ts']) + expect((await hover()).map((link) => link.text)).toEqual(['./present.ts']) + expect(batch).toHaveBeenCalledTimes(1) + expect(batch.mock.calls[0][0]).toHaveLength(2) + expect([...cache.values()].sort()).toEqual([false, true]) +}) + +it('drops stale wrapped links while a batch is pending', async () => { + const rows = [ + makeBufferLine('open src/components/'), + makeBufferLine('terminal-link-handlers.ts', { isWrapped: true }) + ] + const exists = createDeferred() + const batch = vi.fn(() => exists.promise) + window.api.shell.pathsExist = batch + const provider = createProvider(rows) + const callback = vi.fn() + provider.provideLinks(1, callback) + await flushAsyncWork() + expect(batch).toHaveBeenCalledTimes(1) + rows[0] = makeBufferLine('changed src/other/') + exists.resolve([true]) + await flushAsyncWork() + await flushAsyncWork() + expect(callback).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts new file mode 100644 index 000000000000..2945a8c25005 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createTerminalPathExistenceBatch } from '@/components/terminal-pane/terminal-path-existence-batch' +import { requirePathExistenceResults } from '../../../../shared/path-existence-batch' +const remote = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-path-existence-batch', () => ({ runtimePathsExist: remote })) +const context = { settings: null, worktreeId: 'folder:/work', worktreePath: '/work' } +afterEach(() => { + vi.unstubAllGlobals() + remote.mockReset() +}) +it('chunks 257 paths without truncation and preserves each result position', async () => { + const batch = vi.fn(async (paths: string[]) => + paths.map((path) => Number(path.slice(1)) % 2 === 0) + ) + vi.stubGlobal('window', { api: { shell: { pathsExist: batch } } }) + const enqueue = createTerminalPathExistenceBatch() + const results = await Promise.all( + Array.from({ length: 257 }, (_, i) => enqueue(context, `/${i}`, false)) + ) + expect(batch.mock.calls.map((call) => call[0].length)).toEqual([128, 128, 1]) + expect(results).toEqual(Array.from({ length: 257 }, (_, i) => i % 2 === 0)) +}) +it('isolates identical paths by runtime, worktree, connection, and hover turn', async () => { + remote.mockImplementation(async (_context, paths: string[]) => + paths.map(() => ({ exists: true })) + ) + const enqueue = createTerminalPathExistenceBatch() + const contexts = [ + { ...context, settings: { activeRuntimeEnvironmentId: 'a' } }, + { ...context, settings: { activeRuntimeEnvironmentId: 'b' } }, + { + ...context, + settings: { activeRuntimeEnvironmentId: 'a' }, + worktreeId: 'folder:/other', + worktreePath: '/other' + }, + { ...context, connectionId: 'ssh-one' }, + { ...context, connectionId: 'ssh-two' } + ] + await Promise.all( + contexts.flatMap((ctx) => [enqueue(ctx, '/same', true), enqueue(ctx, '/same', true)]) + ) + expect(remote).toHaveBeenCalledTimes(5) + expect(remote.mock.calls.map((call) => call[0])).toEqual(contexts) + expect(remote.mock.calls.every((call) => call[1].length === 1)).toBe(true) + await enqueue(contexts[0], '/same', true) + expect(remote).toHaveBeenCalledTimes(6) +}) +it('rejects transport failure without turning it into a missing path', async () => { + remote.mockRejectedValue(new Error('connection closed')) + const enqueue = createTerminalPathExistenceBatch() + await expect( + Promise.all([ + enqueue({ ...context, connectionId: 'ssh' }, '/a', true), + enqueue({ ...context, connectionId: 'ssh' }, '/b', true) + ]) + ).rejects.toThrow('connection closed') + expect(remote).toHaveBeenCalledTimes(1) +}) +it.each([ + { rows: [{ exists: true, error: 'denied' }] }, + { rows: [{ exists: 'yes', error: 'denied' }] }, + { rows: [{ exists: false }, {}] } +])('rejects ambiguous or incomplete wire results $rows', ({ rows }) => { + expect(() => requirePathExistenceResults(rows, rows.length)).toThrow( + 'Invalid path existence response' + ) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts new file mode 100644 index 000000000000..010fb36bf826 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts @@ -0,0 +1,117 @@ +import { + PATH_EXISTENCE_BATCH_MAX, + type PathExistenceResult +} from '../../../../shared/path-existence-batch' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { captureRuntimeEnvironmentRequestRevision } from '@/runtime/runtime-environment-revision' + +type PendingPath = { + path: string + resolve: (exists: boolean) => void + reject: (error: unknown) => void + promise: Promise +} +type PathGroup = { + context: RuntimeFileOperationArgs + remote: boolean + pairingRevision?: number + paths: Map +} + +/** One hover turn shares a host request; no answers survive into another turn. */ +export function createTerminalPathExistenceBatch(): ( + context: RuntimeFileOperationArgs, + path: string, + remote: boolean +) => Promise { + const groups = new Map() + let queued = false + return (context, path, remote) => { + const target = getActiveRuntimeTarget(context.settings) + const pairingRevision = + target.kind === 'environment' + ? captureRuntimeEnvironmentRequestRevision(target.environmentId) + : undefined + const key = JSON.stringify([ + context.settings?.activeRuntimeEnvironmentId, + context.worktreeId, + context.worktreePath, + context.connectionId, + pairingRevision, + remote + ]) + let group = groups.get(key) + if (!group) { + group = { context, remote, pairingRevision, paths: new Map() } + groups.set(key, group) + } + const existing = group.paths.get(path) + if (existing) { + return existing.promise + } + let resolve!: (exists: boolean) => void + let reject!: (error: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + group.paths.set(path, { path, resolve, reject, promise }) + if (!queued) { + queued = true + queueMicrotask(() => { + void flush() + }) + } + return promise + } + + async function flush(): Promise { + const ready = [...groups.values()] + groups.clear() + queued = false + await Promise.all( + ready.flatMap((group) => { + const pending = [...group.paths.values()] + return Array.from( + { length: Math.ceil(pending.length / PATH_EXISTENCE_BATCH_MAX) }, + (_, index) => + run( + group, + pending.slice( + index * PATH_EXISTENCE_BATCH_MAX, + (index + 1) * PATH_EXISTENCE_BATCH_MAX + ) + ) + ) + }) + ) + } + async function run(group: PathGroup, pending: PendingPath[]): Promise { + try { + const paths = pending.map((row) => row.path) + let results: PathExistenceResult[] + if (group.context.connectionId || group.remote) { + results = await runtimePathsExist(group.context, paths, group.pairingRevision) + } else { + const values = window.api.shell.pathsExist + ? await window.api.shell.pathsExist(paths) + : await Promise.all(paths.map((path) => window.api.shell.pathExists(path))) + if (values.length !== paths.length || values.some((value) => typeof value !== 'boolean')) { + throw new Error('Invalid local path existence response') + } + results = values.map((exists) => ({ exists })) + } + results.forEach((result, index) => { + if ('exists' in result) { + pending[index].resolve(result.exists) + } else { + pending[index].reject(new Error(result.error)) + } + }) + } catch (error) { + pending.forEach((row) => row.reject(error)) + } + } +} diff --git a/src/renderer/src/runtime/runtime-file-metadata-client.ts b/src/renderer/src/runtime/runtime-file-metadata-client.ts index d072e800cb8b..e9d2e867b77d 100644 --- a/src/renderer/src/runtime/runtime-file-metadata-client.ts +++ b/src/renderer/src/runtime/runtime-file-metadata-client.ts @@ -43,6 +43,13 @@ export async function statRuntimePath( ) } +export function isMissingRuntimePathError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return ( + message.includes('enoent') || message.includes('not found') || message.includes('no such file') + ) +} + export async function runtimePathExists( context: RuntimeFileOperationArgs, absolutePath: string, @@ -66,12 +73,7 @@ export async function runtimePathExists( ) return true } catch (err) { - const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase() - if ( - message.includes('enoent') || - message.includes('not found') || - message.includes('no such file') - ) { + if (isMissingRuntimePathError(err)) { return false } throw err diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts new file mode 100644 index 000000000000..877e5a0f9863 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from 'vitest' +import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall, + fsPathExists +} from '@/runtime/runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' +installRuntimeFileClientEnvironment() +const context = { + settings: { activeRuntimeEnvironmentId: 'owner-a' }, + worktreeId: 'folder-1', + worktreePath: '/folder' +} +const paths = Array.from({ length: 8 }, (_, i) => `/folder/file-${i}.ts`) +function status(capabilities: string[]) { + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) +} +it('paired runtime receives one eight-path request scoped to the folder workspace', async () => { + status(['files.pathsExist']) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'batch', + ok: true, + result: paths.map(() => ({ exists: true })) + }) + expect(await runtimePathsExist(context, paths)).toEqual(paths.map(() => ({ exists: true }))) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall.mock.calls[0][0]).toMatchObject({ + selector: 'owner-a', + method: 'files.pathsExist', + params: { worktree: 'id:folder-1', relativePaths: paths.map((p) => p.slice('/folder/'.length)) } + }) + expect(fsPathExists).not.toHaveBeenCalled() +}) +it('old paired host retains scalar stats on that runtime and does not call the new method', async () => { + status([]) + runtimeEnvironmentCall.mockResolvedValue({ id: 'stat', ok: true, result: { size: 1 } }) + expect(await runtimePathsExist(context, paths)).toEqual(paths.map(() => ({ exists: true }))) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(8) + expect( + runtimeEnvironmentCall.mock.calls.every( + ([args]) => args.method === 'files.stat' && args.selector === 'owner-a' + ) + ).toBe(true) + expect(fsPathExists).not.toHaveBeenCalled() +}) +it('new method missing after capability discovery falls back only for method_not_found', async () => { + status(['files.pathsExist']) + runtimeEnvironmentCall.mockImplementation(async (args) => + args.method === 'files.pathsExist' + ? { id: 'batch', ok: false, error: { code: 'method_not_found', message: 'Unknown method' } } + : { id: 'stat', ok: true, result: { size: 1 } } + ) + expect(await runtimePathsExist(context, paths)).toEqual(paths.map(() => ({ exists: true }))) + expect( + runtimeEnvironmentCall.mock.calls.filter(([args]) => args.method === 'files.stat') + ).toHaveLength(8) +}) +it('remote errors stay errors, and an out-of-scope path cannot read the local filesystem', async () => { + status(['files.pathsExist']) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'batch', + ok: true, + result: [{ error: 'SSH connection closed' }] + }) + expect(await runtimePathsExist(context, [paths[0]])).toEqual([{ error: 'SSH connection closed' }]) + expect(await runtimePathsExist(context, ['/outside/file.ts'])).toEqual([ + { error: expect.stringContaining('outside') } + ]) + expect(fsPathExists).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.ts b/src/renderer/src/runtime/runtime-path-existence-batch.ts new file mode 100644 index 000000000000..280bcb4817c6 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-batch.ts @@ -0,0 +1,81 @@ +import { + capturePathExistence, + PATH_EXISTENCE_BATCH_CAPABILITY, + requirePathExistenceResults, + type PathExistenceResult +} from '../../../shared/path-existence-batch' +import type { RuntimeFileOperationArgs } from './runtime-file-client-types' +import { assertLocalFilesystemFallbackAllowed, getRemoteFileArgs } from './runtime-file-routing' +import { isMissingRuntimePathError, runtimePathExists } from './runtime-file-metadata-client' +import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' +import { + callRuntimeRpc, + runtimeEnvironmentSupportsCapability, + RuntimeRpcCallError +} from './runtime-rpc-client' + +export async function runtimePathsExist( + context: RuntimeFileOperationArgs, + paths: string[], + expectedPairingRevision?: number +): Promise { + const routes = paths.map((path) => getRemoteFileArgs(context, path)) + const first = routes[0] + const expectedEnvironmentPairingRevision = first + ? captureRuntimeEnvironmentRequestRevision(first.target.environmentId, expectedPairingRevision) + : undefined + const fallback = () => + Promise.all( + paths.map((path) => + capturePathExistence(() => + runtimePathExists(context, path, expectedEnvironmentPairingRevision) + ) + ) + ) + if (!first || routes.some((route) => !route)) { + if (routes.every((route) => !route) && window.api.fs.pathsExist) { + // Scalar routing performs the same ownership fence before local IPC. + assertLocalFilesystemFallbackAllowed(context) + return requirePathExistenceResults( + await window.api.fs.pathsExist({ filePaths: paths, connectionId: context.connectionId }), + paths.length + ) + } + return fallback() + } + try { + if ( + !(await runtimeEnvironmentSupportsCapability( + first.target.environmentId, + PATH_EXISTENCE_BATCH_CAPABILITY, + 15_000 + )) + ) { + return fallback() + } + const result = requirePathExistenceResults( + await callRuntimeRpc( + first.target, + 'files.pathsExist', + { + worktree: first.worktreeSelector, + relativePaths: routes.map((route) => route!.relativePath) + }, + { timeoutMs: 15_000, expectedEnvironmentPairingRevision } + ), + paths.length + ) + // Preserve runtimePathExists's legacy missing-error interpretation. + return result.map((row) => + 'error' in row && isMissingRuntimePathError(row.error) ? { exists: false } : row + ) + } catch (error) { + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + return fallback() + } + if (isMissingRuntimePathError(error)) { + return paths.map(() => ({ exists: false })) + } + throw error + } +} diff --git a/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts new file mode 100644 index 000000000000..9cb469c4871b --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from 'vitest' +import { runtimePathsExist } from './runtime-path-existence-batch' +import { runtimePathExists } from './runtime-file-metadata-client' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall +} from './runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' +installRuntimeFileClientEnvironment() +for (const mode of ['scalar', 'batch'] as const) { + it(`${mode} preserves missing-owner error interpretation`, async () => { + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'missing', + ok: false, + error: { code: 'not_found', message: 'Worktree not found: id:folder-1' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + if (mode === 'scalar') { + expect(await runtimePathExists(context, '/folder/file.ts')).toBe(false) + } else { + expect(await runtimePathsExist(context, ['/folder/file.ts'])).toEqual([{ exists: false }]) + } + }) +} + +for (const stage of ['status', 'operation'] as const) { + it(`preserves missing errors from ${stage} and still rejects permission/transport failures`, async () => { + let message = 'Worktree not found: id:folder-1' + const failure = () => ({ id: 'failure', ok: false, error: { code: 'not_found', message } }) + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? stage === 'status' + ? failure() + : { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) + runtimeEnvironmentCall.mockImplementation(async () => failure()) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + expect(await runtimePathsExist(context, ['/folder/file.ts'])).toEqual([{ exists: false }]) + for (message of ['permission denied', 'SSH connection closed']) { + await expect(runtimePathsExist(context, ['/folder/file.ts'])).rejects.toThrow(message) + } + }) +} diff --git a/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts new file mode 100644 index 000000000000..6cdc453ec454 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts @@ -0,0 +1,60 @@ +import { expect, it } from 'vitest' +import { runtimePathsExist } from './runtime-path-existence-batch' +import { runtimePathExists } from './runtime-file-metadata-client' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall +} from './runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' + +installRuntimeFileClientEnvironment() +for (const mode of ['scalar', 'batch', 'legacy', 'missing-method']) { + const batch = mode !== 'scalar' + it(`${mode} preserves the owner captured before discovery`, async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 10 }]) + runtimeEnvironmentTransportCall.mockImplementation(async (args) => { + if (args.method === 'status.get') { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 20 }]) + return { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: mode === 'legacy' ? [] : ['files.pathsExist'] + }, + _meta: { runtimeId: 'original-owner' } + } + } + return runtimeEnvironmentCall(args) + }) + runtimeEnvironmentCall.mockImplementation(async (args) => { + if (mode === 'missing-method' && args.method === 'files.pathsExist') { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 30 }]) + return { + id: 'missing', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' } + } + } + return { id: 'result', ok: true, result: [{ exists: true }] } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + await (batch + ? runtimePathsExist(context, ['/folder/file.ts']) + : runtimePathExists(context, '/folder/file.ts')) + expect(runtimeEnvironmentCall.mock.calls.length).toBeGreaterThan(0) + for (const [request] of runtimeEnvironmentCall.mock.calls) { + expect(request.expectedEnvironmentPairingRevision).toBe(10) + } + }) +} diff --git a/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts new file mode 100644 index 000000000000..f972b51eb360 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from 'vitest' +import { createTerminalPathExistenceBatch } from '../components/terminal-pane/terminal-path-existence-batch' +import { runtimePathExists } from './runtime-file-metadata-client' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall +} from './runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' + +installRuntimeFileClientEnvironment() +for (const mode of ['scalar', 'queue', 'legacy', 'missing-method'] as const) { + it(`${mode} retains the pairing owner when a hover waits for its queued flush`, async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 10 }]) + runtimeEnvironmentTransportCall.mockImplementation(async (args) => { + if (args.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: mode === 'legacy' ? [] : ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + } + return runtimeEnvironmentCall(args) + }) + runtimeEnvironmentCall.mockImplementation(async (args) => { + if (mode === 'missing-method' && args.method === 'files.pathsExist') { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 30 }]) + return { + id: 'missing', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' } + } + } + return { id: 'result', ok: true, result: [{ exists: true }] } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + const pending = + mode === 'scalar' + ? runtimePathExists(context, '/folder/file.ts') + : createTerminalPathExistenceBatch()(context, '/folder/file.ts', true) + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 20 }]) + await pending + expect(runtimeEnvironmentCall.mock.calls.length).toBeGreaterThan(0) + for (const [request] of runtimeEnvironmentCall.mock.calls) { + expect(request.expectedEnvironmentPairingRevision).toBe(10) + } + }) +} + +it('keeps two queued hovers on distinct revisions of the same environment', async () => { + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) + runtimeEnvironmentCall.mockResolvedValue({ id: 'result', ok: true, result: [{ exists: true }] }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + const enqueue = createTerminalPathExistenceBatch() + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 10 }]) + const first = enqueue(context, '/folder/file.ts', true) + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 20 }]) + const second = enqueue(context, '/folder/file.ts', true) + expect(first).not.toBe(second) + await Promise.all([first, second]) + expect( + runtimeEnvironmentCall.mock.calls + .map(([args]) => args.expectedEnvironmentPairingRevision) + .sort() + ).toEqual([10, 20]) +}) diff --git a/src/renderer/src/web/preload-api/web-shell-api.ts b/src/renderer/src/web/preload-api/web-shell-api.ts index 59b067fb3290..138ebe9abb46 100644 --- a/src/renderer/src/web/preload-api/web-shell-api.ts +++ b/src/renderer/src/web/preload-api/web-shell-api.ts @@ -1,6 +1,15 @@ import type { PreloadApi } from '../../../../preload/api-types' import { resolveRuntimeFilePath } from './web-runtime-worktree-catalog' +async function pathExistsOnRuntime(path: string): Promise { + try { + await resolveRuntimeFilePath(path) + return true + } catch { + return false + } +} + export function createShellApi(): NonNullable['shell']> { const openResult = { ok: true } as const return { @@ -12,14 +21,9 @@ export function createShellApi(): NonNullable['shell']> { openFilePath: () => Promise.resolve(false), openFileUri: (uri) => Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), - pathExists: async (path) => { - try { - await resolveRuntimeFilePath(path) - return true - } catch { - return false - } - }, + pathExists: async (path) => pathExistsOnRuntime(path), + // Without this the fallback proxy answers `undefined` and the caller's batch rejects. + pathsExist: (paths) => Promise.all(paths.map((path) => pathExistsOnRuntime(path))), pickAttachment: () => Promise.resolve(null), pickImage: () => Promise.resolve(null), pickRepoIconImage: () => Promise.resolve(null), diff --git a/src/renderer/src/web/web-shell-paths-exist.test.ts b/src/renderer/src/web/web-shell-paths-exist.test.ts new file mode 100644 index 000000000000..dc0819afa7df --- /dev/null +++ b/src/renderer/src/web/web-shell-paths-exist.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./preload-api/web-runtime-worktree-catalog', () => ({ + resolveRuntimeFilePath: vi.fn(async (path: string) => { + if (path.includes('missing')) { + throw new Error('not found') + } + return path + }) +})) + +describe('web shell path existence', () => { + it('defines pathsExist so withFallback cannot substitute an undefined-returning proxy', async () => { + const { createShellApi } = await import('./preload-api/web-shell-api') + const { withFallback } = await import('./preload-api/web-fallback-api') + const shell = withFallback(createShellApi(), ['shell']) + expect(shell.pathsExist).toBeTypeOf('function') + const values = await shell.pathsExist?.(['/repo/present.ts', '/repo/missing.ts']) + expect(values).toEqual([true, false]) + }) +}) diff --git a/src/shared/path-existence-batch.ts b/src/shared/path-existence-batch.ts new file mode 100644 index 000000000000..3f499e33162f --- /dev/null +++ b/src/shared/path-existence-batch.ts @@ -0,0 +1,40 @@ +export const PATH_EXISTENCE_BATCH_MAX = 128 +export const PATH_EXISTENCE_BATCH_CAPABILITY = 'files.pathsExist' +export type PathExistenceResult = { exists: boolean } | { error: string } + +export function validatePathExistenceBatch(paths: unknown): asserts paths is string[] { + if ( + !Array.isArray(paths) || + paths.length > PATH_EXISTENCE_BATCH_MAX || + paths.some((path) => typeof path !== 'string') + ) { + throw new Error('Invalid path existence batch') + } +} + +export async function capturePathExistence( + check: () => Promise +): Promise { + try { + return { exists: await check() } + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} + +export function requirePathExistenceResults(value: unknown, count: number): PathExistenceResult[] { + if (!Array.isArray(value) || value.length !== count) { + throw new Error('Invalid path existence response') + } + return value.map((row: unknown): PathExistenceResult => { + if (row && typeof row === 'object') { + if ('exists' in row && typeof row.exists === 'boolean' && !('error' in row)) { + return { exists: row.exists } + } + if ('error' in row && typeof row.error === 'string' && !('exists' in row)) { + return { error: row.error } + } + } + throw new Error('Invalid path existence response') + }) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index aaec08cf46f7..99908de9b844 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -239,6 +239,7 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ] as const export const RUNTIME_CAPABILITIES = [ + 'files.pathsExist', 'runtime.status.compat.v1', 'runtime.environments.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, diff --git a/src/shared/rpc-contract/files-params.ts b/src/shared/rpc-contract/files-params.ts index 6ae6267caa0f..80fdba2a1ac6 100644 --- a/src/shared/rpc-contract/files-params.ts +++ b/src/shared/rpc-contract/files-params.ts @@ -1,7 +1,12 @@ +import { PATH_EXISTENCE_BATCH_MAX } from '../path-existence-batch' import { z } from 'zod' import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open-path-search' import { FileOpen, WorktreeSelector } from './files-target-params' +export const FilePathsExist = WorktreeSelector.extend({ + relativePaths: z.array(z.string()).max(PATH_EXISTENCE_BATCH_MAX) +}) + export const FilePathSearch = WorktreeSelector.extend({ query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''), limit: z.number().int().positive().max(32).default(16), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index eaaab7e66ec0..deae9bc0eaa4 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -169,6 +169,7 @@ import { FileListAll, FileOpenDiff, FilePathSearch, + FilePathsExist, FileReadChunk, FileSearch, FileTreePath, @@ -737,6 +738,7 @@ export const RPC_PARAMS_BY_METHOD = { 'files.listMarkdownDocuments': WorktreeSelector, 'files.open': FileOpen, 'files.openDiff': FileOpenDiff, + 'files.pathsExist': FilePathsExist, 'files.read': FileOpen, 'files.readChunk': FileReadChunk, 'files.readDir': FileTreePath, From 5e70014da8ee6aa6635fe4f031baa7dbaca17231 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:41:51 -0700 Subject: [PATCH 014/209] feat(native-chat): support file drag and drop (#20494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): support workspace file drops * fix(native-chat): report OS file drops that attach nothing #15782 is a silent failure on the Finder route, and that route still swallowed every way it could fail: - the preload handler returned with no feedback when the OS handed us file items `webUtils.getPathForFile` could read no path from (promised or virtual files). It now sends the existing `rejected` payload with a new `unresolved-paths` reason, which the global drop toast names. - the composer's external-attach path dropped the batch with no notice when every path failed authorization, when an upload came back empty, and (new in this branch) when the owner changed mid-flight. Each exit now sets a notice; only a disabled composer stays quiet, because it has no notice surface. Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a drop/IME handler when an SSH connection's generation is gone mid-attach — that is an unknown owner, which the resolver already models as `not-ready`. * refactor(native-chat): one owner-identity check for composer attachments The branch had two near-identical "is this still the same owner" helpers, one per attach route, and they disagreed: the workspace-drop copy ignored the SSH connection generation, so a reconnect between the drop and the IME flush read as the same owner and the path landed on a new connection. Collapses both onto one predicate in the pure ownership module (the store/toast-free seam both routes already depend on), which compares the full SSH expectation and never treats `not-ready` as a match. * perf(file-explorer): resolve drag ownership at dragstart, not per render The virtualized row list resolved the selection's source execution host on every render — the virtualizer re-renders on every scroll frame, so a large multi-selection paid a full projection scan plus a route allocation per selected path per frame, and per visible row on top of that. Only `onDragStart` ever read the result. Rows now receive a resolver they call with the paths they are about to drag. The three copies of the "stamp only if both halves resolve" guard (explorer row, both combined-diff row shapes) collapse into one helper next to the writer. * fix(native-chat): refuse a guarded composer drop visibly The drop handlers claimed the drag (preventDefault + stopPropagation) before checking `disabled`, so a guarded composer told the browser it accepted the drop, left the copy cursor up, and then did nothing — the same silent swallow this branch exists to remove. Dragover now answers `none` when the composer is guarded, so the cursor refuses and no drop event follows. It still claims the event either way: the composer sits inside the terminal surface, which accepts the same drag and would paste the paths into the shell instead. Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already keeps the event off the editor below, so the stronger form only risked suppressing unrelated listeners on the React root. The fake DataTransfer in the test now starts at a dropEffect we never write, so asserting `none` or `copy` proves the handler set it. * fix(native-chat): decide attachment ownership per path, not per batch A queued batch can mix sources — a workspace drop the target host owns and a client-local paste it cannot read — because IME composition holds both until it settles. Collapsing the batch to one verdict refused the whole thing on a remote target, including the drop the user was entitled to make. The verdict now follows the path it belongs to: owned paths attach, client-local ones are refused, and the refusal is reported rather than dropped. A stale owner still refuses everything, since that means the target moved under all of them. Also guards the empty-batch case, which previously read as "every path owned". * refactor(combined-diff): resolve drag ownership from the live workspace The combined diff captured an execution host into the open-file record at tab open and drilled it through three components to reach the row. That host was never persisted, so after a restart every drag from a restored diff was refused until the tab was reopened, and the capture failure was swallowed into an undefined source with no trace. Rows now resolve the owner the same way the source-control rows already do, from the workspace the diff belongs to at the moment of the drag. That deletes the prop drilling, the store capture and its bare catch, and leaves one way to answer "who owns these paths" for every live listing. The file explorer keeps its per-node owner: its tree is a cache that can still be showing a previous host's listing, which is exactly what that field records. * revert(file-explorer): drop the workspace-id tree reset Resetting and reloading the tree when the workspace id changes at an unchanged path is not needed for the drag source to be correct. The tree already records the workspace whose root listing it committed, so a cache left over from a previous workspace stamps that workspace and the composer refuses the drop — the intended answer, reached without touching the reset rule. That rule clears selection, the name filter and undo history, which is more file-explorer behaviour change than this feature asked for. * test(native-chat): stop the external-attach mock hiding new notices The hook's test replaced the whole attachment-owner module with a hand-written stub, so the two notices added alongside the owner-change guards resolved to undefined. Calling them threw inside the async attach loop — an unhandled rejection, which leaves every test in the file reported as passing while the run as a whole fails. CI caught it; a local run reporting only pass/fail counts does not. The mock now spreads the real module, so a notice added later cannot go missing from it, and both owner-change tests assert the string a user would read instead of only asserting that nothing attached. * test(native-chat): guard the last-path owner change on a one-file drop The owner flipping while the final path is authorizing has no next loop iteration to catch it, so the post-loop check is all that stands between a single-file drop and a path attached to a host that no longer owns it — and a one-file drop is the ordinary shape. No test covered that exit. Removing the post-loop check now turns this red; before it, only the multi-path exit was guarded. * fix(native-chat): keep a mixed attachment batch in attach order applyResolvedPaths partitioned a queued batch into a target-owned half and a client-local half and concatenated them. An IME-delayed batch that mixed a workspace drop with a paste made earlier in the same composition was therefore inserted owned-first, so the dropped reference jumped ahead of the pasted one in the draft. Filter against the two verdicts in place instead. Membership is unchanged, the order the user attached in survives, and the two intermediate arrays go away. * fix(file-explorer): name the owner of a dragged path whose row is hidden A multi-selection outlives the rows that showed it. Nothing prunes selectedPaths when a directory collapses, when the name filter narrows, or when dotfiles are hidden, and the drag still carries every selected path. Drag-source resolution read those owners from the row projection, which is built from visible rows only, so one hidden path collapsed the whole drag to an unstamped one and the composer refused it as coming from another workspace. The owner was never unknowable — the dir cache the projection is built from still records which host listed that path. Fall back to it when the path has no visible row. A path in neither (a name-filter synthetic node for a directory that was never listed) still fails closed. * fix(native-chat): ask which workspace the composer serves now The IME-flush ownership check compared the workspace id captured when the drop happened against the same captured value, so for a structured pane the comparison could only ever hold. The live protection came from the host and owner checks beside it; this one asked nothing. Read the id through a ref so the check means what it reads as. A pane whose structured target moves between the drop and the composition settling now refuses the queued path instead of attaching it. * fix(native-chat): ask which workspace an external attach lands on The post-await ownership gate resolved the owner through the render closure, so it re-asked the workspace the attach started in and compared the answer with itself. A tab moved to another workspace mid-authorization passed the gate, and the paths landed in a composer that no longer served that workspace. Read the pane through a ref and compare the workspace identity as well as the owner: two workspaces can both report a local owner, so the owner alone cannot tell them apart. * test(native-chat): read the real notice on a workspace drop The drop tests hand-built their attachment-upload mock and hand-copied the not-ready wording into it, so the assertion tracked the copy rather than the string a user reads: rewording the real notice left all 15 tests green. Spread the real module and override only the owner resolver, matching the two sibling test files in this directory. Rewording the notice now fails the test. * docs(native-chat): restore the hook's doc comment to the hook The workspace comparison landed between the doc block and the function it describes, leaving the comment attached to a type alias. * test(native-chat): cover the upload window for a moved pane The workspace-currency gate guards two windows and only the authorize loop was covered. The upload window is the longer one: the paths go to the worktree the attach captured, so a pane that moved workspaces meanwhile must not receive remote paths living under the workspace it left. * test(native-chat): pin the two untested attachment refusals Refusing an already-blocked target at the drop rather than queueing it had no test: queued paths that can never attach still spend the pending budget, and the next legitimate drop is then turned away for being one too many. Also pins the immediate already-false ownership verdict. Today's only caller settles ownership synchronously so it cannot arrive false, but the hook exports this entry point and the fallback is not a refusal — a false verdict is not "owned", so a remote target blames client-local attachments for an ownership failure. Verified: removing the branch reports the wrong notice. * docs(native-chat): say which rule the ownership refusal follows The per-path comment sat directly above the batch-wide ownership refusal while describing the blocked-target logic below it, so the refusal read as a contradiction of the line under it rather than as the file's stated rule. Name the rule at the refusal: a failed ownership verdict refuses the whole completion, the same way the pending-limit rejection does. --------- Co-authored-by: Merge Sim --- src/preload/preload-runtime-support.ts | 16 +- .../combined-diff/CombinedDiffViewer.tsx | 1 + .../combined-diff-file-tree-row-drag.test.tsx | 87 +++ .../combined-diff-file-tree-row.tsx | 9 + .../combined-diff-file-tree-rows.tsx | 3 + .../browse-files/combined-diff-file-tree.tsx | 3 + .../native-chat/NativeChatComposer.tsx | 9 + .../native-chat/NativeChatComposerField.tsx | 13 +- .../native-chat-attachment-upload.test.ts | 12 + .../native-chat-attachment-upload.ts | 30 +- .../native-chat-composer-drop-scope.test.tsx | 101 +++- ...chat-composer-workspace-file-drop.test.tsx | 506 ++++++++++++++++++ ...ative-chat-resolved-path-ownership.test.ts | 41 ++ .../native-chat-resolved-path-ownership.ts | 35 ++ ...-native-chat-composer-attachments.test.tsx | 138 ++++- .../use-native-chat-composer-attachments.ts | 164 ++---- ...-native-chat-external-attachments.test.tsx | 175 +++++- .../use-native-chat-external-attachments.ts | 74 ++- ...e-native-chat-resolved-path-attachments.ts | 205 +++++++ .../use-native-chat-workspace-file-drop.ts | 167 ++++++ .../FileExplorerFilesTreePane.tsx | 2 + .../right-sidebar/FileExplorerRow.tsx | 14 +- .../right-sidebar/FileExplorerVirtualRows.tsx | 54 +- .../file-explorer-drag-scroll-marker.test.tsx | 155 +++++- .../file-explorer-operation-owner.ts | 6 + .../listing/branch-entry-row.tsx | 2 + .../listing/uncommitted-entry-row.tsx | 2 + .../useFileExplorerTree.stale-dirs.test.tsx | 22 + .../right-sidebar/useFileExplorerTree.ts | 7 + .../src/hooks/useGlobalFileDrop.test.ts | 14 + src/renderer/src/hooks/useGlobalFileDrop.ts | 13 + src/renderer/src/i18n/locales/en.json | 9 +- .../src/lib/workspace-file-drag-source.ts | 16 + .../src/lib/workspace-file-drag.test.ts | 30 +- src/renderer/src/lib/workspace-file-drag.ts | 80 +++ src/shared/native-file-drop.ts | 15 +- 36 files changed, 2053 insertions(+), 177 deletions(-) create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts create mode 100644 src/renderer/src/lib/workspace-file-drag-source.ts diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 27ce7d77bc2e..6a9462473c1f 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -13,7 +13,8 @@ import { resolveNativeFileDropPath, type NativeDropResolution, type NativeFileDropPayload, - type NativeFileDropPathEntry + type NativeFileDropPathEntry, + type NativeFileDropRejectedPayload } from '../shared/native-file-drop' /** Joins the synchronous unload checkpoint with its durable renderer write. */ @@ -133,7 +134,18 @@ export function installNativeFileDropHandlers(): void { paths.push(filePath) } } - if (paths.length === 0 || resolution?.target === 'rejected') { + if (resolution?.target === 'rejected') { + return + } + if (paths.length === 0) { + // The OS offered file items we could read no path from (promised or + // virtual files). Report it — silence here is #15782. + ipcRenderer.send('terminal:file-dropped-from-preload', { + byteLength: 0, + pathCount: files.length, + reason: 'unresolved-paths', + target: 'rejected' + } satisfies NativeFileDropRejectedPayload) return } const payload = createNativeFileDropPayload(resolution, paths) diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index 82e7ece6cfbd..449f89cb70fa 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -348,6 +348,7 @@ export default function CombinedDiffViewer({ ({ + executionHostId: 'local' +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => testState.executionHostId +})) + +const { CombinedDiffFileTreeRow } = await import('./combined-diff-file-tree-row') +const { readWorkspaceFileDragSource } = await import('@/lib/workspace-file-drag') + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const roots: Root[] = [] +afterEach(() => { + roots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.replaceChildren() + testState.executionHostId = 'local' +}) + +function renderRow(sourceWorkspaceId?: string): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + act(() => { + root.render( + {}} + onNavigate={() => {}} + /> + ) + }) + return container +} + +function dragRow(container: HTMLDivElement): DataTransfer { + const transfer = new DataTransfer() + const event = new Event('dragstart', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + act(() => { + container.querySelector('[draggable="true"]')?.dispatchEvent(event) + }) + return transfer +} + +describe('combined diff rows stamp their drag source', () => { + // The tab's entry list is a snapshot, but the paths it drags belong to the + // workspace as it is owned now — the same answer the source-control rows give. + it('stamps the live owner of the workspace the diff belongs to', () => { + testState.executionHostId = 'runtime:env-1' + expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toEqual({ + executionHostId: 'runtime:env-1', + workspaceId: 'wt-1' + }) + }) + + it('leaves the drag unstamped when the owner or the workspace is unknown', () => { + expect(readWorkspaceFileDragSource(dragRow(renderRow(undefined)))).toBeNull() + testState.executionHostId = 'runtime:unresolved-owner' + expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx index dfb417e25188..fe6d5644db1c 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx @@ -6,6 +6,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source' import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitFileStatus, @@ -35,6 +36,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ node, mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, isCollapsed, @@ -45,6 +47,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ node: CombinedDiffTreeNode mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string activeSectionKey: string | null sectionIndexByKey: ReadonlyMap isCollapsed: boolean @@ -62,6 +65,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ draggable onDragStart={(event) => { event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path)) + if (sourceWorkspaceId) { + writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId) + } event.dataTransfer.effectAllowed = 'copy' }} > @@ -117,6 +123,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.entry.path) ) + if (sourceWorkspaceId) { + writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId) + } event.dataTransfer.effectAllowed = 'copy' }} onClick={() => onNavigate(node.entry)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx index 229f9561ac0d..3b5abc6113c3 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx @@ -19,6 +19,7 @@ export function CombinedDiffFileTreeRows({ rows, mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, collapsedDirectoryKeys, @@ -30,6 +31,7 @@ export function CombinedDiffFileTreeRows({ rows: readonly CombinedDiffTreeNode[] mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string activeSectionKey: string | null sectionIndexByKey: ReadonlyMap collapsedDirectoryKeys: ReadonlySet @@ -50,6 +52,7 @@ export function CombinedDiffFileTreeRows({ node={node} mode={mode} worktreePath={worktreePath} + sourceWorkspaceId={sourceWorkspaceId} activeSectionKey={activeSectionKey} sectionIndexByKey={sectionIndexByKey} isCollapsed={collapsedDirectoryKeys.has(node.key)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx index 304aca3d19c0..6c19b55d527e 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx @@ -36,6 +36,7 @@ const EMPTY_TREE_ROWS: CombinedDiffTreeNode[] = [] export function CombinedDiffFileTree({ mode, worktreePath, + sourceWorkspaceId, entries, sectionIndexByKey, activeSectionKey, @@ -46,6 +47,7 @@ export function CombinedDiffFileTree({ }: { mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string entries: readonly CombinedDiffFileTreeEntry[] sectionIndexByKey: ReadonlyMap activeSectionKey: string | null @@ -200,6 +202,7 @@ export function CombinedDiffFileTree({ const sharedRowProps = { mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, collapsedDirectoryKeys, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 4833f89fc94d..2f0688dfede4 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -33,6 +33,7 @@ import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-sen import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection' +import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop' export type { NativeChatComposerHandle, @@ -173,6 +174,13 @@ const NativeChatComposerPane = forwardRef attachment.pending) @@ -413,6 +421,7 @@ const NativeChatComposerPane = forwardRef removeImageAttachment(id)} onAttach={pickAttachment} + workspaceFileDropHandlers={workspaceFileDropHandlers} onDictationToggle={toggleDictation} onDictationHoldStart={startHoldDictation} onDictationHoldEnd={stopHoldDictation} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400e..5ea20e3d0cb7 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,6 +1,11 @@ import { NativeChatPromptEditor } from './NativeChatPromptEditor' import type { NativeChatComposerInput } from './native-chat-composer-input' -import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import type { + ClipboardEventHandler, + DragEventHandler, + KeyboardEventHandler, + RefObject +} from 'react' import { useLayoutEffect, useRef } from 'react' import { ImageOff } from 'lucide-react' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' @@ -48,6 +53,10 @@ export type NativeChatComposerFieldProps = { onAcceptMention: () => void onRemoveImageAttachment: (id: string) => void onAttach: () => void + workspaceFileDropHandlers?: { + onDragOverCapture: DragEventHandler + onDropCapture: DragEventHandler + } onDictationToggle: () => void onDictationHoldStart: () => void onDictationHoldEnd: () => void @@ -120,6 +129,7 @@ export function NativeChatComposerField({ onAcceptMention, onRemoveImageAttachment, onAttach, + workspaceFileDropHandlers, onDictationToggle, onDictationHoldStart, onDictationHoldEnd, @@ -185,6 +195,7 @@ export function NativeChatComposerField({ ) : null}
{ }) }) + it('reports not-ready instead of throwing when the SSH generation is gone', () => { + expect( + resolveNativeChatAttachmentOwner( + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + sshConnectionStates: new Map() + }), + 'tab-1' + ) + ).toEqual({ kind: 'not-ready' }) + }) + it('reports not-ready when an SSH worktree has no known path yet', () => { expect( resolveNativeChatAttachmentOwner( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 8157a5190ffc..b7564ba7d9ca 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -82,11 +82,17 @@ export function resolveNativeChatAttachmentOwnerForWorktree( if (!worktreePath) { return { kind: 'not-ready' } } - return { - kind: 'ssh', - connectionId, - worktreePath, - ...captureDirectSshMutationExpectation(state, connectionId) + try { + return { + kind: 'ssh', + connectionId, + worktreePath, + ...captureDirectSshMutationExpectation(state, connectionId) + } + } catch { + // The connection's generation is gone (disconnect mid-attach). That is an + // unknown owner, not a reason to throw out of the drop/IME handler. + return { kind: 'not-ready' } } } @@ -97,6 +103,20 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } +export function nativeChatAttachmentOwnerChangedNotice(): string { + return translate( + 'components.native-chat.composer.attachmentOwnerChanged', + 'This workspace changed hosts while attaching — drop the files again.' + ) +} + +export function nativeChatAttachmentUnreadableNotice(): string { + return translate( + 'components.native-chat.composer.attachmentUnreadable', + "Couldn't read the dropped files." + ) +} + export function nativeChatLocalAttachmentUnsupportedNotice(): string { return translate( 'components.native-chat.composer.localAttachmentUnsupported', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index e535a59fad06..c7c7dec050ce 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render, screen } from '@testing-library/react' -import { useRef } from 'react' +import { useRef, useState } from 'react' +import type * as AttachmentUploadModule from './native-chat-attachment-upload' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' import { resetLocalImageSrcStateForTests } from '../editor/useLocalImageSrc' @@ -30,7 +33,9 @@ const intake = vi.hoisted(() => ({ upload: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) -vi.mock('./native-chat-attachment-upload', () => ({ +// Keeps the real notice strings so the silent-failure guards assert what users see. +vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ + ...(await importOriginal()), resolveNativeChatAttachmentOwner: () => intake.owner, uploadNativeChatAttachmentPaths: intake.upload })) @@ -49,7 +54,8 @@ import { // Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache. function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) { - const textareaRef = useRef(null) + const textareaRef = useRef(null) + const [notice, setNotice] = useState(null) const attachments = useNativeChatComposerAttachments({ attachmentScopeKey: pane, allowWithoutTarget: true, @@ -60,22 +66,28 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea textareaRef, setCaret: () => {}, setDraft: () => {}, - setNotice: () => {} + setNotice }) const { attachExternalPaths } = useNativeChatExternalAttachments({ terminalTabId: pane, disabled: false, attachResolvedPaths: attachments.attachResolvedPaths, - setNotice: () => {} + setNotice }) useNativeChatFileAttachmentActions(pane, attachExternalPaths) return (