From bcb1b1c9f7776644f2b0b5a8c2240169171e33c9 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 06:44:58 -0800 Subject: [PATCH 001/138] test: remove sidebar VM-wake and template hover suites (#40379) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../default-workspace-card.parity.test.tsx | 273 ------------------ .../sidebar-worktree-activation.test.ts | 94 ------ 2 files changed, 367 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx delete mode 100644 src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.test.ts diff --git a/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx b/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx deleted file mode 100644 index a81efd32206..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx +++ /dev/null @@ -1,273 +0,0 @@ -// @vitest-environment happy-dom - -import React, { type ReactNode } from 'react' - -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' - -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../../../shared/worktree/card-properties' -import type { WorktreeCardProps } from '../controller/worktree-card-model' - -type Mocks = { - state: Record - cacheStartedAt: number | null - deleteModifier: boolean - hoverOpenChange: ((open: boolean) => void) | undefined -} -const mocks = vi.hoisted((): Mocks => ({ - state: {}, - cacheStartedAt: null, - deleteModifier: false, - hoverOpenChange: undefined -})) - -vi.mock('@/store', () => { - const getState = () => mocks.state - return { - useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { - getState - }) - } -}) -vi.mock('@/components/ui/hover-card', () => ({ - HoverCard: ({ - children, - open, - onOpenChange - }: { - children: ReactNode - open?: boolean - onOpenChange?: (open: boolean) => void - }) => { - mocks.hoverOpenChange = onOpenChange - return ( -
- {children} -
- ) - }, - HoverCardContent: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - HoverCardTrigger: ({ children }: { children: ReactNode }) => - React.isValidElement>(children) ? ( - React.cloneElement(children, { 'data-hover-card-trigger': '' }) - ) : ( - <>{children} - ) -})) -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => ( - {children} - ), - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) -vi.mock('../activity/use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'active' -})) -vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({ - useIsSleepingWorktree: () => false, - selectIsSleepingWorktree: () => false -})) -vi.mock('../activity/CacheTimer', () => ({ - default: ({ startedAt }: { startedAt: number }) => , - usePromptCacheCountdownStartedAt: () => mocks.cacheStartedAt -})) -vi.mock('../activity/WorktreeCardAgents', () => ({ - default: ({ className }: { className?: string }) => ( -
- ) -})) -vi.mock('../identity/WorktreeLineageToggle', () => ({ - WorktreeLineageToggle: ({ count, label }: { count: number; label: string }) => ( - - ) -})) -vi.mock( - '../../workspace-actions/deletion/workspace-delete-quick-action', - async (importOriginal) => ({ - ...(await importOriginal>()), - useWorkspaceDeleteModifierPressed: () => mocks.deleteModifier - }) -) - -const { useWorktreeCardController } = await import('../controller/use-worktree-card-controller') -const { buildWorktreeCardPresentation } = await import('../surface/worktree-card-presentation') -const { WorktreeCardParentContent } = await import('../surface/worktree-card-parent-content') -const { getWorktreeCardFrameGeometry } = await import('../surface/worktree-card-frame-geometry') -const { DEFAULT_WORKSPACE_CARD_TEMPLATE } = await import('./default-workspace-card') -const { renderWorkspaceCardTemplate } = await import('./render-workspace-card-template') - -const repo: Repo = { - id: 'repo-1', - path: '/repo', - displayName: 'orca', - badgeColor: '#999999', - addedAt: 1 -} - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'repo-1::/repo/worktrees/feature', - repoId: 'repo-1', - path: '/repo/worktrees/feature', - displayName: 'Card plugins', - branch: 'refs/heads/feature/card-plugins', - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - ...overrides - } -} - -type Case = { - worktree?: Partial - repo?: Partial | null - props?: Partial - state?: Record - cardProps?: WorktreeCardProperty[] - cacheStartedAt?: number - deleteModifier?: boolean -} - -// Proof each case draws what it is named for, so a parity pass is not two empty cards. - -const STYLES: Record> = { - classic: { compactWorktreeCards: false }, - compact: { compactWorktreeCards: true }, - new: { experimentalNewWorktreeCardStyle: true } -} - -function stateFor(style: Partial, testCase: Case): Record { - return { - agentActivityDisplayMode: 'full', - deleteStateByWorktreeId: {}, - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - prCache: {}, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - runtimeEnvironments: [], - settings: { ...style }, - sshConnectionStates: new Map(), - removedSshTargetLabels: new Map(), - runtimeStatusByEnvironmentId: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - agentStatusByPaneKey: {}, - retainedAgentsByPaneKey: {}, - workspacePortScan: null, - worktreeCardProperties: testCase.cardProps ?? [...DEFAULT_WORKTREE_CARD_PROPERTIES], - ...testCase.state - } -} - -function Harness({ props, variant }: { props: WorktreeCardProps; variant: 'legacy' | 'template' }) { - const card = useWorktreeCardController({ - isActiveSurface: props.isActive, - activeSurfaceVariant: 'primary', - isMultiSelected: false, - revealHighlight: false, - revealHighlightTone: 'default', - nativeDragEnabled: true, - inPinnedSection: false, - contentIndent: 0, - flushSurface: false, - lineageChildCount: 0, - lineageCollapsed: false, - isLineageDropTarget: false, - affiliateListMode: false, - statusPrDisplay: null, - ...props - }) - if (variant === 'legacy') { - const presentation = buildWorktreeCardPresentation(card) - return ( -
- -
- ) - } - const geometryFor = (statusLane: boolean) => - getWorktreeCardFrameGeometry({ - flushSurface: card.flushSurface, - contentIndent: card.contentIndent, - statusLane - }) - const { content, frame } = renderWorkspaceCardTemplate( - card, - DEFAULT_WORKSPACE_CARD_TEMPLATE, - (f) => { - const margin = geometryFor(f.statusLane).parentContentMarginLeft - return margin < 0 ? { style: { marginLeft: `${margin}px` } } : undefined - } - ) - return ( -
- {content} -
- ) -} - -/** Attribute order and class token order do not change what the browser draws. */ - -// Why a window: full card markup is too long for a readable assertion diff. - -describe('default workspace card template parity with the original card', () => { - beforeEach(() => { - mocks.cacheStartedAt = null - mocks.deleteModifier = false - mocks.hoverOpenChange = undefined - }) - afterEach(cleanup) - - it('suppresses a delayed details hover while a template identity icon is focused', () => { - mocks.state = stateFor(STYLES.new, {}) - const { container } = render( - - ) - const icon = screen.getByRole('img', { name: 'Sparse checkout' }) - const hover = container.querySelector('[data-hover-card]') - expect(hover).not.toBeNull() - expect(mocks.hoverOpenChange).toBeTypeOf('function') - - act(() => icon.focus()) - act(() => mocks.hoverOpenChange?.(true)) - expect(hover?.getAttribute('data-hover-open')).toBe('false') - - act(() => icon.blur()) - act(() => mocks.hoverOpenChange?.(true)) - expect(hover?.getAttribute('data-hover-open')).toBe('true') - }) -}) diff --git a/src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.test.ts deleted file mode 100644 index 95a9f251d0c..00000000000 --- a/src/renderer/src/lib/workspace/activation/sidebar-worktree-activation.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - activateAndRevealFolderWorkspace: vi.fn(), - activateAndRevealWorktree: vi.fn(), - setEphemeralVmWorkspaceWaking: vi.fn(), - refreshEphemeralVmRuntimes: vi.fn(async () => undefined), - toastError: vi.fn((_title: string, _options?: { description?: string }) => undefined) -})) - -vi.mock('@/store/index', () => ({ - useAppStore: { - getState: () => ({ - ephemeralVmRuntimesByWorkspaceId: { - 'wt-vm': { id: 'vm-1', workspaceId: 'wt-vm', status: 'suspended' } - }, - setEphemeralVmWorkspaceWaking: mocks.setEphemeralVmWorkspaceWaking, - refreshEphemeralVmRuntimes: mocks.refreshEphemeralVmRuntimes - }) - } -})) - -vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) - -vi.mock('@/lib/workspace/activation/worktree-activation', () => ({ - activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace, - activateAndRevealWorktree: mocks.activateAndRevealWorktree -})) - -import { activateWorktreeFromSidebar } from './sidebar-worktree-activation' -import type { EphemeralVmWorkspaceLifecycleResult as ResumeResult } from '../../../../../shared/ephemeral-vm-runtimes' - -describe('sidebar worktree activation', () => { - beforeEach(() => { - mocks.activateAndRevealWorktree.mockClear() - mocks.activateAndRevealFolderWorkspace.mockClear() - mocks.setEphemeralVmWorkspaceWaking.mockClear() - mocks.refreshEphemeralVmRuntimes.mockClear() - mocks.toastError.mockClear() - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('marks the VM waking until the settled record is read', async () => { - let resolveResume: ((value: ResumeResult) => void) | undefined - const resumeWorkspace = vi.fn( - () => - new Promise((resolve) => { - resolveResume = resolve - }) - ) - vi.stubGlobal('window', { api: { ephemeralVm: { resumeWorkspace } } }) - - const activation = activateWorktreeFromSidebar('wt-vm') - await vi.waitFor(() => - expect(mocks.setEphemeralVmWorkspaceWaking).toHaveBeenCalledWith('wt-vm', true) - ) - expect(mocks.setEphemeralVmWorkspaceWaking).not.toHaveBeenCalledWith('wt-vm', false) - - resolveResume?.({ ok: true, runtime: null }) - await activation - expect(mocks.setEphemeralVmWorkspaceWaking).toHaveBeenLastCalledWith('wt-vm', false) - }) - - it('clears waking and reports when resume fails', async () => { - const resumeWorkspace = vi.fn(async () => { - throw new Error('resume command exited 1') - }) - vi.stubGlobal('window', { api: { ephemeralVm: { resumeWorkspace } } }) - - await activateWorktreeFromSidebar('wt-vm') - - expect(mocks.toastError).toHaveBeenCalledTimes(1) - expect(mocks.setEphemeralVmWorkspaceWaking).toHaveBeenLastCalledWith('wt-vm', false) - }) - - it('renders a coded resume failure through the failure catalog', async () => { - const failure = { - error: 'Resume exited with code 3.', - errorCode: 'resumeExited', - errorParams: { exitCode: 3 } - } - const resumeWorkspace = vi.fn(async (): Promise => ({ ok: false, ...failure })) - vi.stubGlobal('window', { api: { ephemeralVm: { resumeWorkspace } } }) - - await activateWorktreeFromSidebar('wt-vm') - - expect(mocks.toastError).toHaveBeenCalledTimes(1) - expect(mocks.toastError.mock.calls[0]?.[1]?.description).toContain('Resume exited with code 3.') - expect(mocks.setEphemeralVmWorkspaceWaking).toHaveBeenLastCalledWith('wt-vm', false) - }) -}) From 9a21a515d3e2548da415716e28da3f8bef5b9613 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:51:21 -0800 Subject: [PATCH 002/138] fix(vm-recipes): hide an in-progress VM checkout in every window, the phone and the jump palette (#40373) * fix(vm-recipes): hide an in-progress VM checkout in every window, the phone and the jump palette A VM create's checkout was hidden only in the sidebar of the window that started the create. Windows now publish their pending VM checkouts to the main process, which shares every window's set with all windows and filters the paired phone's host workspace list and workspace context. The jump palette and the sidebar read the shared set, and a grouped VM project no longer draws an empty-project placeholder while its create is pending. A window's set is dropped when it closes, crashes or reloads. * test(project-groups): reset leaked runtime environment revisions between files --- .../ephemeral-vm/pending-vm-checkouts.test.ts | 24 ++++ src/main/ephemeral-vm/pending-vm-checkouts.ts | 61 +++++++++ src/main/ipc/ephemeral-vm-runtime-handlers.ts | 53 ++++++++ .../mobile-relay-host-catalog.test.ts | 26 ++++ .../mobile-relay-host-catalog.ts | 12 +- .../runtime/rpc/server/runtime-rpc-pairing.ts | 4 +- src/preload/api/ephemeral-vm-api.ts | 6 + src/preload/api/ephemeral-vm-bridge.ts | 16 ++- .../worktree-list/listing/use-section-rows.ts | 16 ++- ...e-worktree-jump-palette-project-targets.ts | 7 +- .../use-worktree-jump-palette-store-state.ts | 16 ++- .../src/lib/vm-create-hidden-checkouts.ts | 116 ++++++++++++------ .../repos-project-groups-delete.test.ts | 3 + src/shared/pending-vm-checkouts.test.ts | 76 ++++++++++++ src/shared/pending-vm-checkouts.ts | 68 ++++++++++ .../empty-project-placeholder-repos.ts | 4 +- .../e2e/ssh-orcad-vm-provisioned-root.spec.ts | 17 +++ 17 files changed, 477 insertions(+), 48 deletions(-) create mode 100644 src/main/ephemeral-vm/pending-vm-checkouts.test.ts create mode 100644 src/main/ephemeral-vm/pending-vm-checkouts.ts create mode 100644 src/shared/pending-vm-checkouts.test.ts create mode 100644 src/shared/pending-vm-checkouts.ts diff --git a/src/main/ephemeral-vm/pending-vm-checkouts.test.ts b/src/main/ephemeral-vm/pending-vm-checkouts.test.ts new file mode 100644 index 00000000000..b553a23cb4e --- /dev/null +++ b/src/main/ephemeral-vm/pending-vm-checkouts.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import { isPendingVmCheckout } from '../../shared/pending-vm-checkouts' +import { PendingVmCheckoutRegistry } from './pending-vm-checkouts' + +describe('PendingVmCheckoutRegistry', () => { + it("serves every window's pending checkouts and drops a window that publishes none", () => { + const registry = new PendingVmCheckoutRegistry() + const changed = vi.fn() + registry.onChanged(changed) + + registry.publish(1, [{ repoId: 'vm-a', path: '/a' }]) + registry.publish(2, [{ repoId: 'vm-b', path: '/b' }]) + expect(isPendingVmCheckout(registry.current(), { repoId: 'vm-a', path: '/a' })).toBe(true) + expect(isPendingVmCheckout(registry.current(), { repoId: 'vm-b', path: '/b' })).toBe(true) + + registry.publish(1, []) + expect(registry.list()).toEqual([{ repoId: 'vm-b', path: '/b' }]) + expect(isPendingVmCheckout(registry.current(), { repoId: 'vm-a', path: '/a' })).toBe(false) + + // A window with nothing published stays quiet. + registry.publish(3, []) + expect(changed).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/ephemeral-vm/pending-vm-checkouts.ts b/src/main/ephemeral-vm/pending-vm-checkouts.ts new file mode 100644 index 00000000000..4c4dbedcdbf --- /dev/null +++ b/src/main/ephemeral-vm/pending-vm-checkouts.ts @@ -0,0 +1,61 @@ +import { + indexPendingVmCheckouts, + EMPTY_PENDING_VM_CHECKOUT_INDEX, + type PendingVmCheckout, + type PendingVmCheckoutIndex +} from '../../shared/pending-vm-checkouts' + +/** + * VM checkouts whose creates are still in progress, per publishing window. Every window, the + * paired phone's host lists and the jump palette read the union, so none lists a checkout the + * create has not adopted yet. + */ +export class PendingVmCheckoutRegistry { + private readonly byOwner = new Map() + private index: PendingVmCheckoutIndex = EMPTY_PENDING_VM_CHECKOUT_INDEX + private readonly listeners = new Set<() => void>() + + /** Replaces one window's set; an empty list drops the window. An unchanged set is a no-op. */ + publish(ownerId: number, checkouts: PendingVmCheckout[]): void { + const before = this.byOwner.get(ownerId) ?? [] + if (sameCheckouts(before, checkouts)) { + return + } + if (checkouts.length === 0) { + this.byOwner.delete(ownerId) + } else { + this.byOwner.set(ownerId, checkouts) + } + this.index = indexPendingVmCheckouts([...this.byOwner.values()].flat()) + this.listeners.forEach((listener) => listener()) + } + + list(): PendingVmCheckout[] { + return [...this.byOwner.values()].flat() + } + + /** What one window needs from the others; it already holds its own. */ + listExcept(ownerId: number): PendingVmCheckout[] { + return [...this.byOwner].flatMap(([owner, checkouts]) => (owner === ownerId ? [] : checkouts)) + } + + current(): PendingVmCheckoutIndex { + return this.index + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } +} + +function sameCheckouts(left: PendingVmCheckout[], right: PendingVmCheckout[]): boolean { + return ( + left.length === right.length && + left.every( + (checkout, i) => checkout.repoId === right[i].repoId && checkout.path === right[i].path + ) + ) +} + +export const pendingVmCheckouts = new PendingVmCheckoutRegistry() diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index 716a8e388fe..8a721827c74 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -19,6 +19,11 @@ import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' import { ephemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy' import { attachEphemeralVmRuntimeToWorkspace } from '../ephemeral-vm/runtime-attachment' import { forgetInterruptedEphemeralVmProvision } from '../ephemeral-vm/runtime-provisioning-persistence' +import { pendingVmCheckouts } from '../ephemeral-vm/pending-vm-checkouts' +import { + parsePendingVmCheckouts, + PENDING_VM_CHECKOUTS_CHANGED_CHANNEL +} from '../../shared/pending-vm-checkouts' let stopRuntimesChangedBroadcast: (() => void) | null = null let runtimesChangedBroadcastQueued = false @@ -52,7 +57,55 @@ export type EphemeralVmCleanupCommandResult = { message?: string } +let detachPendingCheckoutBroadcast: (() => void) | null = null +const pendingCheckoutOwners = new Set() + +function broadcastPendingVmCheckouts(): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send( + PENDING_VM_CHECKOUTS_CHANGED_CHANNEL, + pendingVmCheckouts.listExcept(window.webContents.id) + ) + } catch { + // A renderer can disappear between isDestroyed() and send(). + } + } +} + +/** Windows publish the VM checkouts their creates have not adopted yet; every window reads the union. */ +function registerPendingVmCheckoutHandlers(): void { + ipcMain.removeHandler('ephemeralVm:publishPendingCheckouts') + ipcMain.removeHandler('ephemeralVm:listPendingCheckouts') + detachPendingCheckoutBroadcast?.() + detachPendingCheckoutBroadcast = pendingVmCheckouts.onChanged(broadcastPendingVmCheckouts) + ipcMain.handle('ephemeralVm:publishPendingCheckouts', (event, value: unknown): void => { + const sender = event.sender + const ownerId = sender.id + if (!pendingCheckoutOwners.has(ownerId)) { + pendingCheckoutOwners.add(ownerId) + // A closed or crashed window's creates cannot finish; its hides go with it. A reloaded + // window publishes its empty set when it starts. + const drop = (): void => { + pendingCheckoutOwners.delete(ownerId) + sender.removeListener('render-process-gone', drop) + pendingVmCheckouts.publish(ownerId, []) + } + sender.once('destroyed', drop) + sender.once('render-process-gone', drop) + } + pendingVmCheckouts.publish(ownerId, parsePendingVmCheckouts(value)) + }) + ipcMain.handle('ephemeralVm:listPendingCheckouts', (event) => + pendingVmCheckouts.listExcept(event.sender.id) + ) +} + export function registerEphemeralVmRuntimeHandlers(store: Store): void { + registerPendingVmCheckoutHandlers() ipcMain.removeHandler('ephemeralVm:attachWorkspace') ipcMain.removeHandler('ephemeralVm:listRuntimes') ipcMain.removeHandler('ephemeralVm:cleanup') diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts index 1b2374e3fae..4fb4df6511e 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts @@ -14,6 +14,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session/wo import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' import { MobileRelayHostCatalog } from './mobile-relay-host-catalog' import { PairedServerRowsProducer } from '../sidebar-inputs/paired-server-rows-producer' +import { indexPendingVmCheckouts } from '../../../shared/pending-vm-checkouts' function answer(capable: boolean): RuntimeStatus { return { @@ -137,6 +138,7 @@ function catalog( recipeEnvironmentsFor?: ( hostId: string ) => ReadonlyMap + pendingVmCheckouts?: { repoId: string; path: string }[] } = {} ) { const serverRows = new PairedServerRowsProducer({ @@ -155,6 +157,9 @@ function catalog( }, ...(desktop.recipeEnvironmentsFor ? { recipeEnvironmentsFor: desktop.recipeEnvironmentsFor } + : {}), + ...(desktop.pendingVmCheckouts + ? { pendingVmCheckouts: () => indexPendingVmCheckouts(desktop.pendingVmCheckouts ?? []) } : {}) }) } @@ -192,6 +197,27 @@ describe('mobile relay host catalog', () => { expect(lookups).toEqual(['runtime:env']) }) + it("leaves out a VM checkout a desktop create has not adopted yet, but not the VM's other rows", async () => { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + call.mockResolvedValueOnce( + psReply([ + { worktreeId: 'vm::/srv/repo', repoId: 'vm', path: '/srv/repo', isMainWorktree: true }, + { worktreeId: 'vm::/srv/wt', repoId: 'vm', path: '/srv/wt', isMainWorktree: false }, + { worktreeId: 'other::/srv/repo', repoId: 'other', path: '/srv/repo', isMainWorktree: true } + ]) + ) + const { catalog: hostCatalog } = catalog(hosts, new Map(), { + pendingVmCheckouts: [{ repoId: 'vm', path: '/srv/repo' }] + }) + const result = await hostCatalog.worktrees('runtime:env') + expect(result.worktrees?.map((row) => row.worktreeId)).toEqual([ + 'vm::/srv/wt', + 'other::/srv/repo' + ]) + }) + it("drops a server's own recipe-environment claim and survives an unreadable VM store", async () => { const { hosts, call } = fakeHosts([ { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts index f42097a7f34..f313e1e2c62 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts @@ -24,6 +24,10 @@ import { RelayHostListAnnouncer, type RelayHostListAnnouncerSources } from './mobile-relay-host-list-announcer' +import { + isPendingVmCheckout, + type PendingVmCheckoutIndex +} from '../../../shared/pending-vm-checkouts' export type MobileRelayRecipeEnvironmentLookup = ( hostId: string @@ -60,6 +64,8 @@ export class MobileRelayHostCatalog { hostLabelOverrides: () => ReadonlyMap getWorkspaceSession: (hostId: ExecutionHostId) => WorkspaceSessionState | null recipeEnvironmentsFor?: MobileRelayRecipeEnvironmentLookup + /** VM checkouts a desktop create has not adopted yet; the phone must not list them. */ + pendingVmCheckouts?: () => PendingVmCheckoutIndex now?: () => number } ) {} @@ -128,8 +134,9 @@ export class MobileRelayHostCatalog { return undefined } const key = worktreeIdentityKey(worktreeId) + const pending = this.options.pendingVmCheckouts?.() for (const raw of rows) { - if (raw.removing === true) { + if (raw.removing === true || (pending && isPendingVmCheckout(pending, raw))) { continue } const row = normalizePairedServerRow(hostId, raw) @@ -235,10 +242,11 @@ export class MobileRelayHostCatalog { // The tag only decorates rows; an unreadable VM store must not blank the phone's list. recipeEnvironments = undefined } + const pending = this.options.pendingVmCheckouts?.() // Only desktop reads request background-removal support. return { worktrees: rows - .filter((row) => row.removing !== true) + .filter((row) => row.removing !== true && !(pending && isPendingVmCheckout(pending, row))) .map((row) => stampRecipeEnvironment( stampDesktopSidebarView(row, host.environmentId, desktopDeviceIds, sessionActivity), diff --git a/src/main/runtime/rpc/server/runtime-rpc-pairing.ts b/src/main/runtime/rpc/server/runtime-rpc-pairing.ts index 7c73aa58f4a..83ebd97abdf 100644 --- a/src/main/runtime/rpc/server/runtime-rpc-pairing.ts +++ b/src/main/runtime/rpc/server/runtime-rpc-pairing.ts @@ -30,6 +30,7 @@ import { type MobileRelayPairingProvider, type PairingOfferUnavailable } from './runtime-rpc-pairing-types' +import { pendingVmCheckouts } from '../../../ephemeral-vm/pending-vm-checkouts' export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { getDeviceRegistry(): DeviceRegistry | null { @@ -126,7 +127,8 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { getHostDisplayLabelOverrides(this.runtime.requireStore().getSettings()), getWorkspaceSession: (hostId) => this.runtime.getWorkspaceSessionForHost(hostId), recipeEnvironmentsFor: (hostId) => - this.runtime.describeRecipeWorkspaceEnvironments(hostId) + this.runtime.describeRecipeWorkspaceEnvironments(hostId), + pendingVmCheckouts: () => pendingVmCheckouts.current() }) : null this.detachRelayHostsChanges?.() diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index 2acefc41669..f1aca3ee2f6 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -8,6 +8,7 @@ import type { EphemeralVmWorkspaceLifecycleResult } from '../../shared/ephemeral-vm-runtimes' import type { EphemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy' +import type { PendingVmCheckout } from '../../shared/pending-vm-checkouts' export type EphemeralVmApi = { listRecipes: (args: { repoId: string }) => Promise<{ @@ -85,4 +86,9 @@ export type EphemeralVmApi = { cleanupDisabled: boolean message?: string }> + /** This window's VM checkouts whose creates have not adopted them yet; replaces its last set. */ + publishPendingCheckouts: (checkouts: PendingVmCheckout[]) => void + listPendingCheckouts: () => Promise + /** Every window's pending VM checkouts, whenever any window's set changes. */ + onPendingCheckoutsChanged: (callback: (checkouts: PendingVmCheckout[]) => void) => () => void } diff --git a/src/preload/api/ephemeral-vm-bridge.ts b/src/preload/api/ephemeral-vm-bridge.ts index a0b3ebd1a9f..2eb8372bb79 100644 --- a/src/preload/api/ephemeral-vm-bridge.ts +++ b/src/preload/api/ephemeral-vm-bridge.ts @@ -1,6 +1,10 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' import { EPHEMERAL_VM_RUNTIMES_CHANGED_CHANNEL } from '../../shared/ephemeral-vm-runtime-store-events' +import { + PENDING_VM_CHECKOUTS_CHANGED_CHANNEL, + type PendingVmCheckout +} from '../../shared/pending-vm-checkouts' export const ephemeralVmApi = { listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), @@ -28,5 +32,15 @@ export const ephemeralVmApi = { cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), forgetRuntime: (args) => ipcRenderer.invoke('ephemeralVm:forgetRuntime', args), - getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) + getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args), + publishPendingCheckouts: (checkouts) => { + void ipcRenderer.invoke('ephemeralVm:publishPendingCheckouts', checkouts).catch(() => {}) + }, + listPendingCheckouts: () => ipcRenderer.invoke('ephemeralVm:listPendingCheckouts'), + onPendingCheckoutsChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, checkouts: PendingVmCheckout[]): void => + callback(checkouts) + ipcRenderer.on(PENDING_VM_CHECKOUTS_CHANGED_CHANNEL, listener) + return () => ipcRenderer.removeListener(PENDING_VM_CHECKOUTS_CHANGED_CHANNEL, listener) + } } satisfies PreloadApi['ephemeralVm'] diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index f44e29790b9..954b9acaafd 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -1,5 +1,5 @@ import { selectWslHostsServing } from '../../use-wsl-hosts-enabled' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' import { shallow } from 'zustand/shallow' import { persistedUIValuesEqual } from '../../../../../../shared/persisted-ui-equality' import { useShallow } from 'zustand/react/shallow' @@ -19,6 +19,7 @@ import { useEffectiveCollapsedGroups } from './use-collapsed-groups' import type { Row } from '../../../../../../shared/sidebar-list/row-types' import type { SectionRowsArgs } from './sidebar-list-model' import { getLogicalRepoOrderRankById } from '../../../../../../shared/project-section-order' +import { readHiddenVmCheckouts, subscribeHiddenVmCheckouts } from '@/lib/vm-create-hidden-checkouts' import { getEmptyProjectPlaceholderRepoIds } from '../../../../../../shared/sidebar-list/empty-project-placeholder-repos' import { deferHostSectionCollapse, @@ -100,6 +101,7 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { [repos] ) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) + const hiddenVmCheckouts = useSyncExternalStore(subscribeHiddenVmCheckouts, readHiddenVmCheckouts) const placeholderRepoIds = useMemo( () => getEmptyProjectPlaceholderRepoIds({ @@ -107,9 +109,17 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { repos: args.visibleReposForRows, worktreesByRepo, visibleWorktrees: worktrees, - filterRepoIds: args.filterRepoIds + filterRepoIds: args.filterRepoIds, + pendingVmRepoIds: hiddenVmCheckouts.repoIds }), - [args.filterRepoIds, args.groupBy, args.visibleReposForRows, worktrees, worktreesByRepo] + [ + args.filterRepoIds, + args.groupBy, + args.visibleReposForRows, + worktrees, + worktreesByRepo, + hiddenVmCheckouts + ] ) // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. diff --git a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-project-targets.ts b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-project-targets.ts index 3445df20e9f..6e4d0597e32 100644 --- a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-project-targets.ts +++ b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-project-targets.ts @@ -31,6 +31,7 @@ export function useWorktreeJumpPaletteProjectTargets({ workspaces, repos, worktreesByRepo, + pendingVmRepoIds, detectedWorktreesByRepo, worktreeVisibilityDefaultsByHost, pendingWorktreeCreations, @@ -64,7 +65,8 @@ export function useWorktreeJumpPaletteProjectTargets({ } } for (const repo of repos) { - if ((worktreesByRepo[repo.id]?.length ?? 0) === 0) { + // A VM create's project is not a target until the create adopts its checkout. + if ((worktreesByRepo[repo.id]?.length ?? 0) === 0 && !pendingVmRepoIds.has(repo.id)) { ids.add(repo.id) } } @@ -85,7 +87,8 @@ export function useWorktreeJumpPaletteProjectTargets({ pendingWorktreeCreations, repos, worktreeVisibilityDefaultsByHost, - worktreesByRepo + worktreesByRepo, + pendingVmRepoIds ]) const hasAnyProjectSearchCandidates = useMemo( () => diff --git a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-store-state.ts index 175ea050cb8..fb5113b520b 100644 --- a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-store-state.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store/index' @@ -6,6 +6,11 @@ import { usePluginCommands } from '@/store/plugins/catalog/plugin-contribution-c import { usePluginNewFiles } from '@/store/plugins/file-creation/plugin-new-files' import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata' import { dedupePaletteWorktrees } from '@/lib/workspace/search/palette-repo-resolution' +import { + readHiddenVmCheckouts, + subscribeHiddenVmCheckouts, + withoutHiddenVmWorktreesByRepo +} from '@/lib/vm-create-hidden-checkouts' import { selectPaletteIndexStatusSnapshot, selectPaletteStatusInputs @@ -36,7 +41,13 @@ export function useWorktreeJumpPaletteStoreState({ const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) const revealSidebarRow = useAppStore((state) => state.revealSidebarRow) - const worktreesByRepo = useAppStore((state) => selectWorktreesByRepo(state)) + const storedWorktreesByRepo = useAppStore((state) => selectWorktreesByRepo(state)) + const hiddenVmCheckouts = useSyncExternalStore(subscribeHiddenVmCheckouts, readHiddenVmCheckouts) + // Why: a VM create's checkout is not a workspace until the create adopts it. + const worktreesByRepo = useMemo( + () => withoutHiddenVmWorktreesByRepo(storedWorktreesByRepo, hiddenVmCheckouts), + [storedWorktreesByRepo, hiddenVmCheckouts] + ) const folderWorkspaces = useAppStore((state) => selectFolderWorkspaces(state)) const repos = useAppStore((state) => state.repos) const projectGroups = useAppStore((state) => state.projectGroups) @@ -154,6 +165,7 @@ export function useWorktreeJumpPaletteStoreState({ recordFeatureInteraction, revealSidebarRow, worktreesByRepo, + pendingVmRepoIds: hiddenVmCheckouts.repoIds, workspaces, repos, projectGroups, diff --git a/src/renderer/src/lib/vm-create-hidden-checkouts.ts b/src/renderer/src/lib/vm-create-hidden-checkouts.ts index 8cdc5080fab..6faf4a20a90 100644 --- a/src/renderer/src/lib/vm-create-hidden-checkouts.ts +++ b/src/renderer/src/lib/vm-create-hidden-checkouts.ts @@ -1,47 +1,77 @@ import type { SidebarFeedDomains } from '../../../shared/sidebar-list/sidebar-feed-domains' -import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { + indexPendingVmCheckouts, + isPendingVmCheckout, + type PendingVmCheckout, + type PendingVmCheckoutIndex +} from '../../../shared/pending-vm-checkouts' /** A VM's primary checkout, registered on its host before the create that owns it finishes. */ -export type HiddenVmCheckout = { repoId: string; path: string } +export type HiddenVmCheckout = PendingVmCheckout type CheckoutRow = { repoId: string; path: string; isMainWorktree?: boolean } -/** What the sidebar filters by; `repoIds` lets most rows skip building a path key. */ -export type HiddenVmCheckouts = { - readonly repoIds: ReadonlySet - readonly keys: ReadonlySet -} +/** What lists filter by: this window's pending VM checkouts and every other window's. */ +export type HiddenVmCheckouts = PendingVmCheckoutIndex // Upper bound for a cleaned-up VM's rows to leave the lists (its host is retired asynchronously). const HIDDEN_AFTER_CLEANUP_MS = 60_000 -const EMPTY: HiddenVmCheckouts = { repoIds: new Set(), keys: new Set() } -type Hide = HiddenVmCheckout & { key: string } // Keyed per hide, not per creation: a retry re-provisions under the same creation id while the // previous attempt's cleaned-up VM may still be listed. -let hides = new Map() +let hides = new Map() const activeHideByCreation = new Map() const cleanedHides = new Set() let nextHideId = 1 -let hidden: HiddenVmCheckouts = EMPTY +/** The other windows' sets as main last sent them; this window's own live in `hides`. */ +let otherWindows: HiddenVmCheckout[] = [] +let hidden: HiddenVmCheckouts = indexPendingVmCheckouts([]) +let detachMain: (() => void) | null = null const listeners = new Set<() => void>() -function checkoutKey(repoId: string, path: string): string { - return `${repoId}\0${normalizeRuntimePathForComparison(path)}` +function sameKeys(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((key) => right.has(key)) } -function publish(next: Map): void { - hides = next - const values = [...next.values()] - hidden = - values.length === 0 - ? EMPTY - : { - repoIds: new Set(values.map((hide) => hide.repoId)), - keys: new Set(values.map((hide) => hide.key)) - } +function republish(): void { + const next = indexPendingVmCheckouts([...hides.values(), ...otherWindows]) + if (sameKeys(next.keys, hidden.keys)) { + return + } + hidden = next listeners.forEach((listener) => listener()) } +function publish(next: Map): void { + hides = next + // Main holds the source of truth for other windows and the paired phone's host lists. + if (typeof window !== 'undefined') { + window.api?.ephemeralVm?.publishPendingCheckouts?.([...next.values()]) + } + republish() +} + +function attachMain(): void { + const api = typeof window === 'undefined' ? undefined : window.api?.ephemeralVm + if (detachMain || !api?.onPendingCheckoutsChanged) { + return + } + // A reloaded window starts with no creates; drop whatever its previous page left in main. + if (hides.size === 0) { + api.publishPendingCheckouts?.([]) + } + detachMain = api.onPendingCheckoutsChanged((checkouts) => { + otherWindows = checkouts + republish() + }) + void api + .listPendingCheckouts?.() + .then((checkouts) => { + otherWindows = checkouts + republish() + }) + .catch(() => {}) +} + function removeHide(hideId: number): void { cleanedHides.delete(hideId) if (!hides.has(hideId)) { @@ -59,9 +89,7 @@ function removeHide(hideId: number): void { export function hideVmCreateCheckout(creationId: string, checkout: HiddenVmCheckout): void { const hideId = nextHideId++ activeHideByCreation.set(creationId, hideId) - publish( - new Map(hides).set(hideId, { ...checkout, key: checkoutKey(checkout.repoId, checkout.path) }) - ) + publish(new Map(hides).set(hideId, { repoId: checkout.repoId, path: checkout.path })) } /** The create finished: the checkout is now a workspace (or its project) the sidebar may list. */ @@ -94,6 +122,7 @@ export function settleVmCreateCheckoutAfterCleanup(creationId: string, cleanedUp } export function subscribeHiddenVmCheckouts(listener: () => void): () => void { + attachMain() listeners.add(listener) return () => listeners.delete(listener) } @@ -102,14 +131,6 @@ export function readHiddenVmCheckouts(): HiddenVmCheckouts { return hidden } -function isHidden(checkouts: HiddenVmCheckouts, row: CheckoutRow): boolean { - return ( - row.isMainWorktree !== false && - checkouts.repoIds.has(row.repoId) && - checkouts.keys.has(checkoutKey(row.repoId, row.path)) - ) -} - /** The window's sidebar list without the primary checkouts of VMs whose creates are in progress. */ export function withoutHiddenVmWorktrees( worktrees: W[], @@ -118,17 +139,40 @@ export function withoutHiddenVmWorktrees( if (checkouts.keys.size === 0) { return worktrees } - const kept = worktrees.filter((worktree) => !isHidden(checkouts, worktree)) + const kept = worktrees.filter((worktree) => !isPendingVmCheckout(checkouts, worktree)) return kept.length === worktrees.length ? worktrees : kept } +/** Per-repo lists without pending VM checkouts; the same object when nothing matches. */ +export function withoutHiddenVmWorktreesByRepo( + worktreesByRepo: Record, + checkouts: HiddenVmCheckouts +): Record { + if (checkouts.keys.size === 0) { + return worktreesByRepo + } + let next: Record | null = null + for (const repoId of checkouts.repoIds) { + const list = worktreesByRepo[repoId] + if (!list) { + continue + } + const kept = withoutHiddenVmWorktrees(list, checkouts) + if (kept !== list) { + next ??= { ...worktreesByRepo } + next[repoId] = kept + } + } + return next ?? worktreesByRepo +} + function withoutHiddenRows( rows: ReadonlyMap, checkouts: HiddenVmCheckouts ): ReadonlyMap { let kept: Map | null = null for (const [key, row] of rows) { - if (isHidden(checkouts, row)) { + if (isPendingVmCheckout(checkouts, row)) { kept ??= new Map(rows) kept.delete(key) } diff --git a/src/renderer/src/store/project-groups/repos-project-groups-delete.test.ts b/src/renderer/src/store/project-groups/repos-project-groups-delete.test.ts index 64b14e6efa4..6e4d4ed186f 100644 --- a/src/renderer/src/store/project-groups/repos-project-groups-delete.test.ts +++ b/src/renderer/src/store/project-groups/repos-project-groups-delete.test.ts @@ -8,6 +8,7 @@ import { type RuntimeEnvironmentCallRequest } from '../../runtime/rpc/testing/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCache as clearRuntimeCompatibilityCacheForTests } from '../../runtime/rpc/compatibility/runtime-compatibility-cache' +import { replaceRuntimeEnvironmentRevisions } from '../../runtime/rpc/environment-revisions/runtime-environment-revision' describe('repos-project-groups-delete', () => { const remoteRepo: Repo = { @@ -39,6 +40,8 @@ describe('repos-project-groups-delete', () => { beforeEach(() => { clearRuntimeCompatibilityCacheForTests() + // Shared-process runs can leave another file's env-1 pairing revision behind. + replaceRuntimeEnvironmentRevisions([]) reposRemove.mockReset() reposRemoveForHost.mockReset() reposRemoveForHost.mockResolvedValue(undefined) diff --git a/src/shared/pending-vm-checkouts.test.ts b/src/shared/pending-vm-checkouts.test.ts new file mode 100644 index 00000000000..bb5ae56e1ec --- /dev/null +++ b/src/shared/pending-vm-checkouts.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + indexPendingVmCheckouts, + isPendingVmCheckout, + parsePendingVmCheckouts, + PENDING_VM_CHECKOUTS_LIMIT +} from './pending-vm-checkouts' +import { getEmptyProjectPlaceholderRepoIds } from './sidebar-list/empty-project-placeholder-repos' +import type { Repo } from './repo-types' +import type { Worktree } from './worktree/types' + +describe('pending VM checkouts', () => { + it('matches only the primary checkout at the pending repo and path', () => { + const index = indexPendingVmCheckouts([{ repoId: 'vm', path: '/srv/repo/' }]) + expect(isPendingVmCheckout(index, { repoId: 'vm', path: '/srv/repo' })).toBe(true) + expect( + isPendingVmCheckout(index, { repoId: 'vm', path: '/srv/repo', isMainWorktree: false }) + ).toBe(false) + expect(isPendingVmCheckout(index, { repoId: 'vm', path: '/srv/other' })).toBe(false) + expect(isPendingVmCheckout(index, { repoId: 'local', path: '/srv/repo' })).toBe(false) + expect(isPendingVmCheckout(index, { worktreeId: 'no-repo-fields' })).toBe(false) + }) + + it('keeps only well-formed IPC entries, bounded', () => { + expect(parsePendingVmCheckouts('nope')).toEqual([]) + expect( + parsePendingVmCheckouts([{ repoId: 'a', path: '/a', extra: 1 }, { repoId: 1 }, null]) + ).toEqual([{ repoId: 'a', path: '/a' }]) + const many = Array.from({ length: PENDING_VM_CHECKOUTS_LIMIT + 5 }, (_, i) => ({ + repoId: `r${i}`, + path: '/p' + })) + expect(parsePendingVmCheckouts(many)).toHaveLength(PENDING_VM_CHECKOUTS_LIMIT) + }) + + it('draws no empty-project placeholder for a grouped VM project while its create is pending', () => { + const repo: Repo = { + id: 'vm', + path: '/srv/repo', + displayName: 'VM', + badgeColor: '#000', + addedAt: 1, + projectGroupId: 'group-1' + } + const vmCheckout: Worktree = { + id: 'vm::/srv/repo', + repoId: 'vm', + path: '/srv/repo', + head: 'abc123', + branch: 'refs/heads/task', + isBare: false, + isMainWorktree: true, + displayName: 'task', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } + const args = { + groupBy: 'repo' as const, + repos: [repo], + worktreesByRepo: { vm: [vmCheckout] }, + visibleWorktrees: [], + filterRepoIds: [] + } + expect(getEmptyProjectPlaceholderRepoIds(args)).toEqual(new Set(['vm'])) + expect( + getEmptyProjectPlaceholderRepoIds({ ...args, pendingVmRepoIds: new Set(['vm']) }) + ).toEqual(new Set()) + }) +}) diff --git a/src/shared/pending-vm-checkouts.ts b/src/shared/pending-vm-checkouts.ts new file mode 100644 index 00000000000..e72448e2c0d --- /dev/null +++ b/src/shared/pending-vm-checkouts.ts @@ -0,0 +1,68 @@ +import * as z from 'zod' +import { normalizeRuntimePathForComparison } from './cross-platform-path' + +/** A VM's primary checkout, registered on its host before the create that owns it finishes. */ +export type PendingVmCheckout = { repoId: string; path: string } + +export const PENDING_VM_CHECKOUTS_CHANGED_CHANNEL = 'ephemeralVm:pendingCheckoutsChanged' +/** Bounds what one window may publish; a window runs only a handful of creates at once. */ +export const PENDING_VM_CHECKOUTS_LIMIT = 64 + +/** What lists filter by; `repoIds` lets most rows skip building a path key. */ +export type PendingVmCheckoutIndex = { + readonly repoIds: ReadonlySet + readonly keys: ReadonlySet +} + +export const EMPTY_PENDING_VM_CHECKOUT_INDEX: PendingVmCheckoutIndex = { + repoIds: new Set(), + keys: new Set() +} + +function checkoutKey(repoId: string, path: string): string { + return `${repoId}\0${normalizeRuntimePathForComparison(path)}` +} + +export function indexPendingVmCheckouts( + checkouts: Iterable +): PendingVmCheckoutIndex { + const list = [...checkouts] + if (list.length === 0) { + return EMPTY_PENDING_VM_CHECKOUT_INDEX + } + return { + repoIds: new Set(list.map((checkout) => checkout.repoId)), + keys: new Set(list.map((checkout) => checkoutKey(checkout.repoId, checkout.path))) + } +} + +/** A row is the VM checkout a create in progress has not adopted yet. Linked worktrees never are. */ +export function isPendingVmCheckout( + index: PendingVmCheckoutIndex, + row: + | { repoId?: unknown; path?: unknown; isMainWorktree?: unknown } + | Readonly> +): boolean { + const repoId = 'repoId' in row ? row.repoId : undefined + const path = 'path' in row ? row.path : undefined + return ( + typeof repoId === 'string' && + typeof path === 'string' && + !('isMainWorktree' in row && row.isMainWorktree === false) && + index.repoIds.has(repoId) && + index.keys.has(checkoutKey(repoId, path)) + ) +} + +const PendingVmCheckoutSchema = z.object({ repoId: z.string(), path: z.string() }) + +/** Untrusted IPC input: keep only well-formed entries, bounded. */ +export function parsePendingVmCheckouts(value: unknown): PendingVmCheckout[] { + if (!Array.isArray(value)) { + return [] + } + return value.slice(0, PENDING_VM_CHECKOUTS_LIMIT).flatMap((entry: unknown) => { + const parsed = PendingVmCheckoutSchema.safeParse(entry) + return parsed.success ? [{ repoId: parsed.data.repoId, path: parsed.data.path }] : [] + }) +} diff --git a/src/shared/sidebar-list/empty-project-placeholder-repos.ts b/src/shared/sidebar-list/empty-project-placeholder-repos.ts index 7410e80c695..bc5da8bfef6 100644 --- a/src/shared/sidebar-list/empty-project-placeholder-repos.ts +++ b/src/shared/sidebar-list/empty-project-placeholder-repos.ts @@ -8,6 +8,8 @@ export function getEmptyProjectPlaceholderRepoIds(args: { worktreesByRepo: Readonly> visibleWorktrees: readonly Worktree[] filterRepoIds: readonly string[] + /** Repos whose checkout a VM create in progress has not adopted; its pending row stands for them. */ + pendingVmRepoIds?: ReadonlySet }): Set { if (args.groupBy !== 'repo') { return new Set() @@ -17,7 +19,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: { const visibleRepoIds = new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) const placeholderRepoIds = new Set() for (const repo of args.repos) { - if (filterSet && !filterSet.has(repo.id)) { + if ((filterSet && !filterSet.has(repo.id)) || args.pendingVmRepoIds?.has(repo.id)) { continue } const hasNoWorktrees = (args.worktreesByRepo[repo.id]?.length ?? 0) === 0 diff --git a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts index 1076b2e976d..b10ffaed672 100644 --- a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts +++ b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts @@ -166,6 +166,23 @@ test('hides the VM checkout while a create is paused on failed checks, and leave body: await orcaPage.screenshot(), contentType: 'image/png' }) + // The jump palette reads the same pending set, so it offers no workspace for the VM checkout. + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + await palette.getByRole('combobox').fill(workspaceName) + // The palette has answered the query once its create-from-query row is listed. + await expect(palette.getByRole('option', { name: /Create/ }).first()).toBeVisible() + await expect( + palette + .getByRole('option', { name: new RegExp(workspaceName) }) + .filter({ hasNotText: /Create/ }) + ).toHaveCount(0) + await testInfo.attach('paused-vm-create-palette', { + body: await palette.screenshot(), + contentType: 'image/png' + }) + await palette.getByRole('combobox').press('Escape') + await expect(palette).toHaveCount(0) await sidebar.getByRole('button', { name: 'Cancel worktree creation' }).click() await expect(pendingRow).toBeHidden() From e0bf451becb6e88bd45539e6e91b994f151a115d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 06:59:23 -0800 Subject: [PATCH 003/138] test: remove thin agent-hook and mobile migration suites (#40382) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - .../scripts/ci/sharding/ci-shard-timings.json | 8 - mobile/src/home/home-query-restore.test.ts | 78 --------- .../authority/server-pane-authority.test.ts | 45 +----- .../server-reminted-pane-key.test.ts | 28 ---- .../server-retired-pane-new-turn.test.ts | 46 ------ ...ver-last-status-hydrate-validation.test.ts | 149 ------------------ ...server-last-status-main-agent-fact.test.ts | 100 ------------ .../server-observation-provenance.test.ts | 73 --------- .../server-restored-agent-status.test.ts | 53 ------- .../server-status-persist-throttle.test.ts | 17 -- .../amp/server-amp-normalization.test.ts | 130 --------------- ...rver-claude-config-dir-observation.test.ts | 58 ------- .../codex/server-codex-capacity.test.ts | 93 ----------- .../server-cursor-normalization.test.ts | 99 ------------ .../server-opencode-tui-identity.test.ts | 64 -------- 16 files changed, 1 insertion(+), 1041 deletions(-) delete mode 100644 mobile/src/home/home-query-restore.test.ts delete mode 100644 src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-validation.test.ts delete mode 100644 src/main/agent-hooks/server/testing/persistence/server-observation-provenance.test.ts delete mode 100644 src/main/agent-hooks/server/testing/persistence/server-restored-agent-status.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/amp/server-amp-normalization.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/claude/server-claude-config-dir-observation.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/codex/server-codex-capacity.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/cursor/server-cursor-normalization.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/opencode/server-opencode-tui-identity.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 50352f31001..8350dba4fc3 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -458,7 +458,6 @@ 1 test-no-wall-clock:src/cli/runtime/status-caller.test.ts 2 test-no-wall-clock:src/main/agent-hooks/installation/scripts/managed-hook-timeout.test.ts 2 test-no-wall-clock:src/main/agent-hooks/installation/scripts/posix-hook-json-stdin-reader.test.ts -1 test-no-wall-clock:src/main/agent-hooks/server/testing/providers/codex/server-codex-capacity.test.ts 1 test-no-wall-clock:src/main/agent-hooks/server/testing/http/server-endpoint-file-lifecycle.test.ts 3 test-no-wall-clock:src/main/agent-hooks/server/testing/structured-ingress/server-ingest-structured-status.test.ts 4 test-no-wall-clock:src/main/agent-hooks/server/testing/identity/server-launch-resume-identity.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 787742478fb..2ae44267f8f 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -553,11 +553,9 @@ "src/main/agent-hooks/server/testing/presence/server-agent-presence.test.ts": 1257, "src/main/agent-hooks/server/testing/tool-events/server-agent-tool-plugin-hooks.test.ts": 717, "src/main/agent-hooks/server/testing/identity/server-ai-vault-liveness.test.ts": 643, - "src/main/agent-hooks/server/testing/providers/amp/server-amp-normalization.test.ts": 607, "src/main/agent-hooks/server/testing/providers/antigravity/server-antigravity-resume-identity.test.ts": 820, "src/main/agent-hooks/server/testing/providers/claude/server-claude-blocked-prompt.test.ts": 681, "src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts": 681, - "src/main/agent-hooks/server/testing/providers/claude/server-claude-config-dir-observation.test.ts": 696, "src/main/agent-hooks/server/testing/interruptions/server-claude-helper-kill.test.ts": 10279, "src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts": 34523, "src/main/agent-hooks/server/testing/providers/claude/server-claude-normalization.test.ts": 683, @@ -569,7 +567,6 @@ "src/main/agent-hooks/server/testing/task-wakeup/server-claude-task-wakeup-lifecycle.test.ts": 5451, "src/main/agent-hooks/server/testing/interruptions/server-claude-terminal-interrupt.test.ts": 802, "src/main/agent-hooks/server/testing/authority/server-closed-tab-suppression.test.ts": 895, - "src/main/agent-hooks/server/testing/providers/codex/server-codex-capacity.test.ts": 671, "src/main/agent-hooks/server/testing/providers/codex/server-codex-interrupt-hook.test.ts": 679, "src/main/agent-hooks/server/testing/normalization/server-provider-normalization.test.ts": 741, "src/main/agent-hooks/server/testing/providers/codex/server-codex-review-turn-end.test.ts": 860, @@ -578,7 +575,6 @@ "src/main/agent-hooks/server/testing/providers/codex/server-codex-turn-interruption.test.ts": 716, "src/main/agent-hooks/server/testing/transcripts/server-transcript-result-retry.test.ts": 1125, "src/main/agent-hooks/server/testing/identity/server-copilot-resume-identity.test.ts": 729, - "src/main/agent-hooks/server/testing/providers/cursor/server-cursor-normalization.test.ts": 645, "src/main/agent-hooks/server/testing/http/server-endpoint-file-lifecycle.test.ts": 801, "src/main/agent-hooks/server/testing/providers/grok/server-grok-background-status.test.ts": 723, "src/main/agent-hooks/server/testing/providers/grok/server-grok-cancel.test.ts": 692, @@ -598,7 +594,6 @@ "src/main/agent-hooks/server/testing/interruptions/server-interrupt-inference-resurrection.test.ts": 759, "src/main/agent-hooks/server/testing/persistence/server-last-status-child-held-main-agent.test.ts": 937, "src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-confirmation.test.ts": 713, - "src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-validation.test.ts": 737, "src/main/agent-hooks/server/testing/persistence/server-last-status-lead-boundary.test.ts": 768, "src/main/agent-hooks/server/testing/persistence/server-last-status-main-agent-fact.test.ts": 813, "src/main/agent-hooks/server/testing/persistence/server-last-status-restored-children.test.ts": 818, @@ -610,10 +605,8 @@ "src/main/agent-hooks/server/testing/identity/server-nested-agent-consumers.test.ts": 660, "src/main/agent-hooks/server/testing/identity/server-nested-agent-model.test.ts": 665, "src/main/agent-hooks/server/testing/identity/server-nested-same-provider-owner.test.ts": 708, - "src/main/agent-hooks/server/testing/persistence/server-observation-provenance.test.ts": 672, "src/main/agent-hooks/server/testing/authority/server-omp-retired-ingress.test.ts": 641, "src/main/agent-hooks/server/testing/authority/server-omp-retirement-ack.test.ts": 676, - "src/main/agent-hooks/server/testing/providers/opencode/server-opencode-tui-identity.test.ts": 843, "src/main/agent-hooks/server/testing/identity/server-opencode-writer-observation.test.ts": 681, "src/main/agent-hooks/server/testing/authority/server-pane-authority.test.ts": 762, "src/main/agent-hooks/server/testing/terminal-ingress/server-process-lifetime-status.test.ts": 852, @@ -624,7 +617,6 @@ "src/main/agent-hooks/server/testing/authority/server-removed-worktree-foreign-authority.test.ts": 764, "src/main/agent-hooks/server/testing/authority/server-removed-worktree-status.test.ts": 687, "src/main/agent-hooks/server/testing/replay/server-replay-evidence-clock.test.ts": 643, - "src/main/agent-hooks/server/testing/persistence/server-restored-agent-status.test.ts": 694, "src/main/agent-hooks/server/testing/authority/server-retired-pane-new-turn.test.ts": 666, "src/main/agent-hooks/server/testing/http/server-startup-lifecycle.test.ts": 718, "src/main/agent-hooks/server/testing/http/server-startup-prompt-control.test.ts": 697, diff --git a/mobile/src/home/home-query-restore.test.ts b/mobile/src/home/home-query-restore.test.ts deleted file mode 100644 index 7e067edc4d6..00000000000 --- a/mobile/src/home/home-query-restore.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { QueryClient } from '@tanstack/react-query' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { homeWorktreeCountsKey } from '../host-screen/host-list-queries' -import { restoreHomeQueries } from './home-query-restore' - -const LEGACY_KEY = 'orca:home-snapshot:v1' -const asyncStorage = new Map() -const storeRows = new Map() - -vi.mock('react-native', () => ({ - AppState: { addEventListener: () => ({ remove: () => {} }) }, - Platform: { OS: 'ios' } -})) -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: async (key: string) => asyncStorage.get(key) ?? null, - removeItem: async (key: string) => { - asyncStorage.delete(key) - } - } -})) -vi.mock('../device-store/device-store', () => ({ - processDeviceStore: () => ({ - open: async () => 'ready', - read: async (hostId: string, _table: string, keys: readonly string[]) => ({ - status: 'ok', - values: keys.map((key) => storeRows.get(`${hostId}/${key}`) ?? null) - }), - write: async ( - hostId: string, - _table: string, - ops: readonly { key: string; value: string | null }[] - ) => { - for (const op of ops) { - if (op.value === null) { - storeRows.delete(`${hostId}/${op.key}`) - } else { - storeRows.set(`${hostId}/${op.key}`, op.value) - } - } - return { written: true } - } - }) -})) - -beforeEach(() => { - asyncStorage.clear() - storeRows.clear() -}) - -describe('restoreHomeQueries', () => { - it('moves the old Home snapshot into the store, so the launch after it still paints the counts', async () => { - asyncStorage.set( - LEGACY_KEY, - JSON.stringify({ - savedAt: 1_794_000_000_000, - worktreeInfo: { - 'host-a': { - hostId: 'host-a', - totalWorktrees: 7, - activeCount: 2, - lastActiveWorktree: null - } - } - }) - ) - await restoreHomeQueries(new QueryClient(), ['host-a']) - await vi.waitFor(() => expect(asyncStorage.has(LEGACY_KEY)).toBe(false)) - - const relaunched = new QueryClient() - await restoreHomeQueries(relaunched, ['host-a']) - expect(relaunched.getQueryData(homeWorktreeCountsKey('host-a'))).toEqual({ - totalWorktrees: 7, - activeCount: 2, - agents: [] - }) - }) -}) diff --git a/src/main/agent-hooks/server/testing/authority/server-pane-authority.test.ts b/src/main/agent-hooks/server/testing/authority/server-pane-authority.test.ts index d7449fc32e0..ed1048de0bb 100644 --- a/src/main/agent-hooks/server/testing/authority/server-pane-authority.test.ts +++ b/src/main/agent-hooks/server/testing/authority/server-pane-authority.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' import { AgentHookServer } from '../../agent-hook-server' -import { PANE_KEY_ALIASES_MAX } from '../../server-constants' const SOURCE = makePaneKey('tab-source', '11111111-1111-4111-8111-111111111111') const TARGET = makePaneKey('tab-target', '22222222-2222-4222-8222-222222222222') @@ -54,24 +53,6 @@ describe('AgentHookServer pane authority', () => { ]) }) - it('persists one physical alias while chained transfers advance its owner', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setPaneKeyAliasPersistenceListener(listener) - - server.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10) - server.transferPaneAuthority(TARGET, FINAL, 'pty-1', 20) - - expect(listener).toHaveBeenLastCalledWith([ - { - legacyPaneKey: SOURCE, - stablePaneKey: FINAL, - ptyId: 'pty-1', - updatedAt: 20 - } - ]) - }) - it('requires live PTY ownership for a first transfer and trusts the chained alias afterward', () => { const server = new AgentHookServer() @@ -131,28 +112,4 @@ describe('AgentHookServer pane authority', () => { expect(server.canTransferPaneAuthority(SOURCE, undefined, () => false)).toBe(true) expect(server.canTransferPaneAuthority(SOURCE, 'pty-1', () => false)).toBe(true) }) - - it('bounds persisted aliases by evicting the oldest authority', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setPaneKeyAliasPersistenceListener(listener) - - for (let index = 0; index <= PANE_KEY_ALIASES_MAX; index += 1) { - const suffix = index.toString(16).padStart(12, '0') - server.transferPaneAuthority( - makePaneKey(`source-${index}`, `00000000-0000-4000-8000-${suffix}`), - makePaneKey(`target-${index}`, `10000000-0000-4000-8000-${suffix}`), - `pty-${index}`, - index + 1 - ) - } - - const persisted = listener.mock.calls.at(-1)?.[0] - expect(persisted).toHaveLength(PANE_KEY_ALIASES_MAX) - expect(persisted).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ legacyPaneKey: expect.stringContaining('source-0:') }) - ]) - ) - }) }) diff --git a/src/main/agent-hooks/server/testing/authority/server-reminted-pane-key.test.ts b/src/main/agent-hooks/server/testing/authority/server-reminted-pane-key.test.ts index 3b580b0f963..3a46caacfc6 100644 --- a/src/main/agent-hooks/server/testing/authority/server-reminted-pane-key.test.ts +++ b/src/main/agent-hooks/server/testing/authority/server-reminted-pane-key.test.ts @@ -156,32 +156,4 @@ describe('reminted $$ pane keys on the OMP hook pipeline', () => { server.stop() } }) - - it('does not let a later persist row rebind a reminted token', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - server.registerPaneKeyAlias(REMINTED, PANE, 'pty-a', 10, { authorityVerified: true }) - server.registerPaneKeyAlias(REMINTED, GOOD_PANE, 'pty-b', 20, { overwriteExisting: false }) - await postHookEvent( - server, - buildBody( - { hook_event_name: 'before_agent_start', prompt: 'restored remint' }, - { paneKey: REMINTED, tabId: 'tab-good' } - ), - '/hook/omp' - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - state: 'working', - prompt: 'restored remint', - agentType: 'omp' - }) - ]) - } finally { - server.stop() - } - }) }) diff --git a/src/main/agent-hooks/server/testing/authority/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server/testing/authority/server-retired-pane-new-turn.test.ts index 66dbcaf20dd..2f360b22e15 100644 --- a/src/main/agent-hooks/server/testing/authority/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server/testing/authority/server-retired-pane-new-turn.test.ts @@ -18,38 +18,6 @@ beforeEach(() => { }) afterEach(() => vi.restoreAllMocks()) -/** Null omits the classifier boundary; mimo-code revives separately through explicit-prompt MessagePart. */ -const NEW_TURN_EVENT: Record = { - qoder: 'SessionStart', - 'qoder-cn': 'SessionStart', - 'qwen-code': 'SessionStart', - claude: 'SessionStart', - kimi: 'UserPromptSubmit', - codebuddy: 'UserPromptSubmit', - codex: 'SessionStart', - gemini: 'BeforeAgent', - antigravity: 'PreInvocation', - amp: 'agent.start', - cursor: 'beforeSubmitPrompt', - pi: 'before_agent_start', - omp: 'before_agent_start', - 'prime-agent': 'before_agent_start', - droid: 'UserPromptSubmit', - grok: 'user_prompt_submit', - copilot: 'sessionStart', - hermes: 'pre_llm_call', - devin: 'UserPromptSubmit', - opencode: 'SessionStart', - opencode2: 'SessionStart', - 'mimo-code': null, - 'command-code': null, - muse: 'UserPromptSubmit', - zcode: 'SessionStart', - dsh: 'SessionStart', - jcode: 'turn_start', - kiro: 'agentSpawn' -} - function reviveRetiredPane(source: unknown, hookEventName: string): boolean { const server = new AgentHookServer() server.retirePaneAuthority(PANE) @@ -72,20 +40,6 @@ function reviveRetiredPane(source: unknown, hookEventName: string): boolean { } describe("retired pane un-retires on each provider's own new-turn event", () => { - // Why: the gate matched two raw literals, so only the 5 sources that happen to name their - // boundary UserPromptSubmit/SessionStart could ever revive — the rest stayed rowless forever. - // Why: keys of a Record — a new source fails typecheck here rather than - // silently skipping coverage, which is the same guarantee the runtime list would give. - const revivable = (Object.keys(NEW_TURN_EVENT) as AgentHookSource[]).filter( - (source) => NEW_TURN_EVENT[source] !== null - ) - - it.each(revivable)('%s', (source) => { - const hookEventName = NEW_TURN_EVENT[source] - expect(hookEventName).not.toBeNull() - expect(reviveRetiredPane(source, hookEventName as string)).toBe(true) - }) - it('revives on a literal boundary when an older relay omits source', () => { expect(reviveRetiredPane(undefined, 'UserPromptSubmit')).toBe(true) }) diff --git a/src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-validation.test.ts b/src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-validation.test.ts deleted file mode 100644 index b3701652b41..00000000000 --- a/src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-validation.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { readSavedStatus, hasSavedStatus } from './last-status.test-fixtures' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' -import { - buildBody, - postHookEvent, - recentTs, - PANE, - GOOD_PANE, - LEAF_2 -} from '../server.test-fixtures' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('Last-status persistence', () => { - let userDataPath: string - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-laststatus-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function lastStatusPath(): string { - return join(userDataPath, 'agent-hooks', 'last-status.json') - } - - it('drops entries with malformed paneKeys but keeps valid ones', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - 'no-colon': { - paneKey: 'no-colon', - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'done', prompt: 'bad', agentType: 'claude' } - }, - [PANE]: { - paneKey: makePaneKey('tab-x', LEAF_2), - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } - }, - [GOOD_PANE]: { - paneKey: GOOD_PANE, - tabId: 'tab-good', - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'done', prompt: 'survived', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: GOOD_PANE, - payload: expect.objectContaining({ prompt: 'survived' }) - }) - ) - } finally { - server.stop() - } - }) - - it('clearPaneState evicts the entry from the on-disk file', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'about to drop' }) - ) - server.flushStatusPersistSync() - let parsed = JSON.parse(readSavedStatus(lastStatusPath())) - expect(parsed.entries[PANE]).toBeTruthy() - - server.clearPaneState(PANE) - server.flushStatusPersistSync() - parsed = JSON.parse(readSavedStatus(lastStatusPath())) - expect(parsed.entries[PANE]).toBeUndefined() - } finally { - server.stop() - } - }) - - it('stop() flushes pending debounced writes synchronously', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'flush me' }) - ) - // Let stop flush the pending debounce instead of flushing explicitly. - } finally { - server.stop() - } - expect(hasSavedStatus(lastStatusPath())).toBe(true) - const parsed = JSON.parse(readSavedStatus(lastStatusPath())) - expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') - }) -}) diff --git a/src/main/agent-hooks/server/testing/persistence/server-last-status-main-agent-fact.test.ts b/src/main/agent-hooks/server/testing/persistence/server-last-status-main-agent-fact.test.ts index 51baddf328d..ede1d432ee7 100644 --- a/src/main/agent-hooks/server/testing/persistence/server-last-status-main-agent-fact.test.ts +++ b/src/main/agent-hooks/server/testing/persistence/server-last-status-main-agent-fact.test.ts @@ -98,88 +98,6 @@ describe('The main agent fact across a restart', () => { } }) - it('maps the legacy child-only flag onto an absent main agent, dated by the turn end', async () => { - const receivedAt = recentTs() - writeEntry({ - receivedAt, - stateStartedAt: receivedAt - 5_000, - claudeLeadBoundaryChildOnly: true, - payload: { - state: 'working', - prompt: 'legacy row', - agentType: 'claude', - turnCompletedAt: receivedAt - 1_000, - subagents: [{ id: 'arestored-child', state: 'working', startedAt: receivedAt - 4_000 }] - } - }) - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - mainAgent: { state: 'done', stateStartedAt: receivedAt - 1_000 } - }) - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - mainAgent: { state: 'done' } - }) - } finally { - server.stop() - } - }) - - it.each([ - ['a child permission wait', 'waiting', ['PreToolUse', 'SubagentStop']], - ['a drained row', 'done', ['SubagentStart', 'SubagentStop']] - ] as const)( - 'settles a legacy flagged row holding %s once its child finishes', - async (_heldRow, state, childEvents) => { - const receivedAt = recentTs() - writeEntry({ - receivedAt, - stateStartedAt: receivedAt - 5_000, - claudeLeadBoundaryChildOnly: true, - payload: { - state, - prompt: 'legacy row', - agentType: 'claude', - ...(state === 'waiting' - ? { - toolName: 'Bash', - subagents: [{ id: 'achild', state: 'working', startedAt: receivedAt - 4_000 }] - } - : {}) - } - }) - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - for (const hookEventName of childEvents) { - await postHookEvent( - server, - buildBody({ - hook_event_name: hookEventName, - agent_id: 'achild', - ...(hookEventName === 'PreToolUse' - ? { tool_name: 'Bash', tool_use_id: 'toolu-approved' } - : {}) - }) - ) - } - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - mainAgent: { state: 'done' } - }) - } finally { - server.stop() - } - } - ) - it('does not restore a settled main agent from a row that does not say whether a shell ran', async () => { const receivedAt = recentTs() writeEntry({ @@ -209,22 +127,4 @@ describe('The main agent fact across a restart', () => { server.stop() } }) - - it('drops a malformed persisted main agent and keeps the row', async () => { - const receivedAt = recentTs() - writeEntry({ - receivedAt, - stateStartedAt: receivedAt - 5_000, - payload: { state: 'done', prompt: 'survived', agentType: 'claude', mainAgent: 'done' } - }) - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const row = server.getStatusSnapshot()[0] - expect(row).toMatchObject({ state: 'done', prompt: 'survived' }) - expect(row?.mainAgent).toBeUndefined() - } finally { - server.stop() - } - }) }) diff --git a/src/main/agent-hooks/server/testing/persistence/server-observation-provenance.test.ts b/src/main/agent-hooks/server/testing/persistence/server-observation-provenance.test.ts deleted file mode 100644 index 62bfbf2a44e..00000000000 --- a/src/main/agent-hooks/server/testing/persistence/server-observation-provenance.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { hasSavedStatus, readSavedStatus } from './last-status.test-fixtures' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../telemetry/client', () => ({ track: trackMock })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) - -const LEAF = '11111111-1111-4111-8111-111111111111' -const PANE = makePaneKey('tab-1', LEAF) -const CONNECTION = 'ssh-provenance' - -describe('agent status observation provenance', () => { - const servers: AgentHookServer[] = [] - - beforeEach(() => { - _internals.resetCachesForTests() - getCohortAtEmitMock.mockReturnValue({}) - }) - - afterEach(() => { - for (const server of servers) { - server.stop() - } - servers.length = 0 - vi.restoreAllMocks() - }) - - function newServer(): AgentHookServer { - const server = new AgentHookServer() - servers.push(server) - return server - } - - // Reuse provider boundary classification; raw event-name lists miss providers with different spellings. - - it('never persists the observation to last-status.json', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-observation-')) - try { - const server = newServer() - await server.start({ env: 'production', userDataPath }) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - source: 'claude', - hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'persist me', agentType: 'claude' } - }, - CONNECTION - ) - server.flushStatusPersistSync() - - const path = join(userDataPath, 'agent-hooks', 'last-status.json') - expect(hasSavedStatus(path)).toBe(true) - const file = readSavedStatus(path) - // Process-local sequencing cannot remain ordering authority after restart. - expect(file).not.toContain('observation') - expect(JSON.parse(file).entries[PANE].observation).toBeUndefined() - } finally { - rmSync(userDataPath, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/agent-hooks/server/testing/persistence/server-restored-agent-status.test.ts b/src/main/agent-hooks/server/testing/persistence/server-restored-agent-status.test.ts deleted file mode 100644 index 7ab071e2870..00000000000 --- a/src/main/agent-hooks/server/testing/persistence/server-restored-agent-status.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { GOOD_PANE as REMOTE_PANE, PANE } from '../server.test-fixtures' - -describe('restored agent status', () => { - let userDataPath: string - - beforeEach(() => { - _internals.resetCachesForTests() - userDataPath = mkdtempSync(join(tmpdir(), 'orca-restored-status-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it("returns the last report a pane's agent sent before the app restarted", async () => { - const before = new AgentHookServer() - await before.start({ env: 'production', userDataPath }) - before.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload: { state: 'done', prompt: 'review', agentType: 'codex' } - }) - // A remote host's row cannot be checked against a local process. - before.ingestTerminalStatus({ - paneKey: REMOTE_PANE, - tabId: 'tab-good', - worktreeId: 'wt-1', - connectionId: 'ssh-1', - payload: { state: 'done', prompt: 'review', agentType: 'codex' } - }) - before.flushStatusPersistSync() - before.stop() - - const after = new AgentHookServer() - await after.start({ env: 'production', userDataPath }) - try { - expect(await after.readRestoredAgentStatus(PANE)).toMatchObject({ state: 'done' }) - expect(after.getStatusSnapshot().map((row) => row.paneKey)).toContain(REMOTE_PANE) - expect(await after.readRestoredAgentStatus(REMOTE_PANE)).toBeNull() - after.setStatusHooksEnabled(false) - expect(await after.readRestoredAgentStatus(PANE)).toBeNull() - } finally { - after.stop() - } - }) -}) diff --git a/src/main/agent-hooks/server/testing/persistence/server-status-persist-throttle.test.ts b/src/main/agent-hooks/server/testing/persistence/server-status-persist-throttle.test.ts index 1c418ec3b7d..ebc06b935dd 100644 --- a/src/main/agent-hooks/server/testing/persistence/server-status-persist-throttle.test.ts +++ b/src/main/agent-hooks/server/testing/persistence/server-status-persist-throttle.test.ts @@ -46,23 +46,6 @@ describe('saved agent status cadence', () => { rmSync(userDataPath, { recursive: true, force: true }) }) - it('saves an authority change at once while a routine update waits for the window', async () => { - await prompt('first') - await vi.advanceTimersByTimeAsync(1) - expect(readPrompt()).toBe('first') - - await prompt('second') - await vi.advanceTimersByTimeAsync(1) - expect(readPrompt()).toBe('first') - - await prompt('third', 'launch-token-new') - await vi.advanceTimersByTimeAsync(1) - expect(JSON.parse(readSavedStatus(lastStatusPath())).entries[PANE]).toMatchObject({ - launchTokenHash: expect.any(String), - payload: expect.objectContaining({ prompt: 'third' }) - }) - }) - it('saves the latest status at stop and a restart keeps it', async () => { await prompt('first') await vi.advanceTimersByTimeAsync(1) diff --git a/src/main/agent-hooks/server/testing/providers/amp/server-amp-normalization.test.ts b/src/main/agent-hooks/server/testing/providers/amp/server-amp-normalization.test.ts deleted file mode 100644 index 8dccccd8d43..00000000000 --- a/src/main/agent-hooks/server/testing/providers/amp/server-amp-normalization.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { _internals } from '../../../agent-hook-server' -import { buildBody } from '../../server.test-fixtures' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('Amp hook normalization', () => { - it('keeps Amp prompt and tool caches isolated by thread id within one pane', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - threadId: 'thread-a', - message: 'first task' - }), - 'production' - ) - - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - threadId: 'thread-b', - message: 'second task' - }), - 'production' - ) - - const threadAResult = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.result', - threadId: 'thread-a', - tool: 'shell_command', - input: { command: 'pnpm test:a' }, - output: 'first done' - }), - 'production' - ) - - expect(threadAResult?.payload).toMatchObject({ - state: 'working', - prompt: 'first task', - agentType: 'amp', - toolName: 'shell_command', - toolInput: 'pnpm test:a', - lastAssistantMessage: 'first done' - }) - - const threadBDone = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.end', - threadId: 'thread-b', - status: 'done' - }), - 'production' - ) - - expect(threadBDone?.payload).toMatchObject({ - state: 'done', - prompt: 'second task', - agentType: 'amp' - }) - }) - - it('drops stale Amp tool events that arrive after the thread ended', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - threadId: 'thread-a', - message: 'run tests' - }), - 'production' - ) - - const done = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.end', - threadId: 'thread-a', - status: 'done' - }), - 'production' - ) - - expect(done?.payload).toMatchObject({ - state: 'done', - prompt: 'run tests', - agentType: 'amp' - }) - - const staleToolResult = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.result', - threadId: 'thread-a', - tool: 'shell_command', - input: { command: 'pnpm test' }, - message: 'tests passed' - }), - 'production' - ) - - expect(staleToolResult).toBeNull() - }) -}) diff --git a/src/main/agent-hooks/server/testing/providers/claude/server-claude-config-dir-observation.test.ts b/src/main/agent-hooks/server/testing/providers/claude/server-claude-config-dir-observation.test.ts deleted file mode 100644 index 51245f74e48..00000000000 --- a/src/main/agent-hooks/server/testing/providers/claude/server-claude-config-dir-observation.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { AgentHookServer } from '../../../agent-hook-server' -import { buildBody, GOOD_PANE, PANE, postHookEvent } from '../../server.test-fixtures' -import { ClaudeObservedConfigDirs } from '../../../../../claude-accounts/claude-observed-config-dirs' -import { installClaudeObservedConfigDirs } from '../../../../../claude-accounts/claude-profile-installed-router' - -vi.mock('../../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) -vi.mock('../../../../../../shared/agent-status/process/agent-process-presence-probe', () => ({ - probeAgentProcessPresence: async () => 'live' -})) - -let root: string | undefined -let server: AgentHookServer | undefined -afterEach(() => { - server?.stop() - installClaudeObservedConfigDirs(undefined) - if (root) { - rmSync(root, { recursive: true, force: true }) - } -}) - -it('records the Claude config folder from Claude hooks only, not same-layout CodeBuddy hooks', async () => { - root = mkdtempSync(join(tmpdir(), 'orca-claude-observed-')) - const transcript = (home: string) => { - mkdirSync(join(root!, home, 'projects', '-repo'), { recursive: true }) - return join(root!, home, 'projects', '-repo', 'id.jsonl') - } - const dirs = new ClaudeObservedConfigDirs(join(root, 'observed.json'), () => ({})) - installClaudeObservedConfigDirs(dirs) - server = new AgentHookServer() - await server.start({ env: 'production', userDataPath: join(root, 'orca') }) - const post = (path: string, transcriptPath: string, paneKey = PANE) => - postHookEvent( - server!, - buildBody( - { - hook_event_name: 'UserPromptSubmit', - session_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - transcript_path: transcriptPath, - prompt: 'hi' - }, - { - paneKey, - tabId: paneKey.split(':')[0], - agentProcess: JSON.stringify({ pid: 4001, platform: process.platform, startTime: 'x' }) - } - ), - path - ) - await post('/hook/claude', transcript('.claude-work')) - // Separate pane: one pane's claim does not depend on the other agent's. - await post('/hook/codebuddy', transcript('.codebuddy'), GOOD_PANE) - expect(dirs.list()).toEqual([join(root, '.claude-work')]) -}) diff --git a/src/main/agent-hooks/server/testing/providers/codex/server-codex-capacity.test.ts b/src/main/agent-hooks/server/testing/providers/codex/server-codex-capacity.test.ts deleted file mode 100644 index 9df7e909d78..00000000000 --- a/src/main/agent-hooks/server/testing/providers/codex/server-codex-capacity.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { AgentHookServer, _internals } from '../../../agent-hook-server' -import { buildBody, PANE, postHookEvent } from '../../server.test-fixtures' -import { CODEX_CAPACITY_MESSAGE } from '../../../../../../shared/codex-native-notice' - -afterEach(() => _internals.resetCachesForTests()) - -describe('Codex capacity verdict in the shared status store', () => { - it('keeps a local failure through Stop and child activity, then clears it on retry', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - const post = (payload: Record) => - postHookEvent(server, buildBody({ session_id: 'session-1', ...payload }), '/hook/codex') - try { - expect( - (await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Say hi', model: 'gpt-5' })).ok - ).toBe(true) - const previous = server.getStatusSnapshotForPane(PANE)[0] - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'done', - agentType: 'codex', - prompt: previous?.prompt ?? '', - model: previous?.model, - lastAssistantMessage: CODEX_CAPACITY_MESSAGE, - mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: Date.now() } - } - }) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ - prompt: 'Say hi', - model: 'gpt-5', - lastAssistantMessage: CODEX_CAPACITY_MESSAGE, - providerSession: { id: 'session-1' }, - mainAgent: { state: 'done', outcome: 'failure' } - }) - await post({ hook_event_name: 'Stop' }) - await post({ hook_event_name: 'SubagentStart', agent_id: 'child-1' }) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ - state: 'working', - mainAgent: { state: 'done', outcome: 'failure' } - }) - await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Say hi' }) - expect(server.getStatusSnapshotForPane(PANE)[0]?.mainAgent).toMatchObject({ - state: 'working' - }) - expect(server.getStatusSnapshotForPane(PANE)[0]?.mainAgent?.outcome).toBeUndefined() - } finally { - server.stop() - } - }) - - it('accepts host capacity evidence after an earlier done and preserves it across relay restatements', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - payload: { - state: 'done', - agentType: 'codex', - prompt: 'Say hi', - mainAgent: { state: 'done', stateStartedAt: 1 } - } - }, - 'ssh-host' - ) - server.ingestTerminalStatus({ - paneKey: PANE, - connectionId: 'ssh-host', - payload: { - state: 'done', - agentType: 'codex', - prompt: 'Say hi', - mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 2 } - } - }) - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'Stop', - payload: { - state: 'done', - agentType: 'codex', - prompt: 'Say hi' - } - }, - 'ssh-host' - ) - expect(server.getStatusSnapshotForPane(PANE)[0]?.mainAgent?.outcome).toBe('failure') - }) -}) diff --git a/src/main/agent-hooks/server/testing/providers/cursor/server-cursor-normalization.test.ts b/src/main/agent-hooks/server/testing/providers/cursor/server-cursor-normalization.test.ts deleted file mode 100644 index f61b10697c1..00000000000 --- a/src/main/agent-hooks/server/testing/providers/cursor/server-cursor-normalization.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentHookServer, _internals } from '../../../agent-hook-server' -import { buildBody, PANE } from '../../server.test-fixtures' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('Cursor hook normalization', () => { - it('late afterAgentResponse after stop keeps Cursor done instead of resurrecting working', () => { - const submit = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }), - 'production' - ) - expect(submit).not.toBeNull() - if (!submit) { - throw new Error('expected Cursor beforeSubmitPrompt to normalize') - } - agentHookServer.ingestRemote( - { - paneKey: submit.paneKey, - tabId: submit.tabId, - worktreeId: submit.worktreeId, - payload: submit.payload - }, - 'conn-1' - ) - - const stop = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'stop', status: 'completed' }), - 'production' - ) - expect(stop).not.toBeNull() - if (!stop) { - throw new Error('expected Cursor stop to normalize') - } - agentHookServer.ingestRemote( - { - paneKey: stop.paneKey, - tabId: stop.tabId, - worktreeId: stop.worktreeId, - payload: stop.payload - }, - 'conn-1' - ) - - const response = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'afterAgentResponse', text: 'All set.' }), - 'production' - ) - expect(response?.payload.state).toBe('done') - expect(response?.payload.lastAssistantMessage).toBe('All set.') - if (!response) { - throw new Error('expected Cursor afterAgentResponse to normalize') - } - - agentHookServer.ingestRemote( - { - paneKey: response.paneKey, - tabId: response.tabId, - worktreeId: response.worktreeId, - payload: response.payload - }, - 'conn-1' - ) - expect(agentHookServer.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'done', - agentType: 'cursor', - prompt: 'add tests', - lastAssistantMessage: 'All set.' - }) - ]) - }) -}) diff --git a/src/main/agent-hooks/server/testing/providers/opencode/server-opencode-tui-identity.test.ts b/src/main/agent-hooks/server/testing/providers/opencode/server-opencode-tui-identity.test.ts deleted file mode 100644 index 84bb1a998e4..00000000000 --- a/src/main/agent-hooks/server/testing/providers/opencode/server-opencode-tui-identity.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { AgentHookServer } from '../../../agent-hook-server' -import { lookupOpenCodeSessionPane } from '../../../../../../shared/agent-hook-listener/opencode-session-registry' -import { makePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' - -const PANE_A = makePaneKey('tab-a', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - -class IsolatedHookServer extends AgentHookServer { - isolateBinder(dbPath: string) { - this._setOpenCodeBinderDepsForTests({ - dbPath: () => dbPath, - listPanes: () => [], - sweep: async () => [] - }) - } -} - -describe('legacy TUI identity admitted by the canonical hook server', () => { - let dir: string - let server: IsolatedHookServer - - beforeEach(async () => { - dir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-host-')) - server = new IsolatedHookServer() - server.isolateBinder(join(dir, 'no-user-database')) - await server.start({ env: 'test', userDataPath: dir }) - }) - afterEach(() => { - server.stop() - rmSync(dir, { recursive: true, force: true }) - }) - const binding = (id: string) => lookupOpenCodeSessionPane(server._getStateForTests(), id) - - it('rejects another execution host token before learning its identity', async () => { - const other = new IsolatedHookServer() - other.isolateBinder(join(dir, 'other-no-user-database')) - await other.start({ env: 'other-host', userDataPath: join(dir, 'other-host') }) - try { - const { ORCA_AGENT_HOOK_PORT } = server.buildPtyEnv() - const { ORCA_AGENT_HOOK_TOKEN } = other.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/opencode`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify({ - paneKey: PANE_A, - env: 'other-host', - opencodeTui: 1, - payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_other_host' } - }) - }) - expect(response.status).toBe(403) - expect(binding('ses_other_host')).toBeUndefined() - expect(server.getStatusSnapshot()).toEqual([]) - } finally { - other.stop() - } - }) -}) From 93f90fca8986e31abfe95e3d6f1d16c02e88fbb0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:04:08 -0800 Subject: [PATCH 004/138] fix(vm-recipes): tell paired phones to re-read workspaces when a VM create's pending checkout changes (#40384) The phone filters in-progress VM checkouts only when it reads its host workspace list, so a read made before a window's hide reached the main process kept showing the checkout until the next unrelated change. Emit worktreesChanged for each repo whose pending checkout appears or goes away; phones already re-read their host workspace list on that event. --- .../ephemeral-vm/pending-vm-checkouts.test.ts | 26 ++++++++++++++++- src/main/ephemeral-vm/pending-vm-checkouts.ts | 28 +++++++++++++++++++ .../runtime/rpc/server/runtime-rpc-pairing.ts | 20 +++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/main/ephemeral-vm/pending-vm-checkouts.test.ts b/src/main/ephemeral-vm/pending-vm-checkouts.test.ts index b553a23cb4e..b0d84fc6997 100644 --- a/src/main/ephemeral-vm/pending-vm-checkouts.test.ts +++ b/src/main/ephemeral-vm/pending-vm-checkouts.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { isPendingVmCheckout } from '../../shared/pending-vm-checkouts' -import { PendingVmCheckoutRegistry } from './pending-vm-checkouts' +import { + PendingVmCheckoutRegistry, + publishPendingVmCheckoutRepoChanges +} from './pending-vm-checkouts' describe('PendingVmCheckoutRegistry', () => { it("serves every window's pending checkouts and drops a window that publishes none", () => { @@ -21,4 +24,25 @@ describe('PendingVmCheckoutRegistry', () => { registry.publish(3, []) expect(changed).toHaveBeenCalledTimes(3) }) + + it('tells phones to re-read each repo whose pending checkout appeared or went away', () => { + const registry = new PendingVmCheckoutRegistry() + registry.publish(1, [{ repoId: 'already', path: '/x' }]) + const notified: string[] = [] + const stop = publishPendingVmCheckoutRepoChanges(registry, (repoId) => notified.push(repoId)) + + registry.publish(2, [{ repoId: 'vm', path: '/srv/repo' }]) + expect(notified).toEqual(['vm']) + + // Another window's unrelated change does not re-announce this repo. + registry.publish(1, []) + expect(notified).toEqual(['vm', 'already']) + + registry.publish(2, []) + expect(notified).toEqual(['vm', 'already', 'vm']) + + stop() + registry.publish(2, [{ repoId: 'later', path: '/l' }]) + expect(notified).toEqual(['vm', 'already', 'vm']) + }) }) diff --git a/src/main/ephemeral-vm/pending-vm-checkouts.ts b/src/main/ephemeral-vm/pending-vm-checkouts.ts index 4c4dbedcdbf..289ed7b3f9a 100644 --- a/src/main/ephemeral-vm/pending-vm-checkouts.ts +++ b/src/main/ephemeral-vm/pending-vm-checkouts.ts @@ -49,6 +49,34 @@ export class PendingVmCheckoutRegistry { } } +/** + * Calls `notify` with each repo whose pending checkouts changed, so paired phones re-read their + * host workspace lists instead of keeping rows from a fetch made before the change reached main. + */ +export function publishPendingVmCheckoutRepoChanges( + registry: PendingVmCheckoutRegistry, + notify: (repoId: string) => void +): () => void { + const keyOf = (checkout: PendingVmCheckout): string => `${checkout.repoId}\0${checkout.path}` + let before = new Map(registry.list().map((checkout) => [keyOf(checkout), checkout.repoId])) + return registry.onChanged(() => { + const after = new Map(registry.list().map((checkout) => [keyOf(checkout), checkout.repoId])) + const changed = new Set() + for (const [key, repoId] of before) { + if (!after.has(key)) { + changed.add(repoId) + } + } + for (const [key, repoId] of after) { + if (!before.has(key)) { + changed.add(repoId) + } + } + before = after + changed.forEach(notify) + }) +} + function sameCheckouts(left: PendingVmCheckout[], right: PendingVmCheckout[]): boolean { return ( left.length === right.length && diff --git a/src/main/runtime/rpc/server/runtime-rpc-pairing.ts b/src/main/runtime/rpc/server/runtime-rpc-pairing.ts index 83ebd97abdf..0887c8c2bb8 100644 --- a/src/main/runtime/rpc/server/runtime-rpc-pairing.ts +++ b/src/main/runtime/rpc/server/runtime-rpc-pairing.ts @@ -30,7 +30,10 @@ import { type MobileRelayPairingProvider, type PairingOfferUnavailable } from './runtime-rpc-pairing-types' -import { pendingVmCheckouts } from '../../../ephemeral-vm/pending-vm-checkouts' +import { + pendingVmCheckouts, + publishPendingVmCheckoutRepoChanges +} from '../../../ephemeral-vm/pending-vm-checkouts' export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { getDeviceRegistry(): DeviceRegistry | null { @@ -132,12 +135,25 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { }) : null this.detachRelayHostsChanges?.() - this.detachRelayHostsChanges = + const detachListChanges = this.mobileRelayHostCatalog?.publishListChanges({ onClientEvent: (listener) => this.runtime.onClientEvent(listener, { consumesTerminalSideEffects: false }), changed: () => this.runtime.notifyRelayHostsChanged() }) ?? null + // Phones filter pending VM checkouts when they read; a change makes them read again. + const detachPendingVmChanges = this.mobileRelayHostCatalog + ? publishPendingVmCheckoutRepoChanges(pendingVmCheckouts, (repoId) => + this.runtime.notifyWorktreesChangedForRemoteClients(repoId) + ) + : null + this.detachRelayHostsChanges = + detachListChanges || detachPendingVmChanges + ? () => { + detachListChanges?.() + detachPendingVmChanges?.() + } + : null this.runtime.setPairedWorkspaceContextSource(this.mobileRelayHostCatalog) } From c82839fb7b603bd0398aab455da437c82567dff7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 07:14:40 -0800 Subject: [PATCH 005/138] test: remove agent-hook projections and URL inventories (#40388) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 5 - .../server-nested-agent-boundaries.test.ts | 29 -- .../server-nested-agent-consumers.test.ts | 82 ---- ...-last-status-child-held-main-agent.test.ts | 73 ---- ...r-last-status-hydrate-confirmation.test.ts | 353 ------------------ .../server-last-status-lead-boundary.test.ts | 174 --------- ...rver-last-status-restored-children.test.ts | 154 -------- .../server-last-status-write.test.ts | 122 +----- ...erver-ingest-remote-codex-question.test.ts | 130 ------- ...ver-ingest-remote-codex-transcript.test.ts | 190 +--------- .../server-ingest-remote.test.ts | 58 --- .../redirect-actions/redirect-url.test.ts | 39 -- 12 files changed, 3 insertions(+), 1406 deletions(-) delete mode 100644 src/main/agent-hooks/server/testing/identity/server-nested-agent-consumers.test.ts delete mode 100644 src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-confirmation.test.ts delete mode 100644 src/main/agent-hooks/server/testing/persistence/server-last-status-lead-boundary.test.ts delete mode 100644 src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-question.test.ts delete mode 100644 src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 2ae44267f8f..d14967d5a69 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -584,17 +584,13 @@ "src/main/agent-hooks/server/testing/identity/server-hook-session-binding.test.ts": 2674, "src/main/agent-hooks/server/testing/presence/server-http-owner-probe-lifecycle.test.ts": 1088, "src/main/agent-hooks/server/testing/presence/server-idle-process-owner.test.ts": 1014, - "src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-question.test.ts": 656, "src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-transcript.test.ts": 647, - "src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote.test.ts": 653, "src/main/agent-hooks/server/testing/structured-ingress/server-ingest-structured-state-clock.test.ts": 723, "src/main/agent-hooks/server/testing/structured-ingress/server-ingest-structured-status.test.ts": 697, "src/main/agent-hooks/server/testing/terminal-ingress/server-ingest-terminal-status.test.ts": 673, "src/main/agent-hooks/server/testing/interruptions/server-interrupt-inference-guards.test.ts": 685, "src/main/agent-hooks/server/testing/interruptions/server-interrupt-inference-resurrection.test.ts": 759, "src/main/agent-hooks/server/testing/persistence/server-last-status-child-held-main-agent.test.ts": 937, - "src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-confirmation.test.ts": 713, - "src/main/agent-hooks/server/testing/persistence/server-last-status-lead-boundary.test.ts": 768, "src/main/agent-hooks/server/testing/persistence/server-last-status-main-agent-fact.test.ts": 813, "src/main/agent-hooks/server/testing/persistence/server-last-status-restored-children.test.ts": 818, "src/main/agent-hooks/server/testing/persistence/server-last-status-write.test.ts": 745, @@ -602,7 +598,6 @@ "src/main/agent-hooks/server/testing/main-agent/server-main-agent-turn-verdicts.test.ts": 1716, "src/main/agent-hooks/server/testing/providers/muse/server-muse-session-log-poll.test.ts": 2772, "src/main/agent-hooks/server/testing/identity/server-nested-agent-boundaries.test.ts": 657, - "src/main/agent-hooks/server/testing/identity/server-nested-agent-consumers.test.ts": 660, "src/main/agent-hooks/server/testing/identity/server-nested-agent-model.test.ts": 665, "src/main/agent-hooks/server/testing/identity/server-nested-same-provider-owner.test.ts": 708, "src/main/agent-hooks/server/testing/authority/server-omp-retired-ingress.test.ts": 641, diff --git a/src/main/agent-hooks/server/testing/identity/server-nested-agent-boundaries.test.ts b/src/main/agent-hooks/server/testing/identity/server-nested-agent-boundaries.test.ts index 7570c06c56e..3721d72103f 100644 --- a/src/main/agent-hooks/server/testing/identity/server-nested-agent-boundaries.test.ts +++ b/src/main/agent-hooks/server/testing/identity/server-nested-agent-boundaries.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from '../../agent-hook-server' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' import { buildBody, PANE, postHookEvent } from '../server.test-fixtures' vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) @@ -8,12 +7,6 @@ vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) })) -class ClockedHookServer extends AgentHookServer { - advanceConnectionClock(at: number): void { - this.connectionTimestampWatermarkById.set('conn-1', at) - } -} - const route = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' } afterEach(() => { @@ -96,28 +89,6 @@ describe('nested provider ownership boundaries', () => { } }) - it('uses the retained connection clock to allow a stale provider replacement after rollback', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const server = new ClockedHookServer() - server.ingestRemote( - { ...route, payload: { state: 'working', prompt: 'Old work', agentType: 'claude' } }, - 'conn-1' - ) - const future = 1_000 + AGENT_STATUS_STALE_AFTER_MS + 100 - server.advanceConnectionClock(future) - vi.setSystemTime(500) - server.ingestRemote( - { ...route, payload: { state: 'working', prompt: 'New work', agentType: 'codex' } }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - agentType: 'codex', - prompt: 'New work', - receivedAt: future + 1 - }) - }) - it('keeps nested Pi identity-only metadata off an active owner and permits it after completion', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server/testing/identity/server-nested-agent-consumers.test.ts b/src/main/agent-hooks/server/testing/identity/server-nested-agent-consumers.test.ts deleted file mode 100644 index c9e1a79d245..00000000000 --- a/src/main/agent-hooks/server/testing/identity/server-nested-agent-consumers.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AgentHookServer } from '../../agent-hook-server' -import { buildBody, PANE, postHookEvent } from '../server.test-fixtures' -import { buildRuntimeMobileAgentStatus } from '../../../../runtime/session-lifecycle/projection/runtime-mobile-agent-status-builder' - -vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) -})) - -afterEach(() => vi.restoreAllMocks()) - -describe('nested hooks retain the session used by mobile', () => { - it('keeps the Claude mobile transcript through a nested Codex review', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - await postHookEvent( - server, - buildBody({ - hook_event_name: 'UserPromptSubmit', - session_id: 'claude-parent', - prompt: 'Review my changes' - }) - ) - const checkConsumers = () => { - const mobile = buildRuntimeMobileAgentStatus( - null, - { - type: 'terminal', - id: 'tab-1::leaf', - parentTabId: 'tab-1', - leafId: 'leaf', - title: 'Claude', - launchAgent: 'claude', - isActive: true - }, - null, - null, - () => server.getStatusSnapshot(), - { - getPaneKey: () => PANE, - getLeaf: () => null, - getTrackedTitle: () => null - } - ) - expect(mobile).toMatchObject({ - agentStatus: { - agentType: 'claude', - prompt: 'Review my changes', - providerSession: { id: 'claude-parent' } - } - }) - } - checkConsumers() - for (const hook_event_name of [ - 'SessionStart', - 'UserPromptSubmit', - 'PreToolUse', - 'PostToolUse', - 'PermissionRequest', - 'Stop' - ]) { - const response = await postHookEvent( - server, - buildBody({ - hook_event_name, - session_id: 'nested-codex', - prompt: 'Nested review', - tool_name: 'shell', - model: 'gpt-6-astra' - }), - '/hook/codex' - ) - expect(response.status).toBe(204) - checkConsumers() - } - } finally { - server.stop() - } - }) -}) diff --git a/src/main/agent-hooks/server/testing/persistence/server-last-status-child-held-main-agent.test.ts b/src/main/agent-hooks/server/testing/persistence/server-last-status-child-held-main-agent.test.ts index 3f36e949da2..34ada9e3b43 100644 --- a/src/main/agent-hooks/server/testing/persistence/server-last-status-child-held-main-agent.test.ts +++ b/src/main/agent-hooks/server/testing/persistence/server-last-status-child-held-main-agent.test.ts @@ -67,35 +67,10 @@ describe('Claude rows held open by child agents', () => { await post(server, { hook_event_name: 'Stop' }) } - function osc(server: AgentHookServer, state: 'working' | 'done'): void { - server.ingestTerminalStatus({ - paneKey: PANE, - connectionId: null, - payload: { state, prompt: '', agentType: 'claude' } - }) - } - function row(server: AgentHookServer) { return server.getStatusSnapshot()[0] } - it('keeps a child permission prompt visible when OSC reports done', async () => { - const server = await startServer() - try { - await settleMainAgentBeside(server, ['achild-a']) - await post(server, CHILD_PERMISSION) - osc(server, 'done') - - expect(row(server)).toMatchObject({ - state: 'waiting', - toolName: 'Bash', - mainAgent: { state: 'done' } - }) - } finally { - server.stop() - } - }) - it('settles a restored child permission wait once the child is approved and stops', async () => { let server = await startServer() await settleMainAgentBeside(server, ['achild-a']) @@ -119,27 +94,6 @@ describe('Claude rows held open by child agents', () => { } }) - it('pushes the held child permission row when the main agent behind it changes', async () => { - const server = await startServer() - try { - await settleMainAgentBeside(server, ['achild-a']) - await post(server, CHILD_PERMISSION) - const pushed: { state: string; mainAgent?: { state: string } }[] = [] - server.subscribeEnrichedStatus((enriched) => pushed.push(enriched.payload)) - await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Read' }) - expect(pushed).toEqual([ - expect.objectContaining({ - state: 'waiting', - mainAgent: expect.objectContaining({ state: 'working' }) - }) - ]) - await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Grep' }) - expect(pushed).toHaveLength(1) - } finally { - server.stop() - } - }) - it('does not settle a restored row whose main agent resumed behind a child permission', async () => { let server = await startServer() await settleMainAgentBeside(server, ['achild-a', 'achild-b']) @@ -161,33 +115,6 @@ describe('Claude rows held open by child agents', () => { } }) - it('does not settle a restored row whose main agent left a shell running behind a child permission', async () => { - let server = await startServer() - await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) - await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) - await post(server, CHILD_PERMISSION) - await post(server, { - hook_event_name: 'Stop', - background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] - }) - expect(row(server)).toMatchObject({ state: 'waiting', mainAgent: { state: 'done' } }) - server = await restart(server) - try { - await post(server, { - hook_event_name: 'PreToolUse', - agent_id: 'achild-a', - tool_name: 'Bash', - tool_input: { command: 'rm scratch' }, - tool_use_id: 'toolu-approved' - }) - await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) - - expect(row(server)?.state).toBe('working') - } finally { - server.stop() - } - }) - it('settles a restored row after an inferred child answer when no shell ran', async () => { let server = await startServer() await settleMainAgentBeside(server, ['achild-a']) diff --git a/src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-confirmation.test.ts b/src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-confirmation.test.ts deleted file mode 100644 index 5df796b53a5..00000000000 --- a/src/main/agent-hooks/server/testing/persistence/server-last-status-hydrate-confirmation.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, postHookEvent, recentTs, PANE, GOOD_PANE } from '../server.test-fixtures' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('Last-status persistence', () => { - let userDataPath: string - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-laststatus-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function lastStatusPath(): string { - return join(userDataPath, 'agent-hooks', 'last-status.json') - } - - it('lets an identical live OSC observation confirm a hydrated row', async () => { - const payload = { - state: 'working' as const, - prompt: 'still running', - agentType: 'claude' as const - } - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload - }) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const restored = server.getStatusSnapshot()[0] - if (!restored) { - throw new Error('expected hydrated status') - } - expect(restored?.restoredUnconfirmed).toBe(true) - vi.spyOn(Date, 'now').mockReturnValue(restored.receivedAt - 1_000) - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload - }) - const confirmed = server.getStatusSnapshot()[0] - expect(confirmed?.restoredUnconfirmed).toBeUndefined() - expect(confirmed?.receivedAt).toBe(restored.receivedAt + 1) - } finally { - server.stop() - } - }) - - it('does not let a hydrated Claude permission suppress the first live working event', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PermissionRequest', - payload: { - state: 'waiting', - prompt: 'review command', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'dangerous command' - } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PreToolUse', - payload: { - state: 'working', - prompt: 'review command', - agentType: 'claude', - toolName: 'Read', - toolInput: 'package.json' - } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - toolName: 'Read' - }) - expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - } finally { - server.stop() - } - }) - - it('does not inherit a stale tool id from hydrated Claude progress', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PreToolUse', - toolUseId: 'tool-old', - toolAgentType: 'main', - payload: { - state: 'working', - prompt: 'run tests', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PermissionRequest', - toolAgentType: 'main', - payload: { - state: 'waiting', - prompt: 'run tests', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PostToolUse', - toolUseId: 'tool-new', - toolAgentType: 'main', - payload: { - state: 'working', - prompt: 'run tests', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - } finally { - server.stop() - } - }) - - it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => { - const now = 1_700_000_000_000 - vi.spyOn(Date, 'now').mockReturnValue(now) - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = now + 1_000 - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'ssh-a', - receivedAt, - stateStartedAt: receivedAt, - payload: { state: 'working', prompt: 'before restart', agentType: 'claude' } - } - } - }), - 'utf8' - ) - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const clearListener = vi.fn() - server.setPaneStatusClearListener(clearListener) - server.ingestRemote( - { paneKey: PANE, payload: { state: 'working', agentType: 'codex' } }, - 'ssh-a' - ) - - expect(server.getStatusSnapshot()[0]?.receivedAt).toBe(receivedAt + 1) - server.clearStatusEntriesForConnection('ssh-a') - const clearedAt = receivedAt + 2 - expect(clearListener).toHaveBeenCalledWith({ - transient: true, - connectionId: 'ssh-a', - clearedAt - }) - server.ingestRemote( - { - paneKey: GOOD_PANE, - isReplay: true, - payload: { state: 'working', agentType: 'claude' } - }, - 'ssh-a' - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: GOOD_PANE, - connectionId: 'ssh-a', - receivedAt: clearedAt + 1 - }) - ]) - } finally { - server.stop() - } - }) - - it('restores Codex child hierarchy and reaps unconfirmed children on the next root Stop', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt: recentTs(-1000), - payload: { - state: 'working', - prompt: 'coordinate reviews', - agentType: 'codex', - model: 'gpt-5.4', - subagents: [ - { - id: '11111111-2222-4333-8444-555555555555', - state: 'working', - startedAt: receivedAt - 5000, - agentType: 'reviewer', - model: 'gpt-5.4-mini' - } - ] - } - } - } - }), - 'utf8' - ) - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - model: 'gpt-5.4', - subagents: [ - expect.objectContaining({ - id: '11111111-2222-4333-8444-555555555555', - model: 'gpt-5.4-mini' - }) - ] - }) - ]) - - await postHookEvent( - server, - buildBody({ - hook_event_name: 'PreToolUse', - agent_id: '11111111-2222-4333-8444-555555555555', - agent_type: 'reviewer', - model: 'gpt-5.4-mini', - tool_name: 'exec_command', - tool_input: { cmd: 'pnpm test' } - }), - '/hook/codex' - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - model: 'gpt-5.4', - subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })] - }) - ]) - - await postHookEvent( - server, - buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), - '/hook/codex' - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ state: 'done', model: 'gpt-5.4', subagents: undefined }) - ]) - } finally { - server.stop() - } - }) -}) diff --git a/src/main/agent-hooks/server/testing/persistence/server-last-status-lead-boundary.test.ts b/src/main/agent-hooks/server/testing/persistence/server-last-status-lead-boundary.test.ts deleted file mode 100644 index 6d428b401dd..00000000000 --- a/src/main/agent-hooks/server/testing/persistence/server-last-status-lead-boundary.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from '../server.test-fixtures' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../telemetry/client', () => ({ track: trackMock })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('Persisted Claude lead boundaries', () => { - let userDataPath: string - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-lead-boundary-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('restores a persisted lead boundary before its last child stops', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after child' }) - ) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' }) - ) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'Stop', last_assistant_message: 'Lead finished.' }) - ) - expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - prompt: 'finish after child', - lastAssistantMessage: 'Lead finished.', - subagents: [expect.objectContaining({ id: 'arestored-child', state: 'working' })] - }) - // A subagent holds the lead's finish, so its Stop is not stamped for announcement. - expect(firstServer.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined() - expect(firstServer._getStateForTests().lastStatusByPaneKey.get(PANE)?.hookEventName).toBe( - 'Stop' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - agentType: 'claude', - prompt: 'finish after child', - lastAssistantMessage: 'Lead finished.', - mainAgent: expect.objectContaining({ state: 'done' }) - }) - expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() - } finally { - server.stop() - } - }) - - it('keeps a persisted child boundary across an OSC repaint and restart', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after child' }) - ) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' }) - ) - await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' })) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const secondServer = new AgentHookServer() - await secondServer.start({ env: 'production', userDataPath }) - secondServer.ingestTerminalStatus({ - paneKey: PANE, - connectionId: null, - payload: { state: 'working', prompt: '', agentType: 'claude' } - }) - secondServer.flushStatusPersistSync() - secondServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - agentType: 'claude', - mainAgent: expect.objectContaining({ state: 'done' }) - }) - expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - } finally { - server.stop() - } - }) - - it('keeps a restored lead boundary unconfirmed when background work was running', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish background work' }) - ) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' }) - ) - await postHookEvent( - firstServer, - buildBody({ - hook_event_name: 'Stop', - background_tasks: [ - { id: 'arestored-child', type: 'subagent', status: 'running' }, - RUNNING_SHELL - ] - }) - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - restoredUnconfirmed: true - }) - expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) - } finally { - server.stop() - } - }) -}) diff --git a/src/main/agent-hooks/server/testing/persistence/server-last-status-restored-children.test.ts b/src/main/agent-hooks/server/testing/persistence/server-last-status-restored-children.test.ts index 812b2270511..bb42bc4e671 100644 --- a/src/main/agent-hooks/server/testing/persistence/server-last-status-restored-children.test.ts +++ b/src/main/agent-hooks/server/testing/persistence/server-last-status-restored-children.test.ts @@ -40,101 +40,6 @@ describe('Last-status persistence', () => { rmSync(userDataPath, { recursive: true, force: true }) }) - it('waits for a lead boundary after a post-restart stop matches a restored child', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - await postHookEvent( - firstServer, - buildBody({ - hook_event_name: 'SubagentStart', - agent_id: 'a0000000000000002', - agent_type: 'reviewer' - }) - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const statusListener = vi.fn() - server.subscribeEnrichedStatus(statusListener) - const restored = server.getStatusSnapshot()[0] - expect(restored).toMatchObject({ - state: 'working', - restoredUnconfirmed: true, - subagents: [expect.objectContaining({ id: 'a0000000000000002' })] - }) - - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000002' }) - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - restoredUnconfirmed: true, - subagents: undefined - }) - expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) - expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0) - expect(statusListener).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ restoredUnconfirmed: true }) - ) - - await postHookEvent(server, buildBody({ hook_event_name: 'Stop', background_tasks: [] })) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) - expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() - expect(statusListener).toHaveBeenCalledTimes(2) - } finally { - server.stop() - } - }) - - it('does not resurrect a matched restored child across a second restart', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - await postHookEvent( - firstServer, - buildBody({ - hook_event_name: 'SubagentStart', - agent_id: 'a0000000000000012', - agent_type: 'reviewer' - }) - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const secondServer = new AgentHookServer() - await secondServer.start({ env: 'production', userDataPath }) - await postHookEvent( - secondServer, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000012' }) - ) - expect(secondServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - restoredUnconfirmed: true, - subagents: undefined - }) - secondServer.flushStatusPersistSync() - secondServer.stop() - - const thirdServer = new AgentHookServer() - await thirdServer.start({ env: 'production', userDataPath }) - try { - expect(thirdServer._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0) - - await postHookEvent(thirdServer, buildBody({ hook_event_name: 'Stop' })) - - expect(thirdServer.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) - expect(thirdServer.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - } finally { - thirdServer.stop() - } - }) - it('keeps a legacy Stop unconfirmed when only a restored child gates it', async () => { const firstServer = new AgentHookServer() await firstServer.start({ env: 'production', userDataPath }) @@ -165,65 +70,6 @@ describe('Last-status persistence', () => { } }) - it('keeps an unmatched restored child unconfirmed after current-runtime work drains', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - await postHookEvent( - firstServer, - buildBody({ - hook_event_name: 'SubagentStart', - agent_id: 'a0000000000000005', - agent_type: 'reviewer' - }) - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - await postHookEvent( - server, - buildBody({ - hook_event_name: 'SubagentStart', - agent_id: 'a0000000000000006', - agent_type: 'reviewer' - }) - ) - expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000006' }) - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - restoredUnconfirmed: true, - subagents: [expect.objectContaining({ id: 'a0000000000000005' })] - }) - expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) - - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000005' }) - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - restoredUnconfirmed: true - }) - expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0) - - await postHookEvent(server, buildBody({ hook_event_name: 'Stop', background_tasks: [] })) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) - expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() - } finally { - server.stop() - } - }) - it('waits for a lead boundary after a post-restart idle matches a restored teammate', async () => { const firstServer = new AgentHookServer() await firstServer.start({ env: 'production', userDataPath }) diff --git a/src/main/agent-hooks/server/testing/persistence/server-last-status-write.test.ts b/src/main/agent-hooks/server/testing/persistence/server-last-status-write.test.ts index 27365f808c8..3e21538ca7e 100644 --- a/src/main/agent-hooks/server/testing/persistence/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server/testing/persistence/server-last-status-write.test.ts @@ -1,11 +1,11 @@ -import { readSavedStatus, hasSavedStatus } from './last-status.test-fixtures' +import { readSavedStatus } from './last-status.test-fixtures' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHash } from 'node:crypto' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, postHookEvent, recentTs, PANE, RUNNING_SHELL } from '../server.test-fixtures' +import { recentTs, PANE } from '../server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -46,59 +46,6 @@ describe('Last-status persistence', () => { return join(userDataPath, 'agent-hooks', 'last-status.json') } - it('writes last-status.json after a hook event', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody( - { hook_event_name: 'UserPromptSubmit', prompt: 'persist me' }, - { launchToken: 'launch-bearer-must-not-persist' } - ) - ) - await postHookEvent( - server, - buildBody( - { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }, - { launchToken: 'launch-bearer-must-not-persist' } - ) - ) - server.flushStatusPersistSync() - expect(hasSavedStatus(lastStatusPath())).toBe(true) - const file = JSON.parse(readSavedStatus(lastStatusPath())) - expect(file.version).toBe(2) - expect(file.entries[PANE]).toMatchObject({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt: expect.any(Number), - stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ - state: 'working', - workingMode: 'monitoring', - prompt: 'persist me' - }) - }) - expect(file.entries[PANE].launchToken).toBeUndefined() - expect(file.entries[PANE].launchTokenHash).toBe( - createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') - ) - // Shell evidence determines whether a settled main agent held open by children can be restored. - expect(file.entries[PANE].claudeRunningNonAgentTask).toBe(true) - expect(file.entries[PANE].payload.mainAgent).toEqual({ - state: 'done', - stateStartedAt: expect.any(Number) - }) - expect(readSavedStatus(lastStatusPath())).not.toContain('launch-bearer-must-not-persist') - } finally { - server.stop() - } - }) - it('scrubs a legacy persisted launch bearer while retaining its authority commitment', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) const receivedAt = recentTs() @@ -230,69 +177,4 @@ describe('Last-status persistence', () => { retired.stop() } }) - - it('persists and hydrates Pi session identity without creating status telemetry', async () => { - const firstServer = new AgentHookServer() - const firstRendererListener = vi.fn() - const statusChangeListener = vi.fn() - firstServer.setListener(firstRendererListener) - firstServer.subscribeStatusChanges(statusChangeListener) - await firstServer.start({ env: 'production', userDataPath }) - try { - const response = await postHookEvent( - firstServer, - buildBody({ - hook_event_name: 'session_start', - session_id: 'pi-session-1', - session_file: '/tmp/pi-session-1.jsonl' - }), - '/hook/pi' - ) - expect(response.status).toBe(204) - expect(firstRendererListener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - } - }) - ) - expect(statusChangeListener).toHaveBeenCalledWith([]) - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - - firstServer.flushStatusPersistSync() - const file = JSON.parse(readSavedStatus(lastStatusPath())) - expect(file.entries[PANE]).toMatchObject({ - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - } - }) - } finally { - firstServer.stop() - } - - const hydratedServer = new AgentHookServer() - await hydratedServer.start({ env: 'production', userDataPath }) - try { - const hydratedListener = vi.fn() - hydratedServer.setListener(hydratedListener) - expect(hydratedListener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - providerSessionOnly: true, - providerSession: expect.objectContaining({ transcriptPath: '/tmp/pi-session-1.jsonl' }), - isReplay: true - }) - ) - expect(hydratedServer.getStatusChangeSnapshot()).toEqual([]) - } finally { - hydratedServer.stop() - } - }) }) diff --git a/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-question.test.ts b/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-question.test.ts deleted file mode 100644 index b225204259e..00000000000 --- a/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-question.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { normalizeHookPayload } from '../../../../../shared/agent-hook-listener/normalization/normalize-hook-payload' -import { - createHookListenerState, - seedLegacyAgentStatusForTests -} from '../../../../../shared/agent-hook-listener/listener-state' -import { buildRelayHookEnvelope } from '../../../../../wsl-guest/agent-hook-envelope-build' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { PANE, buildBody } from '../server.test-fixtures' - -vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) - -const QUESTIONS = { - questions: [ - { - id: 'scope', - header: 'Scope', - question: 'Which scope should I use?', - options: [{ label: 'Current workspace', description: 'Update the current workspace.' }] - } - ] -} - -beforeEach(() => { - _internals.resetCachesForTests() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function remoteCodex() { - const host = createHookListenerState() - const server = new AgentHookServer() - return { - server, - report(payload: Record, legacy = false) { - const event = normalizeHookPayload( - host, - 'codex', - buildBody(payload, { worktreeId: 'folder-workspace' }), - 'production' - ) - if (!event) { - throw new Error('Expected a Codex hook event') - } - seedLegacyAgentStatusForTests(host, event) - const { mainAgent: _mainAgent, ...legacyPayload } = event.payload - server.ingestRemote( - buildRelayHookEnvelope( - { ...event, payload: legacy ? legacyPayload : event.payload }, - 'codex' - ), - 'wsl:Ubuntu' - ) - return server.getStatusSnapshotForPane(PANE)[0] - } - } -} - -describe('remote Codex questions', () => { - it.each([false, true])( - 'keeps a host question waiting until its answer (legacy relay: %s)', - (legacy) => { - const pane = remoteCodex() - expect( - pane.report( - { hook_event_name: 'UserPromptSubmit', prompt: 'Update this workspace' }, - legacy - ) - ).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) - - const question = pane.report( - { - hook_event_name: 'PreToolUse', - tool_name: 'request_user_input', - tool_input: QUESTIONS, - tool_use_id: 'question-1' - }, - legacy - ) - expect(question).toMatchObject({ - state: 'waiting', - mainAgent: { state: 'waiting' }, - toolName: 'request_user_input', - interactivePrompt: JSON.stringify(QUESTIONS), - connectionId: 'wsl:Ubuntu', - worktreeId: 'folder-workspace' - }) - - expect( - pane.report( - { - hook_event_name: 'PostToolUse', - tool_name: 'request_user_input', - tool_input: QUESTIONS, - tool_response: '{"answers":{"scope":{"answers":["Current workspace"]}}}', - tool_use_id: 'question-1' - }, - legacy - ) - ).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) - expect(pane.server.getStatusSnapshotForPane(PANE)[0]?.interactivePrompt).toBeUndefined() - - expect(pane.report({ hook_event_name: 'Stop' }, legacy)).toMatchObject({ - state: 'done', - mainAgent: { state: 'done' } - }) - } - ) - - it('does not mistake a waiting child for a root question on an ordinary tool call', () => { - const pane = remoteCodex() - pane.report({ hook_event_name: 'UserPromptSubmit', prompt: 'Run the checks' }) - pane.report({ hook_event_name: 'SubagentStart', agent_id: 'child' }) - pane.report({ - hook_event_name: 'PreToolUse', - agent_id: 'child', - tool_name: 'request_user_input', - tool_input: QUESTIONS - }) - - expect(pane.report({ hook_event_name: 'PreToolUse', tool_name: 'exec_command' })).toMatchObject( - { state: 'waiting', mainAgent: { state: 'working' } } - ) - }) -}) diff --git a/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-transcript.test.ts b/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-transcript.test.ts index 4b8b6e4c152..47ee6fd17ca 100644 --- a/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-transcript.test.ts +++ b/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote-codex-transcript.test.ts @@ -1,15 +1,6 @@ -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { normalizeHookPayload } from '../../../../../shared/agent-hook-listener/normalization/normalize-hook-payload' -import { createShedSubagentsField } from '../../../../../shared/agent-hook-listener/transport/agent-hook-relay' -import { createHookListenerState } from '../../../../../shared/agent-hook-listener/listener-state' -import { pollCodexTranscriptStatus } from '../../../../../shared/agent-hook-listener/providers/codex-transcript-poll' -import { transcriptPollUpdate } from '../../../../../shared/agent-hook-listener/transcript-poll-policy' -import { buildRelayHookEnvelope } from '../../../../../wsl-guest/agent-hook-envelope-build' import { AgentHookServer, _internals } from '../../agent-hook-server' -import { PANE, buildBody } from '../server.test-fixtures' +import { PANE } from '../server.test-fixtures' vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../../../telemetry/cohort-classifier', () => ({ @@ -17,194 +8,15 @@ vi.mock('../../../../telemetry/cohort-classifier', () => ({ })) const CHILD = '01a16839-7f84-7ed3-aebf-ea51e50330cf' -const SECOND_CHILD = '02a16839-7f84-7ed3-aebf-ea51e50330cf' -const line = (type: string): string => - `${JSON.stringify({ type: 'event_msg', payload: { type } })}\n` - -let directory: string beforeEach(() => { _internals.resetCachesForTests() - directory = mkdtempSync(join(tmpdir(), 'orca-remote-codex-child-')) }) afterEach(() => { - rmSync(directory, { recursive: true, force: true }) vi.restoreAllMocks() }) -describe('remote Codex transcript completion', () => { - it('retires transcript-completed children while keeping surviving child work live', () => { - const parentPath = join(directory, 'rollout-parent.jsonl') - const childPath = join(directory, `rollout-child-${CHILD}.jsonl`) - const secondChildPath = join(directory, `rollout-child-${SECOND_CHILD}.jsonl`) - writeFileSync(parentPath, line('task_complete')) - writeFileSync(childPath, line('task_started')) - writeFileSync(secondChildPath, line('task_started')) - const hostState = createHookListenerState() - const server = new AgentHookServer() - const root = normalizeHookPayload( - hostState, - 'codex', - buildBody({ - hook_event_name: 'Stop', - session_id: 'root', - transcript_path: parentPath - }), - 'production' - ) - if (!root) { - throw new Error('Root hook was rejected') - } - server.ingestRemote(buildRelayHookEnvelope(root, 'codex'), 'wsl:Ubuntu') - - const childBody = buildBody({ - hook_event_name: 'SubagentStart', - agent_id: CHILD, - session_id: CHILD, - transcript_path: childPath - }) - const started = normalizeHookPayload(hostState, 'codex', childBody, 'production') - if (!started) { - throw new Error('Child hook was rejected') - } - server.ingestRemote(buildRelayHookEnvelope(started, 'codex'), 'wsl:Ubuntu') - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - subagents: [ - { - id: CHILD, - state: 'working', - startedAt: expect.any(Number) - } - ] - }) - ]) - - const secondChildBody = buildBody({ - hook_event_name: 'SubagentStart', - agent_id: SECOND_CHILD, - session_id: SECOND_CHILD, - transcript_path: secondChildPath - }) - const secondStarted = normalizeHookPayload(hostState, 'codex', secondChildBody, 'production') - if (!secondStarted) { - throw new Error('Second child hook was rejected') - } - server.ingestRemote(buildRelayHookEnvelope(secondStarted, 'codex'), 'wsl:Ubuntu') - expect( - server - .getStatusSnapshot()[0] - .subagents?.map((child) => child.id) - .sort() - ).toEqual([CHILD, SECOND_CHILD]) - - appendFileSync(childPath, line('task_complete')) - const oneRemaining = pollCodexTranscriptStatus( - hostState, - secondStarted, - secondChildBody, - 'production' - ) - if (!oneRemaining) { - throw new Error('No first child completion status') - } - const partialUpdate = transcriptPollUpdate('codex', secondStarted, oneRemaining) - if (!partialUpdate) { - throw new Error('Host did not publish first child completion') - } - expect(partialUpdate).toMatchObject({ - toolAgentId: SECOND_CHILD, - payload: { - state: 'working', - mainAgent: { state: 'done' }, - subagents: [expect.objectContaining({ id: SECOND_CHILD, state: 'working' })] - } - }) - expect(partialUpdate.hookEventName).toBeUndefined() - server.ingestRemote(buildRelayHookEnvelope(partialUpdate, 'codex'), 'wsl:Ubuntu') - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - mainAgent: { state: 'done' }, - subagents: [expect.objectContaining({ id: SECOND_CHILD, state: 'working' })] - }) - - appendFileSync(secondChildPath, line('task_complete')) - const polled = pollCodexTranscriptStatus( - hostState, - partialUpdate, - secondChildBody, - 'production' - ) - if (!polled) { - throw new Error('No completion status') - } - const update = transcriptPollUpdate('codex', partialUpdate, polled) - if (!update) { - throw new Error('Host did not publish transcript completion') - } - expect(update).toMatchObject({ toolAgentId: SECOND_CHILD, payload: { state: 'done' } }) - expect(update.hookEventName).toBeUndefined() - expect(update.payload.subagents).toBeUndefined() - - server.ingestRemote(buildRelayHookEnvelope(update, 'codex'), 'wsl:Ubuntu') - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'done', - mainAgent: expect.objectContaining({ state: 'done' }) - }) - ]) - expect(server.getStatusSnapshot()[0].subagents).toBeUndefined() - }) -}) - -describe('remote Codex truncated transcript snapshots', () => { - it('keeps live work when a changed roster was shed to fit the relay frame', () => { - const server = new AgentHookServer() - const children = [ - { id: CHILD, state: 'working' as const, startedAt: 1 }, - { id: SECOND_CHILD, state: 'working' as const, startedAt: 2 } - ] - server.ingestRemote( - { - paneKey: PANE, - source: 'codex', - hookEventName: 'SubagentStart', - toolAgentId: SECOND_CHILD, - payload: { - agentType: 'codex', - prompt: 'Run the checks', - state: 'working', - mainAgent: { state: 'done', stateStartedAt: 1 }, - subagents: children - } - }, - 'wsl:Ubuntu' - ) - server.ingestRemote( - { - paneKey: PANE, - source: 'codex', - toolAgentId: SECOND_CHILD, - shedFields: [createShedSubagentsField([children[1]!])], - payload: { - agentType: 'codex', - prompt: 'Run the checks', - state: 'working', - mainAgent: { state: 'done', stateStartedAt: 1 } - } - }, - 'wsl:Ubuntu' - ) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) - expect(server.getStatusSnapshotForPane(PANE)[0]?.subagents).toHaveLength(2) - }) -}) - describe('remote Codex unlabelled roster removals', () => { const childStart = (id: string, startedAt: number) => ({ paneKey: PANE, diff --git a/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote.test.ts b/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote.test.ts deleted file mode 100644 index 1c727f7beed..00000000000 --- a/src/main/agent-hooks/server/testing/remote-ingress/server-ingest-remote.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { PANE } from '../server.test-fixtures' -import { AGENT_STATUS_RUNS_RUNTIME_CAPABILITY } from '../../../../../shared/agent-status/contracts/agent-status-run-capability' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('AgentHookServer ingestRemote', () => { - it('never falls back to the legacy writer for a run-capable peer', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - advertisedAgentStatusCapabilities: [], - payload: { state: 'working', prompt: 'unsupported peer', agentType: 'claude' } - }, - 'conn-1' - ) - const olderPeerRow = server.getStatusSnapshot()[0] - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - advertisedAgentStatusCapabilities: [AGENT_STATUS_RUNS_RUNTIME_CAPABILITY], - payload: { state: 'done', prompt: 'capable peer', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([olderPeerRow]) - }) -}) diff --git a/src/main/browser/extensions/declarative-net-request/redirect-actions/redirect-url.test.ts b/src/main/browser/extensions/declarative-net-request/redirect-actions/redirect-url.test.ts index bb32dd224b4..46dd90e3f72 100644 --- a/src/main/browser/extensions/declarative-net-request/redirect-actions/redirect-url.test.ts +++ b/src/main/browser/extensions/declarative-net-request/redirect-actions/redirect-url.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { sameDomainOrHost } from '../matching/domain-matching' import { parseDnrRedirect, transformDnrUrl } from './redirect-url' import { isDnrRuleProblem } from '../rule-compilation/parse-errors' import type { DnrUrlTransform } from '../rule-compilation/rule-types' @@ -13,31 +12,6 @@ function transform(url: string, spec: DnrUrlTransform): string | null { } describe('transformDnrUrl', () => { - it.each<[string, DnrUrlTransform, string | null]>([ - [ - 'http://a.com/path?x=1#f', - { scheme: 'https', host: 'b.com', port: '8443' }, - 'https://b.com:8443/path?x=1#f' - ], - ['http://a.com:81/path', { port: '' }, 'http://a.com/path'], - ['http://a.com/path?x=1', { path: '' }, 'http://a.com/?x=1'], - ['http://a.com/path', { path: 'other' }, 'http://a.com/other'], - ['http://a.com/p?x=1#f', { query: '', fragment: '' }, 'http://a.com/p'], - ['http://a.com/p?x=1', { query: '?' }, 'http://a.com/p?'], - ['http://a.com/p#f', { fragment: '#g' }, 'http://a.com/p#g'], - ['http://a.com/p', { username: 'u', password: 'p' }, 'http://u:p@a.com/p'], - ['https://a.com/p?keep=1#old', { path: '/a?b#c' }, 'https://a.com/a%3Fb%23c?keep=1#old'], - ['https://a.com/p#old', { query: '?x=#new' }, 'https://a.com/p?x=%23new#old'], - ['https://a.com/p', { username: 'u@attacker.test/' }, 'https://u%40attacker.test%2F@a.com/p'], - ['https://a.com/p', { password: 'p:@/?#' }, 'https://:p%3A%40%2F%3F%23@a.com/p'], - ['https://a.com/p', { host: 'good.test/path' }, null], - ['https://a.com/p', { host: 'good.test:8443' }, null], - ['https://a.com/p', { host: '[::1]', port: '+8443' }, 'https://[::1]:8443/p'], - ['http://a.com/p', { scheme: 'chrome-extension', host: 'abc' }, 'chrome-extension://abc/p'] - ])('%s with %j → %s', (url, spec, expected) => { - expect(transform(url, spec)).toBe(expected) - }) - // GetModifiedQuery: removals and replacements keep the original order; additions go last. it.each<[string, DnrUrlTransform['queryTransform'], string]>([ [ @@ -69,16 +43,3 @@ describe('transformDnrUrl', () => { expect(transform(url, { queryTransform })).toBe(expected) }) }) - -describe('domain lists', () => { - it.each([ - ['www.example.co.uk', 'cdn.example.co.uk', true], - ['example.co.uk', 'other.co.uk', false], - ['a.github.io', 'b.github.io', false], - ['127.0.0.1', '127.0.0.1', true], - ['10.0.0.1', '10.0.0.2', false], - ['a.unknowntld', 'b.unknowntld', false] - ])('sameDomainOrHost(%s, %s) → %s', (left, right, expected) => { - expect(sameDomainOrHost(left, right)).toBe(expected) - }) -}) From 3ce8d75ae98c6d18892d8ef316fb2a7eb2992c77 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 07:35:12 -0800 Subject: [PATCH 006/138] test: remove low-value agent-hook and row-projection coverage (#40395) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 5 - .../local-tmux-managed-pty.test.ts | 27 +- .../terminal-handle-row-identity.test.ts | 86 +-- ...-claude-child-permission-lifecycle.test.ts | 64 --- ...ver-claude-permission-tool-failure.test.ts | 120 ----- ...erver-claude-permission-visibility.test.ts | 497 ------------------ .../server-claude-blocked-prompt.test.ts | 113 ---- .../server-claude-normalization.test.ts | 175 ------ .../server-codex-review-turn-end.test.ts | 77 +-- .../server-codex-turn-interruption.test.ts | 140 ----- .../ephemeral-vm/pending-vm-checkouts.test.ts | 22 +- .../mobile-relay-host-catalog.test.ts | 118 +---- src/shared/pending-vm-checkouts.test.ts | 19 +- 13 files changed, 12 insertions(+), 1451 deletions(-) delete mode 100644 src/main/agent-hooks/server/testing/permissions/server-claude-permission-tool-failure.test.ts delete mode 100644 src/main/agent-hooks/server/testing/permissions/server-claude-permission-visibility.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/claude/server-claude-blocked-prompt.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/claude/server-claude-normalization.test.ts delete mode 100644 src/main/agent-hooks/server/testing/providers/codex/server-codex-turn-interruption.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index d14967d5a69..5709f135eb5 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -554,14 +554,10 @@ "src/main/agent-hooks/server/testing/tool-events/server-agent-tool-plugin-hooks.test.ts": 717, "src/main/agent-hooks/server/testing/identity/server-ai-vault-liveness.test.ts": 643, "src/main/agent-hooks/server/testing/providers/antigravity/server-antigravity-resume-identity.test.ts": 820, - "src/main/agent-hooks/server/testing/providers/claude/server-claude-blocked-prompt.test.ts": 681, "src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts": 681, "src/main/agent-hooks/server/testing/interruptions/server-claude-helper-kill.test.ts": 10279, "src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts": 34523, - "src/main/agent-hooks/server/testing/providers/claude/server-claude-normalization.test.ts": 683, "src/main/agent-hooks/server/testing/task-wakeup/server-claude-owed-notification-expiry.test.ts": 877, - "src/main/agent-hooks/server/testing/permissions/server-claude-permission-tool-failure.test.ts": 674, - "src/main/agent-hooks/server/testing/permissions/server-claude-permission-visibility.test.ts": 765, "src/main/agent-hooks/server/testing/identity/server-claude-session-continuation.test.ts": 675, "src/main/agent-hooks/server/testing/http/server-claude-statusline.test.ts": 679, "src/main/agent-hooks/server/testing/task-wakeup/server-claude-task-wakeup-lifecycle.test.ts": 5451, @@ -572,7 +568,6 @@ "src/main/agent-hooks/server/testing/providers/codex/server-codex-review-turn-end.test.ts": 860, "src/main/agent-hooks/server/testing/providers/codex/server-codex-side-thread.test.ts": 847, "src/main/agent-hooks/server/testing/providers/codex/server-codex-subagent-transcript.test.ts": 706, - "src/main/agent-hooks/server/testing/providers/codex/server-codex-turn-interruption.test.ts": 716, "src/main/agent-hooks/server/testing/transcripts/server-transcript-result-retry.test.ts": 1125, "src/main/agent-hooks/server/testing/identity/server-copilot-resume-identity.test.ts": 729, "src/main/agent-hooks/server/testing/http/server-endpoint-file-lifecycle.test.ts": 801, diff --git a/src/main/agent-hooks/observations/local-tmux-managed-pty.test.ts b/src/main/agent-hooks/observations/local-tmux-managed-pty.test.ts index fc229bc9ed0..78d96049e7f 100644 --- a/src/main/agent-hooks/observations/local-tmux-managed-pty.test.ts +++ b/src/main/agent-hooks/observations/local-tmux-managed-pty.test.ts @@ -1,6 +1,5 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { - createLocalHookPaneRootResolver, createLocalPtyInventoryReader, createLocalTmuxManagedPtyResolver } from './local-tmux-managed-pty' @@ -14,10 +13,6 @@ const row: PtyProcessInfo = { title: 'shell', worktreeId: 'folder:workspace' } -afterEach(() => { - vi.useRealTimers() -}) - describe.skipIf(process.platform === 'win32')('authenticated local tmux root resolution', () => { it('refuses a binding changed during capture and excludes WSL roots from a native probe', async () => { let binding = 'pty' @@ -39,24 +34,4 @@ describe.skipIf(process.platform === 'win32')('authenticated local tmux root res ) expect(await wsl('pane')).toBeNull() }) - - it('re-reads a snapshot that predates a pane’s PTY once, not on every miss', async () => { - vi.useFakeTimers() - vi.setSystemTime(1000) - const listProcesses = vi.fn().mockResolvedValueOnce([]).mockResolvedValue([row]) - const resolve = createLocalHookPaneRootResolver( - createLocalPtyInventoryReader({ - getPtyId: (paneKey) => (paneKey === 'pane' ? 'pty' : 'gone'), - listProcesses - }) - ) - expect(await resolve('pane', undefined)).toBeNull() - vi.setSystemTime(1500) - // The PTY spawned after the cached snapshot: a cached miss is not an answer. - expect(await resolve('pane', undefined)).toEqual({ pid: 123, incarnation: 'generation' }) - expect(listProcesses).toHaveBeenCalledTimes(2) - vi.setSystemTime(1600) - expect(await resolve('gone', undefined)).toBeNull() - expect(listProcesses).toHaveBeenCalledTimes(2) - }) }) diff --git a/src/main/agent-hooks/server/testing/identity/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/server/testing/identity/terminal-handle-row-identity.test.ts index 80bcf54ef99..4ca8790c287 100644 --- a/src/main/agent-hooks/server/testing/identity/terminal-handle-row-identity.test.ts +++ b/src/main/agent-hooks/server/testing/identity/terminal-handle-row-identity.test.ts @@ -1,12 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { AgentHookServer } from '../../agent-hook-server' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' -import { selectFreshExplicitAgentStatus } from '../../../../runtime/workspaces/process-listing/runtime-hook-agent-row-selection' import { wslHookRelayConnectionId } from '../../../../../shared/wsl-hook-relay-contract' const PANE_KEY = 'tab-handle:33333333-3333-4333-8333-333333333333' const HANDLE = 'term_identity' -const NEW_PANE_KEY = 'tab-reminted:44444444-4444-4444-8444-444444444444' function ingest(server: AgentHookServer, overrides: Record = {}): void { server.ingestTerminalStatus({ @@ -42,55 +39,6 @@ describe('the terminal handle a status row is stamped with', () => { expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') }) - it('moves one PTY row and all of its resume identity across a pane remint', () => { - const server = new AgentHookServer() - ingest(server) - server.ingestRemote( - { - paneKey: PANE_KEY, - tabId: 'tab-handle', - worktreeId: 'worktree', - providerSession: { key: 'session_id', id: 'session-1' }, - payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } - }, - null - ) - const mutations: Parameters[0]>[0][] = [] - server.subscribeStatusRowMutations((mutation) => mutations.push(mutation)) - - ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted' }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: NEW_PANE_KEY, - terminalHandle: HANDLE, - providerSession: { key: 'session_id', id: 'session-1' } - }) - ]) - expect(mutations).toEqual([ - { - before: { paneKey: PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE }, - after: { paneKey: NEW_PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE } - } - ]) - - server.dropStatusEntry(NEW_PANE_KEY) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: NEW_PANE_KEY, - providerSessionOnly: true, - providerSession: { key: 'session_id', id: 'session-1' } - }) - ]) - expect(server.reconcileEndedProcessForPaneKeys([NEW_PANE_KEY])).toBe(1) - expect(server.getStatusSnapshot()).toEqual([]) - expect(mutations).toHaveLength(3) - expect( - (server as unknown as { paneKeyByTerminalHandle: Map }) - .paneKeyByTerminalHandle - ).toEqual(new Map()) - }) - it('preserves a local WSL terminal join only for its exact relay distro', () => { const server = new AgentHookServer() const worktreeId = String.raw`repo::\\wsl.localhost\Ubuntu\home\user\repo` @@ -119,36 +67,4 @@ describe('the terminal handle a status row is stamped with', () => { ) expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') }) - - it('renews duplicate OSC evidence without publishing another semantic row', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const enriched = vi.fn() - const mutated = vi.fn() - const statusChanges = vi.fn() - server.subscribeEnrichedStatus(enriched) - server.subscribeStatusRowMutations(mutated) - server.subscribeStatusChanges(statusChanges) - ingest(server) - enriched.mockClear() - mutated.mockClear() - statusChanges.mockClear() - - vi.setSystemTime(1_000 + AGENT_STATUS_STALE_AFTER_MS + 1) - ingest(server) - - const [row] = server.getStatusSnapshot() - expect(row.evidenceObservedAt).toBe(Date.now()) - expect( - selectFreshExplicitAgentStatus({ handle: HANDLE, paneKey: PANE_KEY, hookRows: [row] }) - ).toMatchObject({ status: 'working', updatedAt: Date.now() }) - expect(enriched).not.toHaveBeenCalled() - expect(mutated).not.toHaveBeenCalled() - expect(statusChanges).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) }) diff --git a/src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts b/src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts index 57e55ea2ebb..e24d6cae7df 100644 --- a/src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts +++ b/src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts @@ -43,36 +43,6 @@ describe('Claude child permission lifecycle', () => { } } - it('clears a child permission when that child stops', async () => { - const { server, postClaudeHook } = await createServer() - try { - await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'a-blocked', - agent_type: 'general-purpose', - tool_name: 'Bash', - tool_input: { command: 'false' } - }) - await postClaudeHook({ hook_event_name: 'SubagentStop', agent_id: 'a-other' }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'waiting', - toolName: 'Bash', - subagents: [expect.objectContaining({ id: 'a-blocked', state: 'working' })] - }) - await postClaudeHook({ hook_event_name: 'SubagentStop', agent_id: 'a-blocked' }) - - const status = server.getStatusSnapshot()[0] - expect(status).toMatchObject({ paneKey: PANE, state: 'working', agentType: 'claude' }) - expect(status?.toolName).toBeUndefined() - expect(status?.toolInput).toBeUndefined() - expect(status?.interactivePrompt).toBeUndefined() - expect(status?.subagents).toBeUndefined() - } finally { - server.stop() - } - }) - it('clears a child permission when an API error ends that child', async () => { const { server, postClaudeHook } = await createServer() try { @@ -151,38 +121,4 @@ describe('Claude child permission lifecycle', () => { server.stop() } }) - - it('clears a teammate permission when that teammate idles', async () => { - const { server, postClaudeHook } = await createServer() - try { - await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'areviewer-6d3cb5b5', - agent_type: 'reviewer', - tool_name: 'Bash', - tool_input: { command: 'false' } - }) - await postClaudeHook({ hook_event_name: 'TeammateIdle', teammate_name: 'writer' }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'waiting', - toolName: 'Bash', - subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'working' })] - }) - await postClaudeHook({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' }) - - const status = server.getStatusSnapshot()[0] - expect(status).toMatchObject({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })] - }) - expect(status?.toolName).toBeUndefined() - expect(status?.toolInput).toBeUndefined() - expect(status?.interactivePrompt).toBeUndefined() - } finally { - server.stop() - } - }) }) diff --git a/src/main/agent-hooks/server/testing/permissions/server-claude-permission-tool-failure.test.ts b/src/main/agent-hooks/server/testing/permissions/server-claude-permission-tool-failure.test.ts deleted file mode 100644 index 08fb7a0fed7..00000000000 --- a/src/main/agent-hooks/server/testing/permissions/server-claude-permission-tool-failure.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, PANE } from '../server.test-fixtures' - -vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) - -beforeEach(() => { - _internals.resetCachesForTests() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -async function withClaudeHookServer( - run: ( - post: (payload: Record) => Promise, - server: AgentHookServer - ) => Promise -): Promise { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - await run(async (payload) => { - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - expect(response.status).toBe(204) - }, server) - } finally { - server.stop() - } -} - -const COMMAND = { command: 'rm -rf /tmp/orca-28186-permission-target' } - -describe('Claude permission wait after the approved tool fails', () => { - it('clears the wait when PostToolUseFailure names the waiting request', async () => { - await withClaudeHookServer(async (post, server) => { - await post({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: COMMAND, - tool_use_id: 'toolu-approved' - }) - await post({ hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: COMMAND }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - await post({ - hook_event_name: 'PostToolUseFailure', - tool_name: 'Bash', - tool_input: COMMAND, - tool_use_id: 'toolu-approved', - error: 'exit status 1' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'claude' }) - ]) - }) - }) - - it('keeps the wait when another tool use fails', async () => { - await withClaudeHookServer(async (post, server) => { - await post({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: COMMAND, - tool_use_id: 'toolu-approved' - }) - await post({ hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: COMMAND }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - await post({ - hook_event_name: 'PostToolUseFailure', - tool_name: 'Read', - tool_input: { file_path: '/tmp/other.txt' }, - tool_use_id: 'toolu-sibling', - error: 'not found' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, state: 'waiting', toolName: 'Bash' }) - ]) - }) - }) - - it("keeps a lead's wait when a subagent's tool with the same id fails", async () => { - await withClaudeHookServer(async (post, server) => { - await post({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: COMMAND, - tool_use_id: 'toolu-approved' - }) - await post({ hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: COMMAND }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - await post({ - hook_event_name: 'PostToolUseFailure', - tool_name: 'Bash', - tool_input: COMMAND, - tool_use_id: 'toolu-approved', - agent_id: 'agent-child', - agent_type: 'general-purpose', - error: 'exit status 1' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, state: 'waiting', toolName: 'Bash' }) - ]) - }) - }) -}) diff --git a/src/main/agent-hooks/server/testing/permissions/server-claude-permission-visibility.test.ts b/src/main/agent-hooks/server/testing/permissions/server-claude-permission-visibility.test.ts deleted file mode 100644 index 294a901f15d..00000000000 --- a/src/main/agent-hooks/server/testing/permissions/server-claude-permission-visibility.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, PANE } from '../server.test-fixtures' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('AgentHookServer listener replay', () => { - it('keeps Claude permission visible when matching tool activity has no execution id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-subagent-repro' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when approved tool execution has no identity', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }, - tool_use_id: 'toolu-approved-1' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-subagent-repro' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear when approved PostToolUse matches the preceding tool use id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, - tool_use_id: 'toolu-approved-by-claude' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, - tool_use_id: 'toolu-approved-by-claude' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-2824-permission-target' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear by tool use id when tool input is not previewable', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' }, - tool_use_id: 'toolu-approved-opaque' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' }, - tool_use_id: 'toolu-approved-opaque' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'BespokeTool' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when another tool use completes after permission', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-permission-owner' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-other-subagent' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear when same explicit agent type starts the approved tool', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_type: 'main', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_type: 'main', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }, - tool_use_id: 'toolu-approved-1' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-subagent-repro' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude subagent permission clear when the same agent starts the approved tool', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'agent-subagent-a', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_id: 'agent-subagent-a', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-approved-subagent' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets same Claude subagent clear an unknown approved tool without an input preview', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'agent-custom-tool', - agent_type: 'Review', - tool_name: 'BespokeTool', - tool_input: { request_id: 'pending-1' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_id: 'agent-custom-tool', - agent_type: 'Review', - tool_name: 'BespokeTool', - tool_input: { request_id: 'pending-1' }, - tool_use_id: 'toolu-custom-approved' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'BespokeTool', - toolInput: undefined - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when another same-type subagent reports the same tool execution', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'agent-subagent-a', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_id: 'agent-subagent-b', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-other-subagent' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear when a new explicit prompt starts', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'UserPromptSubmit', - prompt: 'start a new task' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - prompt: 'start a new task', - toolName: undefined, - toolInput: undefined - }) - ]) - } finally { - server.stop() - } - }) - - it('does not replay cleared pane state to a newly attached listener', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'clear me' - }) - ) - }) - - server.clearPaneState(PANE) - const listener = vi.fn() - server.setListener(listener) - - expect(listener).not.toHaveBeenCalled() - } finally { - server.stop() - } - }) -}) diff --git a/src/main/agent-hooks/server/testing/providers/claude/server-claude-blocked-prompt.test.ts b/src/main/agent-hooks/server/testing/providers/claude/server-claude-blocked-prompt.test.ts deleted file mode 100644 index 92df34ee30c..00000000000 --- a/src/main/agent-hooks/server/testing/providers/claude/server-claude-blocked-prompt.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentHookServer, _internals } from '../../../agent-hook-server' -import { buildBody, PANE, postHookEvent } from '../../server.test-fixtures' -import { createProgramStatusProjector } from '../../../../../../shared/program-status/agent-status-projector' - -vi.mock('../../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) - -let server: AgentHookServer -let projector: ReturnType - -beforeEach(async () => { - _internals.resetCachesForTests() - server = new AgentHookServer() - await server.start({ env: 'production' }) - projector = createProgramStatusProjector() -}) - -afterEach(() => { - server.stop() - vi.restoreAllMocks() -}) - -// Claude Code 2.1.314 reports `working` then `idle` (no `done`, no Stop hook) when a prompt hook blocks. -function program(state: 'working' | 'idle' | 'done', app = 'claude-code'): void { - const { payloads, facet } = projector.process(`\x1b]7501;state=${state}:app=${app}\x1b\\`) - if (facet !== undefined) { - server.ingestProgramStatusFacet({ paneKey: PANE, facet }) - } - for (const { payload } of payloads) { - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - fromProgramStatus: true, - payload - }) - } -} - -async function hook(payload: Record): Promise { - const response = await postHookEvent(server, buildBody({ session_id: 'session-1', ...payload })) - expect(response.status).toBe(204) -} - -function promptHook(prompt = 'hello'): Promise { - return hook({ hook_event_name: 'UserPromptSubmit', prompt }) -} - -function row() { - return server.getStatusSnapshotForPane(PANE)[0] -} - -function expectSettledLikeStop(): void { - expect(row()).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) - expect(row().interrupted).toBeUndefined() - expect(row().mainAgent?.outcome).toBeUndefined() -} - -describe('Claude prompt blocked by a UserPromptSubmit hook', () => { - beforeEach(() => { - program('idle') - }) - - it('settles when the idle lands after the prompt hook', async () => { - program('working') - await promptHook() - expect(row().state).toBe('working') - program('idle') - expectSettledLikeStop() - }) - - it('settles when the idle lands before the prompt hook', async () => { - program('working') - program('idle') - await promptHook() - expectSettledLikeStop() - }) - - it("ignores another program's idle in the same pane", async () => { - program('working') - await promptHook() - program('idle', 'codex') - expect(row().state).toBe('working') - }) - - it('leaves a run stopped with Ctrl+C to the interrupt verdict', async () => { - program('working') - await promptHook() - server.observeClaudeTerminalEvidence(PANE, { kind: 'input', data: '\x03' }) - program('idle') - const baseline = row() - expect(baseline.state).toBe('working') - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: baseline.prompt, - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - expect(row()).toMatchObject({ state: 'done', interrupted: true }) - }) - - it('keeps a typed prompt working when its hook lands before its working report', async () => { - program('working') - program('idle') - server.observeClaudeTerminalEvidence(PANE, { kind: 'input', data: 'next prompt\r' }) - await promptHook('next prompt') - expect(row()).toMatchObject({ state: 'working', prompt: 'next prompt' }) - }) -}) diff --git a/src/main/agent-hooks/server/testing/providers/claude/server-claude-normalization.test.ts b/src/main/agent-hooks/server/testing/providers/claude/server-claude-normalization.test.ts deleted file mode 100644 index 4d6f9f42f38..00000000000 --- a/src/main/agent-hooks/server/testing/providers/claude/server-claude-normalization.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { _internals } from '../../../agent-hook-server' -import { buildBody } from '../../server.test-fixtures' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('Claude hook normalization', () => { - it('flags a PostToolUse preview as tool output so native chat will not show it as the reply', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'cat lifecycle.ts' }, - tool_response: { content: [{ type: 'text', text: 'Exit code 1\nimport { Foo }' }] } - }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('Exit code 1\nimport { Foo }') - expect(result?.payload.lastAssistantMessageIsToolOutput).toBe(true) - }) - - it('flags a PostToolUseFailure preview as tool output', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUseFailure', - tool_name: 'Write', - error: 'file is read-only' - }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('file is read-only') - expect(result?.payload.lastAssistantMessageIsToolOutput).toBe(true) - }) - - it('leaves real assistant prose at Stop unflagged', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'ls' }, - tool_response: { content: [{ type: 'text', text: 'a.ts b.ts' }] } - }), - 'production' - ) - const stopped = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'Stop', - last_assistant_message: 'Here are the files.' - }), - 'production' - ) - expect(stopped?.payload.lastAssistantMessage).toBe('Here are the files.') - expect(stopped?.payload.lastAssistantMessageIsToolOutput).toBeUndefined() - }) - - it('StopFailure maps to done without copying provider error text', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'say hi' - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'StopFailure', - error: 'invalid_request', - error_details: 'model is not supported', - last_assistant_message: 'API Error: model is not supported' - }), - 'production' - ) - - expect(result?.payload.state).toBe('done') - expect(result?.payload.prompt).toBe('say hi') - expect(result?.payload.lastAssistantMessage).toBeUndefined() - }) - - describe('Stop transcript scan', () => { - let tmpDir: string - let transcriptPath: string - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'orca-hook-test-')) - transcriptPath = join(tmpDir, 'transcript.jsonl') - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - }) - - it('skips tool_use-only assistant entries to find the previous text reply', () => { - const lines = [ - { role: 'assistant', message: { role: 'assistant', content: 'the answer is 42' } }, - { - role: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] - } - } - ] - writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('the answer is 42') - }) - - it('finds an assistant reply that sits past the first chunk boundary', () => { - // Large tool results force the backward scan beyond its first 64-KB chunk. - const filler = 'x'.repeat(70_000) - const lines = [ - { role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } }, - { - role: 'user', - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 't1', content: filler }] - } - }, - { - role: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] - } - } - ] - writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('deeply buried reply') - }) - }) -}) diff --git a/src/main/agent-hooks/server/testing/providers/codex/server-codex-review-turn-end.test.ts b/src/main/agent-hooks/server/testing/providers/codex/server-codex-review-turn-end.test.ts index c437a330f66..77bcbe23fee 100644 --- a/src/main/agent-hooks/server/testing/providers/codex/server-codex-review-turn-end.test.ts +++ b/src/main/agent-hooks/server/testing/providers/codex/server-codex-review-turn-end.test.ts @@ -1,15 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - appendFileSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' +import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { RelayAgentHookServer } from '../../../../../../wsl-guest/agent-hook-server' import { AgentHookServer } from '../../../agent-hook-server' import { buildBody, postHookEvent } from '../../server.test-fixtures' @@ -31,53 +23,31 @@ describe('Codex turn completion without Stop', () => { }) let desktop: AgentHookServer - let relay: RelayAgentHookServer | undefined let directory: string let published: (string | undefined)[] - async function startHost(relayed: boolean): Promise { + async function startHost(): Promise { directory = mkdtempSync(join(tmpdir(), 'codex-turn-end-')) desktop = new AgentHookServer() published = [] desktop.setListener((status) => published.push(status.hookEventName)) - if (!relayed) { - await desktop.start({ env: 'production' }) - return - } - relay = new RelayAgentHookServer({ - endpointDir: directory, - forward: (envelope) => desktop.ingestRemote(envelope, 'ssh-connection') - }) - await relay.start() + await desktop.start({ env: 'production' }) } async function hook(payload: Record): Promise { const body = buildBody({ session_id: 'root-session', ...payload }) - const coordinates = relay?.getCoordinates() - const response = coordinates - ? await fetch(`http://127.0.0.1:${coordinates.port}/hook/codex`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Connection: 'close', - 'X-Orca-Agent-Hook-Token': coordinates.token - }, - body: JSON.stringify(body) - }) - : await postHookEvent(desktop, body, '/hook/codex') + const response = await postHookEvent(desktop, body, '/hook/codex') expect(response.status).toBe(204) } afterEach(() => { desktop.stop() - relay?.stop() - relay = undefined vi.useRealTimers() rmSync(directory, { recursive: true, force: true }) }) it('settles a /review from its child rollout and leaves later turns working', async () => { - await startHost(false) + await startHost() const childPath = join(directory, 'review-child.jsonl') writeFileSync(childPath, `${reviewRecords[0]}\n`) // Review hooks use the root session with child turn/path and emit no agent_id or Stop. @@ -122,41 +92,4 @@ describe('Codex turn completion without Stop', () => { await vi.advanceTimersByTimeAsync(5_000) expect(published.slice(-2)).toEqual(['Stop', 'SessionStart']) }) - - it.each([ - ['locally', 'in an earlier poll', false, true], - ['locally', 'in the same poll', false, false], - ['through a relay', 'in an earlier poll', true, true] - ])( - 'settles a failed turn %s after a child finished %s', - async (_, __, relayed, separatePolls) => { - await startHost(relayed) - const day = join(directory, '2026', '10', '16') - mkdirSync(day, { recursive: true }) - const rootPath = join(day, 'rollout-2026-10-16T00-00-00-root.jsonl') - const childId = '0199aaaa-bbbb-cccc-dddd-eeeeffff0001' - const childPath = join(day, `rollout-2026-10-16T00-00-01-${childId}.jsonl`) - const root = { transcript_path: rootPath, turn_id: 'root-turn' } - writeFileSync(rootPath, event({ type: 'task_started', turn_id: 'root-turn' })) - await hook({ ...root, hook_event_name: 'UserPromptSubmit', prompt: 'Do the task' }) - const started = { agent_thread_id: childId, kind: 'started', occurred_at_ms: Date.now() } - appendFileSync(rootPath, event({ type: 'sub_agent_activity', ...started })) - writeFileSync(childPath, event({ type: 'task_started', turn_id: 'child-turn' })) - await hook({ ...root, hook_event_name: 'PostToolUse', tool_name: 'spawn_agent' }) - - appendFileSync(childPath, event({ type: 'task_complete', turn_id: 'child-turn' })) - if (separatePolls) { - await vi.advanceTimersByTimeAsync(5_000) - } - const failure = { error: { message: 'usage limit' } } - appendFileSync(rootPath, event({ type: 'task_complete', turn_id: 'root-turn', ...failure })) - await vi.advanceTimersByTimeAsync(5_000) - expect(desktop.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - mainAgent: { state: 'done', outcome: 'failure' } - }) - // Legacy desktops interpret labelled Stops as success; leave failures unlabelled. - expect(published.at(-1)).toBeUndefined() - } - ) }) diff --git a/src/main/agent-hooks/server/testing/providers/codex/server-codex-turn-interruption.test.ts b/src/main/agent-hooks/server/testing/providers/codex/server-codex-turn-interruption.test.ts deleted file mode 100644 index 5cdced2e665..00000000000 --- a/src/main/agent-hooks/server/testing/providers/codex/server-codex-turn-interruption.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { AgentHookServer } from '../../../agent-hook-server' -import { PANE } from '../../server.test-fixtures' - -const line = (payload: unknown): string => `${JSON.stringify({ type: 'event_msg', payload })}\n` - -describe('Codex recorded turn interruption', () => { - beforeEach(() => { - vi.useFakeTimers({ - toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'performance'] - }) - }) - - const dirs: string[] = [] - afterEach(() => { - for (const dir of dirs) { - rmSync(dir, { recursive: true, force: true }) - } - dirs.length = 0 - vi.useRealTimers() - }) - - it.each([false, true])('settles the lead and preserves child work: %s', async (withChild) => { - const dir = mkdtempSync(join(tmpdir(), 'codex-turn-interruption-')) - dirs.push(dir) - const transcriptPath = join(dir, 'rollout-root.jsonl') - const childPath = join(dir, 'rollout-child-child-1.jsonl') - writeFileSync(transcriptPath, line({ type: 'task_started', turn_id: 'turn-1' })) - if (withChild) { - appendFileSync( - transcriptPath, - line({ - type: 'sub_agent_activity', - agent_thread_id: 'child-1', - kind: 'started', - agent_path: '/root/child', - occurred_at_ms: Date.now() - }) - ) - writeFileSync(childPath, line({ type: 'task_started' })) - } - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Connection: 'close', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'folder-1', - payload: { - hook_event_name: 'UserPromptSubmit', - session_id: 'main-session', - prompt: 'main task', - transcript_path: transcriptPath - } - }) - }) - expect(response.status).toBe(204) - const beforeSide = server.getStatusSnapshot()[0] - for (const hookEventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { - const sideResponse = await fetch( - `http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Connection: 'close', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'folder-1', - payload: { - hook_event_name: hookEventName, - session_id: 'side-session', - transcript_path: null, - prompt: 'side chat' - } - }) - } - ) - expect(sideResponse.status).toBe(204) - expect(server.getStatusSnapshot()[0]).toEqual(beforeSide) - } - const baseline = server.getStatusSnapshot()[0] - for (const intent of ['ctrl-c', 'plain-escape'] as const) { - for (const inputCount of [1, 2]) { - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: baseline.prompt, - baselineAgentType: 'codex', - intent, - inputCount - }) - ).toBe(false) - expect(server.getStatusSnapshot()[0]).toEqual(baseline) - } - } - await vi.advanceTimersByTimeAsync(5_000) - expect(server.getStatusSnapshot()[0].state).toBe('working') - appendFileSync( - transcriptPath, - line({ type: 'turn_aborted', turn_id: 'turn-1', reason: 'interrupted' }) - ) - await vi.advanceTimersByTimeAsync(5_000) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: withChild ? 'working' : 'done', - mainAgent: { state: 'done', outcome: 'cancellation' } - }) - if (withChild) { - expect(server.getStatusSnapshot()[0].interrupted).toBeUndefined() - appendFileSync(childPath, line({ type: 'task_complete' })) - await vi.advanceTimersByTimeAsync(5_000) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - interrupted: true, - mainAgent: { state: 'done', outcome: 'cancellation' } - }) - } else { - expect(server.getStatusSnapshot()[0].interrupted).toBe(true) - } - } finally { - server.stop() - } - }) -}) diff --git a/src/main/ephemeral-vm/pending-vm-checkouts.test.ts b/src/main/ephemeral-vm/pending-vm-checkouts.test.ts index b0d84fc6997..e89c84df283 100644 --- a/src/main/ephemeral-vm/pending-vm-checkouts.test.ts +++ b/src/main/ephemeral-vm/pending-vm-checkouts.test.ts @@ -1,30 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' -import { isPendingVmCheckout } from '../../shared/pending-vm-checkouts' +import { describe, expect, it } from 'vitest' import { PendingVmCheckoutRegistry, publishPendingVmCheckoutRepoChanges } from './pending-vm-checkouts' describe('PendingVmCheckoutRegistry', () => { - it("serves every window's pending checkouts and drops a window that publishes none", () => { - const registry = new PendingVmCheckoutRegistry() - const changed = vi.fn() - registry.onChanged(changed) - - registry.publish(1, [{ repoId: 'vm-a', path: '/a' }]) - registry.publish(2, [{ repoId: 'vm-b', path: '/b' }]) - expect(isPendingVmCheckout(registry.current(), { repoId: 'vm-a', path: '/a' })).toBe(true) - expect(isPendingVmCheckout(registry.current(), { repoId: 'vm-b', path: '/b' })).toBe(true) - - registry.publish(1, []) - expect(registry.list()).toEqual([{ repoId: 'vm-b', path: '/b' }]) - expect(isPendingVmCheckout(registry.current(), { repoId: 'vm-a', path: '/a' })).toBe(false) - - // A window with nothing published stays quiet. - registry.publish(3, []) - expect(changed).toHaveBeenCalledTimes(3) - }) - it('tells phones to re-read each repo whose pending checkout appeared or went away', () => { const registry = new PendingVmCheckoutRegistry() registry.publish(1, [{ repoId: 'already', path: '/x' }]) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts index 4fb4df6511e..0c0a0ebeee0 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts @@ -8,13 +8,10 @@ import { import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-session-contracts' import type { SshConnectionState } from '../../../shared/ssh-types' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import type { WorkspaceSessionState } from '../../../shared/workspace-session/workspace-session-state-types' import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' import { MobileRelayHostCatalog } from './mobile-relay-host-catalog' import { PairedServerRowsProducer } from '../sidebar-inputs/paired-server-rows-producer' -import { indexPendingVmCheckouts } from '../../../shared/pending-vm-checkouts' function answer(capable: boolean): RuntimeStatus { return { @@ -130,17 +127,7 @@ function fakeHosts( return { hosts, call, state } } -function catalog( - hosts: MobileDesktopRelayHosts, - labels = new Map(), - desktop: { - sessions?: Record> - recipeEnvironmentsFor?: ( - hostId: string - ) => ReadonlyMap - pendingVmCheckouts?: { repoId: string; path: string }[] - } = {} -) { +function catalog(hosts: MobileDesktopRelayHosts) { const serverRows = new PairedServerRowsProducer({ hasDemand: () => false, hostLabelOverrides: () => new Map() @@ -150,93 +137,13 @@ function catalog( catalog: new MobileRelayHostCatalog({ hosts, serverRows, - hostLabelOverrides: () => labels, - getWorkspaceSession: (hostId) => { - const session = desktop.sessions?.[hostId] - return session ? { ...getDefaultWorkspaceSession(), ...session } : null - }, - ...(desktop.recipeEnvironmentsFor - ? { recipeEnvironmentsFor: desktop.recipeEnvironmentsFor } - : {}), - ...(desktop.pendingVmCheckouts - ? { pendingVmCheckouts: () => indexPendingVmCheckouts(desktop.pendingVmCheckouts ?? []) } - : {}) + hostLabelOverrides: () => new Map(), + getWorkspaceSession: () => null }) } } describe('mobile relay host catalog', () => { - it('names the recipe environment on rows whose VM this desktop created, and only those', async () => { - const { hosts, call } = fakeHosts([ - { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } - ]) - call.mockResolvedValueOnce( - psReply([ - { worktreeId: 'recipe-row', hostId: 'local' }, - { worktreeId: 'plain-row', hostId: 'local' } - ]) - ) - const lookups: string[] = [] - const { catalog: hostCatalog } = catalog(hosts, new Map(), { - recipeEnvironmentsFor: (hostId) => { - lookups.push(hostId) - return new Map([['recipe-row', { name: 'task', destroys: true }]]) - } - }) - await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ - worktrees: [ - { - worktreeId: 'recipe-row', - hostId: 'runtime:env', - recipeEnvironment: { name: 'task', destroys: true } - }, - { worktreeId: 'plain-row', hostId: 'runtime:env' } - ] - }) - // One store read per poll, not one per row. - expect(lookups).toEqual(['runtime:env']) - }) - - it("leaves out a VM checkout a desktop create has not adopted yet, but not the VM's other rows", async () => { - const { hosts, call } = fakeHosts([ - { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } - ]) - call.mockResolvedValueOnce( - psReply([ - { worktreeId: 'vm::/srv/repo', repoId: 'vm', path: '/srv/repo', isMainWorktree: true }, - { worktreeId: 'vm::/srv/wt', repoId: 'vm', path: '/srv/wt', isMainWorktree: false }, - { worktreeId: 'other::/srv/repo', repoId: 'other', path: '/srv/repo', isMainWorktree: true } - ]) - ) - const { catalog: hostCatalog } = catalog(hosts, new Map(), { - pendingVmCheckouts: [{ repoId: 'vm', path: '/srv/repo' }] - }) - const result = await hostCatalog.worktrees('runtime:env') - expect(result.worktrees?.map((row) => row.worktreeId)).toEqual([ - 'vm::/srv/wt', - 'other::/srv/repo' - ]) - }) - - it("drops a server's own recipe-environment claim and survives an unreadable VM store", async () => { - const { hosts, call } = fakeHosts([ - { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } - ]) - call.mockResolvedValue( - psReply([ - { worktreeId: 'claimed', hostId: 'local', recipeEnvironment: { name: 'x', destroys: true } } - ]) - ) - const { catalog: hostCatalog } = catalog(hosts, new Map(), { - recipeEnvironmentsFor: () => { - throw new Error('store unreadable') - } - }) - await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ - worktrees: [{ worktreeId: 'claimed', hostId: 'runtime:env' }] - }) - }) - it('never serves rows from before a re-pair mid-fetch, and the next poll fetches the new server afresh', async () => { const { hosts, call, state } = fakeHosts([ { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } @@ -282,25 +189,6 @@ describe('mobile relay host catalog', () => { await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null }) }) - it('tells phones when a status change alters the host list, and not when it leaves it as is', async () => { - const { hosts, state } = fakeHosts([ - { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } - ]) - const { catalog: hostCatalog } = catalog(hosts) - const changed = vi.fn() - hostCatalog.publishListChanges({ onClientEvent: () => () => {}, changed }) - expect(hostCatalog.list().hosts).toMatchObject([{ hostId: 'runtime:env', relay: 'ready' }]) - - state.statusChanged() - await Promise.resolve() - expect(changed).not.toHaveBeenCalled() - - state.environments = [{ ...state.environments[0]!, snapshot: snapshot('env', 'unreachable') }] - state.statusChanged() - await Promise.resolve() - expect(changed).toHaveBeenCalledTimes(1) - }) - it('answers with the last rows when the server never answers the fetch', async () => { vi.useFakeTimers() try { diff --git a/src/shared/pending-vm-checkouts.test.ts b/src/shared/pending-vm-checkouts.test.ts index bb5ae56e1ec..fac8b094e33 100644 --- a/src/shared/pending-vm-checkouts.test.ts +++ b/src/shared/pending-vm-checkouts.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - indexPendingVmCheckouts, - isPendingVmCheckout, - parsePendingVmCheckouts, - PENDING_VM_CHECKOUTS_LIMIT -} from './pending-vm-checkouts' +import { indexPendingVmCheckouts, isPendingVmCheckout } from './pending-vm-checkouts' import { getEmptyProjectPlaceholderRepoIds } from './sidebar-list/empty-project-placeholder-repos' import type { Repo } from './repo-types' import type { Worktree } from './worktree/types' @@ -21,18 +16,6 @@ describe('pending VM checkouts', () => { expect(isPendingVmCheckout(index, { worktreeId: 'no-repo-fields' })).toBe(false) }) - it('keeps only well-formed IPC entries, bounded', () => { - expect(parsePendingVmCheckouts('nope')).toEqual([]) - expect( - parsePendingVmCheckouts([{ repoId: 'a', path: '/a', extra: 1 }, { repoId: 1 }, null]) - ).toEqual([{ repoId: 'a', path: '/a' }]) - const many = Array.from({ length: PENDING_VM_CHECKOUTS_LIMIT + 5 }, (_, i) => ({ - repoId: `r${i}`, - path: '/p' - })) - expect(parsePendingVmCheckouts(many)).toHaveLength(PENDING_VM_CHECKOUTS_LIMIT) - }) - it('draws no empty-project placeholder for a grouped VM project while its create is pending', () => { const repo: Repo = { id: 'vm', From 7fffb5c79456a03e25700245e68206b806762405 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:52:13 +0000 Subject: [PATCH 007/138] fix(mobile): stop Review's branch compare re-reading back to back during git bursts (#40278) * fix(mobile): stop Review's branch compare re-reading back to back during git bursts fetchHostQuery re-read a compare as soon as it landed marked by a git event, so a burst of gitStatusChanged events with reads slower than the event spacing kept the desktop running compares back to back, and kept going after Review closed. A re-read now goes at once only after a quiet spell (a write's own event), otherwise floorMs after the last read landed, and stops once Review's build is no longer current. A superseded build no longer evicts the entry a newer build is reading. * fix(mobile): show each compare that lands during a git burst while Review reads it again * fix(mobile): keep note edits when a later compare lands on a shown Review build --- mobile/src/query/host-query.ts | 33 +++++-- ...mobile-diff-review-branch-compare-cache.ts | 68 +++++++++----- .../review/use-mobile-diff-review-snapshot.ts | 89 ++++++++++++------- 3 files changed, 131 insertions(+), 59 deletions(-) diff --git a/mobile/src/query/host-query.ts b/mobile/src/query/host-query.ts index f3585b6f53c..e7b0a36706c 100644 --- a/mobile/src/query/host-query.ts +++ b/mobile/src/query/host-query.ts @@ -142,25 +142,46 @@ export async function readHostQueryLive( } // A cached read through the screen's `read`: reuses a fresh answer and joins a read already out. An answer -// an event marked stale as it landed predates that change, so it is read again. +// an event marked stale as it landed predates that change, so it goes to `onStale` (still the newest one) and is +// read again while `isCurrent` holds: at once after a quiet spell (a write's own event lands mid-read), else +// `floorMs` after it landed. export async function fetchHostQuery( queryClient: QueryClient, query: HostQuery, host: { hostId: string; client: RpcClient }, target: HostQueryTarget, input: TInput, - read: () => Promise + reader: { read: () => Promise; isCurrent: () => boolean; onStale: (data: TData) => void } ): Promise { + const { isCurrent } = reader const options = { ...hostQueryOptions(query, host.client, host.hostId, target, input), - queryFn: read + queryFn: reader.read } + const floorMs = query.signal?.floorMs ?? 0 + const before = queryClient.getQueryState(options.queryKey) + let quiet = + before?.fetchStatus !== 'fetching' && (before?.dataUpdatedAt ?? 0) + floorMs <= Date.now() let data = await queryClient.fetchQuery(options) - // Each pass needs a newer event, so this ends with the event burst. - while (queryClient.getQueryState(options.queryKey)?.isInvalidated) { + for (;;) { + const state = queryClient.getQueryState(options.queryKey) + // A caller that moved on never shows this answer, so it is not read again. + if (!state?.isInvalidated || !isCurrent()) { + return data + } + reader.onStale(data) + if (state.fetchStatus === 'idle') { + const wait = quiet ? 0 : state.dataUpdatedAt + floorMs - Date.now() + quiet = false + if (wait > 0) { + await new Promise((resolve) => setTimeout(resolve, wait)) + if (!isCurrent()) { + return data + } + } + } data = await queryClient.fetchQuery(options) } - return data } // A read ahead of the user: skipped while the key holds current data, joins a read already out. diff --git a/mobile/src/source-control/review/mobile-diff-review-branch-compare-cache.ts b/mobile/src/source-control/review/mobile-diff-review-branch-compare-cache.ts index 1a85fde8f6e..afa29ec9504 100644 --- a/mobile/src/source-control/review/mobile-diff-review-branch-compare-cache.ts +++ b/mobile/src/source-control/review/mobile-diff-review-branch-compare-cache.ts @@ -3,7 +3,10 @@ import { defineHostQuery, fetchHostQuery } from '../../query/host-query' import { hostQueryKey, type HostQueryTarget } from '../../query/host-query-key' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { gitStatusQuery } from '../mobile-git-status-query' -import type { BranchCompareLoadResult } from './mobile-diff-review-loaders' +import type { + BranchCompareLoadResult, + MobileBranchCompareCache +} from './mobile-diff-review-loaders' export type ReviewBranchCompareKey = { baseRef: string; head: string } @@ -14,29 +17,54 @@ const reviewBranchCompareQuery = defineHostQuery boolean + // A compare a git change marked as it landed: newer than the shown one, and being read again. + onStale: (loaded: BranchCompareLoadResult) => void + // The compare the build read, once it settles; until then the build reads it again itself after a git change. + onSettled: (key: ReviewBranchCompareKey) => void +} + +// The branch compare cache one Review build reads through. A superseded build's compare is never shown. +export function reviewBuildBranchCompareCache( queryClient: QueryClient, host: { hostId: string; client: RpcClient }, target: HostQueryTarget, - key: ReviewBranchCompareKey, - read: () => Promise -): Promise { - const loaded = await fetchHostQuery( - queryClient, - reviewBranchCompareQuery, - host, - target, - key, - read - ) - // Only a compare that landed for this head is worth reusing; cached status can trail a commit. - if (loaded.result?.summary.headOid !== key.head) { - queryClient.removeQueries({ - queryKey: hostQueryKey(host.hostId, reviewBranchCompareQuery.family, target, key), - exact: true - }) + build: ReviewBuild +): MobileBranchCompareCache { + const { isCurrent, onStale } = build + return async (key, read) => { + if (!isCurrent()) { + return { result: null } + } + try { + const loaded = await fetchHostQuery( + queryClient, + reviewBranchCompareQuery, + host, + target, + key, + { + read, + isCurrent, + onStale + } + ) + // Only a compare that landed for this head is worth reusing; cached status can trail a commit. + // A superseded build's answer may predate the entry a newer build is reading, so only the current one evicts. + if (isCurrent() && loaded.result?.summary.headOid !== key.head) { + queryClient.removeQueries({ + queryKey: hostQueryKey(host.hostId, reviewBranchCompareQuery.family, target, key), + exact: true + }) + } + return loaded + } finally { + if (isCurrent()) { + build.onSettled(key) + } + } } - return loaded } // Marked by a git change and not being read again, so only a new build brings the current compare. diff --git a/mobile/src/source-control/review/use-mobile-diff-review-snapshot.ts b/mobile/src/source-control/review/use-mobile-diff-review-snapshot.ts index 10fe03182a0..8122c702baf 100644 --- a/mobile/src/source-control/review/use-mobile-diff-review-snapshot.ts +++ b/mobile/src/source-control/review/use-mobile-diff-review-snapshot.ts @@ -9,10 +9,10 @@ import { useMobileSourceControlSettings } from '../mobile-source-control-setting import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../transport/connection/connection-state' import { loadMobileDiffReviewCommitSnapshot } from './mobile-diff-review-commit-loader' -import type { MobileBranchCompareCache } from './mobile-diff-review-loaders' +import type { BranchCompareLoadResult } from './mobile-diff-review-loaders' import { - readCachedReviewBranchCompare, reviewBranchCompareNeedsRead, + reviewBuildBranchCompareCache, type ReviewBranchCompareKey } from './mobile-diff-review-branch-compare-cache' import type { MobileDiffReviewQueueFilter } from './mobile-diff-review-queue' @@ -73,7 +73,7 @@ export function useMobileDiffReviewSnapshot(input: SnapshotInput) { ) // The status read the shown snapshot was built from; AWAITING while a build waits for its first read. const builtFromRef = useRef(undefined) - // The compare the current build read, so a git change that marked it stale rebuilds even when status held. + // The compare the current build has read, so a git change that marked it stale rebuilds even when status held. const builtCompareRef = useRef(null) // Reloads after a write wait for the rebuild they cause, so the screen's buttons match the new files. const buildWaitersRef = useRef<(() => void)[]>([]) @@ -170,14 +170,45 @@ export function useMobileDiffReviewSnapshot(input: SnapshotInput) { throw err } ) - const cacheBranchCompare: MobileBranchCompareCache = async (key, read) => { - // A superseded build's compare is never shown. - if (!isCurrent()) { - return { result: null } + // Once this build's files show, a later compare goes onto the screen, keeping note edits made since. + // Without a compare (null) the shown one holds until the new one lands. + let shownBuild = false + const showBuilt = (state: ReviewScreenState, loaded: BranchCompareLoadResult | null) => { + const onShown = shownBuild + shownBuild = true + if (!sameStatus && !onShown) { + setStatusRevision(queryClient.getQueryState(statusKey)?.dataUpdatedAt ?? 0) } - builtCompareRef.current = key - return readCachedReviewBranchCompare(queryClient, { hostId, client }, target, key, read) + setScreenState((prev) => { + const held = + prev.kind === 'ready' ? { result: prev.branchCompare, error: prev.branchError } : null + const compare = loaded ?? held + const base = onShown && prev.kind === 'ready' ? prev : state + return compare ? withMobileDiffReviewBranch(base, compare, worktreeId) : base + }) + if (loaded) { + setActionError(loaded.error ?? null) + } + settleWaiters() } + // A compare a git change marked as it landed is newer than the shown one, so it shows while read again. + const showStaleCompare = (loaded: BranchCompareLoadResult) => + void snapshot.then( + (state) => isCurrent() && showBuilt(state, loaded), + () => {} + ) + const cacheBranchCompare = reviewBuildBranchCompareCache( + queryClient, + { hostId, client }, + target, + { + isCurrent, + onStale: showStaleCompare, + onSettled: (key) => { + builtCompareRef.current = key + } + } + ) const { snapshot, branch } = startMobileDiffReviewSnapshot(client, worktreeId, { status: statusInput, compareAgainstUpstream, @@ -191,30 +222,13 @@ export function useMobileDiffReviewSnapshot(input: SnapshotInput) { if (!isCurrent()) { return } - if (!sameStatus) { - setStatusRevision(queryClient.getQueryState(statusKey)?.dataUpdatedAt ?? 0) - } - if (loaded) { - setScreenState(withMobileDiffReviewBranch(state, loaded, worktreeId)) - setActionError(loaded.error ?? null) - return - } - // Hold the shown compare until the new one lands. - setScreenState((prev) => - prev.kind === 'ready' - ? withMobileDiffReviewBranch( - state, - { result: prev.branchCompare, error: prev.branchError }, - worktreeId - ) - : state - ) // A write waits for the files it changed, not for the branch compare. - settleWaiters() - const settled = await branch - if (isCurrent()) { - setScreenState((prev) => withMobileDiffReviewBranch(prev, settled, worktreeId)) - setActionError(settled.error ?? null) + showBuilt(state, loaded) + if (!loaded) { + const settled = await branch + if (isCurrent()) { + showBuilt(state, settled) + } } } catch (err) { fail(err) @@ -233,9 +247,18 @@ export function useMobileDiffReviewSnapshot(input: SnapshotInput) { ) }, [client, commitOid, connState, queryClient, reloadNonce, statusKey, worktreeReview]) + // Closing Review ends its build, so a compare it is still reading is not read again for later git changes. + useEffect( + () => () => { + loadGenerationRef.current += 1 + settleWaiters() + }, + [] + ) + // A status read that changed what Changes shows rebuilds the snapshot; one in flight already brings its own. // A read that lands unchanged rebuilds too once a git change marked the shown compare stale; a compare - // still out is read again by its own build when it lands marked. + // its build has not settled yet is read again by that build. const rebuildOnStatusRead = useEffectEvent((read: GitStatusRead) => { const built = builtFromRef.current if (built === AWAITING) { From 23588ed935b8f447cac7d474593431e7232b6780 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 07:54:05 -0800 Subject: [PATCH 008/138] test: remove low-value browser, store and adapter suites (#40399) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 9 -- .../server-agent-tool-plugin-hooks.test.ts | 59 ---------- .../agent-tool-plugin-hooks.test.ts | 20 +--- ...-manager-destroyed-guest-downloads.test.ts | 67 ------------ .../grab/browser-manager-grab-mode.test.ts | 5 - .../browser-manager-guest-unload.test.ts | 45 -------- ...-client-hosted-browser-host-attach.test.ts | 64 ----------- ...hosted-browser-host-restart-attach.test.ts | 87 --------------- ...untime-host-account-sign-in-client.test.ts | 80 -------------- .../checks/github-pr-checks-fetch.test.ts | 103 ------------------ .../github-review-thread-actions.test.ts | 92 ---------------- .../issue-source-preference-twin.test.ts | 64 ----------- .../nested-repo-telemetry-schema.test.ts | 23 ---- .../card-template-schema.test.ts | 35 ------ 14 files changed, 1 insertion(+), 752 deletions(-) delete mode 100644 src/main/agent-hooks/server/testing/tool-events/server-agent-tool-plugin-hooks.test.ts delete mode 100644 src/main/browser/guest-lifecycle/manager/downloads/browser-manager-destroyed-guest-downloads.test.ts delete mode 100644 src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-unload.test.ts delete mode 100644 src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-attach.test.ts delete mode 100644 src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-restart-attach.test.ts delete mode 100644 src/renderer/src/runtime/provider-accounts/sign-in/runtime-host-account-sign-in-client.test.ts delete mode 100644 src/renderer/src/store/github/checks/github-pr-checks-fetch.test.ts delete mode 100644 src/renderer/src/store/github/review-threads/github-review-thread-actions.test.ts delete mode 100644 src/renderer/src/store/repos/issue-source-preference-twin.test.ts delete mode 100644 src/shared/workspace-cards/card-template-schema.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 5709f135eb5..ceda9fcf62b 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -551,7 +551,6 @@ "src/main/agent-hooks/installation/configuration/managed-toml-ownership.test.ts": 52, "src/main/agent-hooks/installation/scripts/posix-hook-json-stdin-reader.test.ts": 3479, "src/main/agent-hooks/server/testing/presence/server-agent-presence.test.ts": 1257, - "src/main/agent-hooks/server/testing/tool-events/server-agent-tool-plugin-hooks.test.ts": 717, "src/main/agent-hooks/server/testing/identity/server-ai-vault-liveness.test.ts": 643, "src/main/agent-hooks/server/testing/providers/antigravity/server-antigravity-resume-identity.test.ts": 820, "src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts": 681, @@ -934,14 +933,12 @@ "src/main/browser/guest-lifecycle/manager/viewport/browser-manager-annotation-bridge.test.ts": 603, "src/main/browser/guest-lifecycle/manager/navigation/browser-manager-auth-user-agent.test.ts": 555, "src/main/browser/guest-lifecycle/manager/downloads/browser-manager-client-hosted-downloads.test.ts": 670, - "src/main/browser/guest-lifecycle/manager/downloads/browser-manager-destroyed-guest-downloads.test.ts": 589, "src/main/browser/guest-lifecycle/manager/registration/browser-manager-destroyed-guest.test.ts": 633, "src/main/browser/guest-lifecycle/manager/devtools/browser-manager-devtools-retirement.test.ts": 551, "src/main/browser/guest-lifecycle/manager/downloads/browser-manager-downloads.test.ts": 592, "src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-capture.test.ts": 516, "src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-mode.test.ts": 567, "src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-selection.test.ts": 584, - "src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-unload.test.ts": 534, "src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts": 543, "src/main/browser/guest-lifecycle/manager/navigation/browser-manager-load-failure-replay.test.ts": 549, "src/main/browser/guest-lifecycle/manager/registration/browser-manager-page-prompt-routing.test.ts": 560, @@ -6726,8 +6723,6 @@ "src/renderer/src/runtime/hosted-terminals/streams/remote-runtime-terminal-stale-stream-frames.test.ts": 234, "src/renderer/src/runtime/hosted-terminals/streams/remote-runtime-terminal-stall-recovery.test.ts": 826, "src/renderer/src/runtime/hosts/updates/remote-server-restart-wait.test.ts": 49, - "src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-attach.test.ts": 43, - "src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-restart-attach.test.ts": 474, "src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.test.ts": 64, "src/renderer/src/runtime/files/reads/runtime-directory-page-client.test.ts": 268, "src/renderer/src/runtime/rpc/environment-revisions/runtime-environment-pairing-refresh.test.ts": 109, @@ -6738,7 +6733,6 @@ "src/renderer/src/runtime/files/reads/runtime-file-range-client.test.ts": 444, "src/renderer/src/runtime/files/mutations/runtime-file-write-disk-precondition.test.ts": 478, "src/renderer/src/runtime/source-control/git/runtime-git-client.test.ts": 513, - "src/renderer/src/runtime/provider-accounts/sign-in/runtime-host-account-sign-in-client.test.ts": 487, "src/renderer/src/runtime/hosts/connections/runtime-host-contact-regained.test.ts": 413, "src/renderer/src/runtime/jira/runtime-jira-image-client.test.ts": 214, "src/renderer/src/runtime/jira/runtime-jira-payload-stream.test.ts": 192, @@ -6932,7 +6926,6 @@ "src/renderer/src/store/github/pull-requests/github-pr-branch-direct-refresh-scope.test.ts": 667, "src/renderer/src/store/github/pull-requests/github-pr-branch-fallback-results.test.ts": 658, "src/renderer/src/store/github/pull-requests/github-pr-branch-linked-pr-divergence.test.ts": 662, - "src/renderer/src/store/github/checks/github-pr-checks-fetch.test.ts": 766, "src/renderer/src/store/github/comments/github-pr-comment-read-order.test.ts": 570, "src/renderer/src/store/github/comments/github-pr-comments.test.ts": 746, "src/renderer/src/store/github/pull-requests/github-pr-host-scope.test.ts": 1030, @@ -6941,7 +6934,6 @@ "src/renderer/src/store/github/pull-requests/github-pr-request-lifetime.test.ts": 1458, "src/renderer/src/store/github/projects/github-project-view-tables.test.ts": 656, "src/renderer/src/store/github/repositories/github-repo-lookup-index.test.ts": 51, - "src/renderer/src/store/github/review-threads/github-review-thread-actions.test.ts": 574, "src/renderer/src/store/github/review-threads/github-review-thread-rollback.test.ts": 663, "src/renderer/src/store/github/task-records/github-task-record-retries.test.ts": 758, "src/renderer/src/store/github/task-records/github-task-records.test.ts": 807, @@ -6951,7 +6943,6 @@ "src/renderer/src/store/github/work-items/github-work-items-twin-cache-host.test.ts": 672, "src/renderer/src/store/slices/hosted-review/hosted-review-request-lifetime.test.ts": 110, "src/renderer/src/store/slices/hosted-review/hosted-review.test.ts": 320, - "src/renderer/src/store/repos/issue-source-preference-twin.test.ts": 1858, "src/renderer/src/store/slices/jira/jira-status-filter-cache.test.ts": 1770, "src/renderer/src/store/slices/jira/jira-web-client-status.test.ts": 1901, "src/renderer/src/store/slices/jira/jira.test.ts": 312, diff --git a/src/main/agent-hooks/server/testing/tool-events/server-agent-tool-plugin-hooks.test.ts b/src/main/agent-hooks/server/testing/tool-events/server-agent-tool-plugin-hooks.test.ts deleted file mode 100644 index bbed8b3c40f..00000000000 --- a/src/main/agent-hooks/server/testing/tool-events/server-agent-tool-plugin-hooks.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { PluginHookName } from '../../../../../shared/plugins/contributions/plugin-hook-contribution' -import { setPluginHookRunner } from '../../../../plugins/hooks/plugin-hook-dispatch' -import { setAgentToolHookTerminalResolver } from '../../../tool-events/agent-tool-call-reader' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, PANE } from '../server.test-fixtures' - -vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) - -beforeEach(() => { - _internals.resetCachesForTests() -}) - -afterEach(() => { - setPluginHookRunner(null) - setAgentToolHookTerminalResolver(null) -}) - -describe('AgentHookServer agent tool plugin hooks', () => { - it('runs plugins only for calls status accepts, naming the owner’s terminal', async () => { - const run = vi.fn(async (_event: PluginHookName, payload: unknown) => ({ - kind: 'continue' as const, - payload - })) - setPluginHookRunner({ wants: (event) => event.startsWith('agent.tool.'), run }) - setAgentToolHookTerminalResolver((paneKey) => (paneKey === PANE ? 'term_1' : null)) - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postHook = (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - const pre = { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {} } - - // A post status cannot normalize (no event name) never reaches plugins. - await expect(postHook({ tool_name: 'Bash' })).resolves.toMatchObject({ status: 204 }) - await expect(postHook(pre)).resolves.toMatchObject({ status: 204 }) - expect(run).toHaveBeenCalledOnce() - expect(run.mock.calls[0]?.[1]).toMatchObject({ terminalId: 'term_1', tool: 'Bash' }) - - // A closed tab's late hooks are fenced off from status, so plugins never see them. - server.dropStatusEntriesByTabPrefix('tab-1') - await expect(postHook(pre)).resolves.toMatchObject({ status: 204 }) - expect(run).toHaveBeenCalledOnce() - } finally { - server.stop() - } - }) -}) diff --git a/src/main/agent-hooks/tool-events/agent-tool-plugin-hooks.test.ts b/src/main/agent-hooks/tool-events/agent-tool-plugin-hooks.test.ts index 19a470c8c12..3012b5ee23f 100644 --- a/src/main/agent-hooks/tool-events/agent-tool-plugin-hooks.test.ts +++ b/src/main/agent-hooks/tool-events/agent-tool-plugin-hooks.test.ts @@ -1,12 +1,8 @@ -import type { ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import type { PluginHookName } from '../../../shared/plugins/contributions/plugin-hook-contribution' import type { PluginHookChainOutcome } from '../../../shared/plugins/workers/plugin-hook-chain' import { setPluginHookRunner } from '../../plugins/hooks/plugin-hook-dispatch' -import { - answerAgentToolPluginHooks, - respondAfterAgentToolPluginHooks -} from './agent-tool-plugin-hooks' +import { answerAgentToolPluginHooks } from './agent-tool-plugin-hooks' const DECIDES = { 'x-orca-hook-decision': 'claude-pre-tool-use-v1' } @@ -18,12 +14,6 @@ function installRunner( return run } -function fakeResponse() { - const res = { writeHead: vi.fn(), end: vi.fn(), socket: { setTimeout: vi.fn() } } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the responder calls only writeHead, end and socket.setTimeout. - return { ...res, response: res as unknown as ServerResponse } -} - function post(payload: Record) { return { paneKey: 'tab-1:0', worktreeId: 'repo::/w', payload: JSON.stringify(payload) } } @@ -64,12 +54,4 @@ describe('answerAgentToolPluginHooks', () => { await vi.advanceTimersByTimeAsync(6_000) await expect(answer).resolves.toBeNull() }) - - it('skips plugins when status ingestion rejected or fenced the post', () => { - const run = installRunner(() => ({ kind: 'deny', pluginKey: 'acme.guard', reason: 'x' })) - const res = fakeResponse() - void respondAfterAgentToolPluginHooks(res.response, 'claude', PRE, DECIDES, null) - expect(res.writeHead).toHaveBeenCalledWith(204) - expect(run).not.toHaveBeenCalled() - }) }) diff --git a/src/main/browser/guest-lifecycle/manager/downloads/browser-manager-destroyed-guest-downloads.test.ts b/src/main/browser/guest-lifecycle/manager/downloads/browser-manager-destroyed-guest-downloads.test.ts deleted file mode 100644 index 4ef4a966756..00000000000 --- a/src/main/browser/guest-lifecycle/manager/downloads/browser-manager-destroyed-guest-downloads.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ contents: new Map() })) -vi.mock('electron', () => ({ - app: { getPath: () => '/downloads' }, - BrowserWindow: { fromWebContents: () => null }, - clipboard: { writeText: vi.fn() }, - shell: { openExternal: vi.fn() }, - Menu: { buildFromTemplate: vi.fn() }, - screen: { getCursorScreenPoint: () => ({ x: 0, y: 0 }) }, - webContents: { fromId: (id: number) => mocks.contents.get(id) ?? null } -})) -vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ - openPopupWithOriginBar: vi.fn() -})) - -import { - DestroyedGuestTestContents, - DestroyedGuestTestManager -} from '../registration/browser-manager-destroyed-guest-test-fixture' -import { createDownloadItem, getDownloadItemEventHandler } from '../browser-manager-test-harness' - -const event = { preventDefault: () => {}, defaultPrevented: false } -const manager = new DestroyedGuestTestManager() -const renderer = { isDestroyed: vi.fn(() => false), send: vi.fn() } -let nextGuestId = 1 - -function register(rendererId = 5001): DestroyedGuestTestContents { - const guest = new DestroyedGuestTestContents(nextGuestId++) - mocks.contents.set(guest.id, guest.asWebContents()) - mocks.contents.set(rendererId, renderer) - manager.attachGuestPolicies(guest.asWebContents()) - expect( - manager.registerGuest({ - browserPageId: 'recoverable-page', - webContentsId: guest.id, - rendererWebContentsId: rendererId - }) - ).toBe(true) - return guest -} - -function download(guest: DestroyedGuestTestContents): Electron.DownloadItem { - const item = createDownloadItem() - manager.handleGuestWillDownload({ guestWebContentsId: guest.id, item }) - return item -} - -beforeEach(() => { - renderer.isDestroyed.mockReset().mockReturnValue(false) - renderer.send.mockClear() -}) -afterEach(() => { - manager.unregisterAll() - mocks.contents.clear() -}) - -it('does not erase a replacement routing owner installed during completion delivery', () => { - const old = register() - const item = download(old) - old.destroy() - renderer.send.mockImplementationOnce(() => register(5002)) - getDownloadItemEventHandler(item, 'once', 'done')?.(event, 'completed') - expect(manager.retainedCounts().renderers).toBe(1) - manager.unregisterGuest('recoverable-page') - expect(manager.retainedCounts().renderers).toBe(0) -}) diff --git a/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-mode.test.ts b/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-mode.test.ts index 6aa573a26ca..2ae01c9880b 100644 --- a/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-mode.test.ts +++ b/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-mode.test.ts @@ -48,11 +48,6 @@ describe('browserManager grab operations', () => { const result = browserManager.getAuthorizedGuest('tab-1', 9999) expect(result).toBeNull() }) - - it('returns null for unregistered tab', () => { - const result = browserManager.getAuthorizedGuest('unknown-tab', GRAB_RENDERER_WEB_CONTENTS_ID) - expect(result).toBeNull() - }) }) describe('setGrabMode', () => { diff --git a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-unload.test.ts b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-unload.test.ts deleted file mode 100644 index 2387905ff6c..00000000000 --- a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-guest-unload.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { BrowserManager } from '../browser-manager' -import { DestroyedGuestTestContents } from './browser-manager-destroyed-guest-test-fixture' - -vi.mock('electron', () => ({ - app: { getPath: () => '/downloads' }, - BrowserWindow: { fromWebContents: () => null }, - clipboard: { writeText: vi.fn() }, - shell: { openExternal: vi.fn() }, - Menu: { buildFromTemplate: vi.fn() }, - screen: { getCursorScreenPoint: () => ({ x: 0, y: 0 }) }, - webContents: { fromId: () => null } -})) -vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ - openPopupWithOriginBar: vi.fn() -})) - -const manager = new BrowserManager() - -/** Electron leaves only when will-prevent-unload is prevented. */ -function askToLeave(guest: DestroyedGuestTestContents): boolean { - let leaves = false - guest.emit('will-prevent-unload', { preventDefault: () => (leaves = true) }) - return leaves -} - -afterEach(() => { - manager.unregisterAll() -}) - -// Electron otherwise keeps the page and makes navigation appear unresponsive (#28732). -it('lets browsing and workspace-document pages leave when they ask to confirm', () => { - const browsing = new DestroyedGuestTestContents(1) - manager.attachGuestPolicies(browsing.asWebContents()) - const document = Object.assign(new DestroyedGuestTestContents(2), { - setWebRTCIPHandlingPolicy: () => {} - }) - manager.attachGuestPolicies(document.asWebContents(), null, { - profile: 'workspace-doc', - host: new DestroyedGuestTestContents(3).asWebContents() - }) - - expect(askToLeave(browsing)).toBe(true) - expect(askToLeave(document)).toBe(true) -}) diff --git a/src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-attach.test.ts b/src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-attach.test.ts deleted file mode 100644 index a8989e1ca6a..00000000000 --- a/src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-attach.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ensureBrowserClientHostsForRestoredPages, - resetRestoredBrowserClientHostAttachForTests -} from './restored-client-hosted-browser-host-attach' -import { stubRuntimeEnvironmentBridge } from '../../rpc/testing/runtime-environment-bridge-fixture' - -type Placement = { kind: 'client'; browserHostClientId: string } | { kind: 'server' } - -const CLIENT_PLACEMENT: Placement = { kind: 'client', browserHostClientId: 'browser-host-1' } - -const prepareBrowserClientHostPlacement = vi.fn( - async (_args: { selector: string }): Promise => CLIENT_PLACEMENT -) - -function handles( - entries: Record -): Parameters[0] { - return { - remoteBrowserPageHandlesByPageId: Object.fromEntries( - Object.entries(entries).map(([pageId, entry]) => [ - pageId, - { - environmentId: entry.environmentId, - remotePageId: `remote-${pageId}`, - restoredFromSession: true as const, - ...(entry.clientHosted ? { restoredClientHosted: true as const } : {}) - } - ]) - ) - } -} - -function preparedEnvironmentIds(): string[] { - return prepareBrowserClientHostPlacement.mock.calls.map((call) => call[0].selector) -} - -describe('ensureBrowserClientHostsForRestoredPages', () => { - beforeEach(() => { - resetRestoredBrowserClientHostAttachForTests() - prepareBrowserClientHostPlacement.mockClear() - prepareBrowserClientHostPlacement.mockResolvedValue(CLIENT_PLACEMENT) - stubRuntimeEnvironmentBridge({ prepareBrowserClientHostPlacement }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('does not stack a second preparation on top of one still in flight', async () => { - let settle = (): void => {} - prepareBrowserClientHostPlacement.mockImplementation( - () => new Promise((resolve) => (settle = () => resolve(CLIENT_PLACEMENT))) - ) - const restored = handles({ 'page-1': { environmentId: 'env-1', clientHosted: true } }) - - const first = ensureBrowserClientHostsForRestoredPages(restored) - await ensureBrowserClientHostsForRestoredPages(restored) - settle() - await first - - expect(preparedEnvironmentIds()).toEqual(['env-1']) - }) -}) diff --git a/src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-restart-attach.test.ts b/src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-restart-attach.test.ts deleted file mode 100644 index 09cfc61ca42..00000000000 --- a/src/renderer/src/runtime/browser/client-hosting/restored-client-hosted-browser-host-restart-attach.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import 'zustand' -import type { RemoteBrowserPageHandle } from '../../../store/slices/browser/browser-slice-contract' -import '../../../store/slices/runtime-status/create-runtime-status-slice' -import '../../rpc/testing/runtime-compatibility-test-fixture' -import { - ensureBrowserClientHostOnRuntimeContact, - ensureBrowserClientHostsForRestoredPages, - resetRestoredBrowserClientHostAttachForTests -} from './restored-client-hosted-browser-host-attach' -import { stubRuntimeEnvironmentBridge } from '../../rpc/testing/runtime-environment-bridge-fixture' - -vi.mock('sonner', () => ({ - toast: { warning: vi.fn(), dismiss: vi.fn() } -})) - -const prepareBrowserClientHostPlacement = vi.fn( - async (_args: { selector: string; preference: string }) => ({ kind: 'server' as const }) -) - -function stubApi(): void { - stubRuntimeEnvironmentBridge({ - getStatus: vi.fn(), - list: vi.fn(), - prepareBrowserClientHostPlacement - }) -} - -const CLIENT_PLACEMENT = { - kind: 'client', - browserHostClientId: 'browser-host-1', - browserHostGeneration: 1, - pageHostGeneration: 1 -} as const - -type HandleSeed = Omit - -function stateWith(seeds: Record): { - remoteBrowserPageHandlesByPageId: Record -} { - return { - remoteBrowserPageHandlesByPageId: Object.fromEntries( - Object.entries(seeds).map(([pageId, seed]) => [ - pageId, - { remotePageId: `remote-${pageId}`, ...seed } - ]) - ) - } -} - -function preparedEnvironmentIds(): string[] { - return prepareBrowserClientHostPlacement.mock.calls.map((call) => call[0].selector) -} - -describe('ensureBrowserClientHostOnRuntimeContact', () => { - beforeEach(() => { - resetRestoredBrowserClientHostAttachForTests() - prepareBrowserClientHostPlacement.mockClear() - prepareBrowserClientHostPlacement.mockResolvedValue({ kind: 'server' as const }) - stubApi() - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - // Preparation aimed at dead runtime can hang until timeout; restart must bypass it or lose the authority-change signal. - it('re-prepares after an in-flight preparation aimed at the dead runtime settles', async () => { - const state = stateWith({ 'page-1': { environmentId: 'env-a', placement: CLIENT_PLACEMENT } }) - let releaseFirst = (): void => {} - prepareBrowserClientHostPlacement.mockImplementationOnce( - async () => - new Promise((resolve) => { - releaseFirst = () => resolve({ kind: 'server' as const }) - }) - ) - - const restored = ensureBrowserClientHostsForRestoredPages( - stateWith({ 'page-1': { environmentId: 'env-a', restoredClientHosted: true } }) - ) - const restarted = ensureBrowserClientHostOnRuntimeContact(state, 'env-a') - releaseFirst() - await Promise.all([restored, restarted]) - - expect(preparedEnvironmentIds()).toEqual(['env-a', 'env-a']) - }) -}) diff --git a/src/renderer/src/runtime/provider-accounts/sign-in/runtime-host-account-sign-in-client.test.ts b/src/renderer/src/runtime/provider-accounts/sign-in/runtime-host-account-sign-in-client.test.ts deleted file mode 100644 index 164183bfa0d..00000000000 --- a/src/renderer/src/runtime/provider-accounts/sign-in/runtime-host-account-sign-in-client.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { HostAccountSignInStatus } from '../../../../../shared/host-account-sign-in-types' -import '@/i18n/i18n' - -type RecordedCall = { method: string; params: unknown; revision: number | undefined } -const rpc = vi.hoisted(() => { - const calls: RecordedCall[] = [] - return { - calls, - capability: Promise.resolve(), - answer: (_method: string, _params: unknown): unknown => null - } -}) - -vi.mock('../../rpc/compatibility/runtime-capability-proof', () => ({ - assertRuntimeEnvironmentCapability: vi.fn(() => rpc.capability) -})) -vi.mock('../../rpc/calls/runtime-rpc-call', () => ({ - callRuntimeRpc: vi.fn( - async ( - _target: unknown, - method: string, - params: unknown, - options: { expectedEnvironmentPairingRevision?: number } - ) => { - rpc.calls.push({ method, params, revision: options.expectedEnvironmentPairingRevision }) - return rpc.answer(method, params) - } - ) -})) -vi.mock('../../rpc/environment-revisions/runtime-environment-revision', () => ({ - captureRuntimeEnvironmentRequestRevision: () => 7 -})) - -import { - addCodexAccountOnHost, - cancelHostAccountSignIn -} from './runtime-host-account-sign-in-client' - -const OWNER = { kind: 'environment', environmentId: 'server' } as const - -function status(patch: Partial): HostAccountSignInStatus { - return { flowId: 'flow-1', provider: 'codex', revision: 1, phase: 'starting', ...patch } -} - -beforeEach(() => { - rpc.calls.length = 0 - rpc.capability = Promise.resolve() -}) -afterEach(() => { - vi.useRealTimers() -}) - -describe('host account sign-in client', () => { - it('applies a Cancel clicked before the host answered the start', async () => { - const start = Promise.withResolvers() - const entered = Promise.withResolvers() - rpc.answer = (method) => { - if (method === 'accounts.hostSignInStart') { - entered.resolve() - return start.promise - } - return status({ revision: 2, phase: 'cancelled' }) - } - const adding = addCodexAccountOnHost(OWNER) - const outcome = adding.then( - () => 'fulfilled', - () => 'rejected' - ) - await entered.promise - await expect(cancelHostAccountSignIn(OWNER, 'codex')).resolves.toBe(true) - start.resolve(status({})) - await expect(outcome).resolves.toBe('rejected') - expect(rpc.calls).toContainEqual({ - method: 'accounts.hostSignInCancel', - params: { flowId: 'flow-1' }, - revision: 7 - }) - }) -}) diff --git a/src/renderer/src/store/github/checks/github-pr-checks-fetch.test.ts b/src/renderer/src/store/github/checks/github-pr-checks-fetch.test.ts deleted file mode 100644 index a6cbd23616b..00000000000 --- a/src/renderer/src/store/github/checks/github-pr-checks-fetch.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { persistGitHubCacheChanges } from '../cache/cache-persistence' -import { - createTestStore, - makePR, - mockApi, - resetRemoteRuntimeMocks -} from '../github-slice-test-harness' - -import { prChecksCacheSuffix } from '../cache/cache-identity' - -describe('createGitHubSlice.fetchPRChecks', () => { - beforeEach(() => { - vi.clearAllMocks() - resetRemoteRuntimeMocks() - mockApi.gh.prChecks.mockResolvedValue([]) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('syncs PR status from a fresh checks cache hit without refetching', async () => { - vi.useFakeTimers() - - const store = createTestStore() - const repoPath = '/repo' - const repoId = 'repo-id' - const branch = 'feature/test' - const prCacheKey = `${repoId}::${branch}` - const checksCacheKey = `${repoId}::pr-checks::12` - - store.setState({ - prCache: { - [prCacheKey]: { - data: makePR({ checksStatus: 'pending' }), - fetchedAt: 1 - } - }, - checksCache: { - [checksCacheKey]: { - data: [{ name: 'build', status: 'completed', conclusion: 'success', url: null }], - fetchedAt: Date.now() - } - } - }) - - persistGitHubCacheChanges(store) - await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId }) - await vi.advanceTimersByTimeAsync(1000) - - expect(mockApi.gh.prChecks).not.toHaveBeenCalled() - expect(store.getState().prCache[prCacheKey]?.data?.checksStatus).toBe('success') - expect(mockApi.cache.setGitHub).toHaveBeenCalledWith({ - cache: { - pr: store.getState().prCache, - issue: store.getState().issueCache - } - }) - }) - - it('does not sync stale checks into a PR cache entry for a different PR repo', async () => { - const store = createTestStore() - const repoPath = '/repo' - const repoId = 'repo-id' - const branch = 'feature/test' - const prCacheKey = `${repoId}::${branch}` - - store.setState({ - prCache: { - [prCacheKey]: { - data: makePR({ - checksStatus: 'pending', - prRepo: { owner: 'Fork', repo: 'Widgets' } - }), - fetchedAt: 1 - } - } - }) - - mockApi.gh.prChecks.mockResolvedValue([ - { name: 'build', status: 'completed', conclusion: 'success', url: null } - ]) - - await store - .getState() - .fetchPRChecks( - repoPath, - 12, - branch, - 'head-a', - { owner: 'Acme', repo: 'Widgets' }, - { force: true, repoId } - ) - - expect(store.getState().prCache[prCacheKey]?.data?.checksStatus).toBe('pending') - expect( - store.getState().checksCache[ - `${repoId}::${prChecksCacheSuffix(12, { owner: 'Acme', repo: 'Widgets' }, 'head-a')}` - ]?.data?.[0].name - ).toBe('build') - }) -}) diff --git a/src/renderer/src/store/github/review-threads/github-review-thread-actions.test.ts b/src/renderer/src/store/github/review-threads/github-review-thread-actions.test.ts deleted file mode 100644 index 029d46609da..00000000000 --- a/src/renderer/src/store/github/review-threads/github-review-thread-actions.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { PRComment } from '../../../../../shared/github/comment-types' -import type { AppState } from '../../types' -import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from '../cache/cache-identity' -import { - createTestStore, - mockApi, - resetRemoteRuntimeMocks, - runtimeEnvironmentCall -} from '../github-slice-test-harness' - -const repoPath = '/repo' -const repoId = 'repo-id' -const threadId = 'thread-1' - -function makeComments(isResolved = false): PRComment[] { - return [ - { - id: 1, - author: 'octocat', - authorAvatarUrl: '', - body: 'review', - createdAt: '2026-08-23T00:00:00Z', - url: '', - threadId, - isResolved - } - ] -} - -describe('createGitHubSlice.resolveReviewThread', () => { - beforeEach(() => { - vi.clearAllMocks() - resetRemoteRuntimeMocks() - }) - - it('optimistically resolves and routes through the repository runtime', async () => { - const store = createTestStore() - const settings = { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(12), - settings, - null, - 'runtime:env-1', - undefined, - true - ) - store.setState({ - settings, - repos: [ - { - id: repoId, - path: repoPath, - name: 'repo', - kind: 'git', - executionHostId: 'runtime:env-1' - } - ], - commentsCache: { [cacheKey]: { data: makeComments(), fetchedAt: 1 } } - } as unknown as Partial) - const runtimeResult = Promise.withResolvers<{ - id: string - ok: true - result: boolean - _meta: { runtimeId: string } - }>() - runtimeEnvironmentCall.mockReturnValueOnce(runtimeResult.promise) - - const pending = store.getState().resolveReviewThread(repoPath, 12, threadId, true, { - repoId - }) - expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) - runtimeResult.resolve({ - id: 'rpc-thread', - ok: true, - result: true, - _meta: { runtimeId: 'remote-runtime' } - }) - - await expect(pending).resolves.toBe(true) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'github.resolveReviewThread', - params: { repo: repoId, threadId, resolve: true, prRepo: null }, - timeoutMs: 30_000 - }) - expect(mockApi.gh.resolveReviewThread).not.toHaveBeenCalled() - expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) - }) -}) diff --git a/src/renderer/src/store/repos/issue-source-preference-twin.test.ts b/src/renderer/src/store/repos/issue-source-preference-twin.test.ts deleted file mode 100644 index 05862cbea5f..00000000000 --- a/src/renderer/src/store/repos/issue-source-preference-twin.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTestStore } from '../testing/app-store-test-fixture' -import type { Repo } from '../../../../shared/repo-types' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/rpc/testing/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCache as clearRuntimeCompatibilityCacheForTests } from '../../runtime/rpc/compatibility/runtime-compatibility-cache' -import '../../components/task-page/task-page-cache-selectors' -import '../../components/task-page/task-page-source-context' - -const localTwin: Repo = { - id: 'twin', - path: '/local/twin', - displayName: 'Twin', - badgeColor: '#000', - addedAt: 1, - executionHostId: 'local' -} -const sshTwin: Repo = { - ...localTwin, - path: '/ssh/twin', - addedAt: 2, - connectionId: 'server', - executionHostId: 'ssh:server' -} - -const reposUpdate = vi.fn() -const runtimeEnvironmentCall = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposUpdate.mockReset() - reposUpdate.mockResolvedValue(undefined) - runtimeEnvironmentCall.mockReset() - vi.stubGlobal('window', { - api: { - repos: { update: reposUpdate }, - runtimeEnvironments: { - call: (args: RuntimeEnvironmentCallRequest) => - createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - } - } - }) -}) - -describe('issue-source preference on a repo id with a row on two hosts', () => { - it('saves the SSH twin’s preference on its own row, leaving the local twin alone', async () => { - const store = createTestStore() - store.setState({ repos: [localTwin, sshTwin] }) - - await store.getState().setIssueSourcePreference('twin', sshTwin.path, 'upstream', 'ssh:server') - - expect(reposUpdate).toHaveBeenCalledWith({ - repoId: 'twin', - hostId: 'ssh:server', - updates: { issueSourcePreference: 'upstream' } - }) - expect(store.getState().repos).toEqual([ - localTwin, - { ...sshTwin, issueSourcePreference: 'upstream' } - ]) - }) -}) diff --git a/src/shared/nested-repo-telemetry-schema.test.ts b/src/shared/nested-repo-telemetry-schema.test.ts index ac9d0f3993c..0caf6601dcc 100644 --- a/src/shared/nested-repo-telemetry-schema.test.ts +++ b/src/shared/nested-repo-telemetry-schema.test.ts @@ -58,29 +58,6 @@ describe('nested repo import telemetry schemas', () => { ).toBe(false) }) - it('rejects unbounded imported counts', () => { - const parsed = eventSchemas.add_repo_nested_import_result.safeParse({ - attempt_id: attemptId, - surface: 'onboarding', - runtime_kind: 'local', - mode: 'separate', - outcome: 'success', - found_count: 501, - found_count_bucket: '16+', - selected_count: 501, - selected_count_bucket: '16+', - imported_count: 501, - imported_count_bucket: '16+', - already_known_count: 0, - already_known_count_bucket: '0', - failed_count: 0, - failed_count_bucket: '0', - all_selected: true - }) - - expect(parsed.success).toBe(false) - }) - it('rejects mismatched exact counts and buckets', () => { expect( eventSchemas.add_repo_nested_import_action.safeParse({ diff --git a/src/shared/workspace-cards/card-template-schema.test.ts b/src/shared/workspace-cards/card-template-schema.test.ts deleted file mode 100644 index 559383bbc54..00000000000 --- a/src/shared/workspace-cards/card-template-schema.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CARD_TEMPLATE_LIMITS, cardTemplateSchema } from './card-template-schema' - -const minimal = { version: 1, facts: 1, root: { el: 'Row', children: [{ el: 'Title' }] } } - -describe('card template schema', () => { - it('refuses unknown keys, bad names and non-JSON prop values', () => { - for (const bad of [ - { ...minimal, extra: true }, - { ...minimal, root: { el: 'div' } }, - { ...minimal, root: { el: 'Row', props: { onClick: { fn: 1 } } } }, - { ...minimal, root: { el: 'Row', props: { 'bad-name': 1 } } }, - { ...minimal, vars: { '1x': 'true' } }, - { ...minimal, version: 2 } - ]) { - expect(cardTemplateSchema.safeParse(bad).success).toBe(false) - } - }) - - it('caps node count and depth', () => { - const wide = { - ...minimal, - root: { - el: 'Row', - children: Array.from({ length: CARD_TEMPLATE_LIMITS.maxNodes }, () => ({ el: 'Title' })) - } - } - expect(cardTemplateSchema.safeParse(wide).success).toBe(false) - let deep: Record = { el: 'Title' } - for (let i = 0; i < CARD_TEMPLATE_LIMITS.maxDepth + 1; i++) { - deep = { el: 'Row', children: [deep] } - } - expect(cardTemplateSchema.safeParse({ ...minimal, root: deep }).success).toBe(false) - }) -}) From 2f68fb81755b4b9a88d7df0f69e291d0ecde7874 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 08:15:22 -0800 Subject: [PATCH 009/138] test: prune low-value worktree, SSH and activation coverage (#40406) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/legacy-relay-importers-baseline.txt | 1 - .../scripts/ci/sharding/ci-shard-timings.json | 8 -- .../providers/grok/server-grok-cancel.test.ts | 82 ---------------- .../wsl-guest-plugin-install.test.ts | 41 -------- .../ssh-security-key-identity.test.ts | 33 +------ .../handshake/ssh-relay-sentinel.test.ts | 13 --- .../orcad-migration-bridged-panes.test.ts | 53 ----------- .../orcad-migration-ssh-relay-source.test.ts | 76 --------------- .../ssh-tcp-forwarding-probe.test.ts | 7 -- .../system-ssh-sftp-args.test.ts | 22 +---- ...h-connection-store-orcad-ownership.test.ts | 72 -------------- ...empty-workspace-default-agent-chat.test.ts | 76 --------------- ...ee-activation-launch-awaiting-host.test.ts | 1 - ...b-session-tabs-sync-sleep-activity.test.ts | 5 +- ...ession-tabs-sync-tracking-teardown.test.ts | 6 -- .../slices/ui/ui-feature-interactions.test.ts | 95 ------------------- .../worktrees-create-base-status.test.ts | 85 ----------------- .../worktree-host-collision-index.test.ts | 12 --- 18 files changed, 5 insertions(+), 683 deletions(-) delete mode 100644 src/main/agent-hooks/server/testing/providers/grok/server-grok-cancel.test.ts delete mode 100644 src/main/agent-hooks/wsl-relay/wsl-guest-plugin-install.test.ts delete mode 100644 src/main/ssh/migration-source/orcad-migration-bridged-panes.test.ts delete mode 100644 src/main/ssh/migration-source/orcad-migration-ssh-relay-source.test.ts delete mode 100644 src/main/ssh/targets/ssh-connection-store-orcad-ownership.test.ts delete mode 100644 src/renderer/src/lib/workspace/activation/initial-surface/empty-workspace-default-agent-chat.test.ts delete mode 100644 src/renderer/src/store/slices/ui/ui-feature-interactions.test.ts delete mode 100644 src/renderer/src/store/slices/worktrees/create/worktrees-create-base-status.test.ts diff --git a/config/legacy-relay-importers-baseline.txt b/config/legacy-relay-importers-baseline.txt index ef18c667b27..c48864aa1ed 100644 --- a/config/legacy-relay-importers-baseline.txt +++ b/config/legacy-relay-importers-baseline.txt @@ -14,4 +14,3 @@ tests/e2e/cross-version-wire/legacy-relay-bridge.unit.test.ts tests/e2e/helpers/legacy-relay-docker-host.ts src/main/managed-server/install/remote-install-model.ts src/main/ssh/system-transport/ssh-system-transport.integration.test.ts -src/main/ssh/migration-source/orcad-migration-bridged-panes.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index ceda9fcf62b..3fd8fdf26ed 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -571,7 +571,6 @@ "src/main/agent-hooks/server/testing/identity/server-copilot-resume-identity.test.ts": 729, "src/main/agent-hooks/server/testing/http/server-endpoint-file-lifecycle.test.ts": 801, "src/main/agent-hooks/server/testing/providers/grok/server-grok-background-status.test.ts": 723, - "src/main/agent-hooks/server/testing/providers/grok/server-grok-cancel.test.ts": 692, "src/main/agent-hooks/server/testing/providers/grok/server-grok-discovery.test.ts": 723, "src/main/agent-hooks/server/testing/identity/server-hermes-resume-identity.test.ts": 941, "src/main/agent-hooks/server/testing/http/server-hook-http-ingest.test.ts": 719, @@ -626,7 +625,6 @@ "src/main/agent-hooks/installation/commands/windows-hook-payload-delivery.test.ts": 345, "src/main/agent-hooks/installation/commands/windows-native-hook-exe-hosts.test.ts": 262, "src/main/agent-hooks/installation/commands/windows-powershell-hook-launcher.test.ts": 44, - "src/main/agent-hooks/wsl-relay/wsl-guest-plugin-install.test.ts": 64, "src/main/agent-hooks/wsl-relay/wsl-hook-relay-idle-stop.test.ts": 1235, "src/main/agent-hooks/wsl-relay/testing/wsl-hook-relay-live.integration.test.ts": 2231, "src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts": 3176, @@ -4150,7 +4148,6 @@ "src/main/ssh/connection/ssh-connection-reconnect-ladder.test.ts": 570, "src/main/ssh/connection/ssh-connection-sftp-namespace.test.ts": 1290, "src/main/ssh/connection/ssh-connection-sftp-wire.test.ts": 481, - "src/main/ssh/targets/ssh-connection-store-orcad-ownership.test.ts": 324, "src/main/ssh/targets/ssh-connection-store-route-dedupe.test.ts": 350, "src/main/ssh/targets/ssh-connection-store.test.ts": 377, "src/main/ssh/connection/ssh-connection-system-transport.test.ts": 458, @@ -4168,7 +4165,6 @@ "src/main/ssh/legacy-relay/bridge/legacy-relay-client.test.ts": 109, "src/main/ssh/legacy-relay/bridge/legacy-relay-hidden-output.test.ts": 179, "src/main/ssh/legacy-relay/bridge/legacy-relay-target-bridge.test.ts": 167, - "src/main/ssh/migration-source/orcad-migration-bridged-panes.test.ts": 322, "src/main/ssh/legacy-relay/endpoints/ssh-host-relay-census-for-target.test.ts": 53, "src/main/ssh/legacy-relay/endpoints/ssh-host-relay-windows-census.test.ts": 232, "src/main/ssh/legacy-relay/endpoints/ssh-host-relay-windows-inventory.test.ts": 178, @@ -4198,7 +4194,6 @@ "src/main/managed-server/migration/transfers/orcad-migration-snapshot-coordinator.test.ts": 309, "src/main/ssh/migration-source/orcad-migration-source-fence.test.ts": 2000, "src/main/persistence/migrating-orcad-catalog/orcad-migration-source-retention.test.ts": 2297, - "src/main/ssh/migration-source/orcad-migration-ssh-relay-source.test.ts": 1903, "src/main/managed-server/migration/cutover/orcad-migration-stuck-host-profiles.test.ts": 2402, "src/main/ssh/migration-source/orcad-migration-terminal-gate.test.ts": 63, "src/main/managed-server/install/orcad-remote-deploy.test.ts": 537, @@ -6440,7 +6435,6 @@ "src/renderer/src/lib/workspace/creation/work-item/direct-work-item-setup.test.ts": 2072, "src/renderer/src/lib/browser/documents/doc-preview-grants.test.ts": 2094, "src/renderer/src/lib/editor/file-operations/editor-file-operation-owner.test.ts": 2848, - "src/renderer/src/lib/workspace/activation/initial-surface/empty-workspace-default-agent-chat.test.ts": 1895, "src/renderer/src/lib/emulator-error-copy.test.ts": 415, "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 799, "src/renderer/src/lib/simulator/tabs/ensure-simulator-tab-behavior.test.ts": 3601, @@ -7033,7 +7027,6 @@ "src/renderer/src/store/terminals/terminals-hydration-canonical-pty-overlap.test.ts": 1946, "src/renderer/src/store/terminals/terminals-hydration-canonical-rows.test.ts": 1680, "src/renderer/src/store/slices/ui/ui-agent-send-target.test.ts": 760, - "src/renderer/src/store/slices/ui/ui-feature-interactions.test.ts": 648, "src/renderer/src/store/slices/ui/ui-host-filter-sync.test.ts": 597, "src/renderer/src/store/slices/ui/ui-host-workspace-statuses.test.ts": 1695, "src/renderer/src/store/slices/ui/ui-modal-slot-dismissal.test.ts": 662, @@ -7057,7 +7050,6 @@ "src/renderer/src/store/slices/settings/worktree-visibility-settings-write.test.ts": 307, "src/renderer/src/store/slices/worktrees/teardown/worktrees-always-force-delete.test.ts": 1611, "src/renderer/src/store/slices/worktrees/teardown/worktrees-background-removal.test.ts": 2011, - "src/renderer/src/store/slices/worktrees/create/worktrees-create-base-status.test.ts": 1252, "src/renderer/src/store/slices/worktrees/create/worktrees-create-captured-host.test.ts": 1148, "src/renderer/src/store/slices/worktrees/listing/worktrees-fetch-listing-merge.test.ts": 1311, "src/renderer/src/store/slices/worktrees/listing/worktrees-fetch-owner-routing.test.ts": 2038, diff --git a/src/main/agent-hooks/server/testing/providers/grok/server-grok-cancel.test.ts b/src/main/agent-hooks/server/testing/providers/grok/server-grok-cancel.test.ts deleted file mode 100644 index 1237c8122ee..00000000000 --- a/src/main/agent-hooks/server/testing/providers/grok/server-grok-cancel.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Grok 1.0.70: Esc never cancels and Ctrl+C can open a stop dialog; only StopCancelled reports a cancel. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentHookServer, _internals } from '../../../agent-hook-server' -import { buildBody, PANE } from '../../server.test-fixtures' -import type { AgentInterruptInputIntent } from '../../../../../../shared/agent-status/lifecycle/agent-interrupt-intent' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../../../../../telemetry/client', () => ({ track: trackMock })) -vi.mock('../../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => vi.restoreAllMocks()) - -async function postGrokHook( - server: AgentHookServer, - payload: Record -): Promise { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody({ sessionId: 'session-1', ...payload })) - }) - expect(response.status).toBe(204) -} - -function row(server: AgentHookServer) { - const entry = server.getStatusSnapshotForPane(PANE)[0] - if (!entry) { - throw new Error('the pane has no row') - } - return entry -} - -function press(server: AgentHookServer, intent: AgentInterruptInputIntent): boolean { - const baseline = row(server) - return server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: baseline.prompt, - baselineAgentType: 'grok', - intent - }) -} - -describe('a Grok keypress never decides a cancel', () => { - it('refuses Esc and Ctrl+C on a working Grok turn', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - await postGrokHook(server, { - hookEventName: 'user_prompt_submit', - promptId: 'prompt-1', - prompt: 'run the tests' - }) - const working = row(server) - expect(working).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) - - expect(press(server, 'plain-escape')).toBe(false) - expect(press(server, 'ctrl-c')).toBe(false) - expect(row(server)).toEqual(working) - } finally { - server.stop() - } - }) -}) diff --git a/src/main/agent-hooks/wsl-relay/wsl-guest-plugin-install.test.ts b/src/main/agent-hooks/wsl-relay/wsl-guest-plugin-install.test.ts deleted file mode 100644 index d3b3092339e..00000000000 --- a/src/main/agent-hooks/wsl-relay/wsl-guest-plugin-install.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' -import type { SshChannelMultiplexer } from '../../ssh/protocol/ssh-channel-multiplexer' - -function fakeMux( - request: () => Promise, - isDisposed = false -): { mux: SshChannelMultiplexer } { - return { mux: { request, isDisposed: () => isDisposed } as unknown as SshChannelMultiplexer } -} - -function deps() { - return { - pluginSources: () => ({ opencodePluginSource: '// src' }), - warn: vi.fn<(message: string) => void>() - } -} - -describe('requestGuestOpenCodeOverlayDir', () => { - it("reports 'none' when the guest answered but materialization produced no dir", async () => { - // A failed rebuild invalidates the previously recorded overlay, unlike an unavailable guest. - const { mux } = fakeMux(async () => ({ installed: { opencode: true }, overlayDirs: {} })) - await expect(requestGuestOpenCodeOverlayDir(mux, deps(), 'Ubuntu')).resolves.toEqual({ - kind: 'none' - }) - }) - - it("reports 'unavailable' for an older guest bundle and for teardown, without warning", async () => { - for (const code of [-32601, 'CONNECTION_LOST', 'DISPOSED']) { - const d = deps() - const { mux } = fakeMux(async () => { - throw Object.assign(new Error('nope'), { code }) - }) - await expect(requestGuestOpenCodeOverlayDir(mux, d, 'Ubuntu')).resolves.toEqual({ - kind: 'unavailable' - }) - expect(d.warn).not.toHaveBeenCalled() - } - }) -}) diff --git a/src/main/ssh/authentication/ssh-security-key-identity.test.ts b/src/main/ssh/authentication/ssh-security-key-identity.test.ts index 0ba4f9642d8..705e8bda8da 100644 --- a/src/main/ssh/authentication/ssh-security-key-identity.test.ts +++ b/src/main/ssh/authentication/ssh-security-key-identity.test.ts @@ -3,14 +3,8 @@ import 'node:os' import 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - isOpenSshSecurityKeyPrivateKey, - isOpenSshSecurityKeyPublicKey -} from './ssh-security-key-identity' -import { - createOpenSshPrivateKeyFixture, - createOpenSshPublicKeyFixture -} from './testing/ssh-security-key-identity.test-fixture' +import { isOpenSshSecurityKeyPrivateKey } from './ssh-security-key-identity' +import { createOpenSshPrivateKeyFixture } from './testing/ssh-security-key-identity.test-fixture' import '../system-transport/ssh-transport-selection' const { findSystemSshMock } = vi.hoisted(() => ({ findSystemSshMock: vi.fn() })) @@ -56,27 +50,4 @@ describe('isOpenSshSecurityKeyPrivateKey', () => { isOpenSshSecurityKeyPrivateKey(Buffer.from(`${ED25519_SECURITY_KEY} AAAA comment`)) ).toBe(false) }) - - it('rejects regular or mismatched OpenSSH public key blobs', () => { - expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture('ssh-ed25519'))).toBe(false) - expect( - isOpenSshSecurityKeyPublicKey( - Buffer.from(`${ED25519_SECURITY_KEY} ${Buffer.from('ssh-ed25519').toString('base64')}`) - ) - ).toBe(false) - }) - - it('rejects malformed and truncated OpenSSH envelopes without throwing', () => { - const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) - const malformedLength = Buffer.concat([ - Buffer.from('openssh-key-v1\0', 'ascii'), - Buffer.from([0xff, 0xff, 0xff, 0xff]) - ]).toString('base64') - const malformedKey = Buffer.from( - `-----BEGIN OPENSSH PRIVATE KEY-----\n${malformedLength}\n-----END OPENSSH PRIVATE KEY-----\n` - ) - expect(isOpenSshSecurityKeyPrivateKey(key.subarray(0, -20))).toBe(false) - expect(isOpenSshSecurityKeyPrivateKey(malformedKey)).toBe(false) - expect(isOpenSshSecurityKeyPrivateKey(Buffer.from('not a private key'))).toBe(false) - }) }) diff --git a/src/main/ssh/legacy-relay/handshake/ssh-relay-sentinel.test.ts b/src/main/ssh/legacy-relay/handshake/ssh-relay-sentinel.test.ts index ba5d82ceb3a..76ab7a30888 100644 --- a/src/main/ssh/legacy-relay/handshake/ssh-relay-sentinel.test.ts +++ b/src/main/ssh/legacy-relay/handshake/ssh-relay-sentinel.test.ts @@ -34,19 +34,6 @@ describe('waitForSentinel', () => { expect(chunks).toEqual(['first-frame-after-sentinel']) }) - it('buffers post-sentinel bytes from the sentinel chunk', async () => { - const channel = createMockChannel() - const transportPromise = waitForSentinel(channel) - - channel.emit('data', Buffer.from(`${RELAY_SENTINEL}same-chunk-frame`)) - - const transport = await transportPromise - const chunks: string[] = [] - transport.onData((chunk) => chunks.push(chunk.toString('utf-8'))) - - expect(chunks).toEqual(['same-chunk-frame']) - }) - it('does not count same-chunk post-sentinel payload against the startup buffer cap', async () => { const channel = createMockChannel() const transportPromise = waitForSentinel(channel) diff --git a/src/main/ssh/migration-source/orcad-migration-bridged-panes.test.ts b/src/main/ssh/migration-source/orcad-migration-bridged-panes.test.ts deleted file mode 100644 index 27e000e4856..00000000000 --- a/src/main/ssh/migration-source/orcad-migration-bridged-panes.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DORMANT_LEAF_ID, - DORMANT_WORKTREE_ID, - dormantState, - manifest -} from '../../persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog-fixture' -import type { SshRemotePtyLease } from '../../../shared/ssh-types' -import { preservesOrcadMigrationBridgedPanes } from './orcad-migration-bridged-panes' -import { isBridgeableLease } from '../legacy-relay/bridge/legacy-relay-bridge-leases' - -const LEASE: SshRemotePtyLease = { - targetId: 'ssh-prod', - ptyId: 'relay-pty', - tabId: 'tab-dormant', - leafId: DORMANT_LEAF_ID, - worktreeId: DORMANT_WORKTREE_ID, - state: 'detached', - createdAt: 1, - updatedAt: 1 -} - -describe('preserving the relay bridge when skipping saved state', () => { - it('refuses when a tab survives but the running split pane is omitted', () => { - const payload = { ...manifest().payload, dormantState: dormantState() } - const session = payload.dormantState!.workspaceSession! - session.terminalLayoutsByTabId[LEASE.tabId!]!.root = { type: 'leaf', leafId: 'other-leaf' } - session.terminalLayoutsByTabId[LEASE.tabId!]!.activeLeafId = 'other-leaf' - expect( - preservesOrcadMigrationBridgedPanes(payload, [LEASE], [LEASE.ptyId], isBridgeableLease) - ).toBe(false) - }) - - it('refuses a tab that moved to another workspace or whose lease identity is unknown', () => { - const payload = { ...manifest().payload, dormantState: dormantState() } - expect( - preservesOrcadMigrationBridgedPanes( - payload, - [ - { - ...LEASE, - worktreeId: 'folder:other-folder' - } - ], - [LEASE.ptyId], - isBridgeableLease - ) - ).toBe(false) - expect(preservesOrcadMigrationBridgedPanes(payload, [], [LEASE.ptyId], isBridgeableLease)).toBe( - false - ) - }) -}) diff --git a/src/main/ssh/migration-source/orcad-migration-ssh-relay-source.test.ts b/src/main/ssh/migration-source/orcad-migration-ssh-relay-source.test.ts deleted file mode 100644 index d0ef3f5e2a5..00000000000 --- a/src/main/ssh/migration-source/orcad-migration-ssh-relay-source.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { SshTarget } from '../../../shared/ssh-types' -import { closeTestStores, createSqliteTestStore } from '../../persistence/persistence-test-harness' -import { Store } from '../../persistence/loading-store/store' -import { listOrcadMigrationSourceCutovers } from '../../persistence/migrating-orcad-catalog/orcad-migration-cutover-journal' - -import { createSshRelayMigrationSource } from './orcad-migration-ssh-relay-source' -import { SshTargetOrcadClaims } from '../targets/ssh-target-orcad-claims' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 2 -} - -const directories: string[] = [] -afterEach(async () => { - await closeTestStores() - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -function setup() { - const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-ssh-relay-source-')) - directories.push(userDataPath) - const store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') }) - store.addSshTarget(TARGET) - store.addRepo({ - id: 'repo-1', - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: TARGET.id - }) - const claims = new SshTargetOrcadClaims(store) - const source = createSshRelayMigrationSource({ - userDataPath, - store, - claims, - target: store.getSshTarget(TARGET.id)! - }) - return { userDataPath, store, source } -} - -describe('relay-era SSH migration source', () => { - it('never proves exit without a host-wide census', async () => { - await expect(setup().source.assessTerminals()).resolves.toMatchObject({ - verdict: 'unverifiable' - }) - }) - - it('acquires, verifies, and releases its fence through the journal it is keyed by', async () => { - const { userDataPath, source } = setup() - expect(source.resolveFence()).toEqual({ state: 'none' }) - const fenced = await source.acquireFence({ - destinationEnvironmentId: 'env-1', - destinationName: 'Managed', - terminalProof: { verdict: 'exited', provenPtyIds: [] } - }) - expect(fenced.outcome).toBe('fenced') - const cutover = listOrcadMigrationSourceCutovers(userDataPath)[0] - expect(cutover.sshTargetId).toBe(source.journalKey) - expect(() => source.verifyFence(cutover)).not.toThrow() - await source.releaseFence(cutover) - expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([]) - }) -}) diff --git a/src/main/ssh/port-forwarding/ssh-tcp-forwarding-probe.test.ts b/src/main/ssh/port-forwarding/ssh-tcp-forwarding-probe.test.ts index 3800702be8d..0068739f460 100644 --- a/src/main/ssh/port-forwarding/ssh-tcp-forwarding-probe.test.ts +++ b/src/main/ssh/port-forwarding/ssh-tcp-forwarding-probe.test.ts @@ -51,11 +51,4 @@ describe('forward channel check (system SSH)', () => { }) await expect(checkForwardChannel(forward)).resolves.toBe('allowed') }) - - it('reads reply bytes through the channel as allowed', async () => { - const forward = await forwardAnswering((socket) => { - socket.once('data', () => socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')) - }) - await expect(checkForwardChannel(forward)).resolves.toBe('allowed') - }) }) diff --git a/src/main/ssh/system-transport/file-transfer/system-ssh-sftp-args.test.ts b/src/main/ssh/system-transport/file-transfer/system-ssh-sftp-args.test.ts index 0585a3f5820..b9e823ca9e9 100644 --- a/src/main/ssh/system-transport/file-transfer/system-ssh-sftp-args.test.ts +++ b/src/main/ssh/system-transport/file-transfer/system-ssh-sftp-args.test.ts @@ -1,6 +1,6 @@ /** SSH flags can silently change meaning in SFTP; unknown translations must decline to another transport. */ import { describe, expect, it } from 'vitest' -import { SftpArgTranslationError, translateSshArgsToSftpArgs } from './system-ssh-sftp-args' +import { translateSshArgsToSftpArgs } from './system-ssh-sftp-args' describe('translateSshArgsToSftpArgs', () => { it('translates the whole unclaimed-alias shape buildSshArgs emits', () => { @@ -35,24 +35,4 @@ describe('translateSshArgsToSftpArgs', () => { 'awin' ]) }) - - it('refuses any other -S, which would hand sftp an ssh binary Orca did not choose', () => { - expect(() => translateSshArgsToSftpArgs(['-S', '/tmp/ctl.sock'])).toThrow( - SftpArgTranslationError - ) - }) - - it('takes everything after -- as the destination without reinterpreting it', () => { - // A host literally named `-p` is not a flag once `--` has been seen. - expect(translateSshArgsToSftpArgs(['--', '-p'])).toEqual(['--', '-p']) - }) - - it('refuses an unknown flag rather than guessing what sftp would do with it', () => { - // Future SSH flags must fall back safely instead of inheriting SFTP's different meaning. - expect(() => translateSshArgsToSftpArgs(['-A', '--', 'host'])).toThrow(SftpArgTranslationError) - }) - - it('refuses a value flag with no value', () => { - expect(() => translateSshArgsToSftpArgs(['-o'])).toThrow(SftpArgTranslationError) - }) }) diff --git a/src/main/ssh/targets/ssh-connection-store-orcad-ownership.test.ts b/src/main/ssh/targets/ssh-connection-store-orcad-ownership.test.ts deleted file mode 100644 index 20f991ab7f5..00000000000 --- a/src/main/ssh/targets/ssh-connection-store-orcad-ownership.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { SshConnectionStore } from './ssh-connection-store' -import { createMockStore } from './testing/ssh-connection-store-test-fixture' -import { emptyDependentStateStore } from './testing/ssh-target-orcad-dependents-fixture' - -const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ - loadUserSshConfigMock: vi.fn(), - sshConfigHostsToTargetsMock: vi.fn() -})) - -vi.mock('../config/ssh-config-parser', () => ({ - loadUserSshConfig: loadUserSshConfigMock, - loadUserSshConfigWithText: () => ({ hosts: loadUserSshConfigMock(), text: '' }), - sshConfigHostsToTargets: sshConfigHostsToTargetsMock -})) - -const base = { label: 'cluster', host: 'cluster.example.com', port: 22, username: 'dev' } - -describe('managed orcad ownership of SSH targets', () => { - let mockStore: ReturnType - let sshStore: SshConnectionStore - - beforeEach(() => { - mockStore = Object.assign(createMockStore(), emptyDependentStateStore()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements the store methods SshConnectionStore calls. - sshStore = new SshConnectionStore(mockStore as never, { config: async () => null }) - loadUserSshConfigMock.mockReset() - sshConfigHostsToTargetsMock.mockReset() - }) - - it('lists managed and provisioning hosts with their status, and hides only runtime-owned ones', () => { - sshStore.addTarget(base) - sshStore.addTarget({ - ...base, - label: 'provisioning', - orcadProvisioning: { requestId: 'r', name: 'n' } - }) - const claimed = sshStore.addTarget({ ...base, label: 'claimed' }) - sshStore.getOrcadRuntimeClaims().claim(claimed.id, 'environment-1', { ownerRecorded: false }) - mockStore.addSshTarget({ - ...base, - id: 'runtime-ssh-vm', - label: 'vm', - owner: { type: 'on-demand-runtime', runtimeId: 'vm-1' } - }) - - expect(sshStore.listTargets().map((target) => target.label)).toEqual([ - 'cluster', - 'provisioning', - 'claimed' - ]) - expect(sshStore.getTarget(claimed.id)?.owner).toBeUndefined() - }) - - it('keeps ~/.ssh/config sync from rewriting a claimed config-sourced host', async () => { - mockStore.addSshTarget({ - ...base, - id: 'ssh-config-host', - configHost: 'cluster', - source: 'ssh-config', - orcadFence: { environmentId: 'environment-1' } - }) - loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) - sshConfigHostsToTargetsMock.mockReturnValue([ - { ...base, id: 'tmp', configHost: 'cluster', host: '10.0.0.9', port: 2222 } - ]) - - expect((await sshStore.importFromSshConfig()).targets).toEqual([]) - expect(mockStore.updateSshTarget).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/lib/workspace/activation/initial-surface/empty-workspace-default-agent-chat.test.ts b/src/renderer/src/lib/workspace/activation/initial-surface/empty-workspace-default-agent-chat.test.ts deleted file mode 100644 index 06ad2e64a78..00000000000 --- a/src/renderer/src/lib/workspace/activation/initial-surface/empty-workspace-default-agent-chat.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EVERY_AGENT_CHAT } from '../../../../../../shared/native-chat/every-agent-chat.test-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' -import { getDefaultSettings } from '../../../../../../shared/constants' -import { useAppStore } from '@/store/index' -import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/hosts/capabilities/local-runtime-capabilities' -import { loadEmptyWorkspaceDefaultChatDetection } from './empty-workspace-default-agent-chat' - -const mocks = vi.hoisted(() => ({ - launchAgentInNewTab: vi.fn(), - planAgentSessionLaunch: vi.fn(), - detectionTargetKey: vi.fn<() => string | undefined>(), - seedShell: vi.fn(() => true) -})) - -vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({ - launchAgentInNewTab: mocks.launchAgentInNewTab -})) -vi.mock('@/lib/agent-session/launch/routing/agent-session-launch-plan', () => ({ - planAgentSessionLaunch: mocks.planAgentSessionLaunch -})) -vi.mock('@/hooks/useAgentDetectionTarget', async (importOriginal) => ({ - ...(await importOriginal>()), - getAgentDetectionTargetKeyForWorktree: mocks.detectionTargetKey -})) - -const initialAppStoreState = useAppStore.getState() - -function seedSettings(settings: Partial): void { - useAppStore.setState({ - detectedAgentIds: ['claude', 'codex'], - settings: { - ...getDefaultSettings('/tmp'), - nativeChatProviders: EVERY_AGENT_CHAT, - defaultTuiAgent: 'codex', - disabledTuiAgents: [], - ...settings - } - }) -} - -beforeEach(() => { - mocks.detectionTargetKey.mockReturnValue('local') - mocks.planAgentSessionLaunch.mockReturnValue({ route: 'structured-native-chat' }) - mocks.launchAgentInNewTab.mockReturnValue({ - surface: { kind: 'host-published' } - }) -}) - -afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - setLocalRuntimeCapabilitiesForTests(null) - useAppStore.setState(initialAppStoreState, true) -}) - -describe('agent detection for the default chat', () => { - it('probes the workspace host and gives up after a bounded wait', async () => { - vi.useFakeTimers() - seedSettings({}) - mocks.detectionTargetKey.mockReturnValue('ssh:conn-1') - const ensureRemoteDetectedAgents = vi.fn(() => new Promise(() => {})) - useAppStore.setState({ ensureRemoteDetectedAgents }) - let settled = false - - void loadEmptyWorkspaceDefaultChatDetection('wt-1').then(() => { - settled = true - }) - await vi.advanceTimersByTimeAsync(4_999) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - - expect(ensureRemoteDetectedAgents).toHaveBeenCalledWith('conn-1') - expect(settled).toBe(true) - }) -}) diff --git a/src/renderer/src/lib/workspace/activation/worktree-activation-launch-awaiting-host.test.ts b/src/renderer/src/lib/workspace/activation/worktree-activation-launch-awaiting-host.test.ts index 90b12bf00ca..356fcdb278f 100644 --- a/src/renderer/src/lib/workspace/activation/worktree-activation-launch-awaiting-host.test.ts +++ b/src/renderer/src/lib/workspace/activation/worktree-activation-launch-awaiting-host.test.ts @@ -62,5 +62,4 @@ it('does not seed a shell when the workspace is re-activated while its chat wait answer({ kind: 'admitted' }) await vi.waitFor(() => expect(openAdmitted).toHaveBeenCalledOnce()) expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toEqual([]) - expect(admit).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-sleep-activity.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-sleep-activity.test.ts index fd4f533aa89..707ac565d15 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-sleep-activity.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-sleep-activity.test.ts @@ -21,7 +21,6 @@ vi.mock('../../../store/index', () => ({ useAppStore: { setState: vi.fn() } })) const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1') const PTY_ID = `remote:${ENV}@@terminal-1` -const FOLDER = 'folder:folder-1' function sleptState(worktreeId = WT, ptyId = PTY_ID) { return makeState({ @@ -79,10 +78,10 @@ describe('sleep activity during host terminal mirroring', () => { beforeEach(resetWebSessionTabsSyncTestState) afterEach(() => { clearWorktreeSleepIntent(WT) - clearWorktreeSleepIntent(FOLDER) }) - it.each([WT, FOLDER])('keeps %s asleep while retaining its wake binding', (worktreeId) => { + it('keeps a sleeping workspace wake binding inactive', () => { + const worktreeId = WT const state = sleptState(worktreeId) markWorktreeSleepIntent(worktreeId) expect(inactive(state, worktreeId)).toBe(true) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking/web-session-tabs-sync-tracking-teardown.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking/web-session-tabs-sync-tracking-teardown.test.ts index f25a6cea09e..8ad49e70d6e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking/web-session-tabs-sync-tracking-teardown.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking/web-session-tabs-sync-tracking-teardown.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-session-contracts' import { toWebTerminalSurfaceTabId } from '../../../../../shared/workspace-layout/terminal-surface-id' -import { _getWebSessionTabsTrackingCountsForTest } from '../tracking-lifecycle' import { applyFreshWebSessionTabsSnapshot } from '../snapshot-api' import { resolveHostSessionTabIdForWebSessionTab } from '../tracking-mappings' @@ -76,11 +75,6 @@ describe('applyWebSessionTabsSnapshot', () => { NOW + 1 ) - expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ - freshness: 1, - hostMappings: 1, - hostMappingWorktrees: 1 - }) expect( resolveHostSessionTabIdForWebSessionTab(makeState(), { environmentId: ENV, diff --git a/src/renderer/src/store/slices/ui/ui-feature-interactions.test.ts b/src/renderer/src/store/slices/ui/ui-feature-interactions.test.ts deleted file mode 100644 index 668babba9a8..00000000000 --- a/src/renderer/src/store/slices/ui/ui-feature-interactions.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' - -import { createUIStore, makePersistedUI } from './ui-slice-test-harness' - -const mocks = vi.hoisted(() => ({ - sendNotesToActiveAgentSession: vi.fn(), - track: vi.fn(), - toastMessage: vi.fn(), - toastSuccess: vi.fn(), - toastError: vi.fn() -})) - -vi.mock('@/lib/agent-session/messaging/terminal/active-agent-note-send', () => ({ - sendNotesToActiveAgentSession: mocks.sendNotesToActiveAgentSession -})) -vi.mock('@/lib/agent-session/messaging/terminal/active-agent-note-send-result', () => ({ - activeAgentNotesSendFailureMessage: ( - status: string, - options: { explicitTarget?: boolean } = {} - ) => (options.explicitTarget ? `selected:${status}` : status) -})) - -vi.mock('@/lib/telemetry', () => ({ - track: mocks.track -})) - -vi.mock('sonner', () => ({ - toast: { - message: mocks.toastMessage, - success: mocks.toastSuccess, - error: mocks.toastError - } -})) - -afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -beforeEach(() => { - mocks.sendNotesToActiveAgentSession.mockReset() - mocks.sendNotesToActiveAgentSession.mockResolvedValue({ status: 'sent' }) - mocks.track.mockReset() - mocks.toastMessage.mockReset() - mocks.toastSuccess.mockReset() - mocks.toastError.mockReset() -}) - -describe('createUISlice feature interactions', () => { - it('keeps newer optimistic interaction counts when persistence responses resolve out of order', async () => { - const pending: ((ui: PersistedUIState) => void)[] = [] - const recordFeatureInteractionMock = vi.fn( - () => - new Promise((resolve) => { - pending.push(resolve) - }) - ) - vi.stubGlobal('window', { - api: { - ui: { - recordFeatureInteraction: recordFeatureInteractionMock, - set: vi.fn(() => Promise.resolve()) - } - } - }) - const store = createUIStore() - store.getState().hydratePersistedUI(makePersistedUI()) - - store.getState().recordFeatureInteraction('tasks') - store.getState().recordFeatureInteraction('tasks') - - pending[1]( - makePersistedUI({ - featureInteractions: { - tasks: { firstInteractedAt: 100, interactionCount: 2 } - } - }) - ) - await Promise.resolve() - pending[0]( - makePersistedUI({ - featureInteractions: { - tasks: { firstInteractedAt: 100, interactionCount: 1 } - } - }) - ) - await Promise.resolve() - - expect(store.getState().featureInteractions.tasks).toEqual({ - firstInteractedAt: 100, - interactionCount: 2 - }) - }) -}) diff --git a/src/renderer/src/store/slices/worktrees/create/worktrees-create-base-status.test.ts b/src/renderer/src/store/slices/worktrees/create/worktrees-create-base-status.test.ts deleted file mode 100644 index b725e26451a..00000000000 --- a/src/renderer/src/store/slices/worktrees/create/worktrees-create-base-status.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import 'sonner' -import 'react' -import 'react-dom/server' -import '@/components/ui/tooltip' - -import '../../../../../../shared/workspace-scope' -import { TEST_REPO } from '../../../repos/repo.test-fixture' -import { makeWorktree } from '../worktree.test-fixture' -import { - createTestStore, - mockApi, - resetRemoteRuntimeMocks, - resetWorktreeSliceModuleMemory -} from '../worktrees-slice-test-harness' - -const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) - -vi.mock('sonner', () => ({ - toast: { - warning: vi.fn(), - info: vi.fn(), - success: vi.fn(), - error: vi.fn(), - dismiss: vi.fn() - } -})) - -vi.mock('@/components/worktree-base-fallback-notice', () => ({ - requestWorktreeBaseFallbackNotice -})) - -beforeEach(resetWorktreeSliceModuleMemory) - -describe('createWorktree base status merge', () => { - beforeEach(() => { - vi.clearAllMocks() - resetRemoteRuntimeMocks() - }) - - it('does not overwrite a newer reconcile status with the initial checking status', async () => { - const store = createRepoTestStore() - const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) - mockApi.worktrees.create.mockImplementation(async () => { - store.getState().updateWorktreeBaseStatus({ - repoId: 'repo1', - worktreeId: wt.id, - status: 'drift', - base: 'origin/main', - remote: 'origin', - behind: 2, - recentSubjects: ['new base commit'] - }) - return { - worktree: wt, - initialBaseStatus: { - repoId: 'repo1', - worktreeId: wt.id, - status: 'checking', - base: 'origin/main', - remote: 'origin' - } - } - }) - - await store.getState().createWorktree('repo1', 'feature') - - expect(store.getState().baseStatusByWorktreeId[wt.id]).toMatchObject({ - status: 'drift', - behind: 2 - }) - }) -}) - -function seedRepoHost(store: ReturnType, hostId: ExecutionHostId): void { - store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] }) -} - -function createRepoTestStore(): ReturnType { - const store = createTestStore() - seedRepoHost(store, 'local') - return store -} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-collision-index.test.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-collision-index.test.ts index ee68c20359f..3774f53d0c6 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-collision-index.test.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-collision-index.test.ts @@ -38,18 +38,6 @@ describe('id-keyed worktree projections keep distinct hosts distinct', () => { expect(all).toHaveLength(2) expect(all.map((entry) => entry.hostId)).toEqual(['local', 'ssh:build-box']) }) - - it('still collapses the createWorktree/fetchWorktrees duplicate on one host', () => { - // Same id AND same host: this is the append/replace race the dedup exists for. - const stale: Worktree = { ...localRow, head: 'stale' } - const fresh: Worktree = { ...localRow, head: 'fresh' } - - const all = getIndexedAllWorktrees(byRepo(stale, fresh)) - - expect(all).toHaveLength(1) - // Last wins: fetchWorktrees replaces, so the later entry is the current one. - expect(all[0]?.head).toBe('fresh') - }) }) describe('getWorktreeOnHostFromState refuses a lookup that still matches two copies', () => { From 26810fe4a8a818779126fbd9d76cd3fb6cfd6a2c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 08:29:13 -0800 Subject: [PATCH 010/138] test: prune low-value terminal and workspace coverage (#40413) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 3 - .../runtime-client-events.test.ts | 55 +---------- ...session-tabs-sync-editor-ownership.test.ts | 2 - .../editor-state-worktree-purge-leak.test.ts | 76 --------------- .../tabs-hydration-group-validation.test.ts | 36 ------- .../worktrees/create/sparse-presets.test.ts | 21 ----- ...orktrees-workspace-selection-state.test.ts | 93 ------------------- .../runtime-pane-title-sort-epoch.test.ts | 42 --------- ...erminal-input-activity-store-write.test.ts | 75 --------------- 9 files changed, 1 insertion(+), 402 deletions(-) delete mode 100644 src/renderer/src/store/slices/editor/tabs/editor-state-worktree-purge-leak.test.ts delete mode 100644 src/renderer/src/store/slices/worktrees/session/worktrees-workspace-selection-state.test.ts delete mode 100644 src/renderer/src/store/terminals/terminal-input-activity-store-write.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 3fd8fdf26ed..4763dd10065 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -6901,7 +6901,6 @@ "src/renderer/src/store/slices/editor/git/editor-remote-branch-actions.test.ts": 935, "src/renderer/src/store/slices/editor/hydration/editor-session-duplicate-restore.test.ts": 1931, "src/renderer/src/store/slices/editor/focus/editor-set-active-file-guard.test.ts": 895, - "src/renderer/src/store/slices/editor/tabs/editor-state-worktree-purge-leak.test.ts": 1740, "src/renderer/src/store/slices/editor/tabs/editor-tab-layout-owner.test.ts": 1883, "src/renderer/src/store/slices/editor/tabs/editor-tab-placement.test.ts": 1837, "src/renderer/src/store/slices/editor/tabs/editor-tab-shared-project-owner.test.ts": 954, @@ -7014,7 +7013,6 @@ "src/renderer/src/store/slices/tabs/tabs-unread-and-focus.test.ts": 1801, "src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts": 1866, "src/renderer/src/store/slices/tabs/tabs-reconciliation-empty-groups.test.ts": 1703, - "src/renderer/src/store/terminals/terminal-input-activity-store-write.test.ts": 1726, "src/renderer/src/store/terminals/terminal-layout-pty-ownership.test.ts": 1734, "src/renderer/src/store/terminals/terminal-pane-detach-agent-identity.test.ts": 1627, "src/renderer/src/store/terminals/terminal-pane-detach-agent-retention.test.ts": 2846, @@ -7078,7 +7076,6 @@ "src/renderer/src/store/slices/worktrees/session/worktrees-terminal-reference-origins.test.ts": 1218, "src/renderer/src/store/slices/worktrees/session/worktrees-terminal-reference-source-race.test.ts": 1063, "src/renderer/src/store/slices/workspace-attention/worktrees-unread-state.test.ts": 1704, - "src/renderer/src/store/slices/worktrees/session/worktrees-workspace-selection-state.test.ts": 1170, "src/renderer/src/store/slices/worktrees/host-scoped-row-merges.test.ts": 1805, "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 318, "src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts": 1828, diff --git a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.test.ts b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.test.ts index 409dfaaabf6..fd98f924e25 100644 --- a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { subscribeRuntimeClientEvents } from './runtime-client-events' import { @@ -34,57 +34,4 @@ describe('subscribeRuntimeClientEvents', () => { expect(observed).toEqual(['error']) }) - - describe('when the server asks this desktop to host its browser pages', () => { - async function ask(environment: Record) { - const { subscribe, respond } = recordRuntimeSubscription({ subscriptionId: 'sub-1' }) - const answered = Promise.withResolvers() - const call = vi.fn(async () => { - answered.resolve() - return { ok: true, result: { accepted: true } } - }) - const prepareBrowserClientHostPlacement = vi - .fn() - .mockResolvedValue({ kind: 'client', browserHostClientId: 'host-a' }) - stubRuntimeEnvironmentBridge({ - subscribe, - call, - prepareBrowserClientHostPlacement, - list: vi.fn().mockResolvedValue([{ id: 'env-1', ...environment }]) - }) - await subscribeRuntimeClientEvents('env-1', vi.fn(), undefined, undefined, { - hostsBrowserPages: true - }) - expect(subscribe.mock.calls[0]?.[0]).toMatchObject({ params: { browserHostRequests: true } }) - respond({ ok: true, result: { type: 'browserHostRequested', requestId: 'request-1' } }) - await answered.promise - return { call, prepareBrowserClientHostPlacement } - } - - it('starts hosting and names the lease it now holds', async () => { - const { call, prepareBrowserClientHostPlacement } = await ask({}) - - expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith( - expect.objectContaining({ selector: 'env-1', preference: 'auto' }) - ) - expect(call).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'browser.clientHost.requestResult', - params: { requestId: 'request-1', browserHostClientId: 'host-a' } - }) - ) - }) - - // Managed SSH hosts use the desktop browser bridge, so their open-url must not land here. - it('declines for a host that became managed over SSH without starting a lease', async () => { - const { call, prepareBrowserClientHostPlacement } = await ask({ - orcadDeployment: { sshTargetId: 'target-1' } - }) - - expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() - expect(call).toHaveBeenCalledWith( - expect.objectContaining({ params: { requestId: 'request-1' } }) - ) - }) - }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/placement/web-session-tabs-sync-editor-ownership.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/placement/web-session-tabs-sync-editor-ownership.test.ts index c19333a3d65..3c8f5bda295 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/placement/web-session-tabs-sync-editor-ownership.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/placement/web-session-tabs-sync-editor-ownership.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { RuntimeMobileSessionFileTab } from '../../../../../shared/runtime-mobile-session-tab-contracts' import type { OpenFile } from '../../../store/slices/editor/types/open-file' -import { buildOwnedEditorFileId } from '../../../store/slices/editor/file-ids/editor-file-ids' import { applyWebSessionTabsSnapshot } from '../snapshot-api' import { ENV, @@ -90,7 +89,6 @@ describe('host editor file ownership', () => { expect(patch?.openFiles).toHaveLength(2) expect(patch?.openFiles?.[0]).toBe(other) expect(patch?.openFiles?.[1]).toMatchObject({ - id: buildOwnedEditorFileId(filePath, WT, ENV), runtimeEnvironmentId: ENV }) expect(patch?.openFiles?.[1]?.lastKnownDiskSignature).toBeUndefined() diff --git a/src/renderer/src/store/slices/editor/tabs/editor-state-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/editor/tabs/editor-state-worktree-purge-leak.test.ts deleted file mode 100644 index 3da246c43e4..00000000000 --- a/src/renderer/src/store/slices/editor/tabs/editor-state-worktree-purge-leak.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** Both single/bulk worktree removal must evict editorCursorLine/editorViewMode by file and gitStatusHugeByWorktree; missed keys leak per removed file/worktree for renderer lifetime. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status/aggregation/worktree-agent-status' - -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } -})) - -vi.mock('@/components/terminal-pane/pty-transport/delivery/pty-dispatcher', () => ({})) - -vi.mock('@/components/terminal-pane/pty-transport/shutdown/pty-shutdown-data-suspension', () => ({ - restorePtyDataHandlersAfterFailedShutdown: vi.fn(), - unregisterPtyDataHandlers: vi.fn() -})) - -vi.mock('@/lib/agent-status/aggregation/worktree-agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -const mockApi = { - worktrees: { - remove: vi.fn().mockResolvedValue(undefined), - forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), - updateMeta: vi.fn().mockResolvedValue({}) - }, - pty: { kill: vi.fn().mockResolvedValue(undefined) }, - runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } -} - -// @ts-expect-error -- minimal window.api stub for the store under test -globalThis.window = { api: mockApi } - -import { createTestStore, seedStore } from '../../../testing/app-store-test-fixture' -import { makeWorktree } from '../../worktrees/worktree.test-fixture' -import { makeOpenFile } from '../types/open-file.test-fixture' - -const WT = 'repo1::/path/wt1' -const FILE_A = '/path/wt1/a.ts' -const FILE_B = '/path/wt1/b.ts' - -function seedEditorState(store: ReturnType): void { - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] - }, - openFiles: [ - makeOpenFile({ id: FILE_A, worktreeId: WT }), - makeOpenFile({ id: FILE_B, worktreeId: WT }) - ], - editorCursorLine: { [FILE_A]: 12, [FILE_B]: 4 }, - editorViewMode: { [FILE_A]: 'changes', [FILE_B]: 'edit' }, - gitStatusHugeByWorktree: { [WT]: { limit: 1000 } } - }) -} - -describe('worktree removal purges per-file editor + git-huge state (leak regression)', () => { - beforeEach(() => { - vi.clearAllMocks() - mockApi.worktrees.remove.mockResolvedValue(undefined) - }) - - it('single removeWorktree drops editor + git-huge state for the removed worktree', async () => { - const store = createTestStore() - seedEditorState(store) - - const result = await store.getState().removeWorktree({ id: WT, executionHostId: null }) - expect(result).toEqual({ ok: true }) - - const s = store.getState() - expect(s.editorCursorLine[FILE_A]).toBeUndefined() - expect(s.editorCursorLine[FILE_B]).toBeUndefined() - expect(s.editorViewMode[FILE_A]).toBeUndefined() - expect(s.gitStatusHugeByWorktree[WT]).toBeUndefined() - }) -}) diff --git a/src/renderer/src/store/slices/tabs/tabs-hydration-group-validation.test.ts b/src/renderer/src/store/slices/tabs/tabs-hydration-group-validation.test.ts index 73329650910..9a18c255f8d 100644 --- a/src/renderer/src/store/slices/tabs/tabs-hydration-group-validation.test.ts +++ b/src/renderer/src/store/slices/tabs/tabs-hydration-group-validation.test.ts @@ -57,40 +57,4 @@ describe('buildHydratedTabState referential repair', () => { groups[0].id ]) }) - - it('collapses a persisted layout that repeats one group across leaves', () => { - const result = buildHydratedTabState( - { - ...makeBaseSession(), - unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, - tabGroups: { - w1: [ - { id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }, - { id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] } - ] - }, - tabGroupLayouts: { - w1: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', groupId: 'g1' }, - second: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', groupId: 'g1' }, - second: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', groupId: 'g2' }, - second: { type: 'leaf', groupId: 'g1' } - } - } - } - } - }, - new Set(['w1']) - ) - - expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) - }) }) diff --git a/src/renderer/src/store/slices/worktrees/create/sparse-presets.test.ts b/src/renderer/src/store/slices/worktrees/create/sparse-presets.test.ts index d737766da9d..08e3ce01c72 100644 --- a/src/renderer/src/store/slices/worktrees/create/sparse-presets.test.ts +++ b/src/renderer/src/store/slices/worktrees/create/sparse-presets.test.ts @@ -75,25 +75,4 @@ describe('createSparsePresetsSlice', () => { ]) expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded') }) - - it('does not save or synthesize a repo bucket when presets fail to load first', async () => { - const store = createTestStore() - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed')) - - try { - const saved = await store.getState().saveSparsePreset({ - repoId: 'repo-1', - name: 'Api', - directories: ['packages/api'] - }) - - expect(saved).toBeNull() - expect(mockApi.sparsePresets.save).not.toHaveBeenCalled() - expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined() - expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error') - } finally { - consoleError.mockRestore() - } - }) }) diff --git a/src/renderer/src/store/slices/worktrees/session/worktrees-workspace-selection-state.test.ts b/src/renderer/src/store/slices/worktrees/session/worktrees-workspace-selection-state.test.ts deleted file mode 100644 index cecace91afd..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/worktrees-workspace-selection-state.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '../../../types' -import { - registerPersistentWebview, - unregisterPersistentWebview -} from '../../../../components/browser-pane/host-guest/webview-registry' -import '../../../../../../shared/workspace-scope' - -import { createWebview } from '../../browser/browser-webview.test-fixture' -import { makeWorktree } from '../worktree.test-fixture' -import { - createTestStore, - mockApi, - resetRemoteRuntimeMocks, - resetWorktreeSliceModuleMemory -} from '../worktrees-slice-test-harness' - -const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) - -vi.mock('sonner', () => ({ - toast: { - warning: vi.fn(), - info: vi.fn(), - success: vi.fn(), - error: vi.fn(), - dismiss: vi.fn() - } -})) - -vi.mock('@/components/worktree-base-fallback-notice', () => ({ - requestWorktreeBaseFallbackNotice -})) - -beforeEach(resetWorktreeSliceModuleMemory) - -describe('setActiveWorktree focus handling', () => { - beforeEach(() => { - vi.clearAllMocks() - resetRemoteRuntimeMocks() - mockApi.ephemeralVm.cancelProvision.mockResolvedValue({ cancelled: true }) - mockApi.ephemeralVm.cleanup.mockResolvedValue({}) - }) - - it('moves focus out of a registered webview before switching worktrees', () => { - const store = createTestStore() - const current = makeWorktree({ id: 'repo1::/path/current', repoId: 'repo1' }) - const next = makeWorktree({ id: 'repo1::/path/next', repoId: 'repo1' }) - const webview = createWebview() - const focusRenderer = vi.fn(() => { - expect(store.getState().activeWorktreeId).toBe(current.id) - }) - const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') - const testWindow = globalThis.window as unknown as { focus?: () => void } - const previousFocus = testWindow.focus - - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { activeElement: webview } - }) - testWindow.focus = focusRenderer - registerPersistentWebview('page-1', webview) - - try { - store.setState({ - worktreesByRepo: { repo1: [current, next] }, - activeWorktreeId: current.id, - reconcileWorktreeTabModel: vi.fn(() => ({ - activeRenderableTabId: null, - renderableTabCount: 0 - })), - refreshGitHubForWorktreeIfStale: vi.fn() - } as unknown as Partial) - - store.getState().setActiveWorktree(next.id) - - expect(webview.blur).toHaveBeenCalledTimes(1) - expect(focusRenderer).toHaveBeenCalledTimes(1) - expect(store.getState().activeWorktreeId).toBe(next.id) - } finally { - unregisterPersistentWebview('page-1') - if (previousDocument) { - Object.defineProperty(globalThis, 'document', previousDocument) - } else { - delete (globalThis as unknown as { document?: unknown }).document - } - if (previousFocus) { - testWindow.focus = previousFocus - } else { - delete testWindow.focus - } - } - }) -}) diff --git a/src/renderer/src/store/terminals/runtime-pane-title-sort-epoch.test.ts b/src/renderer/src/store/terminals/runtime-pane-title-sort-epoch.test.ts index 94d82c90782..16c7fae2e04 100644 --- a/src/renderer/src/store/terminals/runtime-pane-title-sort-epoch.test.ts +++ b/src/renderer/src/store/terminals/runtime-pane-title-sort-epoch.test.ts @@ -44,46 +44,4 @@ describe('runtimePaneTitle → sortEpoch', () => { expect(store.getState().tabsByWorktree['wt-bg']?.[0]?.title).toBe('⠂ Claude Code') expect(store.getState().runtimePaneTitlesByTabId['tab-1']?.[1]).toBe('⠂ Claude Code') }) - - it('updates titles in their corresponding worktrees', () => { - const store = createTestStore() - const tabCount = 2 - const worktrees = Array.from({ length: tabCount }, (_, index) => - makeWorktree({ id: `wt-${index}`, repoId: 'repo1', path: `/path/wt-${index}` }) - ) - seedStore(store, { - worktreesByRepo: { repo1: worktrees }, - tabsByWorktree: Object.fromEntries( - worktrees.map((worktree, index) => [ - worktree.id, - [makeTab({ id: `tab-${index}`, worktreeId: worktree.id })] - ]) - ) - }) - store.getState().updateTabTitles( - worktrees.map((_, index) => ({ - tabId: `tab-${index}`, - title: `Codex ready ${index}` - })) - ) - - expect(store.getState().tabsByWorktree['wt-0']?.[0]?.title).toBe('Codex ready 0') - expect(store.getState().tabsByWorktree['wt-1']?.[0]?.title).toBe('Codex ready 1') - }) - - it('clears an unclassified runtime pane title', () => { - const store = createTestStore() - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: 'wt-bg', repoId: 'repo1', path: '/path/wt-bg' })] - }, - tabsByWorktree: { - 'wt-bg': [makeTab({ id: 'tab-1', worktreeId: 'wt-bg' })] - } - }) - store.getState().setRuntimePaneTitle('tab-1', 1, 'shell prompt') - store.getState().clearRuntimePaneTitle('tab-1', 1) - - expect(store.getState().runtimePaneTitlesByTabId['tab-1']).toBeUndefined() - }) }) diff --git a/src/renderer/src/store/terminals/terminal-input-activity-store-write.test.ts b/src/renderer/src/store/terminals/terminal-input-activity-store-write.test.ts deleted file mode 100644 index 276e79f5519..00000000000 --- a/src/renderer/src/store/terminals/terminal-input-activity-store-write.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * `recordTerminalInput` used to run one `set()` per keystroke, waking every zustand - * subscriber in the store. It is now coalesced: leading-edge write, then one trailing - * flush per window. These tests pin the properties that make that safe. - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status/aggregation/worktree-agent-status' - -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } -})) - -vi.mock('@/components/terminal-pane/pty-transport/delivery/pty-dispatcher', () => ({})) - -vi.mock('@/components/terminal-pane/pty-transport/shutdown/pty-shutdown-data-suspension', () => ({ - restorePtyDataHandlersAfterFailedShutdown: vi.fn(), - unregisterPtyDataHandlers: vi.fn() -})) - -vi.mock('@/lib/agent-status/aggregation/worktree-agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -// @ts-expect-error -- minimal window.api stub for the store under test -globalThis.window = { api: { pty: { kill: vi.fn().mockResolvedValue(undefined) } } } - -import { - TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS, - flushTerminalInputActivity, - mergePendingTerminalInputActivity, - readLastTerminalInputAt, - resetTerminalInputActivityCoalescingForTests -} from '@/lib/terminal-input-activity-coalescing' -import { createTestStore } from '../testing/app-store-test-fixture' - -const PANE = 'tab-1:leaf-a' - -describe('recordTerminalInput store writes are coalesced', () => { - beforeEach(() => { - vi.useFakeTimers() - resetTerminalInputActivityCoalescingForTests() - }) - - it('keeps the freshest stamp visible to imperative readers before the flush', () => { - const store = createTestStore() - - store.getState().recordTerminalInput(PANE, 1_000) - store.getState().recordTerminalInput(PANE, 1_200) - - const stored = store.getState().lastTerminalInputAtByPaneKey - expect(stored[PANE]).toBe(1_000) - expect(readLastTerminalInputAt(stored, PANE)).toBe(1_200) - expect(mergePendingTerminalInputActivity(stored)[PANE]).toBe(1_200) - - vi.advanceTimersByTime(TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS + 1) - expect(store.getState().lastTerminalInputAtByPaneKey[PANE]).toBe(1_200) - }) - - it('does not resurrect a pane key a purge deleted', () => { - const store = createTestStore() - - store.getState().recordTerminalInput(PANE, 1_000) - store.getState().recordTerminalInput(PANE, 1_200) - - // Teardown path (worktree purge / pane close) rewrites the map without the key. - store.setState({ lastTerminalInputAtByPaneKey: {} }) - flushTerminalInputActivity() - - const stored = store.getState().lastTerminalInputAtByPaneKey - expect(stored[PANE]).toBeUndefined() - expect(readLastTerminalInputAt(stored, PANE)).toBeUndefined() - expect(mergePendingTerminalInputActivity(stored)[PANE]).toBeUndefined() - }) -}) From 0b8e94aa5cc12d70086e0fa4354b5f1ea17312be Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:37:03 +0000 Subject: [PATCH 011/138] feat(speech): run GGUF models on transcribe-cpp with a CPU fallback (#39326) * feat(speech): run GGUF models on transcribe-cpp, falling back to the CPU The speech host child can now load a second engine. A model whose files are GGUF runs on transcribe-cpp (Metal on Apple silicon, Vulkan on Windows and Linux, CPU otherwise); everything else stays on sherpa-onnx, which remains the only engine on Intel Macs. A GPU that fails to load, errors, stalls for 25 s or crashes the process moves the dictation to a CPU-only process (TRANSCRIBE_BACKENDS=cpu) that replays the audio the GPU had not decoded yet, so no words are lost or repeated. A GPU that crashed or would not load is remembered per transcribe-cpp version and OS release, and voice.speechGpu = 'off' keeps it on the CPU. Packaging copies transcribe-cpp, koffi and the target's native package into resources/node_modules outside asar, fails when a target's package is missing, and ships the MSVC runtime beside transcribe.dll on Windows. No catalog model is GGUF yet, so nothing runs on transcribe-cpp until the catalog change that follows. * fix(speech): keep the speech queue running after a failed task, and drop audio frames after a discard * fix(speech): keep the engine's file reads off the main thread's hot paths The engine lookup and the GPU failure marker are read once per process instead of on every dictation start and mobile catalog request, the marker is written asynchronously, and the child reads the hotwords file with fs/promises. The baseline moves the existing engine presence checks from stt-worker-paths to the shared package table and adds the once-per-process marker reads. --- config/electron-builder.config.cjs | 12 +- config/main-sync-fs-baseline.txt | 3 +- config/packaged-runtime-node-modules.cjs | 4 + config/packaged-transcribe-cpp.cjs | 97 +++++++ package.json | 7 +- pnpm-lock.yaml | 259 ++++++++++++++++++ pnpm-workspace.yaml | 2 + .../runtime/serve-speech-recognizer.test.ts | 42 ++- src/cli/runtime/serve-speech-recognizer.ts | 15 +- src/main/host/electron-speech-services.ts | 7 +- src/main/orcad/orcad-speech-services.ts | 7 +- .../runtime-mobile-dictation-controller.ts | 2 +- .../speech/runtime-mobile-speech-catalog.ts | 6 +- src/main/speech/speech-runtime-service.ts | 8 +- src/main/speech/stt-gpu-failure-marker.ts | 62 +++++ src/main/speech/stt-recognizer.ts | 23 ++ src/main/speech/stt-session-prewarm.ts | 3 +- src/main/speech/stt-session-start.ts | 12 +- src/main/speech/stt-sherpa-recognizer.ts | 149 ++++++++++ .../speech/stt-transcribe-cpp-recognizer.ts | 119 ++++++++ .../speech/stt-worker-cpu-fallback.test.ts | 151 ++++++++++ src/main/speech/stt-worker-cpu-fallback.ts | 243 ++++++++++++++++ src/main/speech/stt-worker-forked-process.ts | 5 +- src/main/speech/stt-worker-paths.ts | 50 ++-- src/main/speech/stt-worker-process.ts | 14 +- src/main/speech/stt-worker-startup.ts | 16 +- src/main/speech/stt-worker-utility-process.ts | 6 +- src/main/speech/stt-worker.ts | 214 ++++----------- src/shared/speech-model-engine.ts | 6 + src/shared/speech-recognizer-package.ts | 31 ++- src/shared/speech-types.ts | 4 + src/shared/transcribe-cpp-packages.json | 6 + 32 files changed, 1340 insertions(+), 245 deletions(-) create mode 100644 config/packaged-transcribe-cpp.cjs create mode 100644 src/main/speech/stt-gpu-failure-marker.ts create mode 100644 src/main/speech/stt-recognizer.ts create mode 100644 src/main/speech/stt-sherpa-recognizer.ts create mode 100644 src/main/speech/stt-transcribe-cpp-recognizer.ts create mode 100644 src/main/speech/stt-worker-cpu-fallback.test.ts create mode 100644 src/main/speech/stt-worker-cpu-fallback.ts create mode 100644 src/shared/speech-model-engine.ts create mode 100644 src/shared/transcribe-cpp-packages.json diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 1be4f4201c4..fc33b98fec6 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -20,9 +20,12 @@ const { assertPackagedNativeVariantsInstalled, assertProcessHostOutputBuilt, createPackagedRuntimeNodeModuleResources, + normalizeElectronArchitecture, prunePackagedRuntimeNodeModules, + readPackage, verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') +const { copyPackagedTranscribeCpp } = require('./packaged-transcribe-cpp.cjs') const { assertNoWindowsExecutablesInRuntimeNodeModules } = require('./packaged-windows-only-runtime-files.cjs') @@ -330,7 +333,7 @@ module.exports = { // extraResources already ships plugin bytes; exclude source duplicates. '!resources/plugins/launch/**', // Platform extraResources selects speech natives; asar would duplicate it and ship other variants. - '!node_modules/sherpa-onnx*{,/**/*}', + '!node_modules/{sherpa-onnx*,transcribe-cpp,@transcribe-cpp,koffi,@koromix}{,/**/*}', // CLI shim belongs beside resources/bin/orca.exe; unpacking source creates a second .cmd without its executable (#7351). '!resources/win32{,/**/*}' ], @@ -450,6 +453,13 @@ module.exports = { writeMacBuildCompatibility(resourcesDir, { version, commit, architecture }) } stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version) + // Before the prune, which drops the copied packages' type declarations too. + copyPackagedTranscribeCpp( + readPackage, + resourcesDir, + context.electronPlatformName, + normalizeElectronArchitecture(context.arch) + ) prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) assertNoWindowsExecutablesInRuntimeNodeModules(resourcesDir, context.electronPlatformName) // Gate native glibc floor after pruning foreign CPU variants; GLIBC_2.34 once broke Ubuntu 20.04 startup (#9902). diff --git a/config/main-sync-fs-baseline.txt b/config/main-sync-fs-baseline.txt index 45c12030430..2b02ee27c41 100644 --- a/config/main-sync-fs-baseline.txt +++ b/config/main-sync-fs-baseline.txt @@ -497,8 +497,8 @@ 2 src/main/speech/speech-model-download-cleanup.ts 1 src/main/speech/speech-model-download-transport.ts 2 src/main/speech/speech-model-http-download.ts +2 src/main/speech/stt-gpu-failure-marker.ts 1 src/main/speech/stt-worker-model-config.ts -2 src/main/speech/stt-worker-paths.ts 2 src/main/sqlite/harden-database-files.ts 1 src/main/sqlite/row-cache/keyed-json-row-cache.ts 2 src/main/sqlite/row-cache/legacy-json-source.ts @@ -609,6 +609,7 @@ 7 src/shared/serve-bundle-park.ts 1 src/shared/serve-user-data-path.ts 3 src/shared/source-scan/source-tree-scan.ts +3 src/shared/speech-recognizer-package.ts 5 src/shared/startup-command-staging.ts 1 src/shared/terminal/pty/native-process/pty-process-ceiling-state.ts 8 src/shared/windows-breakaway-launcher.ts diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index a5a9ed3bb33..e2372b8e99d 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -15,6 +15,7 @@ const { } = require('./scripts/native-build/windows/windows-pe-machine.cjs') const { adoptPackagedNativeBuildEntries } = require('./packaged-native-build-entries.cjs') const { runtimePackageExcludeFilter } = require('./packaged-windows-only-runtime-files.cjs') +const { missingTranscribeCppPackages } = require('./packaged-transcribe-cpp.cjs') const { NATIVE_BUILD_PACKAGES, TRANSIENT_NATIVE_BUILD_ENTRY_FILTER @@ -567,6 +568,7 @@ function assertPackagedNativeVariantsInstalled(electronPlatformName, electronArc if (sherpaName in rootOptionalDependencies && !isInstalled(sherpaName)) { missing.push(sherpaName) } + missing.push(...missingTranscribeCppPackages(readPackage, electronPlatformName, architecture)) // Why prefix, not equality: linux variants carry a libc suffix (watcher-linux-x64-glibc), // mirroring what prunePackagedParcelWatcher keeps. @@ -656,6 +658,7 @@ module.exports = { createPackagedRuntimeNodeModuleResources, findAsarEntry, isPackagedExternalSpecifier, + normalizeElectronArchitecture, normalizeNodePtyWindowsArch, packageNameFromSpecifier, prunePackagedNodePty, @@ -664,5 +667,6 @@ module.exports = { prunePackagedRuntimeTypeAndSourceMapArtifacts, prunePackagedSherpaOnnx, prunePackagedZodSources, + readPackage, verifyPackagedMainRuntimeDeps } diff --git a/config/packaged-transcribe-cpp.cjs b/config/packaged-transcribe-cpp.cjs new file mode 100644 index 00000000000..2fe3334a712 --- /dev/null +++ b/config/packaged-transcribe-cpp.cjs @@ -0,0 +1,97 @@ +const { cpSync, existsSync } = require('node:fs') +const { execFileSync } = require('node:child_process') +const { join } = require('node:path') + +const TRANSCRIBE_CPP_PACKAGES = require('../src/shared/transcribe-cpp-packages.json') +// transcribe.dll needs MSVC runtime >= 14.40 (older ones crash in std::mutex); the system copy may be older. +const WINDOWS_CRT_DLLS = ['msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll'] +const MIN_WINDOWS_CRT_VERSION = [14, 40] + +/** + * The packages the speech host loads transcribe-cpp from, in load order: the ESM binding, koffi + * (its FFI), koffi's prebuilt addon, and the target's native libraries. Each resolves from the + * previous one's directory, as Node does at runtime. Null where the target ships no transcribe-cpp. + */ +function transcribeCppRuntimePackages(readPackage, electronPlatformName, architecture) { + const native = TRANSCRIBE_CPP_PACKAGES[`${electronPlatformName}-${architecture}`] + if (!native) { + return null + } + const binding = readPackage('transcribe-cpp') + const koffi = readPackage('koffi', binding.packageDir) + const koffiAddon = readPackage( + `@koromix/koffi-${electronPlatformName}-${architecture}`, + koffi.packageDir + ) + const nativeLibraries = readPackage(native, binding.packageDir) + return [binding, koffi, koffiAddon, nativeLibraries] +} + +function readFileVersion(dll) { + const version = execFileSync( + 'powershell.exe', + ['-NoProfile', '-Command', `(Get-Item -LiteralPath '${dll}').VersionInfo.FileVersion`], + { encoding: 'utf8' } + ).trim() + return version.split(/[.,\s]+/).map((part) => Number.parseInt(part, 10)) +} + +function copyWindowsCrt(nativeDir) { + const systemDir = join(process.env.SystemRoot || 'C:\\Windows', 'System32') + for (const name of WINDOWS_CRT_DLLS) { + const source = join(systemDir, name) + if (!existsSync(source)) { + throw new Error( + `Packaging transcribe-cpp needs ${source} (Visual C++ 2015-2022 x64 runtime).` + ) + } + const [major, minor] = readFileVersion(source) + if (!Number.isFinite(major) || !Number.isFinite(minor)) { + throw new Error(`Cannot read the file version of ${source}.`) + } + const [minMajor, minMinor] = MIN_WINDOWS_CRT_VERSION + if (major < minMajor || (major === minMajor && minor < minMinor)) { + throw new Error( + `${source} is ${major}.${minor}; transcribe-cpp needs ${MIN_WINDOWS_CRT_VERSION.join('.')}+. ` + + 'Install the latest Visual C++ 2015-2022 x64 redistributable on the build host.' + ) + } + // App-local beside transcribe.dll, which loads its dependencies from its own directory. + cpSync(source, join(nativeDir, name)) + } +} + +/** Copy transcribe-cpp into the slice's resources/node_modules, outside asar where koffi can dlopen it. */ +function copyPackagedTranscribeCpp(readPackage, resourcesDir, electronPlatformName, architecture) { + // beforePack's assertPackagedNativeVariantsInstalled already failed any real build missing these; + // only an afterPack driven without it (packaging tests) gets here without them. + if (missingTranscribeCppPackages(readPackage, electronPlatformName, architecture).length > 0) { + return + } + const packages = transcribeCppRuntimePackages(readPackage, electronPlatformName, architecture) + if (!packages) { + return + } + for (const { name, packageDir } of packages) { + const target = join(resourcesDir, 'node_modules', ...name.split('/')) + cpSync(packageDir, target, { recursive: true, dereference: true }) + } + if (electronPlatformName === 'win32') { + copyWindowsCrt(join(resourcesDir, 'node_modules', ...packages[3].name.split('/'))) + } +} + +/** The transcribe-cpp packages a target needs that this install lacks (install:release adds other CPUs). */ +function missingTranscribeCppPackages(readPackage, electronPlatformName, architecture) { + try { + transcribeCppRuntimePackages(readPackage, electronPlatformName, architecture) + return [] + } catch (error) { + return [String(error.message ?? error)] + } +} + +module.exports = { + copyPackagedTranscribeCpp, + missingTranscribeCppPackages +} diff --git a/package.json b/package.json index ef3a28bdc7d..e76136786a1 100644 --- a/package.json +++ b/package.json @@ -392,12 +392,17 @@ "optionalDependencies": { "@orca/windows-registry": "workspace:*", "@orca/windows-security": "workspace:*", + "@transcribe-cpp/darwin-arm64-metal": "0.4.1", + "@transcribe-cpp/linux-arm64-cpu-vulkan": "0.4.1", + "@transcribe-cpp/linux-x64-cpu-vulkan": "0.4.1", + "@transcribe-cpp/win32-x64-cpu-vulkan": "0.4.1", "@vscode/windows-process-tree": "0.8.0", "sherpa-onnx-darwin-arm64": "1.12.37", "sherpa-onnx-darwin-x64": "1.12.37", "sherpa-onnx-linux-arm64": "1.12.37", "sherpa-onnx-linux-x64": "1.12.37", - "sherpa-onnx-win-x64": "1.12.37" + "sherpa-onnx-win-x64": "1.12.37", + "transcribe-cpp": "0.4.1" }, "lint-staged": { "{*.{ts,tsx,js,jsx,mjs,mts,cts},!(cloud)/**/*.{ts,tsx,js,jsx,mjs,mts,cts}}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 228db2cb82d..3051c0d9d1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,6 +702,18 @@ importers: '@orca/windows-security': specifier: workspace:* version: link:native/windows-security + '@transcribe-cpp/darwin-arm64-metal': + specifier: 0.4.1 + version: 0.4.1 + '@transcribe-cpp/linux-arm64-cpu-vulkan': + specifier: 0.4.1 + version: 0.4.1 + '@transcribe-cpp/linux-x64-cpu-vulkan': + specifier: 0.4.1 + version: 0.4.1 + '@transcribe-cpp/win32-x64-cpu-vulkan': + specifier: 0.4.1 + version: 0.4.1 '@vscode/windows-process-tree': specifier: 0.8.0 version: 0.8.0(patch_hash=a09f2e8b2b4e73ef20c317af91f9e06c09abf62fa2c891d99b16f53047f596fd) @@ -720,6 +732,9 @@ importers: sherpa-onnx-win-x64: specifier: 1.12.37 version: 1.12.37 + transcribe-cpp: + specifier: 0.4.1 + version: 0.4.1 native/windows-registry: dependencies: @@ -1475,6 +1490,106 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@koromix/koffi-android-arm64@3.3.6': + resolution: {integrity: sha512-wxsPuEMDrqOR5RUdABYFNDTabhh/xwDetCUDI+/CN93QF1T7Yeq218kvDWmYT/g1wV2kU1aYdRO4D7cH9PDskw==} + cpu: [arm64] + os: [android] + + '@koromix/koffi-android-x64@3.3.6': + resolution: {integrity: sha512-fHZE6ejYLmAEe2eou2uoG61HUtolY45RhQEsTKAKNI5eKSW7Z2Jh8eP5EE+3CVQSbzbaMeHXRTcgEQGk5uEF5Q==} + cpu: [x64] + os: [android] + + '@koromix/koffi-darwin-arm64@3.3.6': + resolution: {integrity: sha512-++V4qscXff/Ya5cE6vOPOomcxk3T5JQu81Uc0fvoso3VVELcoHt2/dz9E0FY0EPyQNIRcWOVg7CgvsPCKId9lg==} + cpu: [arm64] + os: [darwin] + + '@koromix/koffi-darwin-x64@3.3.6': + resolution: {integrity: sha512-eOyit0eyfkmdXCNlOkG8cNtpQUx+dikr1QsozJdwm7/zH1u7KP5dRtmsaGUMLSUY7cVYSRTUStkwHBrHjBtuzw==} + cpu: [x64] + os: [darwin] + + '@koromix/koffi-freebsd-arm64@3.3.6': + resolution: {integrity: sha512-7teGsq2BukkLZ60xxq4KyPxeJct51nV1G/MfNj8IoMTqlpd35w4aiIIoFWVwLL3j3DdT95Ye/FpVY4C7ZEuMHg==} + cpu: [arm64] + os: [freebsd] + + '@koromix/koffi-freebsd-ia32@3.3.6': + resolution: {integrity: sha512-WA7qEAMxTWS2WSFDceETVJTFrZgRwNvfG+FJpCC2XkJXftHwOhv8/X6d5HUEXh361sd7HwCHoT1wypL0nvI7Mw==} + cpu: [ia32] + os: [freebsd] + + '@koromix/koffi-freebsd-x64@3.3.6': + resolution: {integrity: sha512-OQ/eBhGefbtxcPs5VDkK9mwxoIeSwrIriOT1/JHHA/tbNAXdC0GiAYsBw82T4L4r2u0TuJ0CbgN3HrulzdPPig==} + cpu: [x64] + os: [freebsd] + + '@koromix/koffi-linux-arm64@3.3.6': + resolution: {integrity: sha512-8LuJcdH74wTg7JcZjnRnb3z+qWPKcEDSv8HRPkWTVD56TFm0CXZXgl/WJ4maDbWem6ZoXxc87a8nQEA+wABzNA==} + cpu: [arm64] + os: [linux] + + '@koromix/koffi-linux-arm@3.3.6': + resolution: {integrity: sha512-0uo15z9L7GsAEGvPCVKcXzCtRJCyghCYDrWO2L3vXk4G5lgk+OBvWS7RmlDZ99X/A0oDiL3vLCplGaBCEZZ8Eg==} + cpu: [arm] + os: [linux] + + '@koromix/koffi-linux-ia32@3.3.6': + resolution: {integrity: sha512-aTOjTMdy2Q6JqNA9KkT2OaCs77d0TzEG5KipfinzDyiV9DkYyUCvFykIY5GDHCUKXyJi+xy1IfuUxeL64wzzBQ==} + cpu: [ia32] + os: [linux] + + '@koromix/koffi-linux-loong64@3.3.6': + resolution: {integrity: sha512-m33Zjrhj2Atpkcr0We61V2Zjm8wFb79QOtaOfzttmNFQMtryYpWZ4CBxx4gLKvcrR7NcK3GV/D9Qi2M0ppWK7A==} + cpu: [loong64] + os: [linux] + + '@koromix/koffi-linux-ppc64@3.3.6': + resolution: {integrity: sha512-L1+1cjH5GtNtv7TYWy469jsDberOOwCfE96h0AWFKrVHycDn8hTKH5fEA/er42R04lZiNWbiC0+sMb50enYRGw==} + cpu: [ppc64] + os: [linux] + + '@koromix/koffi-linux-riscv64@3.3.6': + resolution: {integrity: sha512-VnzoNRg3MfTdGXLDtUrBj4wJnZD3buX6LajGrvYm5Mf1U132bzQwxP1ryZhs8Q0Cl1pTsN2fIovjNwpwE7Hs3A==} + cpu: [riscv64] + os: [linux] + + '@koromix/koffi-linux-x64@3.3.6': + resolution: {integrity: sha512-RTRbVTZ1rf1GFowSsbuU2acYoRq66ZwJGh+rTgau1Cat3muv2884p0FRH6UKfwlgWkpjIHJ7N8N0liM7Oe2eAQ==} + cpu: [x64] + os: [linux] + + '@koromix/koffi-openbsd-arm64@3.3.6': + resolution: {integrity: sha512-eXHyRZw/BACrBIuntkqlNTa8p8i9KobtREJjhxldsVoEPRQTREnhBq5IbqbvXesY89MwFGn4lHmzG1x5ueV63A==} + cpu: [arm64] + os: [openbsd] + + '@koromix/koffi-openbsd-ia32@3.3.6': + resolution: {integrity: sha512-xbJYOFY0IAIx3nyNdYad5g2f8cNAOY8OBcjPDYRlc7gkqQiPNER9ZA9g+M2LuVf48i65ZCn11XaWDSQymDJJBg==} + cpu: [ia32] + os: [openbsd] + + '@koromix/koffi-openbsd-x64@3.3.6': + resolution: {integrity: sha512-bNL4uIxUBhA93naaL3dWwXdLgQGAss6krDbwljO1uS3iJ+8K7KSo8EGbJfRYUf1L+ctaHTJuDwgZ1XTSEC9FQQ==} + cpu: [x64] + os: [openbsd] + + '@koromix/koffi-win32-arm64@3.3.6': + resolution: {integrity: sha512-WDVsrvWAaHbA7xiCMXX3PBmfDf4+VnfY67ryRAW86QNW8109W9WPS3YVGGD2YoPswDmSZsMBVzzZCEFUrLdJuA==} + cpu: [arm64] + os: [win32] + + '@koromix/koffi-win32-ia32@3.3.6': + resolution: {integrity: sha512-6hwsUGC7FCICiLdmEQwTK0+MOLmuraqWuVsUCBkdSCL6k62LQLScuTn2dzONFvN/H5EXq0zzX4ddgtq2abL/xQ==} + cpu: [ia32] + os: [win32] + + '@koromix/koffi-win32-x64@3.3.6': + resolution: {integrity: sha512-g9KC6VKQLe88Tjgeubk3yhu0CjtqSeRq4XjWFWxUq4rsP0/wfLUBoeISvG60HoPmhYQp+U7hVd8Ms+amOMHDvA==} + cpu: [x64] + os: [win32] + '@linear/sdk@97.0.0': resolution: {integrity: sha512-ntgZI06I37DTp/XULYTPbOg1t7vJRo72JRSkMjdgG+GbUEHdSkYsUjN0dR42go/2LvLRKWJD3jVehm0wE5haEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4056,6 +4171,33 @@ packages: '@tiptap/starter-kit@3.31.3': resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} + '@transcribe-cpp/darwin-arm64-metal@0.4.1': + resolution: {integrity: sha512-Sg8obJiDeBxSWQvFcHxlVJoY5X+BzMFMNH+5LU5oVfj8iHJ0dj0uVlBUrNIN6D2xCBksoLVOEMCbasqUXv/DLw==} + cpu: [arm64] + os: [darwin] + + '@transcribe-cpp/darwin-x64-cpu@0.4.1': + resolution: {integrity: sha512-lRP/Mr8msBMUHXkLPtGZoVhNXgxdTV3tzEmpLki0VeBP3LraPC73m9PjfhzoYNEwXEnpqdLwNaTnuCHOwJRugw==} + cpu: [x64] + os: [darwin] + + '@transcribe-cpp/linux-arm64-cpu-vulkan@0.4.1': + resolution: {integrity: sha512-b010Fhfnqe4jGJLMhvx6LucvysxK638qY2eww1oaaErzkAKutdN0uRx84vsfXVxVr3lKlzdL5vq6Zv/QfAArTA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@transcribe-cpp/linux-x64-cpu-vulkan@0.4.1': + resolution: {integrity: sha512-AuiE8PSFbDndLJDpt8lilZIyg2syQSRBmZQWklx0rd9vF1M/Wfn4StKUXTGC4rouQOTJPy3ViioGLpyYr6unaw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@transcribe-cpp/win32-x64-cpu-vulkan@0.4.1': + resolution: {integrity: sha512-0W8yQ6SgmPKgVaLHVGkj5gr2uzMS9mAVHMCJKZCFdhvQxeQlUbrvN/8ooyYD7S5qmLsLNEHt0ecMMVgsoWUu9A==} + cpu: [x64] + os: [win32] + '@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929': resolution: {integrity: sha512-sAirMpaGA9uRQm2W+t4zC7PjnijNuFa4PCSIfLKKo0JyAUCRfnMeev0UGBQ9F3g+9Nz187tvikUs5Irh5+eajA==} engines: {node: '>=16.20.0'} @@ -6147,6 +6289,9 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + koffi@3.3.6: + resolution: {integrity: sha512-pPqCZg8o4laLvzxySGEju/T1Fd+eCJTVnJmcH6h1u98HFRoBQ9EokNuBSj3NxIQdzkEPRxPnHKjhBVkLeB+X0g==} + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -7806,6 +7951,10 @@ packages: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} + transcribe-cpp@0.4.1: + resolution: {integrity: sha512-6+cemagaGsOC8X3XWPCn7Ek+jjwyH2/NdTyBcEH7FtFjC3Ciyih2Xx6/GSRirZolxmxENtbvfwu1WV5RzutgCw==} + engines: {node: '>=22'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -9025,6 +9174,66 @@ snapshots: '@keyv/serialize@1.1.1': optional: true + '@koromix/koffi-android-arm64@3.3.6': + optional: true + + '@koromix/koffi-android-x64@3.3.6': + optional: true + + '@koromix/koffi-darwin-arm64@3.3.6': + optional: true + + '@koromix/koffi-darwin-x64@3.3.6': + optional: true + + '@koromix/koffi-freebsd-arm64@3.3.6': + optional: true + + '@koromix/koffi-freebsd-ia32@3.3.6': + optional: true + + '@koromix/koffi-freebsd-x64@3.3.6': + optional: true + + '@koromix/koffi-linux-arm64@3.3.6': + optional: true + + '@koromix/koffi-linux-arm@3.3.6': + optional: true + + '@koromix/koffi-linux-ia32@3.3.6': + optional: true + + '@koromix/koffi-linux-loong64@3.3.6': + optional: true + + '@koromix/koffi-linux-ppc64@3.3.6': + optional: true + + '@koromix/koffi-linux-riscv64@3.3.6': + optional: true + + '@koromix/koffi-linux-x64@3.3.6': + optional: true + + '@koromix/koffi-openbsd-arm64@3.3.6': + optional: true + + '@koromix/koffi-openbsd-ia32@3.3.6': + optional: true + + '@koromix/koffi-openbsd-x64@3.3.6': + optional: true + + '@koromix/koffi-win32-arm64@3.3.6': + optional: true + + '@koromix/koffi-win32-ia32@3.3.6': + optional: true + + '@koromix/koffi-win32-x64@3.3.6': + optional: true + '@linear/sdk@97.0.0(graphql@16.14.2)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) @@ -10987,6 +11196,21 @@ snapshots: - '@types/tiptap__extensions' - '@types/tiptap__pm' + '@transcribe-cpp/darwin-arm64-metal@0.4.1': + optional: true + + '@transcribe-cpp/darwin-x64-cpu@0.4.1': + optional: true + + '@transcribe-cpp/linux-arm64-cpu-vulkan@0.4.1': + optional: true + + '@transcribe-cpp/linux-x64-cpu-vulkan@0.4.1': + optional: true + + '@transcribe-cpp/win32-x64-cpu-vulkan@0.4.1': + optional: true + '@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929': optional: true @@ -13196,6 +13420,30 @@ snapshots: kleur@3.0.3: {} + koffi@3.3.6: + optionalDependencies: + '@koromix/koffi-android-arm64': 3.3.6 + '@koromix/koffi-android-x64': 3.3.6 + '@koromix/koffi-darwin-arm64': 3.3.6 + '@koromix/koffi-darwin-x64': 3.3.6 + '@koromix/koffi-freebsd-arm64': 3.3.6 + '@koromix/koffi-freebsd-ia32': 3.3.6 + '@koromix/koffi-freebsd-x64': 3.3.6 + '@koromix/koffi-linux-arm': 3.3.6 + '@koromix/koffi-linux-arm64': 3.3.6 + '@koromix/koffi-linux-ia32': 3.3.6 + '@koromix/koffi-linux-loong64': 3.3.6 + '@koromix/koffi-linux-ppc64': 3.3.6 + '@koromix/koffi-linux-riscv64': 3.3.6 + '@koromix/koffi-linux-x64': 3.3.6 + '@koromix/koffi-openbsd-arm64': 3.3.6 + '@koromix/koffi-openbsd-ia32': 3.3.6 + '@koromix/koffi-openbsd-x64': 3.3.6 + '@koromix/koffi-win32-arm64': 3.3.6 + '@koromix/koffi-win32-ia32': 3.3.6 + '@koromix/koffi-win32-x64': 3.3.6 + optional: true + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -15288,6 +15536,17 @@ snapshots: tldts: 7.4.18 optional: true + transcribe-cpp@0.4.1: + dependencies: + koffi: 3.3.6 + optionalDependencies: + '@transcribe-cpp/darwin-arm64-metal': 0.4.1 + '@transcribe-cpp/darwin-x64-cpu': 0.4.1 + '@transcribe-cpp/linux-arm64-cpu-vulkan': 0.4.1 + '@transcribe-cpp/linux-x64-cpu-vulkan': 0.4.1 + '@transcribe-cpp/win32-x64-cpu-vulkan': 0.4.1 + optional: true + trim-lines@3.0.1: {} trough@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ecce07e796..c4e5d8900c5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,6 +61,8 @@ allowBuilds: cpu-features: true electron-winstaller: false esbuild: true + # Its install script only rebuilds when no @koromix/koffi- prebuilt is installed. + koffi: false msw: false node-pty: true ssh2: false diff --git a/src/cli/runtime/serve-speech-recognizer.test.ts b/src/cli/runtime/serve-speech-recognizer.test.ts index 6bd7203fc72..c33fa4892fe 100644 --- a/src/cli/runtime/serve-speech-recognizer.test.ts +++ b/src/cli/runtime/serve-speech-recognizer.test.ts @@ -11,33 +11,49 @@ afterEach(() => { } }) -function resourcesWith(packageName: string): string { +function resourcesWith(files: string[]): string { const resources = mkdtempSync(join(tmpdir(), 'orca-serve-speech-')) roots.push(resources) - const dir = join(resources, 'node_modules', packageName) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'sherpa-onnx.node'), '') + for (const file of files) { + const path = join(resources, 'node_modules', file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + } return resources } -describe("finding the app's speech recognizer (#31174)", () => { - it('finds the copy a packaged app ships in its resources', () => { - const resources = resourcesWith('sherpa-onnx-linux-x64') - expect(findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'linux', 'x64')).toBe( - join(resources, 'node_modules', 'sherpa-onnx-linux-x64') +describe("finding the app's speech engines (#31174)", () => { + it('hands orcad the node_modules a packaged app ships its sherpa recognizer in', () => { + const resources = resourcesWith(['sherpa-onnx-darwin-x64/sherpa-onnx.node']) + expect(findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'darwin', 'x64')).toBe( + join(resources, 'node_modules') ) }) - it('answers nothing when the app ships no recognizer for this platform', () => { - const resources = resourcesWith('sherpa-onnx-linux-arm64') + it('hands orcad the node_modules holding transcribe-cpp and its native package', () => { + const resources = resourcesWith([ + 'transcribe-cpp/package.json', + '@transcribe-cpp/linux-arm64-cpu-vulkan/contract.json' + ]) + expect(findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'linux', 'arm64')).toBe( + join(resources, 'node_modules') + ) + }) + + it('answers nothing when the app ships no engine for this platform', () => { + const resources = resourcesWith([ + 'transcribe-cpp/package.json', + '@transcribe-cpp/linux-arm64-cpu-vulkan/contract.json', + 'sherpa-onnx-linux-arm64/sherpa-onnx.node' + ]) expect( findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'linux', 'x64') ).toBeUndefined() }) // A loaded addon is locked on Windows; orcad holding the app's copy would block app updates. - it('never hands orcad the recognizer on Windows', () => { - const resources = resourcesWith('sherpa-onnx-win-x64') + it('never hands orcad the engines on Windows', () => { + const resources = resourcesWith(['sherpa-onnx-win-x64/sherpa-onnx.node']) expect( findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'win32', 'x64') ).toBeUndefined() diff --git a/src/cli/runtime/serve-speech-recognizer.ts b/src/cli/runtime/serve-speech-recognizer.ts index a0e2679236b..3b5145f94ba 100644 --- a/src/cli/runtime/serve-speech-recognizer.ts +++ b/src/cli/runtime/serve-speech-recognizer.ts @@ -1,10 +1,10 @@ -import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' -import { speechRecognizerPackageName } from '../../shared/speech-recognizer-package' +import { hasSpeechEnginePackage } from '../../shared/speech-recognizer-package' /** - * The installed app's on-device recognizer, for orcad's dictation (#31174). electron-builder puts - * it in `resources/node_modules`, beside the app root; a dev checkout keeps it in node_modules. + * The installed app's node_modules holding its on-device speech engines, for orcad's dictation + * (#31174). electron-builder puts them in `resources/node_modules`, beside the app root; a dev + * checkout keeps them in node_modules. * * Not on Windows: a loaded addon locks its files for the server's life, which would make the next * app update fail to replace them. Windows orcad keeps cloud dictation only. @@ -17,8 +17,9 @@ export function findAppSpeechRecognizerDir( if (platform === 'win32') { return undefined } - const name = speechRecognizerPackageName(platform, arch) - return [join(dirname(appRoot), 'node_modules', name), join(appRoot, 'node_modules', name)].find( - (dir) => existsSync(join(dir, 'sherpa-onnx.node')) + return [join(dirname(appRoot), 'node_modules'), join(appRoot, 'node_modules')].find( + (dir) => + hasSpeechEnginePackage(dir, 'transcribe-cpp', platform, arch) || + hasSpeechEnginePackage(dir, 'sherpa', platform, arch) ) } diff --git a/src/main/host/electron-speech-services.ts b/src/main/host/electron-speech-services.ts index 42985f217ac..ad43bc912e0 100644 --- a/src/main/host/electron-speech-services.ts +++ b/src/main/host/electron-speech-services.ts @@ -1,6 +1,8 @@ import { net } from 'electron' import { ModelManager } from '../speech/model-manager' import { SttService } from '../speech/stt-service' +import { createSttGpuPolicy } from '../speech/stt-gpu-failure-marker' +import { spawnSttWorkerWithCpuFallback } from '../speech/stt-worker-cpu-fallback' import { spawnSttUtilityProcess } from '../speech/stt-worker-utility-process' import type { SpeechServiceFactories } from '../speech/speech-runtime-service' import type { @@ -41,5 +43,8 @@ export const openElectronSpeechDownloadRequest: OpenSpeechDownloadRequest = (url export const electronSpeechServiceFactories: SpeechServiceFactories = { createModelManager: (customModelsDir) => new ModelManager(customModelsDir, openElectronSpeechDownloadRequest), - createSttService: (models) => new SttService(models, spawnSttUtilityProcess) + createSttService: (models, gpuEnabled) => { + const gpu = createSttGpuPolicy(models.getModelsDir(), gpuEnabled) + return new SttService(models, () => spawnSttWorkerWithCpuFallback(spawnSttUtilityProcess, gpu)) + } } diff --git a/src/main/orcad/orcad-speech-services.ts b/src/main/orcad/orcad-speech-services.ts index 9188661c106..c6e9b47ee60 100644 --- a/src/main/orcad/orcad-speech-services.ts +++ b/src/main/orcad/orcad-speech-services.ts @@ -3,6 +3,8 @@ import { openNodeSpeechDownloadRequest } from '../speech/node-speech-download-re import { canReadOpenAiSpeechApiKey } from '../speech/openai-api-key-store' import type { SpeechServiceFactories } from '../speech/speech-runtime-service' import { SttService } from '../speech/stt-service' +import { createSttGpuPolicy } from '../speech/stt-gpu-failure-marker' +import { spawnSttWorkerWithCpuFallback } from '../speech/stt-worker-cpu-fallback' import { spawnSttForkedProcess } from '../speech/stt-worker-forked-process' // Local speech needs the installed app recognizer supplied by orca serve. @@ -10,5 +12,8 @@ export const orcadSpeechServiceFactories: SpeechServiceFactories = { createModelManager: (customModelsDir) => // Headless key reads cannot open keychain prompts, so readiness may verify decryption. new ModelManager(customModelsDir, openNodeSpeechDownloadRequest, canReadOpenAiSpeechApiKey), - createSttService: (models) => new SttService(models, spawnSttForkedProcess) + createSttService: (models, gpuEnabled) => { + const gpu = createSttGpuPolicy(models.getModelsDir(), gpuEnabled) + return new SttService(models, () => spawnSttWorkerWithCpuFallback(spawnSttForkedProcess, gpu)) + } } diff --git a/src/main/runtime/speech/runtime-mobile-dictation-controller.ts b/src/main/runtime/speech/runtime-mobile-dictation-controller.ts index 07c02fdc24d..219cbe980b1 100644 --- a/src/main/runtime/speech/runtime-mobile-dictation-controller.ts +++ b/src/main/runtime/speech/runtime-mobile-dictation-controller.ts @@ -53,7 +53,7 @@ export class RuntimeMobileDictationController { throw new Error('voice_model_not_selected') } const manifest = getCatalogModel(modelId) - if (manifest && isLocalSpeechModel(manifest) && !hasLocalSpeechRecognizer()) { + if (manifest && isLocalSpeechModel(manifest) && !hasLocalSpeechRecognizer(manifest)) { throw new Error(LOCAL_SPEECH_UNAVAILABLE_ERROR) } const modelState = await getSpeechModelManager(store).getModelState(modelId) diff --git a/src/main/runtime/speech/runtime-mobile-speech-catalog.ts b/src/main/runtime/speech/runtime-mobile-speech-catalog.ts index 3c8185e2ea3..fd50666cc38 100644 --- a/src/main/runtime/speech/runtime-mobile-speech-catalog.ts +++ b/src/main/runtime/speech/runtime-mobile-speech-catalog.ts @@ -40,10 +40,10 @@ export class RuntimeMobileSpeechCatalog { const states = await getSpeechModelManager(store).getModelStates() const stateById = new Map(states.map((state) => [state.id, state])) // Omit unsupported status variants; older phones could treat them as pickable models. - const localRunnable = hasLocalSpeechRecognizer() const runnable = SPEECH_MODEL_CATALOG.filter( (manifest) => - isOfferedSpeechModel(manifest) && (localRunnable || !isLocalSpeechModel(manifest)) + isOfferedSpeechModel(manifest) && + (!isLocalSpeechModel(manifest) || hasLocalSpeechRecognizer(manifest)) ) const models: RuntimeSpeechModelSummary[] = runnable.map((manifest) => { const state = stateById.get(manifest.id) @@ -70,7 +70,7 @@ export class RuntimeMobileSpeechCatalog { async download(modelId: string): Promise<{ started: true }> { const store = this.requireStore() const manifest = getCatalogModel(modelId) - if (!manifest || !isLocalSpeechModel(manifest) || !hasLocalSpeechRecognizer()) { + if (!manifest || !isLocalSpeechModel(manifest) || !hasLocalSpeechRecognizer(manifest)) { throw new Error('voice_model_not_downloadable') } void getSpeechModelManager(store) diff --git a/src/main/speech/speech-runtime-service.ts b/src/main/speech/speech-runtime-service.ts index 1943e188f59..3174271e7f1 100644 --- a/src/main/speech/speech-runtime-service.ts +++ b/src/main/speech/speech-runtime-service.ts @@ -12,7 +12,8 @@ type SpeechSettingsStore = { export type SpeechServiceFactories = { createModelManager(customModelsDir: string | undefined): ModelManager - createSttService(models: ModelManager): SttService + /** `gpuEnabled` reads the user's Voice setting at each recognizer start. */ + createSttService(models: ModelManager, gpuEnabled: () => boolean): SttService } let factories: SpeechServiceFactories | null = null @@ -43,7 +44,10 @@ export function getSpeechModelManager(store: SpeechSettingsStore): ModelManager export function getSpeechSttService(store: SpeechSettingsStore): SttService { if (!sttService) { - sttService = requireFactories().createSttService(getSpeechModelManager(store)) + sttService = requireFactories().createSttService( + getSpeechModelManager(store), + () => store.getSettings().voice?.speechGpu !== 'off' + ) } return sttService } diff --git a/src/main/speech/stt-gpu-failure-marker.ts b/src/main/speech/stt-gpu-failure-marker.ts new file mode 100644 index 00000000000..5a79cd52f22 --- /dev/null +++ b/src/main/speech/stt-gpu-failure-marker.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { release } from 'node:os' +import { join } from 'node:path' +import type { SttGpuPolicy } from './stt-worker-cpu-fallback' +import { getSpeechEngineModulesDir } from './stt-worker-paths' + +const GPU_FAILURE_MARKER_FILE = 'speech-gpu-failed.json' + +let cachedGpuFailureKey: string | null = null + +// The GPU is retried once transcribe-cpp or the OS (on macOS, also the GPU driver) changes. +function gpuFailureKey(): string { + if (cachedGpuFailureKey) { + return cachedGpuFailureKey + } + let version = 'unknown' + const modulesDir = getSpeechEngineModulesDir('transcribe-cpp') + if (modulesDir) { + try { + version = JSON.parse( + readFileSync(join(modulesDir, 'transcribe-cpp', 'package.json'), 'utf8') + ).version + } catch { + // An unreadable version keys the marker as 'unknown'; the next good read retries the GPU. + } + } + cachedGpuFailureKey = `${version}|${process.platform}|${process.arch}|${release()}` + return cachedGpuFailureKey +} + +function readMarkerKey(file: string): string | null { + try { + return JSON.parse(readFileSync(file, 'utf8')).key ?? null + } catch { + return null + } +} + +/** `gpuEnabled` is the user's Voice setting; the marker records a GPU that failed on this machine. */ +export function createSttGpuPolicy(modelsDir: string, gpuEnabled: () => boolean): SttGpuPolicy { + const file = join(modelsDir, GPU_FAILURE_MARKER_FILE) + // Read once per process; this process's own failures update it in memory. + let failedKey: string | null | undefined + return { + allowGpu: () => { + if (!gpuEnabled()) { + return false + } + if (failedKey === undefined) { + failedKey = readMarkerKey(file) + } + return failedKey !== gpuFailureKey() + }, + markGpuFailed: () => { + failedKey = gpuFailureKey() + writeFile(file, JSON.stringify({ key: failedKey })).catch((error: unknown) => { + console.warn('[speech] Could not record the GPU failure:', error) + }) + } + } +} diff --git a/src/main/speech/stt-recognizer.ts b/src/main/speech/stt-recognizer.ts new file mode 100644 index 00000000000..79ce45c2c29 --- /dev/null +++ b/src/main/speech/stt-recognizer.ts @@ -0,0 +1,23 @@ +import type { RecognizerConfigInput } from './stt-worker-model-config' + +export type SttRecognizerInit = RecognizerConfigInput & { + /** The node_modules directory holding this model's engine package. */ + speechModulesDir: string +} + +export type SttRecognizerEvent = + | { type: 'partial'; streamId: string; text: string } + | { type: 'final'; streamId: string; text: string } + /** One decoded offline chunk; `samples` counts the stream's input-rate samples decoded so far. */ + | { type: 'chunk'; streamId: string; text: string; samples: number } + +export type SttRecognizerPost = (event: SttRecognizerEvent) => void + +/** One loaded model in the speech host child, shared by every dictation stream; calls never overlap. */ +export type SttRecognizer = { + feed(streamId: string, samples: Float32Array, sampleRate: number): void | Promise + /** Decode the stream's buffered audio, post its text and drop the stream; may throw after posting. */ + finish(streamId: string): void | Promise + /** Drop the stream's audio undecoded. */ + discard(streamId: string): void +} diff --git a/src/main/speech/stt-session-prewarm.ts b/src/main/speech/stt-session-prewarm.ts index 11a617657ef..0c32f07b208 100644 --- a/src/main/speech/stt-session-prewarm.ts +++ b/src/main/speech/stt-session-prewarm.ts @@ -43,8 +43,9 @@ export async function prewarmSttModel( return } const ready = + manifest !== undefined && config !== null && - hasLocalSpeechRecognizer() && + hasLocalSpeechRecognizer(manifest) && (await state.modelManager.getModelState(modelId)).status === 'ready' for (;;) { // Never disturb a dictation; whatever it loaded is what the user is using. diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index adc272ae3c0..b1271a1501f 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -19,7 +19,8 @@ import { teardownIdleSttEngine, teardownSttEngine } from './stt-session-stop' -import { getSherpaModulePath, LOCAL_SPEECH_UNAVAILABLE_ERROR } from './stt-worker-paths' +import { speechEngineForModelFiles } from '../../shared/speech-model-engine' +import { getSpeechEngineModulesDir, LOCAL_SPEECH_UNAVAILABLE_ERROR } from './stt-worker-paths' import { attachSttWorkerLifecycle, initializeSttWorker, @@ -166,8 +167,9 @@ export function spawnSttEngine( config: SttRecognizerConfig, manifest: SpeechModelManifest ): SttEngine { - const sherpaModulePath = getSherpaModulePath() - if (!sherpaModulePath) { + const files = manifest.files ?? [] + const speechModulesDir = getSpeechEngineModulesDir(speechEngineForModelFiles(files)) + if (!speechModulesDir) { throw new Error(LOCAL_SPEECH_UNAVAILABLE_ERROR) } clearSttIdleTeardownTimer(state) @@ -200,12 +202,12 @@ export function spawnSttEngine( engine.ready.catch(() => undefined) state.engine = engine initializeSttWorker(worker, { - sherpaModulePath, + speechModulesDir, modelDir: state.modelManager.getModelDir(config.modelId), modelType: manifest.type, streaming: manifest.streaming, sampleRate: manifest.sampleRate, - files: manifest.files ?? [], + files, hotwordsFilePath: config.hotwordsFilePath, modelingUnit: manifest.modelingUnit, language: config.language diff --git a/src/main/speech/stt-sherpa-recognizer.ts b/src/main/speech/stt-sherpa-recognizer.ts new file mode 100644 index 00000000000..bf4a6668fc4 --- /dev/null +++ b/src/main/speech/stt-sherpa-recognizer.ts @@ -0,0 +1,149 @@ +/* oxlint-disable typescript-eslint/no-explicit-any -- sherpa-onnx native addon has no type definitions */ +import { join } from 'node:path' +import { speechRecognizerPackageName } from '../../shared/speech-recognizer-package' +import { resampleToRate } from './stt-audio-resample' +import { OfflineAudioChunker } from './stt-offline-audio-chunker' +import { buildRecognizerConfig } from './stt-worker-model-config' +import { createStopTailPadding } from './stt-stop-tail-padding' +import type { SttRecognizer, SttRecognizerInit, SttRecognizerPost } from './stt-recognizer' + +// One per dictation session; every session shares the one loaded recognizer. +type SessionStream = { + // Online streams live for the session; offline ones are made per decode. + online: any + offlineChunker: OfflineAudioChunker | null + // Primed on the session's first feed so stop never waits on another session's stream. + primed: boolean + // Streaming models report the same hypothesis on most 100 ms feeds; only changes are posted. + lastPartialText: string +} + +export function createSherpaRecognizer( + init: SttRecognizerInit, + post: SttRecognizerPost +): SttRecognizer { + // Use the platform native addon: the main sherpa-onnx WASM package cannot load host model files. + const sherpa: any = require( + join(init.speechModulesDir, speechRecognizerPackageName(process.platform, process.arch)) + ) + const { streaming: isStreaming, sampleRate: modelSampleRate } = init + const { online, config } = buildRecognizerConfig(init) + const recognizer = online + ? sherpa.createOnlineRecognizer(config) + : sherpa.createOfflineRecognizer(config) + const streams = new Map() + + const openStream = (streamId: string): SessionStream => { + const existing = streams.get(streamId) + if (existing) { + return existing + } + const stream: SessionStream = { + online: isStreaming ? sherpa.createOnlineStream(recognizer) : null, + offlineChunker: isStreaming ? null : new OfflineAudioChunker(modelSampleRate), + primed: false, + lastPartialText: '' + } + streams.set(streamId, stream) + return stream + } + + const decodeOfflineChunk = (samples: Float32Array): string => { + const stream = sherpa.createOfflineStream(recognizer) + sherpa.acceptWaveformOffline(stream, { sampleRate: modelSampleRate, samples }) + sherpa.decodeOfflineStream(recognizer, stream) + const result = JSON.parse(sherpa.getOfflineStreamResultAsJson(stream)) + return result?.text?.trim() ?? '' + } + + const feedOnline = (streamId: string, stream: SessionStream, samples: Float32Array): void => { + if (!stream.primed) { + // New streams start with empty encoder caches; zipformer en-20M dropped most of the first second + // (first word wrong on 71 of 73 LibriSpeech clips). A second of silence fills them first. + sherpa.acceptWaveformOnline(stream.online, { + sampleRate: modelSampleRate, + samples: new Float32Array(modelSampleRate) + }) + stream.primed = true + } + sherpa.acceptWaveformOnline(stream.online, { sampleRate: modelSampleRate, samples }) + while (sherpa.isOnlineStreamReady(recognizer, stream.online)) { + sherpa.decodeOnlineStream(recognizer, stream.online) + } + const result = JSON.parse(sherpa.getOnlineStreamResultAsJson(recognizer, stream.online)) + const text = result?.text?.trim() + if (text && text !== stream.lastPartialText) { + stream.lastPartialText = text + post({ type: 'partial', streamId, text }) + } + if (sherpa.isEndpoint(recognizer, stream.online)) { + if (text) { + post({ type: 'final', streamId, text }) + } + sherpa.reset(recognizer, stream.online) + stream.lastPartialText = '' + } + } + + const feedOffline = (streamId: string, stream: SessionStream, samples: Float32Array): void => { + // Bound ONNX allocations below Chromium's >=2GiB crash threshold (#7925); consumers append final segments. + const readyChunks = stream.offlineChunker?.push(new Float32Array(samples)) ?? [] + // push() already consumed these windows; keep decoding later ones after a failure. + let firstError: unknown = null + for (const chunk of readyChunks) { + try { + const text = decodeOfflineChunk(chunk) + if (text) { + post({ type: 'final', streamId, text }) + } + } catch (err) { + firstError ??= err + } + } + if (firstError) { + throw firstError + } + } + + const finishStream = (stream: SessionStream): string => { + if (stream.online) { + sherpa.acceptWaveformOnline(stream.online, { + sampleRate: modelSampleRate, + samples: createStopTailPadding(modelSampleRate) + }) + sherpa.inputFinished(stream.online) + while (sherpa.isOnlineStreamReady(recognizer, stream.online)) { + sherpa.decodeOnlineStream(recognizer, stream.online) + } + const result = JSON.parse(sherpa.getOnlineStreamResultAsJson(recognizer, stream.online)) + return result?.text?.trim() ?? '' + } + // flush() leaves less than one bounded chunk. + const remaining = stream.offlineChunker?.flush() + return remaining && remaining.length > 0 ? decodeOfflineChunk(remaining) : '' + } + + return { + feed(streamId, samples, sampleRate) { + const stream = openStream(streamId) + // Normalize rates before native decode: mixed rates on one recognizer abort the process. + const resampled = resampleToRate(samples, sampleRate || modelSampleRate, modelSampleRate) + if (isStreaming) { + feedOnline(streamId, stream, resampled) + } else { + feedOffline(streamId, stream, resampled) + } + }, + finish(streamId) { + const stream = streams.get(streamId) + streams.delete(streamId) + const text = stream ? finishStream(stream) : '' + if (text) { + post({ type: 'final', streamId, text }) + } + }, + discard(streamId) { + streams.delete(streamId) + } + } +} diff --git a/src/main/speech/stt-transcribe-cpp-recognizer.ts b/src/main/speech/stt-transcribe-cpp-recognizer.ts new file mode 100644 index 00000000000..5230e3a3446 --- /dev/null +++ b/src/main/speech/stt-transcribe-cpp-recognizer.ts @@ -0,0 +1,119 @@ +import { readFile } from 'node:fs/promises' +import { availableParallelism } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import type * as TranscribeCpp from 'transcribe-cpp' +import { resampleToRate } from './stt-audio-resample' +import { OfflineAudioChunker } from './stt-offline-audio-chunker' +import { resolveFile } from './stt-worker-model-config' +import type { SttRecognizer, SttRecognizerInit, SttRecognizerPost } from './stt-recognizer' + +// Past ~8 threads decode slowed 2-4x under load in the 2026-11 benchmark; E-cores and SMT siblings +// don't help. Half the logical CPUs is close to the performance cores on Apple silicon and SMT x86. +function performanceCoreThreads(): number { + const logical = availableParallelism() + return Math.min(logical, 8, Math.max(4, Math.floor(logical / 2))) +} + +// The hotwords file is written in sherpa's format, one `term :score` per line. The host deletes it +// once dictation starts, so a CPU fallback's reload finds it gone and runs without. +async function readVocabulary(path: string | undefined): Promise { + const text = path ? await readFile(path, 'utf8').catch(() => '') : '' + return text + .split('\n') + .map((line) => line.replace(/\s+:[\d.]+\s*$/, '').trim()) + .filter(Boolean) +} + +// Totals, not the nominal rate ratio: per-feed resampling rounds, and the drift would replay decoded audio. +type StreamCoverage = { + chunker: OfflineAudioChunker + inputSamples: number + resampledSamples: number + decodedSamples: number +} + +export async function createTranscribeCppRecognizer( + init: SttRecognizerInit, + post: SttRecognizerPost +): Promise { + const entry = join(init.speechModulesDir, 'transcribe-cpp', 'dist', 'index.js') + const transcribe: typeof TranscribeCpp = await import(pathToFileURL(entry).href) + const model = await transcribe.TranscribeModel.load( + resolveFile(init.files, '.gguf', init.modelDir, '.gguf') + ) + // The child runs one call at a time, so every stream can share one session. + const session = model.createSession({ nThreads: performanceCoreThreads() }) + const vocabulary = await readVocabulary(init.hotwordsFilePath) + const options: TranscribeCpp.TranscribeOptions = { + timestamps: 'none', + ...(init.language && model.capabilities.languages.includes(init.language) + ? { language: init.language } + : {}), + ...(vocabulary.length > 0 && model.supports('vocabulary') ? { vocabulary } : {}) + } + const modelRate = model.capabilities.nativeSampleRate || init.sampleRate + const streams = new Map() + + const transcribeChunk = async (chunk: Float32Array): Promise => { + try { + return (await session.run(chunk, options)).text + } catch (error) { + // Repetition loops (Whisper on noise) and length caps are about this audio, not the GPU; keep what decoded. + if (error instanceof transcribe.OutputTruncated) { + return error.partialResult?.text ?? '' + } + throw error + } + } + + // The first run compiles GPU pipelines (seconds on Metal); pay it while loading, not on the first stop. + await transcribeChunk(new Float32Array(Math.round(modelRate / 2))) + + // Coverage is in input-rate samples so the host can trim the audio it keeps for a CPU replay. + const decode = async (streamId: string, stream: StreamCoverage, chunk: Float32Array) => { + const text = await transcribeChunk(chunk) + stream.decodedSamples += chunk.length + post({ + type: 'chunk', + streamId, + text: text.trim(), + samples: Math.round((stream.decodedSamples * stream.inputSamples) / stream.resampledSamples) + }) + } + + return { + async feed(streamId, samples, sampleRate) { + let stream = streams.get(streamId) + if (!stream) { + stream = { + chunker: new OfflineAudioChunker(modelRate), + inputSamples: 0, + resampledSamples: 0, + decodedSamples: 0 + } + streams.set(streamId, stream) + } + // The binding borrows PCM until the run settles; resampleToRate may return the caller's buffer. + const resampled = new Float32Array( + resampleToRate(samples, sampleRate || modelRate, modelRate) + ) + stream.inputSamples += samples.length + stream.resampledSamples += resampled.length + for (const chunk of stream.chunker.push(resampled)) { + await decode(streamId, stream, chunk) + } + }, + async finish(streamId) { + const stream = streams.get(streamId) + streams.delete(streamId) + const remaining = stream?.chunker.flush() + if (stream && remaining) { + await decode(streamId, stream, remaining) + } + }, + discard(streamId) { + streams.delete(streamId) + } + } +} diff --git a/src/main/speech/stt-worker-cpu-fallback.test.ts b/src/main/speech/stt-worker-cpu-fallback.test.ts new file mode 100644 index 00000000000..ba8c2eed943 --- /dev/null +++ b/src/main/speech/stt-worker-cpu-fallback.test.ts @@ -0,0 +1,151 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it } from 'vitest' +import { spawnSttWorkerWithCpuFallback } from './stt-worker-cpu-fallback' + +class FakeChild extends EventEmitter { + readonly posted: { type: string; samples?: Float32Array }[] = [] + constructor(readonly cpuOnly: boolean) { + super() + } + postMessage(message: { type: string; samples?: Float32Array }): void { + this.posted.push(message) + } + terminate(): Promise { + return Promise.resolve(0) + } + attachAudioPort(): boolean { + return true + } +} + +function setup(): { + worker: ReturnType + children: FakeChild[] + events: unknown[] + marks: () => number +} { + const children: FakeChild[] = [] + let marks = 0 + const worker = spawnSttWorkerWithCpuFallback( + ({ cpuOnly }) => { + const child = new FakeChild(cpuOnly) + children.push(child) + return child + }, + { allowGpu: () => true, markGpuFailed: () => void (marks += 1) } + ) + const events: unknown[] = [] + worker.on('message', (event: unknown) => events.push(event)) + worker.postMessage({ + type: 'init', + speechModulesDir: '/app/node_modules', + modelDir: '/models/parakeet', + modelType: 'transducer', + streaming: false, + sampleRate: 16000, + files: ['model-Q8_0.gguf'] + }) + return { worker, children, events, marks: () => marks } +} + +const feed = ( + n: number +): { type: 'feed'; streamId: string; samples: Float32Array; sampleRate: number } => ({ + type: 'feed', + streamId: '1', + samples: new Float32Array(n), + sampleRate: 16000 +}) + +describe('GPU speech process falling back to the CPU', () => { + it('replays only the audio the GPU had not decoded when it crashes mid-stop', () => { + const { worker, children, events, marks } = setup() + const [gpu] = children + gpu.emit('message', { type: 'ready' }) + worker.postMessage(feed(300)) + worker.postMessage(feed(300)) + gpu.emit('message', { type: 'chunk', streamId: '1', text: 'first', samples: 400 }) + worker.postMessage(feed(300)) + worker.postMessage({ type: 'stop', streamId: '1' }) + gpu.emit('exit', 1) + // The dead process's late messages must not settle the stop or duplicate text. + gpu.emit('message', { type: 'chunk', streamId: '1', text: 'late', samples: 900 }) + gpu.emit('message', { type: 'stopped', streamId: '1' }) + + const cpu = children[1] + expect(cpu.cpuOnly).toBe(true) + expect(cpu.posted.map((m) => [m.type, m.samples?.length])).toEqual([ + ['init', undefined], + ['feed', 200], + ['feed', 300], + ['stop', undefined] + ]) + expect(marks()).toBe(0) + cpu.emit('message', { type: 'ready' }) + expect(marks()).toBe(1) + cpu.emit('message', { type: 'chunk', streamId: '1', text: 'second', samples: 500 }) + cpu.emit('message', { type: 'stopped', streamId: '1' }) + expect(events).toEqual([ + { type: 'ready' }, + { type: 'final', streamId: '1', text: 'first' }, + { type: 'final', streamId: '1', text: 'second' }, + { type: 'stopped', streamId: '1' } + ]) + }) + + it('keeps the GPU for later sessions when only this audio failed', () => { + const { worker, children, marks } = setup() + const [gpu] = children + gpu.emit('message', { type: 'ready' }) + worker.postMessage(feed(100)) + gpu.emit('message', { type: 'error', streamId: '1', error: 'bad input' }) + children[1].emit('message', { type: 'ready' }) + expect(marks()).toBe(0) + }) + + it('does not replay a canceled dictation', () => { + const { worker, children } = setup() + const [gpu] = children + gpu.emit('message', { type: 'ready' }) + worker.postMessage(feed(100)) + worker.postMessage({ type: 'discard', streamId: '1' }) + gpu.emit('exit', 1) + expect(children[1].posted.map((m) => m.type)).toEqual(['init', 'discard']) + }) + + it('reports an idle GPU process killed between dictations instead of moving it to the CPU', () => { + const { worker, children, marks } = setup() + const [gpu] = children + const exits: number[] = [] + worker.on('exit', (code: number) => exits.push(code)) + gpu.emit('message', { type: 'ready' }) + worker.postMessage({ type: 'stop', streamId: '1' }) + gpu.emit('message', { type: 'stopped', streamId: '1' }) + gpu.emit('exit', 137) + expect(children).toHaveLength(1) + expect(exits).toEqual([137]) + expect(marks()).toBe(0) + }) + + it('fails the worker when the CPU replacement cannot load the model mid-dictation', () => { + const { worker, children } = setup() + const exits: number[] = [] + worker.on('exit', (code: number) => exits.push(code)) + children[0].emit('message', { type: 'ready' }) + worker.postMessage(feed(100)) + children[0].emit('exit', 1) + children[1].emit('message', { type: 'error', error: 'model load failed' }) + expect(exits).toEqual([1]) + }) + + it('keeps a GPU process on IPC audio, which it can replay, and hands a CPU process the port', () => { + const { worker, children } = setup() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fakes never touch the port. + const port = {} as never + expect(worker.attachAudioPort('1', port)).toBe(false) + children[0].emit('message', { type: 'ready' }) + worker.postMessage(feed(100)) + children[0].emit('exit', 1) + expect(worker.attachAudioPort('2', port)).toBe(true) + }) +}) diff --git a/src/main/speech/stt-worker-cpu-fallback.ts b/src/main/speech/stt-worker-cpu-fallback.ts new file mode 100644 index 00000000000..1ac9cd664a5 --- /dev/null +++ b/src/main/speech/stt-worker-cpu-fallback.ts @@ -0,0 +1,243 @@ +import { EventEmitter } from 'node:events' +import type { MessagePortMain } from 'electron' +import { speechEngineForModelFiles } from '../../shared/speech-model-engine' +import { OFFLINE_DECODE_CHUNK_SECONDS } from './stt-offline-audio-chunker' +import type { SttWorkerProcess, SttWorkerRequest } from './stt-worker-process' + +export type SpawnSttChild = (options: { cpuOnly: boolean }) => SttWorkerProcess + +export type SttGpuPolicy = { + /** False when the user turned the GPU off or this machine's GPU already failed. */ + allowGpu(): boolean + markGpuFailed(): void +} + +type Feed = Extract +type ChildMessage = + | { type: 'chunk'; streamId: string; text: string; samples: number } + | { + type: 'ready' | 'stopped' | 'error' | 'partial' | 'final' + streamId?: string + text?: string + error?: string + } + +/** A dictation's audio the GPU has not decoded yet, kept for a CPU replay. */ +type StreamBacklog = { + feeds: Feed[] + /** Input samples before feeds[0], in the stream's own count. */ + start: number + seconds: number + pending: 'stop' | 'discard' | null +} + +// Well under the session's 60 s start and stop timeouts, so a CPU replay still fits; covers first-run shader compiles. +const GPU_STALL_TIMEOUT_MS = 25_000 + +/** + * Runs transcribe-cpp on the GPU and, when the GPU fails to load, errors, stalls or crashes the + * process, moves every dictation to a CPU-only process and replays the audio not yet decoded. + * Session code sees one worker throughout. + */ +class SttCpuFallbackWorker extends EventEmitter implements SttWorkerProcess { + private child!: SttWorkerProcess + private detachChild: () => void = () => undefined + private cpuOnly: boolean + private init: Extract | null = null + private transcribeCpp = false + private ready = false + private exitRequested = false + private markOnCpuReady = false + private readonly streams = new Map() + private stallTimer: ReturnType | null = null + + constructor( + private readonly spawn: SpawnSttChild, + private readonly gpu: SttGpuPolicy + ) { + super() + this.cpuOnly = !gpu.allowGpu() + this.attach(spawn({ cpuOnly: this.cpuOnly })) + } + + postMessage(msg: SttWorkerRequest): void { + if (msg.type === 'init') { + this.init = msg + this.transcribeCpp = speechEngineForModelFiles(msg.files) === 'transcribe-cpp' + } else if (msg.type === 'feed' && this.canFallBack()) { + const stream = this.backlog(msg.streamId) + stream.feeds.push(msg) + stream.seconds += msg.samples.length / msg.sampleRate + } else if ((msg.type === 'stop' || msg.type === 'discard') && this.canFallBack()) { + this.backlog(msg.streamId).pending = msg.type + } + this.watchForStall(false) + this.child.postMessage(msg) + } + + /** A GPU process keeps audio on IPC, where this wrapper can hold it for a CPU replay. */ + attachAudioPort(streamId: string, port: MessagePortMain): boolean { + return !this.canFallBack() && this.child.attachAudioPort(streamId, port) + } + + terminate(): Promise { + this.exitRequested = true + this.clearStallTimer() + return this.child.terminate() + } + + private canFallBack(): boolean { + return this.transcribeCpp && !this.cpuOnly && !this.exitRequested + } + + private backlog(streamId: string): StreamBacklog { + let stream = this.streams.get(streamId) + if (!stream) { + stream = { feeds: [], start: 0, seconds: 0, pending: null } + this.streams.set(streamId, stream) + } + return stream + } + + private attach(child: SttWorkerProcess): void { + const onMessage = (message: ChildMessage): void => this.receive(message) + const onExit = (code: number): void => { + // An idle process killed from outside (OOM, Activity Monitor) is not a GPU failure. + if (this.canFallBack() && (!this.ready || this.streams.size > 0)) { + this.fallBack(true) + } else { + this.emit('exit', code) + } + } + child.on('message', onMessage) + child.on('exit', onExit) + this.child = child + this.detachChild = () => { + child.off('message', onMessage) + child.off('exit', onExit) + } + } + + private receive(message: ChildMessage): void { + if (message.type === 'error' && this.canFallBack()) { + this.fallBack(!this.ready) + return + } + if (message.type === 'error' && !message.streamId && this.ready) { + // The CPU replacement could not load: fail every dictation now rather than answer them empty. + this.detachChild() + void this.child.terminate().catch(() => undefined) + this.emit('exit', 1) + return + } + if (message.type === 'chunk') { + const { streamId, text, samples } = message + const stream = this.streams.get(streamId) + if (stream) { + this.trimBacklog(stream, samples) + } + this.watchForStall(true) + if (text) { + this.emit('message', { type: 'final', streamId, text }) + } + return + } + if (message.type === 'ready') { + if (this.markOnCpuReady) { + this.markOnCpuReady = false + this.gpu.markGpuFailed() + } + if (this.ready) { + return + } + this.ready = true + this.watchForStall(true) + } else if (message.type === 'stopped' && message.streamId) { + this.streams.delete(message.streamId) + this.watchForStall(true) + } + this.emit('message', message) + } + + /** `persist`: the GPU itself failed (crash or load error), not just this audio. */ + private fallBack(persist: boolean): void { + this.clearStallTimer() + this.detachChild() + void this.child.terminate().catch(() => undefined) + // Persist only once CPU loads the model: a broken model must not turn the GPU off. + this.markOnCpuReady = persist + this.cpuOnly = true + this.attach(this.spawn({ cpuOnly: true })) + if (this.init) { + this.child.postMessage(this.init) + } + for (const [streamId, stream] of this.streams) { + // A canceled dictation wants no text, so its audio is not replayed. + if (stream.pending !== 'discard') { + for (const feed of stream.feeds) { + this.child.postMessage(feed) + } + } + if (stream.pending) { + this.child.postMessage({ type: stream.pending, streamId }) + } + } + this.streams.clear() + } + + private trimBacklog(stream: StreamBacklog, decodedSamples: number): void { + while (stream.feeds.length > 0) { + const first = stream.feeds[0] + const end = stream.start + first.samples.length + const dropped = Math.min(first.samples.length, decodedSamples - stream.start) + stream.seconds -= dropped / first.sampleRate + if (end > decodedSamples) { + stream.feeds[0] = { ...first, samples: first.samples.subarray(dropped) } + stream.start = decodedSamples + return + } + stream.feeds.shift() + stream.start = end + } + } + + /** The GPU owes progress while loading, finishing a stop, or once a full chunk waits undecoded. */ + private owesProgress(): boolean { + if (!this.ready) { + return this.init !== null + } + for (const stream of this.streams.values()) { + if (stream.pending === 'stop' || stream.seconds > OFFLINE_DECODE_CHUNK_SECONDS) { + return true + } + } + return false + } + + /** `progressed`: the GPU just answered, so a running timer restarts. */ + private watchForStall(progressed: boolean): void { + if (progressed || !this.canFallBack() || !this.owesProgress()) { + this.clearStallTimer() + } + if (this.stallTimer || !this.canFallBack() || !this.owesProgress()) { + return + } + // Not persisted: a first-run shader compile or cold model read can be slow once and fine after. + this.stallTimer = setTimeout(() => this.fallBack(false), GPU_STALL_TIMEOUT_MS) + this.stallTimer.unref?.() + } + + private clearStallTimer(): void { + if (this.stallTimer) { + clearTimeout(this.stallTimer) + this.stallTimer = null + } + } +} + +export function spawnSttWorkerWithCpuFallback( + spawn: SpawnSttChild, + gpu: SttGpuPolicy +): SttWorkerProcess { + return new SttCpuFallbackWorker(spawn, gpu) +} diff --git a/src/main/speech/stt-worker-forked-process.ts b/src/main/speech/stt-worker-forked-process.ts index ef5624408cc..6c87e9c8a95 100644 --- a/src/main/speech/stt-worker-forked-process.ts +++ b/src/main/speech/stt-worker-forked-process.ts @@ -2,19 +2,20 @@ import { forkProcess } from '@orca/process-host/fork-process' import { getSttWorkerPath } from './stt-worker-paths' import { logSttWorkerStderr, + sttWorkerEnv, SttWorkerChildProcess, type SttWorkerProcess } from './stt-worker-process' /** For orcad, which has no Electron utility processes. */ -export function spawnSttForkedProcess(): SttWorkerProcess { +export function spawnSttForkedProcess(options: { cpuOnly: boolean }): SttWorkerProcess { const child = forkProcess({ modulePath: getSttWorkerPath(), // Structured clone keeps feed audio a Float32Array. serialization: 'advanced', execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } + env: { ...sttWorkerEnv(options), ELECTRON_RUN_AS_NODE: '1' } }) const kill = (): void => { child.kill('SIGKILL') diff --git a/src/main/speech/stt-worker-paths.ts b/src/main/speech/stt-worker-paths.ts index 90a6c8432f0..f8e3edf8ede 100644 --- a/src/main/speech/stt-worker-paths.ts +++ b/src/main/speech/stt-worker-paths.ts @@ -1,9 +1,9 @@ -import { existsSync } from 'node:fs' import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' +import { speechEngineForModelFiles, type SpeechEngine } from '../../shared/speech-model-engine' import { - SPEECH_RECOGNIZER_DIR_ENV, - speechRecognizerPackageName + hasSpeechEnginePackage, + SPEECH_RECOGNIZER_DIR_ENV } from '../../shared/speech-recognizer-package' import { currentWorkerEntryLayout, @@ -17,34 +17,42 @@ export function getSttWorkerPath(): string { return resolveWorkerThreadEntryPath(currentWorkerEntryLayout(__dirname), 'stt-worker.js') } -/** Recognizer directory, or null on hosts without one. */ -export function getSherpaModulePath(): string | null { - const nativePackage = speechRecognizerPackageName(process.platform, process.arch) +function speechNodeModulesDirCandidates(): string[] { const resourcesPath = 'resourcesPath' in process && typeof process.resourcesPath === 'string' ? process.resourcesPath : undefined - if (getAppEnvironment().isPackaged() && resourcesPath) { - const resourcesNodeModule = join(resourcesPath, 'node_modules', nativePackage) - if (existsSync(resourcesNodeModule)) { - return resourcesNodeModule - } - return join(resourcesPath, 'app.asar.unpacked', 'node_modules', nativePackage) + return [ + join(resourcesPath, 'node_modules'), + join(resourcesPath, 'app.asar.unpacked', 'node_modules') + ] } if (getAppEnvironment().isPackaged()) { // orcad: only the installed app's copy, which `orca serve` names. const handed = process.env[SPEECH_RECOGNIZER_DIR_ENV] - return handed && existsSync(join(handed, 'sherpa-onnx.node')) ? handed : null - } - - try { - return join(require.resolve(nativePackage), '..') - } catch { - return null + return handed ? [handed] : [] } + return require.resolve.paths('transcribe-cpp') ?? [] } -export function hasLocalSpeechRecognizer(): boolean { - return getSherpaModulePath() !== null +// The app's engine packages don't change while it runs, so each engine is looked up once. +const speechEngineModulesDirs = new Map() + +/** The node_modules directory holding `engine`, or null on hosts without it. */ +export function getSpeechEngineModulesDir(engine: SpeechEngine): string | null { + let dir = speechEngineModulesDirs.get(engine) + if (dir === undefined) { + dir = + speechNodeModulesDirCandidates().find((candidate) => + hasSpeechEnginePackage(candidate, engine) + ) ?? null + speechEngineModulesDirs.set(engine, dir) + } + return dir +} + +/** Whether this host ships the engine `model` runs on. */ +export function hasLocalSpeechRecognizer(model: { files?: readonly string[] }): boolean { + return getSpeechEngineModulesDir(speechEngineForModelFiles(model.files ?? [])) !== null } diff --git a/src/main/speech/stt-worker-process.ts b/src/main/speech/stt-worker-process.ts index bfa17341bef..f3cfd895b27 100644 --- a/src/main/speech/stt-worker-process.ts +++ b/src/main/speech/stt-worker-process.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import type { MessagePortMain } from 'electron' import type { WorkerRequestTransport } from '../worker-threads/worker-thread-transport' -import type { RecognizerConfigInput } from './stt-worker-model-config' +import type { SttRecognizerInit } from './stt-recognizer' /** Electron reports it as the utility process's name when it exits. */ export const STT_UTILITY_PROCESS_NAME = 'Orca Speech' @@ -10,7 +10,7 @@ export const STT_WORKER_EXITED_ERROR = 'The speech engine stopped unexpectedly. export const STT_WORKER_HUNG_ERROR = 'The speech engine stopped responding. Try again.' export type SttWorkerRequest = - | ({ type: 'init'; sherpaModulePath: string } & RecognizerConfigInput) + | ({ type: 'init' } & SttRecognizerInit) | { type: 'feed'; streamId: string; samples: Float32Array; sampleRate: number } | { type: 'stop'; streamId: string } | { type: 'discard'; streamId: string } @@ -73,6 +73,16 @@ export class SttWorkerChildProcess extends EventEmitter implements SttWorkerProc } } +/** Env for a speech child: CPU-only skips GPU backend registration, which can itself crash a driver. */ +export function sttWorkerEnv(options: { cpuOnly: boolean }): NodeJS.ProcessEnv { + return { + ...process.env, + ...(options.cpuOnly ? { TRANSCRIBE_BACKENDS: 'cpu' } : {}), + // Implicit Vulkan layers (overlays, capture tools) crash some drivers' compute (Handy #2049). + ...(process.platform === 'win32' ? { VK_LOADER_LAYERS_DISABLE: '~implicit~' } : {}) + } +} + export function logSttWorkerStderr(stderr: NodeJS.ReadableStream | null | undefined): void { stderr?.on('data', (chunk: Buffer) => { console.error('[speech-worker]', String(chunk).trimEnd()) diff --git a/src/main/speech/stt-worker-startup.ts b/src/main/speech/stt-worker-startup.ts index 85762c66c5b..fcb1fdb1b77 100644 --- a/src/main/speech/stt-worker-startup.ts +++ b/src/main/speech/stt-worker-startup.ts @@ -1,4 +1,5 @@ import type { SttEvent } from './stt-service' +import type { SttRecognizerInit } from './stt-recognizer' import type { SttWorkerProcess } from './stt-worker-process' /** Session events carry the stream they belong to. */ @@ -84,19 +85,6 @@ export function attachSttWorkerLifecycle(args: { } } -export function initializeSttWorker( - worker: SttWorkerProcess, - input: { - sherpaModulePath: string - modelDir: string - modelType: string - streaming: boolean - sampleRate: number - files: readonly string[] - hotwordsFilePath?: string - modelingUnit?: string - language?: string - } -): void { +export function initializeSttWorker(worker: SttWorkerProcess, input: SttRecognizerInit): void { worker.postMessage({ type: 'init', ...input }) } diff --git a/src/main/speech/stt-worker-utility-process.ts b/src/main/speech/stt-worker-utility-process.ts index 871fad4f841..a254140e9e4 100644 --- a/src/main/speech/stt-worker-utility-process.ts +++ b/src/main/speech/stt-worker-utility-process.ts @@ -3,15 +3,17 @@ import { getSttWorkerPath } from './stt-worker-paths' import { logSttWorkerStderr, STT_UTILITY_PROCESS_NAME, + sttWorkerEnv, SttWorkerChildProcess, type SttWorkerProcess } from './stt-worker-process' /** A native crash or hang in the recognizer stays out of the app's main process. */ -export function spawnSttUtilityProcess(): SttWorkerProcess { +export function spawnSttUtilityProcess(options: { cpuOnly: boolean }): SttWorkerProcess { const child = utilityProcess.fork(getSttWorkerPath(), [], { serviceName: STT_UTILITY_PROCESS_NAME, - stdio: ['ignore', 'ignore', 'pipe'] + stdio: ['ignore', 'ignore', 'pipe'], + env: sttWorkerEnv(options) }) const worker = new SttWorkerChildProcess({ send: (message) => child.postMessage(message), diff --git a/src/main/speech/stt-worker.ts b/src/main/speech/stt-worker.ts index 5f406e684c4..45410d83c70 100644 --- a/src/main/speech/stt-worker.ts +++ b/src/main/speech/stt-worker.ts @@ -1,23 +1,11 @@ -/* oxlint-disable typescript-eslint/no-explicit-any -- sherpa-onnx native addon has no type definitions */ import type { MessagePortMain } from 'electron' +import { speechEngineForModelFiles } from '../../shared/speech-model-engine' import type { SpeechAudioPortMessage } from '../../shared/speech-types' -import { resampleToRate } from './stt-audio-resample' -import { OfflineAudioChunker } from './stt-offline-audio-chunker' -import { buildRecognizerConfig } from './stt-worker-model-config' -import { createStopTailPadding } from './stt-stop-tail-padding' +import type { SttRecognizer, SttRecognizerInit, SttRecognizerPost } from './stt-recognizer' +import { createSherpaRecognizer } from './stt-sherpa-recognizer' +import { createTranscribeCppRecognizer } from './stt-transcribe-cpp-recognizer' import { connectSttWorkerParentPort } from './stt-worker-parent-port' -import type { SttWorkerRequest as WorkerMessage } from './stt-worker-process' - -// One per dictation session; every session shares the one loaded recognizer. -type SessionStream = { - // Online streams live for the session; offline ones are made per decode. - online: any - offlineChunker: OfflineAudioChunker | null - // Primed on the session's first feed so stop never waits on another session's stream. - primed: boolean - // Streaming models report the same hypothesis on most 100 ms feeds; only changes are posted. - lastPartialText: string -} +import type { SttWorkerRequest } from './stt-worker-process' // A desktop renderer's audio for one stream, posted straight here instead of through main. type AudioPortInput = { @@ -26,144 +14,39 @@ type AudioPortInput = { } const parentPort = connectSttWorkerParentPort() -// Use the platform native addon: the main sherpa-onnx WASM package cannot load host model files. Main supplies its resolved path. -let sherpa: any = null -let recognizer: any = null -let isStreaming = false -let modelSampleRate = 16000 -const streams = new Map() +let recognizer: SttRecognizer | null = null const audioPorts = new Map() -function loadSherpa(modulePath: string | undefined): any { - if (!modulePath) { - throw new Error('init.sherpaModulePath is required') - } - return require(modulePath) +// transcribe-cpp decodes off the event loop; one queue keeps feeds behind init and stop behind feeds. +let queue: Promise = Promise.resolve() +function enqueue(task: () => void | Promise): void { + // A task that throws must not stop every later one; the session's timeouts report the stream. + queue = queue.then(task).catch((err: unknown) => { + console.error('[speech-worker] task failed:', err) + }) } -function handleInit(msg: Extract): void { +async function handleInit(init: SttRecognizerInit): Promise { try { - sherpa = loadSherpa(msg.sherpaModulePath) - isStreaming = msg.streaming - modelSampleRate = msg.sampleRate - const { online, config } = buildRecognizerConfig(msg) - recognizer = online - ? sherpa.createOnlineRecognizer(config) - : sherpa.createOfflineRecognizer(config) + const post: SttRecognizerPost = (event) => parentPort.post(event) + recognizer = + speechEngineForModelFiles(init.files) === 'transcribe-cpp' + ? await createTranscribeCppRecognizer(init, post) + : createSherpaRecognizer(init, post) parentPort.post({ type: 'ready' }) } catch (err) { parentPort.post({ type: 'error', error: String(err) }) } } -function openStream(streamId: string): SessionStream { - const existing = streams.get(streamId) - if (existing) { - return existing - } - const stream: SessionStream = { - online: isStreaming ? sherpa.createOnlineStream(recognizer) : null, - offlineChunker: isStreaming ? null : new OfflineAudioChunker(modelSampleRate), - primed: false, - lastPartialText: '' - } - streams.set(streamId, stream) - return stream -} - -function decodeOfflineChunk(samples: Float32Array): string { - const stream = sherpa.createOfflineStream(recognizer) - sherpa.acceptWaveformOffline(stream, { sampleRate: modelSampleRate, samples }) - sherpa.decodeOfflineStream(recognizer, stream) - const result = JSON.parse(sherpa.getOfflineStreamResultAsJson(stream)) - return result?.text?.trim() ?? '' -} - -function feedOnline(streamId: string, stream: SessionStream, samples: Float32Array): void { - if (!stream.primed) { - // New streams start with empty encoder caches; zipformer en-20M dropped most of the first second - // (first word wrong on 71 of 73 LibriSpeech clips). A second of silence fills them first. - sherpa.acceptWaveformOnline(stream.online, { - sampleRate: modelSampleRate, - samples: new Float32Array(modelSampleRate) - }) - stream.primed = true - } - sherpa.acceptWaveformOnline(stream.online, { sampleRate: modelSampleRate, samples }) - while (sherpa.isOnlineStreamReady(recognizer, stream.online)) { - sherpa.decodeOnlineStream(recognizer, stream.online) - } - const result = JSON.parse(sherpa.getOnlineStreamResultAsJson(recognizer, stream.online)) - const text = result?.text?.trim() - if (text && text !== stream.lastPartialText) { - stream.lastPartialText = text - parentPort.post({ type: 'partial', streamId, text }) - } - if (sherpa.isEndpoint(recognizer, stream.online)) { - if (text) { - parentPort.post({ type: 'final', streamId, text }) - } - sherpa.reset(recognizer, stream.online) - stream.lastPartialText = '' - } -} - -function feedOffline(streamId: string, stream: SessionStream, samples: Float32Array): void { - // Bound ONNX allocations below Chromium's >=2GiB crash threshold (#7925); consumers append final segments. - const readyChunks = stream.offlineChunker?.push(new Float32Array(samples)) ?? [] - // push() already consumed these windows; keep decoding later ones after a failure. - let firstError: unknown = null - for (const chunk of readyChunks) { - try { - const text = decodeOfflineChunk(chunk) - if (text) { - parentPort.post({ type: 'final', streamId, text }) - } - } catch (err) { - firstError ??= err - } - } - if (firstError) { - throw firstError - } -} - -function handleFeed(msg: Extract): void { - if (!recognizer) { - return - } +async function handleFeed(msg: Extract): Promise { try { - const stream = openStream(msg.streamId) - // Normalize rates before native decode: mixed rates on one recognizer abort the process. - const samples = resampleToRate(msg.samples, msg.sampleRate || modelSampleRate, modelSampleRate) - if (isStreaming) { - feedOnline(msg.streamId, stream, samples) - } else { - feedOffline(msg.streamId, stream, samples) - } + await recognizer?.feed(msg.streamId, msg.samples, msg.sampleRate) } catch (err) { parentPort.post({ type: 'error', streamId: msg.streamId, error: String(err) }) } } -function finishStream(stream: SessionStream): string { - if (stream.online) { - sherpa.acceptWaveformOnline(stream.online, { - sampleRate: modelSampleRate, - samples: createStopTailPadding(modelSampleRate) - }) - sherpa.inputFinished(stream.online) - while (sherpa.isOnlineStreamReady(recognizer, stream.online)) { - sherpa.decodeOnlineStream(recognizer, stream.online) - } - const result = JSON.parse(sherpa.getOnlineStreamResultAsJson(recognizer, stream.online)) - return result?.text?.trim() ?? '' - } - // flush() leaves less than one bounded chunk. - const remaining = stream.offlineChunker?.flush() - return remaining && remaining.length > 0 ? decodeOfflineChunk(remaining) : '' -} - function attachAudioPort(streamId: string, port: MessagePortMain | undefined): void { if (!port) { return @@ -181,31 +64,30 @@ function attachAudioPort(streamId: string, port: MessagePortMain | undefined): v port.on('message', ({ data }) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the desktop preload holds the other end, and it sends SpeechAudioPortMessage. const msg = data as SpeechAudioPortMessage - if (msg.type === 'end') { - end() - } else { - handleFeed({ type: 'feed', streamId, samples: msg.samples, sampleRate: msg.sampleRate }) - } + enqueue(() => { + // A discard or end ran first; a late frame must not reopen the stream. + if (audioPorts.get(streamId) !== input) { + return + } + return msg.type === 'end' + ? end() + : handleFeed({ type: 'feed', streamId, samples: msg.samples, sampleRate: msg.sampleRate }) + }) }) port.on('close', end) audioPorts.set(streamId, input) port.start() } -function handleStop(streamId: string): void { +async function handleStop(streamId: string): Promise { const input = audioPorts.get(streamId) // Main's stop can overtake the renderer's last frames on the port; finish only after them. if (input) { - input.onEnded = () => handleStop(streamId) + input.onEnded = () => enqueue(() => handleStop(streamId)) return } - const stream = streams.get(streamId) - streams.delete(streamId) try { - const text = stream && recognizer ? finishStream(stream) : '' - if (text) { - parentPort.post({ type: 'final', streamId, text }) - } + await recognizer?.finish(streamId) } catch (err) { parentPort.post({ type: 'error', streamId, error: String(err) }) } finally { @@ -218,28 +100,30 @@ function handleDiscard(streamId: string): void { const input = audioPorts.get(streamId) audioPorts.delete(streamId) input?.port.close() - streams.delete(streamId) + recognizer?.discard(streamId) parentPort.post({ type: 'stopped', streamId }) } -parentPort.onMessage((message, ports) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the session host posts here, and it sends WorkerMessage. - const msg = message as WorkerMessage +function handleMessage( + msg: SttWorkerRequest, + ports: readonly MessagePortMain[] +): void | Promise { switch (msg.type) { case 'init': - handleInit(msg) - break + return handleInit(msg) case 'feed': - handleFeed(msg) - break + return handleFeed(msg) case 'stop': - handleStop(msg.streamId) - break + return handleStop(msg.streamId) case 'discard': - handleDiscard(msg.streamId) - break + return handleDiscard(msg.streamId) case 'attach-audio': - attachAudioPort(msg.streamId, ports[0]) - break + return attachAudioPort(msg.streamId, ports[0]) } +} + +parentPort.onMessage((message, ports) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the session host posts here, and it sends SttWorkerRequest. + const msg = message as SttWorkerRequest + enqueue(() => handleMessage(msg, ports)) }) diff --git a/src/shared/speech-model-engine.ts b/src/shared/speech-model-engine.ts new file mode 100644 index 00000000000..6dd770b6695 --- /dev/null +++ b/src/shared/speech-model-engine.ts @@ -0,0 +1,6 @@ +export type SpeechEngine = 'sherpa' | 'transcribe-cpp' + +/** GGUF models run on transcribe-cpp; everything else on sherpa-onnx. */ +export function speechEngineForModelFiles(files: readonly string[]): SpeechEngine { + return files.some((file) => file.endsWith('.gguf')) ? 'transcribe-cpp' : 'sherpa' +} diff --git a/src/shared/speech-recognizer-package.ts b/src/shared/speech-recognizer-package.ts index 7a12a2c2e6f..eea35ecf35f 100644 --- a/src/shared/speech-recognizer-package.ts +++ b/src/shared/speech-recognizer-package.ts @@ -1,6 +1,11 @@ -/** Desktop and orca serve share the app’s platform recognizer addon; orcad slots omit the ~70 MB package. */ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { SpeechEngine } from './speech-model-engine' +import transcribeCppPackages from './transcribe-cpp-packages.json' -/** Set by `orca serve` for orcad: the directory of the app's recognizer package. */ +/** Desktop and orca serve share the app’s speech engine packages; orcad slots omit them. */ + +/** Set by `orca serve` for orcad: the app's node_modules directory holding the speech engine packages. */ export const SPEECH_RECOGNIZER_DIR_ENV = 'ORCA_SPEECH_RECOGNIZER_DIR' export function speechRecognizerPackageName(platform: string, arch: string): string { @@ -8,3 +13,25 @@ export function speechRecognizerPackageName(platform: string, arch: string): str ? 'sherpa-onnx-win-x64' : `sherpa-onnx-${platform}-${arch}` } + +// Intel Macs stay on sherpa: transcribe-cpp's darwin-x64 build is SSE-only with no GPU. Packaging reads the same table. +const TRANSCRIBE_CPP_PACKAGES: Record = transcribeCppPackages + +/** Whether `nodeModulesDir` holds a loadable copy of `engine` for this host. */ +export function hasSpeechEnginePackage( + nodeModulesDir: string, + engine: SpeechEngine, + platform: string = process.platform, + arch: string = process.arch +): boolean { + if (engine === 'sherpa') { + const sherpa = speechRecognizerPackageName(platform, arch) + return existsSync(join(nodeModulesDir, sherpa, 'sherpa-onnx.node')) + } + const native = TRANSCRIBE_CPP_PACKAGES[`${platform}-${arch}`] + return ( + native !== undefined && + existsSync(join(nodeModulesDir, 'transcribe-cpp', 'package.json')) && + existsSync(join(nodeModulesDir, ...native.split('/'), 'contract.json')) + ) +} diff --git a/src/shared/speech-types.ts b/src/shared/speech-types.ts index 69e6b77f609..4f8e8bd2061 100644 --- a/src/shared/speech-types.ts +++ b/src/shared/speech-types.ts @@ -76,6 +76,8 @@ export type UserModelConfig = { // See dictation-shortcut-mode.ts for Auto. export type DictationMode = 'toggle' | 'hold' +export type SpeechGpuMode = 'auto' | 'off' + export type VoiceSettings = { enabled: boolean sttModel: string @@ -95,4 +97,6 @@ export type VoiceSettings = { microphoneDeviceLabel: string | null /** Minutes an idle on-device model stays loaded; 0 unloads after each dictation, null never unloads. */ unloadModelAfterMinutes: number | null + /** 'off' keeps on-device recognition on the CPU, for machines whose GPU driver hard-crashes. Absent = 'auto'. */ + speechGpu?: SpeechGpuMode } diff --git a/src/shared/transcribe-cpp-packages.json b/src/shared/transcribe-cpp-packages.json new file mode 100644 index 00000000000..61873816e74 --- /dev/null +++ b/src/shared/transcribe-cpp-packages.json @@ -0,0 +1,6 @@ +{ + "darwin-arm64": "@transcribe-cpp/darwin-arm64-metal", + "linux-x64": "@transcribe-cpp/linux-x64-cpu-vulkan", + "linux-arm64": "@transcribe-cpp/linux-arm64-cpu-vulkan", + "win32-x64": "@transcribe-cpp/win32-x64-cpu-vulkan" +} From f54514ea24b59d974d454b8e0ca35e36aaeef529 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 08:42:54 -0800 Subject: [PATCH 012/138] test: prune low-value browser WSL and telemetry coverage (#40418) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 2 - .../scripts/ci/sharding/ci-shard-timings.json | 2 - .../server-prompt-sent-telemetry.test.ts | 108 +--------- .../wsl-relay/wsl-hook-relay-manager.test.ts | 203 +----------------- .../wsl-relay/wsl-hook-relay-recovery.test.ts | 115 ---------- .../wsl-relay/wsl-hook-relay-sentinel.test.ts | 71 +----- .../wsl-opencode-settings-lifetime.test.ts | 9 +- .../wsl-relay/wsl-opencode-settings.test.ts | 136 ------------ .../browser-manager-downloads.test.ts | 102 +-------- 9 files changed, 10 insertions(+), 738 deletions(-) delete mode 100644 src/main/agent-hooks/wsl-relay/wsl-hook-relay-recovery.test.ts delete mode 100644 src/main/agent-hooks/wsl-relay/wsl-opencode-settings.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 8350dba4fc3..99614e04164 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -147,7 +147,6 @@ 2 test-no-real-time-wait:src/main/agent-hooks/server/testing/http/server-transport-interference.test.ts 1 test-no-real-time-wait:src/main/agent-hooks/server/testing/main-agent/server-turn-ended-at.test.ts 1 test-no-real-time-wait:src/main/agent-hooks/server/testing/main-agent/server-turn-started-at.test.ts -5 test-no-real-time-wait:src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts 1 test-no-real-time-wait:src/main/ai-vault/scanners/opencode/reader-transport/session-scanner-opencode-sqlite-process.test.ts 2 test-no-real-time-wait:src/main/ai-vault/scanners/opencode/sqlite/session-scanner-opencode-sqlite-scan-scope.test.ts 1 test-no-real-time-wait:src/main/antigravity/windows-hook-json-post.test.ts @@ -691,7 +690,6 @@ 2 test-timeout-needs-reason:src/main/acp/journal/acp-structured-broken-stream.test.ts 4 test-timeout-needs-reason:src/main/agent-hooks/server/testing/http/server-transport-interference.test.ts 1 test-timeout-needs-reason:src/main/agent-hooks/wsl-relay/testing/wsl-hook-relay-live.integration.test.ts -1 test-timeout-needs-reason:src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts 1 test-timeout-needs-reason:src/main/ai-vault/parse-cache/session-parse-cache-large-scan.test.ts 1 test-timeout-needs-reason:src/main/antigravity/native-macos-credentials.real.test.ts 1 test-timeout-needs-reason:src/main/antigravity/posix-hook-invocation.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 4763dd10065..f9e502b4944 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -629,10 +629,8 @@ "src/main/agent-hooks/wsl-relay/testing/wsl-hook-relay-live.integration.test.ts": 2231, "src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts": 3176, "src/main/agent-hooks/wsl-relay/wsl-hook-relay-reattach.test.ts": 1280, - "src/main/agent-hooks/wsl-relay/wsl-hook-relay-recovery.test.ts": 1529, "src/main/agent-hooks/wsl-relay/wsl-hook-relay-sentinel.test.ts": 62, "src/main/agent-hooks/wsl-relay/wsl-opencode-settings-lifetime.test.ts": 1951, - "src/main/agent-hooks/wsl-relay/wsl-opencode-settings.test.ts": 1364, "src/main/agent-launch/agent-launch-account-env.test.ts": 958, "src/main/agent-launch/agent-launch-executor-cancel.test.ts": 1455, "src/main/agent-launch/agent-launch-mode-settings.test.ts": 4041, diff --git a/src/main/agent-hooks/server/testing/telemetry/server-prompt-sent-telemetry.test.ts b/src/main/agent-hooks/server/testing/telemetry/server-prompt-sent-telemetry.test.ts index 3488f31c9ec..71bc5a078c9 100644 --- a/src/main/agent-hooks/server/testing/telemetry/server-prompt-sent-telemetry.test.ts +++ b/src/main/agent-hooks/server/testing/telemetry/server-prompt-sent-telemetry.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, PANE, GOOD_PANE, FRESH_PANE } from '../server.test-fixtures' +import { buildBody, PANE } from '../server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -27,112 +27,6 @@ afterEach(() => { }) describe('AgentHookServer prompt-sent telemetry', () => { - it('dedupes duplicate Command Code stop hooks but tracks same-prompt reruns', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-user-1', - payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-user-1', - payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-user-2', - payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(2) - }) - - it('does not let a reused interaction key suppress different prompt text', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-reused', - payload: { state: 'done', prompt: 'first command', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-reused', - payload: { state: 'done', prompt: 'second command', agentType: 'command-code' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(2) - }) - - it('does not track replays, empty prompts, or inherited prompt snapshots', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - isReplay: true, - payload: { state: 'working', prompt: 'replayed prompt', agentType: 'codex' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: GOOD_PANE, - tabId: 'tab-good', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: ' ', agentType: 'codex' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: FRESH_PANE, - tabId: 'tab-fresh', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'inherited prompt', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - }) - it('tracks OpenCode user MessagePart hooks once per message id', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts index dcf4b5694d7..57d9ecb2877 100644 --- a/src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-relay/wsl-hook-relay-manager.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,14 +8,8 @@ import { RelayDispatcher } from '../../../wsl-guest/dispatcher' import { registerWslHookFsHandlers } from '../../../wsl-guest/wsl-hook-fs-bridge' import { SshChannelMultiplexer } from '../../ssh/protocol/ssh-channel-multiplexer' import type { MultiplexerTransport } from '../../ssh/protocol/ssh-multiplexer-transport-writer' -import { createWslHookSftpAdapter, installWslGuestHooks } from './wsl-hook-fs-adapter' -import { - installRemoteManagedAgentHooks, - REMOTE_MANAGED_HOOK_INSTALLER_AGENTS -} from '../installation/remote-managed-hook-installers' +import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter' import { WslHookRelayManager } from './wsl-hook-relay-manager' -import { CLAUDE_AGENT_VIEW_RELAY_METHOD } from '../../../shared/claude-agent-view' -import { awaitExplicitAgentPluginGuestReadiness } from './wsl-agent-plugin-readiness' import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, @@ -110,51 +104,6 @@ describe.skipIf(process.platform === 'win32')( }) expect(outside).toBeInstanceOf(Error) }) - - it('runs the unchanged remote managed hook installers against a WSL guest home', async () => { - const adapter = createWslHookSftpAdapter(harness.mux) - const results = await installRemoteManagedAgentHooks(adapter, home, { - agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS, - claudeVersion: '2.1.310' - }) - - expect(results.length).toBeGreaterThan(0) - expect(results.every((r) => r.state !== 'error')).toBe(true) - - const claudeSettings = JSON.parse( - readFileSync(join(home, '.claude', 'settings.json'), 'utf8') - ) - expect(claudeSettings.hooks.StopFailure).toBeTruthy() - let rawVersion = '11.17.0' - harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({ - agents: ['claude'], - versions: { claude: rawVersion } - })) - const reinstall = () => - installWslGuestHooks({ - mux: harness.mux, - guestHome: home, - codexHomePath: null, - distro: 'Ubuntu', - installHooks: installRemoteManagedAgentHooks, - settings: null, - warn: (message) => { - throw new Error(message) - }, - installCodex: async () => null - }) - await reinstall() - expect(JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'))).toEqual( - claudeSettings - ) - rawVersion = 'launcher 11.17.0\n1.0.64 (Claude Code)' - await reinstall() - const oldSettings = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8')) - expect(oldSettings.hooks.StopFailure).toBeUndefined() - expect(oldSettings.hooks.PreToolUse).toBeTruthy() - const script = readFileSync(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8') - expect(script).toContain('/hook/claude') - }, 20_000) } ) @@ -231,12 +180,6 @@ describe('WslHookRelayManager', () => { return harness.transport } - function startupError(code: number | null, stderr = ''): Error { - return Object.assign(new Error(`exit ${code}`), { - startup: { kind: 'exit' as const, code, stderr } - }) - } - function createManager(overrides: Partial): { manager: WslHookRelayManager deps: WslHookRelayManagerDeps @@ -281,60 +224,6 @@ describe('WslHookRelayManager', () => { return { manager: new WslHookRelayManager(deps), deps } } - it('forwards Claude view requests from the guest only while hooks are enabled', async () => { - let enabled = true - const receive = vi.fn(() => ({ registered: true })) - const { manager, deps } = createManager({ - receiveClaudeAgentView: receive, - managedHookSettings: () => ({ agentStatusHooksEnabled: enabled }) - }) - await manager.ensureForDistro('Ubuntu', codexHome) - await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledOnce()) - const guest = harnesses[0].guestDispatcher - harnesses[0].mux.dispose() - const body = { action: 'register', paneKey: 'pane', sessionId: 'session' } - expect(await guest.requestPrimary(CLAUDE_AGENT_VIEW_RELAY_METHOD, { body })).toEqual({ - registered: true - }) - expect(receive).toHaveBeenCalledWith(body) - enabled = false - expect(await guest.requestPrimary(CLAUDE_AGENT_VIEW_RELAY_METHOD, { body })).toEqual({}) - expect(receive).toHaveBeenCalledOnce() - manager.disposeAll() - }) - - it('does not treat the endpoint as ready before guest install completes', async () => { - let releaseInstall!: () => void - const installGate = new Promise((resolve) => { - releaseInstall = resolve - }) - const { manager } = createManager({ - installHooks: vi.fn(async () => installGate.then(() => [])) - }) - const readiness = awaitExplicitAgentPluginGuestReadiness({ - isWsl: true, - distro: 'Ubuntu', - launchAgent: 'pi', - timeoutMs: 50, - manager - }) - await vi.waitFor(() => expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()) - releaseInstall() - await expect(readiness).resolves.toBe(true) - manager.disposeAll() - }) - - it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => { - const waitForSentinel = vi.fn(async () => guestTransport({ registerInstallPlugins: false })) - const { manager, deps } = createManager({ waitForSentinel }) - manager.ensureForDistro('Ubuntu') - await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBeNull() - expect(deps.warn).not.toHaveBeenCalledWith(expect.stringContaining('installPlugins')) - manager.disposeAll() - }) - // Memory and battery: once no bridge terminal is left, the relay alone held the distro and VM. describe('idle stop', () => { const presence = () => { @@ -389,94 +278,6 @@ describe('WslHookRelayManager', () => { expect(deps.spawnRelay).toHaveBeenCalledOnce() manager.disposeAll() }) - - it('starts again, with the config it served, on the next terminal or agent in the distro', async () => { - const bridges = presence() - bridges.set(0) - const { manager, deps } = createManager({ bridgePtyPresence: bridges, idleStopMs: 1 }) - manager.ensureForDistro('Ubuntu', codexHome) - await vi.waitFor(() => - expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('stopped: idle')) - ) - vi.mocked(deps.installCodex).mockClear() - bridges.set(1) - - manager.ensureForDistro('Ubuntu') - await vi.waitFor(() => expect(manager.getGuestEndpointFilePath('Ubuntu')).not.toBeNull()) - expect(deps.spawnRelay).toHaveBeenCalledTimes(2) - expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu') - manager.disposeAll() - }) - }) - - it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => { - const settings = { agentStatusHooksEnabled: true } - const { manager, deps } = createManager({ managedHookSettings: () => settings }) - manager.ensureForDistro('Ubuntu', codexHome) - await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) - - settings.agentStatusHooksEnabled = false - manager.disposeAll({ permanent: false }) - manager.ensureForDistro('Ubuntu') - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(deps.spawnRelay).toHaveBeenCalledTimes(1) - expect(deps.installHooks).toHaveBeenCalledTimes(1) - expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull() - - settings.agentStatusHooksEnabled = true - manager.resumeStoppedRelays() - await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2)) - await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) - expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu') - manager.disposeAll() - }) - - it('does not resume a relay whose distro the user shut down while hooks were off', async () => { - const settings = { agentStatusHooksEnabled: true } - const isDistroRunning = vi.fn(async () => true) - const { manager, deps } = createManager({ - isDistroRunning, - managedHookSettings: () => settings - }) - manager.ensureForDistro('Ubuntu') - await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(1)) - - settings.agentStatusHooksEnabled = false - manager.disposeAll({ permanent: false }) - settings.agentStatusHooksEnabled = true - // Recovery must not boot a distro the user shut down. - isDistroRunning.mockResolvedValue(false) - manager.resumeStoppedRelays() - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(deps.spawnRelay).toHaveBeenCalledTimes(1) - isDistroRunning.mockResolvedValue(true) - manager.resumeStoppedRelays() - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(deps.spawnRelay).toHaveBeenCalledTimes(1) - }) - - it('abandons a launch that was still in flight when hooks were switched off', async () => { - let failSentinel: ((error: unknown) => void) | undefined - const { manager, deps } = createManager({ - waitForSentinel: vi.fn( - () => - new Promise((_resolve, reject) => { - failSentinel = reject - }) - ) - }) - manager.ensureForDistro('Ubuntu') - await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(1)) - - manager.disposeAll({ permanent: false }) - // Teardown startup failures must not retry into an untracked relay after opt-out. - failSentinel?.(startupError(1)) - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(deps.spawnRelay).toHaveBeenCalledTimes(1) - expect(deps.runInstall).not.toHaveBeenCalled() }) it('keeps a fresh state that replaced a failed one while its restart probe was in flight', async () => { diff --git a/src/main/agent-hooks/wsl-relay/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-relay/wsl-hook-relay-recovery.test.ts deleted file mode 100644 index 471dcbce2f8..00000000000 --- a/src/main/agent-hooks/wsl-relay/wsl-hook-relay-recovery.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type * as WslBackgroundDistroGate from '../../wsl/discovery/wsl-background-distro-gate' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { WslRelayRecovery, type WslRelayRecoveryState } from './wsl-hook-relay-recovery' -import type { WslRelayRestartSetup } from './wsl-hook-relay-state' - -const { noteWslDistroMayHaveStoppedMock } = vi.hoisted(() => ({ - noteWslDistroMayHaveStoppedMock: vi.fn() -})) -vi.mock('../../wsl/discovery/wsl-background-distro-gate', async (importOriginal) => ({ - ...(await importOriginal()), - ...(await (() => ({ - noteWslDistroMayHaveStopped: noteWslDistroMayHaveStoppedMock - }))()) -})) - -beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })) - -afterEach(() => vi.useRealTimers()) - -function makeState(): WslRelayRecoveryState { - return { distro: 'Ubuntu', cooldownUntil: Date.now() - 1_000 } -} - -function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { - return vi.waitFor(() => expect(condition()).toBe(true), { timeout: timeoutMs }) -} - -describe('WslRelayRecovery', () => { - it('re-reads a settled list before a drained restart, and waits again if the distro stopped', async () => { - const restart = vi.fn() - const deferred: (() => void)[] = [] - const isDistroRunning = vi.fn(async (_distro: string, _startedAfter: number) => false) - const recovery = new WslRelayRecovery({ - isDistroRunning, - deferUntilRunning: (_distro, task) => deferred.push(task), - captureSetup: () => ({ launchKinds: [] }), - warn: vi.fn(), - isDisposed: () => false, - isCurrent: () => true, - restart, - dropState: vi.fn() - }) - recovery.scheduleRestart(makeState()) - await vi.advanceTimersByTimeAsync(250) - await waitFor(() => deferred.length === 1) - - // Drained by a list that still named the stopping distro; a fresh settled read says stopped. - deferred[0]() - await waitFor(() => deferred.length === 2) - expect(restart).not.toHaveBeenCalled() - - isDistroRunning.mockResolvedValue(true) - deferred[1]() - await waitFor(() => restart.mock.calls.length === 1) - }) - - it('restarts with the relay config captured before dropState discarded it', async () => { - const restart = vi.fn() - const deferred: (() => void)[] = [] - let live: WslRelayRestartSetup | undefined = { - codexHomePath: '/home/u/.orca-codex', - claudeConfigDir: '/home/u/.orca/claude', - launchKinds: ['pi', 'omp'] - } - const expected = live - const isDistroRunning = vi.fn(async (_distro: string, _startedAfter: number) => false) - const recovery = new WslRelayRecovery({ - isDistroRunning, - deferUntilRunning: (_distro, task) => deferred.push(task), - captureSetup: () => live ?? { launchKinds: [] }, - warn: vi.fn(), - isDisposed: () => false, - isCurrent: () => true, - restart, - dropState: () => { - live = undefined - } - }) - recovery.scheduleRestart(makeState()) - await vi.advanceTimersByTimeAsync(250) - await waitFor(() => deferred.length === 1) - isDistroRunning.mockResolvedValue(true) - deferred[0]() - await waitFor(() => restart.mock.calls.length === 1) - expect(restart).toHaveBeenCalledExactlyOnceWith('Ubuntu', expected) - }) - - it('re-probes once after a stopped verdict and restarts only once', async () => { - const restart = vi.fn() - const deferred: (() => void)[] = [] - const isDistroRunning = vi.fn(async () => false) - const recovery = new WslRelayRecovery({ - isDistroRunning, - deferUntilRunning: (_distro, task) => deferred.push(task), - captureSetup: () => ({ launchKinds: [] }), - warn: vi.fn(), - isDisposed: () => false, - isCurrent: () => true, - restart, - dropState: vi.fn() - }) - recovery.scheduleRestart(makeState()) - await vi.advanceTimersByTimeAsync(250) - await waitFor(() => deferred.length === 1) - // The first read was wrong (the distro was still running): the re-probe recovers it. - isDistroRunning.mockResolvedValue(true) - await vi.advanceTimersByTimeAsync(30_000) - await waitFor(() => restart.mock.calls.length === 1) - deferred[0]() - await vi.advanceTimersByTimeAsync(60_000) - expect(isDistroRunning).toHaveBeenCalledTimes(2) - expect(restart).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/agent-hooks/wsl-relay/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-relay/wsl-hook-relay-sentinel.test.ts index 0940efda51d..430e4c07d96 100644 --- a/src/main/agent-hooks/wsl-relay/wsl-hook-relay-sentinel.test.ts +++ b/src/main/agent-hooks/wsl-relay/wsl-hook-relay-sentinel.test.ts @@ -2,12 +2,8 @@ import { EventEmitter } from 'node:events' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { afterEach, describe, expect, it, vi } from 'vitest' -import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../../ssh/protocol/relay-handshake' -import { - MAX_STARTUP_BUFFER_BYTES, - waitForWslRelaySentinel, - type WslRelayStartupFailure -} from './wsl-hook-relay-sentinel' +import { RELAY_SENTINEL } from '../../ssh/protocol/relay-handshake' +import { waitForWslRelaySentinel } from './wsl-hook-relay-sentinel' type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType } @@ -50,36 +46,11 @@ function emitStdout(child: ChildProcessWithoutNullStreams, data: string | Buffer child.stdout.emit('data', Buffer.isBuffer(data) ? data : Buffer.from(data)) } -function catchStartup( - promise: Promise -): Promise { - return promise.then( - () => { - throw new Error('expected the sentinel wait to reject') - }, - (err: Error & { startup?: WslRelayStartupFailure }) => err - ) -} - afterEach(() => { vi.useRealTimers() }) describe('waitForWslRelaySentinel', () => { - it('resolves when the sentinel is split across two chunks', async () => { - const child = fakeChild() - const promise = waitForWslRelaySentinel(child) - const sentinel = Buffer.from(RELAY_SENTINEL) - const mid = Math.floor(sentinel.length / 2) - emitStdout(child, sentinel.subarray(0, mid)) - emitStdout(child, sentinel.subarray(mid)) - const transport = await promise - transport.pauseReads?.() - transport.resumeReads?.() - expect(child.stdout.pause).toHaveBeenCalledOnce() - expect(child.stdout.resume).toHaveBeenCalledOnce() - }) - it('delivers a pending trailing chunk before a later direct chunk, in wire order', async () => { const child = fakeChild() const promise = waitForWslRelaySentinel(child) @@ -110,33 +81,6 @@ describe('waitForWslRelaySentinel', () => { expect(received).toEqual(['DEFER']) }) - it('kills the child and rejects when startup output exceeds 64 KiB before the sentinel', async () => { - const child = fakeChild() - const settled = catchStartup(waitForWslRelaySentinel(child)) - emitStdout(child, Buffer.alloc(MAX_STARTUP_BUFFER_BYTES + 1, 0x41)) - const err = await settled - expect(err.message).toMatch(/64 KiB/) - expect(child.kill).toHaveBeenCalled() - }) - - it('rejects with an exit failure when the child closes before the sentinel', async () => { - const child = fakeChild() - const settled = catchStartup(waitForWslRelaySentinel(child)) - child.emit('close', 7) - const err = await settled - expect(err.startup).toEqual({ kind: 'exit', code: 7, stderr: '' }) - }) - - it('kills the child and rejects with a timeout after the sentinel deadline', async () => { - vi.useFakeTimers() - const child = fakeChild() - const settled = catchStartup(waitForWslRelaySentinel(child)) - await vi.advanceTimersByTimeAsync(RELAY_SENTINEL_TIMEOUT_MS + 1) - const err = await settled - expect(child.kill).toHaveBeenCalled() - expect(err.startup?.kind).toBe('timeout') - }) - it('forwards write(false), callback settlement, and drain from WSL stdin', async () => { const child = fakeChild() const callback = vi.fn() @@ -157,15 +101,4 @@ describe('waitForWslRelaySentinel', () => { expect(drain).toHaveBeenCalledOnce() expect(transport.supportsWriteSettlement).toBe(true) }) - - it('swallows async stdin EPIPE after the guest dies so it is not uncaught', async () => { - const child = fakeChild() - const promise = waitForWslRelaySentinel(child) - emitStdout(child, RELAY_SENTINEL) - await promise - // Real pipes emit asynchronous EPIPE after a failed write. - expect(() => { - child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) - }).not.toThrow() - }) }) diff --git a/src/main/agent-hooks/wsl-relay/wsl-opencode-settings-lifetime.test.ts b/src/main/agent-hooks/wsl-relay/wsl-opencode-settings-lifetime.test.ts index 8266372ccbe..3e9d284e87b 100644 --- a/src/main/agent-hooks/wsl-relay/wsl-opencode-settings-lifetime.test.ts +++ b/src/main/agent-hooks/wsl-relay/wsl-opencode-settings-lifetime.test.ts @@ -170,11 +170,10 @@ describe.skipIf(process.platform === 'win32')('WSL public-manager settings lifet rmSync(root, { recursive: true, force: true }) }) - const cases = (['detect', 'hooks', 'plugin'] as const).flatMap((stage) => - [true, false].flatMap((initialEnabled) => - [true, false].map((toggleBack) => ({ stage, initialEnabled, toggleBack })) - ) - ) + const cases = [ + { stage: 'plugin', initialEnabled: true, toggleBack: false }, + { stage: 'plugin', initialEnabled: false, toggleBack: false } + ] as const it.each(cases)( 'manager settings across $stage: initially enabled $initialEnabled, toggle back $toggleBack', diff --git a/src/main/agent-hooks/wsl-relay/wsl-opencode-settings.test.ts b/src/main/agent-hooks/wsl-relay/wsl-opencode-settings.test.ts deleted file mode 100644 index 889f1867534..00000000000 --- a/src/main/agent-hooks/wsl-relay/wsl-opencode-settings.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' -import { RelayDispatcher } from '../../../wsl-guest/dispatcher' -import { PluginOverlayManager } from '../../../wsl-guest/plugin-overlay' -import { createInstallPluginsHandler } from '../../../wsl-guest/wsl-install-plugins-handler' -import { SshChannelMultiplexer } from '../../ssh/protocol/ssh-channel-multiplexer' -import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../../shared/agent-hook-listener/transport/agent-hook-relay' -import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' -import { - maybeRerunWslRelayGuestInstall, - runWslRelayGuestInstall -} from './wsl-hook-relay-guest-install' -import type { WslRelayDistroState } from './wsl-hook-relay-state' - -let home: string -let dispatcher: RelayDispatcher -let mux: SshChannelMultiplexer -let state: WslRelayDistroState -let settings: { disabledTuiAgents: TuiAgent[] } -let params: Record[] -const sources = { - opencodePluginSource: '// v1', - opencode2PluginSource: '// v2', - piExtensionSource: '// pi' -} -const deps = { - pluginSources: () => sources, - managedHookSettings: () => settings, - installHooks: async () => [], - installCodex: async () => null, - warn: (message: string) => { - throw new Error(message) - } -} -beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca-wsl-opencode-settings-')) - const xdg = join(home, 'xdg') - mkdirSync(join(xdg, 'opencode'), { recursive: true }) - writeFileSync(join(xdg, 'opencode', 'opencode.json'), '{"model":"guest-fixture"}') - settings = { disabledTuiAgents: [] } - params = [] - const callbacks: ((data: Buffer) => void)[] = [] - dispatcher = new RelayDispatcher((data) => { - for (const callback of callbacks) { - callback(data) - } - }) - mux = new SshChannelMultiplexer({ - write: (data) => { - dispatcher.feed(data) - }, - onData: (callback) => { - callbacks.push(callback) - }, - onClose: () => {} - }) - const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { - HOME: home, - XDG_CONFIG_HOME: xdg, - ORCA_WSL_HOOK_INSTANCE: 'settings-fixture' - }) - dispatcher.onRequest('preflight.detectAgents', async () => ({ agents: [] })) - dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (request) => { - params.push(request) - return install(request) - }) - state = { - distro: 'fixture', - phase: 'running', - mux, - guestHome: home, - launchKinds: new Set(), - failures: 0, - cooldownUntil: 0 - } -}) -afterEach(() => { - mux.dispose() - dispatcher.dispose() - rmSync(home, { recursive: true, force: true }) -}) - -describe('WSL settings over the existing plugin RPC into real fixture overlays', () => { - const cases: TuiAgent[][] = [[], ['opencode'], ['opencode2'], ['opencode', 'opencode2']] - it.each(cases.map((disabled) => ({ disabled })))( - 'honors disabled $disabled', - async ({ disabled }) => { - settings.disabledTuiAgents = disabled - const result = await requestGuestOpenCodeOverlayDir(mux, deps, 'fixture') - for (const agent of ['opencode', 'opencode2'] as const) { - const key = agent === 'opencode' ? 'opencodePluginSource' : 'opencode2PluginSource' - expect(params[0][key]).toBe(disabled.includes(agent) ? '' : sources[key]) - } - if (result.kind === 'dir') { - expect(Boolean(result.dir)).toBe(!disabled.includes('opencode')) - expect(Boolean(result.dir2)).toBe(!disabled.includes('opencode2')) - const dir = result.dir ?? result.dir2 - if (!dir) { - throw new Error('missing overlay') - } - expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"guest-fixture"}') - } else { - expect(disabled).toEqual(['opencode', 'opencode2']) - } - expect(params[0].piExtensionSource).toBe('// pi') - } - ) - - it('revokes cached sources before the reinstall interval and re-enables the same service', async () => { - await runWslRelayGuestInstall(deps, state, mux, home) - const dir = state.opencodeOverlayDir - if (!dir) { - throw new Error('missing original overlay') - } - const path = join(dir, 'plugins', 'orca-opencode-status.js') - writeFileSync(path, '// installed sentinel') - settings.disabledTuiAgents = ['opencode'] - await maybeRerunWslRelayGuestInstall(deps, state) - expect(state.opencodeOverlayDir).toBeUndefined() - expect(state.opencode2OverlayDir).toBeTruthy() - expect(readFileSync(path, 'utf8')).toBe('// installed sentinel') - const omitted = await requestGuestOpenCodeOverlayDir( - mux, - { ...deps, pluginSources: () => ({}) }, - 'fixture' - ) - expect(omitted.kind === 'dir' ? omitted.dir : undefined).toBeUndefined() - settings.disabledTuiAgents = [] - await maybeRerunWslRelayGuestInstall(deps, state) - expect(state.opencodeOverlayDir).toBe(dir) - expect(existsSync(path)).toBe(true) - }) -}) diff --git a/src/main/browser/guest-lifecycle/manager/downloads/browser-manager-downloads.test.ts b/src/main/browser/guest-lifecycle/manager/downloads/browser-manager-downloads.test.ts index 5534a0914fc..434572914b4 100644 --- a/src/main/browser/guest-lifecycle/manager/downloads/browser-manager-downloads.test.ts +++ b/src/main/browser/guest-lifecycle/manager/downloads/browser-manager-downloads.test.ts @@ -41,23 +41,14 @@ vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ })) import { browserManager } from '../browser-manager' -import { browserDownloadDestinationReservations } from '../../../downloads/browser-download-destination' import { createDownloadItem, getDownloadItemEventHandler, - rendererWebContentsId, resetBrowserManagerMocks, resetBrowserManagerState } from '../browser-manager-test-harness' -const { - guestOffMock, - guestOnMock, - guestSetBackgroundThrottlingMock, - guestSetWindowOpenHandlerMock, - guestOpenDevToolsMock, - webContentsFromIdMock -} = browserMocks +const { webContentsFromIdMock } = browserMocks describe('browserManager', () => { beforeEach(() => { @@ -91,95 +82,4 @@ describe('browserManager', () => { expect(next.setSavePath).toHaveBeenCalledWith(join('/downloads', 'report.csv')) claim.release() }) - - it('flushes started and terminal snapshots for downloads that finish before registration', () => { - const rendererSendMock = vi.fn() - const guest = { - id: 409, - isDestroyed: vi.fn(() => false), - isCurrentlyAudible: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock - } - const item = createDownloadItem() - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === guest.id) { - return guest - } - if (id === rendererWebContentsId) { - return { isDestroyed: vi.fn(() => false), send: rendererSendMock } - } - return null - }) - - browserManager.attachGuestPolicies(guest as never) - browserManager.handleGuestWillDownload({ guestWebContentsId: guest.id, item }) - - const doneHandler = getDownloadItemEventHandler(item, 'once', 'done') - doneHandler?.({} as Electron.Event, 'completed') - - expect(rendererSendMock).not.toHaveBeenCalled() - - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: guest.id, - rendererWebContentsId - }) - - // Registration also reports audio state; only download sends matter here. - expect( - rendererSendMock.mock.calls - .map(([channel]) => channel) - .filter((channel) => channel.startsWith('browser:download-')) - ).toEqual(['browser:download-requested', 'browser:download-finished']) - }) - - it('fails a download waiting for manual resume and releases its resources', async () => { - const rendererSendMock = vi.fn() - const item = createDownloadItem({ - getState: () => 'interrupted', - canResume: () => true - }) - const guest = { - id: 420, - isDestroyed: () => false, - isCurrentlyAudible: () => false, - getType: () => 'webview', - on: guestOnMock, - off: guestOffMock, - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock - } - webContentsFromIdMock.mockImplementation((id: number) => - id === guest.id ? guest : { isDestroyed: () => false, send: rendererSendMock } - ) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: guest.id, - rendererWebContentsId - }) - browserManager.handleGuestWillDownload({ guestWebContentsId: guest.id, item }) - const event = { preventDefault: () => {}, defaultPrevented: false } - getDownloadItemEventHandler(item, 'on', 'updated')?.(event, 'interrupted') - await new Promise((resolve) => setImmediate(resolve)) - expect(item.cancel).toHaveBeenCalledTimes(1) - // Registration also reports audio state; only download sends matter here. - expect( - rendererSendMock.mock.calls - .map(([channel]) => channel) - .filter((channel) => channel.startsWith('browser:download-')) - ).toEqual(['browser:download-requested', 'browser:download-finished']) - expect(rendererSendMock).toHaveBeenCalledWith( - 'browser:download-finished', - expect.objectContaining({ status: 'failed', error: 'Download was interrupted.' }) - ) - const managerState = browserManager as unknown as { downloadsById: Map } - expect(managerState.downloadsById.size).toBe(0) - expect(browserDownloadDestinationReservations.reserve('report.csv').filename).toBe('report.csv') - }) }) From ab2f5786e0691a4bf1e898617f517950b692cd41 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:54:30 +0000 Subject: [PATCH 013/138] feat(mobile): keep a failed dictation's recording and offer Retry (#40276) --- .../dictation/MobileDictationLiveControls.tsx | 118 +++++++++++- .../mobile-dictation-controls-copy.ts | 26 ++- .../mobile-dictation-live-context.ts | 4 + .../src/dictation/mobile-dictation-setup.ts | 2 +- .../dictation/use-mobile-dictation-live.ts | 19 +- .../src/hooks/mobile-dictation-audio-chunk.ts | 14 +- .../src/hooks/mobile-dictation-engine-run.ts | 83 ++------ mobile/src/hooks/mobile-dictation-failure.ts | 44 +++++ mobile/src/hooks/mobile-dictation-finish.ts | 77 ++++++++ .../src/hooks/mobile-dictation-held-audio.ts | 6 +- .../src/hooks/mobile-dictation-host-start.ts | 69 +++++++ .../src/hooks/mobile-dictation-recording.ts | 47 +++++ .../src/hooks/mobile-dictation-retry.test.tsx | 166 ++++++++++++++++ mobile/src/hooks/mobile-dictation-retry.ts | 42 +++++ .../hooks/mobile-dictation-session-state.ts | 9 +- mobile/src/hooks/use-mobile-dictation.ts | 178 +++++++----------- mobile/src/i18n/locales/de.json | 12 +- mobile/src/i18n/locales/en.json | 12 +- mobile/src/i18n/locales/es.json | 12 +- mobile/src/i18n/locales/fr.json | 12 +- mobile/src/i18n/locales/ja.json | 12 +- mobile/src/i18n/locales/ko.json | 12 +- mobile/src/i18n/locales/pt.json | 12 +- mobile/src/i18n/locales/ru.json | 12 +- mobile/src/i18n/locales/tr.json | 12 +- mobile/src/i18n/locales/zh-Hant.json | 12 +- mobile/src/i18n/locales/zh.json | 12 +- .../composer/MobileNativeChatComposer.tsx | 3 +- .../composer/MobileNativeChatComposerMic.tsx | 8 +- .../MobileNativeChatComposerSendSlot.tsx | 8 +- .../MobileNativeChatDictationLeading.tsx | 26 ++- ...se-mobile-session-native-chat-dictation.ts | 7 +- .../session/MobileTerminalInputActions.tsx | 23 ++- .../session/MobileTerminalLiveInputStatus.tsx | 11 ++ 34 files changed, 908 insertions(+), 214 deletions(-) create mode 100644 mobile/src/hooks/mobile-dictation-failure.ts create mode 100644 mobile/src/hooks/mobile-dictation-finish.ts create mode 100644 mobile/src/hooks/mobile-dictation-host-start.ts create mode 100644 mobile/src/hooks/mobile-dictation-recording.ts create mode 100644 mobile/src/hooks/mobile-dictation-retry.test.tsx create mode 100644 mobile/src/hooks/mobile-dictation-retry.ts diff --git a/mobile/src/dictation/MobileDictationLiveControls.tsx b/mobile/src/dictation/MobileDictationLiveControls.tsx index 2e298d16c80..0c5ece35fd9 100644 --- a/mobile/src/dictation/MobileDictationLiveControls.tsx +++ b/mobile/src/dictation/MobileDictationLiveControls.tsx @@ -1,14 +1,15 @@ import { useEffect, useMemo, useState, type Ref } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' -import { Laptop, Mic, Smartphone, Square, X } from 'lucide-react-native' +import { CircleAlert, Laptop, Mic, RotateCcw, Smartphone, Square, X } from 'lucide-react-native' import type { MobileDictationMeter } from '../hooks/mobile-dictation-meter' import type { MobileDictationEngineShown } from '../hooks/mobile-dictation-session-state' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { COMPOSER_CONTROL_HIT_SLOP, + COMPOSER_CONTROL_SIZE, composerInputStyles } from '../components/composer-input/composer-input-styles' -import { colors, spacing, typography } from '../theme/mobile-theme' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { @@ -27,11 +28,14 @@ const FINISHING_NOTE_DELAY_MS = 300 export function MobileDictationCancelButton({ onCancel, targetRef, - compact = false + compact = false, + discards = false }: { onCancel: () => void targetRef?: Ref compact?: boolean + // After a failure, ✕ throws the kept recording away. + discards?: boolean }): React.JSX.Element { useMobileColorScheme() useMobileTranslation() @@ -39,7 +43,9 @@ export function MobileDictationCancelButton({ [ composerInputStyles.controlButton, compact && styles.compactCancel, @@ -86,6 +92,77 @@ export function MobileDictationMicIcon({ ) } +// The failure that kept its recording: a reason and "Recording kept", on two lines in chat and one in the terminal. +export function MobileDictationFailureNote({ + reason, + compact = false +}: { + reason: string + compact?: boolean +}): React.JSX.Element { + useMobileColorScheme() + useMobileTranslation() + const kept = dictationControlsCopy.recordingKept() + return ( + + + {compact ? ( + + {`${reason} · ${kept}`} + + ) : ( + + + {reason} + + + {kept} + + + )} + + ) +} + +// Retry sits where Stop was: a labelled pill in chat, the round control in the terminal. +export function MobileDictationRetryButton({ + onRetry, + labelled +}: { + onRetry: () => void + labelled: boolean +}): React.JSX.Element { + useMobileColorScheme() + useMobileTranslation() + return ( + [ + labelled ? styles.retryPill : composerInputStyles.controlButton, + pressed && composerInputStyles.pressed + ]} + hitSlop={COMPOSER_CONTROL_HIT_SLOP} + onPress={onRetry} + > + + {labelled ? ( + {dictationControlsCopy.retryShort()} + ) : null} + + ) +} + function EngineIcon({ kind, color }: { kind: MobileDictationEngineShown['kind']; color: string }) { return kind === 'phone' ? ( @@ -190,5 +267,38 @@ const styles = themedStyleSheet(() => ({ flexShrink: 1, color: colors.textSecondary, fontSize: typography.metaSize + 1 + }, + failureLines: { + flexShrink: 1, + minWidth: 0 + }, + failureReason: { + color: colors.textPrimary, + fontSize: typography.metaSize + 1, + fontWeight: '500' + }, + failureKept: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + failureCompact: { + flexShrink: 1, + color: colors.textPrimary, + fontSize: typography.metaSize + }, + retryPill: { + height: COMPOSER_CONTROL_SIZE, + borderRadius: radii.pill, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 6, + paddingHorizontal: spacing.md, + backgroundColor: colors.surfaceBright + }, + retryLabel: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' } })) diff --git a/mobile/src/dictation/mobile-dictation-controls-copy.ts b/mobile/src/dictation/mobile-dictation-controls-copy.ts index 3947035da5c..8b3b7126219 100644 --- a/mobile/src/dictation/mobile-dictation-controls-copy.ts +++ b/mobile/src/dictation/mobile-dictation-controls-copy.ts @@ -1,5 +1,11 @@ import { translate } from '../i18n/mobile-i18n' -import type { MobileDictationEngineShown } from '../hooks/mobile-dictation-session-state' +import type { + MobileDictationEngineShown, + MobileDictationKeptFailure +} from '../hooks/mobile-dictation-session-state' +import { MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE } from '../hooks/mobile-dictation-pending-audio-budget' +import { phoneDictationFailedMessage } from '../hooks/mobile-dictation-device-engine' +import { dictationFailureMessage, dictationSessionLostMessage } from './mobile-dictation-setup' // Active: starting or recording, so the mic is a Stop. export type MobileDictationMicState = { starting: boolean; active: boolean; processing: boolean } @@ -11,6 +17,10 @@ export const dictationControlsCopy = { stop: () => translate('dictation.controls.stop', 'Stop and insert'), finishing: () => translate('dictation.controls.finishing', 'Finishing dictation'), cancel: () => translate('dictation.controls.cancel', 'Cancel dictation'), + discard: () => translate('dictation.controls.discard', 'Discard recording'), + retry: () => translate('dictation.controls.retry', 'Retry dictation'), + retryShort: () => translate('dictation.controls.retryShort', 'Retry'), + recordingKept: () => translate('dictation.failure.recordingKept', 'Recording kept'), mic: ({ starting, active, processing }: MobileDictationMicState) => starting ? dictationControlsCopy.starting() @@ -79,3 +89,17 @@ export function dictationMeterLabel( } return translate('dictation.controls.meter', 'Recording, {{duration}}', { duration }) } + +// The few words a kept failure shows in the composer row; the full message is too long for it. +export function dictationFailureReason({ message, offline }: MobileDictationKeptFailure): string { + if (offline || dictationFailureMessage(message) === dictationSessionLostMessage()) { + return translate('dictation.failure.connectionLost', 'Connection lost') + } + if (message === MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE) { + return translate('dictation.failure.connectionSlow', 'Connection too slow') + } + if (message === phoneDictationFailedMessage()) { + return translate('dictation.failure.phoneStopped', 'Stopped on this phone') + } + return translate('dictation.failure.stopped', 'Dictation stopped') +} diff --git a/mobile/src/dictation/mobile-dictation-live-context.ts b/mobile/src/dictation/mobile-dictation-live-context.ts index 1032c48710b..9f5d6126b3d 100644 --- a/mobile/src/dictation/mobile-dictation-live-context.ts +++ b/mobile/src/dictation/mobile-dictation-live-context.ts @@ -7,7 +7,11 @@ export type MobileDictationLive = { readonly engine: MobileDictationEngineShown | null // The paired computer's name; null when another server runs the workspace and the phone lacks its name. readonly computerName: string | null + // Also discards a failed dictation's kept recording. readonly cancel: () => void + // The short reason a dictation failed with its recording kept; Retry plays it to the computer again. + readonly failureReason: string | null + readonly retry: () => void } // The chat mic sits several composers deep (overlay, queued edits); it reads the live dictation from here. diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts index 9e7f034dbd8..421cd3c95ac 100644 --- a/mobile/src/dictation/mobile-dictation-setup.ts +++ b/mobile/src/dictation/mobile-dictation-setup.ts @@ -44,7 +44,7 @@ function speechlessServerMessage(): string { ) } -function isSpeechlessServerError(message: string): boolean { +export function isSpeechlessServerError(message: string): boolean { return message.startsWith('speech_unavailable') } diff --git a/mobile/src/dictation/use-mobile-dictation-live.ts b/mobile/src/dictation/use-mobile-dictation-live.ts index 5027a930c1f..37528abb0cb 100644 --- a/mobile/src/dictation/use-mobile-dictation-live.ts +++ b/mobile/src/dictation/use-mobile-dictation-live.ts @@ -4,10 +4,11 @@ import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-hos import { useHostDisplay } from '../transport/hosts/use-host-display' import { useHostProfile } from '../transport/hosts/use-host-profile' import type { MobileDictationLive } from './mobile-dictation-live-context' +import { dictationFailureReason } from './mobile-dictation-controls-copy' -// What every dictation control reads while one runs: meter, engine, the computer's name and cancel. +// What every dictation control reads while one runs or waits for Retry: meter, engine, the computer's name, cancel and Retry. export function useMobileDictationLive( - dictation: Pick, + dictation: Pick, cancel: () => void, hostId: string ): MobileDictationLive { @@ -20,9 +21,17 @@ export function useMobileDictationLive( const hostTitle = useHostDisplay(hostProfile).title // A workspace routed to another server transcribes there, and the phone lacks that server's name. const computerName = useWorkspaceExecutionHost() === undefined && hostProfile ? hostTitle : null - const { meter, engine } = dictation + const { meter, engine, failure, retry } = dictation + const failureReason = failure === null ? null : dictationFailureReason(failure) return useMemo( - () => ({ meter, engine, computerName, cancel: stableCancel }), - [meter, engine, computerName, stableCancel] + () => ({ + meter, + engine, + computerName, + cancel: stableCancel, + failureReason, + retry: () => void retry() + }), + [meter, engine, computerName, stableCancel, failureReason, retry] ) } diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts index c9a18b0d721..524c11ec299 100644 --- a/mobile/src/hooks/mobile-dictation-audio-chunk.ts +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -20,6 +20,7 @@ import type { } from '../platform/dictation-capture-contract' import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' import type { MobileDictationHeldAudio } from './mobile-dictation-held-audio' +import type { MobileDictationRecording } from './mobile-dictation-recording' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import type { MobileDictationRun } from './mobile-dictation-engine-run' @@ -35,7 +36,7 @@ type MobileDictationAudioChunkQueue = { stopAtAudioLimit: () => void } -// Holding: the microphone runs from the tap while the host's start reply is pending, or for a whole on-device dictation. +// Holding: the microphone runs from the tap while the host's start reply is pending, or while a replay's host start is. export type MobileDictationAudioPhase = 'closed' | 'holding' | 'sending' // Newer hosts report the text so far: shown while speaking, and handed over by a failure instead of dropped. @@ -122,6 +123,7 @@ export function useMobileDictationAudioChunks(capture: DictationCapture, run: Mo failActiveDictation: run.failActiveDictation, transcriptSoFar: run.keptTranscript, heldAudio: run.heldAudio.current, + recording: run.recording.current, // Like desktop: stop as the user would, so what the host kept is transcribed and inserted. stopAtAudioLimit: () => { if (run.status.current === 'recording') { @@ -142,7 +144,7 @@ export function subscribeMobileDictationAudioChunks( getActiveId: () => string | null audioPhase: () => MobileDictationAudioPhase }, - queue: Omit + queue: Omit & { recording: MobileDictationRecording } ): { remove: () => void; sendHeld: () => void } { // Reuse wiring on the microphone hot path instead of allocating per chunk. const chunkQueue = { ...queue, isActive: (id: string) => source.getActiveId() === id } @@ -159,11 +161,15 @@ export function subscribeMobileDictationAudioChunks( } const subscription = capture.onChunk((chunk) => { const dictationId = source.getActiveId() + if (!dictationId) { + return + } + // Kept whatever the phase, so a phone's failure or a failed send can play it to the computer again. + queue.recording.keep(chunk) const phase = source.audioPhase() - if (!dictationId || phase === 'closed') { + if (phase === 'closed') { return } - // An on-device dictation holds its audio with no computer at all. if (phase === 'holding') { queue.heldAudio.hold(chunk) return diff --git a/mobile/src/hooks/mobile-dictation-engine-run.ts b/mobile/src/hooks/mobile-dictation-engine-run.ts index 5abe593ec17..5d7307b19d8 100644 --- a/mobile/src/hooks/mobile-dictation-engine-run.ts +++ b/mobile/src/hooks/mobile-dictation-engine-run.ts @@ -9,11 +9,7 @@ import type { MobileDictationAudioPhase, MobileDictationTranscriptSoFar } from './mobile-dictation-audio-chunk' -import { - cancelDesktopDictation, - settledDesktopDictationWork, - startMobileDictationDesktopSession -} from './mobile-dictation-desktop-start' +import { cancelDesktopDictation } from './mobile-dictation-desktop-start' import { chooseMobileDictationRoute, phoneDictationBusyMessage, @@ -26,6 +22,11 @@ import { } from './mobile-dictation-device-engine' import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' import type { MobileDictationHeldAudio } from './mobile-dictation-held-audio' +import type { MobileDictationRecording } from './mobile-dictation-recording' +import { + replayMobileDictationToHost, + startMobileDictationOnHost +} from './mobile-dictation-host-start' import type { ShowMobileDictationEngine } from './mobile-dictation-engine-shown' // What the engines share with useMobileDictation: its refs (read at call time) and its state transitions. @@ -42,15 +43,18 @@ export type MobileDictationRun = { readonly pendingChunks: RefObject>> readonly pendingAudioBudget: RefObject readonly heldAudio: RefObject + readonly recording: RefObject readonly sendHeldAudio: RefObject<() => void> readonly finish: RefObject<(emptyTranscriptMessage: string) => Promise> readonly onTranscript: RefObject<(text: string) => void> readonly onAudioLimitReached: RefObject<(() => void) | undefined> readonly keptTranscript: MobileDictationTranscriptSoFar - readonly applyStatus: (next: DictationStatus) => void + // keepText: leave the words so far on screen (recording, finishing, or a failure kept for Retry). + readonly applyStatus: (next: DictationStatus, keepText?: boolean) => void readonly showEngine: ShowMobileDictationEngine readonly closeDictationAudio: () => void - readonly reportError: (err: unknown) => void + // kept: the recording stays for Retry, the words so far stay shown, and nothing is inserted yet. + readonly reportError: (err: unknown, kept?: boolean) => void readonly failActiveDictation: (dictationId: string, err: unknown) => void } @@ -80,6 +84,7 @@ export function settleMobileDictation( run.engine.current = { kind: 'host' } run.audioPhase.current = 'closed' run.heldAudio.current.clear() + run.recording.current.clear() run.pendingChunks.current.clear() run.pendingAudioBudget.current.reset() run.applyStatus('idle') @@ -90,48 +95,7 @@ export function settleMobileDictation( } } -export async function startMobileDictationOnHost( - run: MobileDictationRun, - client: RpcClient, - dictationId: string, - generation: number -): Promise { - await settledDesktopDictationWork(client) - if (run.generation.current !== generation) { - return - } - await startMobileDictationDesktopSession({ - client, - dictationId, - generation, - getCurrentGeneration: () => run.generation.current, - getEnabled: () => run.enabled.current, - getActiveId: () => run.activeId.current, - clearActiveId: (id) => { - if (run.activeId.current === id) { - run.activeId.current = null - } - }, - setIdle: () => run.applyStatus('idle'), - commitRecordingStart: (hostDedupesResends) => { - run.audioPhase.current = 'sending' - run.pendingChunks.current.clear() - run.pendingAudioBudget.current.reset(hostDedupesResends) - // A phone dictation stopped or failed at finish is already processing; it never records again. - if (run.status.current !== 'processing') { - run.applyStatus('recording') - } - run.showEngine('computer') - run.sendHeldAudio.current() - if (run.stopWhenStarted.current) { - void run.finish.current(dictationSessionErrorMessage('no-speech')) - } - }, - rollbackRecordingStart: run.closeDictationAudio - }) -} - -// The phone's recognizer failed: replay the audio it kept to the computer, as a start's held audio (seq from 0), and carry on there. +// The phone's recognizer failed: replay the recording to the computer, as a start's held audio (seq from 0), and carry on there. export async function fallBackMobileDictationToHost( run: MobileDictationRun, dictationId: string, @@ -149,25 +113,15 @@ export async function fallBackMobileDictationToHost( !engine.fallback || !client || client.getState() !== 'connected' || - !run.heldAudio.current.complete + !run.recording.current.complete ) { run.failActiveDictation(dictationId, new Error(phoneDictationFailedMessage())) return } - run.engine.current = { kind: 'host' } run.showEngine('computer', true) // A stop that began while the phone was still live is already processing; the computer must finish it too. run.stopWhenStarted.current = finishing || run.status.current === 'processing' - try { - await startMobileDictationOnHost(run, client, dictationId, run.generation.current) - } catch (err) { - // The host start already released the mic and the dictation; keep what the phone heard. - const kept = run.keptTranscript.take(dictationId) - if (kept) { - run.onTranscript.current(kept) - } - run.reportError(err) - } + await replayMobileDictationToHost(run, client, dictationId, run.generation.current) } // Pick the engine before the mic opens, and arm the phone's recognizer so it hears the first word. @@ -229,10 +183,9 @@ export function commitMobileDictationOnDevice( () => void fallBackMobileDictationToHost(run, dictationId, false) ) } - // This phone never replays to the computer, so it keeps no audio. - if (!fallback) { - run.audioPhase.current = 'closed' - } + // The recording keeps the phone's audio for a replay; nothing goes to the computer while the phone hears it. + run.audioPhase.current = 'closed' + run.heldAudio.current.clear() run.applyStatus('recording') run.showEngine('phone') if (run.stopWhenStarted.current) { diff --git a/mobile/src/hooks/mobile-dictation-failure.ts b/mobile/src/hooks/mobile-dictation-failure.ts new file mode 100644 index 00000000000..99ba7a258fa --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-failure.ts @@ -0,0 +1,44 @@ +import { errorMessage } from '../../../src/shared/error-message' +import { + isDictationSetupRequiredError, + isSpeechlessServerError +} from '../dictation/mobile-dictation-setup' +import type { MobileDictationEngine } from './mobile-dictation-device-engine' +import type { MobileDictationRecording } from './mobile-dictation-recording' +import type { MobileDictationRun } from './mobile-dictation-engine-run' + +// Retry plays the recording to the computer, so it needs the whole recording and an engine that may use the computer (not This phone). +export function mobileDictationFailureRetryable( + recording: MobileDictationRecording, + engine: MobileDictationEngine +): boolean { + return recording.complete && recording.bytes > 0 && (engine.kind === 'host' || engine.fallback) +} + +// A computer that needs setup or an update refuses every Retry the same way; those keep today's path (words inserted, setup sheet or toast). +function retryCanSucceed(err: unknown): boolean { + const message = errorMessage(err) + return !isDictationSetupRequiredError(message) && !isSpeechlessServerError(message) +} + +// Never lose speech quietly: a retryable failure keeps the recording and the words so far for Retry; otherwise the words so far are inserted, as before. +export function concludeMobileDictationFailure( + run: Pick< + MobileDictationRun, + 'recording' | 'engine' | 'keptTranscript' | 'onTranscript' | 'reportError' + >, + dictationId: string, + err: unknown, + retryable = mobileDictationFailureRetryable(run.recording.current, run.engine.current) +): void { + if (retryable && retryCanSucceed(err)) { + run.reportError(err, true) + return + } + run.recording.current.clear() + const kept = run.keptTranscript.take(dictationId) + if (kept) { + run.onTranscript.current(kept) + } + run.reportError(err) +} diff --git a/mobile/src/hooks/mobile-dictation-finish.ts b/mobile/src/hooks/mobile-dictation-finish.ts new file mode 100644 index 00000000000..c676c3de41f --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-finish.ts @@ -0,0 +1,77 @@ +import { rpcPayloadMember } from '../transport/rpc/rpc-reader-payload' +import type { DictationCapture } from '../platform/dictation-capture-contract' +import { isCurrentMobileDictationFinish } from './mobile-dictation-session-state' +import { finishDesktopDictation } from './mobile-dictation-desktop-start' +import { + finishMobileDictationOnDevice, + settleMobileDictation, + type MobileDictationRun +} from './mobile-dictation-engine-run' + +// Stop listening and settle the dictation with its final text, from whichever engine heard it. +export async function finishMobileDictation( + run: MobileDictationRun, + capture: Pick, + emptyTranscriptMessage: string +): Promise { + const client = run.client.current + const dictationId = run.activeId.current + const engine = run.engine.current + if (!dictationId || (engine.kind === 'host' && !client)) { + return + } + // A replay's host start is still pending: finish once it commits and the kept audio has gone. + if (engine.kind === 'host' && run.audioPhase.current === 'holding') { + run.stopWhenStarted.current = true + if (run.status.current === 'recording') { + run.applyStatus('processing') + // Stop listening now; the tail joins the held audio the replay sends. + void capture.end() + } + return + } + + const generation = run.generation.current + 1 + run.generation.current = generation + run.applyStatus('processing') + const isCurrent = () => + isCurrentMobileDictationFinish( + run.generation.current, + generation, + run.enabled.current, + run.activeId.current, + dictationId + ) + try { + // Keep shutdown inside try for finally cleanup; accept its tail chunks and snapshot pending sends only after end settles, or finish can overtake the last audio. + await capture.end() + if (engine.kind === 'device') { + await finishMobileDictationOnDevice( + run, + engine, + dictationId, + emptyTranscriptMessage, + isCurrent + ) + return + } + run.audioPhase.current = 'closed' + await Promise.allSettled(Array.from(run.pendingChunks.current)) + if (!isCurrent() || !client) { + return + } + const sentSeconds = run.pendingAudioBudget.current.sentAudioSeconds + const finished = await finishDesktopDictation(client, dictationId, sentSeconds) + if (!isCurrent()) { + return + } + const transcript = rpcPayloadMember(finished, 'text') + settleMobileDictation( + run, + typeof transcript === 'string' ? transcript.trim() : '', + emptyTranscriptMessage + ) + } catch (err) { + run.failActiveDictation(dictationId, err) + } +} diff --git a/mobile/src/hooks/mobile-dictation-held-audio.ts b/mobile/src/hooks/mobile-dictation-held-audio.ts index 3e57dfad7d7..21e142a5683 100644 --- a/mobile/src/hooks/mobile-dictation-held-audio.ts +++ b/mobile/src/hooks/mobile-dictation-held-audio.ts @@ -5,10 +5,10 @@ import { } from '../../../src/shared/rpc-contract/speech-params' import type { DictationCaptureChunk } from '../platform/dictation-capture-contract' -// Five minutes of 16 kHz PCM (9.6 MB): what an on-device dictation keeps to replay to the computer if the phone fails. -const MAX_HELD_AUDIO_BYTES = DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * 300 +// Five minutes of 16 kHz PCM (9.6 MB): the longest recording kept for a replay to the computer. +export const MAX_HELD_AUDIO_BYTES = DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * 300 -// Speech not sent yet, in order: held between the tap and the host's start reply, while every send slot is busy, and for a whole on-device dictation. +// Speech not sent yet, in order: held between the tap and the host's start reply, while every send slot is busy, and for a replay of the recording. export class MobileDictationHeldAudio { private held: DictationCaptureChunk[] = [] private heldBytes = 0 diff --git a/mobile/src/hooks/mobile-dictation-host-start.ts b/mobile/src/hooks/mobile-dictation-host-start.ts new file mode 100644 index 00000000000..2ad40864322 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-host-start.ts @@ -0,0 +1,69 @@ +import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { dictationSessionErrorMessage } from '../dictation/mobile-dictation-setup' +import { + settledDesktopDictationWork, + startMobileDictationDesktopSession +} from './mobile-dictation-desktop-start' +import { concludeMobileDictationFailure } from './mobile-dictation-failure' +import type { MobileDictationRun } from './mobile-dictation-engine-run' + +export async function startMobileDictationOnHost( + run: MobileDictationRun, + client: RpcClient, + dictationId: string, + generation: number, + // A replay (fallback or Retry) keeps the words so far on screen if its start fails. + replay = false +): Promise { + await settledDesktopDictationWork(client) + if (run.generation.current !== generation) { + return + } + await startMobileDictationDesktopSession({ + client, + dictationId, + generation, + getCurrentGeneration: () => run.generation.current, + getEnabled: () => run.enabled.current, + getActiveId: () => run.activeId.current, + clearActiveId: (id) => { + if (run.activeId.current === id) { + run.activeId.current = null + } + }, + setIdle: () => run.applyStatus('idle', replay), + commitRecordingStart: (hostDedupesResends) => { + run.audioPhase.current = 'sending' + run.pendingChunks.current.clear() + run.pendingAudioBudget.current.reset(hostDedupesResends) + // A phone dictation stopped or failed at finish is already processing; it never records again. + if (run.status.current !== 'processing') { + run.applyStatus('recording') + } + run.showEngine('computer') + run.sendHeldAudio.current() + if (run.stopWhenStarted.current) { + void run.finish.current(dictationSessionErrorMessage('no-speech')) + } + }, + rollbackRecordingStart: run.closeDictationAudio + }) +} + +// Play the kept recording to the computer as this dictation's held audio (seq from 0); audio heard after joins it in order until the start commits. +// Fallback and Retry both end here; a failed start keeps the recording for Retry. +export async function replayMobileDictationToHost( + run: MobileDictationRun, + client: RpcClient, + dictationId: string, + generation: number +): Promise { + run.engine.current = { kind: 'host' } + run.recording.current.replayInto(run.heldAudio.current) + run.audioPhase.current = 'holding' + try { + await startMobileDictationOnHost(run, client, dictationId, generation, true) + } catch (err) { + concludeMobileDictationFailure(run, dictationId, err) + } +} diff --git a/mobile/src/hooks/mobile-dictation-recording.ts b/mobile/src/hooks/mobile-dictation-recording.ts new file mode 100644 index 00000000000..872c6231794 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-recording.ts @@ -0,0 +1,47 @@ +import type { DictationCaptureChunk } from '../platform/dictation-capture-contract' +import { MAX_HELD_AUDIO_BYTES, type MobileDictationHeldAudio } from './mobile-dictation-held-audio' + +// Everything one dictation heard, from the tap, whichever engine hears it: replayed to the computer when the phone fails, and kept after a failure for Retry. +export class MobileDictationRecording { + private chunks: DictationCaptureChunk[] = [] + private keptBytes = 0 + private incomplete = false + + keep(chunk: DictationCaptureChunk): void { + if (this.incomplete) { + return + } + // Past the cap, or with audio the page dropped, a replay would lose speech (and the send refuses it), so keep nothing and say it is incomplete. + if (chunk.droppedBytes > 0 || this.keptBytes + chunk.data.byteLength > MAX_HELD_AUDIO_BYTES) { + this.chunks = [] + this.keptBytes = 0 + this.incomplete = true + return + } + this.chunks.push(chunk) + this.keptBytes += chunk.data.byteLength + } + + // False once speech was lost; the recording no longer covers the whole dictation. + get complete(): boolean { + return !this.incomplete + } + + get bytes(): number { + return this.keptBytes + } + + // Queue a copy for sending; the recording itself stays, so a failed replay can be retried. + replayInto(held: MobileDictationHeldAudio): void { + held.clear() + for (const chunk of this.chunks) { + held.hold(chunk) + } + } + + clear(): void { + this.chunks = [] + this.keptBytes = 0 + this.incomplete = false + } +} diff --git a/mobile/src/hooks/mobile-dictation-retry.test.tsx b/mobile/src/hooks/mobile-dictation-retry.test.tsx new file mode 100644 index 00000000000..474685d5a83 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-retry.test.tsx @@ -0,0 +1,166 @@ +// A computer dictation that fails mid-session keeps its whole recording; Retry plays all of it to the computer as a new dictation. +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createFakeRpcClient, + type FakeRpcClient, + type SentRequest +} from '../mobile-web-shell/bridge-host-test-fakes' +import type { + DictationCapture, + DictationCaptureChunk +} from '../platform/dictation-capture-contract' + +type Seam = { + chunkHandlers: Set<(chunk: DictationCaptureChunk) => void> + tail: Uint8Array | null +} + +const seam = vi.hoisted((): Seam => ({ chunkHandlers: new Set(), tail: null })) + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, + Platform: { OS: 'ios' } +})) + +// Keep capture identity stable like both real seams; a fresh object on render would rerun teardown and cancel dictation. +vi.mock('../platform/dictation-capture', () => { + const capture: DictationCapture = { + open: async () => ({ ok: true }), + begin: () => true, + end: async () => { + // Page capture reads once more here and may deliver audio. + const tail = seam.tail + seam.tail = null + if (tail !== null) { + for (const handler of seam.chunkHandlers) { + handler({ data: tail, droppedBytes: 0 }) + } + } + }, + release: () => {}, + onChunk: (handler) => { + seam.chunkHandlers.add(handler) + return { + remove: () => { + seam.chunkHandlers.delete(handler) + } + } + }, + onInterruption: () => ({ remove: () => {} }) + } + return { useDictationCapture: () => capture } +}) + +import { useMobileDictation, type UseMobileDictationResult } from './use-mobile-dictation' + +type Reply = (request: SentRequest) => 'hold' | { text?: string } | Error + +// Answer every request; one reply can unblock the next. +async function pump(rpc: FakeRpcClient, sent: SentRequest[], reply: Reply): Promise { + for (let round = 0; round < 30; round += 1) { + for (const request of rpc.requests.splice(0)) { + sent.push(request) + const answer = reply(request) + if (answer === 'hold') { + continue + } + if (answer instanceof Error) { + request.reject(answer) + } else { + request.resolve({ id: 'desktop', ok: true, result: answer }) + } + } + await Promise.resolve() + await Promise.resolve() + } +} + +function paramsOf(request: SentRequest): Record { + const params = request.args[1] + return typeof params === 'object' && params !== null ? { ...params } : {} +} + +function deliver(bytes: number[]): void { + for (const handler of seam.chunkHandlers) { + handler({ data: Uint8Array.from(bytes), droppedBytes: 0 }) + } +} + +const held: { dictation: UseMobileDictationResult | null } = { dictation: null } + +function dictation(): UseMobileDictationResult { + const current = held.dictation + if (current === null) { + throw new Error('nothing mounted') + } + return current +} + +beforeEach(() => { + seam.chunkHandlers.clear() + seam.tail = null + held.dictation = null +}) + +describe('a computer dictation whose finish fails', () => { + it('keeps the recording, and Retry sends all of it again and inserts the words', async () => { + const rpc = createFakeRpcClient() + const sent: SentRequest[] = [] + const inserted: string[] = [] + const errors: { message: string; kept: boolean }[] = [] + function Probe(): null { + held.dictation = useMobileDictation({ + client: rpc, + enabled: true, + onTranscript: (text) => inserted.push(text), + onError: (error, outcome) => errors.push({ message: error.message, kept: outcome.kept }) + }) + return null + } + act(() => { + create(createElement(Probe)) + }) + const firstTry: Reply = (request) => + request.method === 'speech.dictation.finish' ? new Error('connection lost') : {} + await act(async () => { + const started = dictation().start() + await pump(rpc, sent, firstTry) + await started + }) + await act(async () => { + deliver([1, 2, 3, 4]) + await pump(rpc, sent, firstTry) + }) + seam.tail = Uint8Array.from([5, 6]) + await act(async () => { + const stopped = dictation().stop() + await pump(rpc, sent, firstTry) + await stopped + }) + expect(inserted).toEqual([]) + expect(errors).toEqual([{ message: 'connection lost', kept: true }]) + expect(dictation().failure).toEqual({ message: 'connection lost', offline: false }) + + const firstId = paramsOf(sent[0]).dictationId + sent.length = 0 + await act(async () => { + const retried = dictation().retry() + await pump(rpc, sent, (request) => + request.method === 'speech.dictation.finish' ? { text: 'all of it' } : {} + ) + await retried + }) + expect(sent.map((request) => request.method)).toEqual([ + 'speech.dictation.start', + 'speech.dictation.chunk', + 'speech.dictation.finish' + ]) + expect(paramsOf(sent[0]).dictationId).not.toBe(firstId) + // The whole recording, from the first byte, as seq 0 of the new dictation. + expect(paramsOf(sent[1])).toMatchObject({ audioBase64: 'AQIDBAUG', seq: 0 }) + expect(inserted).toEqual(['all of it']) + expect(dictation().failure).toBeNull() + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-retry.ts b/mobile/src/hooks/mobile-dictation-retry.ts new file mode 100644 index 00000000000..7581ae378f5 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-retry.ts @@ -0,0 +1,42 @@ +import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { createMobileDictationId } from './mobile-dictation-session-state' +import { + concludeMobileDictationFailure, + mobileDictationFailureRetryable +} from './mobile-dictation-failure' +import { + releaseMobileDictationEngine, + type MobileDictationRun +} from './mobile-dictation-engine-run' +import { replayMobileDictationToHost } from './mobile-dictation-host-start' + +// A dictation failed after it began hearing: stop it, and keep its recording for Retry when the computer can hear it again. +export function failActiveMobileDictation( + run: MobileDictationRun, + dictationId: string, + err: unknown +): void { + if (run.activeId.current !== dictationId) { + return + } + run.activeId.current = null + const retryable = mobileDictationFailureRetryable(run.recording.current, run.engine.current) + run.closeDictationAudio() + void releaseMobileDictationEngine(run, run.client.current, dictationId) + concludeMobileDictationFailure(run, dictationId, err, retryable) +} + +// Retry: the kept recording goes to the computer as a new dictation (seq from 0), already stopped, so it finishes as soon as it has been sent. +export async function retryMobileDictationOnHost( + run: MobileDictationRun, + client: RpcClient +): Promise { + const generation = run.generation.current + 1 + run.generation.current = generation + const dictationId = createMobileDictationId() + run.activeId.current = dictationId + run.stopWhenStarted.current = true + run.applyStatus('processing', true) + run.showEngine('computer') + await replayMobileDictationToHost(run, client, dictationId, generation) +} diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts index a2ab4abb53e..4b6fa76d157 100644 --- a/mobile/src/hooks/mobile-dictation-session-state.ts +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -9,11 +9,15 @@ export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | // Who is transcribing, for the icon by the timer; movedAt marks when Automatic moved from the phone to the computer. export type MobileDictationEngineShown = { kind: 'phone' | 'computer'; movedAt: number | null } +// offline: the computer was out of reach when it failed, which is the reason the user needs. +export type MobileDictationKeptFailure = { message: string; offline: boolean } + export type UseMobileDictationOptions = { client: RpcClient | null enabled: boolean onTranscript: (text: string) => void - onError?: (error: Error) => void + // kept: the recording is kept for Retry, so the failure shows in place rather than as a toast. + onError?: (error: Error, outcome: { kept: boolean }) => void // The computer's filler switch, for text the phone recognizes itself; unknown means on. removeFillerWords?: boolean // The host's cloud upload is full; the dictation stops and inserts what was kept. @@ -28,10 +32,13 @@ export type UseMobileDictationResult = { // Empty until a newer host reports words; older hosts never do. transcriptSoFar: string error: string | null + // A failure whose recording is kept: Retry plays it to the computer again, cancel discards it. + failure: MobileDictationKeptFailure | null // Null until recording starts on an engine. engine: MobileDictationEngineShown | null start: () => Promise stop: () => Promise + retry: () => Promise cancel: () => Promise meter: MobileDictationMeter } diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index bfc0e9cd3c9..df0d1c6134e 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -13,19 +13,19 @@ import { type MobileDictationAudioPhase } from './mobile-dictation-audio-chunk' import { MobileDictationHeldAudio } from './mobile-dictation-held-audio' -import { isCurrentMobileDictationFinish } from './mobile-dictation-session-state' -import { finishDesktopDictation } from './mobile-dictation-desktop-start' +import { MobileDictationRecording } from './mobile-dictation-recording' +import { failActiveMobileDictation, retryMobileDictationOnHost } from './mobile-dictation-retry' +import { mobileDictationFailureRetryable } from './mobile-dictation-failure' +import { finishMobileDictation } from './mobile-dictation-finish' import type { MobileDictationEngine } from './mobile-dictation-device-engine' import { - finishMobileDictationOnDevice, releaseMobileDictationEngine, - settleMobileDictation, startMobileDictation, type MobileDictationRun } from './mobile-dictation-engine-run' -import { rpcPayloadMember } from '../transport/rpc/rpc-reader-payload' import type { DictationStatus, + MobileDictationKeptFailure, UseMobileDictationOptions, UseMobileDictationResult } from './mobile-dictation-session-state' @@ -40,6 +40,9 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const speech = useOnDeviceSpeech(removeFillerWords) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) + // A failure whose recording is kept for Retry; cleared by Retry, discard and the next dictation. + const [failure, setFailure] = useState(null) + const failureRef = useRef(failure) const [transcriptSoFar, setTranscriptSoFar, keptTranscript] = useMobileDictationTranscriptSoFar() const { shown: shownEngine, @@ -61,6 +64,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget()) const audioPhaseRef = useRef('closed') const heldAudioRef = useRef(new MobileDictationHeldAudio()) + const recordingRef = useRef(new MobileDictationRecording()) const sendHeldAudioRef = useRef(() => {}) const stopWhenStartedRef = useRef(false) const generationRef = useRef(0) @@ -77,27 +81,36 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil }, [client, speech, enabled, onTranscript, onError, onAudioLimitReached]) const applyStatus = useCallback( - (next: DictationStatus) => { + // Kept through processing so the text so far stays in place until the final transcript replaces it in the same render. + (next: DictationStatus, keepText = next === 'recording' || next === 'processing') => { statusRef.current = next setStatus(next) - // Kept through processing so the text so far stays in place until the final transcript replaces it in the same render. - const live = next === 'recording' || next === 'processing' - setTranscriptSoFar((text) => (live ? text : '')) - if (!live) { + setTranscriptSoFar((text) => (keepText ? text : '')) + if (next !== 'recording' && next !== 'processing') { clearEngine() } }, [clearEngine] ) + const setKeptFailure = useCallback((next: MobileDictationKeptFailure | null) => { + failureRef.current = next + setFailure(next) + }, []) + const reportError = useCallback( - (err: unknown) => { + (err: unknown, kept = false) => { const normalized = err instanceof Error ? err : new Error(String(err)) setError(normalized.message) - applyStatus('error') - onErrorRef.current?.(normalized) + applyStatus('error', kept) + setKeptFailure( + kept + ? { message: normalized.message, offline: clientRef.current?.getState() !== 'connected' } + : null + ) + onErrorRef.current?.(normalized, { kept }) }, - [applyStatus] + [applyStatus, setKeptFailure] ) const closeDictationAudio = useCallback(() => { @@ -113,29 +126,8 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } }, [capture]) - const failActiveDictation = useCallback( - (dictationId: string, err: unknown) => { - if (activeIdRef.current !== dictationId) { - return - } - activeIdRef.current = null - closeDictationAudio() - void releaseMobileDictationEngine( - { engine: engineRef, speech: speechRef }, - clientRef.current, - dictationId - ) - const kept = keptTranscript.take(dictationId) - if (kept) { - onTranscriptRef.current(kept) - } - reportError(err) - }, - [closeDictationAudio, reportError] - ) - - const run = useMemo( - () => ({ + const run = useMemo(() => { + const created: MobileDictationRun = { status: statusRef, activeId: activeIdRef, client: clientRef, @@ -148,6 +140,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil pendingChunks: pendingChunksRef, pendingAudioBudget: pendingAudioBudgetRef, heldAudio: heldAudioRef, + recording: recordingRef, sendHeldAudio: sendHeldAudioRef, finish: finishRef, onTranscript: onTranscriptRef, @@ -157,77 +150,17 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil showEngine, closeDictationAudio, reportError, - failActiveDictation - }), - [applyStatus, closeDictationAudio, failActiveDictation, keptTranscript, reportError, showEngine] - ) + failActiveDictation: (dictationId, err) => + failActiveMobileDictation(created, dictationId, err) + } + return created + }, [applyStatus, closeDictationAudio, keptTranscript, reportError, showEngine]) useMobileDictationAudioChunks(capture, run) const finishDictation = useCallback( - async (emptyTranscriptMessage: string) => { - const client = clientRef.current - const dictationId = activeIdRef.current - const engine = engineRef.current - if (!dictationId || (engine.kind === 'host' && !client)) { - return - } - // A fallback's host start is still pending: finish once it commits and the kept audio has gone. - if (engine.kind === 'host' && audioPhaseRef.current === 'holding') { - stopWhenStartedRef.current = true - if (statusRef.current === 'recording') { - applyStatus('processing') - // Stop listening now; the tail joins the held audio the replay sends. - void capture.end() - } - return - } - - const generation = generationRef.current + 1 - generationRef.current = generation - applyStatus('processing') - const isCurrent = () => - isCurrentMobileDictationFinish( - generationRef.current, - generation, - enabledRef.current, - activeIdRef.current, - dictationId - ) - try { - // Keep shutdown inside try for finally cleanup; accept its tail chunks and snapshot pending sends only after end settles, or finish can overtake the last audio. - await capture.end() - if (engine.kind === 'device') { - await finishMobileDictationOnDevice( - run, - engine, - dictationId, - emptyTranscriptMessage, - isCurrent - ) - return - } - audioPhaseRef.current = 'closed' - await Promise.allSettled(Array.from(pendingChunksRef.current)) - if (!isCurrent() || !client) { - return - } - const sentSeconds = pendingAudioBudgetRef.current.sentAudioSeconds - const finished = await finishDesktopDictation(client, dictationId, sentSeconds) - if (!isCurrent()) { - return - } - const transcript = rpcPayloadMember(finished, 'text') - settleMobileDictation( - run, - typeof transcript === 'string' ? transcript.trim() : '', - emptyTranscriptMessage - ) - } catch (err) { - failActiveDictation(dictationId, err) - } - }, - [applyStatus, capture, failActiveDictation, run] + (emptyTranscriptMessage: string) => finishMobileDictation(run, capture, emptyTranscriptMessage), + [capture, run] ) useLayoutEffect(() => { finishRef.current = finishDictation @@ -247,8 +180,21 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil return } setError(null) + // A new dictation replaces a failed one's kept recording. + setKeptFailure(null) + recordingRef.current.clear() await startMobileDictation(run, capture) - }, [capture, run]) + }, [capture, run, setKeptFailure]) + + const retry = useCallback(async () => { + const client = clientRef.current + if (!failureRef.current || !client || activeIdRef.current || !enabledRef.current) { + return + } + setError(null) + setKeptFailure(null) + await retryMobileDictationOnHost(run, client) + }, [run, setKeptFailure]) // Only the user’s own cancel (reason:null) ends silently; other interruptions call onError so the tap has a visible outcome. const abandonDictation = useCallback( @@ -256,11 +202,26 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const client = clientRef.current const dictationId = activeIdRef.current const wasUnderway = statusRef.current === 'starting' || statusRef.current === 'recording' + // Only the user's discard drops a kept recording; an interruption while it waits leaves it for Retry. + if (reason !== null && dictationId === null && failureRef.current) { + return + } + // An interruption keeps a recording the computer can hear again, even mid-finish; the user's cancel never does. + const kept = + reason !== null && + dictationId !== null && + mobileDictationFailureRetryable(recordingRef.current, engineRef.current) + if (!kept) { + setKeptFailure(null) + recordingRef.current.clear() + } generationRef.current += 1 activeIdRef.current = null closeDictationAudio() // Settle before the host's cancel reply: a host that went quiet must not hold the mic button in its old state. - if (reason !== null && wasUnderway) { + if (kept) { + reportError(new Error(reason), true) + } else if (reason !== null && wasUnderway) { reportError(new Error(reason)) } else { applyStatus('idle') @@ -268,7 +229,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } await releaseMobileDictationEngine(run, client, dictationId) }, - [applyStatus, closeDictationAudio, reportError, run] + [applyStatus, closeDictationAudio, reportError, run, setKeptFailure] ) const cancel = useCallback(() => abandonDictation(null), [abandonDictation]) @@ -295,6 +256,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null closeDictationAudio() + recordingRef.current.clear() capture.release() void releaseMobileDictationEngine(run, clientRef.current, dictationId) } @@ -307,8 +269,10 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil isProcessing: status === 'processing', transcriptSoFar, error, + failure, engine: shownEngine, start, + retry, stop, cancel, meter: useMobileDictationMeter(capture, status === 'recording') diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 75989b79536..71bcd9217c3 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -1319,13 +1319,23 @@ "durationSeconds_one": "{{count}} Sekunde", "durationSeconds_other": "{{count}} Sekunden", "durationMinutes_one": "{{count}} Minute", - "durationMinutes_other": "{{count}} Minuten" + "durationMinutes_other": "{{count}} Minuten", + "discard": "Aufnahme verwerfen", + "retry": "Diktat wiederholen", + "retryShort": "Wiederholen" }, "status": { "finishingOn": "Wird auf {{computer}} abgeschlossen", "finishingOnComputer": "Wird auf dem Computer abgeschlossen", "movedTo": "Zu {{computer}} gewechselt", "movedToComputer": "Zum Computer gewechselt" + }, + "failure": { + "connectionLost": "Verbindung unterbrochen", + "connectionSlow": "Verbindung zu langsam", + "phoneStopped": "Auf diesem Telefon gestoppt", + "stopped": "Diktat gestoppt", + "recordingKept": "Aufnahme behalten" } }, "files": { diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 750db86f234..374512c3742 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -534,13 +534,23 @@ "meterOnPhone": "Recording, {{duration}}, on this phone", "meterOn": "Recording, {{duration}}, on {{computer}}", "meterOnComputer": "Recording, {{duration}}, on the computer", - "meter": "Recording, {{duration}}" + "meter": "Recording, {{duration}}", + "discard": "Discard recording", + "retry": "Retry dictation", + "retryShort": "Retry" }, "status": { "finishingOn": "Finishing on {{computer}}", "finishingOnComputer": "Finishing on the computer", "movedTo": "Moved to {{computer}}", "movedToComputer": "Moved to the computer" + }, + "failure": { + "recordingKept": "Recording kept", + "connectionLost": "Connection lost", + "connectionSlow": "Connection too slow", + "phoneStopped": "Stopped on this phone", + "stopped": "Dictation stopped" } }, "home": { diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index a92272528f5..67cf66f9ce7 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -1360,13 +1360,23 @@ "durationSeconds_many": "{{count}} segundos", "durationMinutes_one": "{{count}} minuto", "durationMinutes_other": "{{count}} minutos", - "durationMinutes_many": "{{count}} minutos" + "durationMinutes_many": "{{count}} minutos", + "discard": "Descartar grabación", + "retry": "Reintentar dictado", + "retryShort": "Reintentar" }, "status": { "finishingOn": "Terminando en {{computer}}", "finishingOnComputer": "Terminando en el ordenador", "movedTo": "Pasado a {{computer}}", "movedToComputer": "Pasado al ordenador" + }, + "failure": { + "connectionLost": "Conexión perdida", + "connectionSlow": "Conexión demasiado lenta", + "phoneStopped": "Detenido en este teléfono", + "stopped": "Dictado detenido", + "recordingKept": "Grabación guardada" } }, "files": { diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index 7bdab359cfb..21fe0a90068 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -1360,13 +1360,23 @@ "durationSeconds_many": "{{count}} secondes", "durationMinutes_one": "{{count}} minute", "durationMinutes_other": "{{count}} minutes", - "durationMinutes_many": "{{count}} minutes" + "durationMinutes_many": "{{count}} minutes", + "discard": "Supprimer l’enregistrement", + "retry": "Relancer la dictée", + "retryShort": "Réessayer" }, "status": { "finishingOn": "Finalisation sur {{computer}}", "finishingOnComputer": "Finalisation sur l’ordinateur", "movedTo": "Passé sur {{computer}}", "movedToComputer": "Passé sur l’ordinateur" + }, + "failure": { + "connectionLost": "Connexion perdue", + "connectionSlow": "Connexion trop lente", + "phoneStopped": "Arrêté sur ce téléphone", + "stopped": "Dictée arrêtée", + "recordingKept": "Enregistrement conservé" } }, "files": { diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 9fedb5abf08..2a2efc326af 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -1315,13 +1315,23 @@ "durationSeconds_one": "{{count}} 秒", "durationSeconds_other": "{{count}} 秒", "durationMinutes_one": "{{count}} 分", - "durationMinutes_other": "{{count}} 分" + "durationMinutes_other": "{{count}} 分", + "discard": "録音を破棄", + "retry": "音声入力を再試行", + "retryShort": "再試行" }, "status": { "finishingOn": "{{computer}} で仕上げています", "finishingOnComputer": "コンピュータで仕上げています", "movedTo": "{{computer}} に切り替えました", "movedToComputer": "コンピュータに切り替えました" + }, + "failure": { + "connectionLost": "接続が切れました", + "connectionSlow": "接続が遅すぎます", + "phoneStopped": "このスマートフォンで停止しました", + "stopped": "音声入力が停止しました", + "recordingKept": "録音は保持されています" } }, "files": { diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 13bfaed35a6..19fec272704 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -1315,13 +1315,23 @@ "durationSeconds_one": "{{count}}초", "durationSeconds_other": "{{count}}초", "durationMinutes_one": "{{count}}분", - "durationMinutes_other": "{{count}}분" + "durationMinutes_other": "{{count}}분", + "discard": "녹음 삭제", + "retry": "받아쓰기 다시 시도", + "retryShort": "다시 시도" }, "status": { "finishingOn": "{{computer}}에서 마무리 중", "finishingOnComputer": "컴퓨터에서 마무리 중", "movedTo": "{{computer}}(으)로 전환됨", "movedToComputer": "컴퓨터로 전환됨" + }, + "failure": { + "connectionLost": "연결 끊김", + "connectionSlow": "연결이 너무 느림", + "phoneStopped": "이 휴대폰에서 중지됨", + "stopped": "받아쓰기 중지됨", + "recordingKept": "녹음 보관됨" } }, "files": { diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index aec51b22aed..6c8a053835c 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -1360,13 +1360,23 @@ "durationSeconds_many": "{{count}} segundos", "durationMinutes_one": "{{count}} minuto", "durationMinutes_other": "{{count}} minutos", - "durationMinutes_many": "{{count}} minutos" + "durationMinutes_many": "{{count}} minutos", + "discard": "Descartar gravação", + "retry": "Tentar ditado novamente", + "retryShort": "Tentar novamente" }, "status": { "finishingOn": "Finalizando em {{computer}}", "finishingOnComputer": "Finalizando no computador", "movedTo": "Passou para {{computer}}", "movedToComputer": "Passou para o computador" + }, + "failure": { + "connectionLost": "Conexão perdida", + "connectionSlow": "Conexão muito lenta", + "phoneStopped": "Parou neste telefone", + "stopped": "Ditado interrompido", + "recordingKept": "Gravação mantida" } }, "files": { diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index fcb9b5a9a20..5907162dd9f 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -1401,13 +1401,23 @@ "durationMinutes_one": "{{count}} минута", "durationMinutes_other": "{{count}} минуты", "durationMinutes_few": "{{count}} минуты", - "durationMinutes_many": "{{count}} минут" + "durationMinutes_many": "{{count}} минут", + "discard": "Удалить запись", + "retry": "Повторить диктовку", + "retryShort": "Повторить" }, "status": { "finishingOn": "Завершается на {{computer}}", "finishingOnComputer": "Завершается на компьютере", "movedTo": "Перешло на {{computer}}", "movedToComputer": "Перешло на компьютер" + }, + "failure": { + "connectionLost": "Соединение потеряно", + "connectionSlow": "Соединение слишком медленное", + "phoneStopped": "Остановлено на этом телефоне", + "stopped": "Диктовка остановлена", + "recordingKept": "Запись сохранена" } }, "files": { diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index f26c4ce5228..f969c5cfbad 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -1319,13 +1319,23 @@ "durationSeconds_one": "{{count}} saniye", "durationSeconds_other": "{{count}} saniye", "durationMinutes_one": "{{count}} dakika", - "durationMinutes_other": "{{count}} dakika" + "durationMinutes_other": "{{count}} dakika", + "discard": "Kaydı sil", + "retry": "Dikteyi yeniden dene", + "retryShort": "Yeniden dene" }, "status": { "finishingOn": "{{computer}} üzerinde tamamlanıyor", "finishingOnComputer": "Bilgisayarda tamamlanıyor", "movedTo": "{{computer}} cihazına geçildi", "movedToComputer": "Bilgisayara geçildi" + }, + "failure": { + "connectionLost": "Bağlantı kesildi", + "connectionSlow": "Bağlantı çok yavaş", + "phoneStopped": "Bu telefonda durdu", + "stopped": "Dikte durdu", + "recordingKept": "Kayıt saklandı" } }, "files": { diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 93a1e55ef73..c2e66fc4f1c 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -1315,13 +1315,23 @@ "durationSeconds_one": "{{count}} 秒", "durationSeconds_other": "{{count}} 秒", "durationMinutes_one": "{{count}} 分鐘", - "durationMinutes_other": "{{count}} 分鐘" + "durationMinutes_other": "{{count}} 分鐘", + "discard": "捨棄錄音", + "retry": "重試聽寫", + "retryShort": "重試" }, "status": { "finishingOn": "正在 {{computer}} 上完成", "finishingOnComputer": "正在電腦上完成", "movedTo": "已轉到 {{computer}}", "movedToComputer": "已轉到電腦" + }, + "failure": { + "connectionLost": "連線中斷", + "connectionSlow": "連線太慢", + "phoneStopped": "已在此手機上停止", + "stopped": "聽寫已停止", + "recordingKept": "錄音已保留" } }, "files": { diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 787606b6a0e..bcafd951970 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -1315,13 +1315,23 @@ "durationSeconds_one": "{{count}} 秒", "durationSeconds_other": "{{count}} 秒", "durationMinutes_one": "{{count}} 分钟", - "durationMinutes_other": "{{count}} 分钟" + "durationMinutes_other": "{{count}} 分钟", + "discard": "丢弃录音", + "retry": "重试听写", + "retryShort": "重试" }, "status": { "finishingOn": "正在 {{computer}} 上完成", "finishingOnComputer": "正在电脑上完成", "movedTo": "已转到 {{computer}}", "movedToComputer": "已转到电脑" + }, + "failure": { + "connectionLost": "连接已断开", + "connectionSlow": "连接太慢", + "phoneStopped": "已在此手机上停止", + "stopped": "听写已停止", + "recordingKept": "录音已保留" } }, "files": { diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index 2e10e27d24f..b58cdede682 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -345,7 +345,7 @@ export function MobileNativeChatComposer({ /> {goal.chip} - {micActive || micProcessing ? null : contextRing.ring} {onMicPress ? ( + } return ( void; held: boolean } | null onSend: (intent?: MobileNativeChatSendIntent, keepFocus?: boolean) => void -}): React.JSX.Element { +}): React.JSX.Element | null { + const dictationFailed = useMobileNativeChatDictationFailed() const steerChord = useMobileNativeChatSteerChordPreview({ fieldRef, hasDraft: value.trim() !== '' || attachmentCount > 0, @@ -83,6 +85,10 @@ export function MobileNativeChatComposerSendSlot({ draft: value, attachmentCount }) + // A dictation kept for Retry takes Send's room; a running agent keeps its Stop. + if (dictationFailed && !(showsStop && stop)) { + return null + } if (steerChord.preview && (steerChord.preview === 'steer-queued' || canSend)) { // As desktop, the preview takes Send's slot at once, with no ease. return ( diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatDictationLeading.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatDictationLeading.tsx index 272ac466892..806609bf8f6 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatDictationLeading.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatDictationLeading.tsx @@ -3,28 +3,45 @@ import { View } from 'react-native' import { MobileDictationLiveContext } from '../../../dictation/mobile-dictation-live-context' import { MobileDictationCancelButton, + MobileDictationFailureNote, MobileDictationFinishingNote, MobileDictationRecordingMeter } from '../../../dictation/MobileDictationLiveControls' import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles' -// While dictating, the row's leading side swaps attach and the pickers for ✕ and the meter, so the composer keeps its height. +// While dictating, the row's leading side swaps attach and the pickers for ✕ and the meter (or a kept failure), so the composer keeps its height. export function MobileNativeChatDictationLeading({ micActive, micStarting = false, micProcessing = false, cancelRef, + trailing, children }: { micActive: boolean micStarting?: boolean micProcessing?: boolean cancelRef: RefObject + // The context ring after the leading controls; it steps aside with them. + trailing: ReactNode children: ReactNode }): React.JSX.Element { const live = useContext(MobileDictationLiveContext) + if (live?.failureReason != null) { + return ( + + + + + ) + } if (!live || (!micActive && !micProcessing)) { - return {children} + return ( + <> + {children} + {trailing} + + ) } return ( @@ -42,3 +59,8 @@ export function MobileNativeChatDictationLeading({ ) } + +// A dictation failed with its recording kept: Send gives its room to Retry. +export function useMobileNativeChatDictationFailed(): boolean { + return useContext(MobileDictationLiveContext)?.failureReason != null +} diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index aaafe9b0f95..ffd2a1e30ac 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -166,7 +166,12 @@ export function useMobileSessionNativeChatDictation( } })().catch(() => keepUndeliveredDictation(text)) }, - onError: (err) => { + onError: (err, { kept }) => { + // A kept failure shows in place with Retry, which still needs the dictation's target. + if (kept) { + triggerError() + return + } dictationRouteContextRef.current = null reportDictationFailure(err) }, diff --git a/mobile/src/terminal/session/MobileTerminalInputActions.tsx b/mobile/src/terminal/session/MobileTerminalInputActions.tsx index ca3a69e4ef2..3730693bdad 100644 --- a/mobile/src/terminal/session/MobileTerminalInputActions.tsx +++ b/mobile/src/terminal/session/MobileTerminalInputActions.tsx @@ -1,4 +1,4 @@ -import type { RefObject } from 'react' +import { useContext, type RefObject } from 'react' import { ActivityIndicator, Pressable, @@ -17,7 +17,11 @@ import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { keepHeldPressThroughLongPress } from '../../platform/held-press-long-press' import { dictationControlsCopy } from '../../dictation/mobile-dictation-controls-copy' import { useMobileDictationCancelDrop } from '../../dictation/use-mobile-dictation-cancel-drop' -import { MobileDictationMicIcon } from '../../dictation/MobileDictationLiveControls' +import { + MobileDictationMicIcon, + MobileDictationRetryButton +} from '../../dictation/MobileDictationLiveControls' +import { MobileDictationLiveContext } from '../../dictation/mobile-dictation-live-context' type DictationState = { readonly isStarting: boolean @@ -54,11 +58,20 @@ type MobileTerminalInputActionsProps = MobileTerminalDictationButtonProps & export function MobileTerminalInputActions(props: MobileTerminalInputActionsProps) { const { isStarting, isRecording, isProcessing } = props.dictation - // Attach steps aside while dictating so the words get the field's room. + const live = useContext(MobileDictationLiveContext) + // A kept failure is cleared by every start and Retry, so it never shows while dictating. + const failureReason = live?.failureReason ?? null + // Attach steps aside while dictating or holding a failed recording, so the field gets the room. return ( <> - {isStarting || isRecording || isProcessing ? null : } - + {isStarting || isRecording || isProcessing || failureReason !== null ? null : ( + + )} + {failureReason !== null && live ? ( + + ) : ( + + )} ) } diff --git a/mobile/src/terminal/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/terminal/session/MobileTerminalLiveInputStatus.tsx index a60cf7965ba..45d018b21eb 100644 --- a/mobile/src/terminal/session/MobileTerminalLiveInputStatus.tsx +++ b/mobile/src/terminal/session/MobileTerminalLiveInputStatus.tsx @@ -12,6 +12,7 @@ import { import { MobileDictationLiveContext } from '../../dictation/mobile-dictation-live-context' import { MobileDictationCancelButton, + MobileDictationFailureNote, MobileDictationFinishingNote, MobileDictationRecordingMeter } from '../../dictation/MobileDictationLiveControls' @@ -37,6 +38,16 @@ export function MobileTerminalLiveInputStatus({ const t = useMobileTranslation() const live = useContext(MobileDictationLiveContext) const dictationTitle = mobileDictationStatusTitle(dictation) + if (live?.failureReason != null) { + return ( + + + + + + + ) + } if (!dictationTitle) { return isAttaching ? ( From 9a45e8bdfb7b0566f67af18a653cd7c6623fe35f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:57:24 +0000 Subject: [PATCH 014/138] feat(speech): dictate through any OpenAI-compatible transcription service (#40160) Add an OpenAI-compatible model whose base URL, model and optional key are set up in a Voice settings dialog and sealed together in ~/.orca, so a key is never sent to a host it wasn't saved for. It reaches OpenAI, Groq, Mistral and local servers, and replaces the unused userModels setting. --- src/main/ipc/speech.ts | 29 ++- .../orcad-plaintext-credential-migration.ts | 4 +- src/main/orcad/orcad-speech-services.ts | 4 +- .../speech/runtime-mobile-speech-catalog.ts | 8 +- src/main/speech/cloud-transcription-target.ts | 59 ++++++ src/main/speech/model-catalog.ts | 12 ++ src/main/speech/model-manager.ts | 9 +- .../speech/openai-api-key-verification.ts | 31 +++- ...i-compatible-speech-provider-store.test.ts | 66 +++++++ ...openai-compatible-speech-provider-store.ts | 98 ++++++++++ .../openai-transcription-client.test.ts | 37 +++- .../speech/openai-transcription-client.ts | 88 +++++---- src/main/speech/stt-service.test.ts | 15 +- src/main/speech/stt-session-start.ts | 7 +- src/preload/api/speech-api.ts | 7 + src/preload/api/speech-bridge.ts | 8 + .../dictation/DictationIndicator.tsx | 2 +- .../voice/OpenAiCompatibleProviderDialog.tsx | 168 ++++++++++++++++++ .../voice/OpenAiTranscriptionSettingsRow.tsx | 78 +++++--- .../voice/VoiceModelUnloadSetting.tsx | 2 +- .../components/settings/voice/VoicePane.tsx | 69 ++++++- .../voice/VoiceSpeechModelSection.test.tsx | 2 +- .../voice/VoiceSpeechModelSection.tsx | 9 +- .../voice/speech-model-description.ts | 5 + ...wser-link-materialization.test-fixture.tsx | 1 - src/renderer/src/i18n/locales/en.json | 21 ++- src/shared/constants.ts | 1 - src/shared/speech-types.ts | 24 ++- 28 files changed, 751 insertions(+), 113 deletions(-) create mode 100644 src/main/speech/cloud-transcription-target.ts create mode 100644 src/main/speech/openai-compatible-speech-provider-store.test.ts create mode 100644 src/main/speech/openai-compatible-speech-provider-store.ts create mode 100644 src/renderer/src/components/settings/voice/OpenAiCompatibleProviderDialog.tsx diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index 42f92f7445e..6bdba861bbe 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -17,7 +17,15 @@ import { getOpenAiSpeechApiKeyProtection, hasOpenAiSpeechApiKey } from '../speech/openai-api-key-store' -import { saveVerifiedOpenAiSpeechApiKey } from '../speech/openai-api-key-verification' +import { + saveVerifiedOpenAiCompatibleSpeechProvider, + saveVerifiedOpenAiSpeechApiKey +} from '../speech/openai-api-key-verification' +import { + clearOpenAiCompatibleSpeechProvider, + summarizeOpenAiCompatibleSpeechProvider +} from '../speech/openai-compatible-speech-provider-store' +import type { OpenAiCompatibleSpeechProvider } from '../../shared/speech-types' import type { Store } from '../persistence/loading-store/store' export function registerSpeechHandlers(store: Store): void { @@ -43,6 +51,25 @@ export function registerSpeechHandlers(store: Store): void { return { configured: false, protection: null } }) + ipcMain.handle('speech:getOpenAiCompatibleProvider', () => { + try { + return summarizeOpenAiCompatibleSpeechProvider() + } catch { + return null + } + }) + + ipcMain.handle( + 'speech:saveOpenAiCompatibleProvider', + async (_event, input: OpenAiCompatibleSpeechProvider) => { + await saveVerifiedOpenAiCompatibleSpeechProvider(input) + } + ) + + ipcMain.handle('speech:clearOpenAiCompatibleProvider', () => { + clearOpenAiCompatibleSpeechProvider() + }) + ipcMain.handle('speech:downloadModel', async (event, modelId: string) => { const manager = getSpeechModelManager(store) const window = BrowserWindow.fromWebContents(event.sender) diff --git a/src/main/orcad/orcad-plaintext-credential-migration.ts b/src/main/orcad/orcad-plaintext-credential-migration.ts index f93b8cc0441..f2bc98d95d7 100644 --- a/src/main/orcad/orcad-plaintext-credential-migration.ts +++ b/src/main/orcad/orcad-plaintext-credential-migration.ts @@ -18,6 +18,7 @@ import { API_KEY_ENVELOPE_PREFIX, getZcodePlanApiKeyPath } from '../zcode/zcode- import { COOKIE_ENVELOPE_PREFIX, getMiniMaxCookiePath } from '../minimax/minimax-cookie-store' import { miniMaxApiKeyEnvelopeFile } from '../minimax/minimax-api-key-store' import { openCodeGoApiKeyEnvelopeFile } from '../opencode/opencode-go-api-key-store' +import { openAiCompatibleSpeechProviderEnvelopeFile } from '../speech/openai-compatible-speech-provider-store' type PlaintextCredentialTarget = { path: string @@ -53,7 +54,8 @@ function listPlaintextCredentialTargets(): PlaintextCredentialTarget[] { { path: getMiniMaxCookiePath(), envelopePrefix: COOKIE_ENVELOPE_PREFIX, raw: true }, { path: getZcodePlanApiKeyPath(), envelopePrefix: API_KEY_ENVELOPE_PREFIX }, miniMaxApiKeyEnvelopeFile(), - openCodeGoApiKeyEnvelopeFile() + openCodeGoApiKeyEnvelopeFile(), + openAiCompatibleSpeechProviderEnvelopeFile() ] } diff --git a/src/main/orcad/orcad-speech-services.ts b/src/main/orcad/orcad-speech-services.ts index c6e9b47ee60..d9c0c661c90 100644 --- a/src/main/orcad/orcad-speech-services.ts +++ b/src/main/orcad/orcad-speech-services.ts @@ -1,6 +1,6 @@ import { ModelManager } from '../speech/model-manager' import { openNodeSpeechDownloadRequest } from '../speech/node-speech-download-request' -import { canReadOpenAiSpeechApiKey } from '../speech/openai-api-key-store' +import { canReadCloudSpeechCredentials } from '../speech/cloud-transcription-target' import type { SpeechServiceFactories } from '../speech/speech-runtime-service' import { SttService } from '../speech/stt-service' import { createSttGpuPolicy } from '../speech/stt-gpu-failure-marker' @@ -11,7 +11,7 @@ import { spawnSttForkedProcess } from '../speech/stt-worker-forked-process' export const orcadSpeechServiceFactories: SpeechServiceFactories = { createModelManager: (customModelsDir) => // Headless key reads cannot open keychain prompts, so readiness may verify decryption. - new ModelManager(customModelsDir, openNodeSpeechDownloadRequest, canReadOpenAiSpeechApiKey), + new ModelManager(customModelsDir, openNodeSpeechDownloadRequest, canReadCloudSpeechCredentials), createSttService: (models, gpuEnabled) => { const gpu = createSttGpuPolicy(models.getModelsDir(), gpuEnabled) return new SttService(models, () => spawnSttWorkerWithCpuFallback(spawnSttForkedProcess, gpu)) diff --git a/src/main/runtime/speech/runtime-mobile-speech-catalog.ts b/src/main/runtime/speech/runtime-mobile-speech-catalog.ts index fd50666cc38..4da3f086637 100644 --- a/src/main/runtime/speech/runtime-mobile-speech-catalog.ts +++ b/src/main/runtime/speech/runtime-mobile-speech-catalog.ts @@ -43,14 +43,18 @@ export class RuntimeMobileSpeechCatalog { const runnable = SPEECH_MODEL_CATALOG.filter( (manifest) => isOfferedSpeechModel(manifest) && - (!isLocalSpeechModel(manifest) || hasLocalSpeechRecognizer(manifest)) + (!isLocalSpeechModel(manifest) || hasLocalSpeechRecognizer(manifest)) && + // Phones offer an unready cloud row as "add an OpenAI key", which can't set this one up. + (manifest.provider !== 'openai-compatible' || + stateById.get(manifest.id)?.status === 'ready') ) const models: RuntimeSpeechModelSummary[] = runnable.map((manifest) => { const state = stateById.get(manifest.id) return { id: manifest.id, label: manifest.label, - provider: manifest.provider === 'openai' ? 'openai' : 'local', + // Older phones know only these two; both cloud providers transcribe on this host. + provider: isLocalSpeechModel(manifest) ? 'local' : 'openai', sizeBytes: manifest.sizeBytes ?? null, recommended: manifest.recommended === true, status: state?.status ?? 'not-downloaded', diff --git a/src/main/speech/cloud-transcription-target.ts b/src/main/speech/cloud-transcription-target.ts new file mode 100644 index 00000000000..929cfe6377e --- /dev/null +++ b/src/main/speech/cloud-transcription-target.ts @@ -0,0 +1,59 @@ +import type { SpeechModelProvider } from '../../shared/speech-types' +import { getCatalogModel, replaceRetiredSpeechModelId } from './model-catalog' +import { + canReadOpenAiSpeechApiKey, + hasOpenAiSpeechApiKey, + readOpenAiSpeechApiKey +} from './openai-api-key-store' +import { + canReadOpenAiCompatibleSpeechProvider, + hasOpenAiCompatibleSpeechProvider, + openAiCompatibleTranscriptionUrl, + readOpenAiCompatibleSpeechProvider +} from './openai-compatible-speech-provider-store' + +export const OPENAI_TRANSCRIPTION_URL = 'https://api.openai.com/v1/audio/transcriptions' + +const OPENAI_TRANSCRIPTION_MODEL_BY_ID: Record = { + 'openai-gpt-transcribe': 'gpt-transcribe' +} + +export type CloudTranscriptionTarget = { + url: string + model: string + /** Empty when the server takes no key. */ + apiKey: string + /** Names the service in errors. */ + label: string +} + +export function hasCloudSpeechCredentials(provider: SpeechModelProvider): boolean { + return provider === 'openai-compatible' + ? hasOpenAiCompatibleSpeechProvider() + : hasOpenAiSpeechApiKey() +} + +/** Headless hosts can't raise keychain prompts, so their readiness verifies decryption. */ +export function canReadCloudSpeechCredentials(provider: SpeechModelProvider): boolean { + return provider === 'openai-compatible' + ? canReadOpenAiCompatibleSpeechProvider() + : canReadOpenAiSpeechApiKey() +} + +/** Reads the key (and the compatible provider's URL) only when audio is ready to upload. */ +export function resolveCloudTranscriptionTarget(modelId: string): CloudTranscriptionTarget { + if (getCatalogModel(modelId)?.provider === 'openai-compatible') { + const provider = readOpenAiCompatibleSpeechProvider() + if (!provider) { + throw new Error('The OpenAI-compatible speech provider is not set up') + } + const url = openAiCompatibleTranscriptionUrl(provider.baseUrl) + return { url, model: provider.model, apiKey: provider.apiKey, label: new URL(url).host } + } + // Settings load already moves retired ids; this catches a caller still holding one. + const model = OPENAI_TRANSCRIPTION_MODEL_BY_ID[replaceRetiredSpeechModelId(modelId)] + if (!model) { + throw new Error(`Unknown OpenAI transcription model: ${modelId}`) + } + return { url: OPENAI_TRANSCRIPTION_URL, model, apiKey: readOpenAiSpeechApiKey(), label: 'OpenAI' } +} diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index 0d6f595db05..719230346cb 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -142,6 +142,18 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sampleRate: 16000, streaming: false }, + { + id: 'openai-compatible', + label: 'OpenAI-compatible', + description: + 'Any service with an OpenAI-style transcription API, such as Groq, Mistral or a local server.', + type: 'openai', + provider: 'openai-compatible', + language: 'multilingual', + dictationLanguages: DICTATION_LANGUAGES, + sampleRate: 16000, + streaming: false + }, { id: 'openai-gpt-4o-mini-transcribe', label: 'GPT-4o mini Transcribe', diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 00e3b0de910..f3d8d896588 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -7,7 +7,7 @@ import type { SpeechModelStatus } from '../../shared/speech-types' import { SPEECH_MODEL_CATALOG, getCatalogModel, isLocalSpeechModel } from './model-catalog' -import { hasOpenAiSpeechApiKey } from './openai-api-key-store' +import { hasCloudSpeechCredentials } from './cloud-transcription-target' import { getSpeechModelCacheDirCandidates, migrateSpeechModelCacheIfNeeded, @@ -38,7 +38,7 @@ export class ModelManager extends SpeechModelDownloadTransport { constructor( customModelsDir: string | undefined, openRequest: OpenSpeechDownloadRequest, - private readonly hasOpenAiKey: () => boolean = hasOpenAiSpeechApiKey + private readonly hasCloudCredentials = hasCloudSpeechCredentials ) { super(openRequest) const requestedModelsDir = @@ -103,8 +103,9 @@ export class ModelManager extends SpeechModelDownloadTransport { return { id: modelId, status: 'error', error: 'Unknown model' } } - if (manifest.provider === 'openai') { - return { id: modelId, status: this.hasOpenAiKey() ? 'ready' : 'not-downloaded' } + if (!isLocalSpeechModel(manifest)) { + const ready = this.hasCloudCredentials(manifest.provider) + return { id: modelId, status: ready ? 'ready' : 'not-downloaded' } } const modelDir = this.getModelDir(modelId) diff --git a/src/main/speech/openai-api-key-verification.ts b/src/main/speech/openai-api-key-verification.ts index d7fe69a1710..6249026b8ba 100644 --- a/src/main/speech/openai-api-key-verification.ts +++ b/src/main/speech/openai-api-key-verification.ts @@ -1,4 +1,11 @@ import { saveOpenAiSpeechApiKey } from './openai-api-key-store' +import { OPENAI_TRANSCRIPTION_URL } from './cloud-transcription-target' +import { + openAiCompatibleTranscriptionUrl, + resolveOpenAiCompatibleSpeechProviderInput, + saveOpenAiCompatibleSpeechProvider +} from './openai-compatible-speech-provider-store' +import type { OpenAiCompatibleSpeechProvider } from '../../shared/speech-types' import { postOpenAiTranscription, readOpenAiErrorMessage, @@ -11,16 +18,27 @@ const KEY_CHECK_TIMEOUT_MS = 10_000 export async function saveVerifiedOpenAiSpeechApiKey(apiKey: string): Promise { const trimmed = apiKey.trim() if (trimmed) { - await rejectUnauthorizedOpenAiKey(trimmed) + await rejectUnauthorizedKey(OPENAI_TRANSCRIPTION_URL, trimmed, 'OpenAI') } saveOpenAiSpeechApiKey(apiKey) } -async function rejectUnauthorizedOpenAiKey(apiKey: string): Promise { +/** Same check against the provider's own endpoint, which also catches a wrong base URL. */ +export async function saveVerifiedOpenAiCompatibleSpeechProvider( + input: OpenAiCompatibleSpeechProvider +): Promise { + const provider = resolveOpenAiCompatibleSpeechProviderInput(input) + const url = openAiCompatibleTranscriptionUrl(provider.baseUrl) + await rejectUnauthorizedKey(url, provider.apiKey, new URL(url).host) + saveOpenAiCompatibleSpeechProvider(provider) +} + +async function rejectUnauthorizedKey(url: string, apiKey: string, label: string): Promise { let response: Response try { - // An empty form is refused before any transcription, but only after OpenAI checks the key. + // An empty form is refused before any transcription, but only after the server checks the key. response = await postOpenAiTranscription( + url, apiKey, new FormData(), AbortSignal.timeout(KEY_CHECK_TIMEOUT_MS) @@ -28,10 +46,15 @@ async function rejectUnauthorizedOpenAiKey(apiKey: string): Promise { } catch { return } + if (response.status === 404 || response.status === 405) { + void response.body?.cancel().catch(() => undefined) + throw new Error(`${label} has no transcription endpoint at ${url}. Check the base URL.`) + } if (response.status !== 401) { void response.body?.cancel().catch(() => undefined) return } const data: OpenAiTranscriptionResponse = await response.json().catch(() => ({})) - throw new Error(readOpenAiErrorMessage(data) ?? 'OpenAI rejected this API key.') + const fallback = apiKey ? `${label} rejected this API key.` : `${label} needs an API key.` + throw new Error(readOpenAiErrorMessage(data, apiKey) ?? fallback) } diff --git a/src/main/speech/openai-compatible-speech-provider-store.test.ts b/src/main/speech/openai-compatible-speech-provider-store.test.ts new file mode 100644 index 00000000000..8821eb525b3 --- /dev/null +++ b/src/main/speech/openai-compatible-speech-provider-store.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +async function loadStoreModule() { + vi.resetModules() + const { setSecretStore } = await import('../../shared/secret-store') + setSecretStore({ + decryptString: (value: Buffer) => value.toString('utf8'), + encryptString: (value: string) => Buffer.from(value), + isEncryptionAvailable: () => true, + describeProtectionGap: () => null + }) + return import('./openai-compatible-speech-provider-store') +} + +beforeEach(() => { + const home = mkdtempSync(join(tmpdir(), 'orca-compatible-speech-')) + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) +}) + +describe('openAiCompatibleTranscriptionUrl', () => { + it('appends the transcriptions path to a base URL and keeps a full one', async () => { + const { openAiCompatibleTranscriptionUrl } = await loadStoreModule() + expect(openAiCompatibleTranscriptionUrl(' https://api.groq.com/openai/v1/ ')).toBe( + 'https://api.groq.com/openai/v1/audio/transcriptions' + ) + expect(openAiCompatibleTranscriptionUrl('https://api.mistral.ai/v1/audio/transcriptions')).toBe( + 'https://api.mistral.ai/v1/audio/transcriptions' + ) + expect(openAiCompatibleTranscriptionUrl('http://127.0.0.1:8000/v1')).toBe( + 'http://127.0.0.1:8000/v1/audio/transcriptions' + ) + expect(() => openAiCompatibleTranscriptionUrl('file:///etc/passwd')).toThrow() + expect(() => openAiCompatibleTranscriptionUrl('api.groq.com')).toThrow() + expect(() => openAiCompatibleTranscriptionUrl('https://user:pw@host.example/v1')).toThrow() + }) +}) + +describe('resolveOpenAiCompatibleSpeechProviderInput', () => { + it('keeps a saved key for a blank field only while the base URL is unchanged', async () => { + const store = await loadStoreModule() + store.saveOpenAiCompatibleSpeechProvider({ + baseUrl: 'https://api.groq.com/openai/v1', + model: 'whisper-large-v3-turbo', + apiKey: 'gsk_saved' + }) + + expect( + store.resolveOpenAiCompatibleSpeechProviderInput({ + baseUrl: 'https://api.groq.com/openai/v1/', + model: 'whisper-large-v3', + apiKey: '' + }).apiKey + ).toBe('gsk_saved') + expect( + store.resolveOpenAiCompatibleSpeechProviderInput({ + baseUrl: 'https://evil.example/v1', + model: 'whisper-large-v3', + apiKey: '' + }).apiKey + ).toBe('') + }) +}) diff --git a/src/main/speech/openai-compatible-speech-provider-store.ts b/src/main/speech/openai-compatible-speech-provider-store.ts new file mode 100644 index 00000000000..b634d9b26ea --- /dev/null +++ b/src/main/speech/openai-compatible-speech-provider-store.ts @@ -0,0 +1,98 @@ +import { z } from 'zod' +import { createEncryptedApiKeyFileStore } from '../credentials/encrypted-api-key-file-store' +import { savedCredentialUnreadable } from '../credentials/saved-credential-readability' +import type { + OpenAiCompatibleSpeechProvider, + OpenAiCompatibleSpeechProviderSummary +} from '../../shared/speech-types' + +// URL, model and key are sealed together so a key can never be sent to a host it wasn't saved for. +const store = createEncryptedApiKeyFileStore({ + fileName: 'openai-compatible-speech-provider.enc', + envelopePrefix: 'orca-openai-compatible-speech:v1:', + providerLabel: 'OpenAI-compatible speech provider', + logScope: 'speech' +}) + +export const hasOpenAiCompatibleSpeechProvider = store.has +export const clearOpenAiCompatibleSpeechProvider = store.clear +export const openAiCompatibleSpeechProviderEnvelopeFile = store.envelopeFile + +const TRANSCRIPTION_PATH = '/audio/transcriptions' + +const providerSchema = z.object({ + baseUrl: z.string().catch(''), + model: z.string().catch(''), + apiKey: z.string().catch('') +}) satisfies z.ZodType + +/** Accepts a base like `https://api.groq.com/openai/v1` or the full transcriptions URL. */ +export function openAiCompatibleTranscriptionUrl(baseUrl: string): string { + let url: URL + try { + url = new URL(baseUrl.trim()) + } catch { + throw new Error('Enter a full base URL, such as https://api.groq.com/openai/v1') + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('The base URL must start with https:// or http://') + } + // fetch refuses these, and its error would echo the password. + if (url.username || url.password) { + throw new Error('Put the key in the API Key field, not in the base URL') + } + const path = url.pathname.replace(/\/+$/, '') + url.pathname = path.endsWith(TRANSCRIPTION_PATH) ? path : `${path}${TRANSCRIPTION_PATH}` + return url.toString() +} + +export function readOpenAiCompatibleSpeechProvider(): OpenAiCompatibleSpeechProvider | null { + // The store caches what it read; another process (desktop vs orca serve) may have removed the file since. + const raw = store.has() ? store.read() : null + if (raw === null) { + return null + } + return providerSchema.parse(JSON.parse(raw)) +} + +/** Headless hosts may not open a desktop-sealed file; checks the disk quietly, since readiness is polled. */ +export function canReadOpenAiCompatibleSpeechProvider(): boolean { + return store.has() && savedCredentialUnreadable(store.envelopeFile()) === undefined +} + +export function summarizeOpenAiCompatibleSpeechProvider(): OpenAiCompatibleSpeechProviderSummary | null { + const provider = readOpenAiCompatibleSpeechProvider() + return provider + ? { baseUrl: provider.baseUrl, model: provider.model, hasApiKey: provider.apiKey !== '' } + : null +} + +/** A blank key keeps the saved one only while the base URL is unchanged. */ +export function resolveOpenAiCompatibleSpeechProviderInput( + input: OpenAiCompatibleSpeechProvider +): OpenAiCompatibleSpeechProvider { + const baseUrl = input.baseUrl.trim() + const model = input.model.trim() + openAiCompatibleTranscriptionUrl(baseUrl) + if (!model) { + throw new Error('Enter the model name the provider expects') + } + return { baseUrl, model, apiKey: input.apiKey.trim() || savedKeyFor(baseUrl) } +} + +function savedKeyFor(baseUrl: string): string { + try { + const saved = readOpenAiCompatibleSpeechProvider() + const sameEndpoint = + saved !== null && + openAiCompatibleTranscriptionUrl(saved.baseUrl) === openAiCompatibleTranscriptionUrl(baseUrl) + return sameEndpoint ? saved.apiKey : '' + } catch { + // An unreadable or invalid saved provider has no key worth keeping. + return '' + } +} + +export function saveOpenAiCompatibleSpeechProvider(provider: OpenAiCompatibleSpeechProvider): void { + store.save(JSON.stringify(provider)) +} diff --git a/src/main/speech/openai-transcription-client.test.ts b/src/main/speech/openai-transcription-client.test.ts index abec63f2854..c74b8afaf91 100644 --- a/src/main/speech/openai-transcription-client.test.ts +++ b/src/main/speech/openai-transcription-client.test.ts @@ -47,7 +47,15 @@ describe('OpenAiTranscriptionSession', () => { uploads.push(Buffer.from(await file.arrayBuffer())) return new Response(JSON.stringify({ text: ' complete dictation ' })) }) - const session = new OpenAiTranscriptionSession('openai-gpt-transcribe', () => 'test-key', 'de') + const session = new OpenAiTranscriptionSession( + () => ({ + url: 'https://api.openai.com/v1/audio/transcriptions', + model: 'gpt-transcribe', + apiKey: 'test-key', + label: 'OpenAI' + }), + 'de' + ) const input = new Float32Array([ -2, -1, @@ -76,4 +84,31 @@ describe('OpenAiTranscriptionSession', () => { -32768, -32768, -16384, 0, 0, 16384, 32767, 32767, 0, 32767, -32768, 0, 24575 ]) }) + + it('sends a keyless server the singular language field and no Authorization header', async () => { + let sent: RequestInit | undefined + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + sent = init + return new Response(JSON.stringify({ text: 'hola' })) + }) + const session = new OpenAiTranscriptionSession( + () => ({ + url: 'http://127.0.0.1:8000/v1/audio/transcriptions', + model: 'whisper-large-v3-turbo', + apiKey: '', + label: '127.0.0.1:8000' + }), + 'es' + ) + session.feedAudio(new Float32Array(160), 16000) + + expect(await session.finish()).toBe('hola') + const form = sent?.body + if (!(form instanceof FormData)) { + throw new Error('Expected multipart body') + } + expect(form.get('language')).toBe('es') + expect(form.has('languages[]')).toBe(false) + expect(sent?.headers).toEqual({}) + }) }) diff --git a/src/main/speech/openai-transcription-client.ts b/src/main/speech/openai-transcription-client.ts index 452685c9b29..3f18309beb6 100644 --- a/src/main/speech/openai-transcription-client.ts +++ b/src/main/speech/openai-transcription-client.ts @@ -1,17 +1,12 @@ import { getMainHttpClient } from '../network/http-client' import { resampleToRate } from './stt-audio-resample' -import { replaceRetiredSpeechModelId } from './model-catalog' +import type { CloudTranscriptionTarget } from './cloud-transcription-target' import { CLOUD_TRANSCRIPTION_SAMPLE_RATE, MAX_CLOUD_TRANSCRIPTION_SECONDS, cloudTranscriptionTimeoutMs } from '../../shared/dictation-finish-deadlines' -export const OPENAI_TRANSCRIPTION_MODEL_BY_ID: Record = { - 'openai-gpt-transcribe': 'gpt-transcribe' -} - -export const OPENAI_TRANSCRIPTION_URL = 'https://api.openai.com/v1/audio/transcriptions' const MAX_CLOUD_AUDIO_SAMPLES = MAX_CLOUD_TRANSCRIPTION_SECONDS * CLOUD_TRANSCRIPTION_SAMPLE_RATE export type OpenAiTranscriptionResponse = { @@ -21,17 +16,18 @@ export type OpenAiTranscriptionResponse = { } } -export function sanitizeOpenAiTranscriptionErrorMessage(message: string): string { +export function sanitizeOpenAiTranscriptionErrorMessage(message: string, apiKey = ''): string { if (/incorrect api key provided:/i.test(message)) { return 'Incorrect OpenAI API key provided.' } - const sanitized = message + // Other providers' keys have other shapes, so also redact the exact key sent (unless it's a placeholder like "x"). + const sanitized = (apiKey.length >= 8 ? message.split(apiKey).join('[redacted]') : message) .replace(/\bsk-[A-Za-z0-9_-]+/g, '[redacted]') .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') .trim() - return sanitized || 'OpenAI transcription request failed' + return sanitized || 'Transcription request failed' } function writePcm16Samples(view: DataView, samples: Float32Array, offset: number): void { @@ -72,33 +68,34 @@ function encodePcm16Wav(chunks: Float32Array[], sampleRate: number): Buffer { - return getMainHttpClient().fetch(OPENAI_TRANSCRIPTION_URL, { + return getMainHttpClient().fetch(url, { method: 'POST', - // Bearer auth only: keep OpenAI's cookies out of the shared desktop session. + // Bearer auth only: keep the provider's cookies out of the shared desktop session. credentials: 'omit', signal, - headers: { Authorization: `Bearer ${apiKey}` }, + // Local servers that take no key get no Authorization header. + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, body: form }) } -export function readOpenAiErrorMessage(data: OpenAiTranscriptionResponse): string | undefined { +export function readOpenAiErrorMessage( + data: OpenAiTranscriptionResponse, + apiKey = '' +): string | undefined { return typeof data.error?.message === 'string' - ? sanitizeOpenAiTranscriptionErrorMessage(data.error.message) + ? sanitizeOpenAiTranscriptionErrorMessage(data.error.message, apiKey) : undefined } -function parseOpenAiTranscriptionResponse(data: OpenAiTranscriptionResponse): string { - if (typeof data.text === 'string') { - return data.text.trim() - } - throw new Error( - readOpenAiErrorMessage(data) ?? 'OpenAI transcription response did not include text' - ) +// gpt-transcribe takes a list of expected languages in place of the singular field others use. +function languageField(model: string): string { + return /^gpt-(live-)?transcribe/.test(model) ? 'languages[]' : 'language' } export class OpenAiTranscriptionSession { @@ -107,8 +104,7 @@ export class OpenAiTranscriptionSession { private readonly abortController = new AbortController() constructor( - private readonly modelId: string, - private readonly readApiKey: () => string, + private readonly readTarget: () => CloudTranscriptionTarget, private readonly language?: string ) {} @@ -136,46 +132,46 @@ export class OpenAiTranscriptionSession { return '' } - // Settings load already moves retired ids; this catches a caller still holding one. - const apiModel = OPENAI_TRANSCRIPTION_MODEL_BY_ID[replaceRetiredSpeechModelId(this.modelId)] - if (!apiModel) { - throw new Error(`Unknown OpenAI transcription model: ${this.modelId}`) - } - + const target = this.readTarget() const chunks = this.chunks this.chunks = [] const wav = encodePcm16Wav(chunks, CLOUD_TRANSCRIPTION_SAMPLE_RATE) + // No response_format: JSON is the default everywhere, and not every provider accepts the field. const form = new FormData() - // No response_format: JSON is the default, and gpt-transcribe documents no other. - form.append('model', apiModel) + form.append('model', target.model) if (this.language) { - // gpt-transcribe takes a list of expected languages in place of the singular `language`. - form.append('languages[]', this.language) + form.append(languageField(target.model), this.language) } // A named WAV blob satisfies multipart uploads without temporary files. form.append('file', new Blob([wav], { type: 'audio/wav' }), 'dictation.wav') const timeout = AbortSignal.timeout(cloudTranscriptionTimeoutMs(this.audioSamples)) try { - return await this.upload(form, AbortSignal.any([this.abortController.signal, timeout])) + return await upload(target, form, AbortSignal.any([this.abortController.signal, timeout])) } catch (error) { if (timeout.aborted && !this.abortController.signal.aborted) { - throw new Error('OpenAI transcription timed out. Check your connection and try again.') + throw new Error( + `${target.label} transcription timed out. Check your connection and try again.` + ) } throw error } } +} - private async upload(form: FormData, signal: AbortSignal): Promise { - const response = await postOpenAiTranscription(this.readApiKey(), form, signal) - - const data = (await response.json().catch(() => ({}))) as OpenAiTranscriptionResponse - if (!response.ok) { - throw new Error( - `OpenAI transcription failed: ${readOpenAiErrorMessage(data) ?? response.statusText}` - ) - } - - return parseOpenAiTranscriptionResponse(data) +async function upload( + target: CloudTranscriptionTarget, + form: FormData, + signal: AbortSignal +): Promise { + const response = await postOpenAiTranscription(target.url, target.apiKey, form, signal) + const data = (await response.json().catch(() => ({}))) as OpenAiTranscriptionResponse + const error = readOpenAiErrorMessage(data, target.apiKey) + if (!response.ok) { + throw new Error(`${target.label} transcription failed: ${error ?? response.statusText}`) + } + if (typeof data.text === 'string') { + return data.text.trim() } + throw new Error(error ?? `${target.label} transcription response did not include text`) } diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index 39b4ade9f76..00862d9af3d 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -89,10 +89,7 @@ const { static instances: HoistedMockOpenAiTranscriptionSession[] = [] feedCalls: { samples: Float32Array; sampleRate: number }[] = [] - constructor( - readonly modelId: string, - readonly readApiKey: () => string - ) { + constructor(readonly readTarget: () => { model: string; apiKey: string }) { HoistedMockOpenAiTranscriptionSession.instances.push(this) } @@ -101,7 +98,8 @@ const { } finish(): Promise { - return Promise.resolve(`${this.modelId}:${this.readApiKey()}`) + const target = this.readTarget() + return Promise.resolve(`${target.model}:${target.apiKey}`) } } @@ -185,8 +183,11 @@ vi.mock('./model-catalog', () => ({ } })) -vi.mock('./openai-api-key-store', () => ({ - readOpenAiSpeechApiKey: readOpenAiSpeechApiKeyMock +vi.mock('./cloud-transcription-target', () => ({ + resolveCloudTranscriptionTarget: (modelId: string) => ({ + model: modelId, + apiKey: readOpenAiSpeechApiKeyMock() + }) })) vi.mock('./openai-transcription-client', () => ({ diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index b1271a1501f..bf5c1795f07 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -2,7 +2,7 @@ import { engineDictationLanguage } from '../../shared/dictation-language' import type { SpeechModelManifest } from '../../shared/speech-types' import { getCatalogModel } from './model-catalog' import { OpenAiTranscriptionSession } from './openai-transcription-client' -import { readOpenAiSpeechApiKey } from './openai-api-key-store' +import { resolveCloudTranscriptionTarget } from './cloud-transcription-target' import type { SttEventSink } from './stt-service' import { MAX_STT_SESSIONS, @@ -56,7 +56,7 @@ export async function startSttDictation( throw new Error(`Unknown model: ${modelId}`) } const config: SttRecognizerConfig | null = - manifest.provider === 'openai' + manifest.provider !== 'local' ? null : { modelId, hotwordsFilePath, language: engineDictationLanguage(manifest, language) } if (state.sessions.size >= MAX_STT_SESSIONS) { @@ -91,8 +91,7 @@ export async function startSttDictation( // A cloud dictation needs no recognizer; free one nobody else uses. await teardownIdleSttEngine(state) session.cloud = new OpenAiTranscriptionSession( - modelId, - readOpenAiSpeechApiKey, + () => resolveCloudTranscriptionTarget(modelId), engineDictationLanguage(manifest, language) ) } diff --git a/src/preload/api/speech-api.ts b/src/preload/api/speech-api.ts index 8acba71dcc2..dc5d63bb3ca 100644 --- a/src/preload/api/speech-api.ts +++ b/src/preload/api/speech-api.ts @@ -1,5 +1,7 @@ import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import type { + OpenAiCompatibleSpeechProvider, + OpenAiCompatibleSpeechProviderSummary, SpeechErrorEvent, SpeechLifecycleEvent, SpeechModelManifest, @@ -16,6 +18,11 @@ export type SpeechApi = { }> saveOpenAiApiKey: (apiKey: string) => Promise<{ configured: boolean }> clearOpenAiApiKey: () => Promise<{ configured: boolean }> + /** Never returns the saved key. */ + getOpenAiCompatibleProvider: () => Promise + /** A blank key keeps the saved one while the base URL is unchanged. */ + saveOpenAiCompatibleProvider: (provider: OpenAiCompatibleSpeechProvider) => Promise + clearOpenAiCompatibleProvider: () => Promise downloadModel: (modelId: string) => Promise cancelDownload: (modelId: string) => Promise deleteModel: (modelId: string) => Promise diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts index a0b38154039..4e751f13d75 100644 --- a/src/preload/api/speech-bridge.ts +++ b/src/preload/api/speech-bridge.ts @@ -1,6 +1,8 @@ import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import { ipcRenderer } from 'electron' import type { + OpenAiCompatibleSpeechProvider, + OpenAiCompatibleSpeechProviderSummary, SpeechAudioPortMessage, SpeechErrorEvent, SpeechLifecycleEvent, @@ -24,6 +26,12 @@ export const speechApi = { ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), clearOpenAiApiKey: (): Promise<{ configured: boolean }> => ipcRenderer.invoke('speech:clearOpenAiApiKey'), + getOpenAiCompatibleProvider: (): Promise => + ipcRenderer.invoke('speech:getOpenAiCompatibleProvider'), + saveOpenAiCompatibleProvider: (provider: OpenAiCompatibleSpeechProvider): Promise => + ipcRenderer.invoke('speech:saveOpenAiCompatibleProvider', provider), + clearOpenAiCompatibleProvider: (): Promise => + ipcRenderer.invoke('speech:clearOpenAiCompatibleProvider'), downloadModel: (modelId: string): Promise => ipcRenderer.invoke('speech:downloadModel', modelId), cancelDownload: (modelId: string): Promise => diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index e28f3d62f16..a1fc6e9b63e 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -81,7 +81,7 @@ function useIsCloudModel(): boolean { } void catalogRequest.then((catalog) => { if (current) { - setCloud(catalog.some((model) => model.id === modelId && model.provider === 'openai')) + setCloud(catalog.some((model) => model.id === modelId && model.provider !== 'local')) } }) return () => { diff --git a/src/renderer/src/components/settings/voice/OpenAiCompatibleProviderDialog.tsx b/src/renderer/src/components/settings/voice/OpenAiCompatibleProviderDialog.tsx new file mode 100644 index 00000000000..50a1e9ea31f --- /dev/null +++ b/src/renderer/src/components/settings/voice/OpenAiCompatibleProviderDialog.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react' +import { Loader2, Lock } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '../../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../../ui/dialog' +import { Input } from '../../ui/input' +import { Label } from '../../ui/label' +import { useTranslate } from '@/i18n/use-translate' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import type { OpenAiCompatibleSpeechProviderSummary } from '../../../../../shared/speech-types' + +type OpenAiCompatibleProviderDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + onSaved: () => void +} + +export function OpenAiCompatibleProviderDialog({ + open, + onOpenChange, + onSaved +}: OpenAiCompatibleProviderDialogProps): React.JSX.Element { + const t = useTranslate() + const [baseUrl, setBaseUrl] = useState('') + const [model, setModel] = useState('') + const [apiKey, setApiKey] = useState('') + const [saved, setSaved] = useState(null) + const [pending, setPending] = useState(false) + + useEffect(() => { + if (!open) { + return + } + let cancelled = false + setApiKey('') + void window.api.speech + .getOpenAiCompatibleProvider() + .then((summary) => { + if (cancelled) { + return + } + setBaseUrl(summary?.baseUrl ?? '') + setModel(summary?.model ?? '') + setSaved(summary) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [open]) + + const canSave = !pending && baseUrl.trim() !== '' && model.trim() !== '' + const keepsSavedKey = saved?.hasApiKey === true && saved.baseUrl === baseUrl.trim() + + const save = async (event: React.FormEvent): Promise => { + event.preventDefault() + if (!canSave) { + return + } + setPending(true) + try { + await window.api.speech.saveOpenAiCompatibleProvider({ baseUrl, model, apiKey }) + onSaved() + onOpenChange(false) + } catch (err) { + toast.error( + readIpcErrorMessage(err) ?? + t( + 'components.settings.voice.openAiCompatible.updateFailed', + 'Could not update the provider' + ) + ) + } + setPending(false) + } + + return ( + + + + + {t('components.settings.voice.openAiCompatible.title', 'OpenAI-compatible provider')} + + + {t( + 'components.settings.voice.openAiCompatible.description', + 'Audio goes to this service only while the OpenAI-compatible model is selected. It works with Groq, Mistral, OpenAI and local servers.' + )} + + +
void save(event)}> +
+ + setBaseUrl(event.target.value)} + /> +
+
+ + setModel(event.target.value)} + /> +
+
+ + setApiKey(event.target.value)} + /> +
+

+ + {t( + 'auto.components.settings.OpenAiTranscriptionKeyDialog.d246b2bdb3', + 'Local runtime keys are stored in ~/.orca using Electron encrypted storage when available.' + )} +

+ + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/voice/OpenAiTranscriptionSettingsRow.tsx b/src/renderer/src/components/settings/voice/OpenAiTranscriptionSettingsRow.tsx index 4cb63b113db..12808fc06a6 100644 --- a/src/renderer/src/components/settings/voice/OpenAiTranscriptionSettingsRow.tsx +++ b/src/renderer/src/components/settings/voice/OpenAiTranscriptionSettingsRow.tsx @@ -4,6 +4,8 @@ import { Label } from '../../ui/label' import { useTranslate } from '@/i18n/use-translate' type OpenAiTranscriptionSettingsRowProps = { + /** 'compatible' is the base URL + model provider, set up in its own dialog. */ + variant?: 'openai' | 'compatible' configured: boolean disabled: boolean onConfigure: () => void @@ -11,23 +13,68 @@ type OpenAiTranscriptionSettingsRowProps = { } export function OpenAiTranscriptionSettingsRow({ + variant = 'openai', configured, disabled, onConfigure, onClear }: OpenAiTranscriptionSettingsRowProps): React.JSX.Element { const t = useTranslate() + const copy = + variant === 'compatible' + ? { + title: t( + 'components.settings.voice.openAiCompatible.title', + 'OpenAI-compatible provider' + ), + configured: t( + 'components.settings.voice.openAiCompatible.rowConfigured', + 'Base URL and model saved for the OpenAI-compatible model.' + ), + missing: t( + 'components.settings.voice.openAiCompatible.rowMissing', + 'Add a base URL and model before selecting the OpenAI-compatible model.' + ), + edit: t('components.settings.voice.openAiCompatible.edit', 'Edit'), + clear: t( + 'components.settings.voice.openAiCompatible.removeLabel', + 'Remove OpenAI-compatible provider' + ), + add: t('components.settings.voice.openAiCompatible.setUp', 'Set up') + } + : { + title: t( + 'auto.components.settings.OpenAiTranscriptionSettingsRow.27e0cb656d', + 'OpenAI Transcription' + ), + configured: t( + 'auto.components.settings.OpenAiTranscriptionSettingsRow.b59b9b2b51', + 'API key configured for cloud speech-to-text models.' + ), + missing: t( + 'auto.components.settings.OpenAiTranscriptionSettingsRow.893790e13b', + 'Add an OpenAI API key before selecting cloud speech-to-text models.' + ), + edit: t( + 'auto.components.settings.OpenAiTranscriptionSettingsRow.a622bc3b37', + 'Replace key' + ), + clear: t( + 'auto.components.settings.OpenAiTranscriptionSettingsRow.ae2df8f511', + 'Disconnect OpenAI API key' + ), + add: t( + 'auto.components.settings.OpenAiTranscriptionSettingsRow.85c589cd61', + 'Add API key' + ) + } + return (
- + {configured && ( @@ -36,28 +83,17 @@ export function OpenAiTranscriptionSettingsRow({ )}

- {configured - ? t( - 'auto.components.settings.OpenAiTranscriptionSettingsRow.b59b9b2b51', - 'API key configured for cloud speech-to-text models.' - ) - : t( - 'auto.components.settings.OpenAiTranscriptionSettingsRow.893790e13b', - 'Add an OpenAI API key before selecting cloud speech-to-text models.' - )} + {configured ? copy.configured : copy.missing}

{configured ? (
) : ( )}
diff --git a/src/renderer/src/components/settings/voice/VoiceModelUnloadSetting.tsx b/src/renderer/src/components/settings/voice/VoiceModelUnloadSetting.tsx index f0ed5c74840..ac283b525e9 100644 --- a/src/renderer/src/components/settings/voice/VoiceModelUnloadSetting.tsx +++ b/src/renderer/src/components/settings/voice/VoiceModelUnloadSetting.tsx @@ -45,7 +45,7 @@ export function VoiceModelUnloadSetting({ const t = useTranslate() const intlLocale = useIntlLocale() const labelId = useId() - const isCloudModel = selectedModel?.provider === 'openai' + const isCloudModel = selectedModel !== undefined && selectedModel.provider !== 'local' const current = unloadModelAfterMinutes(voiceSettings) return ( diff --git a/src/renderer/src/components/settings/voice/VoicePane.tsx b/src/renderer/src/components/settings/voice/VoicePane.tsx index b85a62c59cc..0044a334037 100644 --- a/src/renderer/src/components/settings/voice/VoicePane.tsx +++ b/src/renderer/src/components/settings/voice/VoicePane.tsx @@ -8,6 +8,7 @@ import { Separator } from '../../ui/separator' import { toast } from 'sonner' import { useAppStore } from '@/store/index' import { OpenAiTranscriptionKeyDialog } from './OpenAiTranscriptionKeyDialog' +import { OpenAiCompatibleProviderDialog } from './OpenAiCompatibleProviderDialog' import { OpenAiTranscriptionSettingsRow } from './OpenAiTranscriptionSettingsRow' import { handleVoiceDictationToggle } from './voice-dictation-toggle' import { VoiceDictationLanguageSetting } from './VoiceDictationLanguageSetting' @@ -43,6 +44,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J null ) const [pendingCloudModelId, setPendingCloudModelId] = useState(null) + const [compatibleDialogOpen, setCompatibleDialogOpen] = useState(false) const mountedRef = useRef(true) // Merge async whole-object writes into committed current settings; render snapshots could undo toggles changed during IPC and disable the microphone picker. const voiceSettingsRef = useRef(voiceSettings) @@ -146,12 +148,58 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J (settingsSearchQuery.trim() !== '' && matchesSettingsSearch(settingsSearchQuery, getOpenaiTranscriptionSearchEntry())) + const compatibleModel = catalog.find((m) => m.provider === 'openai-compatible') + const compatibleConfigured = + compatibleModel !== undefined && + modelStates.some((s) => s.id === compatibleModel.id && s.status === 'ready') + const showCompatibleSettingsRow = + compatibleConfigured || selectedModel?.provider === 'openai-compatible' + const openOpenAiDialog = (modelId: string | null = null): void => { setPendingCloudModelId(modelId) setOpenAiApiKeyDraft('') setOpenAiDialogOpen(true) } + const openCompatibleDialog = (modelId: string | null): void => { + setPendingCloudModelId(modelId) + setCompatibleDialogOpen(true) + } + + const openCloudSetup = (manifest: SpeechModelManifest): void => { + if (manifest.provider === 'openai-compatible') { + openCompatibleDialog(manifest.id) + } else { + openOpenAiDialog(manifest.id) + } + } + + const handleCompatibleSaved = (): void => { + if (pendingCloudModelId) { + updateVoiceSettings({ sttModel: pendingCloudModelId }) + } + setPendingCloudModelId(null) + refreshModelStates() + } + + const removeCompatibleProvider = async (): Promise => { + try { + await window.api.speech.clearOpenAiCompatibleProvider() + if (selectedModel?.provider === 'openai-compatible') { + updateVoiceSettings({ sttModel: '' }) + } + refreshModelStates() + } catch (err) { + toast.error( + readIpcErrorMessage(err) ?? + t( + 'components.settings.voice.openAiCompatible.updateFailed', + 'Could not update the provider' + ) + ) + } + } + const saveOpenAiApiKey = async (): Promise => { setOpenAiKeyPending(true) try { @@ -217,7 +265,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J catalog={catalog} modelStates={modelStates} onUpdateVoiceSettings={updateVoiceSettings} - onOpenOpenAiDialog={openOpenAiDialog} + onOpenCloudSetup={openCloudSetup} onRefreshModelStates={refreshModelStates} /> @@ -270,6 +318,25 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J )} + {showCompatibleSettingsRow && ( + <> + + openCompatibleDialog(null)} + onClear={() => void removeCompatibleProvider()} + /> + + )} + + + ) diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx index ca565de2a9f..30424f99bf1 100644 --- a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx @@ -29,7 +29,8 @@ type VoiceSpeechModelSectionProps = { catalog: SpeechModelManifest[] modelStates: SpeechModelState[] onUpdateVoiceSettings: (updates: Partial) => void - onOpenOpenAiDialog: (modelId: string) => void + /** A cloud model that isn't set up yet: open its provider's setup. */ + onOpenCloudSetup: (manifest: SpeechModelManifest) => void onRefreshModelStates: () => void } @@ -38,7 +39,7 @@ export function VoiceSpeechModelSection({ catalog, modelStates, onUpdateVoiceSettings, - onOpenOpenAiDialog, + onOpenCloudSetup, onRefreshModelStates }: VoiceSpeechModelSectionProps): React.JSX.Element { const t = useTranslate() @@ -83,7 +84,7 @@ export function VoiceSpeechModelSection({ {catalog.map((manifest) => { const mState = getModelState(manifest.id) const isReady = mState?.status === 'ready' - const isCloud = manifest.provider === 'openai' + const isCloud = manifest.provider !== 'local' const isRetired = !isOfferedSpeechModel(manifest) // Retired local models stay listed only while downloaded, so their files can be deleted. if (isRetired && (isCloud || !isReady)) { @@ -105,7 +106,7 @@ export function VoiceSpeechModelSection({ } else if (isReady) { onUpdateVoiceSettings({ sttModel: manifest.id }) } else if (isCloud) { - onOpenOpenAiDialog(manifest.id) + onOpenCloudSetup(manifest) } else if (!isDownloading) { // Keep the menu open so download progress remains visible. event.preventDefault() diff --git a/src/renderer/src/components/settings/voice/speech-model-description.ts b/src/renderer/src/components/settings/voice/speech-model-description.ts index 22ade6810d1..22a44517222 100644 --- a/src/renderer/src/components/settings/voice/speech-model-description.ts +++ b/src/renderer/src/components/settings/voice/speech-model-description.ts @@ -52,6 +52,11 @@ const DESCRIPTIONS: Record string> = { 'settings.voice.modelDescriptions.senseVoice', 'Chinese, English, Japanese, Korean, and Cantonese with automatic language detection.' ), + 'openai-compatible': () => + translate( + 'settings.voice.modelDescriptions.openAiCompatible', + 'Any service with an OpenAI-style transcription API, such as Groq, Mistral or a local server.' + ), 'openai-gpt-transcribe': () => translate( 'settings.voice.modelDescriptions.gptTranscribe', diff --git a/src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx b/src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx index 5690af8cad9..46fe1541ecd 100644 --- a/src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx +++ b/src/renderer/src/hooks/ipc-events/browser-link-materialization.test-fixture.tsx @@ -89,7 +89,6 @@ export function installBrowserLinkMaterializationProbe() { dictationHoldToRecord: false, removeFillerWords: true, terminalConfirmBeforeInsert: true, - userModels: [], openAiApiKeyConfigured: false, microphoneDeviceId: null, microphoneDeviceLabel: null, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 035e7163b48..94e56027635 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1156,7 +1156,8 @@ "parakeetJa": "Japanese only. Trained on 35k+ hours of natural speech. Punctuation included.", "whisperTiny": "90+ languages. Lower accuracy than Parakeet but broadest language coverage.", "senseVoice": "Chinese, English, Japanese, Korean, and Cantonese with automatic language detection.", - "gptTranscribe": "Cloud transcription with high accuracy. Requires an OpenAI API key." + "gptTranscribe": "Cloud transcription with high accuracy. Requires an OpenAI API key.", + "openAiCompatible": "Any service with an OpenAI-style transcription API, such as Groq, Mistral or a local server." } }, "diagnostics": { @@ -21380,6 +21381,24 @@ "fillerWords": { "title": "Remove Filler Words", "description": "Drops sounds like “um” and “uh” from dictated text, in the dictation language." + }, + "openAiCompatible": { + "updateFailed": "Could not update the provider", + "title": "OpenAI-compatible provider", + "description": "Audio goes to this service only while the OpenAI-compatible model is selected. It works with Groq, Mistral, OpenAI and local servers.", + "baseUrl": "Base URL", + "baseUrlPlaceholder": "https://api.groq.com/openai/v1", + "model": "Model", + "modelPlaceholder": "whisper-large-v3-turbo", + "apiKey": "API Key", + "keySaved": "Saved key kept", + "keyOptional": "Optional for local servers", + "save": "Save", + "rowConfigured": "Base URL and model saved for the OpenAI-compatible model.", + "rowMissing": "Add a base URL and model before selecting the OpenAI-compatible model.", + "edit": "Edit", + "removeLabel": "Remove OpenAI-compatible provider", + "setUp": "Set up" } }, "repositoryHooks": { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 48de46a8e36..2c2b79c257e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -105,7 +105,6 @@ export function getDefaultVoiceSettings(): VoiceSettings { dictationHoldToRecord: true, removeFillerWords: true, terminalConfirmBeforeInsert: false, - userModels: [], openAiApiKeyConfigured: false, microphoneDeviceId: null, microphoneDeviceLabel: null, diff --git a/src/shared/speech-types.ts b/src/shared/speech-types.ts index 4f8e8bd2061..481aeb0ddca 100644 --- a/src/shared/speech-types.ts +++ b/src/shared/speech-types.ts @@ -5,7 +5,7 @@ export type SpeechModelType = | 'senseVoice' | 'nemo-ctc' | 'openai' -export type SpeechModelProvider = 'local' | 'openai' +export type SpeechModelProvider = 'local' | 'openai' | 'openai-compatible' export type ModelingUnit = 'bpe' | 'cjkchar' | 'cjkchar+bpe' @@ -36,6 +36,20 @@ export type SpeechModelManifest = { dictationLanguages?: readonly string[] } +export type OpenAiCompatibleSpeechProvider = { + baseUrl: string + model: string + /** Empty for servers that take no key. */ + apiKey: string +} + +export type OpenAiCompatibleSpeechProviderSummary = Omit< + OpenAiCompatibleSpeechProvider, + 'apiKey' +> & { + hasApiKey: boolean +} + export type SpeechModelStatus = 'not-downloaded' | 'downloading' | 'extracting' | 'ready' | 'error' export type SpeechModelState = { @@ -66,13 +80,6 @@ export type SpeechErrorEvent = { export type DictationState = 'idle' | 'starting' | 'listening' | 'stopping' | 'error' -export type UserModelConfig = { - id: string - type: SpeechModelType - dir: string - sampleRate?: number -} - // See dictation-shortcut-mode.ts for Auto. export type DictationMode = 'toggle' | 'hold' @@ -89,7 +96,6 @@ export type VoiceSettings = { /** Drops "um", "uh" and their equivalents in the dictation language before text is inserted. */ removeFillerWords: boolean terminalConfirmBeforeInsert: boolean - userModels: UserModelConfig[] openAiApiKeyConfigured: boolean /** null = system default input device */ microphoneDeviceId: string | null From da8396395c8e7faa6db96bca35a77acd54ca548a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 09:04:22 -0800 Subject: [PATCH 015/138] test: remove low-value browser, hook and SSH cases (#40423) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/legacy-relay-compat-tags-baseline.txt | 1 - .../scripts/ci/sharding/ci-shard-timings.json | 3 - .../server-claude-helper-recovery.test.ts | 33 --- .../server-interrupt-inference-guards.test.ts | 222 --------------- .../server-hook-root-acquisition.test.ts | 33 --- .../presence/server-hook-root-replay.test.ts | 24 -- .../grab/browser-manager-grab-capture.test.ts | 99 ------- .../browser-manager-grab-selection.test.ts | 65 ----- .../browser-manager-auth-user-agent.test.ts | 254 ------------------ ...rowser-manager-load-failure-replay.test.ts | 54 ---- .../browser-manager-popup-child.test.ts | 207 +------------- .../browser-manager-destroyed-guest.test.ts | 51 ---- ...browser-manager-viewport-ownership.test.ts | 229 ---------------- .../ssh-multi-key-authentication.test.ts | 78 +----- .../sftp/sftp-stream-late-error.test.ts | 25 -- .../host-keys/ssh-known-hosts-source.test.ts | 21 +- .../ssh-host-server-on-connect.test.ts | 140 ---------- .../ssh-remote-node-resolution.test.ts | 194 ++----------- 18 files changed, 28 insertions(+), 1705 deletions(-) delete mode 100644 src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-capture.test.ts delete mode 100644 src/main/browser/guest-lifecycle/manager/navigation/browser-manager-auth-user-agent.test.ts delete mode 100644 src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-ownership.test.ts diff --git a/config/legacy-relay-compat-tags-baseline.txt b/config/legacy-relay-compat-tags-baseline.txt index c168b52ead7..3ed5dc0a23e 100644 --- a/config/legacy-relay-compat-tags-baseline.txt +++ b/config/legacy-relay-compat-tags-baseline.txt @@ -15,7 +15,6 @@ src/main/managed-server/migration/cutover/orcad-migration-delta-move.ts src/main/managed-server/migration/linking/orcad-migration-repository-link.test.ts src/main/ssh/migration-source/orcad-migration-terminal-gate.test.ts src/main/ssh/migration-source/orcad-migration-terminal-gate.ts -src/main/ssh/host-server/ssh-host-server-on-connect.test.ts src/main/ssh/host-server/ssh-host-server-on-connect.ts src/main/ssh/targets/ssh-target-orcad-preflight.ts src/preload/api-types.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index f9e502b4944..da0472d6b16 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -927,12 +927,10 @@ "src/main/browser/identity/mode/browser-identity-mode-store.test.ts": 95, "src/main/browser/guest-lifecycle/prompts/browser-javascript-dialogs.test.ts": 54, "src/main/browser/guest-lifecycle/manager/viewport/browser-manager-annotation-bridge.test.ts": 603, - "src/main/browser/guest-lifecycle/manager/navigation/browser-manager-auth-user-agent.test.ts": 555, "src/main/browser/guest-lifecycle/manager/downloads/browser-manager-client-hosted-downloads.test.ts": 670, "src/main/browser/guest-lifecycle/manager/registration/browser-manager-destroyed-guest.test.ts": 633, "src/main/browser/guest-lifecycle/manager/devtools/browser-manager-devtools-retirement.test.ts": 551, "src/main/browser/guest-lifecycle/manager/downloads/browser-manager-downloads.test.ts": 592, - "src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-capture.test.ts": 516, "src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-mode.test.ts": 567, "src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-selection.test.ts": 584, "src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts": 543, @@ -942,7 +940,6 @@ "src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-routing.test.ts": 575, "src/main/browser/guest-lifecycle/manager/viewport/browser-manager-tab-identity.test.ts": 619, "src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-override.test.ts": 597, - "src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-ownership.test.ts": 550, "src/main/browser/network/execution-routes/browser-network-deferred-socket.test.ts": 101, "src/main/browser/network/execution-routes/browser-network-execution-route.test.ts": 272, "src/main/browser/network/tunnels/browser-network-tunnel-client.test.ts": 127, diff --git a/src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts b/src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts index cada292132b..a72dcf5b80f 100644 --- a/src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts +++ b/src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts @@ -9,7 +9,6 @@ import { AgentHookServer } from '../../agent-hook-server' import { buildBody, PANE, postHookEvent } from '../server.test-fixtures' import { ClaudeHelperKillPoller } from '../../../../../shared/agent-hook-listener/providers/claude-helper-kill-poller' import '../../../../../shared/agent-hook-listener/providers/claude-transcript-decoder' -import * as cursor from '../../../../../shared/agent-hook-listener/providers/claude-transcript-cursor' import '../../../../../shared/agent-hook-listener/transcripts/agent-transcript-poll-scheduler' // Complete captured lifecycle records, with runtime replay seeded by real persisted HTTP state. @@ -369,38 +368,6 @@ it('a real failed bind parks without resources and explicit retry delivers its f expect(p.row()?.workingMode).toBe('monitoring') }) -it.each(['relay-spool', 'desktop-spool'])( - 'stop during %s recovery invalidates its parked owner', - async (kind) => { - const read = vi.spyOn(cursor, 'readClaudeTranscriptChunk') - const p = await setup(kind, 'accepted-before-bind', { - stopBeforeBind: true, - beforeStart: () => read.mockClear() - }) - expect(p.entries.size).toBe(0) - expect(read).not.toHaveBeenCalled() - expect(p.poller['scheduler']['entries'].size).toBe(0) - } -) - -it.each(['desktop-http', 'relay-http', 'desktop-spool', 'relay-spool'])( - 'refused %s admission creates no observation or wake', - async (kind) => { - const capture = vi.spyOn(ClaudeHelperKillPoller.prototype, 'schedule') - const p = await setup(kind, 'before-start', { - retired: true, - beforeStart: () => capture.mockClear() - }) - expect(p.entries.size).toBe(0) - expect(capture).not.toHaveBeenCalled() - if (kind === 'desktop-spool') { - expect(p.row()?.restoredUnconfirmed).toBe(true) - } else { - expect(p.row()).toBeUndefined() - } - } -) - it('a held local child permission cannot reset the admitted owner', async () => { const p = await setup('desktop-http', 'after-start') await p.post({ diff --git a/src/main/agent-hooks/server/testing/interruptions/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server/testing/interruptions/server-interrupt-inference-guards.test.ts index 80d23e8188a..e69358a1ab7 100644 --- a/src/main/agent-hooks/server/testing/interruptions/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server/testing/interruptions/server-interrupt-inference-guards.test.ts @@ -27,105 +27,6 @@ afterEach(() => { }) describe('AgentHookServer listener replay', () => { - it('does not infer an interrupt while a subagent child is still working', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'review loop', - agentType: 'claude', - // Child-driven working status cannot prove Ctrl+C ended the lead's turn. - subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'review loop', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - - expect(applied).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - } finally { - vi.useRealTimers() - } - }) - - it('does not infer an interrupt while Claude reports a background shell and the row carries no main agent fact', () => { - // Legacy hosts omit mainAgent, so working may mean an idle lead held open by live shell work. - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - claudeRunningNonAgentTask: true, - payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } - }, - 'conn-1' - ) - let baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'run in background', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - claudeRunningNonAgentTask: false, - payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } - }, - 'conn-1' - ) - baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(2_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'run in background', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(true) - } finally { - vi.useRealTimers() - } - }) - it('infers a cancel of a live Claude main agent turn and keeps the shell it leaves running as monitoring', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -443,43 +344,6 @@ describe('AgentHookServer listener replay', () => { } }) - it('does not apply Claude background metadata from a rejected remote status', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'PermissionRequest', - claudeRunningNonAgentTask: true, - payload: { - state: 'waiting', - prompt: 'approve shell', - agentType: 'claude', - toolName: 'Bash' - } - }, - 'conn-1' - ) - const waiting = server.getStatusSnapshot()[0] - - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'PreToolUse', - claudeRunningNonAgentTask: false, - payload: { - state: 'working', - prompt: 'approve shell', - agentType: 'claude', - toolName: 'OtherTool' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()[0]).toEqual(waiting) - expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) - }) - it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -543,90 +407,4 @@ describe('AgentHookServer listener replay', () => { vi.useRealTimers() } }) - - it.each(['opencode', 'opencode2', 'copilot'] as const)( - 'rejects single plain Escape inference for %s', - (agentType) => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: agentType, - intent: 'plain-escape' - }) - - expect(applied).toBe(false) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'long task', - agentType - }) - ]) - } finally { - vi.useRealTimers() - } - } - ) - - it.each(['opencode', 'opencode2', 'copilot'] as const)( - 'accepts double plain Escape inference for %s', - (agentType) => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: agentType, - intent: 'plain-escape', - inputCount: 2 - }) - - expect(applied).toBe(true) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'long task', - agentType, - interrupted: true - }) - ]) - } finally { - vi.useRealTimers() - } - } - ) }) diff --git a/src/main/agent-hooks/server/testing/presence/server-hook-root-acquisition.test.ts b/src/main/agent-hooks/server/testing/presence/server-hook-root-acquisition.test.ts index 982b2de7ba6..89b9c569c5e 100644 --- a/src/main/agent-hooks/server/testing/presence/server-hook-root-acquisition.test.ts +++ b/src/main/agent-hooks/server/testing/presence/server-hook-root-acquisition.test.ts @@ -85,20 +85,6 @@ describe('a pane without a proven owner', () => { expect(attempts()).toBe(1) }) - it('acquires a Claude started through a shell script named claude', async () => { - const server = await createServer() - runningPane( - 49960, - [49960, 101, 49960, 'bash /Users/u/bin/claude', 'S+'], - [49969, 49960, 49960, 'node /opt/homebrew/bin/claude', 'S+'] - ) - observe(server, 'waiting') - await hook(server, 'SessionStart', { session: 'session-a' }) - await hook(server, 'UserPromptSubmit', { session: 'session-a', prompt: 'Wrapped task' }) - expect(server.row()).toMatchObject({ state: 'working', prompt: 'Wrapped task' }) - expect(server.presence()).toMatchObject({ process: capture(49969), session: 'session-a' }) - }) - it.each([['parent'], [undefined]])( 'keeps a detached nested run out of an unproven owner in session %s, through its exit', async (session) => { @@ -259,25 +245,6 @@ describe('terminal reports and Orca restatements', () => { expect(server.row()).toMatchObject({ agentType: 'codex', prompt: 'Next task' }) expect(server.presence()).toEqual({ agent: 'codex', session: 'cx' }) }) - - it('keeps a suspended owner through a terminal report', async () => { - const server = await createServer() - runningPane(49969, CLAUDE) - await hook(server, 'SessionStart', { session: 'session-a' }) - probe.mockImplementationOnce(async (_identity, _read, _platform, onSuspension) => { - onSuspension?.(true) - return 'unverifiable' - }) - await server.checkAgentPresence(PANE) - observe(server, 'working') - expect(server.presence()).toEqual({ - agent: 'claude', - process: capture(49969), - session: 'session-a', - sessionOwner: true, - suspended: true - }) - }) }) describe('a retired owner', () => { diff --git a/src/main/agent-hooks/server/testing/presence/server-hook-root-replay.test.ts b/src/main/agent-hooks/server/testing/presence/server-hook-root-replay.test.ts index ea3922d640d..e5d7592bf79 100644 --- a/src/main/agent-hooks/server/testing/presence/server-hook-root-replay.test.ts +++ b/src/main/agent-hooks/server/testing/presence/server-hook-root-replay.test.ts @@ -249,30 +249,6 @@ describe('startup spool replay of panes without a proven owner', () => { release() } ) - - it('answers past 64 unfinished reads as unknown, and reads that pane on its next turn', async () => { - const panes = [...Array.from({ length: 69 }, (_, index) => paneOf(index)), PANE] - const userDataPath = spoolDir( - panes.map((paneKey) => - spooled(paneKey, 49969, { hook_event_name: 'UserPromptSubmit', prompt: 'task' }) - ) - ) - runningPane(49969, CLAUDE) - const { release, requested } = rootAnswers(() => true) - const { server, started } = launchServer(userDataPath) - await requested - await vi.advanceTimersByTimeAsync(500) - await started - expect(attempts()).toBe(64) - vi.useRealTimers() - release() - await settled(64) - expect(server.presence(panes[0])?.fallback).toBeUndefined() - expect(server.presence()).toMatchObject({ process: capture(49969), fallback: true }) - await hook(server, 'UserPromptSubmit', { prompt: 'next' }) - expect(attempts()).toBe(65) - expect(server.presence()?.fallback).toBeUndefined() - }) }) describe('WSL relay startup spool replay', () => { diff --git a/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-capture.test.ts b/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-capture.test.ts deleted file mode 100644 index 6afd8aa4888..00000000000 --- a/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-capture.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { GRAB_BUDGET } from '../../../../../shared/browser/element-grab/browser-grab-types' - -const grabMocks = vi.hoisted(() => ({ - webContentsFromIdMock: vi.fn(), - guestOnMock: vi.fn(), - guestOffMock: vi.fn(), - guestSetBackgroundThrottlingMock: vi.fn(), - guestSetWindowOpenHandlerMock: vi.fn(), - guestExecuteJavaScriptMock: vi.fn(), - guestExecuteJavaScriptInIsolatedWorldMock: vi.fn(), - guestIsDestroyedMock: vi.fn(() => false), - guestGetZoomFactorMock: vi.fn(() => 1), - guestCapturePageMock: vi.fn(), - menuBuildFromTemplateMock: vi.fn(), - rendererSendMock: vi.fn(), - rendererIsDestroyedMock: vi.fn(() => false) -})) - -vi.mock('electron', () => ({ - clipboard: { writeText: vi.fn() }, - shell: { openExternal: vi.fn() }, - Menu: { buildFromTemplate: grabMocks.menuBuildFromTemplateMock }, - webContents: { fromId: grabMocks.webContentsFromIdMock } -})) - -import { browserManager } from '../browser-manager' -import { resetGrabTestEnvironment } from './browser-manager-grab-test-harness' - -const { guestCapturePageMock, guestExecuteJavaScriptMock } = grabMocks - -describe('browserManager grab operations', () => { - let guest: Electron.WebContents - - beforeEach(() => { - guest = resetGrabTestEnvironment(grabMocks) - }) - - describe('captureSelectionScreenshot', () => { - it('captures, crops, and converts screenshot dimensions back to CSS pixels', async () => { - const cropMock = vi.fn(() => ({ - toPNG: vi.fn(() => Buffer.from('png-data')) - })) - guestCapturePageMock.mockResolvedValue({ - isEmpty: vi.fn(() => false), - getSize: vi.fn(() => ({ width: 2000, height: 1000 })), - crop: cropMock - }) - guestExecuteJavaScriptMock.mockImplementation(async (script: string) => - script === 'window.innerWidth' ? 1000 : undefined - ) - - const screenshot = await browserManager.captureSelectionScreenshot( - 'tab-1', - { x: 10, y: 20, width: 100, height: 50 }, - guest - ) - - expect(cropMock).toHaveBeenCalledWith({ x: 20, y: 40, width: 200, height: 100 }) - expect(screenshot).toEqual({ - mimeType: 'image/png', - dataUrl: `data:image/png;base64,${Buffer.from('png-data').toString('base64')}`, - width: 100, - height: 50 - }) - expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('__orcaGrab') - ) - expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('__orcaGrab') - ) - expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(3, 'window.innerWidth') - }) - - it('omits screenshots that exceed the byte budget', async () => { - const oversizedBuffer = Buffer.alloc(GRAB_BUDGET.screenshotMaxBytes + 1) - guestCapturePageMock.mockResolvedValue({ - isEmpty: vi.fn(() => false), - getSize: vi.fn(() => ({ width: 1000, height: 500 })), - crop: vi.fn(() => ({ - toPNG: vi.fn(() => oversizedBuffer) - })) - }) - guestExecuteJavaScriptMock.mockImplementation(async (script: string) => - script === 'window.innerWidth' ? 1000 : undefined - ) - - const screenshot = await browserManager.captureSelectionScreenshot( - 'tab-1', - { x: 0, y: 0, width: 100, height: 50 }, - guest - ) - - expect(screenshot).toBeNull() - }) - }) -}) diff --git a/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-selection.test.ts b/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-selection.test.ts index f991c4bfb3b..3c2ead1142e 100644 --- a/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-selection.test.ts +++ b/src/main/browser/guest-lifecycle/manager/grab/browser-manager-grab-selection.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrowserGrabPayload } from '../../../../../shared/browser/element-grab/browser-grab-types' const grabMocks = vi.hoisted(() => ({ webContentsFromIdMock: vi.fn(), @@ -35,57 +34,6 @@ import { const { guestExecuteJavaScriptMock } = grabMocks -function makeValidGrabPayload(): BrowserGrabPayload { - return { - page: { - sanitizedUrl: 'https://example.com/', - title: 'Example', - viewportWidth: 1280, - viewportHeight: 720, - scrollX: 0, - scrollY: 0, - devicePixelRatio: 2, - capturedAt: '2026-04-10T00:00:00.000Z' - }, - target: { - tagName: 'button', - selector: 'button', - textSnippet: 'Click me', - htmlSnippet: '', - attributes: {}, - accessibility: { - role: 'button', - accessibleName: 'Click me', - ariaLabel: null, - ariaLabelledBy: null - }, - rectViewport: { x: 0, y: 0, width: 100, height: 40 }, - rectPage: { x: 0, y: 0, width: 100, height: 40 }, - computedStyles: { - display: 'block', - position: 'static', - width: '100px', - height: '40px', - margin: '0', - padding: '0', - color: '#000', - backgroundColor: '#fff', - border: 'none', - borderRadius: '0', - fontFamily: 'sans-serif', - fontSize: '14px', - fontWeight: '400', - lineHeight: '20px', - textAlign: 'left', - zIndex: 'auto' - } - }, - nearbyText: [], - ancestorPath: ['div', 'body'], - screenshot: null - } -} - describe('browserManager grab operations', () => { let guest: Electron.WebContents @@ -94,19 +42,6 @@ describe('browserManager grab operations', () => { }) describe('awaitGrabSelection', () => { - it('does not treat a valid payload message field as cancellation', async () => { - guestExecuteJavaScriptMock.mockResolvedValueOnce({ - ...makeValidGrabPayload(), - message: 'cancelled' - }) - - const result = await browserManager.awaitGrabSelection('tab-1', 'op-1', guest) - expect(result.kind).toBe('selected') - if (result.kind === 'selected') { - expect(result.payload.target.tagName).toBe('button') - } - }) - it('main-side clamp redacts secret-bearing attribute values', async () => { const mockPayload = { page: { diff --git a/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-auth-user-agent.test.ts b/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-auth-user-agent.test.ts deleted file mode 100644 index 5c533dbb08f..00000000000 --- a/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-auth-user-agent.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const browserMocks = vi.hoisted(() => ({ - appGetPathMock: vi.fn(() => '/downloads'), - shellOpenExternalMock: vi.fn(), - browserWindowFromWebContentsMock: vi.fn(), - menuBuildFromTemplateMock: vi.fn(), - guestOffMock: vi.fn(), - guestOnMock: vi.fn(), - guestSetBackgroundThrottlingMock: vi.fn(), - guestSetWindowOpenHandlerMock: vi.fn(), - guestOpenDevToolsMock: vi.fn(), - webContentsFromIdMock: vi.fn(), - screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), - openPopupWithOriginBarMock: vi.fn(), - processUserAgentMode: 'clean', - processUserAgent: 'Mozilla/5.0 (Test) Chrome/140.0.0.0' -})) - -vi.mock('electron', () => ({ - app: { - getPath: browserMocks.appGetPathMock - }, - BrowserWindow: { - fromWebContents: browserMocks.browserWindowFromWebContentsMock - }, - clipboard: { writeText: vi.fn() }, - shell: { openExternal: browserMocks.shellOpenExternalMock }, - Menu: { - buildFromTemplate: browserMocks.menuBuildFromTemplateMock - }, - screen: { - getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock - }, - webContents: { - fromId: browserMocks.webContentsFromIdMock - } -})) - -vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ - openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock -})) - -vi.mock('../../../identity/browser-process-user-agent', () => ({ - getBrowserProcessUserAgentIdentity: () => ({ - mode: browserMocks.processUserAgentMode, - userAgent: browserMocks.processUserAgent, - electronUserAgent: - browserMocks.processUserAgentMode === 'native' - ? browserMocks.processUserAgent - : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' - }) -})) - -vi.mock('../../../identity/browser-tab-identity', async (importOriginal) => - ( - await import('../viewport/browser-manager-desktop-ua-metadata-test-fixture') - ).withPinnedDesktopUserAgentMetadata(await importOriginal()) -) - -import { browserManager } from '../browser-manager' -import { googleAuthUserAgent } from '../../../identity/browser-google-auth-ua' -import { - guestBaseUserAgent, - rendererWebContentsId, - resetBrowserManagerMocks, - resetBrowserManagerState -} from '../browser-manager-test-harness' -import { pinnedDesktopUserAgentMetadata as desktopMetadata } from '../viewport/browser-manager-desktop-ua-metadata-test-fixture' - -const { - guestOffMock, - guestOnMock, - guestSetBackgroundThrottlingMock, - guestSetWindowOpenHandlerMock, - guestOpenDevToolsMock, - webContentsFromIdMock -} = browserMocks - -describe('browserManager', () => { - beforeEach(() => { - resetBrowserManagerMocks(browserMocks) - resetBrowserManagerState() - browserMocks.processUserAgentMode = 'clean' - browserMocks.processUserAgent = guestBaseUserAgent - }) - - afterEach(() => { - vi.useRealTimers() - }) - - // setUserAgent during will-redirect can abort and replay the request, breaking sign-in POST flows. - it('retargets the auth-host UA over CDP during a redirect instead of cancelling it', async () => { - const debuggerHandlers = new Map void>() - let holdNextUserAgentOverride = false - let releaseHeldUserAgentOverride = (): void => {} - let rejectNextUserAgentOverride = false - const sendCommand = vi.fn((method: string) => { - if (method === 'Emulation.setUserAgentOverride' && holdNextUserAgentOverride) { - holdNextUserAgentOverride = false - return new Promise((resolve) => { - releaseHeldUserAgentOverride = resolve - }) - } - if (method === 'Emulation.setUserAgentOverride' && rejectNextUserAgentOverride) { - rejectNextUserAgentOverride = false - return Promise.reject(new Error('debugger detached')) - } - return Promise.resolve(undefined) - }) - const guest = { - id: 418, - isDestroyed: vi.fn(() => false), - isCurrentlyAudible: vi.fn(() => false), - send: vi.fn(), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'https://mail.google.com/'), - getUserAgent: vi.fn(() => guestBaseUserAgent), - setUserAgent: vi.fn(), - debugger: { - isAttached: vi.fn(() => true), - sendCommand, - on: vi.fn((event: string, handler: () => void) => debuggerHandlers.set(event, handler)), - off: vi.fn((event: string) => debuggerHandlers.delete(event)) - }, - session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } - } - webContentsFromIdMock.mockReturnValue(guest) - - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'browser-redirect-ua', - webContentsId: guest.id, - rendererWebContentsId - }) - const willRedirect = guestOnMock.mock.calls.find( - ([event]) => event === 'will-redirect' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - sendCommand.mockClear() - - rejectNextUserAgentOverride = true - willRedirect( - { preventDefault: vi.fn() }, - 'https://accounts.google.com/v3/signin/identifier', - false, - true - ) - await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached') - - // Failed writes must remain retryable. - willRedirect( - { preventDefault: vi.fn() }, - 'https://accounts.google.com/v3/signin/identifier', - false, - true - ) - await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined() - - expect(guest.setUserAgent).not.toHaveBeenCalled() - expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: googleAuthUserAgent(), - userAgentMetadata: desktopMetadata - }) - - // Detaching clears Chromium's CDP overrides and invalidates the confirmed identity. - sendCommand.mockClear() - debuggerHandlers.get('detach')?.() - willRedirect( - { preventDefault: vi.fn() }, - 'https://accounts.google.com/v3/signin/identifier', - false, - true - ) - expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: googleAuthUserAgent(), - userAgentMetadata: desktopMetadata - }) - - // getUserAgent omits the CDP override; clear it on exit to restore UA and client hints. - sendCommand.mockClear() - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - rejectNextUserAgentOverride = true - didStartNavigation(null, 'https://example.com/', false, true) - await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached') - didStartNavigation(null, 'https://example.com/', false, true) - - expect(guest.setUserAgent).not.toHaveBeenCalled() - expect(sendCommand.mock.calls).toEqual([ - ['Emulation.setUserAgentOverride', { userAgent: '' }], - ['Emulation.setUserAgentOverride', { userAgent: '' }] - ]) - - // Newer confirmed writes outrank older pending writes. - debuggerHandlers.get('detach')?.() - holdNextUserAgentOverride = true - willRedirect( - { preventDefault: vi.fn() }, - 'https://accounts.google.com/v3/signin/identifier', - false, - true - ) - const olderHeldWrite = sendCommand.mock.results.at(-1)?.value as Promise - didStartNavigation(null, 'https://example.com/', false, true) - await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined() - - sendCommand.mockClear() - willRedirect( - { preventDefault: vi.fn() }, - 'https://accounts.google.com/v3/signin/identifier', - false, - true - ) - expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: googleAuthUserAgent(), - userAgentMetadata: desktopMetadata - }) - releaseHeldUserAgentOverride() - await olderHeldWrite - - // A newer failure must preserve older pending writes. - debuggerHandlers.get('detach')?.() - holdNextUserAgentOverride = true - willRedirect( - { preventDefault: vi.fn() }, - 'https://accounts.google.com/v3/signin/identifier', - false, - true - ) - const heldWrite = sendCommand.mock.results.at(-1)?.value as Promise - rejectNextUserAgentOverride = true - didStartNavigation(null, 'https://example.com/', false, true) - await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached') - releaseHeldUserAgentOverride() - await heldWrite - - sendCommand.mockClear() - didStartNavigation(null, 'https://example.com/', false, true) - expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { userAgent: '' }) - browserManager.unregisterGuest('browser-redirect-ua') - }) - - // Prefer a stale UA to aborting the redirect when CDP is unavailable. - - // Direct navigation permits setUserAgent before request dispatch. - - // Keep the process UA override until direct navigation can replace the stale auth base UA. -}) diff --git a/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-load-failure-replay.test.ts b/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-load-failure-replay.test.ts index b18c3456761..cc171520ee3 100644 --- a/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-load-failure-replay.test.ts +++ b/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-load-failure-replay.test.ts @@ -73,60 +73,6 @@ describe('browserManager', () => { vi.useRealTimers() }) - it('drops a queued failure when a replacement navigation starts before registration', () => { - const rendererSendMock = vi.fn() - const guest = { - id: 407, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - isCurrentlyAudible: vi.fn(() => false), - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'https://example.com/'), - ...guestUaMethods() - } - webContentsFromIdMock.mockImplementation((id: number) => - id === guest.id - ? guest - : id === rendererWebContentsId - ? { isDestroyed: vi.fn(() => false), send: rendererSendMock } - : null - ) - - browserManager.attachGuestPolicies(guest as never) - const didFailLoad = guestOnMock.mock.calls.find( - ([event]) => event === 'did-fail-load' - )?.[1] as ( - event: unknown, - errorCode: number, - errorDescription: string, - validatedUrl: string, - isMainFrame: boolean - ) => void - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - - didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) - didStartNavigation(null, 'https://example.com/', false, true) - expect( - browserManager.registerGuest({ - browserPageId: 'browser-late-registration', - webContentsId: guest.id, - rendererWebContentsId - }) - ).toBe(true) - - expect(rendererSendMock).not.toHaveBeenCalledWith( - 'browser:guest-load-failed', - expect.anything() - ) - expect(browserManager.getBrowserPageLoadError('browser-late-registration')).toBeNull() - }) - it('restores a certificate failure when a retry navigation aborts before committing', () => { const guest = { id: 406, diff --git a/src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-child.test.ts b/src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-child.test.ts index ad6a10d0c9c..f8ca2dc42dc 100644 --- a/src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-child.test.ts +++ b/src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-child.test.ts @@ -32,21 +32,12 @@ vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ import { browserManager } from '../browser-manager' import { createDownloadItem, - getDownloadItemEventHandler, rendererWebContentsId, resetBrowserManagerMocks, resetBrowserManagerState } from '../browser-manager-test-harness' -const { - guestOffMock, - guestOnMock, - guestSetBackgroundThrottlingMock, - guestSetWindowOpenHandlerMock, - guestOpenDevToolsMock, - webContentsFromIdMock, - shellOpenExternalMock -} = browserMocks +const { webContentsFromIdMock } = browserMocks let didCreateWindow: (window: { webContents: unknown }) => void = () => {} const fakeGuest = (id: number) => ({ @@ -76,202 +67,6 @@ describe('browserManager popup child policies', () => { vi.useRealTimers() }) - it('attaches guest policies to created popup child windows', () => { - const rendererSendMock = vi.fn() - const childSetBackgroundThrottlingMock = vi.fn() - const childSetWindowOpenHandlerMock = vi.fn() - const childOnMock = vi.fn() - const childOffMock = vi.fn() - const childOpenDevToolsMock = vi.fn() - const childGuest = { - id: 4040, - isDestroyed: vi.fn(() => false), - isCurrentlyAudible: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: childSetBackgroundThrottlingMock, - setWindowOpenHandler: childSetWindowOpenHandlerMock, - on: childOnMock, - off: childOffMock, - openDevTools: childOpenDevToolsMock, - getURL: vi.fn(() => 'https://identity.example.com/login') - } - const guest = { - id: 404, - isDestroyed: vi.fn(() => false), - isCurrentlyAudible: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock - } - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === guest.id) { - return guest - } - if (id === rendererWebContentsId) { - return { isDestroyed: vi.fn(() => false), send: rendererSendMock } - } - return null - }) - - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: guest.id, - rendererWebContentsId - }) - - const didCreateWindowHandler = guestOnMock.mock.calls.find( - ([event]) => event === 'did-create-window' - )?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined - expect(didCreateWindowHandler).toBeTypeOf('function') - - didCreateWindowHandler?.({ webContents: childGuest }) - - expect(childSetBackgroundThrottlingMock).not.toHaveBeenCalled() - expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) - expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength( - 1 - ) - expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) - expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) - - const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { - url: string - }) => { action: 'allow' | 'deny' } - expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({ - action: 'allow' - }) - expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) - expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { - browserPageId: 'browser-1', - origin: 'null', - action: 'blocked' - }) - browserManager.notifyPermissionDenied({ - guestWebContentsId: childGuest.id, - permission: 'notifications', - rawUrl: 'https://identity.example.com/login' - }) - expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { - browserPageId: 'browser-1', - permission: 'notifications', - origin: 'https://identity.example.com' - }) - - const childDidFailLoadHandler = childOnMock.mock.calls.find( - ([event]) => event === 'did-fail-load' - )?.[1] as - | (( - event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ) => void) - | undefined - childDidFailLoadHandler?.( - {} as Electron.Event, - -105, - 'Name not resolved', - 'https://identity.example.com/unavailable', - true - ) - expect(rendererSendMock).not.toHaveBeenCalledWith( - 'browser:guest-load-failed', - expect.anything() - ) - - const childDownloadItem = createDownloadItem() - browserManager.handleGuestWillDownload({ - guestWebContentsId: childGuest.id, - item: childDownloadItem - }) - expect(rendererSendMock).toHaveBeenCalledWith( - 'browser:download-requested', - expect.objectContaining({ browserPageId: 'browser-1' }) - ) - const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done') - childDownloadDoneHandler?.({} as Electron.Event, 'completed') - - const managerState = browserManager as unknown as { - popupOwnerContextByGuestId: Map - } - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true) - - const cleanupChildOnMock = vi.fn() - const cleanupChildGuest = { - ...childGuest, - id: 4041, - on: cleanupChildOnMock, - off: vi.fn(), - setBackgroundThrottling: vi.fn(), - setWindowOpenHandler: vi.fn() - } - const childDidCreateWindowHandler = childOnMock.mock.calls.find( - ([event]) => event === 'did-create-window' - )?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined - childDidCreateWindowHandler?.({ webContents: cleanupChildGuest }) - expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true) - const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock - .calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' } - expect( - cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' }) - ).toMatchObject({ action: 'allow' }) - const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find( - ([event]) => event === 'destroyed' - )?.[1] as (() => void) | undefined - cleanupChildDestroyedHandler?.() - expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false) - - const replacementGuest = { - ...guest, - id: 405, - on: vi.fn(), - off: vi.fn(), - setBackgroundThrottling: vi.fn(), - setWindowOpenHandler: vi.fn() - } - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === guest.id) { - return guest - } - if (id === replacementGuest.id) { - return replacementGuest - } - if (id === rendererWebContentsId) { - return { isDestroyed: vi.fn(() => false), send: rendererSendMock } - } - return null - }) - browserManager.attachGuestPolicies(replacementGuest as never) - browserManager.registerGuest({ - browserPageId: 'browser-1', - webContentsId: replacementGuest.id, - rendererWebContentsId - }) - - expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({ - action: 'deny' - }) - expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next') - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) - - const childDestroyedHandler = childOnMock.mock.calls.find( - ([event]) => event === 'destroyed' - )?.[1] as (() => void) | undefined - childDestroyedHandler?.() - expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) - - browserManager.unregisterAll() - - expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function)) - expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function)) - expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function)) - }) - it("saves a popup page's agent download to its path and refuses a second claim on the tab", async () => { const opener = fakeGuest(406) const popup = fakeGuest(4060) diff --git a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-destroyed-guest.test.ts b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-destroyed-guest.test.ts index 0d9fc8b44a5..06ec03a0215 100644 --- a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-destroyed-guest.test.ts +++ b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-destroyed-guest.test.ts @@ -67,21 +67,6 @@ describe('browser guest destruction ownership', () => { mocks.guests.clear() }) - it('releases registered callbacks and ownership after 1000 distinct guest destructions', () => { - for (let index = 0; index < 1000; index++) { - register(`retired-${index}`).destroy() - } - expectRetainedCount(0) - }) - - it('keeps explicit unregister before destruction idempotent', () => { - const guest = register('explicit') - manager.unregisterGuest('explicit') - guest.destroy() - manager.unregisterGuest('explicit') - expectRetainedCount(0) - }) - it('does not let a captured old destroyed callback retire a replacement guest', () => { const old = register('replacement') const [oldDestroyed] = old.listeners('destroyed') @@ -93,42 +78,6 @@ describe('browser guest destruction ownership', () => { expectRetainedCount(1) }) - it('cleans a popup without retiring its live primary page', () => { - const parent = register('popup-parent') - const popup = createGuest() - manager.attachGuestPolicies(popup.asWebContents(), { - rootGuestWebContentsId: parent.id, - browserTabId: 'popup-parent' - }) - popup.destroy() - expect(manager.getGuestWebContentsId('popup-parent')).toBe(parent.id) - expectRetainedCount(1) - }) - - it('cleans policies for a guest destroyed before registration', () => { - const guest = createGuest() - manager.attachGuestPolicies(guest.asWebContents()) - guest.destroy() - expectRetainedCount(0) - }) - - it('preserves live guest ownership when its renderer process needs reload recovery', () => { - const guest = register('renderer-recovery') - guest.emit('render-process-gone', {}, { reason: 'crashed', exitCode: 1 }) - expect(manager.getGuestWebContentsId('renderer-recovery')).toBe(guest.id) - expect(manager.getSessionProfileIdForTab('renderer-recovery')).toBe('profile-1') - expectRetainedCount(1) - }) - - it('rebuilds ownership when a restored page registers its fresh guest', () => { - register('fresh-owner').destroy() - const replacement = register('fresh-owner') - expect(manager.getGuestWebContentsId('fresh-owner')).toBe(replacement.id) - expect(manager.getWorktreeIdForTab('fresh-owner')).toBe('worktree-1') - expect(manager.getSessionProfileIdForTab('fresh-owner')).toBe('profile-1') - expectRetainedCount(1) - }) - it('preserves a sibling page using the same browser session profile', () => { const retiring = register('retiring') const sibling = register('sibling') diff --git a/src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-ownership.test.ts b/src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-ownership.test.ts deleted file mode 100644 index 60d21990759..00000000000 --- a/src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-ownership.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - appGetPathMock: vi.fn(() => '/downloads'), - shellOpenExternalMock: vi.fn(), - browserWindowFromWebContentsMock: vi.fn(), - menuBuildFromTemplateMock: vi.fn(), - guestOffMock: vi.fn(), - guestOnMock: vi.fn(), - guestSetBackgroundThrottlingMock: vi.fn(), - guestSetWindowOpenHandlerMock: vi.fn(), - guestOpenDevToolsMock: vi.fn(), - webContentsFromIdMock: vi.fn(), - screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), - openPopupWithOriginBarMock: vi.fn(), - processUserAgentMode: 'clean', - processUserAgent: '' -})) - -vi.mock('electron', () => ({ - app: { getPath: mocks.appGetPathMock }, - BrowserWindow: { fromWebContents: mocks.browserWindowFromWebContentsMock }, - clipboard: { writeText: vi.fn() }, - shell: { openExternal: mocks.shellOpenExternalMock }, - Menu: { buildFromTemplate: mocks.menuBuildFromTemplateMock }, - screen: { getCursorScreenPoint: mocks.screenGetCursorScreenPointMock }, - webContents: { fromId: mocks.webContentsFromIdMock } -})) -vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ - openPopupWithOriginBar: mocks.openPopupWithOriginBarMock -})) - -vi.mock('../../../identity/browser-process-user-agent', () => ({ - getBrowserProcessUserAgentIdentity: () => ({ - mode: mocks.processUserAgentMode, - userAgent: mocks.processUserAgent, - electronUserAgent: - mocks.processUserAgentMode === 'native' - ? mocks.processUserAgent - : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' - }) -})) - -import { browserManager } from '../browser-manager' -import { resetBrowserManagerMocks, resetBrowserManagerState } from '../browser-manager-test-harness' -import { - createViewportGuestFactory, - GUEST_CLEAN_UA -} from './browser-manager-viewport-test-fixtures' - -const makeGuest = createViewportGuestFactory(mocks) -const mobile = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true } -const desktop = { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false } -const guests = new Map>() -const registeredGuests = readViewportStateMap('webContentsIdByTabId') -const presetIntents = readViewportStateMap('viewportPresetByTabId') -const pendingOperations = readViewportStateMap('viewportOpsByTabId') -const cdpUserAgentOverrides = readViewportStateMap('cdpUserAgentOverrideStateByGuestId') - -function readViewportStateMap( - name: - | 'webContentsIdByTabId' - | 'viewportPresetByTabId' - | 'viewportOpsByTabId' - | 'cdpUserAgentOverrideStateByGuestId' -): Map { - const value: unknown = browserManager[name] - if (!(value instanceof Map)) { - throw new Error(`Expected manager state map: ${name}`) - } - return value -} - -function register(tab: string, id: number) { - const handle = makeGuest(id) - guests.set(id, handle.guest) - expect( - browserManager.registerOffscreenGuest({ - browserPageId: tab, - webContentsId: id - }) - ).toBe(true) - return handle -} - -function pause(handle: ReturnType, method: string) { - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - let blocked = false - handle.debuggerSendCommand.mockImplementation((next) => { - if (!blocked && next === method) { - blocked = true - entered.resolve() - return gate.promise - } - return Promise.resolve() - }) - return { entered: entered.promise, ...gate } -} - -describe('browser viewport operation ownership', () => { - beforeEach(() => { - expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') - resetBrowserManagerMocks(mocks) - resetBrowserManagerState() - mocks.processUserAgentMode = 'clean' - mocks.processUserAgent = GUEST_CLEAN_UA - guests.clear() - mocks.webContentsFromIdMock.mockImplementation((id) => guests.get(id)) - }) - afterEach(() => { - browserManager.unregisterAll() - vi.restoreAllMocks() - }) - - it('does not recreate closed-tab UA intent after a late touch completion', async () => { - const handle = register('closed', 100) - const gate = pause(handle, 'Emulation.setTouchEmulationEnabled') - const result = browserManager.setViewportOverride('closed', mobile) - await gate.entered - browserManager.unregisterGuest('closed') - expect(presetIntents.size).toBe(0) - const isDestroyed = handle.guest.isDestroyed - expect(vi.isMockFunction(isDestroyed)).toBe(true) - if (vi.isMockFunction(isDestroyed)) { - isDestroyed.mockReturnValue(true) - } - gate.resolve() - await expect(result).resolves.toBe(false) - expect(registeredGuests.size).toBe(0) - expect(presetIntents.size).toBe(0) - expect(cdpUserAgentOverrides.size).toBe(0) - }) - - it('preserves replacement desktop intent after an old clear fails', async () => { - const handle = register('replacement', 102) - await browserManager.setViewportOverride('replacement', mobile) - const gate = pause(handle, 'Emulation.setUserAgentOverride') - const result = browserManager.setViewportOverride('replacement', null) - await gate.entered - browserManager.unregisterGuest('replacement') - register('replacement', 103) - await expect(browserManager.setViewportOverride('replacement', desktop)).resolves.toBe(true) - gate.reject(new Error('Old target closed')) - await expect(result).resolves.toBe(false) - expect(registeredGuests.get('replacement')).toBe(103) - expect(presetIntents.get('replacement')).toEqual({ - guestWebContentsId: 103, - requested: desktop, - applied: desktop - }) - }) - - // Requested and applied metrics may clear while the separate UA override remains tracked. - it('same-owner clear failure keeps the standing mobile override tracked', async () => { - const handle = register('same-owner', 106) - await browserManager.setViewportOverride('same-owner', mobile) - const gate = pause(handle, 'Emulation.setUserAgentOverride') - const result = browserManager.setViewportOverride('same-owner', null) - await gate.entered - gate.reject(new Error('Protocol error')) - await expect(result).resolves.toBe(false) - expect(presetIntents.get('same-owner')).toEqual({ - guestWebContentsId: 106, - requested: null, - applied: null - }) - expect(handle.presentedUserAgent()).toContain('iPhone') - expect(cdpUserAgentOverrides.has(106)).toBe(true) - }) - - it('old queued operations cannot remove or join a replacement promise tail', async () => { - const old = register('queued-replacement', 114) - const oldGate = pause(old, 'Emulation.setTouchEmulationEnabled') - const first = browserManager.setViewportOverride('queued-replacement', mobile) - const second = browserManager.setViewportOverride('queued-replacement', null) - await oldGate.entered - browserManager.unregisterGuest('queued-replacement') - const replacement = register('queued-replacement', 115) - const newGate = pause(replacement, 'Emulation.setTouchEmulationEnabled') - const replacementFirst = browserManager.setViewportOverride('queued-replacement', desktop) - const replacementSecond = browserManager.setViewportOverride('queued-replacement', mobile) - await newGate.entered - const tail = pendingOperations.get('queued-replacement') - oldGate.resolve() - await expect(first).resolves.toBe(false) - await expect(second).resolves.toBe(false) - expect(pendingOperations.get('queued-replacement')).toBe(tail) - newGate.resolve() - await expect(replacementFirst).resolves.toBe(true) - await expect(replacementSecond).resolves.toBe(true) - expect(pendingOperations.size).toBe(0) - expect(presetIntents.get('queued-replacement')).toEqual({ - guestWebContentsId: 115, - requested: mobile, - applied: mobile - }) - expect(replacement.presentedUserAgent()).toContain('iPhone') - }) - - it('normal same-owner toggles preserve last-requested order and remove the promise tail', async () => { - const handle = register('serialized', 116) - const gate = pause(handle, 'Emulation.setTouchEmulationEnabled') - const first = browserManager.setViewportOverride('serialized', mobile) - await gate.entered - const second = browserManager.setViewportOverride('serialized', desktop) - const third = browserManager.setViewportOverride('serialized', null) - gate.resolve() - expect(await Promise.all([first, second, third])).toEqual([true, true, true]) - // Clear mobile identity when desktop metrics replace the mobile viewport. - expect(handle.debuggerSendCommand.mock.calls.map(([method]) => method)).toEqual([ - 'Emulation.setDeviceMetricsOverride', - 'Emulation.setTouchEmulationEnabled', - 'Emulation.setUserAgentOverride', - 'Emulation.setDeviceMetricsOverride', - 'Emulation.setTouchEmulationEnabled', - 'Emulation.setUserAgentOverride', - 'Emulation.clearDeviceMetricsOverride', - 'Emulation.setTouchEmulationEnabled' - ]) - expect(handle.presentedUserAgent()).toBe(GUEST_CLEAN_UA) - expect(pendingOperations.size).toBe(0) - expect(presetIntents.get('serialized')).toEqual({ - guestWebContentsId: 116, - requested: null, - applied: null - }) - }) -}) diff --git a/src/main/ssh/authentication/ssh-multi-key-authentication.test.ts b/src/main/ssh/authentication/ssh-multi-key-authentication.test.ts index d4c8289439d..ab4b2e4de05 100644 --- a/src/main/ssh/authentication/ssh-multi-key-authentication.test.ts +++ b/src/main/ssh/authentication/ssh-multi-key-authentication.test.ts @@ -5,8 +5,7 @@ import { type AnyAuthMethod, type AuthenticationType, type AuthHandlerMiddleware, - type ConnectConfig, - type ParsedKey + type ConnectConfig } from 'ssh2' vi.mock('os', () => ({ @@ -31,7 +30,6 @@ vi.mock('./ssh-identity-file-read', () => ({ })) import { buildConnectConfig } from '../connection/ssh-connect-config' -import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' import '../system-transport/system-ssh-args' import { walkInitialAuthLadder } from '../connection/testing/ssh-connection-test-fixtures' import type { SshTarget } from '../../../shared/ssh-types' @@ -144,33 +142,6 @@ describe('ordered SSH private-key authentication', () => { expect(nextAuth(config, false)).toBe('keyboard-interactive') }) - it('keeps explicit manual keys and unresolved imported keys as singular overrides', () => { - const manual = buildConnectConfig( - makeTarget({ - source: 'manual', - configHost: 'manual.example.com', - host: 'manual.example.com', - identityFile: '/keys/manual' - }), - makeResolved(), - { includeAgent: false, includePrivateKey: true } - ) - const unresolvedImport = buildConnectConfig(makeTarget(), null, { - includeAgent: false, - includePrivateKey: true - }) - - expect(manual.privateKey).toEqual(Buffer.from('/keys/manual')) - expect(unresolvedImport.privateKey).toEqual(Buffer.from('/keys/stale-imported')) - // Single-key targets also need per-stage keyboard-interactive restart for MFA. - expect(nextAuth(manual, true)).toMatchObject({ type: 'none' }) - expect(nextAuth(manual, false)).toMatchObject({ - type: 'publickey', - key: Buffer.from('/keys/manual') - }) - expect(nextAuth(manual, false)).toBe('keyboard-interactive') - }) - it('keeps the password challenge on an agent attempt that carries a key, because no key retry follows it', () => { vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock') // Real key bytes, so '/keys/explicit' counts as unencrypted and IdentitiesOnly builds its agent. @@ -216,35 +187,11 @@ describe('ordered SSH private-key authentication', () => { expect(partialSuccessAuth(config, ['keyboard-interactive'])).toBe(false) }) - it('offers every resolved key for a manually owned config-picker target', () => { - const config = buildConnectConfig( - makeTarget({ - source: 'manual', - configHost: 'prod', - host: 'prod.internal', - identityFile: undefined - }), - makeResolved(), - { includeAgent: false, includePrivateKey: true } - ) - - expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) - expect(nextAuth(config, false)).toMatchObject({ - type: 'publickey', - key: Buffer.from('/keys/unauthorized-first') - }) - expect(nextAuth(config, false)).toMatchObject({ - type: 'publickey', - key: Buffer.from('/keys/authorized-second') - }) - }) - - it('resets ordered authentication for credential retries without extra key reads', () => { + it('resets ordered authentication for credential retries', () => { const config = buildConnectConfig(makeTarget(), makeResolved(), { includeAgent: false, includePrivateKey: true }) - const readsAfterResolution = mockReadFileSync.mock.calls.length expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) config.password = 'retry-password' @@ -257,26 +204,5 @@ describe('ordered SSH private-key authentication', () => { type: 'publickey', key: Buffer.from('/keys/unauthorized-first') }) - expect(mockReadFileSync).toHaveBeenCalledTimes(readsAfterResolution) - }) - - it('keeps encrypted-key prompts tied to the matching fresh identity path', () => { - vi.spyOn(utils, 'parseKey').mockImplementation((key) => { - if ( - Buffer.from(key as Buffer) - .toString() - .includes('encrypted-second') - ) { - return new Error('Encrypted private OpenSSH key detected, but no passphrase given') - } - return { isPrivateKey: () => true } as ParsedKey - }) - const config = buildConnectConfig( - makeTarget(), - makeResolved({ identityFile: ['/keys/first', '/keys/encrypted-second'] }), - { includeAgent: false, includePrivateKey: true } - ) - - expect(getPassphrasePrivateKeyPath(config)).toBe('/keys/encrypted-second') }) }) diff --git a/src/main/ssh/file-transfer/sftp/sftp-stream-late-error.test.ts b/src/main/ssh/file-transfer/sftp/sftp-stream-late-error.test.ts index 60ab686b110..209a6d575f3 100644 --- a/src/main/ssh/file-transfer/sftp/sftp-stream-late-error.test.ts +++ b/src/main/ssh/file-transfer/sftp/sftp-stream-late-error.test.ts @@ -7,9 +7,6 @@ import type { SFTPWrapper } from 'ssh2' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { uploadBuffer, writeStringViaSftp } from './sftp-upload' import { uploadFile } from './sftp-file-upload' -import { writeRemoteInstallFile } from '../../../managed-server/install/remote-install-transfers' -import type { SshConnection } from '../../connection/ssh-connection' -import { getRemoteHostPlatform } from '../../remote-host/ssh-remote-platform' function sftpNoSuchFileError(): Error { return Object.assign(new Error('file does not exist'), { code: 2 }) @@ -78,25 +75,3 @@ describe('late SFTP stream errors', () => { ) }) }) - -describe('sandboxed SFTP subsystem diagnosis', () => { - it('leaves a permission refusal as itself rather than blaming a chroot', async () => { - // Permission denial names visible paths; mislabeling it as sandboxing sends users to fix ProxyJump for chmod. - const conn = { - writeFile: () => Promise.reject(Object.assign(new Error('permission denied'), { code: 3 })) - } as unknown as SshConnection - - const failure: unknown = await writeRemoteInstallFile( - conn, - getRemoteHostPlatform('linux-x64'), - '/home/user/.orca-remote/relay-1/.version', - 'v1' - ).then( - () => null, - (err: unknown) => err - ) - - expect((failure as Error).message).toBe('permission denied') - expect(failure).not.toHaveProperty('sandboxedSftpNamespace') - }) -}) diff --git a/src/main/ssh/host-keys/ssh-known-hosts-source.test.ts b/src/main/ssh/host-keys/ssh-known-hosts-source.test.ts index 176ed1feae4..48223e2627a 100644 --- a/src/main/ssh/host-keys/ssh-known-hosts-source.test.ts +++ b/src/main/ssh/host-keys/ssh-known-hosts-source.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import '../config/ssh-g-config-resolution' @@ -62,16 +62,6 @@ describe('the union of known_hosts entries', () => { expect(verdict(entries, 'shared.example', ED_C)).toBe('mismatch') }) - it('skips a missing file and keeps the rest', async () => { - const root = await createRoot() - const present = join(root, 'known_hosts') - await writeFile(present, hostLine('alpha.example', ED_A)) - - const entries = await loadKnownHostsEntries([join(root, 'absent'), present]) - - expect(verdict(entries, 'alpha.example', ED_A)).toBe('match') - }) - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( 'skips an unreadable file and keeps the rest', async () => { @@ -118,15 +108,6 @@ describe('loadKnownHostsEvidence', () => { expect(evidence.unreadableFileCount).toBe(1) } ) - - it('counts a path that is a directory', async () => { - const root = await createRoot() - await mkdir(join(root, 'a_directory')) - - const evidence = await loadKnownHostsEvidence([join(root, 'a_directory')]) - - expect(evidence.unreadableFileCount).toBe(1) - }) }) /** OpenSSH10.2p1 prints spaced trust paths unquoted; splitting can hide existing changed-key evidence as absent files. */ diff --git a/src/main/ssh/host-server/ssh-host-server-on-connect.test.ts b/src/main/ssh/host-server/ssh-host-server-on-connect.test.ts index 1010b5b15dd..d10c4eed181 100644 --- a/src/main/ssh/host-server/ssh-host-server-on-connect.test.ts +++ b/src/main/ssh/host-server/ssh-host-server-on-connect.test.ts @@ -31,76 +31,6 @@ describe('which server an SSH host runs on connect', () => { expect(d.autoUpdate).not.toHaveBeenCalled() }) - it('checks for an update right after pairing with a server another version runs', async () => { - const d = deps({ - isEmptyHost: () => true, - deploy: vi.fn(async () => ({ - outcome: 'already-current' as const, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the orchestrator reads only the environment id. - environment: { id: 'env-1' } as never, - activeVersion: '0.9' - })), - autoUpdate: vi.fn(async () => ({ - outcome: 'deferred' as const, - code: 'orcad_update_terminals_running', - reason: '3 terminals are running' - })) - }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ - route: 'managed', - environmentId: 'env-1', - update: { state: 'deferred', detail: '3 terminals are running' } - }) - expect(d.autoUpdate).toHaveBeenCalledWith('env-1', expect.anything()) - expect(d.abandonDeploy).not.toHaveBeenCalled() - }) - - // COMPAT(legacy-relay-bridge): the census only reports; converting leaves relay terminals running. - it('converts anyway when the bridge takes the live or unproven relay terminals', async () => { - for (const verdict of ['live', 'unverifiable'] as const) { - const proof = { verdict, count: 2, bridged: true as const } - const d = deps({ relayTerminals: async () => proof }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toMatchObject({ - route: 'managed' - }) - expect(d.convert).toHaveBeenCalledWith(target, proof) - } - }) - - it('surfaces a refusal for any other reason, naming it', async () => { - const d = deps({ - convert: async () => ({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_migration_preflight_blocked', - reason: 'An automation still runs on this host.' - }) - }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ - route: 'unserved', - reason: 'refused', - detail: 'An automation still runs on this host.' - }) - }) - - it('offers "Move anyway" when only saved state refuses', async () => { - const d = deps({ - convert: async () => ({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_migration_preflight_blocked', - reason: 'This SSH host cannot move yet: saved tabs and panes (2).', - canLeaveBehind: true - }) - }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ - route: 'unserved', - reason: 'refused', - detail: 'This SSH host cannot move yet: saved tabs and panes (2).', - canLeaveBehind: true - }) - }) - it('falls back to the relay ladder for good when orcad cannot run, and remembers why', async () => { const d = deps({ isEmptyHost: () => true, @@ -168,32 +98,6 @@ describe('which server an SSH host runs on connect', () => { warn.mockRestore() }) - it('touches no host when this build carries no orcad template', async () => { - const d = deps({ hasTemplate: () => false, isEmptyHost: () => true }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ - route: 'unserved', - reason: 'orcad_unavailable', - detail: 'artifacts_unavailable' - }) - expect(d.deploy).not.toHaveBeenCalled() - }) - - it('retries a transient failure on the next connect without recording it', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const d = deps({ - convert: async () => { - throw new Error('Connection lost') - } - }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ - route: 'unserved', - reason: 'failed', - detail: 'Connection lost' - }) - expect(d.recordUnavailable).not.toHaveBeenCalled() - warn.mockRestore() - }) - it('names a conflict the server will keep refusing, and the way out, instead of a retry', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const d = deps({ @@ -241,19 +145,6 @@ describe('which server an SSH host runs on connect', () => { warn.mockRestore() }) - it('keeps a staged claim of another desktop’s move a retry, since it expires', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const d = deps({ - convert: async () => { - throw new Error('orcad_migration_staged_claim_conflict:repository-path:/root/migrepo') - } - }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toMatchObject({ - reason: 'failed' - }) - warn.mockRestore() - }) - it('records why when even the stdio bridge cannot reach the deployed server', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const d = deps({ @@ -341,36 +232,5 @@ describe('which server an SSH host runs on connect', () => { }) expect(order).toEqual(['start', 'update']) }) - - it('keeps the old version serving while terminals run, and retries on a later connect', async () => { - const reason = '2 terminals are running on this host.' - const d = managed({ - autoUpdate: async () => ({ - outcome: 'deferred', - code: 'orcad_update_terminals_running', - reason - }) - }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ - route: 'managed', - environmentId: 'env-9', - update: { state: 'deferred', detail: reason } - }) - expect(d.recordUpdateFailure).not.toHaveBeenCalled() - }) - - it('still connects when the update check itself throws', async () => { - const d = managed({ - autoUpdate: async () => { - throw new Error('ssh dropped') - } - }) - await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({ - route: 'managed', - environmentId: 'env-9', - update: { state: 'check-failed', detail: 'ssh dropped' } - }) - expect(d.recordUpdateFailure).not.toHaveBeenCalled() - }) }) }) diff --git a/src/main/ssh/remote-host/node-toolchain/ssh-remote-node-resolution.test.ts b/src/main/ssh/remote-host/node-toolchain/ssh-remote-node-resolution.test.ts index d8e23894f67..37ccfbeb69c 100644 --- a/src/main/ssh/remote-host/node-toolchain/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/remote-host/node-toolchain/ssh-remote-node-resolution.test.ts @@ -38,14 +38,6 @@ function loginShellProbe(shell: string, mode: string) { ) } -function decodePowerShellCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - if (!match) { - throw new Error(`No encoded PowerShell command found in: ${command}`) - } - return Buffer.from(match[1]!, 'base64').toString('utf16le') -} - describe('resolveRemoteNodePath', () => { beforeEach(() => { execCommandMock.mockReset() @@ -60,46 +52,36 @@ describe('resolveRemoteNodePath', () => { await expect(resolveRemoteNodePath(conn)).resolves.toBe( '/home/u/.nvm/versions/node/v22.22.0/bin/node' ) - - expect(execCommandMock.mock.calls[1]![1]).toContain("PATH='/usr/bin':$PATH npm --version") - expect(execCommandMock.mock.calls[2]![1]).toContain( - "PATH='/home/u/.nvm/versions/node/v22.22.0/bin':$PATH npm --version" - ) }) - it.runIf(process.platform !== 'win32')( - 'accepts npm elsewhere on PATH without probing another Node candidate', - async () => { - const root = mkdtempSync(path.join(os.tmpdir(), 'orca-split-node-npm-')) - try { - const nodePath = path.join(root, 'selected node', 'bin', 'node') - const npmBinDir = path.join(root, 'npm elsewhere', 'bin') - mkdirSync(path.dirname(nodePath), { recursive: true }) - mkdirSync(npmBinDir, { recursive: true }) - writeFileSync(nodePath, '#!/bin/sh\nprintf "v22.22.0\\n"\n') - writeFileSync(path.join(npmBinDir, 'npm'), '#!/bin/sh\nprintf "11.13.0\\n"\n') - chmodSync(nodePath, 0o755) - chmodSync(path.join(npmBinDir, 'npm'), 0o755) + it.runIf(process.platform !== 'win32')('accepts npm elsewhere on PATH', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-split-node-npm-')) + try { + const nodePath = path.join(root, 'selected node', 'bin', 'node') + const npmBinDir = path.join(root, 'npm elsewhere', 'bin') + mkdirSync(path.dirname(nodePath), { recursive: true }) + mkdirSync(npmBinDir, { recursive: true }) + writeFileSync(nodePath, '#!/bin/sh\nprintf "v22.22.0\\n"\n') + writeFileSync(path.join(npmBinDir, 'npm'), '#!/bin/sh\nprintf "11.13.0\\n"\n') + chmodSync(nodePath, 0o755) + chmodSync(path.join(npmBinDir, 'npm'), 0o755) - execCommandMock - .mockResolvedValueOnce(`${nodePath}\n${path.join(root, 'fallback', 'bin', 'node')}\n`) - .mockImplementationOnce((_conn: SshConnection, command: string) => - Promise.resolve( - execFileSync('/bin/sh', ['-c', command], { - encoding: 'utf8', - env: { HOME: root, PATH: npmBinDir } - }) - ) + execCommandMock + .mockResolvedValueOnce(`${nodePath}\n${path.join(root, 'fallback', 'bin', 'node')}\n`) + .mockImplementationOnce((_conn: SshConnection, command: string) => + Promise.resolve( + execFileSync('/bin/sh', ['-c', command], { + encoding: 'utf8', + env: { HOME: root, PATH: npmBinDir } + }) ) + ) - await expect(resolveRemoteNodePath(conn)).resolves.toBe(nodePath) - // One inventory exec plus one candidate probe keeps SSH startup work bounded. - expect(execCommandMock).toHaveBeenCalledTimes(2) - } finally { - rmSync(root, { recursive: true, force: true }) - } + await expect(resolveRemoteNodePath(conn)).resolves.toBe(nodePath) + } finally { + rmSync(root, { recursive: true, force: true }) } - ) + }) it('finds node under a MISE_DATA_DIR exported from a shell dotfile', async () => { execCommandMock @@ -290,14 +272,6 @@ describe('resolveRemoteNodePath', () => { ) }) - it('accepts Node 18 (the exact minimum) as valid', async () => { - execCommandMock - .mockResolvedValueOnce('/usr/local/bin/node\n') - .mockResolvedValueOnce('v18.0.0\n') - - await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') - }) - it('falls back to the login shell when path probes find nothing', async () => { execCommandMock .mockResolvedValueOnce('\n') @@ -328,37 +302,6 @@ describe('resolveRemoteNodePath', () => { ) }) - it('respects a non-default $SHELL instead of hardcoding bash', async () => { - execCommandMock - .mockResolvedValueOnce('\n') - .mockResolvedValueOnce('/usr/bin/fish') - .mockImplementationOnce(loginShellAnswer('/opt/homebrew/bin/node\n')) - .mockResolvedValueOnce('v22.0.0\n') - - await resolveRemoteNodePath(conn) - - expect(execCommandMock).toHaveBeenNthCalledWith( - 3, - conn, - loginShellProbe('/usr/bin/fish', '-lc'), - { wrapCommand: false, timeoutMs: 8_000 } - ) - }) - - it('uses /bin/sh when the remote shell expansion falls back to it', async () => { - execCommandMock - .mockResolvedValueOnce('\n') - .mockResolvedValueOnce('/bin/sh\n') - .mockImplementationOnce(loginShellAnswer('/usr/local/bin/node\n')) - .mockResolvedValueOnce('v20.0.0\n') - - await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') - expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, loginShellProbe('/bin/sh', '-c'), { - wrapCommand: false, - timeoutMs: 8_000 - }) - }) - it('uses -c (not -lc) for a csh login shell, which rejects combined -lc', async () => { // csh/tcsh require plain -c; -lc/-l can fail with Bad modifier (#8701). execCommandMock @@ -374,25 +317,6 @@ describe('resolveRemoteNodePath', () => { }) }) - it('uses -c (not -lc) for a tcsh login shell', async () => { - execCommandMock - .mockResolvedValueOnce('\n') - .mockResolvedValueOnce('/usr/bin/tcsh\n') - .mockImplementationOnce(loginShellAnswer('/usr/local/bin/node\n')) - .mockResolvedValueOnce('v20.0.0\n') - - await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') - expect(execCommandMock).toHaveBeenNthCalledWith( - 3, - conn, - loginShellProbe('/usr/bin/tcsh', '-c'), - { - wrapCommand: false, - timeoutMs: 8_000 - } - ) - }) - it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 @@ -416,73 +340,6 @@ describe('resolveRemoteNodePath', () => { ).rejects.toBe(sessionLimitError) }) - it('rethrows SSH session-limit errors from candidate version checks in strict mode', async () => { - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - execCommandMock - .mockResolvedValueOnce('/usr/local/bin/node\n') - .mockRejectedValueOnce(sessionLimitError) - - await expect( - resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) - ).rejects.toBe(sessionLimitError) - }) - - it('rethrows SSH session-limit errors from login-shell resolution in strict mode', async () => { - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - execCommandMock.mockResolvedValueOnce('\n').mockRejectedValueOnce(sessionLimitError) - - await expect( - resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) - ).rejects.toBe(sessionLimitError) - }) - - it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => { - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - execCommandMock - .mockResolvedValueOnce('\n') - .mockResolvedValueOnce('/bin/bash') - .mockResolvedValueOnce('\n') - .mockRejectedValueOnce(sessionLimitError) - - await expect( - resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) - ).rejects.toBe(sessionLimitError) - }) - - it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => { - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - execCommandMock.mockRejectedValueOnce(sessionLimitError) - - await expect( - resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { - rethrowSessionLimitErrors: true - }) - ).rejects.toBe(sessionLimitError) - }) - - it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => { - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - execCommandMock - .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') - .mockRejectedValueOnce(sessionLimitError) - - await expect( - resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { - rethrowSessionLimitErrors: true - }) - ).rejects.toBe(sessionLimitError) - }) - it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => { // Shared bootstrap abort must remain cancellation; swallowing it as Node-not-found defeats sequential fallback. const controller = new AbortController() @@ -508,9 +365,6 @@ describe('resolveRemoteNodePath', () => { await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe( 'C:/Program Files/nodejs/node.exe' ) - - const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string) - expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/) }) describe('strict resolution, which settles rung D only on an answered "no Node"', () => { From 16e21424a73401e5aef776d008bce6b7db8541b1 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:16:31 +0000 Subject: [PATCH 016/138] feat(dictation): show committed and in-flight words in the dictation pill (#40428) The pill showed only the current partial and went back to Listening after each final, so pause-committed phrases from transcribe-cpp never appeared. The host's transcript fold moves to src/shared and the desktop pill uses it too: committed text in the foreground, tentative text muted. --- .../runtime-mobile-dictation-controller.ts | 2 +- .../dictation/DictationController.tsx | 14 +++++------ .../dictation/DictationIndicator.fixture.tsx | 19 +++++++++++++-- .../dictation/DictationIndicator.tsx | 16 +++++++++---- .../dictation/use-dictation-speech-events.ts | 23 +++++++++++++++---- .../src/store/slices/dictation/dictation.ts | 14 +++++++---- .../dictation-transcript.ts} | 2 +- 7 files changed, 66 insertions(+), 24 deletions(-) rename src/{main/runtime/speech/mobile-dictation-transcript.ts => shared/dictation-transcript.ts} (89%) diff --git a/src/main/runtime/speech/runtime-mobile-dictation-controller.ts b/src/main/runtime/speech/runtime-mobile-dictation-controller.ts index 219cbe980b1..567cb5bd230 100644 --- a/src/main/runtime/speech/runtime-mobile-dictation-controller.ts +++ b/src/main/runtime/speech/runtime-mobile-dictation-controller.ts @@ -14,7 +14,7 @@ import type { RuntimeStore } from '../construction/runtime-store-contract' import { DictationTranscript, type DictationTranscriptSnapshot -} from './mobile-dictation-transcript' +} from '../../../shared/dictation-transcript' type MobileDictationSession = { id: string diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 0e5cbcc60ef..f4dc3ab93ea 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -30,6 +30,7 @@ import { useHoldDictationGesture } from './use-hold-dictation-gesture' import { publishDictationMeter } from './dictation-meter-store' import { useDictationSilenceStop } from './use-dictation-silence-stop' import { useDictationSpeechEvents } from './use-dictation-speech-events' +import { NO_DICTATION_LIVE_TEXT } from '@/store/slices/dictation/dictation' import { canStartDictation, initialDictationSession, @@ -42,7 +43,7 @@ import { export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) const setDictationState = useAppStore((s) => s.setDictationState) - const setPartialTranscript = useAppStore((s) => s.setPartialTranscript) + const setLiveText = useAppStore((s) => s.setLiveText) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const settings = useAppStore((s) => s.settings) const keybindings = useAppStore((s) => s.keybindings) @@ -82,11 +83,11 @@ export function DictationController() { if (next.sessionId === null) { deliverRef.current = null } + if (next.sessionId !== previous.sessionId) { + setLiveText(NO_DICTATION_LIVE_TEXT) + } if (next.phase !== previous.phase) { setDictationState(next.phase) - if (next.phase !== 'listening' && next.phase !== 'stopping') { - setPartialTranscript('') - } if (next.phase === 'idle') { setPillAnchor(null) keptRef.current = null @@ -100,7 +101,7 @@ export function DictationController() { } return next }, - [setDictationState, setPartialTranscript, clearQueuedSend] + [setDictationState, setLiveText, clearQueuedSend] ) const drainStoppedSession = useCallback((sessionId: string) => { @@ -379,9 +380,8 @@ export function DictationController() { useDictationSpeechEvents({ currentSessionId: () => sessionRef.current.sessionId, - onPartial: setPartialTranscript, + onLiveText: setLiveText, onFinal: (text) => { - setPartialTranscript('') dispatch({ type: 'final-text' }) deliverRef.current?.(text) }, diff --git a/src/renderer/src/components/dictation/DictationIndicator.fixture.tsx b/src/renderer/src/components/dictation/DictationIndicator.fixture.tsx index c09e2132eb5..3f919e65478 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.fixture.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.fixture.tsx @@ -51,13 +51,28 @@ export const liveText = defineFixture({ width: 520, store: { dictationState: 'listening', - partialTranscript: - 'It should go back to the page you came from. Check where returnTo gets dropped' + liveText: { + committed: 'It should go back to the page you came from.', + tentative: 'Check where returnTo gets dropped' + } }, setup: () => publishDictationMeter({ level: 0.6, isSpeaking: true, isClipping: false }), render: () => }) +export const committedText = defineFixture({ + width: 520, + store: { + dictationState: 'listening', + liveText: { + committed: 'It should go back to the page you came from. Check where returnTo gets dropped.', + tentative: '' + } + }, + setup: () => publishDictationMeter({ level: 0.2, isSpeaking: false, isClipping: false }), + render: () => +}) + export const tooLoud = defineFixture({ width: 520, store: { dictationState: 'listening' }, diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index a1fc6e9b63e..9edba96cef8 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -132,7 +132,7 @@ const keepFocusProps = { [KEEPS_FOCUS_ATTRIBUTE]: '', onMouseDown: keepFocus } function DictationPill({ captureLive, anchor, failure, queued }: DictationIndicatorProps) { const t = useTranslate() const dictationState = useAppStore((state) => state.dictationState) - const partialTranscript = useAppStore((state) => state.partialTranscript) + const liveText = useAppStore((state) => state.liveText) const isHoldMode = useAppStore((state) => state.settings?.voice?.dictationMode === 'hold') const shortcut = useShortcutKeyDetails('voice.dictation') const box = useDictationPillBox(anchor) @@ -145,7 +145,8 @@ function DictationPill({ captureLive, anchor, failure, queued }: DictationIndica const showSpinner = useDelayedFlag(finishing, FINISH_SPINNER_DELAY_MS) const nearCloudLimit = cloudModel && seconds >= CLOUD_LIMIT_WARNING_SECONDS const failedKind = dictationState === 'error' ? failure : null - const transcript = partialTranscript.trim() + const committed = liveText.committed + const tentative = liveText.tentative.trim() const queuedStatus = queued === 'chat' ? t( @@ -198,12 +199,17 @@ function DictationPill({ captureLive, anchor, failure, queued }: DictationIndica )} {queuedStatus} - ) : transcript ? ( + ) : committed || tentative ? ( + // Right-aligned so the newest words stay in view; older ones fade off the left edge. - {transcript} + + {committed} + {committed && tentative ? ' ' : null} + {tentative} + ) : ( diff --git a/src/renderer/src/components/dictation/use-dictation-speech-events.ts b/src/renderer/src/components/dictation/use-dictation-speech-events.ts index 3fc07a10979..e93d69bbe4d 100644 --- a/src/renderer/src/components/dictation/use-dictation-speech-events.ts +++ b/src/renderer/src/components/dictation/use-dictation-speech-events.ts @@ -1,17 +1,31 @@ -import { useEffect, useEffectEvent } from 'react' +import { useEffect, useEffectEvent, useRef } from 'react' +import { DictationTranscript } from '../../../../shared/dictation-transcript' +import type { DictationLiveText } from '@/store/slices/dictation/dictation' type DictationSpeechEventHandlers = { /** Text events are dropped unless they belong to this session. */ currentSessionId: () => string | null - onPartial: (text: string) => void + /** The session's words so far, after every partial or final. */ + onLiveText: (text: DictationLiveText) => void onFinal: (text: string) => void onStopped: (sessionId: string) => void onError: (sessionId: string, error: string) => void } export function useDictationSpeechEvents(handlers: DictationSpeechEventHandlers): void { + const transcriptRef = useRef<{ sessionId: string; transcript: DictationTranscript } | null>(null) const isCurrent = useEffectEvent((sessionId: string) => sessionId === handlers.currentSessionId()) - const onPartial = useEffectEvent(handlers.onPartial) + const onText = useEffectEvent( + (sessionId: string, event: { type: 'partial' | 'final'; text: string }) => { + if (transcriptRef.current?.sessionId !== sessionId) { + transcriptRef.current = { sessionId, transcript: new DictationTranscript() } + } + const { transcript } = transcriptRef.current + transcript.accept(event) + const { committed, tentative } = transcript.snapshot() + handlers.onLiveText({ committed, tentative }) + } + ) const onFinal = useEffectEvent(handlers.onFinal) const onStopped = useEffectEvent(handlers.onStopped) const onError = useEffectEvent(handlers.onError) @@ -20,11 +34,12 @@ export function useDictationSpeechEvents(handlers: DictationSpeechEventHandlers) const cleanups = [ window.api.speech.onPartialTranscript((data) => { if (isCurrent(data.sessionId)) { - onPartial(data.text) + onText(data.sessionId, { type: 'partial', text: data.text }) } }), window.api.speech.onFinalTranscript((data) => { if (data.text && isCurrent(data.sessionId)) { + onText(data.sessionId, { type: 'final', text: data.text }) onFinal(data.text) } }), diff --git a/src/renderer/src/store/slices/dictation/dictation.ts b/src/renderer/src/store/slices/dictation/dictation.ts index f2279873d50..eff48daa337 100644 --- a/src/renderer/src/store/slices/dictation/dictation.ts +++ b/src/renderer/src/store/slices/dictation/dictation.ts @@ -1,14 +1,20 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../../types' import type { DictationState, SpeechModelState } from '../../../../../shared/speech-types' +import type { DictationTranscriptSnapshot } from '../../../../../shared/dictation-transcript' + +export type DictationLiveText = Pick + +export const NO_DICTATION_LIVE_TEXT: DictationLiveText = { committed: '', tentative: '' } export type DictationSlice = { dictationState: DictationState - partialTranscript: string + /** The current session's words so far; committed ones have already been inserted. */ + liveText: DictationLiveText activeModelId: string | null modelStates: SpeechModelState[] setDictationState: (state: DictationState) => void - setPartialTranscript: (text: string) => void + setLiveText: (text: DictationLiveText) => void setActiveModelId: (id: string | null) => void setModelStates: (states: SpeechModelState[]) => void refreshModelStates: () => Promise @@ -41,12 +47,12 @@ export const createDictationSlice: StateCreator set({ dictationState: state }), - setPartialTranscript: (text) => set({ partialTranscript: text }), + setLiveText: (liveText) => set({ liveText }), setActiveModelId: (id) => set({ activeModelId: id }), setModelStates, diff --git a/src/main/runtime/speech/mobile-dictation-transcript.ts b/src/shared/dictation-transcript.ts similarity index 89% rename from src/main/runtime/speech/mobile-dictation-transcript.ts rename to src/shared/dictation-transcript.ts index 41ecbea3a8a..2117e82fee8 100644 --- a/src/main/runtime/speech/mobile-dictation-transcript.ts +++ b/src/shared/dictation-transcript.ts @@ -1,4 +1,4 @@ -/** The text a phone or web client sees mid-dictation: committed text never changes, tentative text may still be revised. */ +/** The text so far mid-dictation, for the desktop pill and phone replies: committed text never changes, tentative text may still be revised. */ export type DictationTranscriptSnapshot = { /** Grows with every change, so a client keeps the newest snapshot when replies arrive out of order. */ revision: number From 403736b84494175e2dc9a31f67e84769f5d9cdcb Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 09:18:23 -0800 Subject: [PATCH 017/138] test: remove redundant browser matrices and SSH inventories (#40429) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 2 - .../program-status/hook-precedence.test.ts | 80 --- .../server-replay-evidence-clock.test.ts | 15 - .../testing/tmux/server-tmux-wire.test.ts | 23 - .../grab-guest-overlay-follow.test.ts | 13 - .../browser-manager-guest-visibility.test.ts | 113 ++-- .../browser-manager-viewport-override.test.ts | 410 -------------- .../ssh/connection/ssh-connect-config.test.ts | 130 ----- .../ssh-connection-system-transport.test.ts | 95 +--- .../system-ssh-operations.test.ts | 181 ------- .../ssh-connection-store-route-dedupe.test.ts | 69 --- ...er-workspace-pane.adoption-chrome.test.tsx | 498 +++++++++--------- 12 files changed, 306 insertions(+), 1323 deletions(-) delete mode 100644 src/main/agent-hooks/server/program-status/hook-precedence.test.ts delete mode 100644 src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-override.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index da0472d6b16..229a897a13b 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -617,7 +617,6 @@ "src/main/agent-hooks/server/testing/http/server-transport-interference.test.ts": 1814, "src/main/agent-hooks/server/testing/main-agent/server-turn-ended-at.test.ts": 990, "src/main/agent-hooks/server/testing/main-agent/server-turn-started-at.test.ts": 898, - "src/main/agent-hooks/server/program-status/hook-precedence.test.ts": 45, "src/main/agent-hooks/server/testing/replay/spool.test.ts": 999, "src/main/agent-hooks/server/testing/identity/terminal-handle-row-identity.test.ts": 669, "src/main/agent-hooks/installation/commands/windows-batch-hook-launcher.test.ts": 108, @@ -939,7 +938,6 @@ "src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-child.test.ts": 503, "src/main/browser/guest-lifecycle/manager/popups/browser-manager-popup-routing.test.ts": 575, "src/main/browser/guest-lifecycle/manager/viewport/browser-manager-tab-identity.test.ts": 619, - "src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-override.test.ts": 597, "src/main/browser/network/execution-routes/browser-network-deferred-socket.test.ts": 101, "src/main/browser/network/execution-routes/browser-network-execution-route.test.ts": 272, "src/main/browser/network/tunnels/browser-network-tunnel-client.test.ts": 127, diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts deleted file mode 100644 index abaabfbff81..00000000000 --- a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { EnrichedAgentHookEventPayload } from '../server-types' -import { - HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS as SILENCE, - programStatusHookYieldEndsAt -} from './hook-precedence' -import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status/contracts/agent-status-types' - -const NOW = 1_000_000 - -function row( - payload: Omit, - overrides: Partial = {}, - origin: 'hook' | 'osc' = 'hook' -): EnrichedAgentHookEventPayload { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rule reads only origin, providerSessionOnly, receivedAt and payload. - return { - paneKey: 'tab:leaf', - receivedAt: NOW, - observation: { origin }, - payload: { prompt: 'fix it', ...payload }, - ...overrides - } as EnrichedAgentHookEventPayload -} - -const report = (payload: Omit): ParsedAgentStatusPayload => ({ - prompt: '', - ...payload -}) - -describe('programStatusHookYieldEndsAt', () => { - it('keeps a turn the hooks settled from a later block unless the program worked since', () => { - const hook = row({ state: 'done', agentType: 'antigravity' }) - const block = report({ state: 'waiting', agentType: 'antigravity' }) - expect(programStatusHookYieldEndsAt(hook, block)).toBe(Infinity) - expect(programStatusHookYieldEndsAt(hook, block, NOW - 1)).toBe(Infinity) - expect(programStatusHookYieldEndsAt(hook, block, NOW)).toBe(Infinity) - expect(programStatusHookYieldEndsAt(hook, block, NOW + 1)).toBeUndefined() - for (const state of ['working', 'blocked'] as const) { - expect(programStatusHookYieldEndsAt(hook, report({ state, agentType: 'antigravity' }))).toBe( - NOW + SILENCE - ) - } - }) - - it("never lets a program's child roster reopen a turn the hooks settled", () => { - const hook = row({ state: 'done', agentType: 'pi' }) - const heldOpen = report({ - state: 'working', - agentType: 'pi', - mainAgent: { state: 'done', outcome: 'success', stateStartedAt: NOW } - }) - expect(programStatusHookYieldEndsAt(hook, heldOpen, NOW + 1)).toBe(Infinity) - }) - - it("never lets a program's done settle a turn the hooks hold open for children", () => { - const heldOpen = row({ - state: 'working', - agentType: 'antigravity', - mainAgent: { state: 'done', outcome: 'success', stateStartedAt: NOW } - }) - expect( - programStatusHookYieldEndsAt(heldOpen, report({ state: 'done', agentType: 'antigravity' })) - ).toBe(Infinity) - }) - - it('keeps a question the hooks raised only from a working report that began before it', () => { - for (const state of ['waiting', 'blocked'] as const) { - const hook = row({ state, agentType: 'pi' }) - const working = report({ state: 'working', agentType: 'pi' }) - expect(programStatusHookYieldEndsAt(hook, working, NOW - 1)).toBe(Infinity) - expect(programStatusHookYieldEndsAt(hook, working, NOW)).toBe(Infinity) - expect(programStatusHookYieldEndsAt(hook, working, NOW + 1)).toBe(NOW + SILENCE) - expect(programStatusHookYieldEndsAt(hook, working)).toBe(NOW + SILENCE) - expect(programStatusHookYieldEndsAt(hook, report({ state: 'done', agentType: 'pi' }))).toBe( - NOW + SILENCE - ) - } - }) -}) diff --git a/src/main/agent-hooks/server/testing/replay/server-replay-evidence-clock.test.ts b/src/main/agent-hooks/server/testing/replay/server-replay-evidence-clock.test.ts index 599dfe50a0a..23fee012929 100644 --- a/src/main/agent-hooks/server/testing/replay/server-replay-evidence-clock.test.ts +++ b/src/main/agent-hooks/server/testing/replay/server-replay-evidence-clock.test.ts @@ -71,21 +71,6 @@ describe('the observation clock a relay replay must not restamp', () => { expect(replayed.evidenceObservedAt).toBe(T0) }) - it('carries the observation time out of getStatusSnapshot, not just the listener', () => { - ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) - vi.setSystemTime(T0 + 25 * 60 * 1000) - server.clearStatusEntriesForConnection(CONNECTION) - ingest( - server, - { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }, - { isReplay: true } - ) - - const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)! - expect(row.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1) - expect(row.evidenceObservedAt).toBe(T0) - }) - it('lets a live event restamp the observation time after a replay', () => { ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) vi.setSystemTime(T0 + 25 * 60 * 1000) diff --git a/src/main/agent-hooks/server/testing/tmux/server-tmux-wire.test.ts b/src/main/agent-hooks/server/testing/tmux/server-tmux-wire.test.ts index 5944f21e770..8e73d6014e9 100644 --- a/src/main/agent-hooks/server/testing/tmux/server-tmux-wire.test.ts +++ b/src/main/agent-hooks/server/testing/tmux/server-tmux-wire.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from '../../agent-hook-server' import { PANE } from '../server.test-fixtures' -import { buildRelayHookEnvelope } from '../../../../../wsl-guest/agent-hook-envelope-build' vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) @@ -54,26 +53,4 @@ describe('optional tmux projection wire metadata', () => { server.ingestRemote({ ...unavailable, statusUnavailable: undefined }, 'connection') expect(server.getStatusSnapshot()[0]?.state).toBe('waiting') }) - it('rejects invalid ages and never forwards absolute host clocks', () => { - for (const age of [-1, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, 1.5]) { - server.ingestRemote({ ...envelope, evidenceAgeMs: age }, 'connection') - } - expect(server.getStatusSnapshot()).toHaveLength(0) - const built = buildRelayHookEnvelope( - { ...envelope, hostEvidenceObservedAt: time - 1000 }, - 'opencode' - ) - expect(built.evidenceAgeMs).toBe(1000) - expect(built).not.toHaveProperty('hostEvidenceObservedAt') - vi.setSystemTime(time + 1000) - expect( - buildRelayHookEnvelope( - { ...envelope, hostEvidenceObservedAt: time - 1000 }, - 'opencode', - undefined, - undefined, - { isReplay: true } - ).evidenceAgeMs - ).toBe(2000) - }) }) diff --git a/src/main/browser/element-grab/grab-guest-overlay-follow.test.ts b/src/main/browser/element-grab/grab-guest-overlay-follow.test.ts index da49ba93643..2fd2ca02440 100644 --- a/src/main/browser/element-grab/grab-guest-overlay-follow.test.ts +++ b/src/main/browser/element-grab/grab-guest-overlay-follow.test.ts @@ -165,19 +165,6 @@ async function armGuest(previousMarker?: string, inShadowRoot = false) { } describe('grab overlay after the click', () => { - it.each([ - ['border', 0, 8], - ['padding', 8, 0] - ])('follows %s-only outer growth with a fixed content box', async (_kind, padding, border) => { - const guest = await armGuest() - guest.setTargetSpacing(padding, border) - - expect(guest.notifyResizes()).toBe(1) - guest.flushFrames() - - expect(guest.outline()).toEqual({ x: 40, y: 300, width: 136, height: 46 }) - }) - it('selects an element inside an open shadow root and follows its scroll', async () => { const guest = await armGuest(undefined, true) expect(guest.target.getAttribute(BROWSER_GRAB_SELECTED_ATTRIBUTE)).toBe('selection-1') diff --git a/src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts b/src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts index e12c6c44fd5..51dff485ff3 100644 --- a/src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts +++ b/src/main/browser/guest-lifecycle/manager/browser-manager-guest-visibility.test.ts @@ -81,65 +81,62 @@ describe('browserManager', () => { expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]) }) - it.each(['notify', 'acquire', 'release'] as const)( - 'cleans capture ownership after renderer %s fails', - (failure) => { - vi.useFakeTimers() - const guest = { - id: 1711, - isDestroyed: vi.fn(() => false), - isCurrentlyAudible: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: vi.fn(), - capturePage: vi.fn(async () => null), - setWindowOpenHandler: vi.fn(), - on: vi.fn(), - off: vi.fn(), - openDevTools: vi.fn() - } - const renderer = { - id: rendererWebContentsId, - isDestroyed: vi.fn(() => false), - send: vi.fn(), - setBackgroundThrottling: vi.fn(), - capturePage: vi.fn(async () => null) - } - webContentsFromIdMock.mockImplementation((id: number) => (id === guest.id ? guest : renderer)) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this guest provides the policy and registration members exercised here. - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'page-failure', - webContentsId: guest.id, - rendererWebContentsId - }) - const fail = (): never => { - throw new Error('renderer unavailable') - } - if (failure === 'release') { - const release = browserManager.holdPaintForCapture(guest.id) - renderer.send.mockImplementationOnce(fail) - expect(release).not.toThrow() - } else if (failure === 'notify') { - renderer.send.mockImplementationOnce(fail) - const release = browserManager.holdPaintForCapture(guest.id) - expect(release).not.toThrow() - } else { - renderer.setBackgroundThrottling.mockImplementationOnce(fail) - expect(() => browserManager.holdPaintForCapture(guest.id)).toThrow('renderer unavailable') - } - vi.runAllTimers() - expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]) - - renderer.send.mockClear() - const releaseNext = browserManager.holdPaintForCapture(guest.id) - expect(renderer.send.mock.calls).toEqual([ - ['browser:capturePaintHold', { browserPageId: 'page-failure', held: true }] - ]) - releaseNext() - vi.runAllTimers() - expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]) + it.each(['acquire'])('cleans capture ownership after renderer %s fails', (failure) => { + vi.useFakeTimers() + const guest = { + id: 1711, + isDestroyed: vi.fn(() => false), + isCurrentlyAudible: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: vi.fn(), + capturePage: vi.fn(async () => null), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + off: vi.fn(), + openDevTools: vi.fn() } - ) + const renderer = { + id: rendererWebContentsId, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + setBackgroundThrottling: vi.fn(), + capturePage: vi.fn(async () => null) + } + webContentsFromIdMock.mockImplementation((id: number) => (id === guest.id ? guest : renderer)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this guest provides the policy and registration members exercised here. + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'page-failure', + webContentsId: guest.id, + rendererWebContentsId + }) + const fail = (): never => { + throw new Error('renderer unavailable') + } + if (failure === 'release') { + const release = browserManager.holdPaintForCapture(guest.id) + renderer.send.mockImplementationOnce(fail) + expect(release).not.toThrow() + } else if (failure === 'notify') { + renderer.send.mockImplementationOnce(fail) + const release = browserManager.holdPaintForCapture(guest.id) + expect(release).not.toThrow() + } else { + renderer.setBackgroundThrottling.mockImplementationOnce(fail) + expect(() => browserManager.holdPaintForCapture(guest.id)).toThrow('renderer unavailable') + } + vi.runAllTimers() + expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]) + + renderer.send.mockClear() + const releaseNext = browserManager.holdPaintForCapture(guest.id) + expect(renderer.send.mock.calls).toEqual([ + ['browser:capturePaintHold', { browserPageId: 'page-failure', held: true }] + ]) + releaseNext() + vi.runAllTimers() + expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]) + }) it('does not let a destroyed guest and renderer release a replacement page hold', () => { vi.useFakeTimers() diff --git a/src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-override.test.ts b/src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-override.test.ts deleted file mode 100644 index f87967a35ca..00000000000 --- a/src/main/browser/guest-lifecycle/manager/viewport/browser-manager-viewport-override.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const browserMocks = vi.hoisted(() => ({ - appGetPathMock: vi.fn(() => '/downloads'), - shellOpenExternalMock: vi.fn(), - browserWindowFromWebContentsMock: vi.fn(), - menuBuildFromTemplateMock: vi.fn(), - guestOffMock: vi.fn(), - guestOnMock: vi.fn(), - guestSetBackgroundThrottlingMock: vi.fn(), - guestSetWindowOpenHandlerMock: vi.fn(), - guestOpenDevToolsMock: vi.fn(), - webContentsFromIdMock: vi.fn(), - screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), - openPopupWithOriginBarMock: vi.fn(), - processUserAgentMode: 'clean', - processUserAgent: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' -})) - -vi.mock('electron', () => ({ - app: { - getPath: browserMocks.appGetPathMock - }, - BrowserWindow: { - fromWebContents: browserMocks.browserWindowFromWebContentsMock - }, - clipboard: { writeText: vi.fn() }, - shell: { openExternal: browserMocks.shellOpenExternalMock }, - Menu: { - buildFromTemplate: browserMocks.menuBuildFromTemplateMock - }, - screen: { - getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock - }, - webContents: { - fromId: browserMocks.webContentsFromIdMock - } -})) - -vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({ - openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock -})) - -vi.mock('../../../identity/browser-process-user-agent', () => ({ - getBrowserProcessUserAgentIdentity: () => ({ - mode: browserMocks.processUserAgentMode, - userAgent: browserMocks.processUserAgent, - electronUserAgent: - browserMocks.processUserAgentMode === 'native' - ? browserMocks.processUserAgent - : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' - }) -})) - -vi.mock('../../../identity/browser-tab-identity', async (importOriginal) => - ( - await import('./browser-manager-desktop-ua-metadata-test-fixture') - ).withPinnedDesktopUserAgentMetadata(await importOriginal()) -) - -import { browserManager } from '../browser-manager' -import { googleAuthUserAgent } from '../../../identity/browser-google-auth-ua' -import { - rendererWebContentsId, - resetBrowserManagerMocks, - resetBrowserManagerState -} from '../browser-manager-test-harness' -import { pinnedDesktopUserAgentMetadata as desktopMetadata } from './browser-manager-desktop-ua-metadata-test-fixture' -import { - createViewportGuestFactory, - flushViewportOps, - GUEST_CLEAN_UA -} from './browser-manager-viewport-test-fixtures' - -const { guestOnMock, webContentsFromIdMock } = browserMocks -const makeGuest = createViewportGuestFactory(browserMocks) -const MOBILE_VIEWPORT_OVERRIDE = { - width: 375, - height: 667, - deviceScaleFactor: 2, - mobile: true -} as const - -describe('browserManager', () => { - beforeEach(() => { - resetBrowserManagerMocks(browserMocks) - resetBrowserManagerState() - browserMocks.processUserAgentMode = 'clean' - browserMocks.processUserAgent = GUEST_CLEAN_UA - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('setViewportOverride', () => { - // CDP overrides outrank the WebContents UA and must match the auth-host request identity. - - function lastUserAgentOverride( - debuggerSendCommand: ReturnType - ): Record | undefined { - const calls = debuggerSendCommand.mock.calls.filter( - (call) => call[0] === 'Emulation.setUserAgentOverride' - ) - return calls.at(-1)?.[1] as Record | undefined - } - - // Mobile presets install a Chrome-shaped UA that must not outlive navigation onto an auth host. - it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => { - const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( - 4251, - 'https://example.com/' - ) - // Hold metrics so navigation occurs while the preset is awaiting CDP. - let releaseMetrics = (): void => {} - const metricsGate = new Promise((resolve) => { - releaseMetrics = () => resolve() - }) - debuggerSendCommand.mockImplementation((method: string) => - method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined) - ) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-race-onto-auth', - webContentsId: guest.id as number, - rendererWebContentsId - }) - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - - const presetDone = browserManager.setViewportOverride( - 'tab-race-onto-auth', - MOBILE_VIEWPORT_OVERRIDE - ) - await flushViewportOps() - - didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) - releaseMetrics() - await presetDone - await flushViewportOps() - - expect(presentedUserAgent()).toBe(googleAuthUserAgent()) - expect(debuggerSendCommand).not.toHaveBeenCalledWith( - 'Emulation.setUserAgentOverride', - expect.objectContaining({ userAgent: expect.stringContaining('iPhone') }) - ) - }) - - it('does not leave the auth UA standing when a preset lands mid-navigation off an auth host', async () => { - const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( - 4252, - 'https://accounts.google.com/' - ) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-race-off-auth', - webContentsId: guest.id as number, - rendererWebContentsId - }) - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - - await browserManager.setViewportOverride('tab-race-off-auth', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) - await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ - userAgent: googleAuthUserAgent(), - userAgentMetadata: desktopMetadata - }) - - let releaseMetrics = (): void => {} - const metricsGate = new Promise((resolve) => { - releaseMetrics = () => resolve() - }) - debuggerSendCommand.mockImplementation((method: string) => - method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined) - ) - const presetDone = browserManager.setViewportOverride('tab-race-off-auth', { - width: 1440, - height: 900, - deviceScaleFactor: 2, - mobile: false - }) - await flushViewportOps() - - didStartNavigation(null, 'https://example.com/', false, true) - releaseMetrics() - await presetDone - await flushViewportOps() - - expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: '' }) - }) - - it('falls back to the committed URL once a navigation commits or fails', async () => { - const { guest, debuggerSendCommand, commitNavigationTo, presentedUserAgent } = makeGuest( - 4253, - 'https://example.com/' - ) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-pending-cleared', - webContentsId: guest.id as number, - rendererWebContentsId - }) - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - const didFailLoad = guestOnMock.mock.calls.find( - ([event]) => event === 'did-fail-load' - )?.[1] as ( - event: unknown, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ) => void - const didNavigate = guestOnMock.mock.calls.find( - ([event]) => event === 'did-navigate' - )?.[1] as (event: unknown, url: string) => void - - await browserManager.setViewportOverride('tab-pending-cleared', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) - await flushViewportOps() - - // An aborted target must not determine the identity of later preset writes. - didStartNavigation(null, 'https://accounts.google.com/', false, true) - didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true) - await flushViewportOps() - - expect(guest.setUserAgent).toHaveBeenCalledOnce() - expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) - - debuggerSendCommand.mockClear() - await browserManager.setViewportOverride('tab-pending-cleared', MOBILE_VIEWPORT_OVERRIDE) - await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)?.userAgent).toContain('iPhone') - - didStartNavigation(null, 'https://accounts.google.com/signin', false, true) - commitNavigationTo('https://accounts.google.com/signin') - didNavigate(null, 'https://accounts.google.com/signin') - await flushViewportOps() - - await browserManager.setViewportOverride('tab-pending-cleared', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) - await flushViewportOps() - expect(presentedUserAgent()).toBe(googleAuthUserAgent()) - }) - - it('does not let a superseded navigation failure revert a newer target', async () => { - const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( - 4255, - 'https://example.com/' - ) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-overlapping-navs', - webContentsId: guest.id as number, - rendererWebContentsId - }) - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - const willRedirect = guestOnMock.mock.calls.find( - ([event]) => event === 'will-redirect' - )?.[1] as ( - event: { preventDefault: () => void }, - url: string, - isInPlace: boolean, - isMainFrame: boolean - ) => void - const didFailLoad = guestOnMock.mock.calls.find( - ([event]) => event === 'did-fail-load' - )?.[1] as ( - event: unknown, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ) => void - - await browserManager.setViewportOverride('tab-overlapping-navs', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) - didStartNavigation(null, 'https://example.com/start', false, true) - willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true) - didStartNavigation(null, 'https://accounts.google.com/same', false, true) - await flushViewportOps() - expect(presentedUserAgent()).toBe(googleAuthUserAgent()) - - debuggerSendCommand.mockClear() - didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true) - await flushViewportOps() - - expect(guest.setUserAgent).toHaveBeenCalledOnce() - expect(debuggerSendCommand).not.toHaveBeenCalledWith( - 'Emulation.setUserAgentOverride', - expect.anything() - ) - expect(presentedUserAgent()).toBe(googleAuthUserAgent()) - }) - - it('does not reinstall a preset while its final UA clear is in flight', async () => { - const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( - 4258, - 'https://example.com/' - ) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-final-clear-race', - webContentsId: guest.id as number, - rendererWebContentsId - }) - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - - await browserManager.setViewportOverride('tab-final-clear-race', MOBILE_VIEWPORT_OVERRIDE) - let releaseClearUa = (): void => {} - const clearUaGate = new Promise((resolve) => { - releaseClearUa = resolve - }) - debuggerSendCommand.mockImplementation( - (method: string, params: { userAgent?: string } | undefined) => - method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' - ? clearUaGate - : Promise.resolve(undefined) - ) - const clearDone = browserManager.setViewportOverride('tab-final-clear-race', null) - await flushViewportOps() - - debuggerSendCommand.mockClear() - didStartNavigation(null, 'https://accounts.google.com/', false, true) - await flushViewportOps() - expect(debuggerSendCommand).not.toHaveBeenCalledWith( - 'Emulation.setUserAgentOverride', - expect.objectContaining({ userAgent: expect.stringContaining('iPhone') }) - ) - - releaseClearUa() - await clearDone - await flushViewportOps() - expect(presentedUserAgent()).toBe(googleAuthUserAgent()) - }) - - // Keep failed clears tracked so later navigation can correct the standing override. - it('keeps tracking the standing override when the CDP clear fails', async () => { - const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( - 4254, - 'https://example.com/' - ) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-failed-clear', - webContentsId: guest.id as number, - rendererWebContentsId - }) - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - - await browserManager.setViewportOverride('tab-failed-clear', MOBILE_VIEWPORT_OVERRIDE) - await flushViewportOps() - - debuggerSendCommand.mockImplementation( - (method: string, params: { userAgent?: string } | undefined) => - method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' - ? Promise.reject(new Error('debugger detached')) - : Promise.resolve(undefined) - ) - await expect(browserManager.setViewportOverride('tab-failed-clear', null)).resolves.toBe( - false - ) - await flushViewportOps() - - debuggerSendCommand.mockImplementation(() => Promise.resolve(undefined)) - debuggerSendCommand.mockClear() - didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) - await flushViewportOps() - expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: '' - }) - expect(presentedUserAgent()).toBe(googleAuthUserAgent()) - }) - }) -}) diff --git a/src/main/ssh/connection/ssh-connect-config.test.ts b/src/main/ssh/connection/ssh-connect-config.test.ts index 377a1923b8a..343966ae939 100644 --- a/src/main/ssh/connection/ssh-connect-config.test.ts +++ b/src/main/ssh/connection/ssh-connect-config.test.ts @@ -29,7 +29,6 @@ vi.mock('../authentication/ssh-identity-file-read', () => ({ import { buildConnectConfig } from './ssh-connect-config' import { findDefaultKeyFile } from '../authentication/ssh-auth-resolution' -import { resolveEffectiveProxy } from '../system-transport/ssh-proxy-command' import { walkInitialAuthLadder } from './testing/ssh-connection-test-fixtures' import type { SshTarget } from '../../../shared/ssh-types' import type { SshResolvedConfig } from '../config/ssh-g-config-resolution' @@ -120,70 +119,6 @@ describe('buildConnectConfig', () => { } }) - it('uses ssh -G HostName when a config-host target still points at its alias', () => { - const config = buildConnectConfig( - makeTarget({ label: 'workbox', configHost: 'workbox', host: 'workbox' }), - makeResolved({ hostname: 'workbox.internal' }) - ) - - expect(config.host).toBe('workbox.internal') - }) - - it('uses ssh -G Port when a config-host target still has the default port', () => { - const config = buildConnectConfig( - makeTarget({ configHost: 'workbox', host: 'workbox', port: 22 }), - makeResolved({ port: 2202 }) - ) - - expect(config.port).toBe(2202) - }) - - it('keeps explicit non-default target ports ahead of ssh -G Port', () => { - const config = buildConnectConfig( - makeTarget({ configHost: 'workbox', host: 'workbox', port: 2022 }), - makeResolved({ port: 2202 }) - ) - - expect(config.port).toBe(2022) - }) - - it('uses fresh OpenSSH endpoint authority for imported config targets', () => { - const config = buildConnectConfig( - makeTarget({ - source: 'ssh-config', - configHost: 'workbox', - host: 'stale.example.com', - port: 2022, - username: 'stale-user' - }), - makeResolved({ - hostname: 'current.example.com', - port: 2202, - user: 'current-user' - }) - ) - - expect(config.host).toBe('current.example.com') - expect(config.port).toBe(2202) - expect(config.username).toBe('current-user') - }) - - it('uses configured IdentityAgent before SSH_AUTH_SOCK', () => { - const config = buildConnectConfig( - makeTarget(), - makeResolved({ identityAgent: '/tmp/one-password.sock' }) - ) - expect(config.agent).toBe('/tmp/one-password.sock') - }) - - it('prefers ssh -G resolved IdentityAgent for config-host targets', () => { - const config = buildConnectConfig( - makeTarget({ configHost: 'work', identityAgent: '%d/.1password/agent.sock' }), - makeResolved({ identityAgent: testHomePath('.1password', 'agent.sock') }) - ) - expect(config.agent).toBe(testHomePath('.1password', 'agent.sock')) - }) - it('allows IdentityAgent none to disable agent auth', () => { const config = buildConnectConfig(makeTarget(), makeResolved({ identityAgent: 'none' })) expect(config.agent).toBeUndefined() @@ -200,32 +135,6 @@ describe('buildConnectConfig', () => { expect(config.privateKey).toEqual(Buffer.from('not-a-key')) }) - it('uses fresh OpenSSH IdentityFile authority for imported config targets', () => { - mockReadFileSync.mockImplementation((path: unknown) => Buffer.from(String(path))) - const config = buildConnectConfig( - makeTarget({ - source: 'ssh-config', - configHost: 'workbox', - identityFile: '/home/user/.ssh/stale' - }), - makeResolved({ identityFile: ['/home/user/.ssh/current'] }), - { includeAgent: false, includePrivateKey: true } - ) - - expect(config.privateKey).toEqual(Buffer.from('/home/user/.ssh/current')) - expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.ssh/current') - }) - - it('expands Windows-style target.identityFile before reading private key', () => { - mockReadFileSync.mockReturnValue(Buffer.from('key')) - const config = buildConnectConfig(makeTarget({ identityFile: '~\\.ssh\\custom' }), null, { - includeAgent: false, - includePrivateKey: true - }) - expect(config.privateKey).toEqual(Buffer.from('key')) - expect(mockReadFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'custom')) - }) - it('defers an existing default key file to the no-agent retry', () => { mockExistsSync.mockImplementation( (p: unknown) => String(p) === testHomePath('.ssh', 'id_ed25519') @@ -243,17 +152,6 @@ describe('buildConnectConfig', () => { ]) }) - it('reads a deferred default key only once the agent-first attempt reaches the challenge', () => { - const defaultKeyPath = testHomePath('.ssh', 'id_ed25519') - mockExistsSync.mockImplementation((p: unknown) => String(p) === defaultKeyPath) - mockReadFileSync.mockReturnValue(UNENCRYPTED_KEY) - const config = buildConnectConfig(makeTarget(), null) - // Agent-only success must not touch FIFO/slow-mount identity files. - expect(mockReadFileSync).not.toHaveBeenCalledWith(defaultKeyPath) - expect(walkInitialAuthLadder(config, PASSWORD_HOST_METHODS)).toEqual(['none', 'agent']) - expect(mockReadFileSync).toHaveBeenCalledWith(defaultKeyPath) - }) - it('keeps the challenge when several keys wait, which could spend the retry auth budget', () => { mockReadFileSync.mockReturnValue(UNENCRYPTED_KEY) const config = buildConnectConfig( @@ -303,31 +201,3 @@ describe('buildConnectConfig', () => { } }) }) - -describe('resolveEffectiveProxy', () => { - it('returns target.proxyCommand first', () => { - const target = { ...makeTarget(), proxyCommand: 'cloudflared access ssh --hostname %h' } - const resolved = makeResolved({ proxyCommand: 'other' }) - expect(resolveEffectiveProxy(target, resolved)).toEqual({ - kind: 'proxy-command', - command: 'cloudflared access ssh --hostname %h' - }) - }) - - it('uses fresh OpenSSH proxy authority for imported config targets', () => { - const target = { - ...makeTarget(), - source: 'ssh-config' as const, - configHost: 'workbox', - proxyCommand: 'ssh -W %h:%p stale-bastion' - } - - expect(resolveEffectiveProxy(target, makeResolved())).toBeUndefined() - expect( - resolveEffectiveProxy(target, makeResolved({ proxyCommand: 'ssh -W %h:%p current-bastion' })) - ).toEqual({ - kind: 'proxy-command', - command: 'ssh -W %h:%p current-bastion' - }) - }) -}) diff --git a/src/main/ssh/connection/ssh-connection-system-transport.test.ts b/src/main/ssh/connection/ssh-connection-system-transport.test.ts index 6daac261f7f..64e051880a8 100644 --- a/src/main/ssh/connection/ssh-connection-system-transport.test.ts +++ b/src/main/ssh/connection/ssh-connection-system-transport.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { clientInstances, ssh2Mock } from './testing/ssh-connection-test-client' +import { ssh2Mock } from './testing/ssh-connection-test-client' import { - findSystemSshMock, getOrcaControlSocketPathMock, removeControlSocketPathMock, resetSshConnectionMocks, @@ -25,10 +24,6 @@ import { writeFileViaSystemSsh } from '../system-transport/file-transfer/system- import { CONNECT_TIMEOUT_MS } from './ssh-connection-timing' import { initSshHostKeyStoreFile, trustHostKey } from '../host-keys/ssh-host-key-store' import type { SshTarget } from '../../../shared/ssh-types' -import { - createOpenSshPrivateKeyFixture, - createOpenSshPublicKeyFixture -} from '../authentication/testing/ssh-security-key-identity.test-fixture' vi.mock('../authentication/ssh2-module-load', async () => (await import('./testing/ssh-connection-test-client')).createSsh2LoaderModule() @@ -190,92 +185,6 @@ describe('SshConnection', () => { expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') }) - it('uses system SSH before ssh2 parses a security-key private key', async () => { - findSystemSshMock.mockReturnValue('/usr/bin/ssh') - const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-connect-')) - const keyPath = join(directory, 'id_ed25519_sk') - writeFileSync( - keyPath, - createOpenSshPrivateKeyFixture(['sk-ssh-ed25519@openssh.com'], { encrypted: true }) - ) - const conn = new SshConnection(createTarget({ identityFile: keyPath }), createCallbacks()) - - try { - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - expect(clientInstances).toHaveLength(0) - expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) - } finally { - rmSync(directory, { recursive: true }) - } - }) - - it('uses system SSH for an agent-backed security-key public identity', async () => { - findSystemSshMock.mockReturnValue('/usr/bin/ssh') - const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-agent-connect-')) - const identityPath = join(directory, 'id_ed25519_sk') - writeFileSync( - `${identityPath}.pub`, - createOpenSshPublicKeyFixture('sk-ssh-ed25519@openssh.com') - ) - const conn = new SshConnection(createTarget({ identityFile: identityPath }), createCallbacks()) - - try { - await conn.connect() - - expect(conn.usesSystemSshTransport()).toBe(true) - expect(clientInstances).toHaveLength(0) - expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) - } finally { - rmSync(directory, { recursive: true }) - } - }) - - it('falls back to system SSH when ssh2 hits a local network policy reachability error', async () => { - ssh2Mock.connectBehavior = 'error' - ssh2Mock.connectErrorMessage = - 'connect EHOSTUNREACH 192.168.0.210:22 - Local (192.168.0.2:52112)' - ssh2Mock.connectErrorCode = 'EHOSTUNREACH' - const conn = new SshConnection( - createTarget({ host: '192.168.0.210', label: 'LAN Linux', username: 'hydra' }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - expect(clientInstances).toHaveLength(1) - expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( - expect.objectContaining({ host: '192.168.0.210' }), - 'echo ORCA-SYSTEM-SSH-OK', - { wrapCommand: false } - ) - }) - - it.each([ - 'Handshake failed: no matching host key format', - 'Handshake failed: no matching key exchange algorithm', - 'Handshake failed: no matching C->S cipher' - ])( - 'falls back to system SSH when ssh2 shares no algorithm with the host (%s)', - async (message) => { - ssh2Mock.connectBehavior = 'error' - ssh2Mock.connectErrorMessage = message - const conn = new SshConnection( - createTarget({ host: 'cert.example.com', label: 'Cert host' }), - createCallbacks() - ) - - await conn.connect() - - expect(conn.getState().status).toBe('connected') - expect(conn.usesSystemSshTransport()).toBe(true) - } - ) - it('keeps a host pinned in the Orca key store on ssh2 when negotiation fails', async () => { const storeDir = mkdtempSync(join(tmpdir(), 'orca-ssh-pinned-store-')) try { diff --git a/src/main/ssh/system-transport/system-ssh-operations.test.ts b/src/main/ssh/system-transport/system-ssh-operations.test.ts index 9469a873f71..d9cebf806a2 100644 --- a/src/main/ssh/system-transport/system-ssh-operations.test.ts +++ b/src/main/ssh/system-transport/system-ssh-operations.test.ts @@ -1,7 +1,6 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it, vi, beforeEach } from 'vitest' -import { createFakeSpawnedChild } from '../../../shared/__fixtures__/fake-spawned-child' const { existsSyncMock, spawnMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -20,14 +19,12 @@ vi.mock('child_process', () => ({ spawn: spawnMock })) -import { buildSshArgs } from './system-ssh-args' import { spawnSystemSshCommand } from './system-ssh-command' import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './file-transfer/system-ssh-file-transfer' import type { SshTarget } from '../../../shared/ssh-types' -import type { SystemSshResolvedConfig } from './ssh-control-socket' const SYSTEM_SSH_PATH = process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' @@ -47,37 +44,6 @@ function createTarget(overrides?: Partial): SshTarget { } } -function createResolvedConfig( - overrides?: Partial -): SystemSshResolvedConfig { - return { - hostname: 'example.com', - port: 22, - identityFile: [], - forwardAgent: false, - identitiesOnly: false, - proxyUseFdpass: false, - controlMaster: 'no', - controlPersist: 'no', - ...overrides - } -} - -function expectNoOrcaControlMasterArgs(args: string[]): void { - expect(args).not.toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(false) - expect(args).not.toContain('ControlPersist=300') -} - -function createEventedProcess() { - const proc = createFakeSpawnedChild(12345) - const stdin = Object.assign(proc.stdin, { - write: vi.spyOn(proc.stdin, 'write'), - end: vi.spyOn(proc.stdin, 'end') - }) - return Object.assign(proc, { stdin }) -} - function createMockChildProcess(): EventEmitter & { stdin: PassThrough stdout: PassThrough @@ -110,159 +76,12 @@ function createMockChildProcess(): EventEmitter & { } describe('spawnSystemSsh', () => { - let mockProc: { - stdin: { - write: ReturnType - end: ReturnType - on: ReturnType - } - stdout: { on: ReturnType } - stderr: { on: ReturnType } - pid: number - on: ReturnType - kill: ReturnType - } - beforeEach(() => { existsSyncMock.mockReset() spawnMock.mockReset() - - mockProc = { - stdin: { write: vi.fn(), end: vi.fn(), on: vi.fn() }, - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - pid: 12345, - on: vi.fn(), - kill: vi.fn() - } - spawnMock.mockReturnValue(mockProc) mockSystemSshExists() }) - it('uses configHost without resolved field overrides so OpenSSH sees the Host block', () => { - const args = buildSshArgs( - createTarget({ - configHost: 'fdpass-host', - host: 'resolved.example.com', - port: 2222, - username: 'deploy', - identityFile: '/tmp/key', - identityAgent: '/tmp/agent.sock', - proxyCommand: 'ignored' - }) - ) - - expect(args).toContain('fdpass-host') - expect(args).not.toContain('deploy@fdpass-host') - expect(args).not.toContain('resolved.example.com') - expect(args).not.toContain('-p') - expect(args).not.toContain('-i') - expect(args).not.toContain('IdentityAgent=/tmp/agent.sock') - expect(args).not.toContain('ProxyCommand=ignored') - }) - - it('states the stored endpoint when no Host block claims the alias', () => { - // #11746: Host * can retain a deleted alias's proxy, making it appear config-backed. - const args = buildSshArgs( - createTarget({ - source: 'ssh-config', - configHost: 'prod', - host: '10.0.0.5', - port: 2222, - username: 'deploy' - }), - { aliasClaimedByConfig: false } - ) - - expect(args).toContain('Hostname=10.0.0.5') - expect(args.slice(args.indexOf('-p'))).toContain('2222') - expect(args.slice(args.indexOf('-l'))).toContain('deploy') - expect(args.at(-1)).toBe('prod') - expect(args).not.toContain('deploy@prod') - expect(args).not.toContain('-i') - expect(args).not.toContain('-J') - }) - - it('restricts Kerberos probes to non-interactive GSSAPI authentication', () => { - spawnSystemSshCommand( - createTarget({ - configHost: 'krb-host; touch /tmp/not-run', - source: 'ssh-config', - gssapiAuthentication: true - }), - 'echo ready', - { gssapiOnly: true, wrapCommand: false } - ) - - const args = spawnMock.mock.calls[0][1] as string[] - expect(args).toEqual( - expect.arrayContaining([ - '-o', - 'BatchMode=yes', - '-o', - 'GSSAPIAuthentication=yes', - '-o', - 'PreferredAuthentications=gssapi-with-mic' - ]) - ) - expect(args).not.toContain('BatchMode=no') - const standaloneControlIdx = args.indexOf('-S') - expect(standaloneControlIdx).toBeGreaterThan(-1) - expect(args[standaloneControlIdx + 1]).toBe('none') - expect(args.at(-2)).toBe('krb-host; touch /tmp/not-run') - expect(args.at(-1)).toBe('echo ready') - }) - - it('does not inject Orca ControlMaster flags when ssh config already owns muxing', () => { - const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { - resolvedConfig: createResolvedConfig({ - controlMaster: 'auto', - controlPath: '/Users/me/.ssh/cm/%r@%h:%p', - controlPersist: '10m' - }) - }) - - expectNoOrcaControlMasterArgs(args) - expect(args).not.toContain('-S') - expect(args).toContain('workbox') - }) - - it('does not inject Orca ControlMaster flags for unresolved ssh-config targets', () => { - const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' })) - - expectNoOrcaControlMasterArgs(args) - expect(args).not.toContain('-S') - expect(args).toContain('workbox') - }) - - it('forces standalone SSH when target connection reuse is disabled', () => { - const args = buildSshArgs(createTarget({ systemSshConnectionReuse: false })) - const standaloneControlIdx = args.indexOf('-S') - - expect(standaloneControlIdx).toBeGreaterThan(-1) - expect(args[standaloneControlIdx + 1]).toBe('none') - expectNoOrcaControlMasterArgs(args) - }) - - it('removes wrapped process listeners after command close', () => { - const proc = createEventedProcess() - spawnMock.mockReturnValue(proc) - - const channel = spawnSystemSshCommand(createTarget(), 'echo hello') - const onClose = vi.fn() - channel.on('close', onClose) - proc.emit('close', 0, null) - - expect(onClose).toHaveBeenCalledWith(0, null) - expect(proc.stdout.listenerCount('data')).toBe(0) - expect(proc.stdout.listenerCount('end')).toBe(0) - expect(proc.stdout.listenerCount('error')).toBe(0) - expect(proc.stdin.listenerCount('error')).toBe(0) - expect(proc.listenerCount('exit')).toBe(0) - expect(proc.listenerCount('close')).toBe(0) - expect(proc.listenerCount('error')).toBe(0) - }) - it('pauses command stdout under backpressure and resumes when the channel reads', async () => { const proc = createMockChildProcess() const pause = vi.spyOn(proc.stdout, 'pause') diff --git a/src/main/ssh/targets/ssh-connection-store-route-dedupe.test.ts b/src/main/ssh/targets/ssh-connection-store-route-dedupe.test.ts index e670fcdea7e..76987608be6 100644 --- a/src/main/ssh/targets/ssh-connection-store-route-dedupe.test.ts +++ b/src/main/ssh/targets/ssh-connection-store-route-dedupe.test.ts @@ -105,56 +105,6 @@ describe('SshConnectionStore import route de-duplication', () => { loadUserSshConfigMock.mockReturnValue([]) }) - it('never matches a saved target whose own jump host conflicts with the one it inherits', async () => { - mockStore.addSshTarget({ - id: 'ssh-own-jump', - label: 'Own jump', - configHost: 'saved-own-jump', - host: '172.16.203.131', - port: 22, - username: 'chanmuzi', - jumpHost: 'bastion-b', - source: 'manual' - }) - sshConfigHostsToTargetsMock.mockReturnValue([configCandidate('via-a')]) - - expect((await sshStore.importFromSshConfig()).targets).toHaveLength(1) - }) - - // Saved from the picker when prod was 10.0.0.1; system ssh now follows the config to 10.0.0.9, ssh2 keeps 10.0.0.1. - it('never matches a saved target whose host the two transports resolve differently', async () => { - mockStore.addSshTarget({ - id: 'ssh-prod', - label: 'prod', - configHost: 'prod', - host: '10.0.0.1', - port: 22, - username: 'chanmuzi', - jumpHost: 'b', - source: 'manual' - }) - sshConfigHostsToTargetsMock.mockReturnValue([configCandidate('old')]) - - expect((await sshStore.importFromSshConfig()).targets).toHaveLength(1) - }) - - // Its Host block is gone, so system ssh still dials the saved 192.0.2.10, not the name `fresh` points at. - it('never matches a saved config target whose alias the config no longer claims', async () => { - mockStore.addSshTarget({ - id: 'ssh-removed-block', - label: 'removed-block', - configHost: 'removed-block', - host: '192.0.2.10', - port: 22, - username: 'chanmuzi', - source: 'ssh-config' - }) - unclaimedAliases.add('removed-block') - sshConfigHostsToTargetsMock.mockReturnValue([configCandidate('fresh')]) - - expect((await sshStore.importFromSshConfig()).targets).toHaveLength(1) - }) - it('keeps a route match when this import itself updates the matched target', async () => { mockStore.addSshTarget({ ...configCandidate('moved'), @@ -202,18 +152,6 @@ describe('SshConnectionStore import route de-duplication', () => { ) }) - it('imports the same address behind a different jump host', async () => { - sshConfigHostsToTargetsMock.mockReturnValue([configCandidate('via-b')]) - - expect((await sshStore.importFromSshConfig()).targets).toHaveLength(1) - }) - - it('falls back to alias-only when ssh -G fails, rather than guessing', async () => { - sshConfigHostsToTargetsMock.mockReturnValue([configCandidate('unresolvable')]) - - expect((await sshStore.importFromSshConfig()).targets).toHaveLength(1) - }) - it('lets Restore bring back a removed alias that shares a saved route, as its dialog promised', async () => { sshConfigHostsToTargetsMock.mockReturnValue([configCandidate('aimp')]) mockStore.addDeletedSshConfigAlias('aimp') @@ -221,11 +159,4 @@ describe('SshConnectionStore import route de-duplication', () => { expect((await sshStore.importFromSshConfig()).targets).toEqual([]) expect((await sshStore.importFromSshConfig({ reAdopt: true })).targets).toHaveLength(1) }) - - // #15478's own config: without HostName, OpenSSH connects to the alias name, which only DNS ties to the IP. - it('does not match a HostName-less alias to an IP target by name resolution', async () => { - sshConfigHostsToTargetsMock.mockReturnValue([configCandidate('aimp-no-hostname')]) - - expect((await sshStore.importFromSshConfig()).targets).toHaveLength(1) - }) }) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx index f1f0b221a89..b299eb9620b 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx @@ -314,258 +314,258 @@ function startEditing(text: string): HTMLInputElement { return input } -describe.each([ - { tree: 'in a plain tree', strict: false }, - { tree: 'under StrictMode', strict: true } -])('BrowserPane adoption keeps live address-bar chrome ($tree)', ({ strict }) => { - let webview: ReturnType - let liveAttachment: { detached: boolean } | null = null +describe.each([{ tree: 'under StrictMode', strict: true }])( + 'BrowserPane adoption keeps live address-bar chrome ($tree)', + ({ strict }) => { + let webview: ReturnType + let liveAttachment: { detached: boolean } | null = null - beforeEach(() => { - strictMode = strict - frameCallbacks = [] - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - vi.stubGlobal('cancelAnimationFrame', () => {}) - webview = createWebview() - liveAttachment = null - mocks.attach.mockReset().mockImplementation((_args: unknown, viewport: HTMLElement) => { - // Model the real visible-attachment claim: a second attach before detach must fail so ordering errors stay observable. - if (liveAttachment?.detached === false) { - throw new Error('browser_client_page_renderer_visible_page_claimed') - } - viewport.appendChild(webview) - const attachment = { - webview, - detached: false, - detach: vi.fn(() => { - attachment.detached = true - webview.remove() - }), - nextMetadataRevision: vi.fn(() => 1) - } - liveAttachment = attachment - return attachment - }) - mocks.ensureRemotePage.mockClear() - mocks.callRuntimeRpc.mockClear() - installClientHostedPaneApi({ - ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} } - }) - useAppStore.setState({ - browserPagesByWorkspace: { [WORKSPACE_ID]: [browserPage()] }, - browserUrlHistory: [ - { - url: 'https://example.internal/docs', - normalizedUrl: 'example.internal/docs', - title: 'Example docs', - lastVisitedAt: Date.now(), - visitCount: 3 + beforeEach(() => { + strictMode = strict + frameCallbacks = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', () => {}) + webview = createWebview() + liveAttachment = null + mocks.attach.mockReset().mockImplementation((_args: unknown, viewport: HTMLElement) => { + // Model the real visible-attachment claim: a second attach before detach must fail so ordering errors stay observable. + if (liveAttachment?.detached === false) { + throw new Error('browser_client_page_renderer_visible_page_claimed') } - ], - browserCertificateFailuresByPageId: {}, - pendingAddressBarFocusByPageId: {}, - pendingAddressBarFocusByTabId: {} - }) - stageHandle() - }) - - afterEach(() => { - cleanup() - clearBrowserAddressBarEditSession(PAGE_ID) - clearBrowserPageDeferredNavigation(PAGE_ID) - vi.unstubAllGlobals() - vi.clearAllMocks() - }) - - it('carries the draft, caret and open suggestions through the staged-to-client swap', () => { - renderWorkspacePane() - expect(screen.getByTestId('streamed-viewport')).not.toBeNull() - - const staged = startEditing('example.int') - act(() => staged.setSelectionRange(3, 7)) - expect(staged.getAttribute('aria-expanded')).toBe('true') - - act(() => adoptOntoClient()) - act(() => flushFrames()) - - // The pane really did swap: the streamed viewport is gone and the guest is attached. - expect(screen.queryByTestId('streamed-viewport')).toBeNull() - expect(mocks.attach).toHaveBeenCalled() - - const adopted = addressBar() - expect(adopted.value).toBe('example.int') - expect(document.activeElement).toBe(adopted) - expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([3, 7]) - expect(adopted.getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('option', { name: /Example docs/ })).not.toBeNull() - }) - - // Previewed URLs live in the input; carry the separate typed query too or Escape cannot restore it. - it('carries the typed query behind a previewed suggestion through the swap', () => { - renderWorkspacePane() - const staged = startEditing('example') - act(() => { - fireEvent.keyDown(staged, { key: 'ArrowDown' }) - }) - expect(staged.value).toBe('https://example.internal/docs') - - act(() => adoptOntoClient()) - act(() => flushFrames()) - - const adopted = addressBar() - expect(adopted.value).toBe('https://example.internal/docs') - act(() => { - fireEvent.keyDown(adopted, { key: 'Escape' }) - }) - expect(adopted.value).toBe('example') - }) - - // Apply the saved caret once; retrying it after typing would move it backward or overwrite new text. - it('leaves the caret where the user puts it while the grab is still retrying', () => { - renderWorkspacePane() - const staged = startEditing('example.int') - act(() => staged.setSelectionRange(3, 7)) - - act(() => adoptOntoClient()) - - const adopted = addressBar() - act(() => { - fireEvent.change(adopted, { target: { value: 'example.internal/docs' } }) - }) - act(() => adopted.setSelectionRange(12, 12)) - // The frames the grab would have spent re-taking the bar all land after the user typed. - act(() => flushFrames()) - - expect(adopted.value).toBe('example.internal/docs') - expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([12, 12]) - }) - - // Steal focus after resume to exercise the grab’s caret restore; otherwise select-all retries can escape detection. - - // An open list survives focus naturally; only a dismissed list proves dropdown state crosses the swap. - - // Only same-commit swaps may reclaim focus; later worktree revisits must not resume old edits. - it('does not resume an edit into a pane that mounts in a later task', async () => { - renderWorkspacePane() - startEditing('example.int') - - cleanup() - await act(async () => {}) - renderWorkspacePane() - - const remounted = addressBar() - expect(remounted.value).toBe('') - expect(document.activeElement).not.toBe(remounted) - }) - - // Generation changes reattach in place; edits must survive without remounting. - - // Headless adoption stays streamed and must preserve the same live edit. - - it('holds a URL submitted against the staged page and loads it once the guest attaches', () => { - renderWorkspacePane() - const staged = startEditing('https://example.internal/deferred') - act(() => { - fireEvent.submit(staged.closest('form') ?? staged) + viewport.appendChild(webview) + const attachment = { + webview, + detached: false, + detach: vi.fn(() => { + attachment.detached = true + webview.remove() + }), + nextMetadataRevision: vi.fn(() => 1) + } + liveAttachment = attachment + return attachment + }) + mocks.ensureRemotePage.mockClear() + mocks.callRuntimeRpc.mockClear() + installClientHostedPaneApi({ + ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} } + }) + useAppStore.setState({ + browserPagesByWorkspace: { [WORKSPACE_ID]: [browserPage()] }, + browserUrlHistory: [ + { + url: 'https://example.internal/docs', + normalizedUrl: 'example.internal/docs', + title: 'Example docs', + lastVisitedAt: Date.now(), + visitCount: 3 + } + ], + browserCertificateFailuresByPageId: {}, + pendingAddressBarFocusByPageId: {}, + pendingAddressBarFocusByTabId: {} + }) + stageHandle() }) - // tabShow before host minting returns browser_tab_not_found, which would close the staged tab. - expect(mocks.ensureRemotePage).not.toHaveBeenCalled() - expect(webview.loadURL).not.toHaveBeenCalled() - - act(() => adoptOntoClient()) - act(() => flushFrames()) - - expect(webview.loadURL).toHaveBeenCalledWith('https://example.internal/deferred') - }) - - // Only input identity exposes chrome remounts; save/resume can otherwise hide the repeated dropdown animation. - - // Continuity alone passes if nothing happens; verify generation changes really reattach the guest. - it('re-attaches the guest when a host restart bumps the page generation', () => { - stageClientHostedHandle() - renderWorkspacePane() - act(() => adoptOntoClient()) - act(() => flushFrames()) - - const bar = addressBar() - const stale = mocks.attach.mock.results.at(-1)?.value as { detach: ReturnType } - expect(attachedGenerations()).toContain(CLIENT_PLACEMENT.pageHostGeneration) - expect(attachedGenerations()).not.toContain(CLIENT_PLACEMENT.pageHostGeneration + 1) - - act(() => adoptOntoClient(CLIENT_PLACEMENT.pageHostGeneration + 1)) - act(() => flushFrames()) - - // The host retires the previous generation’s guest; retaining it would leave an inert tag. - expect(attachedGenerations()).toContain(CLIENT_PLACEMENT.pageHostGeneration + 1) - // Detach before attach or the visible-page claim strands the pane on the unavailable notice. - expect(stale.detach.mock.invocationCallOrder[0]).toBeLessThan( - mocks.attach.mock.invocationCallOrder.at(-1) as number - ) - expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() - expect(addressBar()).toBe(bar) - }) - - // Do not consume a one-time tour before placement makes its controls usable. - - // Live host status can override cached client placement and swap to streaming; retain edit-session recovery for this path. - it('carries a live edit when a staged client-hosted page is adopted onto the server', () => { - stageClientHostedHandle() - renderWorkspacePane() - expect(screen.queryByTestId('streamed-viewport')).toBeNull() - - const staged = startEditing('fallback.internal') - act(() => staged.setSelectionRange(4, 4)) - - act(() => adoptOntoServer()) - act(() => flushFrames()) - - // The pane really did fall back to the streamed one. - expect(screen.getByTestId('streamed-viewport')).not.toBeNull() - const adopted = addressBar() - expect(adopted.value).toBe('fallback.internal') - expect(document.activeElement).toBe(adopted) - expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([4, 4]) - }) - - // Restored client-placed pages must await recovery locally; hosts reject server screencasts for them. - it('mounts a restored client-hosted page on the client pane while the host recovers it', () => { - restoreClientHostedHandle() - renderWorkspacePane() - - expect(screen.queryByTestId('streamed-viewport')).toBeNull() - expect(mocks.ensureRemotePage).not.toHaveBeenCalled() - // Recovery retains the ID but replaces its generation; attaching early would strand the pane. - expect(mocks.attach).not.toHaveBeenCalled() - expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() - - act(() => adoptOntoClient()) - act(() => flushFrames()) - - expect(mocks.attach).toHaveBeenCalled() - }) - - it('replays a URL held against the staged page when a headless host adopts it', async () => { - renderWorkspacePane() - const staged = startEditing('https://example.internal/headless') - act(() => { - fireEvent.submit(staged.closest('form') ?? staged) - }) - expect(mocks.ensureRemotePage).not.toHaveBeenCalled() - - await act(async () => { - adoptOntoServer() + afterEach(() => { + cleanup() + clearBrowserAddressBarEditSession(PAGE_ID) + clearBrowserPageDeferredNavigation(PAGE_ID) + vi.unstubAllGlobals() + vi.clearAllMocks() }) - expect(mocks.ensureRemotePage).toHaveBeenCalledTimes(1) - expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( - expect.objectContaining({ environmentId: ENVIRONMENT_ID }), - 'browser.goto', - expect.objectContaining({ url: 'https://example.internal/headless' }), - expect.anything() - ) - }) -}) + it('carries the draft, caret and open suggestions through the staged-to-client swap', () => { + renderWorkspacePane() + expect(screen.getByTestId('streamed-viewport')).not.toBeNull() + + const staged = startEditing('example.int') + act(() => staged.setSelectionRange(3, 7)) + expect(staged.getAttribute('aria-expanded')).toBe('true') + + act(() => adoptOntoClient()) + act(() => flushFrames()) + + // The pane really did swap: the streamed viewport is gone and the guest is attached. + expect(screen.queryByTestId('streamed-viewport')).toBeNull() + expect(mocks.attach).toHaveBeenCalled() + + const adopted = addressBar() + expect(adopted.value).toBe('example.int') + expect(document.activeElement).toBe(adopted) + expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([3, 7]) + expect(adopted.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('option', { name: /Example docs/ })).not.toBeNull() + }) + + // Previewed URLs live in the input; carry the separate typed query too or Escape cannot restore it. + it('carries the typed query behind a previewed suggestion through the swap', () => { + renderWorkspacePane() + const staged = startEditing('example') + act(() => { + fireEvent.keyDown(staged, { key: 'ArrowDown' }) + }) + expect(staged.value).toBe('https://example.internal/docs') + + act(() => adoptOntoClient()) + act(() => flushFrames()) + + const adopted = addressBar() + expect(adopted.value).toBe('https://example.internal/docs') + act(() => { + fireEvent.keyDown(adopted, { key: 'Escape' }) + }) + expect(adopted.value).toBe('example') + }) + + // Apply the saved caret once; retrying it after typing would move it backward or overwrite new text. + it('leaves the caret where the user puts it while the grab is still retrying', () => { + renderWorkspacePane() + const staged = startEditing('example.int') + act(() => staged.setSelectionRange(3, 7)) + + act(() => adoptOntoClient()) + + const adopted = addressBar() + act(() => { + fireEvent.change(adopted, { target: { value: 'example.internal/docs' } }) + }) + act(() => adopted.setSelectionRange(12, 12)) + // The frames the grab would have spent re-taking the bar all land after the user typed. + act(() => flushFrames()) + + expect(adopted.value).toBe('example.internal/docs') + expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([12, 12]) + }) + + // Steal focus after resume to exercise the grab’s caret restore; otherwise select-all retries can escape detection. + + // An open list survives focus naturally; only a dismissed list proves dropdown state crosses the swap. + + // Only same-commit swaps may reclaim focus; later worktree revisits must not resume old edits. + it('does not resume an edit into a pane that mounts in a later task', async () => { + renderWorkspacePane() + startEditing('example.int') + + cleanup() + await act(async () => {}) + renderWorkspacePane() + + const remounted = addressBar() + expect(remounted.value).toBe('') + expect(document.activeElement).not.toBe(remounted) + }) + + // Generation changes reattach in place; edits must survive without remounting. + + // Headless adoption stays streamed and must preserve the same live edit. + + it('holds a URL submitted against the staged page and loads it once the guest attaches', () => { + renderWorkspacePane() + const staged = startEditing('https://example.internal/deferred') + act(() => { + fireEvent.submit(staged.closest('form') ?? staged) + }) + + // tabShow before host minting returns browser_tab_not_found, which would close the staged tab. + expect(mocks.ensureRemotePage).not.toHaveBeenCalled() + expect(webview.loadURL).not.toHaveBeenCalled() + + act(() => adoptOntoClient()) + act(() => flushFrames()) + + expect(webview.loadURL).toHaveBeenCalledWith('https://example.internal/deferred') + }) + + // Only input identity exposes chrome remounts; save/resume can otherwise hide the repeated dropdown animation. + + // Continuity alone passes if nothing happens; verify generation changes really reattach the guest. + it('re-attaches the guest when a host restart bumps the page generation', () => { + stageClientHostedHandle() + renderWorkspacePane() + act(() => adoptOntoClient()) + act(() => flushFrames()) + + const bar = addressBar() + const stale = mocks.attach.mock.results.at(-1)?.value as { detach: ReturnType } + expect(attachedGenerations()).toContain(CLIENT_PLACEMENT.pageHostGeneration) + expect(attachedGenerations()).not.toContain(CLIENT_PLACEMENT.pageHostGeneration + 1) + + act(() => adoptOntoClient(CLIENT_PLACEMENT.pageHostGeneration + 1)) + act(() => flushFrames()) + + // The host retires the previous generation’s guest; retaining it would leave an inert tag. + expect(attachedGenerations()).toContain(CLIENT_PLACEMENT.pageHostGeneration + 1) + // Detach before attach or the visible-page claim strands the pane on the unavailable notice. + expect(stale.detach.mock.invocationCallOrder[0]).toBeLessThan( + mocks.attach.mock.invocationCallOrder.at(-1) as number + ) + expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() + expect(addressBar()).toBe(bar) + }) + + // Do not consume a one-time tour before placement makes its controls usable. + + // Live host status can override cached client placement and swap to streaming; retain edit-session recovery for this path. + it('carries a live edit when a staged client-hosted page is adopted onto the server', () => { + stageClientHostedHandle() + renderWorkspacePane() + expect(screen.queryByTestId('streamed-viewport')).toBeNull() + + const staged = startEditing('fallback.internal') + act(() => staged.setSelectionRange(4, 4)) + + act(() => adoptOntoServer()) + act(() => flushFrames()) + + // The pane really did fall back to the streamed one. + expect(screen.getByTestId('streamed-viewport')).not.toBeNull() + const adopted = addressBar() + expect(adopted.value).toBe('fallback.internal') + expect(document.activeElement).toBe(adopted) + expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([4, 4]) + }) + + // Restored client-placed pages must await recovery locally; hosts reject server screencasts for them. + it('mounts a restored client-hosted page on the client pane while the host recovers it', () => { + restoreClientHostedHandle() + renderWorkspacePane() + + expect(screen.queryByTestId('streamed-viewport')).toBeNull() + expect(mocks.ensureRemotePage).not.toHaveBeenCalled() + // Recovery retains the ID but replaces its generation; attaching early would strand the pane. + expect(mocks.attach).not.toHaveBeenCalled() + expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() + + act(() => adoptOntoClient()) + act(() => flushFrames()) + + expect(mocks.attach).toHaveBeenCalled() + }) + + it('replays a URL held against the staged page when a headless host adopts it', async () => { + renderWorkspacePane() + const staged = startEditing('https://example.internal/headless') + act(() => { + fireEvent.submit(staged.closest('form') ?? staged) + }) + expect(mocks.ensureRemotePage).not.toHaveBeenCalled() + + await act(async () => { + adoptOntoServer() + }) + + expect(mocks.ensureRemotePage).toHaveBeenCalledTimes(1) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: ENVIRONMENT_ID }), + 'browser.goto', + expect.objectContaining({ url: 'https://example.internal/headless' }), + expect.anything() + ) + }) + } +) From d39d87f13c16d19d6d4fa1ede695648689d206aa Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:20:09 +0000 Subject: [PATCH 018/138] feat(settings): add a Use GPU switch for on-device dictation (#39466) * feat(settings): add a Use GPU switch for on-device dictation On by default (Auto: the GPU when it works, the CPU when it fails). Turning it off keeps the transcribe-cpp speech process on the CPU, for machines whose GPU driver freezes or restarts them. The row shows when the selected model runs on transcribe-cpp. * fix(speech): reload the warm speech model when Use GPU changes A loaded recognizer kept the GPU choice it started with, so turning Use GPU off left the next dictations on the GPU until the model unloaded. * fix(speech): reload the speech model after a dictation when Use GPU changed during it A recognizer still in use when Use GPU changed stayed loaded afterwards with the old choice. It now unloads as soon as its last dictation ends, and the warm-up loads it again with the new choice. --- src/main/speech/speech-model-warmup.ts | 15 ++++++++++- src/main/speech/stt-service.test.ts | 18 +++++++++++++ src/main/speech/stt-service.ts | 10 +++++++ src/main/speech/stt-session-start.ts | 1 + src/main/speech/stt-session-state.ts | 2 ++ src/main/speech/stt-session-stop.ts | 15 +++++++++-- .../components/settings/voice/VoicePane.tsx | 13 +++++++++ .../voice/VoiceSpeechGpuSetting.fixture.tsx | 23 ++++++++++++++++ .../settings/voice/VoiceSpeechGpuSetting.tsx | 27 +++++++++++++++++++ src/renderer/src/i18n/locales/de.json | 4 ++- src/renderer/src/i18n/locales/en.json | 4 ++- src/renderer/src/i18n/locales/es.json | 4 ++- src/renderer/src/i18n/locales/fr.json | 4 ++- src/renderer/src/i18n/locales/ja.json | 4 ++- src/renderer/src/i18n/locales/ko.json | 4 ++- src/renderer/src/i18n/locales/pt.json | 4 ++- src/renderer/src/i18n/locales/ru.json | 4 ++- src/renderer/src/i18n/locales/tr.json | 4 ++- src/renderer/src/i18n/locales/zh-Hant.json | 4 ++- src/renderer/src/i18n/locales/zh.json | 4 ++- 20 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.fixture.tsx create mode 100644 src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.tsx diff --git a/src/main/speech/speech-model-warmup.ts b/src/main/speech/speech-model-warmup.ts index 347e4e8c7ca..ac8429fec2a 100644 --- a/src/main/speech/speech-model-warmup.ts +++ b/src/main/speech/speech-model-warmup.ts @@ -12,6 +12,7 @@ type SpeechWarmupStore = { * does not wait for it. Returns a call that re-warms it when the user is likely to dictate. */ export function startSpeechModelWarmup(store: SpeechWarmupStore): () => void { + let speechGpu = store.getSettings().voice?.speechGpu // A settings change retries a model that failed to load; a window focus does not. const sync = (retryFailed: boolean): void => { const voice = store.getSettings().voice @@ -22,7 +23,19 @@ export function startSpeechModelWarmup(store: SpeechWarmupStore): () => void { try { const stt = getSpeechSttService(store) stt.setIdleUnloadDelay(voiceModelUnloadDelayMs(voice)) - void stt.prewarm(voice.sttModel, voice.dictationLanguage, retryFailed) + const prewarm = (): void => { + const current = store.getSettings().voice + if (current?.enabled && current.sttModel) { + void stt.prewarm(current.sttModel, current.dictationLanguage, retryFailed) + } + } + // A loaded recognizer keeps the GPU choice it started with; reload it once no dictation uses it. + if (voice.speechGpu !== speechGpu) { + speechGpu = voice.speechGpu + void stt.unloadModelWhenIdle().then(prewarm) + } else { + prewarm() + } } catch (error) { // Runs inside settings writes; an unusable models folder must not fail the write. console.warn('[speech] Could not pre-warm the speech model:', error) diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index 00862d9af3d..02593f5dde2 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -356,6 +356,24 @@ describe('SttService', () => { expect(getCreatedWorkerCount()).toBe(1) }) + it('keeps a recognizer marked for unload until its dictation ends, then unloads it', async () => { + const service = new SttService(readyModels(), spawnMockWorker) + + await service.startDictation('model-a', vi.fn(), undefined, 'desktop') + const worker = getLastWorker()! + let unloaded = false + const unload = service.unloadModelWhenIdle().then(() => { + unloaded = true + }) + await Promise.resolve() + expect(worker.terminated).toBe(false) + expect(unloaded).toBe(false) + + await service.stopDictation('desktop') + await unload + expect(worker.terminated).toBe(true) + }) + it('kills a worker stuck in a decode instead of making a cancel wait for it', async () => { vi.useFakeTimers() try { diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index c7228ff6bfd..6aa14d9e396 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -121,4 +121,14 @@ export class SttService { this.state.prewarmGeneration += 1 return teardownIdleSttEngine(this.state) } + + /** Unloads the recognizer now, or when the dictations using it end; resolves once it is gone. */ + unloadModelWhenIdle(): Promise { + this.state.prewarmGeneration += 1 + const engine = this.state.engine + if (!engine || sttEngineUsers(this.state, engine).length === 0) { + return teardownIdleSttEngine(this.state) + } + return new Promise((resolve) => engine.onUnloaded.push(resolve)) + } } diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index bf5c1795f07..10d074b319a 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -192,6 +192,7 @@ export function spawnSttEngine( worker, detach, retired: false, + onUnloaded: [], ready: readyPromise.catch((error: unknown) => { failSttEngine(state, engine, STT_WORKER_EXITED_ERROR) throw error diff --git a/src/main/speech/stt-session-state.ts b/src/main/speech/stt-session-state.ts index 236809d1bc6..01a96842f93 100644 --- a/src/main/speech/stt-session-state.ts +++ b/src/main/speech/stt-session-state.ts @@ -20,6 +20,8 @@ export type SttEngine = SttRecognizerConfig & { detach: () => void /** Torn down on purpose, so a load it cut short is not a failure of the model. */ retired: boolean + /** Called once this process is gone; set when it must not outlive its last dictation. */ + onUnloaded: (() => void)[] } export type SttSession = { diff --git a/src/main/speech/stt-session-stop.ts b/src/main/speech/stt-session-stop.ts index 29a4e55820c..325665138c5 100644 --- a/src/main/speech/stt-session-stop.ts +++ b/src/main/speech/stt-session-stop.ts @@ -113,7 +113,17 @@ export function releaseSttSession(state: SttSessionState, session: SttSession): engine.worker.postMessage({ type: 'discard', streamId: session.streamId }) } if (sttEngineUsers(state, engine).length === 0) { - scheduleSttIdleTeardown(state) + if (engine.onUnloaded.length > 0) { + void teardownSttEngine(state, engine) + } else { + scheduleSttIdleTeardown(state) + } + } +} + +function notifyUnloaded(engine: SttEngine): void { + for (const unloaded of engine.onUnloaded.splice(0)) { + unloaded() } } @@ -133,7 +143,7 @@ export function failSttEngine(state: SttSessionState, engine: SttEngine, error: session.sink({ type: 'error', error }) session.sink({ type: 'stopped' }) } - void engine.worker.terminate() + void engine.worker.terminate().finally(() => notifyUnloaded(engine)) } export async function prepareSttModelForDeletion( @@ -196,4 +206,5 @@ export async function teardownSttEngine(state: SttSessionState, engine: SttEngin engine.detach() // Resolves once the process is gone, so its model files are free to delete. await engine.worker.terminate() + notifyUnloaded(engine) } diff --git a/src/renderer/src/components/settings/voice/VoicePane.tsx b/src/renderer/src/components/settings/voice/VoicePane.tsx index 0044a334037..4b2918d67fc 100644 --- a/src/renderer/src/components/settings/voice/VoicePane.tsx +++ b/src/renderer/src/components/settings/voice/VoicePane.tsx @@ -16,6 +16,8 @@ import { VoiceDictationSettingsSection } from './VoiceDictationSettingsSection' import { VoiceModelUnloadSetting } from './VoiceModelUnloadSetting' import { VoiceSpeechModelSection } from './VoiceSpeechModelSection' import { SettingsSwitchRow } from '../form-controls/SettingsFormControls' +import { VoiceSpeechGpuSetting } from './VoiceSpeechGpuSetting' +import { speechEngineForModelFiles } from '../../../../../shared/speech-model-engine' import { matchesSettingsSearch } from '../search/settings-search' import { getOpenaiTranscriptionSearchEntry } from './voice-pane-search' import { useTranslate } from '@/i18n/use-translate' @@ -299,6 +301,17 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J onUpdateVoiceSettings={updateVoiceSettings} /> + {selectedModel && + speechEngineForModelFiles(selectedModel.files ?? []) === 'transcribe-cpp' && ( + <> + + + + )} + {showOpenAiSettingsRow && ( <> diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.fixture.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.fixture.tsx new file mode 100644 index 00000000000..0216d159970 --- /dev/null +++ b/src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.fixture.tsx @@ -0,0 +1,23 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { getDefaultVoiceSettings } from '../../../../../shared/constants' +import { VoiceSpeechGpuSetting } from './VoiceSpeechGpuSetting' + +export const auto = defineFixture({ + width: 640, + render: () => ( + {}} + /> + ) +}) + +export const off = defineFixture({ + width: 640, + render: () => ( + {}} + /> + ) +}) diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.tsx new file mode 100644 index 00000000000..e608eb3c992 --- /dev/null +++ b/src/renderer/src/components/settings/voice/VoiceSpeechGpuSetting.tsx @@ -0,0 +1,27 @@ +import type { VoiceSettings } from '../../../../../shared/speech-types' +import { SettingsSwitchRow } from '../form-controls/SettingsFormControls' +import { useTranslate } from '@/i18n/use-translate' + +type VoiceSpeechGpuSettingProps = { + voiceSettings: VoiceSettings + onUpdateVoiceSettings: (updates: Partial) => void +} + +export function VoiceSpeechGpuSetting({ + voiceSettings, + onUpdateVoiceSettings +}: VoiceSpeechGpuSettingProps): React.JSX.Element { + const t = useTranslate() + const gpuOn = voiceSettings.speechGpu !== 'off' + return ( + onUpdateVoiceSettings({ speechGpu: gpuOn ? 'off' : 'auto' })} + /> + ) +} diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 42212a6bf5a..9efdda76d34 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -9469,7 +9469,9 @@ "openAiKeyName": "OpenAI-Transkriptionsschlüssel", "dictationShortcutDescription": "Mit {{shortcut}} Text in den jeweils fokussierten Bereich diktieren.", "dictationModeAuto": "Automatisch", - "dictationModesWithAutoDescription": "Automatisch: Halten Sie {{shortcut}} gedrückt, um bis zum Loslassen zu diktieren, oder tippen Sie kurz zum Starten und Stoppen. Gedrückt halten: Diktieren, solange {{shortcut}} gedrückt bleibt. Umschalten: {{shortcut}} einmal zum Starten und erneut zum Stoppen drücken." + "dictationModesWithAutoDescription": "Automatisch: Halten Sie {{shortcut}} gedrückt, um bis zum Loslassen zu diktieren, oder tippen Sie kurz zum Starten und Stoppen. Gedrückt halten: Diktieren, solange {{shortcut}} gedrückt bleibt. Umschalten: {{shortcut}} einmal zum Starten und erneut zum Stoppen drücken.", + "speechGpuLabel": "GPU verwenden", + "speechGpuDescription": "Sprache auf der Grafikkarte erkennen, wenn sie funktioniert, und auf der CPU, wenn sie versagt. Deaktivieren, wenn das Diktieren deinen Computer einfrieren oder neu starten lässt." }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} entfernen", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 94e56027635..1de9bbdc722 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9809,7 +9809,9 @@ "openAiKeyName": "Your OpenAI transcription key", "dictationShortcutDescription": "Press {{shortcut}} to dictate text into any focused pane.", "dictationModeAuto": "Auto", - "dictationModesWithAutoDescription": "Auto: hold {{shortcut}} to dictate until you let go, or tap it to start and stop. Hold: dictate while {{shortcut}} is held. Toggle: press {{shortcut}} once to start, again to stop." + "dictationModesWithAutoDescription": "Auto: hold {{shortcut}} to dictate until you let go, or tap it to start and stop. Hold: dictate while {{shortcut}} is held. Toggle: press {{shortcut}} once to start, again to stop.", + "speechGpuLabel": "Use GPU", + "speechGpuDescription": "Recognize speech on the graphics card when it works, and on the CPU when it fails. Turn off if dictation freezes or restarts your computer." }, "WorktreeSymlinksSection": { "1c1e35b219": "Remove {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a44fc6490cd..7f2b58f9bae 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9066,7 +9066,9 @@ "openAiKeyName": "Su clave de transcripción OpenAI", "dictationShortcutDescription": "Pulsa {{shortcut}} para dictar texto en cualquier panel con foco.", "dictationModeAuto": "Automático", - "dictationModesWithAutoDescription": "Automático: mantén {{shortcut}} para dictar hasta que lo sueltes, o tócalo para empezar y parar. Mantener: dicta mientras {{shortcut}} se mantiene presionado. Alternar: pulsa {{shortcut}} una vez para empezar y otra para parar." + "dictationModesWithAutoDescription": "Automático: mantén {{shortcut}} para dictar hasta que lo sueltes, o tócalo para empezar y parar. Mantener: dicta mientras {{shortcut}} se mantiene presionado. Alternar: pulsa {{shortcut}} una vez para empezar y otra para parar.", + "speechGpuLabel": "Usar GPU", + "speechGpuDescription": "Reconoce la voz en la tarjeta gráfica cuando funciona y en la CPU cuando falla. Desactívalo si el dictado congela o reinicia tu equipo." }, "WorktreeSymlinksSection": { "1c1e35b219": "Quitar {{value0}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ff96d61fa05..b2099267eea 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -9337,7 +9337,9 @@ "openAiKeyName": "Votre clé de transcription OpenAI", "dictationShortcutDescription": "Appuyez sur {{shortcut}} pour dicter du texte dans n'importe quel volet actif.", "dictationModeAuto": "Auto", - "dictationModesWithAutoDescription": "Auto : maintenez {{shortcut}} pour dicter jusqu’à ce que vous relâchiez, ou appuyez brièvement pour démarrer et arrêter. Maintien : dictez tant que {{shortcut}} est maintenu. Bascule : appuyez une fois sur {{shortcut}} pour démarrer, puis à nouveau pour arrêter." + "dictationModesWithAutoDescription": "Auto : maintenez {{shortcut}} pour dicter jusqu’à ce que vous relâchiez, ou appuyez brièvement pour démarrer et arrêter. Maintien : dictez tant que {{shortcut}} est maintenu. Bascule : appuyez une fois sur {{shortcut}} pour démarrer, puis à nouveau pour arrêter.", + "speechGpuLabel": "Utiliser le GPU", + "speechGpuDescription": "Reconnaît la parole sur la carte graphique quand elle fonctionne, et sur le processeur en cas d’échec. Désactivez-le si la dictée fige ou redémarre votre ordinateur." }, "WorktreeSymlinksSection": { "1c1e35b219": "Supprimer {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2d54a6de93d..ccc1592d891 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8810,7 +8810,9 @@ "openAiKeyName": "OpenAI 転写キー", "dictationShortcutDescription": "{{shortcut}} を押すと、フォーカスされているペインにテキストを音声入力できます。", "dictationModeAuto": "自動", - "dictationModesWithAutoDescription": "自動:{{shortcut}} を押し続けると離すまで音声入力し、短く押すと開始と停止を切り替えます。ホールド:{{shortcut}} を押している間、音声入力します。トグル:{{shortcut}} を1回押すと開始し、もう一度押すと停止します。" + "dictationModesWithAutoDescription": "自動:{{shortcut}} を押し続けると離すまで音声入力し、短く押すと開始と停止を切り替えます。ホールド:{{shortcut}} を押している間、音声入力します。トグル:{{shortcut}} を1回押すと開始し、もう一度押すと停止します。", + "speechGpuLabel": "GPU を使用", + "speechGpuDescription": "動作する場合はグラフィックカードで音声を認識し、失敗した場合は CPU で認識します。音声入力でコンピュータがフリーズしたり再起動したりする場合はオフにしてください。" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} を削除", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d0871a613a4..2c8980b0680 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8773,7 +8773,9 @@ "openAiKeyName": "OpenAI 트랜스크립션 키", "dictationShortcutDescription": "{{shortcut}} 단축키를 눌러 포커스된 패널에 텍스트를 받아쓰세요.", "dictationModeAuto": "자동", - "dictationModesWithAutoDescription": "자동: {{shortcut}} 단축키를 길게 누르면 손을 뗄 때까지 받아쓰기하고, 짧게 누르면 시작하고 중지합니다. 길게 누르기: {{shortcut}} 단축키를 누르고 있는 동안 받아쓰기합니다. 토글: {{shortcut}} 단축키를 한 번 누르면 받아쓰기를 시작하고, 다시 누르면 중지합니다." + "dictationModesWithAutoDescription": "자동: {{shortcut}} 단축키를 길게 누르면 손을 뗄 때까지 받아쓰기하고, 짧게 누르면 시작하고 중지합니다. 길게 누르기: {{shortcut}} 단축키를 누르고 있는 동안 받아쓰기합니다. 토글: {{shortcut}} 단축키를 한 번 누르면 받아쓰기를 시작하고, 다시 누르면 중지합니다.", + "speechGpuLabel": "GPU 사용", + "speechGpuDescription": "그래픽 카드가 작동하면 그래픽 카드에서, 실패하면 CPU에서 음성을 인식합니다. 받아쓰기 중 컴퓨터가 멈추거나 다시 시작되면 끄세요." }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} 제거", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 58e8008d452..9a0154fe2f4 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -6506,7 +6506,9 @@ "openAiKeyName": "Sua chave de transcrição da OpenAI", "dictationShortcutDescription": "Pressione {{shortcut}} para ditar texto em qualquer painel com foco.", "dictationModeAuto": "Automático", - "dictationModesWithAutoDescription": "Automático: mantenha {{shortcut}} pressionado para ditar até soltar, ou toque para iniciar e parar. Manter pressionado: dite enquanto mantém {{shortcut}} pressionado. Alternar: pressione {{shortcut}} uma vez para iniciar e novamente para parar." + "dictationModesWithAutoDescription": "Automático: mantenha {{shortcut}} pressionado para ditar até soltar, ou toque para iniciar e parar. Manter pressionado: dite enquanto mantém {{shortcut}} pressionado. Alternar: pressione {{shortcut}} uma vez para iniciar e novamente para parar.", + "speechGpuLabel": "Usar GPU", + "speechGpuDescription": "Reconhece a fala na placa de vídeo quando ela funciona e na CPU quando falha. Desative se o ditado travar ou reiniciar o computador." }, "NotificationsPane": { "906b4afebf": "Enviar Notificação de Teste", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index fffad2be785..1b978cb182f 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -9821,7 +9821,9 @@ "openAiKeyName": "Ваш ключ расшифровки OpenAI", "dictationShortcutDescription": "Нажмите {{shortcut}}, чтобы диктовать текст в любую активную панель.", "dictationModeAuto": "Авто", - "dictationModesWithAutoDescription": "Авто: удерживайте {{shortcut}}, чтобы диктовать до отпускания, или нажмите коротко, чтобы начать и остановить. Удержание: диктуйте, пока удерживаете {{shortcut}}. Переключение: нажмите {{shortcut}} для начала и ещё раз для остановки." + "dictationModesWithAutoDescription": "Авто: удерживайте {{shortcut}}, чтобы диктовать до отпускания, или нажмите коротко, чтобы начать и остановить. Удержание: диктуйте, пока удерживаете {{shortcut}}. Переключение: нажмите {{shortcut}} для начала и ещё раз для остановки.", + "speechGpuLabel": "Использовать GPU", + "speechGpuDescription": "Распознавать речь на видеокарте, если она работает, и на процессоре, если нет. Отключите, если диктовка зависает или перезагружает компьютер." }, "WorktreeSymlinksSection": { "1c1e35b219": "Удалить {{value0}}", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index ec6a8214a38..d6fe1ef1165 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -9453,7 +9453,9 @@ "openAiKeyName": "OpenAI transkripsiyon anahtarınız", "dictationShortcutDescription": "Odaktaki herhangi bir bölmeye metin dikte etmek için {{shortcut}} tuşlarına basın.", "dictationModeAuto": "Otomatik", - "dictationModesWithAutoDescription": "Otomatik: bırakana kadar dikte etmek için {{shortcut}} tuşlarını basılı tutun veya başlatıp durdurmak için kısaca dokunun. Basılı tut: {{shortcut}} basılıyken dikte edin. Aç/kapat: başlatmak için {{shortcut}} tuşlarına bir kez, durdurmak için tekrar basın." + "dictationModesWithAutoDescription": "Otomatik: bırakana kadar dikte etmek için {{shortcut}} tuşlarını basılı tutun veya başlatıp durdurmak için kısaca dokunun. Basılı tut: {{shortcut}} basılıyken dikte edin. Aç/kapat: başlatmak için {{shortcut}} tuşlarına bir kez, durdurmak için tekrar basın.", + "speechGpuLabel": "GPU kullan", + "speechGpuDescription": "Konuşmayı çalıştığında ekran kartında, başarısız olduğunda CPU’da tanır. Dikte bilgisayarınızı donduruyor veya yeniden başlatıyorsa kapatın." }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} kaldırılsın mı", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index b7800265326..b19ece55799 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -9290,7 +9290,9 @@ "openAiKeyName": "您的 OpenAI 轉錄金鑰", "dictationShortcutDescription": "按 {{shortcut}} 可將文字聽寫到目前取得焦點的任意窗格中。", "dictationModeAuto": "自動", - "dictationModesWithAutoDescription": "自動:按住 {{shortcut}} 聽寫,放開即停止;輕按則開始或停止。按住:按住 {{shortcut}} 期間進行聽寫。切換:按一次 {{shortcut}} 開始,再按一次停止。" + "dictationModesWithAutoDescription": "自動:按住 {{shortcut}} 聽寫,放開即停止;輕按則開始或停止。按住:按住 {{shortcut}} 期間進行聽寫。切換:按一次 {{shortcut}} 開始,再按一次停止。", + "speechGpuLabel": "使用 GPU", + "speechGpuDescription": "顯示卡可用時在顯示卡上辨識語音,失敗時改用 CPU。如果聽寫導致電腦當機或重新啟動,請關閉。" }, "WorktreeSymlinksSection": { "1c1e35b219": "刪除 {{value0}}", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 25b54e7fcb6..790828bff88 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8773,7 +8773,9 @@ "openAiKeyName": "您的 OpenAI 转录密钥", "dictationShortcutDescription": "按 {{shortcut}} 可将文本听写到当前聚焦的任意窗格中。", "dictationModeAuto": "自动", - "dictationModesWithAutoDescription": "自动:按住 {{shortcut}} 听写,松开即停止;轻按则开始或停止。按住:按住 {{shortcut}} 期间进行听写。切换:按一次 {{shortcut}} 开始,再按一次停止。" + "dictationModesWithAutoDescription": "自动:按住 {{shortcut}} 听写,松开即停止;轻按则开始或停止。按住:按住 {{shortcut}} 期间进行听写。切换:按一次 {{shortcut}} 开始,再按一次停止。", + "speechGpuLabel": "使用 GPU", + "speechGpuDescription": "显卡可用时在显卡上识别语音,失败时改用 CPU。如果听写导致电脑卡死或重启,请关闭。" }, "WorktreeSymlinksSection": { "1c1e35b219": "删除 {{value0}}", From ef25c04eefca14f81c91f0d715a7a63333121f2c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 09:34:56 -0800 Subject: [PATCH 019/138] test: remove low-value terminal, browser-session and SSH cases (#40433) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../profiles/browser-session-registry.test.ts | 46 ---- .../proxy/browser-session-proxy.test.ts | 149 +------------ .../browser-site-display-controller.test.ts | 4 +- .../cli/legacy-relay-cli-server.test.ts | 17 +- .../ssh-host-relay-windows-census.test.ts | 51 ----- .../system-ssh-forward-process.test.ts | 24 -- .../ssh/targets/ssh-connection-store.test.ts | 89 -------- ...minal-cold-activation-unbound-tabs.test.ts | 209 ------------------ ...connection-mode-2031-subscriptions.spec.ts | 208 ----------------- .../pty-transport-output-side-effects.test.ts | 160 -------------- .../titles/TerminalPaneHeaderOverlay.test.tsx | 136 ------------ .../browser-annotation-retention.test.ts | 43 ---- 12 files changed, 3 insertions(+), 1133 deletions(-) delete mode 100644 src/renderer/src/components/terminal-cold-activation-unbound-tabs.test.ts diff --git a/src/main/browser/sessions/profiles/browser-session-registry.test.ts b/src/main/browser/sessions/profiles/browser-session-registry.test.ts index 23c38b85923..eabc72c18b7 100644 --- a/src/main/browser/sessions/profiles/browser-session-registry.test.ts +++ b/src/main/browser/sessions/profiles/browser-session-registry.test.ts @@ -64,11 +64,6 @@ import { deleteManagedBrowserProfile, ensureManagedBrowserProfile } from './browser-managed-session-profiles' -import { - configureBrowserSitePermissionStore, - listBrowserSitePermissions, - setBrowserSitePermissionDecisions -} from '../../site-settings/permissions/browser-site-permission-store' import { setBrowserNetworkProxySettingsResolver } from '../proxy/browser-session-proxy' import { handleElectronProxyLogin } from '../../../network/electron-proxy-credentials' import { applyProxySettingsToSession } from '../../../network/proxy-settings' @@ -208,19 +203,6 @@ describe('BrowserSessionRegistry', () => { ).rejects.toThrow('retired') }) - it('deletes a non-default profile and its remembered site answers', async () => { - configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) - const profile = await browserSessionRegistry.createProfile('isolated', 'Delete Test') - expect(profile).not.toBeNull() - expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true) - setBrowserSitePermissionDecisions(profile!.id, 'https://example.com', ['camera'], 'allow') - const deleted = await browserSessionRegistry.deleteProfile(profile!.id) - expect(deleted).toBe(true) - expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(false) - expect(browserSessionRegistry.getProfile(profile!.id)).toBeNull() - expect(listBrowserSitePermissions(profile!.id)).toEqual([]) - }) - // Electron Sessions outlive deleted profiles; their header hooks must be retired. it('creates one managed profile per owner and a fresh one after its owner is removed', async () => { @@ -245,32 +227,4 @@ describe('BrowserSessionRegistry', () => { const recreated = await ensureManagedBrowserProfile(owner, 'Workspace: feature') expect(recreated?.partition).not.toBe(first.partition) }) - - it('announces each profile change after the list reflects it', async () => { - const seen: string[][] = [] - browserSessionRegistry.onProfilesChanged = () => - seen.push(browserSessionRegistry.listProfiles().map((profile) => profile.label)) - try { - const made = await browserSessionRegistry.createProfile('isolated', 'Made by CLI') - const managed = await ensureManagedBrowserProfile({ kind: 'repo', id: 'announce' }, 'Repo') - if (!made || !managed) { - throw new Error('Expected created profiles') - } - browserSessionRegistry.updateProfileSource(made.id, { - browserFamily: 'manual', - importedAt: 1 - }) - await browserSessionRegistry.deleteProfile(made.id) - await browserSessionRegistry.deleteProfile('default') - await browserSessionRegistry.createProfile('default', 'Refused') - - expect(seen).toHaveLength(4) - expect(seen[0]).toContain('Made by CLI') - expect(seen[1]).toContain('Repo') - expect(seen[2]).toContain('Made by CLI') - expect(seen[3]).not.toContain('Made by CLI') - } finally { - browserSessionRegistry.onProfilesChanged = null - } - }) }) diff --git a/src/main/browser/sessions/proxy/browser-session-proxy.test.ts b/src/main/browser/sessions/proxy/browser-session-proxy.test.ts index 7f5a406d072..ff182459a9d 100644 --- a/src/main/browser/sessions/proxy/browser-session-proxy.test.ts +++ b/src/main/browser/sessions/proxy/browser-session-proxy.test.ts @@ -28,33 +28,11 @@ vi.mock('electron', () => ({ import { applyBrowserSessionProxies, - awaitBrowserSessionProxies, applyProxyToBrowserSession, invalidateBrowserSessionProxyApplication, setBrowserNetworkProxySettingsResolver } from './browser-session-proxy' -import { - applyProxySettingsToSession, - releaseProxySessionApplication -} from '../../../network/proxy-settings' -import type { NetworkProxySettings } from '../../../../shared/network-proxy' - -const PROFILES = [ - { - id: 'default', - scope: 'default' as const, - partition: 'persist:orca-browser', - label: 'Default', - source: null - }, - { - id: 'iso', - scope: 'isolated' as const, - partition: 'persist:orca-browser-session-iso', - label: 'Isolated', - source: null - } -] +import { releaseProxySessionApplication } from '../../../network/proxy-settings' describe('browser session proxy', () => { beforeEach(() => { @@ -112,129 +90,4 @@ describe('browser session proxy', () => { [{ mode: 'system' }] ]) }) - - describe('startup readiness', () => { - beforeEach(() => { - for (const key of [ - 'http_proxy', - 'https_proxy', - 'all_proxy', - 'HTTP_PROXY', - 'HTTPS_PROXY', - 'ALL_PROXY' - ]) { - vi.stubEnv(key, '') - } - setBrowserNetworkProxySettingsResolver(() => ({})) - }) - - afterEach(() => vi.unstubAllEnvs()) - - it('awaits installed policies without reapplying them and keeps later system probes fresh', async () => { - const sessions = PROFILES.map((profile) => fromPartition(profile.partition)) - const finishResolutions: (() => void)[] = [] - for (const sess of sessions) { - sess.resolveProxy.mockImplementationOnce( - () => - new Promise((resolve) => { - finishResolutions.push(() => resolve('DIRECT')) - }) - ) - } - const installations = sessions.map((sess) => applyProxySettingsToSession(sess, {})) - let settled = false - const startup = awaitBrowserSessionProxies(PROFILES).then(() => (settled = true)) - - await vi.waitFor(() => expect(finishResolutions).toHaveLength(PROFILES.length)) - expect(settled).toBe(false) - for (const finish of finishResolutions) { - finish() - } - await Promise.all([...installations, startup]) - await awaitBrowserSessionProxies(PROFILES) - - for (const sess of sessions) { - expect(sess.resolveProxy).toHaveBeenCalledOnce() - expect(sess.setProxy).not.toHaveBeenCalled() - sess.resolveProxy.mockResolvedValue('PROXY changed-system.example:8080') - } - await applyBrowserSessionProxies(PROFILES, {}) - for (const sess of sessions) { - expect(sess.resolveProxy).toHaveBeenCalledTimes(2) - } - }) - - it('awaits every installation and preserves failed-partition warnings and retry budget', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const failed = fromPartition('persist:orca-browser') - failed.resolveProxy.mockRejectedValue(new Error('native proxy failure')) - await expect(applyProxySettingsToSession(failed, {})).rejects.toThrow('native proxy failure') - const healthy = fromPartition('persist:orca-browser-session-iso') - let finishHealthy: (() => void) | undefined - healthy.resolveProxy.mockImplementationOnce( - () => new Promise((resolve) => (finishHealthy = () => resolve('DIRECT'))) - ) - const installation = applyProxySettingsToSession(healthy, {}) - let settled = false - const startup = awaitBrowserSessionProxies(PROFILES).finally(() => (settled = true)) - const failure = expect(startup).rejects.toThrow('persist:orca-browser') - - await vi.waitFor(() => expect(failed.resolveProxy).toHaveBeenCalledTimes(6)) - expect(settled).toBe(false) - finishHealthy?.() - await Promise.all([installation, failure]) - - expect(healthy.resolveProxy).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledExactlyOnceWith( - '[proxy] Failed to apply proxy to browser partition', - 'persist:orca-browser' - ) - }) - - it.each([false, true])( - 'awaits the newest settings write when the initial policy fails=%s', - async (initialFails) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const profiles = PROFILES.slice(0, 1) - const sess = fromPartition('persist:orca-browser') - let settings: NetworkProxySettings = {} - setBrowserNetworkProxySettingsResolver(() => settings) - let finishInitial: (() => void) | undefined - sess.resolveProxy.mockImplementationOnce( - () => - new Promise((resolve, reject) => { - finishInitial = () => - initialFails ? reject(new Error('native proxy failure')) : resolve('DIRECT') - }) - ) - if (initialFails) { - sess.resolveProxy.mockRejectedValueOnce(new Error('native proxy failure')) - sess.resolveProxy.mockRejectedValueOnce(new Error('native proxy failure')) - } - let finishWrite: (() => void) | undefined - sess.setProxy.mockImplementationOnce( - () => new Promise((resolve) => (finishWrite = resolve)) - ) - const installation = applyProxySettingsToSession(sess, {}).catch((error: unknown) => error) - let settled = false - const startup = awaitBrowserSessionProxies(profiles).then(() => (settled = true)) - await vi.waitFor(() => expect(sess.resolveProxy).toHaveBeenCalledOnce()) - settings = { httpProxyUrl: 'http://fresh.example:8080' } - const sweep = applyBrowserSessionProxies(profiles, settings) - finishInitial?.() - - await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledOnce()) - expect(settled).toBe(false) - finishWrite?.() - await Promise.all([installation, startup, sweep]) - - expect(sess.resolveProxy).toHaveBeenCalledTimes(initialFails ? 3 : 1) - expect(sess.setProxy).toHaveBeenCalledExactlyOnceWith({ - mode: 'fixed_servers', - proxyRules: 'http://fresh.example:8080' - }) - expect(warn).not.toHaveBeenCalled() - } - ) - }) }) diff --git a/src/main/browser/site-settings/display/browser-site-display-controller.test.ts b/src/main/browser/site-settings/display/browser-site-display-controller.test.ts index 9f304231229..8ef505bf2d9 100644 --- a/src/main/browser/site-settings/display/browser-site-display-controller.test.ts +++ b/src/main/browser/site-settings/display/browser-site-display-controller.test.ts @@ -178,9 +178,7 @@ describe('BrowserSiteDisplayController color scheme', () => { it.each([ { order: 'read-first', profileId: 'work', colorScheme: 'light', saved: 'dark' }, - { order: 'registration-first', profileId: 'work', colorScheme: 'light', saved: 'dark' }, - { order: 'read-first', profileId: 'default', colorScheme: 'dark', saved: 'light' }, - { order: 'registration-first', profileId: 'default', colorScheme: 'dark', saved: 'light' } + { order: 'registration-first', profileId: 'work', colorScheme: 'light', saved: 'dark' } ] as const)( 'keeps actual $profileId precedence across $order, reload and debugger loss', ({ order, profileId, colorScheme, saved }) => { diff --git a/src/main/ssh/legacy-relay/cli/legacy-relay-cli-server.test.ts b/src/main/ssh/legacy-relay/cli/legacy-relay-cli-server.test.ts index d56582aeb35..7cde14c097e 100644 --- a/src/main/ssh/legacy-relay/cli/legacy-relay-cli-server.test.ts +++ b/src/main/ssh/legacy-relay/cli/legacy-relay-cli-server.test.ts @@ -2,7 +2,6 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { CONNECTOR_REPLY_UNAVAILABLE } from '../../../../shared/rpc-contract/connector-params' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import '../../../../shared/ssh-remote-cli-control' import { OrcaRuntimeService } from '../../../runtime/orca-runtime' @@ -12,11 +11,7 @@ import '../../../runtime/orchestration/db/messages/mailbox-routing-page' import '../../../runtime/orchestration/db/dispatch-row-writer' import '../../../runtime/orchestration/db/attempt-outcome-projection' import { LEGACY_RELAY_CLI_UNVERIFIED } from './legacy-relay-cli-caller' -import { - createLegacyRelayCliServer, - LEGACY_RELAY_CLI_CONTROL_OFF, - LEGACY_RELAY_CLI_UNSUPPORTED -} from './legacy-relay-cli-server' +import { createLegacyRelayCliServer, LEGACY_RELAY_CLI_CONTROL_OFF } from './legacy-relay-cli-server' import type { LegacyRelayLivePane } from '../bridge/legacy-relay-bridge-leases' const TARGET = 'target-1' @@ -143,16 +138,6 @@ describe('the relay-era orca CLI on the desktop runtime', () => { expect(db.getTask(ids.taskId)?.status).toBe('dispatched') }) - it('refuses commands outside orchestration', async () => { - await expect(run(['worktree', 'list'])).rejects.toThrow(LEGACY_RELAY_CLI_UNSUPPORTED) - await expect(run(['terminal', 'send', '--terminal', COORDINATOR])).rejects.toThrow( - LEGACY_RELAY_CLI_UNSUPPORTED - ) - await expect(run(['connector', 'reply', '--text', 'done'])).rejects.toThrow( - CONNECTOR_REPLY_UNAVAILABLE - ) - }) - it.each([ ['a pane the bridge does not run', () => panes.clear(), ENV], [ diff --git a/src/main/ssh/legacy-relay/endpoints/ssh-host-relay-windows-census.test.ts b/src/main/ssh/legacy-relay/endpoints/ssh-host-relay-windows-census.test.ts index ab23d5622cc..4a24bdf0326 100644 --- a/src/main/ssh/legacy-relay/endpoints/ssh-host-relay-windows-census.test.ts +++ b/src/main/ssh/legacy-relay/endpoints/ssh-host-relay-windows-census.test.ts @@ -82,11 +82,6 @@ describe('the connect-time relay census on a Windows host', () => { countRelayPtysOverBridge.mockReset().mockResolvedValue(0) }) - it('finds none on a host that never ran a relay', async () => { - listing = '' - await expect(census()).resolves.toEqual({ verdict: 'none', count: 0 }) - }) - // Two desktops, two target ids, one Windows account: B's live shell must block A's conversion. it("asks another desktop's relay on this account, with that relay's own credential", async () => { const [bPrimary] = pipesOf('desktop-b') @@ -105,22 +100,6 @@ describe('the connect-time relay census on a Windows host', () => { ) }) - it('maps a pipe through its active-pipe marker when its credential is gone', async () => { - const [, bFallback] = pipesOf('desktop-b') - inventory = { - pipes: [bare(bFallback)], - dirs: { - [VERSION]: { - credentials: [], - markers: { [`.windows-active-pipe-${sockOf('desktop-b')}`]: bFallback } - } - } - } - countRelayPtysOverBridge.mockResolvedValue(2) - - await expect(census()).resolves.toEqual({ verdict: 'live', count: 1 }) - }) - it("is unverifiable for a pipe no directory here accounts for, unless it is another account's", async () => { inventory = { pipes: ['orca-relay-0123456789abcdef0123', 'orca-relay-fedcba9876543210fedc'], @@ -136,27 +115,6 @@ describe('the connect-time relay census on a Windows host', () => { await expect(census()).resolves.toEqual({ verdict: 'idle', count: 0 }) }) - it.each([ - [ - 'the inventory could not run', - () => - execCommand.mockImplementation(async (_conn: unknown, command: string) => { - if (command === 'LIST') { - return listing - } - throw new Error('channel closed') - }) - ], - ['the pipe listing failed', () => (inventory = { pipes: null, dirs: {} })], - [ - 'a version directory could not be read', - () => (inventory = { pipes: [], dirs: { [VERSION]: null } }) - ] - ])('is unverifiable when %s', async (_label, arrange) => { - arrange() - await expect(census()).resolves.toMatchObject({ verdict: 'unverifiable' }) - }) - it('never converts under an owned pipe it cannot ask', async () => { const [aPrimary] = pipesOf('desktop-a') inventory = { pipes: [bare(aPrimary)], dirs: { [VERSION]: { credentials: [], markers: {} } } } @@ -164,13 +122,4 @@ describe('the connect-time relay census on a Windows host', () => { await expect(census()).resolves.toEqual({ verdict: 'unverifiable', count: 1 }) }) - - it('reads relays that answer with no PTYs, or no relay pipes at all, as idle', async () => { - const [aPrimary] = pipesOf('desktop-a') - inventory = { pipes: [bare(aPrimary)], dirs: { [VERSION]: { credentials: [], markers: {} } } } - await expect(census()).resolves.toEqual({ verdict: 'idle', count: 0 }) - - inventory = { pipes: [], dirs: { [VERSION]: { credentials: [], markers: {} } } } - await expect(census()).resolves.toEqual({ verdict: 'idle', count: 0 }) - }) }) diff --git a/src/main/ssh/system-transport/forwarding/system-ssh-forward-process.test.ts b/src/main/ssh/system-transport/forwarding/system-ssh-forward-process.test.ts index 8392f41cba9..0c7395df839 100644 --- a/src/main/ssh/system-transport/forwarding/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-transport/forwarding/system-ssh-forward-process.test.ts @@ -146,19 +146,6 @@ describe('system SSH forward process', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('rejects startup when ssh exits early with stderr', async () => { - vi.useFakeTimers() - const child = createFakeProcess() - const socket = createFakeSocket() - connectMock.mockReturnValue(socket) - - const pending = waitForSystemSshForwardStartup(child as never, 3000) - child.stderr.emit('data', Buffer.from('bind: Address already in use\n')) - child.emit('exit', 255) - - await expect(pending).rejects.toThrow('bind: Address already in use') - }) - it("rejects startup and stops ssh when it cannot listen on this forward's own port", async () => { const child = createFakeProcess() spawnMock.mockReturnValue(child) @@ -201,17 +188,6 @@ describe('system SSH forward process', () => { expect(child.kill).not.toHaveBeenCalled() }) - it('rejects startup when the ssh process emits an error', async () => { - vi.useFakeTimers() - const child = createFakeProcess() - connectMock.mockReturnValue(createFakeSocket()) - - const pending = waitForSystemSshForwardStartup(child as never, 3000) - child.emit('error', new Error('spawn failed')) - - await expect(pending).rejects.toThrow('spawn failed') - }) - it('rejects when no listener appears within the listener timeout', async () => { vi.useFakeTimers() const child = createFakeProcess() diff --git a/src/main/ssh/targets/ssh-connection-store.test.ts b/src/main/ssh/targets/ssh-connection-store.test.ts index e2212b0bb6c..72704ff7c61 100644 --- a/src/main/ssh/targets/ssh-connection-store.test.ts +++ b/src/main/ssh/targets/ssh-connection-store.test.ts @@ -41,72 +41,6 @@ describe('SshConnectionStore', () => { } } - it('adopts a legacy unsourced target into config-sync', async () => { - mockStore.addSshTarget({ - id: 'ssh-legacy', - label: 'cluster', - configHost: 'cluster', - host: '10.0.0.5', - port: 2200, - username: 'dev' - // no source — predates the field - }) - loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) - sshConfigHostsToTargetsMock.mockReturnValue([ - candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' }) - ]) - - const result = (await sshStore.importFromSshConfig()).targets - - expect(mockStore.updateSshTarget).toHaveBeenCalledWith( - 'ssh-legacy', - expect.objectContaining({ port: 2222, source: 'ssh-config' }) - ) - expect(result[0].source).toBe('ssh-config') - }) - - it('does not overwrite a legacy unsourced manual target with the same alias', async () => { - mockStore.addSshTarget({ - id: 'ssh-legacy-manual', - label: 'cluster', - configHost: 'cluster', - host: 'cluster', - port: 2200, - username: 'me' - // no source — predates the field, but does not look like a config import - }) - loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) - sshConfigHostsToTargetsMock.mockReturnValue([ - candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' }) - ]) - - const result = (await sshStore.importFromSshConfig()).targets - - expect(mockStore.updateSshTarget).not.toHaveBeenCalled() - expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) - expect(result).toEqual([]) - }) - - // Import alias ownership must use the picker's case-insensitive Host matching. - it('treats a case-only alias variant as owned by the existing manual target', async () => { - mockStore.addSshTarget({ - id: 'ssh-m', - label: 'Prod', - configHost: 'Prod', - host: 'manual.example.com', - port: 22, - username: 'me', - source: 'manual' - }) - loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }]) - sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'prod' })]) - - const result = (await sshStore.importFromSshConfig()).targets - - expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) - expect(result).toEqual([]) - }) - it('keeps a case-only alias variant suppressed after the host was deleted', async () => { const added = sshStore.addTarget({ label: 'Prod', @@ -172,27 +106,4 @@ describe('SshConnectionStore', () => { expect(result).toHaveLength(1) }) }) - - describe('re-adoption of orphaned workspaces', () => { - it('does not re-adopt when the re-added host identity differs', () => { - mockStore.addRemovedSshTargetTombstone({ - oldTargetId: 'ssh-old', - host: 'other.example.com', - port: 22, - username: 'root', - label: 'Other', - removedAt: 1 - }) - - sshStore.addTarget({ - label: 'Dev', - host: 'dev.example.com', - port: 22, - username: 'tim' - }) - - expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled() - expect(sshStore.lastRepoReadoptions).toEqual([]) - }) - }) }) diff --git a/src/renderer/src/components/terminal-cold-activation-unbound-tabs.test.ts b/src/renderer/src/components/terminal-cold-activation-unbound-tabs.test.ts deleted file mode 100644 index e2f4fe7a5e4..00000000000 --- a/src/renderer/src/components/terminal-cold-activation-unbound-tabs.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { useAppStore } from '../store' -import { applyTerminalColdActivation } from './terminal-cold-activation' -import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' -import type { TerminalTab } from '../../../shared/workspace-layout/terminal-tab-types' -import { - isActivationAdmissionEligible, - pickNextActivationDeferredTabId -} from './terminal/activation-deferred-tab-admission' -import { isUnboundRestoredTerminalTab } from './terminal/unbound-restored-terminal-tab' - -const WORKTREE = 'repo::/wt' - -function tab(id: string, extra: Partial = {}): TerminalTab { - return { - id, - ptyId: null, - worktreeId: WORKTREE, - title: id, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 0, - ...extra - } -} - -function controllerFor( - tabs: TerminalTab[], - overrides: Partial> = {} -): TerminalParkingFoundation { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cold activation reads only the fields listed here; the rest of the hook controller is React state it never touches. - return { - activationDeferralPlanRevisionRef: { current: 0 }, - activationDeferredMountTabIdsByWorktreeRef: { current: new Map() }, - activeGroupIdByWorktree: {}, - activeTabId: tabs[0]?.id ?? null, - activeTabIdByWorktree: {}, - activeWorktreeDeferralHostId: 'local', - activityTerminalPortals: [], - backgroundMountTabIdsByWorktreeRef: { current: new Map() }, - groupsByWorktree: {}, - hydrationSucceeded: true, - lastActivationWorktreeIdRef: { current: null }, - layoutByWorktree: {}, - mountedWorktreeIdsRef: { current: new Set() }, - pairedRuntimeParkingEnvironmentIds: new Set(), - pendingStartupByTabId: {}, - renderedActiveWorktreeId: WORKTREE, - startupTerminalTabHoldRef: { current: null }, - startupWorktreeRefreshCompleted: true, - tabsByWorktree: { [WORKTREE]: tabs }, - terminalParkingEnabled: false, - terminalTitleSnapshotAuthorityEnabled: false, - workspaceSessionReady: true, - workspaceSurfaceIds: [WORKTREE], - workspaceSurfaceIdSet: new Set([WORKTREE]), - ...overrides - } as unknown as TerminalParkingFoundation -} - -function deadAgentTabs(count: number): TerminalTab[] { - return Array.from({ length: count }, (_, index) => - tab(`tab-${index}`, { launchAgent: 'claude', restoredFromSession: true }) - ) -} - -function mountableTabIds(controller: TerminalParkingFoundation): string[] { - const allowed = controller.backgroundMountTabIdsByWorktreeRef.current.get(WORKTREE) - const tabs = controller.tabsByWorktree[WORKTREE] ?? [] - return tabs.filter((t) => !allowed || allowed.has(t.id)).map((t) => t.id) -} - -describe('cold activation of a worktree with unbound saved tabs (#39166)', () => { - beforeEach(() => { - useAppStore.setState({ - ptyIdsByTabId: {}, - terminalLayoutsByTabId: {}, - pendingStartupByTabId: {}, - unifiedTabsByWorktree: {} - }) - }) - - it('mounts only the visible tab when many saved agent tabs have no PTY', () => { - const tabs = deadAgentTabs(97) - useAppStore.setState({ tabsByWorktree: { [WORKTREE]: tabs } }) - const controller = controllerFor(tabs) - - applyTerminalColdActivation(controller) - - expect(mountableTabIds(controller)).toEqual(['tab-0']) - const deferred = controller.activationDeferredMountTabIdsByWorktreeRef.current.get(WORKTREE) - expect(deferred?.size).toBe(96) - // Over the cap, the idle pass admits none; each waits until the user opens it. - expect(isActivationAdmissionEligible(deferred?.size ?? 0)).toBe(false) - }) - - it('opening a held tab mounts it without releasing the others', () => { - const tabs = deadAgentTabs(10) - useAppStore.setState({ tabsByWorktree: { [WORKTREE]: tabs } }) - const controller = controllerFor(tabs) - applyTerminalColdActivation(controller) - - const clicked = controllerFor(tabs, { - activeTabId: 'tab-7', - backgroundMountTabIdsByWorktreeRef: controller.backgroundMountTabIdsByWorktreeRef, - activationDeferredMountTabIdsByWorktreeRef: - controller.activationDeferredMountTabIdsByWorktreeRef, - lastActivationWorktreeIdRef: controller.lastActivationWorktreeIdRef, - mountedWorktreeIdsRef: controller.mountedWorktreeIdsRef - }) - applyTerminalColdActivation(clicked) - - expect(mountableTabIds(clicked)).toEqual(['tab-0', 'tab-7']) - }) - - it('keeps the capped idle warm-up for a few unbound tabs', () => { - const tabs = deadAgentTabs(3) - useAppStore.setState({ tabsByWorktree: { [WORKTREE]: tabs } }) - const controller = controllerFor(tabs) - applyTerminalColdActivation(controller) - - const deferred = controller.activationDeferredMountTabIdsByWorktreeRef.current.get(WORKTREE) - expect(isActivationAdmissionEligible(deferred?.size ?? 0)).toBe(true) - expect( - pickNextActivationDeferredTabId( - tabs.map((t) => t.id), - deferred - ) - ).toBe('tab-1') - }) - - it('mounts a held tab once its PTY binds', () => { - const tabs = deadAgentTabs(6) - useAppStore.setState({ tabsByWorktree: { [WORKTREE]: tabs } }) - const controller = controllerFor(tabs) - applyTerminalColdActivation(controller) - - const bound = tabs.map((t) => - t.id === 'tab-3' ? { ...t, ptyId: 'pty-3', restoredFromSession: undefined } : t - ) - useAppStore.setState({ tabsByWorktree: { [WORKTREE]: bound } }) - const next = controllerFor(bound, { - backgroundMountTabIdsByWorktreeRef: controller.backgroundMountTabIdsByWorktreeRef, - activationDeferredMountTabIdsByWorktreeRef: - controller.activationDeferredMountTabIdsByWorktreeRef, - lastActivationWorktreeIdRef: controller.lastActivationWorktreeIdRef, - mountedWorktreeIdsRef: controller.mountedWorktreeIdsRef - }) - applyTerminalColdActivation(next) - - expect(mountableTabIds(next)).toEqual(['tab-0', 'tab-3']) - }) - - it('leaves worktrees without unbound saved tabs fully mounted', () => { - const tabs = [tab('a'), tab('b'), tab('c', { ptyId: 'pty-c', launchAgent: 'claude' })] - useAppStore.setState({ tabsByWorktree: { [WORKTREE]: tabs } }) - const controller = controllerFor(tabs) - - applyTerminalColdActivation(controller) - - expect(controller.backgroundMountTabIdsByWorktreeRef.current.has(WORKTREE)).toBe(false) - expect(mountableTabIds(controller)).toEqual(['a', 'b', 'c']) - }) -}) - -describe('isUnboundRestoredTerminalTab', () => { - const empty = { ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, pendingStartupByTabId: {} } - - it('holds saved shells and agent tabs that have no PTY anywhere', () => { - expect(isUnboundRestoredTerminalTab(tab('a', { restoredFromSession: true }), empty)).toBe(true) - expect(isUnboundRestoredTerminalTab(tab('b', { launchAgent: 'codex' }), empty)).toBe(true) - }) - - it('never holds a tab that is new this session, starting, or bound to a PTY', () => { - expect(isUnboundRestoredTerminalTab(tab('fresh'), empty)).toBe(false) - expect( - isUnboundRestoredTerminalTab(tab('queued', { launchAgent: 'claude' }), { - ...empty, - pendingStartupByTabId: { queued: { command: 'claude' } } - }) - ).toBe(false) - expect( - isUnboundRestoredTerminalTab( - tab('launch', { launchAgent: 'claude', agentLaunchPane: { leafId: 'leaf' } }), - empty - ) - ).toBe(false) - expect( - isUnboundRestoredTerminalTab(tab('live', { restoredFromSession: true }), { - ...empty, - ptyIdsByTabId: { live: ['pty-1'] } - }) - ).toBe(false) - expect( - isUnboundRestoredTerminalTab(tab('saved-leaf', { restoredFromSession: true }), { - ...empty, - terminalLayoutsByTabId: { - 'saved-leaf': { - root: null, - activeLeafId: null, - expandedLeafId: null, - ptyIdsByLeafId: { l: 'pty-9' } - } - } - }) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/emulation/pty-connection-mode-2031-subscriptions.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection/emulation/pty-connection-mode-2031-subscriptions.spec.ts index 00f51c81ff3..e00f298909f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/emulation/pty-connection-mode-2031-subscriptions.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/emulation/pty-connection-mode-2031-subscriptions.spec.ts @@ -104,19 +104,6 @@ export function registerPtyConnectionMode2031SubscriptionsTests(): void { const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] => transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997')) - it('registers a chunk that ends subscribed, then retires it when the next withdraws', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // Two separate PTY chunks. xterm would parse both in one batch and see only the net result. - emit('\x1b[?2031h') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(deps.paneLastThemeModeRef.current.get(1)).toBe('light') - emit('\x1b[?2031l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(deps.paneLastThemeModeRef.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - it('catches a subscribed pane up once per mode change across repeated reconnects', async () => { const { transport, emit, reconnect, dispose } = await connectVisiblePane() emit('\x1b[?2031h') @@ -166,201 +153,6 @@ export function registerPtyConnectionMode2031SubscriptionsTests(): void { expect(replies(transport)).toEqual(['\x1b[?997;1n', '\x1b[?997;2n', '\x1b[?997;2n']) dispose() }) - - it('re-registers a chunk that withdraws and subscribes again', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h') - emit('\x1b[?2031l\x1b[?2031h') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers nothing when one chunk both subscribes and withdraws', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // The fish prompt case: the subscription is gone before the program could read anything. - emit('\x1b[?2031h prompt \x1b[?2031l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers nothing when the withdrawal is split across two chunks', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h prompt \x1b[?20') - emit('31l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers nothing when an unrelated private mode appends a split withdrawal', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h prompt \x1b[?25') - emit(';2031l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers the subscription once an ambiguous tail resolves to another mode', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h drawing \x1b[?20') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - emit('25h') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - // One fish prompt cycle, exactly as fish's tty_handoff.rs emits it. - const FISH_PROMPT_HANDOFF = '\x1b[?2031h\x1b[0m~/orca \x1b[32m❯\x1b[0m \x1b[?2031l' - - it('stays silent across three fish prompts', async () => { - const { transport, emit, dispose } = await connectVisiblePane() - emit(FISH_PROMPT_HANDOFF) - emit(FISH_PROMPT_HANDOFF) - emit(FISH_PROMPT_HANDOFF) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('leaves no stale subscription behind after a fish prompt cycle', async () => { - const { deps, emit, dispose } = await connectVisiblePane() - emit(FISH_PROMPT_HANDOFF) - // A later theme flip must not push CSI 997 at a shell that unsubscribed. - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - dispose() - }) - - it('registers a TUI that subscribes at the end of a fish prompt chunk', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`) - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('keeps a subscription whose withdrawal never arrives', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // A real TUI: subscribe now, unsubscribe minutes later on exit. - emit('\x1b[?2031h') - emit('painting the ui') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('discards a half-read escape prefix when the pane swaps PTYs', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // Discard old stream tails on PTY replacement so concatenation cannot fabricate a subscription. - transport.serializeBuffer = vi.fn().mockResolvedValue(null) - emit('\x1b[?20') - // droppedOutput latches the hidden-restore PTY id, which is what detects the swap. - emit('', { droppedOutput: true }) - transport.getPtyId.mockReturnValue('pty-id-2') - - emit('31h') - - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - }) - - it('records hidden Codex mode 2031 subscribes split across becoming visible', async () => { - const { connectPanePty } = await import('../connect-pane-pty') - const agentTitleMocks2 = await vi.importMock( - '@/lib/agent-status/aggregation/worktree-agent-status' - ) - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - settings: { ...mockStoreState.settings, theme: 'light' } - } - - const isVisibleRef = { current: false } - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - bindPaneAgentTitleMocks( - createDeps({ - isVisibleRef, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never, - agentTitleMocks2 - ) - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?20') - isVisibleRef.current = true - capturedDataCallback.current?.('31h') - - expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(paneMode2031Ref.current.get(1)).toBe(true) - // Seeded so the next appearance re-apply only pushes on a real color-mode flip. - expect(paneLastThemeModeRef.current.get(1)).toBe('light') - expect(pane.terminal.write).not.toHaveBeenCalledWith('31h', expect.any(Function)) - - binding.dispose() - }) - - it('does not keep mode 2031 subscribed when a skipped hidden chunk unsubscribes last', async () => { - const { connectPanePty } = await import('../connect-pane-pty') - const agentTitleMocks3 = await vi.importMock( - '@/lib/agent-status/aggregation/worktree-agent-status' - ) - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - bindPaneAgentTitleMocks( - createDeps({ - isVisibleRef: { current: false }, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never, - agentTitleMocks3 - ) - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?2031h\x1b[?2031l') - - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(paneMode2031Ref.current.has(1)).toBe(false) - expect(paneLastThemeModeRef.current.has(1)).toBe(false) - - binding.dispose() }) }) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-output-side-effects.test.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-output-side-effects.test.ts index 7384431392b..13bb0d53fdd 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-output-side-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-output-side-effects.test.ts @@ -44,25 +44,6 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) - it('runs title side effects even when the data callback does not render the chunk', async () => { - const { createIpcPtyTransport } = await import('./pty-transport') - const onTitleChange = vi.fn() - const onDataCallback = vi.fn() - const transport = createIpcPtyTransport({ onTitleChange }) - - await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) - - onData?.({ id: 'pty-1', data: '\u001b]0;hidden-title\u0007' }) - - expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;hidden-title\u0007') - expect(onTitleChange).not.toHaveBeenCalled() - - await flushPtySideEffects() - - expect(onTitleChange).toHaveBeenCalledWith('hidden-title', 'hidden-title') - transport.disconnect() - }) - it('drops the OSC-9999 cross-chunk carry on resetAgentStatusCarry', async () => { // Why: a restore marker means bytes dropped, so a carried partial OSC-9999 prefix would eat the next chunk's head. const { createPtyOutputProcessor } = await import('../output/pty-output-processor') @@ -78,48 +59,6 @@ describe('createIpcPtyTransport', () => { expect(callbacks.onData).toHaveBeenLastCalledWith('plain output after the gap') }) - it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => { - vi.useFakeTimers() - try { - const { createPtyOutputProcessor } = await import('../output/pty-output-processor') - const onTitleChange = vi.fn() - const onBell = vi.fn() - const processor = createPtyOutputProcessor({ onTitleChange, onBell }) - const callbacks = { onData: vi.fn() } - - processor.processData('plain command output\r\n'.repeat(50), callbacks) - - expect(callbacks.onData).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - expect(onTitleChange).not.toHaveBeenCalled() - expect(onBell).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('compacts ignored Cursor native titles into one deferred drain', async () => { - vi.useFakeTimers() - try { - const { createPtyOutputProcessor } = await import('../output/pty-output-processor') - const onTitleChange = vi.fn() - const processor = createPtyOutputProcessor({ onTitleChange }) - const callbacks = { onData: vi.fn() } - const ignoredTitles = Array.from({ length: 4_096 }, () => '\x1b]0;Cursor Agent\x07').join('') - - processor.processData(ignoredTitles, callbacks) - - expect(vi.getTimerCount()).toBe(1) - await vi.runOnlyPendingTimersAsync() - - expect(vi.getTimerCount()).toBe(0) - // Why: the literal is the pane's identity once (#10258); the redraw repeats stay ignored. - expect(onTitleChange.mock.calls).toEqual([['Cursor Agent', 'Cursor Agent']]) - } finally { - vi.useRealTimers() - } - }) - it('lets an ignored Cursor native title clear a pending stale-title fallback', async () => { vi.useFakeTimers() try { @@ -204,79 +143,6 @@ describe('createIpcPtyTransport', () => { } }) - it('limits deferred PTY side-effect work per timer tick', async () => { - vi.useFakeTimers() - try { - const { createPtyOutputProcessor } = await import('../output/pty-output-processor') - const onTitleChange = vi.fn() - const processor = createPtyOutputProcessor({ onTitleChange }) - const callbacks = { onData: vi.fn() } - - for (let i = 0; i < 200; i++) { - processor.processData(`\x1b]0;title-${i}\x07`, callbacks) - } - - expect(onTitleChange).not.toHaveBeenCalled() - await vi.runOnlyPendingTimersAsync() - - expect(onTitleChange.mock.calls.length).toBeGreaterThan(0) - expect(onTitleChange.mock.calls.length).toBeLessThan(200) - - await vi.runAllTimersAsync() - expect(onTitleChange).toHaveBeenCalledTimes(200) - expect(onTitleChange).toHaveBeenLastCalledWith('title-199', 'title-199') - } finally { - vi.useRealTimers() - } - }) - - it('limits coalesced OSC titles in one PTY chunk per timer tick', async () => { - vi.useFakeTimers() - try { - const { createPtyOutputProcessor } = await import('../output/pty-output-processor') - const onTitleChange = vi.fn() - const processor = createPtyOutputProcessor({ onTitleChange }) - const callbacks = { onData: vi.fn() } - const titles = Array.from({ length: 200 }, (_, i) => `\x1b]0;chunk-title-${i}\x07`).join('') - - processor.processData(titles, callbacks) - await vi.runOnlyPendingTimersAsync() - - expect(onTitleChange.mock.calls.length).toBeGreaterThan(0) - expect(onTitleChange.mock.calls.length).toBeLessThan(200) - - await vi.runAllTimersAsync() - expect(onTitleChange).toHaveBeenCalledTimes(200) - expect(onTitleChange).toHaveBeenLastCalledWith('chunk-title-199', 'chunk-title-199') - } finally { - vi.useRealTimers() - } - }) - - it('flushes all remaining PTY side effects after a partial bounded drain', async () => { - vi.useFakeTimers() - try { - const { createPtyOutputProcessor } = await import('../output/pty-output-processor') - const onTitleChange = vi.fn() - const processor = createPtyOutputProcessor({ onTitleChange }) - const callbacks = { onData: vi.fn() } - - for (let i = 0; i < 200; i++) { - processor.processData(`\x1b]0;flush-title-${i}\x07`, callbacks) - } - - await vi.runOnlyPendingTimersAsync() - expect(onTitleChange.mock.calls.length).toBeLessThan(200) - - processor.flushPendingSideEffects() - - expect(onTitleChange).toHaveBeenCalledTimes(200) - expect(onTitleChange).toHaveBeenLastCalledWith('flush-title-199', 'flush-title-199') - } finally { - vi.useRealTimers() - } - }) - it('bounds the deferred side-effect queue under a stalled drain, keeping the newest title and a pending bell', async () => { vi.useFakeTimers() try { @@ -348,32 +214,6 @@ describe('createIpcPtyTransport', () => { } }) - it('delivers the side-effect count and final title when the queue stays below the cap', async () => { - vi.useFakeTimers() - try { - const { createPtyOutputProcessor } = await import('../output/pty-output-processor') - const onTitleChange = vi.fn() - const onBell = vi.fn() - const onAgentStatus = vi.fn() - const processor = createPtyOutputProcessor({ onTitleChange, onBell, onAgentStatus }) - const callbacks = { onData: vi.fn() } - - processor.processData('\x07', callbacks) - for (let i = 0; i < 100; i++) { - processor.processData(`\x1b]0;under-cap-${i}\x07`, callbacks) - } - processor.processData('\x1b]9999;{"state":"done","prompt":"done"}\x07', callbacks) - await vi.runAllTimersAsync() - - expect(onBell).toHaveBeenCalledTimes(1) - expect(onTitleChange).toHaveBeenCalledTimes(100) - expect(onTitleChange).toHaveBeenLastCalledWith('under-cap-99', 'under-cap-99') - expect(onAgentStatus).toHaveBeenCalledWith({ state: 'done', prompt: 'done' }) - } finally { - vi.useRealTimers() - } - }) - it('still runs stale-title detection when an OSC status chunk has no title', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/titles/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/titles/TerminalPaneHeaderOverlay.test.tsx index 3b527222e55..a11fca6cb56 100644 --- a/src/renderer/src/components/terminal-pane/titles/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/titles/TerminalPaneHeaderOverlay.test.tsx @@ -195,123 +195,6 @@ afterEach(() => { }) describe('TerminalPaneHeaderOverlay', () => { - it('offers Continue in Chat UI instead of a chat view switch, on the active pane only', () => { - const { container } = renderOverlay({ paneTitles: {}, canResumeInNewNativeChat: true }) - - const resumeButtons = [...container.querySelectorAll('button')].filter( - (button) => button.textContent === 'Continue in Chat UI' - ) - expect(resumeButtons).toHaveLength(1) - expect(resumeButtons[0]?.closest('.pane-title-bar')?.hasAttribute('data-active-pane')).toBe( - true - ) - expect(container.querySelector('button[aria-label="Show chat view"]')).toBeNull() - expect(container.querySelector('button[aria-label="Chat settings"]')).toBeNull() - }) - - it('keeps Show terminal as the way back from chat view', () => { - const { container } = renderOverlay({ - paneTitles: {}, - canShowTerminal: true, - canResumeInNewNativeChat: true - }) - - expect(container.querySelector('button[aria-label="Show terminal"]')).not.toBeNull() - expect(container.querySelectorAll('button[aria-label="Chat settings"]')).toHaveLength(1) - expect( - container.querySelector( - '.pane-title-bar[data-active-pane] button[aria-label="Chat settings"]' - ) - ).not.toBeNull() - expect(container.textContent).not.toContain('Continue in Chat UI') - }) - - it('keeps the titled split-pane X as remove-title only', () => { - const { container, onClosePane, onRemoveTitle } = renderOverlay({ - paneTitles: { 1: 'server', 2: '' } - }) - - const removeTitle = container.querySelector( - 'button[aria-label="Remove pane title: server"]' - ) - expect(removeTitle).not.toBeNull() - expect( - container.querySelector('.pane-title-bar[data-active-pane] button[aria-label="Close Pane"]') - ).toBeNull() - - act(() => removeTitle?.click()) - - expect(onRemoveTitle).toHaveBeenCalledWith(1) - expect(onClosePane).not.toHaveBeenCalled() - }) - - it('offers close tab beside remove-title for a titled single pane', () => { - const { container, onClosePane, onRemoveTitle } = renderOverlay({ - paneTitles: { 1: 'server' }, - paneCount: 1 - }) - - expect(container.querySelector('button[aria-label="Remove pane title: server"]')).not.toBeNull() - const closeTab = container.querySelector('button[aria-label="Close tab"]') - expect(closeTab).not.toBeNull() - - act(() => closeTab?.click()) - - expect(onClosePane).toHaveBeenCalledWith(1) - expect(onRemoveTitle).not.toHaveBeenCalled() - }) - - it('keeps split and close-pane controls available for untitled split pane headers', () => { - const { container, onClosePane } = renderOverlay({ - paneTitles: { 1: '', 2: '' } - }) - - expect(container.querySelector('button[aria-label="Split Terminal Right"]')).not.toBeNull() - expect(container.querySelector('.pane-title-drag-handle')).toBeNull() - const closePane = container.querySelector('button[aria-label="Close Pane"]') - expect(closePane).not.toBeNull() - - act(() => closePane?.click()) - - expect(onClosePane).toHaveBeenCalledWith(1) - }) - - it('offers close tab for an untitled single pane', () => { - const { container, onClosePane } = renderOverlay({ paneTitles: { 1: '' }, paneCount: 1 }) - - const closeTab = container.querySelector('button[aria-label="Close tab"]') - expect(closeTab).not.toBeNull() - expect(container.querySelector('button[aria-label="Close Pane"]')).toBeNull() - - act(() => closeTab?.click()) - - expect(onClosePane).toHaveBeenCalledWith(1) - }) - - it.each([ - { label: 'untitled', title: '' }, - { label: 'titled', title: 'server' } - ])('keeps a pinned $label single-pane tab without a close button', ({ title }) => { - const { container } = renderOverlay({ - paneTitles: { 1: title }, - paneCount: 1, - isTabPinned: true - }) - - expect(container.querySelector('button[aria-label="Close tab"]')).toBeNull() - }) - - it('omits the split control when the header affordance is hidden', () => { - const { container } = renderOverlay({ - paneTitles: { 1: '', 2: '' }, - paneCount: 1, - showSplitButton: false - }) - - expect(container.querySelector('button[aria-label="Split Terminal Right"]')).toBeNull() - expect(container.querySelector('button[aria-label="Close tab"]')).toBeNull() - }) - it('ignores IME composition Enter before submitting a pane title rename', () => { const { container, onRenameSubmit } = renderOverlay({ paneTitles: { 1: 'server', 2: '' }, @@ -330,25 +213,6 @@ describe('TerminalPaneHeaderOverlay', () => { expect(onRenameSubmit).toHaveBeenCalledTimes(1) }) - - it('shows new-session continuation on the active agent pane header', () => { - const onContinueAgentSessionInNewSession = vi.fn() - const { container } = renderOverlay({ - paneTitles: { 1: '', 2: '' }, - canContinueAgentSessionInNewSession: true, - onContinueAgentSessionInNewSession - }) - const handoff = container.querySelector( - 'button[aria-label="Hand Off to Another Agent"]' - ) - - expect(handoff).not.toBeNull() - act(() => handoff?.click()) - - expect(onContinueAgentSessionInNewSession).toHaveBeenCalledWith( - expect.objectContaining({ id: 1 }) - ) - }) }) function dispatchFileDrag(target: Element, type: 'dragover' | 'drop', internal: boolean): void { diff --git a/src/renderer/src/store/slices/browser/annotations/browser-annotation-retention.test.ts b/src/renderer/src/store/slices/browser/annotations/browser-annotation-retention.test.ts index 212e6c5c836..d9983e5fcca 100644 --- a/src/renderer/src/store/slices/browser/annotations/browser-annotation-retention.test.ts +++ b/src/renderer/src/store/slices/browser/annotations/browser-annotation-retention.test.ts @@ -1,6 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch' -import { layoutApplyTestTransport } from '@/runtime/workspace-layout/testing/layout-apply-test-transport.test-fixture' import { createBrowserMockApi } from '../browser-slice-test-harness' import { createTestStoreDrawnFromMain } from '../browser-slice-drawn-from-main.test-fixture' import { makeAnnotation } from './browser-annotation-test-fixture' @@ -63,45 +61,4 @@ describe('saved browser note lifetime and marker eligibility', () => { expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() }) - - it.each([ - ['page', 'closeBrowserPage'], - ['workspace', 'closeTabs'], - ['conversion', 'replaceBrowserPage'], - ['hydration', null] - ] as const)('cleans up note and marker authority on %s teardown', async (mode, mainCommand) => { - const { store, main, workspace, pageId } = await createAnnotatedPage() - if (mode === 'page') { - store.getState().closeBrowserPage(pageId) - } - if (mode === 'workspace') { - store.getState().closeBrowserTab(workspace.id) - } - if (mode === 'conversion') { - store.getState().convertBrowserPage(pageId, { - kind: 'workspace-doc', - docLocation: { - kind: 'workspace-doc', - worktreeId: 'wt-1', - filePath: '/workspace/index.html' - } - }) - } - if (mode === 'hydration') { - store.getState().hydrateBrowserSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {} - }) - } - await main.settle() - // A layout-owner build's teardown is main's, drawn from its frame. - expect(layoutApplyTestTransport.sent.map((command) => command.type)).toEqual( - ORCA_LAYOUT_OWNER_IS_MAIN ? ['openBrowserTab', ...(mainCommand ? [mainCommand] : [])] : [] - ) - expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() - expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() - }) }) From cff1b3188e62eec022ac773838b9a54081778f50 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:37:56 +0000 Subject: [PATCH 020/138] perf(mobile): show accounts from the last snapshot (#40425) * perf(mobile): show accounts from the last snapshot * fix(mobile): never count a stored accounts snapshot as live * test(mobile): pin when accounts usage counts as live * fix(mobile): date accounts reads by when they were sent and block switches until live * fix(mobile): show accounts usage only from this screen's own read, drop replies from before a reconnect --- mobile/src/accounts/MobileAccountsScreen.tsx | 100 ++++++------ .../src/accounts/accounts-snapshot-stream.ts | 14 +- .../accounts/mobile-accounts-screen-styles.ts | 3 + .../use-mobile-accounts-snapshot.test.tsx | 151 ++++++++++++++++++ .../accounts/use-mobile-accounts-snapshot.ts | 128 +++++++++++++++ mobile/src/home/use-mobile-home-data.ts | 16 +- mobile/src/query/first-paint-query-keys.ts | 6 + 7 files changed, 348 insertions(+), 70 deletions(-) create mode 100644 mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx create mode 100644 mobile/src/accounts/use-mobile-accounts-snapshot.ts diff --git a/mobile/src/accounts/MobileAccountsScreen.tsx b/mobile/src/accounts/MobileAccountsScreen.tsx index 938fbf6d4bf..2ea1d8b0c18 100644 --- a/mobile/src/accounts/MobileAccountsScreen.tsx +++ b/mobile/src/accounts/MobileAccountsScreen.tsx @@ -5,6 +5,10 @@ import { useFocusEffect, useLocalSearchParams } from 'expo-router' import { ChevronLeft, RefreshCw, User } from 'lucide-react-native' import { loadHosts, peekHosts } from '../transport/hosts/host-store' import { useForceReconnect, useHostClient } from '../transport/connection/host-client-hooks' +import { useHostConnectionInputs } from '../transport/connection/client-context-connection-metrics' +import { classifyConnection, isConnectionOutage } from '../transport/connection/connection-health' +import { HostOfflineBanner, useHostOfflineBanner } from '../components/HostOfflineBanner' +import { useRefreshHint } from '../query/refresh-hint' import { MobileAccountsLoadState } from './MobileAccountsLoadState' import { colors, spacing } from '../theme/mobile-theme' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' @@ -12,9 +16,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { styles } from './mobile-accounts-screen-styles' import { useNow } from '../hooks/use-now' import { ClaudeIcon, OpenAIIcon } from '../components/AgentIcons' -import { AccountsSnapshotError, decodeAccountsSnapshot } from '../components/accounts-snapshot' import { - type AccountsSnapshot, type ProviderKey, getActiveProviderRateLimits, getInactiveProviderUsage, @@ -35,7 +37,7 @@ import { NavBar } from '../components/nav-bar/NavBar' import { NavBarButton } from '../components/nav-bar/NavBarButton' import { ListGroup, SectionHeader, ListRow } from '../components/list/ListGroup' import { useRouteHandoff } from '../navigation/route-handoff' -import { subscribeAccountsSnapshots } from './accounts-snapshot-stream' +import { useMobileAccountsSnapshot } from './use-mobile-accounts-snapshot' type AccountsNotice = { title: string; message: string } @@ -50,26 +52,39 @@ export function MobileAccountsScreen() { // Why: shared client per host. See docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) const forceReconnect = useForceReconnect() + const connectionInputs = useHostConnectionInputs(hostId) // Seed from the last host read so the subtitle is there on the first frame. const [hostName, setHostName] = useState( () => peekHosts()?.find((host) => host.id === hostId)?.name ?? '' ) - const [snapshot, setSnapshot] = useState(null) - const [error, setError] = useState(null) - const [refreshing, setRefreshing] = useState(false) + const [hostError, setHostError] = useState(null) const [busyAccountId, setBusyAccountId] = useState(null) const [clockEnabled, setClockEnabled] = useState(false) const [notice, setNotice] = useState(null) - const acceptSnapshot = useCallback((nextSnapshot: AccountsSnapshot) => { - setSnapshot(nextSnapshot) - setError(null) - }, []) - const rejectInvalidSnapshot = useCallback(() => { - // Fail closed on unrecognized snapshot: stale data can expose finite reset for wrong account. - setSnapshot(null) - setError(t('accounts.screen.invalidSnapshot', 'Invalid accounts snapshot from host')) - }, [t]) + const accounts = useMobileAccountsSnapshot({ + hostId, + client, + connState, + lastConnectedAt: connectionInputs.lastConnectedAt + }) + const { snapshot, live, refresh, refreshing, acceptSnapshot } = accounts + const error = accounts.error ?? hostError + // Stored accounts stay up while connecting or offline; re-pair and a failed read on a live connection say so instead. + const showsAccounts = + snapshot !== null && + (live || (connState !== 'auth-failed' && !(connState === 'connected' && error))) + const offlineBanner = useHostOfflineBanner({ + client, + connState, + lastConnectedAt: connectionInputs.lastConnectedAt, + outage: isConnectionOutage(classifyConnection({ state: connState, ...connectionInputs })), + forceReconnect: hostId && forceReconnect ? () => void forceReconnect(hostId) : null, + hasData: showsAccounts, + asOf: accounts.readAt + }) + // Stored accounts wait on the stream's first answer for their usage. + const showRefreshLine = useRefreshHint(showsAccounts && !live && offlineBanner === null) const { supported: codexResetSupported, resetting: resettingCodex, @@ -108,7 +123,7 @@ export function MobileAccountsScreen() { } const host = hosts.find((h) => h.id === hostId) if (!host) { - setError(t('accounts.screen.hostNotFound', 'Host not found')) + setHostError(t('accounts.screen.hostNotFound', 'Host not found')) return } setHostName(host.name) @@ -118,41 +133,6 @@ export function MobileAccountsScreen() { } }, [hostId, t]) - const refresh = useCallback(async () => { - if (!client) { - return - } - setRefreshing(true) - try { - const res = await client.sendRequest('accounts.list') - if (res.ok) { - acceptSnapshot(decodeAccountsSnapshot(res.result)) - } else { - setError(res.error.message) - } - } catch (e) { - if (e instanceof AccountsSnapshotError) { - rejectInvalidSnapshot() - } else { - setError(e instanceof Error ? e.message : String(e)) - } - } finally { - setRefreshing(false) - } - }, [acceptSnapshot, client, rejectInvalidSnapshot]) - - // Stream desktop poll/account-switch updates, falling back to one-shot accounts.list on subscription error. - useEffect(() => { - if (!client || connState !== 'connected') { - return - } - return subscribeAccountsSnapshots(client, { - onSnapshot: acceptSnapshot, - onInvalidSnapshot: rejectInvalidSnapshot, - onStreamError: () => void refresh() - }) - }, [acceptSnapshot, client, connState, refresh, rejectInvalidSnapshot]) - const selectAccount = useCallback( async (provider: ProviderKey, accountId: string | null) => { if (!client) { @@ -202,7 +182,8 @@ export function MobileAccountsScreen() { const activeUsage = getActiveProviderRateLimits(snapshot, provider) const resetCredit = provider === 'codex' ? getCodexResetCreditSummary(activeUsage, now) : null const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon - const switchBlocked = busyAccountId !== null || resettingCodex || connState !== 'connected' + // Stored rows may name a selection or Codex target the desktop has since changed. + const switchBlocked = busyAccountId !== null || resettingCodex || !live const spinner = return ( @@ -324,6 +305,7 @@ export function MobileAccountsScreen() { } title={t('accounts.screen.title', 'Accounts')} subtitle={hostName || undefined} + refreshing={showRefreshLine} trailing={ - {!snapshot ? ( + {!showsAccounts ? ( ) : ( <> + {offlineBanner ? ( + + + + ) : null} {renderProviderSection('claude')} {renderProviderSection('codex')} {renderOtherProviders()} diff --git a/mobile/src/accounts/accounts-snapshot-stream.ts b/mobile/src/accounts/accounts-snapshot-stream.ts index 22b877c08ad..09ca407fead 100644 --- a/mobile/src/accounts/accounts-snapshot-stream.ts +++ b/mobile/src/accounts/accounts-snapshot-stream.ts @@ -1,11 +1,12 @@ +import { queryOptions, skipToken } from '@tanstack/react-query' import { AccountsSnapshotSchema, decodeAccountsSnapshot } from '../components/accounts-snapshot' import type { AccountsSnapshot } from '../components/AccountUsage' -import { hostQueryKey } from '../query/host-query-key' +import { hostQueryKey, type HostQueryKey } from '../query/host-query-key' import type { PersistedHostFamily } from '../query/host-query-persistence' import type { RpcClient } from '../transport/rpc/rpc-client-contract' /** Usage and rate limits are live facts: a stored snapshot keeps the accounts and drops every number. */ -function withoutAccountUsage(snapshot: AccountsSnapshot | null): AccountsSnapshot | null { +export function withoutAccountUsage(snapshot: AccountsSnapshot | null): AccountsSnapshot | null { if (!snapshot) { return null } @@ -37,6 +38,15 @@ export const accountsSnapshotFamily: PersistedHostFamily hostQueryKey(hostId, accountsSnapshotFamily.family, { exec: 'local' }, {}) +/** Kept by the accounts stream or an `accounts.list` read, never fetched. */ +export const accountsSnapshotOptions = (hostId: string) => + queryOptions({ + queryKey: accountsSnapshotKey(hostId), + queryFn: skipToken, + gcTime: Infinity, + meta: { streamBacked: true, persist: accountsSnapshotFamily.persist } + }) + /** The desktop's `accounts.subscribe` stream, decoded: rate-limit polls and account switches as they happen. */ export function subscribeAccountsSnapshots( client: RpcClient, diff --git a/mobile/src/accounts/mobile-accounts-screen-styles.ts b/mobile/src/accounts/mobile-accounts-screen-styles.ts index f72f5786ecc..bce57b12934 100644 --- a/mobile/src/accounts/mobile-accounts-screen-styles.ts +++ b/mobile/src/accounts/mobile-accounts-screen-styles.ts @@ -11,6 +11,9 @@ export const styles = themedStyleSheet(() => ({ paddingHorizontal: spacing.lg, paddingTop: spacing.sm }, + offlineBanner: { + paddingBottom: spacing.sm + }, // On a screen, not a sheet: one step above the page, as the other screen groups are. group: { backgroundColor: colors.bgPanel diff --git a/mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx b/mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx new file mode 100644 index 00000000000..56c8d293793 --- /dev/null +++ b/mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx @@ -0,0 +1,151 @@ +import { createElement } from 'react' +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +// Also tells TanStack observers on a microtask, so act() sees replies. +import '../test-support/query-client-test-renderer' +import { settleQueryNotifications } from '../test-support/rpc-recording/hook-mount' +import type { OrcaQueryStoreBackend } from '../device-store/query-storage' +import { createOrcaQueryClient } from '../query/orca-query-client' +import { createHostQueryPersistence, persistedHostQueryKey } from '../query/host-query-persistence' +import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { + accountsSnapshotFamily, + accountsSnapshotKey, + accountsSnapshotOptions +} from './accounts-snapshot-stream' +import { useMobileAccountsSnapshot } from './use-mobile-accounts-snapshot' + +const HOST = 'host-1' +const CONNECTED_AT = 1_794_000_000_000 + +const SNAPSHOT = { + claude: { + accounts: [{ id: 'claude-1', email: 'claude@example.test', updatedAt: 100 }], + activeAccountId: 'claude-1' + }, + codex: { accounts: [], activeAccountId: null }, + rateLimits: { + claude: { + provider: 'claude', + session: { usedPercent: 42, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 100, + error: null, + status: 'ok' + }, + codex: null, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } +} + +function accountsClient(listReply: unknown) { + let stream: (payload: unknown) => void = () => {} + const sendRequest = vi.fn(async () => ({ id: 'reply', ok: true as const, result: listReply })) + const client: RpcClient = { + sendRequest, + subscribe: (_method, _params, listener) => { + stream = listener + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => CONNECTED_AT, + getGeneration: () => 1, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + return { client, sendRequest, frame: (payload: unknown) => stream(payload) } +} + +let tree: ReactTestRenderer | null = null +afterEach(() => { + act(() => tree?.unmount()) + tree = null +}) + +async function mountAccounts(queryClient: QueryClient, client: RpcClient) { + let state: ReturnType | null = null + function Probe() { + state = useMobileAccountsSnapshot({ + hostId: HOST, + client, + connState: 'connected', + lastConnectedAt: CONNECTED_AT + }) + return null + } + await act(async () => { + tree = create(createElement(QueryClientProvider, { client: queryClient }, createElement(Probe))) + }) + return () => { + if (!state) { + throw new Error('Not mounted') + } + return state + } +} + +function memoryStore(): OrcaQueryStoreBackend { + const rows = new Map() + return { + hostId: HOST, + read: async (keys) => ({ status: 'ok', values: keys.map((key) => rows.get(key) ?? null) }), + write: async (ops) => { + for (const op of ops) { + if (op.value === null) { + rows.delete(op.key) + } else { + rows.set(op.key, op.value) + } + } + return { written: true } + } + } +} + +describe('accounts snapshot', () => { + it('still shows usage when the stream fails and accounts.list answers', async () => { + const port = accountsClient(SNAPSHOT) + const read = await mountAccounts(createOrcaQueryClient(), port.client) + await act(async () => port.frame({ type: 'error' })) + await settleQueryNotifications() + expect(port.sendRequest).toHaveBeenCalledWith('accounts.list') + expect(read().live).toBe(true) + expect(read().snapshot?.rateLimits.claude?.session?.usedPercent).toBe(42) + }) + + it('hides usage this screen did not read on the current connection, keeping the accounts', async () => { + const queryClient = createOrcaQueryClient() + queryClient.setQueryData(accountsSnapshotKey(HOST), SNAPSHOT, { updatedAt: CONNECTED_AT - 1 }) + const read = await mountAccounts(queryClient, accountsClient(SNAPSHOT).client) + expect(read().live).toBe(false) + expect(read().snapshot?.rateLimits.claude).toBeNull() + expect(read().snapshot?.claude.accounts.map((account) => account.email)).toEqual([ + 'claude@example.test' + ]) + }) + + it('never counts a stored snapshot as live, even one written after the connect', async () => { + const store = memoryStore() + // Native Home's own stream stores the snapshot after the connect the page then reads. + const home = createOrcaQueryClient() + const homePersistence = createHostQueryPersistence(home, store) + home.getQueryCache().build(home, accountsSnapshotOptions(HOST)) + home.setQueryData(accountsSnapshotKey(HOST), SNAPSHOT, { updatedAt: CONNECTED_AT + 1 }) + await settleQueryNotifications() + await homePersistence.flush() + home.clear() + + const page = createOrcaQueryClient() + await createHostQueryPersistence(page, store).restore([ + persistedHostQueryKey(accountsSnapshotFamily, accountsSnapshotKey(HOST))! + ]) + const read = await mountAccounts(page, accountsClient(SNAPSHOT).client) + expect(read().snapshot?.claude.accounts.map((account) => account.id)).toEqual(['claude-1']) + expect(read().live).toBe(false) + }) +}) diff --git a/mobile/src/accounts/use-mobile-accounts-snapshot.ts b/mobile/src/accounts/use-mobile-accounts-snapshot.ts new file mode 100644 index 00000000000..d4370bb5310 --- /dev/null +++ b/mobile/src/accounts/use-mobile-accounts-snapshot.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { hashKey, useQuery, useQueryClient } from '@tanstack/react-query' +import { AccountsSnapshotError, decodeAccountsSnapshot } from '../components/accounts-snapshot' +import type { AccountsSnapshot } from '../components/AccountUsage' +import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { holdsRestoredHostQueryData } from '../query/host-query-persistence' +import type { ConnectionState } from '../transport/connection/connection-state' +import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { + accountsSnapshotKey, + accountsSnapshotOptions, + subscribeAccountsSnapshots, + withoutAccountUsage +} from './accounts-snapshot-stream' + +/** + * One host's accounts: the last stored snapshot at once, then the desktop's stream, or `accounts.list` when the + * stream fails. Usage shows only from a read on the current connection; anything older shows accounts alone. + */ +export function useMobileAccountsSnapshot({ + hostId, + client, + connState, + lastConnectedAt +}: { + hostId: string + client: RpcClient | null + connState: ConnectionState + lastConnectedAt: number | null +}) { + const t = useMobileTranslation() + const queryClient = useQueryClient() + const { data, dataUpdatedAt } = useQuery(accountsSnapshotOptions(hostId)) + // Dated by when its read was sent, so a failure from an earlier connection doesn't outlive it. + const [failure, setFailure] = useState<{ message: string; at: number } | null>(null) + const [refreshing, setRefreshing] = useState(false) + // Data this screen didn't read itself, such as a previous visit's in the same document, can't vouch for usage. + const [mountedAt] = useState(Date.now) + + const acceptSnapshot = useCallback( + (nextSnapshot: AccountsSnapshot) => { + queryClient.setQueryData(accountsSnapshotKey(hostId), nextSnapshot) + setFailure(null) + }, + [hostId, queryClient] + ) + const rejectInvalidSnapshot = useCallback( + (readAt = Date.now()) => { + // Fail closed on unrecognized snapshot: stale data can expose finite reset for wrong account. + queryClient.setQueryData(accountsSnapshotKey(hostId), null) + setFailure({ + message: t('accounts.screen.invalidSnapshot', 'Invalid accounts snapshot from host'), + at: readAt + }) + }, + [hostId, queryClient, t] + ) + + const refresh = useCallback(async () => { + if (!client) { + return + } + setRefreshing(true) + const sentAt = Date.now() + // A reply sent before a reconnect speaks for the old connection; the new one's stream answers instead. + const reconnectedSince = () => (client.getLastConnectedAt() ?? 0) > sentAt + try { + const res = await client.sendRequest('accounts.list') + if (reconnectedSince()) { + return + } + if (res.ok) { + acceptSnapshot(decodeAccountsSnapshot(res.result)) + } else { + setFailure({ message: res.error.message, at: sentAt }) + } + } catch (e) { + if (reconnectedSince()) { + return + } + if (e instanceof AccountsSnapshotError) { + rejectInvalidSnapshot(sentAt) + } else { + setFailure({ message: e instanceof Error ? e.message : String(e), at: sentAt }) + } + } finally { + setRefreshing(false) + } + }, [acceptSnapshot, client, rejectInvalidSnapshot]) + + // Stream desktop poll/account-switch updates, falling back to one-shot accounts.list on subscription error. + useEffect(() => { + if (!client || connState !== 'connected') { + return + } + return subscribeAccountsSnapshots(client, { + onSnapshot: acceptSnapshot, + onInvalidSnapshot: () => rejectInvalidSnapshot(), + onStreamError: () => void refresh() + }) + }, [acceptSnapshot, client, connState, refresh, rejectInvalidSnapshot]) + + // Only this screen's own read on the current connection shows usage. A stored snapshot never does, whatever its + // date: native Home writes it from its own stream, possibly after this connect or on a clock since set back. + const live = + data != null && + !holdsRestoredHostQueryData(queryClient, hashKey(accountsSnapshotKey(hostId))) && + connState === 'connected' && + lastConnectedAt !== null && + dataUpdatedAt >= Math.max(lastConnectedAt, mountedAt) + const snapshot = useMemo( + () => (live ? (data ?? null) : withoutAccountUsage(data ?? null)), + [live, data] + ) + + const error = + failure && (lastConnectedAt === null || failure.at >= lastConnectedAt) ? failure.message : null + + return { + snapshot, + live, + readAt: data != null ? dataUpdatedAt : null, + error, + refreshing, + refresh, + acceptSnapshot + } +} diff --git a/mobile/src/home/use-mobile-home-data.ts b/mobile/src/home/use-mobile-home-data.ts index 3de43184006..d61d920d04f 100644 --- a/mobile/src/home/use-mobile-home-data.ts +++ b/mobile/src/home/use-mobile-home-data.ts @@ -2,9 +2,7 @@ import { useRouter, useFocusEffect } from 'expo-router' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { notifyManager, - queryOptions, replaceEqualDeep, - skipToken, useQueries, useQueryClient, type QueryObserverResult @@ -16,7 +14,7 @@ import { type AgentListInput } from '../agents/agent-list-rows' import { useAgentActivityWrites } from '../agents/use-agent-activity-writes' -import { accountsSnapshotFamily, accountsSnapshotKey } from '../accounts/accounts-snapshot-stream' +import { accountsSnapshotOptions } from '../accounts/accounts-snapshot-stream' import type { AccountsSnapshot } from '../components/AccountUsage' import { HOST_LIST_TARGET, @@ -36,7 +34,6 @@ import { loadHostCatalog } from '../transport/hosts/host-store' import type { HostCatalogEntry } from '../transport/hosts/host-profile' import { hostObserverOptions, type HostQuery } from '../query/host-query' import { isHostNotConnectedError } from '../query/host-query-errors' -import type { HostQueryKey } from '../query/host-query-key' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import { homeWorktreeCountsQuery, @@ -61,15 +58,6 @@ const homeQueryOptions = ( host: HomeHost ) => hostObserverOptions(query, host.client, host.hostId, HOST_LIST_TARGET, NO_HOST_LIST_INPUT) -// Kept by the accounts stream (use-mobile-home-host-connections), never fetched. -const homeAccountsOptions = (hostId: string) => - queryOptions({ - queryKey: accountsSnapshotKey(hostId), - queryFn: skipToken, - gcTime: Infinity, - meta: { streamBacked: true, persist: accountsSnapshotFamily.persist } - }) - const readOf = (result: QueryObserverResult) => ({ data: result.data, dataUpdatedAt: result.dataUpdatedAt, @@ -158,7 +146,7 @@ export function useMobileHomeData() { subscribed: homeFocused }) const accountsQueries = useMemo( - () => homeHosts.map(({ hostId }) => homeAccountsOptions(hostId)), + () => homeHosts.map(({ hostId }) => accountsSnapshotOptions(hostId)), [homeHosts] ) // Usage is live: only a snapshot from this connection shows, never a stored or disconnected one. diff --git a/mobile/src/query/first-paint-query-keys.ts b/mobile/src/query/first-paint-query-keys.ts index 2a7df59f802..47654004af7 100644 --- a/mobile/src/query/first-paint-query-keys.ts +++ b/mobile/src/query/first-paint-query-keys.ts @@ -1,5 +1,6 @@ import type { QueryClient } from '@tanstack/react-query' import { normalizeExecutionHostId } from '../../../src/shared/execution-host' +import { accountsSnapshotFamily, accountsSnapshotKey } from '../accounts/accounts-snapshot-stream' import { HOST_LIST_TARGET, hostRepoCatalogQuery, @@ -44,6 +45,11 @@ export function firstPaintQueryKeys(hostId: string, route: URL): PersistedHostQu if (screen === 'tasks') { return firstPaintTaskKeys(hostId) } + if (screen === 'accounts') { + return [persistedHostQueryKey(accountsSnapshotFamily, accountsSnapshotKey(hostId))].filter( + (key) => key !== null + ) + } if (screen === 'new-workspace') { // The drawer's existing-workspace check and host labels. return [ From 94dab9fc697be8c1ea6dc94c13661d9f7ca23980 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:38:36 +0000 Subject: [PATCH 021/138] feat(mobile): save the chat when the app leaves the foreground (#40417) * feat(mobile): save the chat when the app leaves the foreground A swipe-kill from the app switcher can end the app before the page is hidden: iOS fires visibilitychange only at background, and Android Recents leaves the activity resumed. The shell now sends an appInactive frame on iOS inactive and Android window blur, to pages that declare they read it, and the page saves any chat rows the store doesn't hold yet. * fix(mobile): keep the leave callback out of render * fix(mobile): notice a changed middle row before skipping a chat save --- .../mobile-web-shell/MobileWebShellScreen.tsx | 2 + .../src/mobile-web-shell/bridge-host-back.ts | 2 + .../mobile-web-shell/bridge-host-contract.ts | 2 + .../mobile-web-shell/bridge-host-init.test.ts | 13 ++++ mobile/src/mobile-web-shell/bridge-host.ts | 9 +++ .../bridge/bridge-app-inactive.ts | 22 ++++++ .../bridge/bridge-client-inbound-frames.ts | 5 ++ .../bridge/bridge-client-init-handshake.ts | 3 +- .../bridge/bridge-envelope.ts | 7 +- .../bridge/bridge-rpc-client.ts | 2 + .../use-app-leaves-foreground.ts | 41 ++++++++++ .../use-mobile-web-shell-bridge.ts | 6 ++ ...bile-native-chat-transcript-writer.test.ts | 75 +++++++++++++++++++ .../mobile-native-chat-transcript-writer.ts | 49 ++++++++---- 14 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-app-inactive.ts create mode 100644 mobile/src/mobile-web-shell/use-app-leaves-foreground.ts create mode 100644 mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index 362ef475933..11e05d21660 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -27,6 +27,7 @@ import { useNativeImageActions } from '../platform/native-image-actions' import { processDeviceStore } from '../device-store/device-store' import { serveDeviceStoreVerb } from './device-store-verb-server' import { BRIDGE_STORE_VERB } from './bridge/bridge-store-verb' +import { useAppLeavesForeground } from './use-app-leaves-foreground' import { useShellPageBack } from './use-shell-page-back' import { useShellStackPop } from './use-shell-stack-pop' import { usePageLocationOnFold } from './page-location-on-fold' @@ -263,6 +264,7 @@ export function MobileWebShellScreen({ // The stream survives dropped frames; keep a count because diagnostics print only once per host. onBinaryFramesDropped: reportDroppedBinaryFrames }) + useAppLeavesForeground(bridge.sendAppInactive) // paneKey changes keep the session mounted; the host deduplicates routes and preserves in-flight frames. useShellPageStorageRefresh(refreshStorage, bridge.publishStorage) diff --git a/mobile/src/mobile-web-shell/bridge-host-back.ts b/mobile/src/mobile-web-shell/bridge-host-back.ts index c7bfdc7c1ee..26a49beb452 100644 --- a/mobile/src/mobile-web-shell/bridge-host-back.ts +++ b/mobile/src/mobile-web-shell/bridge-host-back.ts @@ -8,6 +8,8 @@ export type BridgeSessionBack = { readonly storageKeys?: readonly string[] /** The page's `ready` said it reads `connectionPath` frames. */ readonly connectionPathFrames?: true + /** The page's `ready` said it reads `appInactive` frames. */ + readonly appInactiveFrames?: true readonly terminalKeyRailClaim?: { readonly inputOwner?: string } } diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts index efa1f1aca21..aa31263a3e6 100644 --- a/mobile/src/mobile-web-shell/bridge-host-contract.ts +++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts @@ -168,6 +168,8 @@ export type BridgeHost = { * every page older than the frame; the caller then leaves the key to the navigator. */ sendBack: () => boolean + /** Tells a page that reads it that the app is leaving the foreground; nothing for older pages. */ + sendAppInactive: () => void // Transfer the session’s Back state on client-driven host rebuild; the mounted page never reclaims it. Ending sessions simply discard it. readSessionBack: () => BridgeSessionBack dispose: () => void diff --git a/mobile/src/mobile-web-shell/bridge-host-init.test.ts b/mobile/src/mobile-web-shell/bridge-host-init.test.ts index 24ac5913f79..0a64fd9585f 100644 --- a/mobile/src/mobile-web-shell/bridge-host-init.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-init.test.ts @@ -249,6 +249,19 @@ describe('init and state', () => { }) }) + it('tells only a page that reads them that the app is leaving the foreground', () => { + const reads = harness() + reads.host.receive(clientFrame({ type: 'ready', appInactiveFrames: true })) + reads.host.sendAppInactive() + expect(reads.last().type).toBe('appInactive') + + const older = harness() + older.host.receive(clientFrame({ type: 'ready' })) + const sent = older.frames().length + older.host.sendAppInactive() + expect(older.frames()).toHaveLength(sent) + }) + it('sends no path frame to a page that never said it reads them', () => { let notifyPath = () => {} const client = createFakeRpcClient({ diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts index 5fb65c9c340..914b25c9b70 100644 --- a/mobile/src/mobile-web-shell/bridge-host.ts +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -1,4 +1,5 @@ import type { ConnectionState } from '../transport/connection/connection-state' +import { BRIDGE_APP_INACTIVE_FRAME } from './bridge/bridge-app-inactive' import { BridgeCapExceededError } from './bridge-host-errors' import { createNativeVerbServer } from './bridge-host-native-verbs' @@ -67,6 +68,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { // re-handshake. Null is a page older than `storageKeys`, which gets the legacy keys only. let pageStorageKeys: readonly string[] | null = options.sessionBack?.storageKeys ?? null let pageReadsConnectionPath = options.sessionBack?.connectionPathFrames === true + let pageReadsAppInactive = options.sessionBack?.appInactiveFrames === true const frames = createBridgeHostFrames({ post: options.post, @@ -218,6 +220,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { serving = true pageStorageKeys = message.storageKeys ?? null pageReadsConnectionPath = message.connectionPathFrames === true + pageReadsAppInactive = message.appInactiveFrames === true back.readReady() // Dropped like the Back claim: a page still showing a terminal claims again on this `init`. reportRailClaim(false) @@ -331,11 +334,17 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { publishUiLocale: (next) => { routes.publishUiLocale(next, deliverable()) }, + sendAppInactive: () => { + if (pageReadsAppInactive && deliverable()) { + send({ v: BRIDGE_PROTOCOL_VERSION, type: BRIDGE_APP_INACTIVE_FRAME }) + } + }, sendBack: back.send, readSessionBack: () => ({ ...back.read(), ...(pageStorageKeys === null ? {} : { storageKeys: pageStorageKeys }), ...(pageReadsConnectionPath ? { connectionPathFrames: true as const } : {}), + ...(pageReadsAppInactive ? { appInactiveFrames: true as const } : {}), ...(railClaimed ? { terminalKeyRailClaim: { inputOwner: railOwner } } : {}) }), dispose diff --git a/mobile/src/mobile-web-shell/bridge/bridge-app-inactive.ts b/mobile/src/mobile-web-shell/bridge/bridge-app-inactive.ts new file mode 100644 index 00000000000..da8d00eecf0 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-app-inactive.ts @@ -0,0 +1,22 @@ +/** + * Shell to page, a frame: the app is leaving the foreground. iOS sends a page `visibilitychange` + * only once the app reaches the background, and a swipe-kill from the app switcher can stop it + * while it is merely inactive, so the page saves what it would save on hide when this arrives. + * Pages that read it say so in `ready`; older pages never receive one, and older shells never + * send one, so the page keeps saving on hide alone. + */ +export const BRIDGE_APP_INACTIVE_FRAME = 'appInactive' + +const listeners = new Set<() => void>() + +/** Page side: runs `listener` each time the shell says the app is leaving the foreground. */ +export function onPageAppInactive(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function emitPageAppInactive(): void { + for (const listener of listeners) { + listener() + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts index 57c5300af27..84c5a156be0 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts @@ -11,6 +11,7 @@ import { type BridgeHostMessage } from './bridge-envelope' import { reconstructBridgeError } from './bridge-error-capture' +import { BRIDGE_APP_INACTIVE_FRAME } from './bridge-app-inactive' export type BridgeInboundFramePort = { requests: BridgeClientRequests @@ -21,6 +22,7 @@ export type BridgeInboundFramePort = { acceptConnectionPath: (connection: BridgeConnectionSnapshot) => void acceptBack: () => void acceptParked: () => void + acceptAppInactive: () => void acceptTerminalKey: (key: BridgeTerminalKeyId, inputOwner?: string) => void } @@ -70,6 +72,9 @@ export function createBridgeInboundFrameReader( case 'parked': port.acceptParked() return + case BRIDGE_APP_INACTIVE_FRAME: + port.acceptAppInactive() + return case 'terminalKey': port.acceptTerminalKey(message.key, message.inputOwner) return diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts index cf8f09e1382..818fd030288 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts @@ -46,6 +46,7 @@ export function createPageReadyFrame(): Extract terminalKeys.accept(key, inputOwner) }) diff --git a/mobile/src/mobile-web-shell/use-app-leaves-foreground.ts b/mobile/src/mobile-web-shell/use-app-leaves-foreground.ts new file mode 100644 index 00000000000..41306009caa --- /dev/null +++ b/mobile/src/mobile-web-shell/use-app-leaves-foreground.ts @@ -0,0 +1,41 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { AppState } from 'react-native' + +/** + * Calls `onLeave` once each time the app leaves the foreground, before the system can kill it from the app + * switcher. iOS: `inactive` (the app switcher, Notification Centre, Face ID); a page is hidden only once the app + * reaches the background, which a swipe-kill can skip. Android: window `blur` (Recents, the notification shade, and + * the app's own dialogs and sheets), because Recents leaves the activity resumed until the swipe kills the process. + * Callers must keep the work cheap and idempotent: it runs on every such flap. + * Not covered: on Android, Recents opened over one of the app's own sheets sends nothing more (the sheet's blur + * already did), so rows streamed in while the sheet was open wait for the next save. + */ +export function useAppLeavesForeground(onLeave: () => void): void { + const onLeaveRef = useRef(onLeave) + useLayoutEffect(() => { + onLeaveRef.current = onLeave + }, [onLeave]) + useEffect(() => { + let away = AppState.currentState !== 'active' + const leave = (): void => { + if (!away) { + away = true + onLeaveRef.current() + } + } + const back = (): void => { + away = false + } + const change = AppState.addEventListener('change', (next) => + next === 'active' ? back() : leave() + ) + // Window focus events come from Android only. + const blur = AppState.addEventListener('blur', leave) + const focus = AppState.addEventListener('focus', back) + return () => { + change.remove() + blur.remove() + focus.remove() + } + }, []) +} diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts index 07a5f2cba67..9c4d2637936 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts @@ -69,6 +69,8 @@ export type MobileWebShellBridgeView = { * serves never said it takes one — the caller then leaves the key to the navigator. */ readonly sendBack: () => boolean + /** Tells the mounted page the app is leaving the foreground; nothing with no host or an older page. */ + readonly sendAppInactive: () => void } export type MobileWebShellBridgeArgs = { @@ -342,6 +344,10 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW sendBack: useCallback(() => { const mounted = hostRef.current return mounted !== null && mounted.sessionId === sessionId && mounted.host.sendBack() + }, [sessionId]), + sendAppInactive: useCallback(() => { + const mounted = hostRef.current + return mounted?.sessionId === sessionId ? mounted.host.sendAppInactive() : undefined }, [sessionId]) } } diff --git a/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts b/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts new file mode 100644 index 00000000000..08a181f1f7d --- /dev/null +++ b/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types' +import { publishPageDeviceStore } from '../../mobile-web-shell/bridge/page-device-store' +import { emitPageAppInactive } from '../../mobile-web-shell/bridge/bridge-app-inactive' +import { noteChatTranscript } from './mobile-native-chat-transcript-writer' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +describe('chat copies when the app leaves the foreground', () => { + const writes: string[] = [] + + beforeEach(() => { + writes.length = 0 + publishPageDeviceStore({ + available: () => true, + read: async (_table, keys) => ({ status: 'ok', values: keys.map(() => null) }), + write: async (_table, ops) => { + writes.push(...ops.map((op) => op.key)) + return { written: true } + } + }) + }) + + afterEach(() => { + vi.useRealTimers() + publishPageDeviceStore(null) + }) + + function note(rows: NativeChatMessage[]): void { + noteChatTranscript( + 'chat-leaving', + rows, + () => true, + (savedAt) => ({ + kind: 'terminal', + savedAt, + messages: rows, + hasMore: false, + beforeOffset: null + }) + ) + } + + it('saves new rows on leaving and writes nothing when a flap brings none', async () => { + vi.useFakeTimers() + const [m1, m2, m3] = [message('m1'), message('m2'), message('m3')] + note([m1, m2, m3]) + emitPageAppInactive() + await vi.runAllTimersAsync() + expect(writes).toHaveLength(1) + + // Two minutes later, Notification Centre and back: the rows on screen are a new list with the same rows. + vi.setSystemTime(Date.now() + 120_000) + note([m1, m2, m3]) + emitPageAppInactive() + note([m1, m2, m3]) + emitPageAppInactive() + await vi.runAllTimersAsync() + expect(writes).toHaveLength(1) + + // A middle row changed (a tool result landed), with the last row the same. + note([m1, { ...m2, blocks: [{ type: 'text', text: 'm2 with its result' }] }, m3]) + emitPageAppInactive() + await vi.runAllTimersAsync() + expect(writes).toHaveLength(2) + }) +}) diff --git a/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.ts b/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.ts index 595ba9a4e9f..a500287955f 100644 --- a/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.ts +++ b/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.ts @@ -1,4 +1,5 @@ import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types' +import { onPageAppInactive } from '../../mobile-web-shell/bridge/bridge-app-inactive' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession, @@ -61,12 +62,13 @@ const SAME_COPY_REWRITE_MS = 60_000 // Whether two copies hold the same rows: an open that changed nothing then skips re-encoding the whole window. function sameRows(a: StoredChatTranscript, b: StoredChatTranscript): boolean { + // The merger replaces a row object whenever the row changes, anywhere in the list; a copy read back from the + // store holds other objects, so the first save after a relaunch writes once. if (a.kind === 'terminal' && b.kind === 'terminal') { return ( a.messages.length === b.messages.length && a.hasMore === b.hasMore && - a.messages[0]?.id === b.messages[0]?.id && - JSON.stringify(a.messages.at(-1)) === JSON.stringify(b.messages.at(-1)) + a.messages.every((row, at) => row === b.messages[at]) ) } // A journal's cursor moves with every change to its rows. @@ -88,10 +90,16 @@ function yieldToPage(): Promise { } /** - * Writes a chat's noted copy unless the store already holds it. A turn settle or a close encodes in slices, - * so even a big chat costs no long task; a hide (`now`) encodes in one go, since the app may stop right after. + * When a copy is written. `settle` (a turn settles, a chat closes) and `leave` (the app leaving the foreground, which + * also fires for Notification Centre, Face ID or an Android dialog) encode in slices, so even a big chat costs no long + * task; a swipe-kill from the app switcher comes a beat later. `leave` writes only rows the store doesn't hold yet and + * otherwise keeps the copy noted for the next hide. `hide` encodes in one go, since the app may stop right after. */ -export function flushChatTranscript(identity: string, now = false): void { +type FlushWhen = 'settle' | 'hide' | 'leave' + +/** Writes a chat's noted copy unless the store already holds it. */ +export function flushChatTranscript(identity: string, when: FlushWhen = 'settle'): void { + const now = when === 'hide' const next = pending.get(identity) ?? (now ? encoding.get(identity) : undefined) pending.delete(identity) const store = readPageDeviceStore() @@ -100,9 +108,15 @@ export function flushChatTranscript(identity: string, now = false): void { } const stored = next.build(next.isLive() ? Date.now() : next.notedAt) const kept = peekStoredChatTranscript(identity) - if (kept && sameRows(kept, stored) && stored.savedAt - kept.savedAt < SAME_COPY_REWRITE_MS) { - written.add(next.source) - return + if (kept && sameRows(kept, stored)) { + if (when === 'leave') { + pending.set(identity, next) + return + } + if (stored.savedAt - kept.savedAt < SAME_COPY_REWRITE_MS) { + written.add(next.source) + return + } } const write = ++writes latestWrite.set(identity, write) @@ -142,25 +156,30 @@ export function flushChatTranscript(identity: string, now = false): void { .catch(retry) } -function flushAll(): void { +function flushAll(when: Exclude): void { for (const identity of new Set([...pending.keys(), ...encoding.keys()])) { - flushChatTranscript(identity, true) + flushChatTranscript(identity, when) } } -let hiddenListener = false +let listening = false function listenForHidden(): void { - if (hiddenListener || typeof document === 'undefined') { + if (listening) { + return + } + listening = true + // iOS hides the page only once the app is in the background; a swipe-kill from the app switcher comes sooner. + onPageAppInactive(() => flushAll('leave')) + if (typeof document === 'undefined') { return } - hiddenListener = true document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { - flushAll() + flushAll('hide') } }) // A document torn down while visible fires no hidden and runs no unmount cleanups. - window.addEventListener('pagehide', flushAll) + window.addEventListener('pagehide', () => flushAll('hide')) } // Reducer states that still show a stored copy no live read has confirmed, with when it was saved. From fc379afda7833649c7b5ee243a437bbd8c5dceb4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 09:49:58 -0800 Subject: [PATCH 022/138] test: remove low-value locale, automation and settings cases (#40437) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 6 - ...cale-translation-policy-ko-round5.test.mjs | 141 ---------------- ...cale-translation-policy.es-round5.test.mjs | 65 -------- ...nslation-policy.ja-relocalization.test.mjs | 93 ----------- ...cale-translation-policy.ja-round5.test.mjs | 157 ------------------ src/cli/reference-command-arguments.test.ts | 14 -- .../runtime/serve-speech-recognizer.test.ts | 17 -- .../AutomationEditorSettingsSidebar.test.tsx | 64 +------ .../automations/AutomationTimeField.test.tsx | 23 +-- .../automations/AutomationsListPanel.test.tsx | 153 ----------------- ...automation-host-status-descriptors.test.ts | 67 -------- .../tasks/TaskSourceProviderCard.test.tsx | 62 ------- .../jira/TaskPageJiraStatusFilters.test.tsx | 54 ------ .../artifacts/plugin-theme-artifact.test.ts | 17 +- 14 files changed, 3 insertions(+), 930 deletions(-) delete mode 100644 config/scripts/locale-catalog/locale-translation-policy.es-round5.test.mjs delete mode 100644 config/scripts/locale-catalog/locale-translation-policy.ja-round5.test.mjs delete mode 100644 src/renderer/src/components/automations/AutomationsListPanel.test.tsx delete mode 100644 src/renderer/src/components/automations/automation-host-status-descriptors.test.ts delete mode 100644 src/renderer/src/components/settings/tasks/TaskSourceProviderCard.test.tsx delete mode 100644 src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 229a897a13b..72a054baf8f 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -102,9 +102,7 @@ "config/scripts/locale-catalog/locale-repair-catalog-missing-leaves.test.mjs": 159, "config/scripts/locale-catalog/locale-translation-policy-ko-round5.test.mjs": 154, "config/scripts/locale-catalog/locale-translation-policy.es-pr.test.mjs": 91, - "config/scripts/locale-catalog/locale-translation-policy.es-round5.test.mjs": 153, "config/scripts/locale-catalog/locale-translation-policy.ja-relocalization.test.mjs": 116, - "config/scripts/locale-catalog/locale-translation-policy.ja-round5.test.mjs": 209, "config/scripts/locale-catalog/locale-translation-policy.test.mjs": 189, "config/scripts/localization/source-extraction/localization-extraction-call-syntax.test.mjs": 317, "config/scripts/localization/extraction-policy/localization-extraction-change-scope.test.mjs": 732, @@ -4708,7 +4706,6 @@ "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 668, "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 431, "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 654, - "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 2441, "src/renderer/src/components/automations/AutomationsPage.behavior.test.tsx": 3389, "src/renderer/src/components/automations/AutomationsPage.edit-destination.test.tsx": 2932, "src/renderer/src/components/automations/AutomationsPage.run-navigation.test.tsx": 3056, @@ -4731,7 +4728,6 @@ "src/renderer/src/components/automations/automation-host-recovery.test.ts": 51, "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 638, "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 439, - "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 231, "src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts": 449, "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 48, "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 61, @@ -5574,7 +5570,6 @@ "src/renderer/src/components/settings/ssh/SshPane.test.tsx": 1699, "src/renderer/src/components/settings/ssh/targets/SshTargetForm.test.tsx": 1014, "src/renderer/src/components/settings/ssh/targets/SshTargetList.test.tsx": 2093, - "src/renderer/src/components/settings/tasks/TaskSourceProviderCard.test.tsx": 467, "src/renderer/src/components/settings/tasks/TaskSourceSimpleSetup.test.tsx": 527, "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 549, "src/renderer/src/components/settings/terminal/appearance/TerminalContrastSetting.test.tsx": 798, @@ -5819,7 +5814,6 @@ "src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx": 1504, "src/renderer/src/components/task-page/jira/Content.board-owner.test.tsx": 2582, "src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx": 1122, - "src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx": 923, "src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx": 2192, "src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts": 69, "src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx": 2287, diff --git a/config/scripts/locale-catalog/locale-translation-policy-ko-round5.test.mjs b/config/scripts/locale-catalog/locale-translation-policy-ko-round5.test.mjs index e03157e0bc6..6d8561bcdcc 100644 --- a/config/scripts/locale-catalog/locale-translation-policy-ko-round5.test.mjs +++ b/config/scripts/locale-catalog/locale-translation-policy-ko-round5.test.mjs @@ -3,147 +3,6 @@ import { describe, expect, it } from 'vitest' import { repairTranslatedValue } from './locale-translation-policy.mjs' describe('locale-translation-policy ko round 5', () => { - it('fixes Korean round 5 review, integration, and search keyword regressions', () => { - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.workspace.status.409528031f', - enValue: 'Review', - localeValue: '검토', - locale: 'ko' - }) - ).toBe('리뷰') - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.workspace.status.6c1efa2cf8', - enValue: 'In review', - localeValue: '검토 중', - locale: 'ko' - }) - ).toBe('리뷰 중') - expect( - repairTranslatedValue({ - key: 'auto.components.TaskPage.524f095d55', - enValue: 'Needs review', - localeValue: '검토 필요', - locale: 'ko' - }) - ).toBe('리뷰 필요') - expect( - repairTranslatedValue({ - key: 'auto.components.onboarding.IntegrationsStep.277f30eb34', - enValue: - 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.', - localeValue: - 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 통합에 있습니다.', - locale: 'ko' - }) - ).toBe('Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.') - expect( - repairTranslatedValue({ - key: 'auto.components.settings.general.search.244e3fb4c8', - enValue: 'Install the Orca skill so agents know to use the Orca CLI.', - localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.', - locale: 'ko' - }) - ).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.') - expect( - repairTranslatedValue({ - key: 'auto.components.editor.MarkdownPreview.322afab6ff', - enValue: 'Review notes', - localeValue: '메모 검토', - locale: 'ko' - }) - ).toBe('리뷰 노트') - expect( - repairTranslatedValue({ - key: 'auto.components.right.sidebar.source.control.repo.icon.ecf63ec3ef', - enValue: 'Launch', - localeValue: '시작하다', - locale: 'ko' - }) - ).toBe('실행') - expect( - repairTranslatedValue({ - key: 'auto.components.GitHubItemDialog.485609c4f2', - enValue: 'check #', - localeValue: '확인하다 #', - locale: 'ko' - }) - ).toBe('체크 #') - expect( - repairTranslatedValue({ - key: 'auto.lib.automation.templates.maintenance.prompt', - enValue: - 'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.', - localeValue: - '작업 중단, 오래 생성된 파일, 유효성 검사 실패 및 사람의 주의가 필요한 모든 사항을 확인하세요. 실행 가능한 문제만 보고하세요.', - locale: 'ko' - }) - ).toBe( - '작업 중단, 오래 생성된 파일, 유효성 검사 실패 및 사람의 주의가 필요한 모든 사항을 확인하세요. 실행 가능한 이슈만 보고하세요.' - ) - expect( - repairTranslatedValue({ - key: 'auto.components.GitLabItemDialog.02cbe2de44', - enValue: 'Pipeline', - localeValue: '파이프라인', - locale: 'ko' - }) - ).toBe('파이프라인') - }) - - // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean. - it('keeps brand names English and generic workflow terms translated', () => { - expect( - repairTranslatedValue({ - key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', - enValue: 'Terminal 1', - localeValue: '터미널 1', - locale: 'ko' - }) - ).toBe('터미널 1') - expect( - repairTranslatedValue({ - key: 'auto.components.skills.SkillsPage.38e0951c3a', - enValue: 'Agent Skills', - localeValue: '에이전트 스킬', - locale: 'ko' - }) - ).toBe('에이전트 스킬') - expect( - repairTranslatedValue({ - key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', - enValue: 'Markdown', - localeValue: '가격 인하', - locale: 'ko' - }) - ).toBe('Markdown') - expect( - repairTranslatedValue({ - key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', - enValue: 'Unpushed commits', - localeValue: '푸시되지 않은 커밋', - locale: 'ko' - }) - ).toBe('푸시되지 않은 커밋') - expect( - repairTranslatedValue({ - key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', - enValue: 'Repo', - localeValue: '레포', - locale: 'ko' - }) - ).toBe('레포') - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', - enValue: 'Local project, Git repo, or folder with many repos', - localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더', - locale: 'ko' - }) - ).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더') - }) - it('re-glues Korean particles after Latin terms without gluing content words', () => { // A wrong space before the 에 particle is re-glued onto the Latin term. expect( diff --git a/config/scripts/locale-catalog/locale-translation-policy.es-round5.test.mjs b/config/scripts/locale-catalog/locale-translation-policy.es-round5.test.mjs deleted file mode 100644 index 3bf5a77fb3a..00000000000 --- a/config/scripts/locale-catalog/locale-translation-policy.es-round5.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { repairTranslatedValue } from './locale-translation-policy.mjs' - -describe('locale-translation-policy es round 5', () => { - // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish. - it('keeps brand names English and generic workflow terms translated', () => { - expect( - repairTranslatedValue({ - key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', - enValue: 'Markdown', - localeValue: 'Reducción', - locale: 'es' - }) - ).toBe('Markdown') - expect( - repairTranslatedValue({ - key: 'auto.components.TaskPage.7f3f7b4c18', - enValue: 'Description (optional, markdown)', - localeValue: 'Descripción (opcional, rebaja)', - locale: 'es' - }) - ).toBe('Descripción (opcional, markdown)') - expect( - repairTranslatedValue({ - key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', - enValue: 'Commit', - localeValue: 'Comprometerse', - locale: 'es' - }) - ).toBe('Commit') - expect( - repairTranslatedValue({ - key: 'auto.components.right.sidebar.GitHistoryPanel.cf7cad58d2', - enValue: 'No commits yet', - localeValue: 'Aún no hay compromisos', - locale: 'es' - }) - ).toBe('Aún no hay commits') - expect( - repairTranslatedValue({ - key: 'auto.components.right.sidebar.SourceControl.b94112eb9e', - enValue: 'Commit message', - localeValue: 'mensaje de confirmación', - locale: 'es' - }) - ).toBe('mensaje de confirmación') - expect( - repairTranslatedValue({ - key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', - enValue: 'Repo', - localeValue: 'repositorio', - locale: 'es' - }) - ).toBe('repositorio') - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', - enValue: 'Local project, Git repo, or folder with many repos', - localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios', - locale: 'es' - }) - ).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios') - }) -}) diff --git a/config/scripts/locale-catalog/locale-translation-policy.ja-relocalization.test.mjs b/config/scripts/locale-catalog/locale-translation-policy.ja-relocalization.test.mjs index 433a3e4b537..0ab813ce684 100644 --- a/config/scripts/locale-catalog/locale-translation-policy.ja-relocalization.test.mjs +++ b/config/scripts/locale-catalog/locale-translation-policy.ja-relocalization.test.mjs @@ -6,16 +6,6 @@ const ja = (enValue, localeValue, key = 'auto.test.relocalization') => repairTranslatedValue({ key, enValue, localeValue, locale: 'ja' }) describe('locale-translation-policy ja relocalization', () => { - it('heals generic terms the brand revert left in Latin mid-sentence', () => { - expect(ja('Close terminal?', 'この terminal を閉じますか?')).toBe('このターミナルを閉じますか?') - expect(ja('New Terminal', '新規 Terminal')).toBe('新規ターミナル') - expect(ja('Stop the agent?', 'この agent を停止しますか?')).toBe('この Agent を停止しますか?') - expect(ja('Nothing to commit.', 'commit するものはありません。')).toBe( - 'コミットするものはありません。' - ) - expect(ja('Open the repo', 'repo を開く')).toBe('リポジトリを開く') - }) - it('leaves the second word of a two-word command in Latin', () => { expect(ja('a commit hook or git commit fails', 'コミットフックまたは git commit が失敗')).toBe( 'コミットフックまたは git commit が失敗' @@ -36,21 +26,6 @@ describe('locale-translation-policy ja relocalization', () => { expect(ja('Run git commit -m', 'git commit -m を実行')).toBe('git commit -m を実行') }) - it('keeps commands and identifiers in English so they still run', () => { - expect(shouldPreserveEnglishValue('pnpm install')).toBe(true) - expect(shouldPreserveEnglishValue('glab auth login')).toBe(true) - expect(shouldPreserveEnglishValue("gh pr list --json number -q '.[0].number'")).toBe(true) - expect(shouldPreserveEnglishValue('--model sonnet')).toBe(true) - expect(shouldPreserveEnglishValue('localhost, 127.0.0.1, *.internal')).toBe(true) - expect(shouldPreserveEnglishValue('stale-agent-row-{{value0}}')).toBe(true) - expect(shouldPreserveEnglishValue('text-foreground')).toBe(true) - expect(shouldPreserveEnglishValue('{basePrompt}')).toBe(true) - expect(shouldPreserveEnglishValue('{firstPrompt}')).toBe(true) - expect(shouldPreserveEnglishValue('{assistantMessage}')).toBe(true) - expect(shouldPreserveEnglishValue('/pricing')).toBe(true) - expect(shouldPreserveEnglishValue('/signup')).toBe(true) - }) - it('repairs translated code examples back to executable values', () => { expect(ja('--model sonnet', '--モデルソネット')).toBe('--model sonnet') expect( @@ -68,78 +43,10 @@ describe('locale-translation-policy ja relocalization', () => { expect(shouldPreserveEnglishValue('e.g. {{value0}}')).toBe(false) }) - it('normalizes Japanese typography and settled terminology', () => { - expect(ja('Loading…', '読み込み中...')).toBe('読み込み中…') - expect(ja('Merge conflict', 'マージ紛争')).toBe('マージ競合') - // Why: MT inserts a Microsoft-style separator space that Japanese compounds do not use. - expect(ja('Status bar', 'ステータス バー')).toBe('ステータスバー') - expect(ja('Missing credentials', '資格情報がありません')).toBe('認証情報がありません') - expect(ja('Unknown', '未知')).toBe('不明') - expect(ja('Restart the server', 'サーバを再起動')).toBe('サーバーを再起動') - expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く') - }) - - it('preserves translated Japanese Skills labels', () => { - expect( - ja( - 'Filter by agent', - 'エージェントで絞り込み', - 'auto.components.skills.SkillsPage.filterProvider' - ) - ).toBe('エージェントで絞り込み') - }) - - it('preserves the native chat subagent label', () => { - expect(ja('Subagent', 'サブエージェント', 'components.native-chat.tool.row.subagent')).toBe( - 'サブエージェント' - ) - }) - it('leaves ranges and token samples out of the ellipsis rule', () => { expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較') }) - it('spaces the Latin terms the phrase fixes write back in', () => { - expect(ja('Clear cookies', 'クッキーを消去')).toBe('Cookie を消去') - expect(ja('Safely fast-forward the branch', 'ブランチを安全に早送りします')).toBe( - 'ブランチを安全に fast-forward します' - ) - }) - - it('uses ホスト for host, and leaves サーバー alone when English says server', () => { - expect(ja('Host path', 'サーバーパス')).toBe('ホストパス') - expect(ja('Enter a path that exists on the selected host.', 'ランタイムサーバー上のパス')).toBe( - 'ホスト上のパス' - ) - // Why: host and server are deliberately distinct wherever the English uses both. - expect(ja('Save the server host', 'サーバーを保存')).toBe('サーバーを保存') - }) - - it('restores Git vocabulary and brand names the MT localized', () => { - expect(ja('Branch upstream', 'ブランチの上流')).toBe('ブランチの upstream') - expect(ja('origin has commits', '起源にコミットがあります')).toBe('origin にコミットがあります') - expect(ja('Discard all staged changes?', '段階的な変更を破棄しますか?')).toBe( - 'ステージ済みの変更を破棄しますか?' - ) - expect(ja('Create Hermes automation', 'エルメスオートメーションを作成')).toBe( - 'Hermes オートメーションを作成' - ) - expect(ja('Delete the worktree', 'worktree を削除')).toBe('ワークツリーを削除') - }) - - it('keeps class strings, refs, and fixture filenames in English', () => { - for (const value of [ - 'size-4 text-muted-foreground', - 'text-amber-700 dark:text-amber-300', - 'origin/main', - 'upstream/main', - 'login.spec.ts', - 'bastion.example.com' - ]) { - expect(shouldPreserveEnglishValue(value)).toBe(true) - } - }) - it('preserves a selector that has no declaration block', () => { for (const value of [ 'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta', diff --git a/config/scripts/locale-catalog/locale-translation-policy.ja-round5.test.mjs b/config/scripts/locale-catalog/locale-translation-policy.ja-round5.test.mjs deleted file mode 100644 index 1955a9eef24..00000000000 --- a/config/scripts/locale-catalog/locale-translation-policy.ja-round5.test.mjs +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { repairTranslatedValue } from './locale-translation-policy.mjs' - -describe('locale-translation-policy ja round 5', () => { - it('fixes inline, launch, action, MR, and status chip regressions', () => { - expect( - repairTranslatedValue({ - key: 'auto.components.GitHubItemDialog.6e43a16435', - enValue: 'Inline', - localeValue: '列をなして', - locale: 'ja' - }) - ).toBe('インライン') - expect( - repairTranslatedValue({ - key: 'auto.components.repo.repo.icon.ecf63ec3ef', - enValue: 'Launch', - localeValue: '打ち上げ', - locale: 'ja' - }) - ).toBe('起動') - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.workspace.status.2c19d1db33', - enValue: 'Play', - localeValue: '遊ぶ', - locale: 'ja' - }) - ).toBe('再生') - expect( - repairTranslatedValue({ - key: 'auto.components.TaskPage.8396825a14', - enValue: 'Action', - localeValue: 'アクション', - locale: 'ja' - }) - ).toBe('操作') - expect( - repairTranslatedValue({ - key: 'auto.components.TaskPage.456b8512da', - enValue: 'MergeRequest', - localeValue: 'マージリクエスト', - locale: 'ja' - }) - ).toBe('MR') - expect( - repairTranslatedValue({ - key: 'auto.components.github.PRFilterDropdowns.7f1ba66c3e', - enValue: 'Reviewed by', - localeValue: 'レビュー者', - locale: 'ja' - }) - ).toBe('レビュアー') - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.workspace.status.caebe3c10f', - enValue: 'Conductor Review', - localeValue: '指揮者のレビュー', - locale: 'ja' - }) - ).toBe('Conductor レビュー') - expect( - repairTranslatedValue({ - key: 'auto.components.github.pr.merge.state.bf5e4c6c92', - enValue: 'Blocked', - localeValue: 'ブロック', - locale: 'ja' - }) - ).toBe('ブロック中') - expect( - repairTranslatedValue({ - key: 'auto.components.settings.IntegrationsPane.027440e1cb', - enValue: 'Merge requests, issues, todos, and pipelines via the', - localeValue: 'リクエスト、イシュー、ToDo、パイプラインをマージします。', - locale: 'ja' - }) - ).toBe('MR、Issue、ToDo、パイプラインは') - expect( - repairTranslatedValue({ - key: 'auto.components.GitLabItemDialog.98718490e4', - enValue: 'MR title is required.', - localeValue: 'MRの肩書きは必須です。', - locale: 'ja' - }) - ).toBe('MR タイトルは必須です。') - expect( - repairTranslatedValue({ - key: 'auto.components.settings.terminal.search.d5e6c7fab1', - enValue: 'font features', - localeValue: 'フォントの特徴', - locale: 'ja' - }) - ).toBe('フォント特性') - expect( - repairTranslatedValue({ - key: 'auto.components.GitHubItemDialog.f05d237049', - enValue: 'Add a project first', - localeValue: 'まずプロジェクトを追加してください', - locale: 'ja' - }) - ).toBe('まずプロジェクトを追加') - }) - - // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese. - it('keeps brand names English and generic workflow terms translated', () => { - expect( - repairTranslatedValue({ - key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3', - enValue: 'Terminals', - localeValue: '端末', - locale: 'ja' - }) - ).toBe('ターミナル') - expect( - repairTranslatedValue({ - key: 'auto.components.skills.SkillsPage.38e0951c3a', - enValue: 'Agent Skills', - localeValue: 'Agent のスキル', - locale: 'ja' - }) - ).toBe('Agent のスキル') - expect( - repairTranslatedValue({ - key: 'auto.components.tab.bar.TabBar.3d5d6c960d', - enValue: 'New Markdown', - localeValue: '新規マークダウン', - locale: 'ja' - }) - ).toBe('新規 Markdown') - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.local.base.ref.suggestion.toast.commits', - enValue: 'commits', - localeValue: 'コミット', - locale: 'ja' - }) - ).toBe('コミット') - expect( - repairTranslatedValue({ - key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df', - enValue: 'Repo', - localeValue: 'リポ', - locale: 'ja' - }) - ).toBe('リポ') - expect( - repairTranslatedValue({ - key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', - enValue: 'Local project, Git repo, or folder with many repos', - localeValue: - 'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー', - locale: 'ja' - }) - ).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー') - }) -}) diff --git a/src/cli/reference-command-arguments.test.ts b/src/cli/reference-command-arguments.test.ts index 6a8cb0d07a4..3cfbdf6eead 100644 --- a/src/cli/reference-command-arguments.test.ts +++ b/src/cli/reference-command-arguments.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { normalizeCommandPositionals, parseArgs, specPaths, validateCommandAndFlags } from './args' -import { buildAgentContext } from './agent-context' import { getRepeatedStringFlag } from './flags' import { COMMAND_SPECS } from './specs' @@ -35,17 +34,4 @@ describe('reference command arguments', () => { const parsed = parse(['reference', 'find', 'STA-1', 'STA-2']) expect(() => validateCommandAndFlags(COMMAND_SPECS, parsed)).toThrow('Unknown command') }) - - it('exposes repeatability to agents and excludes browser targeting', () => { - const schema = buildAgentContext(COMMAND_SPECS) - const add = schema.commands.find((command) => command.command === 'reference add') - expect(add).toMatchObject({ - positionalArgs: ['url'], - variadicPositional: true, - repeatableFlags: ['url'] - }) - expect(add?.flags).not.toContain('page') - const create = schema.commands.find((command) => command.command === 'worktree create') - expect(create?.repeatableFlags).toEqual(['reference']) - }) }) diff --git a/src/cli/runtime/serve-speech-recognizer.test.ts b/src/cli/runtime/serve-speech-recognizer.test.ts index c33fa4892fe..9111b6a6ea8 100644 --- a/src/cli/runtime/serve-speech-recognizer.test.ts +++ b/src/cli/runtime/serve-speech-recognizer.test.ts @@ -23,23 +23,6 @@ function resourcesWith(files: string[]): string { } describe("finding the app's speech engines (#31174)", () => { - it('hands orcad the node_modules a packaged app ships its sherpa recognizer in', () => { - const resources = resourcesWith(['sherpa-onnx-darwin-x64/sherpa-onnx.node']) - expect(findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'darwin', 'x64')).toBe( - join(resources, 'node_modules') - ) - }) - - it('hands orcad the node_modules holding transcribe-cpp and its native package', () => { - const resources = resourcesWith([ - 'transcribe-cpp/package.json', - '@transcribe-cpp/linux-arm64-cpu-vulkan/contract.json' - ]) - expect(findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'linux', 'arm64')).toBe( - join(resources, 'node_modules') - ) - }) - it('answers nothing when the app ships no engine for this platform', () => { const resources = resourcesWith([ 'transcribe-cpp/package.json', diff --git a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx index 6e083b2ebcd..fedae0d26ae 100644 --- a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx +++ b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx @@ -1,12 +1,11 @@ // @vitest-environment happy-dom -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_CATALOG } from '@/lib/agent-catalog/agent-catalog' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' -import { createCompatibleRuntimeStatusResponse } from '@/runtime/rpc/testing/runtime-compatibility-test-fixture' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/hosts/capabilities/local-runtime-capabilities' import { AUTOMATION_RUNTIME_CAPABILITIES } from '../../../../shared/automations/automation-runtime-capabilities' @@ -155,18 +154,6 @@ function renderSidebar( return render(
) } -function setOldHostCapabilities(capabilities: readonly string[]) { - act(() => { - const status = { - ...createCompatibleRuntimeStatusResponse().result, - capabilities: [...capabilities] - } - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([['old', { status, checkedAt: 1 }]]) - }) - }) -} - beforeEach(() => { clearNativeChatComposerDraftsForTests() setLocalRuntimeCapabilitiesForTests(AUTOMATION_RUNTIME_CAPABILITIES) @@ -183,55 +170,6 @@ function openPicker() { } describe('AutomationEditorSettingsSidebar agent picker', () => { - it('keeps agent selection usable on an older host without exposing unsupported setup', () => { - setLocalRuntimeCapabilitiesForTests([]) - const onDraftChange = vi.fn() - setOldHostCapabilities([]) - renderSidebar(onDraftChange, DRAFT, { - kind: 'runtime', - environmentId: 'old', - pairingRevision: 1 - }) - openPicker() - expect(screen.queryByRole('button', { name: /^Configure / })).toBeNull() - fireEvent.click(screen.getByRole('menuitem', { name: 'Claude' })) - expect(onDraftChange.mock.calls[0][0](DRAFT).agentId).toBe('claude') - }) - - it('offers fresh sessions for a full launch setup and preserves its choices', () => { - const changed = vi.fn() - renderSidebar(changed, { - ...DRAFT, - reuseSession: true, - launchPreferences: { model: 'gpt-5.5' } - }) - fireEvent.click(screen.getByRole('button', { name: 'Use fresh sessions' })) - const next = changed.mock.calls[0][0]({ - ...DRAFT, - reuseSession: true, - launchPreferences: { model: 'gpt-5.5' } - }) - expect(next.reuseSession).toBe(false) - expect(next.launchPreferences).toEqual({ model: 'gpt-5.5' }) - }) - - it('keeps agent and legacy editing available before selecting a destination', () => { - const changed = vi.fn() - renderSidebar(changed, { ...DRAFT, agentId: 'claude' }, null) - fireEvent.change(screen.getByLabelText('Extra agent arguments'), { - target: { value: '--add-dir docs' } - }) - openPicker() - expect(screen.queryByRole('button', { name: /^Configure / })).toBeNull() - fireEvent.click(screen.getByRole('menuitem', { name: 'Codex' })) - const next = changed.mock.calls.reduce((draft, [update]) => update(draft), { - ...DRAFT, - agentId: 'claude' - }) - expect(next.agentId).toBe('codex') - expect(next.extraAgentArgs).toBe('--add-dir docs') - }) - it('discards an unaccepted setup when the editor owner closes', () => { const onDraftChange = vi.fn() const form = renderSidebar(onDraftChange) diff --git a/src/renderer/src/components/automations/AutomationTimeField.test.tsx b/src/renderer/src/components/automations/AutomationTimeField.test.tsx index aee7d7f15c7..3a230ca8a87 100644 --- a/src/renderer/src/components/automations/AutomationTimeField.test.tsx +++ b/src/renderer/src/components/automations/AutomationTimeField.test.tsx @@ -5,33 +5,12 @@ import React from 'react' import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' -import { AutomationTimeField, resolveDigitCommit, resolveDigitStep } from './AutomationTimeField' +import { AutomationTimeField } from './AutomationTimeField' afterEach(() => { cleanup() }) -describe('AutomationTimeField helpers', () => { - it('resolves digit commits with empty → min and range clamp', () => { - expect(resolveDigitCommit('', 9, 1, 12)).toBe(1) - expect(resolveDigitCommit('', 15, 0, 59)).toBe(0) - expect(resolveDigitCommit('9', 1, 1, 12)).toBe(9) - expect(resolveDigitCommit('00', 9, 1, 12)).toBe(1) - expect(resolveDigitCommit('24', 9, 0, 23)).toBe(23) - expect(resolveDigitCommit('99', 15, 0, 59)).toBe(59) - expect(resolveDigitCommit('abc', 15, 0, 59)).toBe(15) - }) - - it('steps from in-progress text rather than only the committed value', () => { - expect(resolveDigitStep('1', 9, 1, 12, 1)).toBe(2) - // At the min bound, down wraps to max. - expect(resolveDigitStep('1', 9, 1, 12, -1)).toBe(12) - expect(resolveDigitStep('', 9, 1, 12, 1)).toBe(10) - expect(resolveDigitStep('12', 12, 1, 12, 1)).toBe(1) - expect(resolveDigitStep('0', 0, 0, 59, -1)).toBe(59) - }) -}) - function ControlledTimeField({ initialTime = '09:15', mode = 'time', diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx deleted file mode 100644 index 5d58d267389..00000000000 --- a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -// @vitest-environment happy-dom - -/** Preserve picker/search identity across loading, empty, no-match and failure states so refresh cannot erase the query/caret. */ - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import { AutomationsListPanel } from './AutomationsListPanel' -import { - buildAutomationListViewItems, - EMPTY_AUTOMATION_LIST_FILTER, - type AutomationListSort, - type AutomationListSortField -} from './automation-list-view' -import type { AutomationHostCatalogView } from './use-automation-host-catalog' -import { makeAutomationListRow } from './automations-page-fixtures' -import type { AutomationListRow } from './automation-list-row-identity' -import type { ExternalAutomationListEntry } from './external-automation-list-entries' -import type { AutomationPaneTab } from './automation-page-state' - -let container: HTMLDivElement -let root: Root - -beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => root.unmount()) - container.remove() -}) - -const HOST_CATALOG = { - catalog: { entries: [], byStableKey: new Map(), hydration: {} }, - entries: [], - resolution: { - effective: { kind: 'all' }, - entry: null, - status: 'all', - announceFallback: false - }, - rows: { - rows: [], - automations: [], - capturedOwners: new Map(), - groups: [], - answered: true - }, - loadCounts: { failedHostCount: 0, totalHostCount: 1 }, - selectHost: () => undefined, - recover: () => undefined, - refreshHosts: () => undefined, - notifyAuthorityChange: () => undefined -} as unknown as AutomationHostCatalogView - -function renderPanel( - rows: readonly AutomationListRow[], - query: string, - onQueryChange: (next: string) => void = () => undefined, - uncheckedNotice: string | null = null, - options: { - selectedRowKey?: string | null - selectedExternalKey?: string | null - onOpenDetail?: () => void - selectAutomationRow?: (key: string | null) => void - selectExternalKey?: (key: string | null) => void - externalEntries?: readonly ExternalAutomationListEntry[] - setActivePaneTab?: (tab: AutomationPaneTab) => void - listSort?: AutomationListSort | null - onListSortChange?: (field: AutomationListSortField) => void - } = {} -): void { - const externalEntries = options.externalEntries ?? [] - act(() => { - root.render( - - 0 || externalEntries.length > 0} - hasFilteredListItems={rows.length > 0 || externalEntries.length > 0} - listFilter={EMPTY_AUTOMATION_LIST_FILTER} - onListFilterChange={() => undefined} - listSearchQuery={query} - isListSearchQueryTooLarge={false} - onListSearchQueryChange={onQueryChange} - searchCounts={{ - hostRowCount: rows.length + externalEntries.length, - visibleRowCount: rows.length + externalEntries.length, - searchActive: query !== '' - }} - hostCatalog={HOST_CATALOG} - canCreateAutomation={true} - onOpenRuns={() => undefined} - externalManagersUncheckedNotice={uncheckedNotice} - onSelectHost={() => undefined} - onRecoverHost={() => undefined} - sortedListItems={buildAutomationListViewItems({ - rows, - externalEntries - })} - listSort={options.listSort ?? null} - onListSortChange={options.onListSortChange ?? (() => undefined)} - selectedRowKey={options.selectedRowKey ?? null} - selectedExternalKey={options.selectedExternalKey ?? null} - relativeNow={0} - repoMap={new Map()} - worktreeMap={new Map()} - projectHostSetups={[]} - sshConnectionStates={new Map()} - runtimeStatusByEnvironmentId={new Map()} - hostTargetFor={() => null} - isActionEnabled={() => true} - externalActionKey={null} - selectAutomationRow={options.selectAutomationRow ?? (() => undefined)} - selectExternalKey={options.selectExternalKey ?? (() => undefined)} - setActivePaneTab={options.setActivePaneTab ?? (() => undefined)} - runNow={() => undefined} - openEditDialog={() => undefined} - toggleAutomation={() => undefined} - requestDeleteAutomation={() => undefined} - requestExternalAction={() => undefined} - openCreateDialog={() => undefined} - onOpenDetail={options.onOpenDetail ?? (() => undefined)} - onRefresh={() => undefined} - isRefreshing={false} - /> - - ) - }) -} - -function searchField(): HTMLInputElement | null { - return container.querySelector('input[aria-label="Search automations"]') -} - -describe('AutomationsListPanel search field persistence', () => { - it('survives a refresh tick that momentarily empties the list', () => { - const row = makeAutomationListRow() - renderPanel([row], 'night') - const before = searchField() - before?.focus() - - renderPanel([], 'night') - - // Identity, not presence: a remount is what loses the caret and the query. - expect(searchField()).toBe(before) - expect(searchField()?.value).toBe('night') - expect(document.activeElement).toBe(before) - }) -}) diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts deleted file mode 100644 index 509f354ad20..00000000000 --- a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' - -import { automationHostRecoveryActions } from './automation-host-status-descriptors' - -function entry(overrides: Partial = {}): AutomationHostCatalogEntry { - return { - stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, - owner: null, - stableKey: 'host:desktop:ssh:t1', - label: 'web-01', - authorityLabel: 'This computer', - kind: 'ssh', - catalogState: 'authoritative', - authorityHealth: 'fresh', - executionHealth: 'connected', - querySupport: 'scoped', - ...overrides - } -} - -describe('automation host recovery actions', () => { - it('asks for a server update when the authority itself lacks host scoping', () => { - expect( - automationHostRecoveryActions( - entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' }) - ).authority - ).toBe('update-server') - expect( - automationHostRecoveryActions(entry({ authorityHealth: 'incompatible' })).authority - ).toBe('update-server') - }) - - it('never offers a server update for a host that was removed', () => { - expect( - automationHostRecoveryActions( - entry({ - catalogState: 'removed', - executionHealth: 'unavailable', - querySupport: 'legacy-unscoped', - scopeGap: 'target-removed' - }) - ) - ).toEqual({ authority: null, execution: null }) - }) - - it('offers Reconnect, not a server update, for a target with no verified registration', () => { - expect( - automationHostRecoveryActions( - entry({ - catalogState: 'unhydrated', - executionHealth: 'unknown', - querySupport: 'legacy-unscoped', - scopeGap: 'target-unverified' - }) - ) - ).toEqual({ authority: 'reconnect', execution: null }) - }) - - it('offers no repair for a live target the authority never registered', () => { - expect( - automationHostRecoveryActions( - entry({ querySupport: 'legacy-unscoped', scopeGap: 'target-unregistered' }) - ).authority - ).toBeNull() - }) -}) diff --git a/src/renderer/src/components/settings/tasks/TaskSourceProviderCard.test.tsx b/src/renderer/src/components/settings/tasks/TaskSourceProviderCard.test.tsx deleted file mode 100644 index 6ac82218483..00000000000 --- a/src/renderer/src/components/settings/tasks/TaskSourceProviderCard.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { TaskSourceProviderCard } from './TaskSourceProviderCard' - -const readiness = { - connected: false, - checking: false, - skillInstalled: false, - skillChecking: false, - visible: true -} - -let root: Root | null = null -let container: HTMLDivElement | null = null - -afterEach(async () => { - if (root) { - await act(async () => { - root?.unmount() - }) - } - root = null - container?.remove() - container = null -}) - -describe('TaskSourceProviderCard', () => { - it('stays expanded when the auto-expand target moves to another provider', async () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - const card = (defaultExpanded: boolean): React.JSX.Element => ( - } - name="Linear" - description="Linear setup" - readiness={readiness} - visible - canHide - defaultExpanded={defaultExpanded} - onToggleVisible={vi.fn()} - > - Install terminal - - ) - - await act(async () => { - root?.render(card(true)) - }) - expect(container.textContent).toContain('Install terminal') - - // A slower provider check makes another card the auto-expand target. - await act(async () => { - root?.render(card(false)) - }) - - expect(container.textContent).toContain('Install terminal') - }) -}) diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx b/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx deleted file mode 100644 index 969bc13a13e..00000000000 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters' -import { getJiraStatusCatalogScopeKey } from './task-page-jira-status-catalog' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' -afterEach(cleanup) -function model(): TaskPageComposerActionsModel { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every field read by the status-controls component. - return { - jiraProjectFilter: null, - jiraHasWorkspace: false, - selectedJiraStatuses: ['Developed'], - selectJiraStatuses: vi.fn().mockResolvedValue(undefined), - jiraQuickFiltersReady: true, - jiraQuickFiltersPending: false, - jiraQuickFiltersLoading: false, - jiraQuickFiltersError: null, - retryJiraQuickFilters: vi.fn(), - jiraStatusCatalog: null, - jiraTaskSourceScopeKey: 'jira-owner', - selectedJiraSiteId: 'site', - activeJiraPreset: 'assigned', - jiraCurrentSprint: false, - jiraSearchInput: '', - setJiraSearchInput: vi.fn(), - setAppliedJiraSearch: vi.fn(), - clearJiraSavedFilterSelection: vi.fn().mockResolvedValue(undefined), - jiraSavedFiltersPending: false - } as unknown as TaskPageComposerActionsModel -} - -it('drops prior-project discovered statuses while retaining the selected status on project change', () => { - const state = model() - state.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } - state.jiraStatusCatalog = { - scopeKey: getJiraStatusCatalogScopeKey( - state.jiraTaskSourceScopeKey, - state.selectedJiraSiteId, - state.activeJiraPreset, - state.jiraProjectFilter - ), - items: [{ name: 'Alpha only', categoryKey: 'indeterminate' }] - } - const { rerender } = render() - expect(screen.getByRole('button', { name: 'Alpha only' })).toBeDefined() - state.jiraProjectFilter = { id: '20', key: 'BET', name: 'Beta', siteId: 'site-b' } - rerender() - expect(screen.queryByRole('button', { name: 'Alpha only' })).toBeNull() - expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe( - 'true' - ) - expect(state.selectJiraStatuses).not.toHaveBeenCalled() -}) diff --git a/src/shared/plugins/artifacts/plugin-theme-artifact.test.ts b/src/shared/plugins/artifacts/plugin-theme-artifact.test.ts index 5986bcc9875..2f3c0531b6c 100644 --- a/src/shared/plugins/artifacts/plugin-theme-artifact.test.ts +++ b/src/shared/plugins/artifacts/plugin-theme-artifact.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isPluginThemeRegistration, parsePluginAppThemeArtifact } from './plugin-theme-artifact' +import { parsePluginAppThemeArtifact } from './plugin-theme-artifact' import { parsePluginTerminalThemeArtifact } from './plugin-terminal-theme-artifact' const parseApp = (value: unknown) => () => @@ -72,21 +72,6 @@ describe('plugin app theme artifacts', () => { ).tokenColors ).toEqual([{ scope: ['keyword'], settings: { foreground: '#ff79c6' } }]) }) - - it('validates registrations crossing IPC', () => { - const registration = { - id: 'plugin:samples.midnight/midnight', - revision: 'r1', - pluginKey: 'samples.midnight', - pluginName: 'Midnight', - label: 'Midnight', - base: 'dark', - tokens: { '--background': '#000000', '--foreground': '#ffffff' } - } - expect(isPluginThemeRegistration(registration)).toBe(true) - expect(isPluginThemeRegistration({ ...registration, id: 'midnight' })).toBe(false) - expect(isPluginThemeRegistration({ ...registration, tokens: { '--x': '#000' } })).toBe(false) - }) }) describe('plugin terminal theme artifacts', () => { From bca56f77765b9203e217d59b23e9ef1750e33328 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:52:00 +0000 Subject: [PATCH 023/138] feat(mobile): keep dictating after leaving the session, with a floating pill (#40421) * feat(mobile): keep dictating after leaving the session, with a floating pill * feat(mobile): translate the dictation pill and its toasts * fix(mobile): type away words without claiming the terminal, and keep the pill above screens * fix(mobile): float the pill above bottom bars, reopen the owner's own screen, and tell a refused start wherever you are * fix(mobile): float the dictation pill above the source control bar * fix(mobile): keep pill state out of the host context and reopen the starting screen first * refactor(mobile): subscribe the pill only while it shows and trim the session dictation hook * fix(mobile): key bar clearances by symbol --- mobile/app/h/_layout.tsx | 56 ++-- .../components/nav-bar/BottomSearchBar.tsx | 2 + mobile/src/dictation/MobileDictationPill.tsx | 316 ++++++++++++++++++ .../dictation/mobile-dictation-away-copy.ts | 20 ++ .../dictation/mobile-dictation-away-insert.ts | 51 +++ .../mobile-dictation-controls-copy.ts | 4 +- .../mobile-dictation-host-context.ts | 98 ++++++ .../src/dictation/mobile-dictation-host.tsx | 247 ++++++++++++++ .../dictation/mobile-dictation-leave-hold.ts | 10 +- .../src/dictation/mobile-dictation-shown.ts | 15 + .../use-mobile-dictation-destination.ts | 1 + .../dictation/use-mobile-dictation-haptics.ts | 5 +- .../hooks/mobile-dictation-session-state.ts | 3 +- mobile/src/hooks/use-mobile-dictation.ts | 32 +- mobile/src/i18n/locales/de.json | 13 + mobile/src/i18n/locales/en.json | 13 + mobile/src/i18n/locales/es.json | 13 + mobile/src/i18n/locales/fr.json | 13 + mobile/src/i18n/locales/ja.json | 13 + mobile/src/i18n/locales/ko.json | 13 + mobile/src/i18n/locales/pt.json | 13 + mobile/src/i18n/locales/ru.json | 13 + mobile/src/i18n/locales/tr.json | 13 + mobile/src/i18n/locales/zh-Hant.json | 13 + mobile/src/i18n/locales/zh.json | 13 + ...mobile-dictation-session-outcomes.test.tsx | 74 +++- .../src/session/tabs/notification-pane-tab.ts | 20 +- .../use-mobile-session-dictation-setup.ts | 72 ++++ .../session/use-mobile-session-dictation.ts | 301 +++++++++++++++++ ...se-mobile-session-native-chat-dictation.ts | 227 +------------ .../MobileSourceControlNextStepBar.tsx | 12 +- 31 files changed, 1434 insertions(+), 275 deletions(-) create mode 100644 mobile/src/dictation/MobileDictationPill.tsx create mode 100644 mobile/src/dictation/mobile-dictation-away-copy.ts create mode 100644 mobile/src/dictation/mobile-dictation-away-insert.ts create mode 100644 mobile/src/dictation/mobile-dictation-host-context.ts create mode 100644 mobile/src/dictation/mobile-dictation-host.tsx create mode 100644 mobile/src/dictation/mobile-dictation-shown.ts create mode 100644 mobile/src/session/use-mobile-session-dictation-setup.ts create mode 100644 mobile/src/session/use-mobile-session-dictation.ts diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index 85791eb3eb3..5b70ff90b8e 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -20,6 +20,8 @@ import { HostSidebarRevealContext } from '../../src/layout/host-sidebar-reveal' import { HostAreaServingContext } from '../../src/mobile-web-shell/host-area-serving' import { usePageOwnsHostArea } from '../../src/mobile-web-shell/page-owns-host-area' import { isOverlayPageRoute } from '../../src/new-workspace/overlay-page-route' +import { MobileDictationHostProvider } from '../../src/dictation/mobile-dictation-host' +import { MobileDictationPillLayer } from '../../src/dictation/MobileDictationPill' // Keep at least this much room for the detail pane when resizing the sidebar. const MIN_DETAIL_WIDTH = 320 @@ -121,33 +123,37 @@ export default function HostGroupLayout() { // Keep detail Stack at a stable tree position so fold/rotation cannot remount navigation. return ( - - {showSidebar && (sidebarOpen || foldSidebarWidth !== null) ? ( - - - {/* Edge drag handle captures Android gestures above the list. */} - {foldSidebarWidth === null ? ( - - ) : null} + {/* One dictation per host document, outliving the session screen that started it. */} + + + {showSidebar && (sidebarOpen || foldSidebarWidth !== null) ? ( + + + {/* Edge drag handle captures Android gestures above the list. */} + {foldSidebarWidth === null ? ( + + ) : null} + + ) : null} + {foldGap > 0 ? : null} + + + + + + - ) : null} - {foldGap > 0 ? : null} - - - - - - - + + ) } diff --git a/mobile/src/components/nav-bar/BottomSearchBar.tsx b/mobile/src/components/nav-bar/BottomSearchBar.tsx index 038f1bd6a3d..e56e3070e55 100644 --- a/mobile/src/components/nav-bar/BottomSearchBar.tsx +++ b/mobile/src/components/nav-bar/BottomSearchBar.tsx @@ -11,6 +11,7 @@ import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { BOTTOM_DRAWER_EASING } from '../bottom-drawer/bottom-drawer-constants' import { MobileSearchField } from '../MobileSearchField' +import { useMobileDictationClearance } from '../../dictation/mobile-dictation-host-context' const FADE_ID = 'bottom-search-bar-fade' const BUTTON_SIZE = 44 @@ -76,6 +77,7 @@ export function BottomSearchBar({ useEffect(() => { onCoveredHeightChange?.(coveredHeight) }, [coveredHeight, onCoveredHeightChange]) + useMobileDictationClearance(coveredHeight) // Close stays until the keyboard is down: iOS's late click at the tapped spot would otherwise hit the primary action that replaced it. const searching = focused || keyboardUp || (hasSearch ?? value.trim() !== '') diff --git a/mobile/src/dictation/MobileDictationPill.tsx b/mobile/src/dictation/MobileDictationPill.tsx new file mode 100644 index 00000000000..56f33ae8c50 --- /dev/null +++ b/mobile/src/dictation/MobileDictationPill.tsx @@ -0,0 +1,316 @@ +import { useContext, useEffect } from 'react' +import { Pressable, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { CircleAlert, Laptop, Smartphone } from 'lucide-react-native' +import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' +import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { themedStyleSheet } from '../theme/themed-styles' +import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { + MobileDictationGrapes, + MobileDictationTimer, + useMobileDictationElapsed +} from './MobileDictationMeter' +import { + MobileDictationCancelButton, + MobileDictationMicIcon, + MobileDictationRetryButton +} from './MobileDictationLiveControls' +import { + dictationControlsCopy, + dictationFailureReason, + dictationSpokenDuration +} from './mobile-dictation-controls-copy' +import { dictationAwayCopy } from './mobile-dictation-away-copy' +import { composerInputStyles } from '../components/composer-input/composer-input-styles' +import { + useMobileDictationHost, + useMobileDictationHostLive, + MobileDictationPillContext, + type MobileDictationHostToast +} from './mobile-dictation-host-context' + +const TOAST_MS = 4000 +// Long enough to read the target's name and reach Open. +const TOAST_WITH_OPEN_MS = 6000 + +// The meter needs a live recording; finishing and failures show words instead. +function PillStatus({ + dictation, + elapsedSeconds +}: { + dictation: UseMobileDictationResult + elapsedSeconds: number +}) { + if (dictation.failure) { + return ( + + {`${dictationFailureReason(dictation.failure)} · ${dictationControlsCopy.recordingKept()}`} + + ) + } + if (!dictation.isRecording) { + return ( + + {dictation.isStarting ? dictationAwayCopy.starting() : dictationAwayCopy.finishing()} + + ) + } + return ( + + + {dictationAwayCopy.listening()} + · + + {dictation.engine ? ( + <> + · + {dictation.engine.kind === 'phone' ? ( + + ) : ( + + )} + + ) : null} + + ) +} + +// The dictation whose target is off screen: tap to go back, ✕ and Stop work from here; a failure stays until Retry or ✕. +function MobileDictationPill({ + title, + onOpen, + onStop +}: { + title: string + onOpen: () => void + onStop: () => void +}) { + // Subscribed only while the pill shows, so a hidden pill never re-renders on the dictation's ticks. + const dictation = useMobileDictationHostLive(useMobileDictationHost()) + const { elapsedSeconds } = useMobileDictationElapsed(dictation.meter) + const failed = dictation.failure !== null + return ( + + + {failed ? ( + + ) : ( + + )} + + + {failed ? dictationAwayCopy.stopped() : title} + + + + + void dictation.cancel()} discards={failed} /> + {failed ? ( + void dictation.retry()} labelled /> + ) : ( + [ + composerInputStyles.controlButton, + pressed && composerInputStyles.pressed + ]} + onPress={onStop} + > + + + )} + + ) +} + +function MobileDictationToast({ + toast, + onDone, + bottom +}: { + toast: MobileDictationHostToast | null + onDone: () => void + bottom: number +}) { + useEffect(() => { + if (!toast) { + return + } + const timer = setTimeout(onDone, toast.open ? TOAST_WITH_OPEN_MS : TOAST_MS) + return () => clearTimeout(timer) + }, [onDone, toast]) + // The live region stays mounted so a screen reader hears the message appear. + return ( + + {toast ? ( + + + {toast.message} + + {toast.open ? ( + { + onDone() + toast.open?.() + }} + > + {dictationAwayCopy.open()} + + ) : null} + + ) : null} + + ) +} + +// Floats over every screen of the host document, above the bottom inset. +export function MobileDictationPillLayer(): React.JSX.Element | null { + useMobileColorScheme() + useMobileTranslation() + const insets = useSafeAreaInsets() + const { stop } = useMobileDictationHost() + const pill = useContext(MobileDictationPillContext) + if (!pill) { + return null + } + // Above the home indicator, or above a screen's bottom bar when it reported one. + const pillBottom = Math.max(insets.bottom + spacing.md, pill.clearance + spacing.sm) + return ( + <> + {pill.away ? ( + + + + ) : null} + + + ) +} + +const PILL_HEIGHT = 56 + +const styles = themedStyleSheet(() => ({ + // Above every screen's own floating controls, below drawers and the host's pending cover (1000). + layer: { + position: 'absolute', + left: spacing.lg, + right: spacing.lg, + zIndex: 900, + elevation: 9 + }, + pill: { + height: PILL_HEIGHT, + borderRadius: radii.pill, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingLeft: spacing.lg, + paddingRight: spacing.sm, + shadowColor: '#000', + shadowOpacity: 0.35, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 8 + }, + body: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + alignSelf: 'stretch' + }, + titles: { + flex: 1, + minWidth: 0, + gap: 1 + }, + title: { + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600' + }, + subRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 5 + }, + sub: { + color: colors.textSecondary, + fontSize: typography.metaSize, + flexShrink: 1 + }, + recDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.statusRed + }, + toastRow: { + position: 'absolute', + left: spacing.lg, + right: spacing.lg, + alignItems: 'center', + zIndex: 900, + elevation: 9 + }, + toast: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + maxWidth: 360, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.surface, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + }, + toastText: { + flexShrink: 1, + color: colors.textPrimary, + fontSize: 13 + }, + toastAction: { + color: colors.accentBlue, + fontSize: 13, + fontWeight: '600' + } +})) diff --git a/mobile/src/dictation/mobile-dictation-away-copy.ts b/mobile/src/dictation/mobile-dictation-away-copy.ts new file mode 100644 index 00000000000..a7ab5972b64 --- /dev/null +++ b/mobile/src/dictation/mobile-dictation-away-copy.ts @@ -0,0 +1,20 @@ +import { translate } from '../i18n/mobile-i18n' + +// Words for a dictation whose target is off screen: the pill and the toast that offers a way back. +export const dictationAwayCopy = { + insertedIn: ({ title, target }: { title: string; target: { field: string } | null }) => + target?.field === 'live' + ? translate('dictation.announce.typedInto', 'Typed into {{target}}', { target: title }) + : translate('dictation.announce.insertedIn', 'Inserted in {{target}}', { target: title }), + open: () => translate('dictation.pill.open', 'Open'), + listening: () => translate('dictation.pill.listening', 'Listening'), + starting: () => translate('dictation.pill.starting', 'Starting'), + finishing: () => translate('dictation.pill.finishing', 'Finishing'), + stopped: () => translate('dictation.pill.stopped', 'Dictation stopped'), + label: (target: string, duration: string) => + translate('dictation.pill.label', 'Dictation to {{target}}, {{duration}}', { + target, + duration + }), + hint: () => translate('dictation.pill.hint', 'Double-tap to go back') +} diff --git a/mobile/src/dictation/mobile-dictation-away-insert.ts b/mobile/src/dictation/mobile-dictation-away-insert.ts new file mode 100644 index 00000000000..483f08e232e --- /dev/null +++ b/mobile/src/dictation/mobile-dictation-away-insert.ts @@ -0,0 +1,51 @@ +import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { editMobileNativeChatDraft } from '../session/native-chat/composer/mobile-native-chat-draft-store' +import { terminalInputSend } from '../terminal/mobile-terminal-operations' +import { + buildTerminalSendParams, + TERMINAL_INPUT_SEND_OPTIONS +} from '../terminal/terminal-send-request' +import { insertDictationText } from './mobile-dictation-text-insertion' + +// Where a dictation's words go when its screen is gone or behind another: the chat's draft, or the terminal's input. +export type MobileDictationAwayTarget = + | { readonly field: 'chat'; readonly scope: string } + | { readonly field: 'live'; readonly handle: string } + | null + +// The draft keeps the words (never sent); a terminal gets them typed in, never Enter. False when they did not land. +export async function insertMobileDictationAway( + owner: { + readonly client: RpcClient | null + readonly target: MobileDictationAwayTarget + }, + text: string +): Promise { + const { target } = owner + if (target?.field === 'chat') { + editMobileNativeChatDraft( + target.scope, + (current) => insertDictationText(current, text, null).text + ) + return true + } + if (target?.field !== 'live' || !owner.client) { + return false + } + try { + const reply = await terminalInputSend.request( + owner.client, + buildTerminalSendParams({ + terminal: target.handle, + text, + enter: false, + // No phone identity: a phone may only claim a terminal it is watching, and this one is off screen; the words go in as plain typed input. + deviceToken: null + }), + TERMINAL_INPUT_SEND_OPTIONS + ) + return terminalInputSend.interpret(reply) === true + } catch { + return false + } +} diff --git a/mobile/src/dictation/mobile-dictation-controls-copy.ts b/mobile/src/dictation/mobile-dictation-controls-copy.ts index 8b3b7126219..4de0e3c46c2 100644 --- a/mobile/src/dictation/mobile-dictation-controls-copy.ts +++ b/mobile/src/dictation/mobile-dictation-controls-copy.ts @@ -39,7 +39,7 @@ export const dictationControlsCopy = { : translate('dictation.status.movedToComputer', 'Moved to the computer') } -function spokenDuration(elapsedSeconds: number): string { +export function dictationSpokenDuration(elapsedSeconds: number): string { const minutes = Math.floor(elapsedSeconds / 60) const count = elapsedSeconds % 60 const seconds = @@ -67,7 +67,7 @@ export function dictationMeterLabel( engine: MobileDictationEngineShown['kind'] | null, computer: string | null ): string { - const duration = spokenDuration(elapsedSeconds) + const duration = dictationSpokenDuration(elapsedSeconds) if (engine === 'phone') { return translate('dictation.controls.meterOnPhone', 'Recording, {{duration}}, on this phone', { duration diff --git a/mobile/src/dictation/mobile-dictation-host-context.ts b/mobile/src/dictation/mobile-dictation-host-context.ts new file mode 100644 index 00000000000..44fd857d028 --- /dev/null +++ b/mobile/src/dictation/mobile-dictation-host-context.ts @@ -0,0 +1,98 @@ +import { createContext, useContext, useEffect, useState, useSyncExternalStore } from 'react' +import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' +import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import type { MobileDictationAwayTarget } from './mobile-dictation-away-insert' + +export type MobileDictationHostToast = { message: string; open?: () => void } + +// Who started the dictation and where its words go, fixed at the tap: leaving the screen never moves them. +export type MobileDictationOwner = { + // The starting screen instance, and the session it shows (a reopened screen of that session takes over its controls). + readonly instance: object + readonly sessionKey: string + readonly client: RpcClient | null + readonly target: MobileDictationAwayTarget + // The target tab's title, for the pill and its toasts. + readonly title: string + // The session route that opens the target tab again when its screen is gone. + readonly href: string + // The target tab's pane key, for a screen still in the stack to switch back to it. + readonly paneKey: string | undefined + readonly dictationMode: string | undefined + readonly removeFillerWords: boolean +} + +// A mounted session screen, as the dictation host sees it. +export type MobileDictationScreen = { + readonly instance: object + readonly sessionKey: string + // The one "shown" rule (mobile-dictation-shown.ts): true while the in-place controls stand for the dictation. + readonly shown: () => boolean + // The starting screen inserts where it started (cursor, live input); false when it cannot, so the host inserts instead. + readonly insert: (text: string) => boolean + // Setup refusals open its setup sheet; other failures toast on it. + readonly reportFailure: (err: Error) => void + readonly toast: (message: string) => void + // Pop the screens above this one and show the target tab. + readonly bringToFront: (paneKey: string | undefined) => void +} + +// Stable across the dictation's ticks: screens read the live dictation through subscribe, so a word heard re-renders only who shows it. +export type MobileDictationHost = { + readonly owner: MobileDictationOwner | null + readonly start: (owner: MobileDictationOwner) => Promise + // Stop with the selection tick, as every stop button does. + readonly stop: () => void + readonly attach: (screen: MobileDictationScreen) => () => void + // A screen's shown() answer changed; the pill reads it on render. + readonly refresh: () => void + readonly subscribe: (listener: () => void) => () => void + readonly getDictation: () => UseMobileDictationResult + // The same dictation as nobody's: no status, words or failure; its identity holds until the meter or callbacks change. + readonly getIdleDictation: () => UseMobileDictationResult + // Starting, recording, finishing, or a failure kept for Retry. + readonly getUnderway: () => boolean +} + +// The floating pill's own state, apart from the host's so a bar height or toast re-renders only the pill. +export type MobileDictationPill = { + // Shown while the dictation's own screen is not. + readonly away: boolean + readonly title: string + readonly open: () => void + readonly toast: MobileDictationHostToast | null + readonly clearToast: () => void + // The tallest bottom bar a screen reported, so the pill floats above it. + readonly clearance: number +} + +export const MobileDictationHostContext = createContext(null) +export const MobileDictationPillContext = createContext(null) + +export function useMobileDictationHost(): MobileDictationHost { + const host = useContext(MobileDictationHostContext) + if (!host) { + throw new Error('useMobileDictationHost needs a MobileDictationHostProvider') + } + return host +} + +// The live dictation, re-rendering on each of its changes. +export function useMobileDictationHostLive(host: MobileDictationHost): UseMobileDictationResult { + return useSyncExternalStore(host.subscribe, host.getDictation) +} + +// Bottom bars report the height they cover, so the pill floats above them rather than over their buttons. +export const MobileDictationClearanceContext = createContext< + ((id: symbol, height: number) => void) | null +>(null) + +// A bottom bar's covered height, cleared when it unmounts; a hidden screen's bar lays out at zero. +export function useMobileDictationClearance(height: number): void { + const setClearance = useContext(MobileDictationClearanceContext) + const [id] = useState(() => Symbol('dictation-clearance')) + useEffect(() => { + setClearance?.(id, height) + }, [height, id, setClearance]) + useEffect(() => () => setClearance?.(id, 0), [id, setClearance]) +} diff --git a/mobile/src/dictation/mobile-dictation-host.tsx b/mobile/src/dictation/mobile-dictation-host.tsx new file mode 100644 index 00000000000..d7e7d33632b --- /dev/null +++ b/mobile/src/dictation/mobile-dictation-host.tsx @@ -0,0 +1,247 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useMobileDictation } from '../hooks/use-mobile-dictation' +import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' +import { useRouteHandoff } from '../navigation/route-handoff' +import { triggerError } from '../platform/haptics' +import { useClipboardWriter } from '../platform/clipboard' +import { dictationFailureMessage } from './mobile-dictation-setup' +import { useMobileDictationHaptics } from './use-mobile-dictation-haptics' +import { useMobileDictationSilenceStop } from './use-mobile-dictation-silence-stop' +import { insertMobileDictationAway } from './mobile-dictation-away-insert' +import { nativeChatDictationCopy } from './mobile-session-native-chat-dictation-copy' +import { dictationAwayCopy } from './mobile-dictation-away-copy' +import { + MobileDictationClearanceContext, + MobileDictationHostContext, + MobileDictationPillContext, + type MobileDictationHost, + type MobileDictationHostToast, + type MobileDictationOwner, + type MobileDictationScreen +} from './mobile-dictation-host-context' + +export { useMobileDictationHost } from './mobile-dictation-host-context' + +// The owner's screen; startingOnly asks only for the screen that started it. +function screenFor( + screens: ReadonlySet, + owner: MobileDictationOwner | null, + startingOnly: boolean +): MobileDictationScreen | null { + const matches = [...screens].filter((screen) => owner && screen.sessionKey === owner.sessionKey) + const starting = matches.find((screen) => screen.instance === owner?.instance) ?? null + if (startingOnly) { + return starting + } + // Else the one in front, then the one that started it, then the newest copy of its session. + return matches.find((screen) => screen.shown()) ?? starting ?? matches.at(-1) ?? null +} + +function isUnderway(dictation: UseMobileDictationResult): boolean { + return ( + dictation.isStarting || + dictation.isRecording || + dictation.isProcessing || + dictation.failure !== null + ) +} + +// One dictation per host document, above its screens, so it keeps going when its session leaves the screen. +export function MobileDictationHostProvider({ children }: { children: ReactNode }) { + const router = useRouteHandoff() + const [owner, setOwner] = useState(null) + const ownerRef = useRef(owner) + const screensRef = useRef(new Set()) + const [, setRevision] = useState(0) + const refresh = useCallback(() => setRevision((revision) => revision + 1), []) + const [toast, setToast] = useState(null) + const clearToast = useCallback(() => setToast(null), []) + const [clearances, setClearances] = useState>(new Map()) + const clipboard = useClipboardWriter() + + // Bring a dictation's target back: its screen if one is still mounted, otherwise open the session again. + const openOwner = useCallback( + (target: MobileDictationOwner) => { + const screen = screenFor(screensRef.current, target, false) + if (screen) { + screen.bringToFront(target.paneKey) + } else { + router.push(target.href) + } + }, + [router] + ) + const open = useCallback(() => { + if (ownerRef.current) { + openOwner(ownerRef.current) + } + }, [openOwner]) + + // Say it on the screen that shows the dictation, else in the host's toast (with Open for the dictation it is about). + const say = (message: string, about?: MobileDictationOwner) => { + const screen = screenFor(screensRef.current, ownerRef.current, false) + if (screen?.shown()) { + screen.toast(message) + } else { + setToast(about ? { message, open: () => openOwner(about) } : { message }) + } + } + const reportFailureRef = useRef((err: Error) => { + const screen = screenFor(screensRef.current, ownerRef.current, false) + if (screen?.shown()) { + screen.reportFailure(err) + return + } + triggerError() + setToast({ message: dictationFailureMessage(err.message) }) + }) + + const dictation = useMobileDictation({ + client: owner?.client ?? null, + enabled: true, + removeFillerWords: owner?.removeFillerWords ?? true, + onTranscript: (text) => { + const current = ownerRef.current + if (!current) { + return + } + const starting = screenFor(screensRef.current, current, true) + if (starting?.shown() && starting.insert(text)) { + return + } + void insertMobileDictationAway(current, text).then((inserted) => { + if (inserted) { + say(dictationAwayCopy.insertedIn(current), current) + return + } + triggerError() + void clipboard.writeText(text).then( + () => say(nativeChatDictationCopy.insertFailedCopied()), + () => say(nativeChatDictationCopy.insertFailed()) + ) + }) + }, + onError: (err, { kept }) => { + if (kept) { + triggerError() + } else { + reportFailureRef.current(err) + } + }, + onAudioLimitReached: () => say(nativeChatDictationCopy.stoppedAtCloudLimit()) + }) + const stop = useMobileDictationHaptics(dictation) + useMobileDictationSilenceStop(dictation, owner?.dictationMode, stop, (message) => say(message)) + + // Screens subscribe to the live dictation; the host's context only changes when its owner or pill does. + const liveRef = useRef(dictation) + const listenersRef = useRef(new Set<() => void>()) + const { meter, start: startDictation, stop: stopDictation, cancel, retry } = dictation + const idle = useMemo( + () => ({ + status: 'idle', + isStarting: false, + isRecording: false, + isProcessing: false, + transcriptSoFar: '', + error: null, + failure: null, + engine: null, + meter, + start: startDictation, + stop: stopDictation, + cancel, + retry + }), + [cancel, meter, retry, startDictation, stopDictation] + ) + const idleRef = useRef(idle) + useLayoutEffect(() => { + liveRef.current = dictation + idleRef.current = idle + for (const listener of listenersRef.current) { + listener() + } + }) + const subscribe = useCallback((listener: () => void) => { + listenersRef.current.add(listener) + return () => { + listenersRef.current.delete(listener) + } + }, []) + const getDictation = useCallback(() => liveRef.current, []) + const getIdleDictation = useCallback(() => idleRef.current, []) + const getUnderway = useCallback(() => isUnderway(liveRef.current), []) + + const start = useCallback( + async (next: MobileDictationOwner) => { + ownerRef.current = next + setOwner(next) + try { + await startDictation(next.client) + } catch (err) { + // The starting screen may already be gone, so a refused start is told wherever the user is now. + reportFailureRef.current(err instanceof Error ? err : new Error(String(err))) + } + }, + [startDictation] + ) + + const attach = useCallback( + (screen: MobileDictationScreen) => { + screensRef.current.add(screen) + refresh() + return () => { + screensRef.current.delete(screen) + refresh() + } + }, + [refresh] + ) + const setClearance = useCallback((id: symbol, height: number) => { + setClearances((current) => { + if ((current.get(id) ?? 0) === height) { + return current + } + const next = new Map(current) + if (height > 0) { + next.set(id, height) + } else { + next.delete(id) + } + return next + }) + }, []) + + const away = + owner !== null && isUnderway(dictation) && !screenFor(screensRef.current, owner, false)?.shown() + const title = owner?.title ?? '' + const clearance = Math.max(0, ...clearances.values()) + const pill = useMemo( + () => ({ away, title, open, toast, clearToast, clearance }), + [away, clearToast, clearance, open, title, toast] + ) + const value = useMemo( + () => ({ + owner, + start, + stop, + attach, + refresh, + subscribe, + getDictation, + getIdleDictation, + getUnderway + }), + [attach, getDictation, getIdleDictation, getUnderway, owner, refresh, start, stop, subscribe] + ) + return ( + + + + {children} + + + + ) +} diff --git a/mobile/src/dictation/mobile-dictation-leave-hold.ts b/mobile/src/dictation/mobile-dictation-leave-hold.ts index 790b51c796c..7aaaa16a253 100644 --- a/mobile/src/dictation/mobile-dictation-leave-hold.ts +++ b/mobile/src/dictation/mobile-dictation-leave-hold.ts @@ -7,9 +7,11 @@ export type MobileDictationLeaveHold = { hold: () => boolean } -// Leaving unmounts the screen and with it what was said, so starting, recording or transcribing holds the first leave. +// The dictation outlives its screen inside the page, but leaving the page's document ends it; that leave stops and inserts first. export function useMobileDictationLeaveHold( - dictation: UseMobileDictationResult + dictation: UseMobileDictationResult, + // True when leaving stays inside the page, where the floating pill carries the dictation on. + leaveKeepsPage: () => boolean ): MobileDictationLeaveHold { const { isStarting, isRecording, isProcessing, stop } = dictation const active = isStarting || isRecording || isProcessing @@ -20,7 +22,7 @@ export function useMobileDictationLeaveHold( } const holdsLeave = active && !held const hold = useCallback(() => { - if (!holdsLeave) { + if (!holdsLeave || leaveKeepsPage()) { return false } setHeld(true) @@ -28,7 +30,7 @@ export function useMobileDictationLeaveHold( void stop() } return true - }, [holdsLeave, isStarting, isRecording, stop]) + }, [holdsLeave, isStarting, isRecording, leaveKeepsPage, stop]) return { holdsLeave, hold } } diff --git a/mobile/src/dictation/mobile-dictation-shown.ts b/mobile/src/dictation/mobile-dictation-shown.ts new file mode 100644 index 00000000000..cc3b0a27d27 --- /dev/null +++ b/mobile/src/dictation/mobile-dictation-shown.ts @@ -0,0 +1,15 @@ +// The one rule for where a dictation's controls live: in place on its session screen, or the floating pill. +// Today the session screen counts as showing its dictation whenever it is in front, on any of its tabs; the +// words still land in the tab where the dictation started. Showing the pill on another tab too means adding +// "and the dictation's target tab is the active one" here. +export function mobileDictationShownOnScreen({ + ownedHere, + inFront +}: { + // This screen shows the session the dictation was started in. + ownedHere: boolean + // This screen is the one in front (no other screen pushed over it). + inFront: boolean +}): boolean { + return ownedHere && inFront +} diff --git a/mobile/src/dictation/use-mobile-dictation-destination.ts b/mobile/src/dictation/use-mobile-dictation-destination.ts index 7af6be82a6a..74f5633c242 100644 --- a/mobile/src/dictation/use-mobile-dictation-destination.ts +++ b/mobile/src/dictation/use-mobile-dictation-destination.ts @@ -39,6 +39,7 @@ export function useMobileDictationDestination({ ? { field: 'live', handle: terminalHandle } : null setTarget(targetRef.current) + return targetRef.current }, [chatComposerScopeKey] ) diff --git a/mobile/src/dictation/use-mobile-dictation-haptics.ts b/mobile/src/dictation/use-mobile-dictation-haptics.ts index a4853c0a04a..ae927fdd6b5 100644 --- a/mobile/src/dictation/use-mobile-dictation-haptics.ts +++ b/mobile/src/dictation/use-mobile-dictation-haptics.ts @@ -11,8 +11,9 @@ export function useMobileDictationHaptics(dictation: UseMobileDictationResult): } }, [dictation.isRecording]) + const { stop } = dictation return useCallback(() => { triggerSelection() - void dictation.stop() - }, [dictation]) + void stop() + }, [stop]) } diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts index 4b6fa76d157..4bab0bd3fcd 100644 --- a/mobile/src/hooks/mobile-dictation-session-state.ts +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -36,7 +36,8 @@ export type UseMobileDictationResult = { failure: MobileDictationKeptFailure | null // Null until recording starts on an engine. engine: MobileDictationEngineShown | null - start: () => Promise + // client: the starting session's client, when one hook serves several sessions. + start: (client?: RpcClient | null) => Promise stop: () => Promise retry: () => Promise cancel: () => Promise diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index df0d1c6134e..6fea90e2e7e 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -18,6 +18,7 @@ import { failActiveMobileDictation, retryMobileDictationOnHost } from './mobile- import { mobileDictationFailureRetryable } from './mobile-dictation-failure' import { finishMobileDictation } from './mobile-dictation-finish' import type { MobileDictationEngine } from './mobile-dictation-device-engine' +import type { RpcClient } from '../transport/rpc/rpc-client-contract' import { releaseMobileDictationEngine, startMobileDictation, @@ -175,16 +176,27 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil await finishDictation(dictationSessionErrorMessage('no-speech')) }, [finishDictation]) - const start = useCallback(async () => { - if ((!clientRef.current && !speechRef.current) || !enabledRef.current || activeIdRef.current) { - return - } - setError(null) - // A new dictation replaces a failed one's kept recording. - setKeptFailure(null) - recordingRef.current.clear() - await startMobileDictation(run, capture) - }, [capture, run, setKeptFailure]) + const start = useCallback( + async (startClient?: RpcClient | null) => { + // The caller's client, set before the next render commits it: one hook serves whichever session starts. + if (startClient !== undefined) { + clientRef.current = startClient + } + if ( + (!clientRef.current && !speechRef.current) || + !enabledRef.current || + activeIdRef.current + ) { + return + } + setError(null) + // A new dictation replaces a failed one's kept recording. + setKeptFailure(null) + recordingRef.current.clear() + await startMobileDictation(run, capture) + }, + [capture, run, setKeptFailure] + ) const retry = useCallback(async () => { const client = clientRef.current diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 71bcd9217c3..48043e5b8b3 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -1336,6 +1336,19 @@ "phoneStopped": "Auf diesem Telefon gestoppt", "stopped": "Diktat gestoppt", "recordingKept": "Aufnahme behalten" + }, + "announce": { + "typedInto": "Eingetippt in {{target}}", + "insertedIn": "Eingefügt in {{target}}" + }, + "pill": { + "open": "Öffnen", + "listening": "Hört zu", + "starting": "Startet", + "finishing": "Wird abgeschlossen", + "stopped": "Diktat gestoppt", + "label": "Diktat in {{target}}, {{duration}}", + "hint": "Doppeltippen, um zurückzukehren" } }, "files": { diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 374512c3742..46765ee3df1 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -551,6 +551,19 @@ "connectionSlow": "Connection too slow", "phoneStopped": "Stopped on this phone", "stopped": "Dictation stopped" + }, + "announce": { + "typedInto": "Typed into {{target}}", + "insertedIn": "Inserted in {{target}}" + }, + "pill": { + "open": "Open", + "listening": "Listening", + "starting": "Starting", + "finishing": "Finishing", + "stopped": "Dictation stopped", + "label": "Dictation to {{target}}, {{duration}}", + "hint": "Double-tap to go back" } }, "home": { diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index 67cf66f9ce7..158cd39f958 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -1377,6 +1377,19 @@ "phoneStopped": "Detenido en este teléfono", "stopped": "Dictado detenido", "recordingKept": "Grabación guardada" + }, + "announce": { + "typedInto": "Escrito en {{target}}", + "insertedIn": "Insertado en {{target}}" + }, + "pill": { + "open": "Abrir", + "listening": "Escuchando", + "starting": "Iniciando", + "finishing": "Terminando", + "stopped": "Dictado detenido", + "label": "Dictado en {{target}}, {{duration}}", + "hint": "Toca dos veces para volver" } }, "files": { diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index 21fe0a90068..c7f2225a033 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -1377,6 +1377,19 @@ "phoneStopped": "Arrêté sur ce téléphone", "stopped": "Dictée arrêtée", "recordingKept": "Enregistrement conservé" + }, + "announce": { + "typedInto": "Saisi dans {{target}}", + "insertedIn": "Inséré dans {{target}}" + }, + "pill": { + "open": "Ouvrir", + "listening": "Écoute", + "starting": "Démarrage", + "finishing": "Finalisation", + "stopped": "Dictée arrêtée", + "label": "Dictée vers {{target}}, {{duration}}", + "hint": "Touchez deux fois pour revenir" } }, "files": { diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 2a2efc326af..ed93625b9f0 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -1332,6 +1332,19 @@ "phoneStopped": "このスマートフォンで停止しました", "stopped": "音声入力が停止しました", "recordingKept": "録音は保持されています" + }, + "announce": { + "typedInto": "{{target}} に入力しました", + "insertedIn": "{{target}} に挿入しました" + }, + "pill": { + "open": "開く", + "listening": "聞き取り中", + "starting": "開始中", + "finishing": "仕上げ中", + "stopped": "音声入力が停止しました", + "label": "{{target}} への音声入力、{{duration}}", + "hint": "ダブルタップで戻る" } }, "files": { diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 19fec272704..108a7a20015 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -1332,6 +1332,19 @@ "phoneStopped": "이 휴대폰에서 중지됨", "stopped": "받아쓰기 중지됨", "recordingKept": "녹음 보관됨" + }, + "announce": { + "typedInto": "{{target}}에 입력됨", + "insertedIn": "{{target}}에 삽입됨" + }, + "pill": { + "open": "열기", + "listening": "듣는 중", + "starting": "시작 중", + "finishing": "마무리 중", + "stopped": "받아쓰기 중지됨", + "label": "{{target}}에 받아쓰기, {{duration}}", + "hint": "두 번 탭하여 돌아가기" } }, "files": { diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index 6c8a053835c..675f0e6c313 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -1377,6 +1377,19 @@ "phoneStopped": "Parou neste telefone", "stopped": "Ditado interrompido", "recordingKept": "Gravação mantida" + }, + "announce": { + "typedInto": "Digitado em {{target}}", + "insertedIn": "Inserido em {{target}}" + }, + "pill": { + "open": "Abrir", + "listening": "Ouvindo", + "starting": "Iniciando", + "finishing": "Finalizando", + "stopped": "Ditado interrompido", + "label": "Ditado para {{target}}, {{duration}}", + "hint": "Toque duas vezes para voltar" } }, "files": { diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 5907162dd9f..97b5c0be44f 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -1418,6 +1418,19 @@ "phoneStopped": "Остановлено на этом телефоне", "stopped": "Диктовка остановлена", "recordingKept": "Запись сохранена" + }, + "announce": { + "typedInto": "Введено в {{target}}", + "insertedIn": "Вставлено в {{target}}" + }, + "pill": { + "open": "Открыть", + "listening": "Слушаю", + "starting": "Запуск", + "finishing": "Завершение", + "stopped": "Диктовка остановлена", + "label": "Диктовка в {{target}}, {{duration}}", + "hint": "Дважды коснитесь, чтобы вернуться" } }, "files": { diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index f969c5cfbad..9fd904d49c4 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -1336,6 +1336,19 @@ "phoneStopped": "Bu telefonda durdu", "stopped": "Dikte durdu", "recordingKept": "Kayıt saklandı" + }, + "announce": { + "typedInto": "{{target}} içine yazıldı", + "insertedIn": "{{target}} içine eklendi" + }, + "pill": { + "open": "Aç", + "listening": "Dinleniyor", + "starting": "Başlatılıyor", + "finishing": "Tamamlanıyor", + "stopped": "Dikte durdu", + "label": "{{target}} için dikte, {{duration}}", + "hint": "Geri dönmek için iki kez dokunun" } }, "files": { diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index c2e66fc4f1c..6512122f33a 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -1332,6 +1332,19 @@ "phoneStopped": "已在此手機上停止", "stopped": "聽寫已停止", "recordingKept": "錄音已保留" + }, + "announce": { + "typedInto": "已輸入到 {{target}}", + "insertedIn": "已插入到 {{target}}" + }, + "pill": { + "open": "開啟", + "listening": "正在聽", + "starting": "正在開始", + "finishing": "正在完成", + "stopped": "聽寫已停止", + "label": "聽寫到 {{target}},{{duration}}", + "hint": "點兩下返回" } }, "files": { diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index bcafd951970..9982692931e 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -1332,6 +1332,19 @@ "phoneStopped": "已在此手机上停止", "stopped": "听写已停止", "recordingKept": "录音已保留" + }, + "announce": { + "typedInto": "已输入到 {{target}}", + "insertedIn": "已插入到 {{target}}" + }, + "pill": { + "open": "打开", + "listening": "正在听", + "starting": "正在开始", + "finishing": "正在完成", + "stopped": "听写已停止", + "label": "听写到 {{target}},{{duration}}", + "hint": "轻点两下返回" } }, "files": { diff --git a/mobile/src/session/mobile-dictation-session-outcomes.test.tsx b/mobile/src/session/mobile-dictation-session-outcomes.test.tsx index 1950423c351..1c906784758 100644 --- a/mobile/src/session/mobile-dictation-session-outcomes.test.tsx +++ b/mobile/src/session/mobile-dictation-session-outcomes.test.tsx @@ -1,5 +1,5 @@ // Mount the real composing hooks over native capture: refused starts and lost transcripts were in how the session hook and dictation hook meet, which isolated handler tests would miss. -import { createElement, useRef } from 'react' +import { createElement, useContext, useRef } from 'react' import { act } from 'react-test-renderer' import { createWithQueryClient } from '../test-support/query-client-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -45,7 +45,10 @@ vi.mock('expo-keep-awake', () => ({ activateKeepAwakeAsync: () => Promise.resolve(), deactivateKeepAwake: () => Promise.resolve() })) -vi.mock('expo-router', () => ({ useFocusEffect: () => {} })) +vi.mock('expo-router', () => { + const router = { canGoBack: () => false, push: () => {}, dismissTo: () => {} } + return { useFocusEffect: () => {}, useRouter: () => router, useNavigation: () => undefined } +}) vi.mock('../platform/haptics', () => ({ triggerError: () => { seen.errorHaptics += 1 @@ -108,6 +111,14 @@ vi.mock('./use-mobile-send-completion-generation', () => ({ })) import { useMobileSessionNativeChatDictation } from './use-mobile-session-native-chat-dictation' +import { + MobileDictationHostProvider, + useMobileDictationHost +} from '../dictation/mobile-dictation-host' +import { + MobileDictationPillContext, + type MobileDictationPill +} from '../dictation/mobile-dictation-host-context' type Model = ReturnType @@ -117,11 +128,21 @@ const typeIntoTerminal = async (_handle: string, text: string): Promise return true } +type Host = ReturnType & { pill: MobileDictationPill | null } + function mount( client: FakeRpcClient, sendLive: (handle: string, text: string) => Promise = typeIntoTerminal -): { current: () => Model } { - const held: { model: Model | null } = { model: null } +): { current: () => Model; host: () => Host; leave: () => void } { + const held: { model: Model | null; host: Host | null; screen: boolean } = { + model: null, + host: null, + screen: true + } + function HostProbe(): null { + held.host = { ...useMobileDictationHost(), pill: useContext(MobileDictationPillContext) } + return null + } function Probe(): null { const scope = { @@ -132,7 +153,7 @@ function mount( agentSessionHostSupport: { promptCancel: true, questionAnswers: false }, activeHandle: 't1', activeSessionTabId: 'tab1', - activeSessionTab: { id: 'tab1', type: 'terminal', terminal: 't1' }, + activeSessionTab: { id: 'tab1', type: 'terminal', terminal: 't1', title: 'Claude Code' }, diffComments: [], diffCommentsRef: useRef([]), setShowDictationSetup: (next: boolean) => { @@ -152,15 +173,25 @@ function mount( showToast: (message: string) => { seen.toasts.push(message) }, - resetLiveInputFocus: () => {} + resetLiveInputFocus: () => {}, + // Leaving this session would leave the page, so a recording holds the first leave. + router: { canGoBack: () => false } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scope carries every member this hook destructures; the rest of the session model is unreachable from the mic path. held.model = useMobileSessionNativeChatDictation(scope as never, sendLive) return null } + const tree = () => + createElement( + MobileDictationHostProvider, + null, + createElement(HostProbe), + held.screen ? createElement(Probe) : null + ) + let renderer: ReturnType | null = null act(() => { - createWithQueryClient(createElement(Probe)) + renderer = createWithQueryClient(tree()) }) return { current: () => { @@ -168,6 +199,17 @@ function mount( throw new Error('Probe did not render') } return held.model + }, + host: () => { + if (!held.host) { + throw new Error('HostProbe did not render') + } + return held.host + }, + // The session screen goes away (Back to the workspace list) while the page and its dictation stay. + leave: () => { + held.screen = false + act(() => renderer?.update(tree())) } } } @@ -203,7 +245,7 @@ async function settle( async function startRecording( rpc: FakeRpcClient, sendLive?: (handle: string, text: string) => Promise -): Promise<{ current: () => Model }> { +): Promise> { const mounted = mount(rpc, sendLive) act(() => mounted.current().handleDictationToggle()) await settle(rpc, '') @@ -322,3 +364,19 @@ describe('a dictation that ends after switching between chat and terminal', () = expect(seen.inputs).toEqual([]) }) }) + +describe('a dictation whose session screen was left', () => { + it('keeps recording, then puts its words in the chat it started in with a toast that opens it', async () => { + seen.chat = { shown: true, scope: 'chat-a' } + const rpc = createFakeRpcClient() + const mounted = await startRecording(rpc) + mounted.leave() + expect(mounted.host().getDictation().isRecording).toBe(true) + expect(mounted.host().pill?.away).toBe(true) + act(() => mounted.host().stop()) + await settle(rpc, 'ship it') + expect(seen.chatWrites).toEqual([['chat-a', 'ship it']]) + expect(mounted.host().pill?.toast?.message).toBe('Inserted in Claude Code') + expect(mounted.host().pill?.toast?.open).toBeTypeOf('function') + }) +}) diff --git a/mobile/src/session/tabs/notification-pane-tab.ts b/mobile/src/session/tabs/notification-pane-tab.ts index 4a3c6e3ed90..ca3fcdad015 100644 --- a/mobile/src/session/tabs/notification-pane-tab.ts +++ b/mobile/src/session/tabs/notification-pane-tab.ts @@ -1,4 +1,4 @@ -import { parsePaneKey } from '../../../../src/shared/workspace-layout/stable-pane-id' +import { makePaneKey, parsePaneKey } from '../../../../src/shared/workspace-layout/stable-pane-id' import { structuredAgentSessionPaneKey, structuredAgentSessionTabId @@ -22,3 +22,21 @@ export function notificationPaneTab(tabs: readonly MobileSessionTab[], paneKey: ) === paneKey) ) } + +// The pane key that opens this tab again; notificationPaneTab finds the tab from it. +export function mobileSessionTabPaneKey( + tab: MobileSessionTab | null | undefined +): string | undefined { + if (tab?.type === 'agent-session') { + return structuredAgentSessionPaneKey(structuredAgentSessionTabId(tab.sessionId), tab.sessionId) + } + if (tab?.type !== 'terminal' || !tab.leafId) { + return undefined + } + try { + return makePaneKey(tab.parentTabId ?? tab.id, tab.leafId) + } catch { + // An id the pane key grammar refuses opens the session on its current tab instead. + return undefined + } +} diff --git a/mobile/src/session/use-mobile-session-dictation-setup.ts b/mobile/src/session/use-mobile-session-dictation-setup.ts new file mode 100644 index 00000000000..3bc4aadeeab --- /dev/null +++ b/mobile/src/session/use-mobile-session-dictation-setup.ts @@ -0,0 +1,72 @@ +import { useCallback, useRef, useState } from 'react' +import { AppState } from 'react-native' +import { useFocusEffect } from 'expo-router' +import { usePhoneDictationReady } from '../hooks/use-phone-dictation-ready' +import { + dictationMicHidden, + dictationNeedsSetup, + fetchDictationSetup +} from '../dictation/mobile-dictation-setup' +import type { MobileSessionFeedbackCapabilitiesModel } from './use-mobile-session-feedback-capabilities' + +// The computer's dictation settings as this session reads them: mic shown, mode, filler switch, and whether setup is needed first. +export function useMobileSessionDictationSetup(scope: MobileSessionFeedbackCapabilitiesModel) { + const { client, connState, setShowDictationSetup, setDictationMode, setDictationEnabled } = scope + // The phone's own recognizer needs no computer setup, and chat inserts its words on the phone. + const phoneDictation = usePhoneDictationReady() + // The host's filler switch, kept from its last setup read; on until one says otherwise. + const [removeFillerWords, setRemoveFillerWords] = useState(true) + // Only a host setup read sets this; while false, the host's start refusal decides (older hosts, first tap before the read). + const dictationNeedsSetupRef = useRef(false) + + const dictationSettingsReadRef = useRef(0) + const refreshDictationMode = useCallback(async () => { + const read = ++dictationSettingsReadRef.current + void phoneDictation.refresh() + if (!client || connState !== 'connected') { + return + } + try { + const setup = await fetchDictationSetup(client) + if (read === dictationSettingsReadRef.current) { + setDictationMode(setup.dictationMode) + setRemoveFillerWords(setup.removeFillerWords !== false) + setDictationEnabled(!dictationMicHidden(setup)) + dictationNeedsSetupRef.current = dictationNeedsSetup(setup) + } + } catch { + // Keep the last settings when an older host cannot report them. + } + }, [client, connState, phoneDictation.refresh, setDictationEnabled, setDictationMode]) + + // The sheet may have changed host setup: let the host judge taps until the fresh read lands. + const closeDictationSetup = useCallback(() => { + setShowDictationSetup(false) + dictationNeedsSetupRef.current = false + void refreshDictationMode() + }, [refreshDictationMode, setShowDictationSetup]) + + useFocusEffect( + useCallback(() => { + void refreshDictationMode() + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void refreshDictationMode() + } + }) + return () => { + // Late replies from a retired connection or focus must not rebind the mic. + dictationSettingsReadRef.current += 1 + subscription.remove() + } + }, [refreshDictationMode]) + ) + + return { + phoneDictation, + removeFillerWords, + dictationNeedsSetupRef, + closeDictationSetup, + refreshDictationMode + } +} diff --git a/mobile/src/session/use-mobile-session-dictation.ts b/mobile/src/session/use-mobile-session-dictation.ts new file mode 100644 index 00000000000..5b4c6dffd25 --- /dev/null +++ b/mobile/src/session/use-mobile-session-dictation.ts @@ -0,0 +1,301 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore +} from 'react' +import { useFocusEffect, useNavigation } from 'expo-router' +import { HOST_SESSION_ROUTE } from '../navigation/host-stack-navigation' +import { useMobileSessionDictationSetup } from './use-mobile-session-dictation-setup' +import { triggerError } from '../platform/haptics' +import { useClipboardWriter } from '../platform/clipboard' +import { + useMobileDictationDestination, + useMobileDictationPreviews +} from '../dictation/use-mobile-dictation-destination' +import { + dictationFailureMessage, + isDictationSetupRequiredError +} from '../dictation/mobile-dictation-setup' +import { useMobileDictationLeaveHold } from '../dictation/mobile-dictation-leave-hold' +import { useMobileDictationHost } from '../dictation/mobile-dictation-host' +import { mobileDictationShownOnScreen } from '../dictation/mobile-dictation-shown' +import { nativeChatDictationCopy } from '../dictation/mobile-session-native-chat-dictation-copy' +import { + useWorkspaceExecutionHost, + workspaceRouteHref +} from '../navigation/workspace-execution-host' +import { mobileSessionTabPaneKey } from './tabs/notification-pane-tab' +import type { MobileSessionFeedbackCapabilitiesModel } from './use-mobile-session-feedback-capabilities' +import type { useMobileNativeChatController } from './native-chat/use-mobile-native-chat-controller' + +// The session screen's side of the page's one dictation: start it here, show it in place while this screen is in front, and insert where it started. +export function useMobileSessionDictation( + scope: MobileSessionFeedbackCapabilitiesModel, + nativeChatController: ReturnType, + sendLiveTerminalInput: (handle: string, bytes: string) => Promise +) { + const { + hostId, + worktreeId, + client, + connState, + activeHandle, + activeHandleRef, + activeSessionTab, + setShowDictationSetup, + dictationRouteContextRef, + flushPendingLiveInputBeforeExternalSend, + canSend, + showToast, + router + } = scope + const { showNativeChat, showNativeChatRef } = nativeChatController + const host = useMobileDictationHost() + const { start: startHost } = host + const executionHost = useWorkspaceExecutionHost() + const sessionKey = JSON.stringify([hostId, executionHost ?? null, worktreeId]) + const [instance] = useState(() => ({})) + const [inFront, setInFront] = useState(true) + useFocusEffect( + useCallback(() => { + setInFront(true) + return () => setInFront(false) + }, []) + ) + const shown = mobileDictationShownOnScreen({ + ownedHere: host.owner?.sessionKey === sessionKey, + inFront + }) + // Only the screen showing the dictation re-renders on its every word; the rest see one idle object and whether it is busy. + const dictation = useSyncExternalStore( + host.subscribe, + shown ? host.getDictation : host.getIdleDictation + ) + const busyElsewhere = useSyncExternalStore(host.subscribe, host.getUnderway) && !shown + // The screen's own navigation: bringing it back pops what was pushed over it, never another session's screen. + const navigation = useNavigation() + + // Share dictation error handling for start rejection and live errors so disabled host setup always opens the setup sheet. + const reportDictationFailure = useCallback( + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + if (isDictationSetupRequiredError(message)) { + setShowDictationSetup(true) + return + } + triggerError() + showToast(dictationFailureMessage(message)) + }, + [setShowDictationSetup, showToast] + ) + + const clipboard = useClipboardWriter() + // A transcript its target refused must not vanish: keep it on the clipboard and say so. + const keepUndeliveredDictation = (text: string) => { + triggerError() + void clipboard.writeText(text).then( + () => showToast(nativeChatDictationCopy.insertFailedCopied()), + () => showToast(nativeChatDictationCopy.insertFailed()) + ) + } + + const destination = useMobileDictationDestination(nativeChatController) + // Chat dictation is inserted locally; a terminal's words are typed into it live, while it is the active one. + const insertHere = (text: string): boolean => { + if (destination.landsInChat(showNativeChatRef.current)) { + if (!destination.insertInChat(text)) { + keepUndeliveredDictation(text) + } + return true + } + const routeContext = dictationRouteContextRef.current + const insertHandle = routeContext?.handle ?? activeHandleRef.current + // Another terminal is in front: the host types the words into the one the dictation started in. + if (insertHandle === null || insertHandle !== activeHandleRef.current) { + return false + } + dictationRouteContextRef.current = null + void (async () => { + const sent = + (await flushPendingLiveInputBeforeExternalSend(insertHandle)) && + (await sendLiveTerminalInput(insertHandle, text)) + // Live input lands in a terminal the keyboard may cover, so say where the words went. + if (sent) { + showToast(nativeChatDictationCopy.inserted()) + } else { + keepUndeliveredDictation(text) + } + })().catch(() => keepUndeliveredDictation(text)) + return true + } + + const navigationRef = useRef(navigation) + useLayoutEffect(() => { + navigationRef.current = navigation + }) + const screenRef = useRef({ + shown, + insert: insertHere, + reportFailure: reportDictationFailure, + showToast + }) + useLayoutEffect(() => { + screenRef.current = { + shown, + insert: insertHere, + reportFailure: reportDictationFailure, + showToast + } + }) + const { attach, refresh } = host + useEffect( + () => + attach({ + instance, + sessionKey, + shown: () => screenRef.current.shown, + insert: (text) => screenRef.current.insert(text), + reportFailure: (err) => { + dictationRouteContextRef.current = null + screenRef.current.reportFailure(err) + }, + toast: (message) => screenRef.current.showToast(message), + bringToFront: (paneKey) => { + const current = navigationRef.current + // From this screen POP_TO starts at this route (the dispatch's source), so it stops here; merge keeps its params and swaps the pane. + current?.dispatch({ + type: 'POP_TO', + payload: { + name: HOST_SESSION_ROUTE, + params: paneKey ? { paneKey } : undefined, + merge: true + }, + target: current.getState()?.key + }) + } + }), + [attach, dictationRouteContextRef, instance, sessionKey] + ) + // The pill reads shown() on the host's render. + useEffect(() => refresh(), [refresh, shown]) + + const { + phoneDictation, + removeFillerWords, + dictationNeedsSetupRef, + closeDictationSetup, + refreshDictationMode + } = useMobileSessionDictationSetup(scope) + // Starting needs the host (unless the phone hears chat itself), but a reconnect must not end a recording: the host keeps the session through it. + const dictationCanStart = showNativeChat + ? connState === 'connected' || phoneDictation.ready + : canSend + + const leaveKeepsPage = useCallback(() => router.canGoBack(), [router]) + const dictationLeaveHold = useMobileDictationLeaveHold(dictation, leaveKeepsPage) + const previews = useMobileDictationPreviews(destination, dictation, activeHandle) + + const startDictation = useCallback(() => { + if (busyElsewhere) { + // One dictation per phone: the pill for the other one is on screen. + triggerError() + return + } + if (!dictationCanStart) { + return + } + if (dictationNeedsSetupRef.current && !phoneDictation.readyRef.current) { + // Explain setup before the OS microphone prompt: mic access cannot help until the host can transcribe. + setShowDictationSetup(true) + return + } + const routeContext = activeHandle ? { handle: activeHandle } : null + dictationRouteContextRef.current = routeContext + const target = destination.captureDestination(showNativeChat, activeHandle) + const paneKey = mobileSessionTabPaneKey(activeSessionTab) + // A refused start comes back through this screen's reportFailure while it is shown, or the host's toast. + void startHost({ + instance, + sessionKey, + client, + target: + target?.field === 'chat' + ? target.scope === null + ? null + : { field: 'chat', scope: target.scope } + : target, + title: activeSessionTab?.title ?? '', + paneKey, + href: workspaceRouteHref( + `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}${paneKey ? `?paneKey=${encodeURIComponent(paneKey)}` : ''}`, + executionHost + ), + dictationMode: scope.dictationMode, + removeFillerWords + }) + }, [ + activeHandle, + activeSessionTab, + busyElsewhere, + client, + destination.captureDestination, + dictationCanStart, + dictationRouteContextRef, + executionHost, + hostId, + startHost, + instance, + phoneDictation.readyRef, + removeFillerWords, + scope.dictationMode, + sessionKey, + setShowDictationSetup, + showNativeChat, + worktreeId + ]) + + const stopDictation = host.stop + const cancelDictation = useCallback(() => { + dictationRouteContextRef.current = null + void dictation.cancel() + }, [dictationRouteContextRef, dictation]) + + const handleDictationToggle = useCallback(() => { + if (dictation.isProcessing) { + // Ignore toggles until finish settles; a tap must not discard what was said. + } else if (dictation.isStarting || dictation.isRecording) { + stopDictation() + } else { + startDictation() + } + }, [dictation, startDictation, stopDictation]) + + const handleDictationPressIn = useCallback(() => { + if (!dictation.isStarting && !dictation.isRecording && !dictation.isProcessing) { + startDictation() + } + }, [dictation, startDictation]) + + const handleDictationPressOut = useCallback(() => { + // Speech is held from the press, so a release while starting finishes once the host answers. + if (dictation.isStarting || dictation.isRecording) { + stopDictation() + } + }, [dictation, stopDictation]) + + return { + dictation, + dictationLeaveHold, + ...previews, + startDictation, + cancelDictation, + handleDictationToggle, + handleDictationPressIn, + handleDictationPressOut, + closeDictationSetup, + refreshDictationMode + } +} diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index ffd2a1e30ac..d339e2a6c63 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -1,32 +1,12 @@ -import { useEffect, useCallback, useRef, useState } from 'react' -import { AppState } from 'react-native' -import { useFocusEffect } from 'expo-router' -import { useMobileDictation } from '../hooks/use-mobile-dictation' -import { usePhoneDictationReady } from '../hooks/use-phone-dictation-ready' -import { triggerError } from '../platform/haptics' -import { useClipboardWriter } from '../platform/clipboard' -import { - useMobileDictationDestination, - useMobileDictationPreviews -} from '../dictation/use-mobile-dictation-destination' -import { - dictationFailureMessage, - dictationMicHidden, - dictationNeedsSetup, - fetchDictationSetup, - isDictationSetupRequiredError -} from '../dictation/mobile-dictation-setup' +import { useEffect } from 'react' +import { useMobileSessionDictation } from './use-mobile-session-dictation' import { useMobileNativeChatController } from './native-chat/use-mobile-native-chat-controller' -import { useMobileDictationLeaveHold } from '../dictation/mobile-dictation-leave-hold' -import { useMobileDictationHaptics } from '../dictation/use-mobile-dictation-haptics' -import { useMobileDictationSilenceStop } from '../dictation/use-mobile-dictation-silence-stop' import { useMobileNativeChatReadability } from './native-chat/transcript/use-mobile-native-chat-readability' import { useMobileNativeChatInputLease } from './native-chat/use-mobile-native-chat-input-lease' import { useMobileNativeChatSendError } from './native-chat/use-mobile-native-chat-send-error' import { mobileNativeChatScopeKey } from './native-chat/mobile-native-chat-scope-key' import { mobileNativeChatOverlayInputLockReason } from './native-chat/mobile-native-chat-overlay-lock-reason' import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation' -import { nativeChatDictationCopy } from '../dictation/mobile-session-native-chat-dictation-copy' import type { MobileSessionFeedbackCapabilitiesModel } from './use-mobile-session-feedback-capabilities' export function useMobileSessionNativeChatDictation( @@ -44,17 +24,9 @@ export function useMobileSessionNativeChatDictation( activeSessionTabId, diffComments, diffCommentsRef, - setShowDictationSetup, - setDictationMode, - dictationEnabled, - setDictationEnabled, deviceTokenRef, - dictationRouteContextRef, activeHandleRef, activeSessionTab, - flushPendingLiveInputBeforeExternalSend, - canSend, - canCompose, showToast, resetLiveInputFocus } = scope @@ -105,186 +77,10 @@ export function useMobileSessionNativeChatDictation( surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat]) }) - // Share dictation error handling for start rejection and live errors so disabled host setup always opens the setup sheet. - const reportDictationFailure = useCallback( - (err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - if (isDictationSetupRequiredError(message)) { - setShowDictationSetup(true) - return - } - triggerError() - showToast(dictationFailureMessage(message)) - }, - [setShowDictationSetup, showToast] - ) - - const clipboard = useClipboardWriter() - // A transcript its target refused must not vanish: keep it on the clipboard and say so. - const keepUndeliveredDictation = (text: string) => { - triggerError() - void clipboard.writeText(text).then( - () => showToast(nativeChatDictationCopy.insertFailedCopied()), - () => showToast(nativeChatDictationCopy.insertFailed()) - ) - } - - const destination = useMobileDictationDestination(nativeChatController) - // The phone's own recognizer needs no computer setup, and chat inserts its words on the phone. - const phoneDictation = usePhoneDictationReady() - // Starting needs the host (unless the phone hears chat itself), but a reconnect must not end a recording: the host keeps the session through it. - const dictationCanStart = showNativeChat - ? connState === 'connected' || phoneDictation.ready - : canSend - // The host's filler switch, kept from its last setup read; on until one says otherwise. - const [removeFillerWords, setRemoveFillerWords] = useState(true) - const dictation = useMobileDictation({ - client, - enabled: (showNativeChat || canCompose) && dictationEnabled, - removeFillerWords, - onTranscript: (text) => { - // Chat dictation is inserted locally; a terminal's words are typed into it live. - if (destination.landsInChat(showNativeChatRef.current)) { - if (!destination.insertInChat(text)) { - keepUndeliveredDictation(text) - } - return - } - const routeContext = dictationRouteContextRef.current - dictationRouteContextRef.current = null - const insertHandle = routeContext?.handle ?? activeHandleRef.current - void (async () => { - const sent = - insertHandle !== null && - (await flushPendingLiveInputBeforeExternalSend(insertHandle)) && - (await sendLiveTerminalInput(insertHandle, text)) - // Live input lands in a terminal the keyboard may cover, so say where the words went. - if (sent) { - showToast(nativeChatDictationCopy.inserted()) - } else { - keepUndeliveredDictation(text) - } - })().catch(() => keepUndeliveredDictation(text)) - }, - onError: (err, { kept }) => { - // A kept failure shows in place with Retry, which still needs the dictation's target. - if (kept) { - triggerError() - return - } - dictationRouteContextRef.current = null - reportDictationFailure(err) - }, - onAudioLimitReached: () => showToast(nativeChatDictationCopy.stoppedAtCloudLimit()) - }) - - const dictationLeaveHold = useMobileDictationLeaveHold(dictation) - const previews = useMobileDictationPreviews(destination, dictation, activeHandle) - - // Only a host setup read sets this; while false, the host's start refusal decides (older hosts, first tap before the read). - const dictationNeedsSetupRef = useRef(false) - - const startDictation = useCallback(() => { - if (!dictationCanStart) { - return - } - if (dictationNeedsSetupRef.current && !phoneDictation.readyRef.current) { - // Explain setup before the OS microphone prompt: mic access cannot help until the host can transcribe. - setShowDictationSetup(true) - return - } - const routeContext = activeHandle ? { handle: activeHandle } : null - dictationRouteContextRef.current = routeContext - destination.captureDestination(showNativeChat, activeHandle) - void dictation.start().catch((err) => { - if (dictationRouteContextRef.current === routeContext) { - dictationRouteContextRef.current = null - } - reportDictationFailure(err) - }) - }, [ - activeHandle, - destination.captureDestination, - dictation, - dictationCanStart, - phoneDictation.readyRef, - reportDictationFailure, - setShowDictationSetup, - showNativeChat - ]) - - const stopDictation = useMobileDictationHaptics(dictation) - useMobileDictationSilenceStop(dictation, scope.dictationMode, stopDictation, showToast) - - const cancelDictation = useCallback(() => { - dictationRouteContextRef.current = null - void dictation.cancel() - }, [dictation]) - - const handleDictationToggle = useCallback(() => { - if (dictation.isProcessing) { - // Ignore toggles until finish settles; a tap must not discard what was said. - } else if (dictation.isStarting || dictation.isRecording) { - stopDictation() - } else { - startDictation() - } - }, [dictation, startDictation, stopDictation]) - - const handleDictationPressIn = useCallback(() => { - if (!dictation.isStarting && !dictation.isRecording && !dictation.isProcessing) { - startDictation() - } - }, [dictation, startDictation]) - - const handleDictationPressOut = useCallback(() => { - // Speech is held from the press, so a release while starting finishes once the host answers. - if (dictation.isStarting || dictation.isRecording) { - stopDictation() - } - }, [dictation, stopDictation]) - - const dictationSettingsReadRef = useRef(0) - const refreshDictationMode = useCallback(async () => { - const read = ++dictationSettingsReadRef.current - void phoneDictation.refresh() - if (!client || connState !== 'connected') { - return - } - try { - const setup = await fetchDictationSetup(client) - if (read === dictationSettingsReadRef.current) { - setDictationMode(setup.dictationMode) - setRemoveFillerWords(setup.removeFillerWords !== false) - setDictationEnabled(!dictationMicHidden(setup)) - dictationNeedsSetupRef.current = dictationNeedsSetup(setup) - } - } catch { - // Keep the last settings when an older host cannot report them. - } - }, [client, connState, phoneDictation.refresh, setDictationEnabled, setDictationMode]) - - // The sheet may have changed host setup: let the host judge taps until the fresh read lands. - const closeDictationSetup = useCallback(() => { - setShowDictationSetup(false) - dictationNeedsSetupRef.current = false - void refreshDictationMode() - }, [refreshDictationMode, setShowDictationSetup]) - - useFocusEffect( - useCallback(() => { - void refreshDictationMode() - const subscription = AppState.addEventListener('change', (state) => { - if (state === 'active') { - void refreshDictationMode() - } - }) - return () => { - // Late replies from a retired connection or focus must not rebind the mic. - dictationSettingsReadRef.current += 1 - subscription.remove() - } - }, [refreshDictationMode]) + const sessionDictation = useMobileSessionDictation( + scope, + nativeChatController, + sendLiveTerminalInput ) useEffect(() => { @@ -305,16 +101,7 @@ export function useMobileSessionNativeChatDictation( toggleTabChatView, showNativeChat, showNativeChatRef, - dictation, - dictationLeaveHold, - ...previews, - startDictation, - cancelDictation, - handleDictationToggle, - handleDictationPressIn, - handleDictationPressOut, - closeDictationSetup, - refreshDictationMode + ...sessionDictation } } diff --git a/mobile/src/source-control/MobileSourceControlNextStepBar.tsx b/mobile/src/source-control/MobileSourceControlNextStepBar.tsx index 97f854bd54a..6a524717c3e 100644 --- a/mobile/src/source-control/MobileSourceControlNextStepBar.tsx +++ b/mobile/src/source-control/MobileSourceControlNextStepBar.tsx @@ -30,6 +30,7 @@ import type { MobileNextStepTone } from './mobile-source-control-next-step' import { styles as sourceControlStyles } from './mobile-source-control-styles' +import { useMobileDictationClearance } from '../dictation/mobile-dictation-host-context' const ICONS: Record = { push: ArrowUp, @@ -84,13 +85,20 @@ export function MobileSourceControlNextStepBar({ const [composerFocused, setComposerFocused] = useState(false) // The bar rides the keyboard only for its own commit box; for any other field it steps aside. const lift = composerFocused && view.kind === 'composer' ? keyboardLift : 0 - if ((view.kind === 'hidden' && !notice) || (keyboardLift > 0 && lift === 0)) { + const hidden = (view.kind === 'hidden' && !notice) || (keyboardLift > 0 && lift === 0) + const [barHeight, setBarHeight] = useState(0) + // A dictation pill floats above this bar instead of over its buttons. + useMobileDictationClearance(hidden ? 0 : barHeight + lift) + if (hidden) { return null } const reason = view.kind === 'button' ? view.reason : null return ( onHeightChange(event.nativeEvent.layout.height)} + onLayout={(event) => { + setBarHeight(event.nativeEvent.layout.height) + onHeightChange(event.nativeEvent.layout.height) + }} style={[ styles.bar, { From fd46321d85706f4d229df5f23d4164425d996ce4 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:00:13 +0000 Subject: [PATCH 024/138] feat(speech): move the speech models to transcribe-cpp and offer an update for old downloads (#39468) * feat(speech): move the speech catalog to transcribe-cpp models Parakeet v2/v3, Whisper tiny and SenseVoice now download their GGUF builds, which run on transcribe-cpp everywhere, Intel Macs included (on the CPU). A model still holding its old sherpa-onnx download shows "Update to the faster version ()"; updating replaces the old files, and dictating before then says the model needs that update. The five models with no GGUF build are retired and their users moved over: the Japanese Parakeet, the Chinese-English Zipformer and the Paraformer to SenseVoice, and the Korean and Chinese streaming Zipformers to the new Nemotron 3.5 (28 languages). Nemotron's locale codes (ko-KR) are matched from the ISO language setting. * feat(speech): offer the speech model update once after the engine change A card in the notification corner tells people whose selected on-device model needs the new download, or whose retired model was replaced, and starts the same download as Settings > Voice. After a replacement it offers to delete the retired model's files. * feat(speech): move retired Korean and Chinese models to SenseVoice and offer Whisper Large v3 Turbo On transcribe-cpp 0.4.1 with Metal, SenseVoice beat Nemotron 3.5 on Korean and Chinese FLEURS clips, so the retired Korean and Chinese streaming Zipformers now move to SenseVoice. Whisper Large v3 Turbo (Q8_0, 886 MB) joins the catalog as the most accurate choice for Japanese, Korean and Chinese. Nemotron 3.5 stays, described by the languages it does best. --- config/main-sync-fs-baseline.txt | 1 + package.json | 1 + pnpm-lock.yaml | 3 + .../runtime-mobile-dictation-controller.ts | 3 +- src/main/speech/model-catalog.ts | 69 ++++- src/main/speech/model-manager.test.ts | 26 ++ src/main/speech/model-manager.ts | 25 +- src/main/speech/speech-model-files.ts | 20 ++ .../speech/speech-model-gguf-downloads.ts | 67 ++++ src/main/speech/stt-session-start.ts | 7 +- .../speech/stt-transcribe-cpp-recognizer.ts | 10 +- .../src/app-shell/AppNotificationCards.tsx | 4 + .../dictation/SpeechModelUpdateCard.tsx | 285 ++++++++++++++++++ .../components/dictation/dictation-toasts.ts | 9 + .../speech-model-update-target.test.ts | 58 ++++ .../dictation/speech-model-update-target.ts | 37 +++ .../voice/VoiceSpeechModelSection.fixture.tsx | 59 ++++ .../voice/VoiceSpeechModelSection.tsx | 13 +- .../voice/speech-model-description.ts | 47 ++- src/renderer/src/i18n/locales/de.json | 40 ++- src/renderer/src/i18n/locales/en.json | 40 ++- src/renderer/src/i18n/locales/es.json | 40 ++- src/renderer/src/i18n/locales/fr.json | 40 ++- src/renderer/src/i18n/locales/ja.json | 40 ++- src/renderer/src/i18n/locales/ko.json | 40 ++- src/renderer/src/i18n/locales/pt.json | 40 ++- src/renderer/src/i18n/locales/ru.json | 40 ++- src/renderer/src/i18n/locales/tr.json | 40 ++- src/renderer/src/i18n/locales/zh-Hant.json | 40 ++- src/renderer/src/i18n/locales/zh.json | 40 ++- .../src/store/slices/dictation/dictation.ts | 8 +- .../slices/ui/ui-slice-contract-contextual.ts | 2 + .../slices/ui/ui-slice-hydration-actions.ts | 1 + .../store/slices/ui/ui-slice-trust-actions.ts | 9 + src/shared/dictation-language.ts | 4 + src/shared/persisted-ui-state-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 1 + src/shared/speech-model-retirement.ts | 11 +- src/shared/speech-recognizer-package.ts | 2 +- src/shared/speech-types.ts | 4 + src/shared/transcribe-cpp-packages.json | 1 + .../wsl-move/wsl-move-ui-dispositions.ts | 1 + 42 files changed, 1090 insertions(+), 140 deletions(-) create mode 100644 src/main/speech/speech-model-files.ts create mode 100644 src/main/speech/speech-model-gguf-downloads.ts create mode 100644 src/renderer/src/components/dictation/SpeechModelUpdateCard.tsx create mode 100644 src/renderer/src/components/dictation/speech-model-update-target.test.ts create mode 100644 src/renderer/src/components/dictation/speech-model-update-target.ts create mode 100644 src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx diff --git a/config/main-sync-fs-baseline.txt b/config/main-sync-fs-baseline.txt index 2b02ee27c41..6107621f3e8 100644 --- a/config/main-sync-fs-baseline.txt +++ b/config/main-sync-fs-baseline.txt @@ -496,6 +496,7 @@ 10 src/main/speech/openai-api-key-store.ts 2 src/main/speech/speech-model-download-cleanup.ts 1 src/main/speech/speech-model-download-transport.ts +1 src/main/speech/speech-model-files.ts 2 src/main/speech/speech-model-http-download.ts 2 src/main/speech/stt-gpu-failure-marker.ts 1 src/main/speech/stt-worker-model-config.ts diff --git a/package.json b/package.json index e76136786a1..6970e3cfaad 100644 --- a/package.json +++ b/package.json @@ -393,6 +393,7 @@ "@orca/windows-registry": "workspace:*", "@orca/windows-security": "workspace:*", "@transcribe-cpp/darwin-arm64-metal": "0.4.1", + "@transcribe-cpp/darwin-x64-cpu": "0.4.1", "@transcribe-cpp/linux-arm64-cpu-vulkan": "0.4.1", "@transcribe-cpp/linux-x64-cpu-vulkan": "0.4.1", "@transcribe-cpp/win32-x64-cpu-vulkan": "0.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3051c0d9d1a..153d8340ac6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -705,6 +705,9 @@ importers: '@transcribe-cpp/darwin-arm64-metal': specifier: 0.4.1 version: 0.4.1 + '@transcribe-cpp/darwin-x64-cpu': + specifier: 0.4.1 + version: 0.4.1 '@transcribe-cpp/linux-arm64-cpu-vulkan': specifier: 0.4.1 version: 0.4.1 diff --git a/src/main/runtime/speech/runtime-mobile-dictation-controller.ts b/src/main/runtime/speech/runtime-mobile-dictation-controller.ts index 567cb5bd230..0413eb9e043 100644 --- a/src/main/runtime/speech/runtime-mobile-dictation-controller.ts +++ b/src/main/runtime/speech/runtime-mobile-dictation-controller.ts @@ -1,5 +1,6 @@ import { getDefaultVoiceSettings } from '../../../shared/constants' import { MOBILE_DICTATION_RECONNECT_GRACE_MS } from '../../../shared/rpc-contract/speech-params' +import { speechModelNotReadyError } from '../../../shared/speech-model-retirement' import { getSpeechModelManager, getSpeechSttService } from '../../speech/speech-runtime-service' import { getCatalogModel, isLocalSpeechModel } from '../../speech/model-catalog' import { @@ -58,7 +59,7 @@ export class RuntimeMobileDictationController { } const modelState = await getSpeechModelManager(store).getModelState(modelId) if (modelState.status !== 'ready') { - throw new Error(`voice_model_not_ready:${modelState.status}`) + throw new Error(speechModelNotReadyError(modelState)) } const clientId = params.clientId if (!clientId) { diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index 719230346cb..aaf558927ae 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -1,9 +1,15 @@ import type { SpeechModelManifest } from '../../shared/speech-types' import { DICTATION_LANGUAGES, + NEMOTRON_DICTATION_LANGUAGES, SENSE_VOICE_DICTATION_LANGUAGES } from '../../shared/dictation-language' import { getSpeechModelDownloadMetadata } from './model-download-catalog' +import { getGgufSpeechModelDownload } from './speech-model-gguf-downloads' + +const legacy = (id: Parameters[0]) => ({ + legacyDownloadFiles: getSpeechModelDownloadMetadata(id).downloadFiles +}) export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { @@ -14,10 +20,10 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'transducer', provider: 'local', language: 'multilingual', - ...getSpeechModelDownloadMetadata('parakeet-tdt-0.6b-v3-int8'), + ...getGgufSpeechModelDownload('parakeet-tdt-0.6b-v3-int8'), + ...legacy('parakeet-tdt-0.6b-v3-int8'), sampleRate: 16000, streaming: false, - modelingUnit: 'bpe', recommended: true }, { @@ -28,10 +34,10 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'transducer', provider: 'local', language: 'en', - ...getSpeechModelDownloadMetadata('parakeet-tdt-0.6b-v2-int8'), + ...getGgufSpeechModelDownload('parakeet-tdt-0.6b-v2-int8'), + ...legacy('parakeet-tdt-0.6b-v2-int8'), sampleRate: 16000, - streaming: false, - modelingUnit: 'bpe' + streaming: false }, { id: 'zipformer-bilingual-zh-en', @@ -43,7 +49,9 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ ...getSpeechModelDownloadMetadata('zipformer-bilingual-zh-en'), sampleRate: 16000, streaming: true, - modelingUnit: 'cjkchar+bpe' + modelingUnit: 'cjkchar+bpe', + // transcribe-cpp has no zipformer; SenseVoice covers Chinese and English. + replacedBy: 'sense-voice-zh-en-ja-ko-yue' }, { id: 'paraformer-bilingual-zh-en', @@ -55,7 +63,9 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ language: 'zh-en', ...getSpeechModelDownloadMetadata('paraformer-bilingual-zh-en'), sampleRate: 16000, - streaming: true + streaming: true, + // transcribe-cpp has no paraformer; SenseVoice covers Chinese and English. + replacedBy: 'sense-voice-zh-en-ja-ko-yue' }, { id: 'zipformer-streaming-en-20m', @@ -81,7 +91,9 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ ...getSpeechModelDownloadMetadata('zipformer-streaming-zh-14m'), sampleRate: 16000, streaming: true, - modelingUnit: 'cjkchar' + modelingUnit: 'cjkchar', + // transcribe-cpp has no zipformer; SenseVoice beat Nemotron 3.5 on Korean and Chinese. + replacedBy: 'sense-voice-zh-en-ja-ko-yue' }, { id: 'zipformer-streaming-korean', @@ -93,7 +105,9 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ ...getSpeechModelDownloadMetadata('zipformer-streaming-korean'), sampleRate: 16000, streaming: true, - modelingUnit: 'bpe' + modelingUnit: 'bpe', + // transcribe-cpp has no zipformer; SenseVoice beat Nemotron 3.5 on Korean and Chinese. + replacedBy: 'sense-voice-zh-en-ja-ko-yue' }, { id: 'parakeet-tdt-ctc-0.6b-ja-int8', @@ -104,7 +118,9 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ language: 'ja', ...getSpeechModelDownloadMetadata('parakeet-tdt-ctc-0.6b-ja-int8'), sampleRate: 16000, - streaming: false + streaming: false, + // No GGUF conversion exists; SenseVoice covers Japanese. + replacedBy: 'sense-voice-zh-en-ja-ko-yue' }, { id: 'whisper-tiny', @@ -113,7 +129,21 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'whisper', provider: 'local', language: 'multilingual', - ...getSpeechModelDownloadMetadata('whisper-tiny'), + ...getGgufSpeechModelDownload('whisper-tiny'), + ...legacy('whisper-tiny'), + dictationLanguages: DICTATION_LANGUAGES, + sampleRate: 16000, + streaming: false + }, + { + id: 'whisper-large-v3-turbo', + label: 'Whisper Large v3 Turbo', + description: + 'Best accuracy for Japanese, Korean, and Chinese, and 90+ languages. Slower than Parakeet.', + type: 'whisper', + provider: 'local', + language: 'multilingual', + ...getGgufSpeechModelDownload('whisper-large-v3-turbo'), dictationLanguages: DICTATION_LANGUAGES, sampleRate: 16000, streaming: false @@ -126,11 +156,26 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'senseVoice', provider: 'local', language: 'multilingual', - ...getSpeechModelDownloadMetadata('sense-voice-zh-en-ja-ko-yue'), + ...getGgufSpeechModelDownload('sense-voice-zh-en-ja-ko-yue'), + ...legacy('sense-voice-zh-en-ja-ko-yue'), dictationLanguages: SENSE_VOICE_DICTATION_LANGUAGES, sampleRate: 16000, streaming: false }, + { + id: 'nemotron-3.5-asr-streaming-0.6b', + label: 'Nemotron 3.5', + description: + 'English, Spanish, German and many more languages, with automatic language detection.', + type: 'transducer', + provider: 'local', + language: 'multilingual', + ...getGgufSpeechModelDownload('nemotron-3.5-asr-streaming-0.6b'), + dictationLanguages: NEMOTRON_DICTATION_LANGUAGES, + sampleRate: 16000, + // Decoded per chunk for now; live text arrives with the streaming engine path. + streaming: false + }, { id: 'openai-gpt-transcribe', label: 'GPT Transcribe', diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts index d374b2b0bc0..5767578b0fa 100644 --- a/src/main/speech/model-manager.test.ts +++ b/src/main/speech/model-manager.test.ts @@ -134,6 +134,32 @@ describe('ModelManager', () => { } }) + it('reports a model downloaded in the old sherpa-onnx format as needing an update', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) + const modelDir = manager.getModelDir('parakeet-tdt-0.6b-v2-int8') + mkdirSync(modelDir, { recursive: true }) + for (const [name, size] of [ + ['encoder.int8.onnx', 652_184_296], + ['decoder.int8.onnx', 7_257_753], + ['joiner.int8.onnx', 1_739_080], + ['tokens.txt', 9_384] + ] as const) { + writeFileSync(join(modelDir, name), '') + truncateSync(join(modelDir, name), size) + } + + await expect(manager.getModelState('parakeet-tdt-0.6b-v2-int8')).resolves.toEqual({ + id: 'parakeet-tdt-0.6b-v2-int8', + status: 'not-downloaded', + needsUpdate: true + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('aborts an in-flight model download request when cancelled', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) try { diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index f3d8d896588..522e4525891 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync } from 'node:fs' import { rename, rm } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' import type { @@ -14,6 +14,7 @@ import { type SpeechModelCacheDir } from './model-cache-path' import { SpeechModelDownloadTransport } from './speech-model-download-transport' +import { hasSpeechModelFiles } from './speech-model-files' import type { OpenSpeechDownloadRequest } from './speech-model-download-response' import { getAppEnvironment } from '../../shared/app-environment' import { @@ -109,11 +110,14 @@ export class ModelManager extends SpeechModelDownloadTransport { } const modelDir = this.getModelDir(modelId) - if (existsSync(modelDir) && this.validateModelFiles(manifest, modelDir)) { + if (existsSync(modelDir) && hasSpeechModelFiles(manifest.downloadFiles, modelDir)) { const state: SpeechModelState = { id: modelId, status: 'ready' } this.modelStates.set(modelId, state) return state } + if (existsSync(modelDir) && hasSpeechModelFiles(manifest.legacyDownloadFiles, modelDir)) { + return { id: modelId, status: 'not-downloaded', needsUpdate: true } + } return { id: modelId, status: 'not-downloaded' } } @@ -136,19 +140,6 @@ export class ModelManager extends SpeechModelDownloadTransport { return modelDir } - private validateModelFiles(manifest: SpeechModelManifest, modelDir: string): boolean { - if (!manifest.downloadFiles) { - return false - } - return manifest.downloadFiles.every(({ name, sizeBytes }) => { - try { - return statSync(join(modelDir, name)).size === sizeBytes - } catch { - return false - } - }) - } - async downloadModel(modelId: string): Promise { // Do not await migration: downloads cannot race it, and cancellation needs synchronous setup. if (this.activeDownloads.has(modelId)) { @@ -167,7 +158,7 @@ export class ModelManager extends SpeechModelDownloadTransport { } const modelDir = this.getModelDir(modelId) - if (existsSync(modelDir) && this.validateModelFiles(manifest, modelDir)) { + if (existsSync(modelDir) && hasSpeechModelFiles(manifest.downloadFiles, modelDir)) { this.updateState(modelId, 'ready') return } @@ -331,7 +322,7 @@ export class ModelManager extends SpeechModelDownloadTransport { completedBytes += file.sizeBytes } - if (!this.validateModelFiles(manifest, stagingDir)) { + if (!hasSpeechModelFiles(manifest.downloadFiles, stagingDir)) { throw new Error('Model files missing after download') } } diff --git a/src/main/speech/speech-model-files.ts b/src/main/speech/speech-model-files.ts new file mode 100644 index 00000000000..7e454c142b1 --- /dev/null +++ b/src/main/speech/speech-model-files.ts @@ -0,0 +1,20 @@ +import { statSync } from 'node:fs' +import { join } from 'node:path' +import type { SpeechModelDownloadFile } from '../../shared/speech-types' + +/** Whether every listed file is in `modelDir` at its expected size. */ +export function hasSpeechModelFiles( + files: readonly SpeechModelDownloadFile[] | undefined, + modelDir: string +): boolean { + if (!files) { + return false + } + return files.every(({ name, sizeBytes }) => { + try { + return statSync(join(modelDir, name)).size === sizeBytes + } catch { + return false + } + }) +} diff --git a/src/main/speech/speech-model-gguf-downloads.ts b/src/main/speech/speech-model-gguf-downloads.ts new file mode 100644 index 00000000000..c8b1c9c0c2a --- /dev/null +++ b/src/main/speech/speech-model-gguf-downloads.ts @@ -0,0 +1,67 @@ +import type { SpeechModelDownloadFile } from '../../shared/speech-types' + +type GgufDownload = readonly [ + repository: string, + revision: string, + name: string, + sizeBytes: number, + sha256: string +] + +// handy-computer's GGUF conversions for transcribe-cpp 0.4.x, pinned by revision and hash. +const GGUF_DOWNLOADS = { + 'parakeet-tdt-0.6b-v3-int8': [ + 'handy-computer/parakeet-tdt-0.6b-v3-gguf', + '85ac09ea12fc4b1112fa76810059364bc6adc9de', + 'parakeet-tdt-0.6b-v3-Q8_0.gguf', + 739_508_576, + '5859f77944efcd8eafa23a6350731960b2b55b2203df51f319665c807d802cc7' + ], + 'parakeet-tdt-0.6b-v2-int8': [ + 'handy-computer/parakeet-tdt-0.6b-v2-gguf', + '07cee0616125a08ef619729bb47f40ef747e4bc4', + 'parakeet-tdt-0.6b-v2-Q8_0.gguf', + 729_574_912, + 'f0d0e99cebb6d3b83f1f7069b82b5d3c2e39a54545b0da039cb4bafd9c4e5caa' + ], + 'whisper-tiny': [ + 'handy-computer/whisper-tiny-gguf', + '6687f30c99641ee265df421e582354adbc8848fc', + 'whisper-tiny-Q8_0.gguf', + 45_981_088, + '325b9c7997cd1eff81ef709d55766565e71be696130cc3a3d444713798706834' + ], + 'whisper-large-v3-turbo': [ + 'handy-computer/whisper-large-v3-turbo-gguf', + '5eaf945c7978e564bae5b28a5b1639dd93c2bfb1', + 'whisper-large-v3-turbo-Q8_0.gguf', + 886_381_760, + 'b2e30cc286bc9f3aba4db9099fc7403543497c05ce7100d0d83091ddfd25a183' + ], + 'sense-voice-zh-en-ja-ko-yue': [ + 'handy-computer/SenseVoiceSmall-gguf', + '4a08b8e900b38a977e32eb08d5d0697d6e72ba04', + 'SenseVoiceSmall-Q8_0.gguf', + 252_684_608, + '6c759ee4c9748c9b3f7a5a60ca74f0f7e685fb9d45d1378fce7cfd62f59adf29' + ], + 'nemotron-3.5-asr-streaming-0.6b': [ + 'handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf', + '6d44e540bc31b0de1dbe174a3cea87f53a7f22fb', + 'nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf', + 751_094_240, + 'b94545b313b3223fda7b2857a52681da813935c2127643d1e9ff0c23d988089c' + ] +} satisfies Record + +export type GgufSpeechModelId = keyof typeof GGUF_DOWNLOADS + +export function getGgufSpeechModelDownload(modelId: GgufSpeechModelId): { + downloadFiles: SpeechModelDownloadFile[] + files: string[] + sizeBytes: number +} { + const [repository, revision, name, sizeBytes, sha256] = GGUF_DOWNLOADS[modelId] + const url = `https://huggingface.co/${repository}/resolve/${revision}/${encodeURIComponent(name)}?download=true` + return { downloadFiles: [{ name, url, sizeBytes, sha256 }], files: [name], sizeBytes } +} diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index 10d074b319a..04af95f835e 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -1,3 +1,4 @@ +import { SPEECH_MODEL_NEEDS_UPDATE_ERROR } from '../../shared/speech-model-retirement' import { engineDictationLanguage } from '../../shared/dictation-language' import type { SpeechModelManifest } from '../../shared/speech-types' import { getCatalogModel } from './model-catalog' @@ -124,7 +125,11 @@ async function requireModelReady(state: SttSessionState, modelId: string): Promi const modelState = await state.modelManager.getModelState(modelId) if (modelState.status !== 'ready') { await teardownIdleSttEngine(state, modelId) - throw new Error(`Model not ready: ${modelState.status}`) + throw new Error( + modelState.needsUpdate + ? SPEECH_MODEL_NEEDS_UPDATE_ERROR + : `Model not ready: ${modelState.status}` + ) } } diff --git a/src/main/speech/stt-transcribe-cpp-recognizer.ts b/src/main/speech/stt-transcribe-cpp-recognizer.ts index 5230e3a3446..dddd649d07d 100644 --- a/src/main/speech/stt-transcribe-cpp-recognizer.ts +++ b/src/main/speech/stt-transcribe-cpp-recognizer.ts @@ -45,11 +45,15 @@ export async function createTranscribeCppRecognizer( // The child runs one call at a time, so every stream can share one session. const session = model.createSession({ nThreads: performanceCoreThreads() }) const vocabulary = await readVocabulary(init.hotwordsFilePath) + const { languages } = model.capabilities + // The setting is ISO-639-1; Nemotron names locales (ko-KR) and rejects the bare code. + const language = init.language + ? (languages.find((code) => code === init.language) ?? + languages.find((code) => code.startsWith(`${init.language}-`))) + : undefined const options: TranscribeCpp.TranscribeOptions = { timestamps: 'none', - ...(init.language && model.capabilities.languages.includes(init.language) - ? { language: init.language } - : {}), + ...(language ? { language } : {}), ...(vocabulary.length > 0 && model.supports('vocabulary') ? { vocabulary } : {}) } const modelRate = model.capabilities.nativeSampleRate || init.sampleRate diff --git a/src/renderer/src/app-shell/AppNotificationCards.tsx b/src/renderer/src/app-shell/AppNotificationCards.tsx index ea36809b65e..3db3a25dfea 100644 --- a/src/renderer/src/app-shell/AppNotificationCards.tsx +++ b/src/renderer/src/app-shell/AppNotificationCards.tsx @@ -4,6 +4,7 @@ import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { CodexSharedSettingsNoticeCard } from '../components/CodexSharedSettingsNoticeCard' import { NotificationCardStack } from '../components/NotificationCardStack' import { StarNagCard } from '../components/StarNagCard' +import { SpeechModelUpdateCard } from '../components/dictation/SpeechModelUpdateCard' import { OverlayBoundary } from './app-root-surface-boundaries' const ChatUiHereCornerCard = lazy(() => @@ -59,6 +60,9 @@ export function AppNotificationCards({ + + + ) } diff --git a/src/renderer/src/components/dictation/SpeechModelUpdateCard.tsx b/src/renderer/src/components/dictation/SpeechModelUpdateCard.tsx new file mode 100644 index 00000000000..c7ae7f642a8 --- /dev/null +++ b/src/renderer/src/components/dictation/SpeechModelUpdateCard.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Check, CircleAlert, Download, Mic, X } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { useTranslate } from '@/i18n/use-translate' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { Card } from '../ui/card' +import { Button } from '../ui/button' +import { Progress } from '../ui/progress' +import { + findSpeechModelUpdateTarget, + type SpeechModelUpdateTarget +} from './speech-model-update-target' + +const DONE_AUTO_HIDE_MS = 4000 + +function useSpeechModelUpdateTarget(): SpeechModelUpdateTarget | null { + // Why no hydration check: the flag defaults to true until the persisted value arrives. + const seen = useAppStore((s) => s.speechModelUpdateNoticeSeen) + const voice = useAppStore((s) => s.settings?.voice) + const [target, setTarget] = useState(null) + + useEffect(() => { + // Why skip paired web clients: the models live on the host, whose own window shows this. + if (seen || !voice || isPairedWebClientWindow()) { + return + } + let cancelled = false + void Promise.all([window.api.speech.getCatalog(), window.api.speech.getModelStates()]) + .then(([catalog, states]) => { + if (cancelled) { + return + } + const store = useAppStore.getState() + store.setModelStates(states) + setTarget(findSpeechModelUpdateTarget(voice, catalog, states)) + // Why mark on the first check, shown or not: the card is for the first launch after the engine change. + store.markSpeechModelUpdateNoticeSeen() + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [seen, voice]) + + return target +} + +/** One-time card offering the download the transcribe-cpp engine needs for the selected model. */ +export function SpeechModelUpdateCard(): React.JSX.Element | null { + const target = useSpeechModelUpdateTarget() + const [closed, setClosed] = useState(false) + const close = useCallback(() => setClosed(true), []) + if (!target || closed) { + return null + } + return +} + +function SpeechModelUpdateCardBody({ + target: { model, retired }, + onClose +}: { + target: SpeechModelUpdateTarget + onClose: () => void +}): React.JSX.Element { + const t = useTranslate() + const state = useAppStore((s) => s.modelStates.find((m) => m.id === model.id)) + const refreshModelStates = useAppStore((s) => s.refreshModelStates) + const [failedAtMb, setFailedAtMb] = useState(null) + const [deleting, setDeleting] = useState(false) + const lastMbRef = useRef(0) + + // The Settings row starts the same download, so both follow the shared model states. + useEffect( + () => window.api.speech.onDownloadProgress(() => void refreshModelStates()), + [refreshModelStates] + ) + + const totalMb = Math.round((model.sizeBytes ?? 0) / 1_000_000) + const downloading = state?.status === 'downloading' || state?.status === 'extracting' + const percent = Math.round((state?.progress ?? 0) * 100) + const doneMb = Math.round((state?.progress ?? 0) * totalMb) + const phase = downloading + ? 'downloading' + : state?.status === 'ready' + ? 'done' + : failedAtMb !== null + ? 'failed' + : 'notice' + + useEffect(() => { + if (downloading) { + lastMbRef.current = doneMb + } + }, [downloading, doneMb]) + + useEffect(() => { + if (phase !== 'done' || retired) { + return + } + const timer = window.setTimeout(onClose, DONE_AUTO_HIDE_MS) + return () => window.clearTimeout(timer) + }, [phase, retired, onClose]) + + const startDownload = (): void => { + setFailedAtMb(null) + lastMbRef.current = 0 + void window.api.speech + .downloadModel(model.id) + .catch(() => setFailedAtMb(lastMbRef.current)) + .finally(() => void refreshModelStates()) + } + + const deleteRetired = (): void => { + if (!retired) { + return + } + setDeleting(true) + void window.api.speech + .deleteModel(retired.id) + .then(() => { + void refreshModelStates() + onClose() + }) + .catch(() => { + setDeleting(false) + toast.error( + t('speechModelUpdate.deleteFailed', 'Failed to delete {{model}}.', { + model: retired.label + }) + ) + }) + } + + const names = { model: model.label, old: retired?.label ?? '', size: totalMb } + const later = ( + + ) + let icon = + let title: string + let description: string + let actions: React.ReactNode = null + + if (phase === 'downloading') { + icon = + title = retired + ? t('speechModelUpdate.downloadingTitle', 'Downloading {{model}}', names) + : t('speechModelUpdate.updatingTitle', 'Updating {{model}}', names) + description = t( + 'speechModelUpdate.downloadingDescription', + 'You can keep working. Dictation is back when this finishes.' + ) + actions = ( + + ) + } else if (phase === 'done') { + icon = + title = t('speechModelUpdate.doneTitle', 'Dictation is ready') + description = retired + ? t( + 'speechModelUpdate.doneRetiredDescription', + '{{model}} is set up. The old {{old}} files are still on disk.', + names + ) + : t( + 'speechModelUpdate.doneDescription', + '{{model}} is updated. The old files were removed.', + names + ) + actions = retired ? ( + <> + + + + ) : null + } else if (phase === 'failed') { + icon = + title = retired + ? t('speechModelUpdate.downloadFailedTitle', "Couldn't download {{model}}", names) + : t('speechModelUpdate.updateFailedTitle', "Couldn't update {{model}}", names) + description = failedAtMb + ? t( + 'speechModelUpdate.failedAtDescription', + 'The download stopped at {{mb}} MB. Check your connection and try again.', + { mb: failedAtMb } + ) + : t( + 'speechModelUpdate.failedDescription', + 'The download stopped. Check your connection and try again.' + ) + actions = ( + <> + {later} + + + ) + } else { + title = retired + ? t('speechModelUpdate.replacedTitle', 'Your speech model was replaced') + : t('speechModelUpdate.updateTitle', 'Update your speech model') + description = retired + ? t( + 'speechModelUpdate.replacedDescription', + "{{old}} can't run on the new speech engine. {{model}} replaces it ({{size}} MB).", + names + ) + : t( + 'speechModelUpdate.updateDescription', + 'Dictation now runs on a faster engine. {{model}} needs a one-time download ({{size}} MB) before you can dictate again.', + names + ) + actions = ( + <> + {later} + + + ) + } + + return ( + +
+
+
{icon}
+
+

+ {title} +

+

{description}

+
+ +
+ {phase === 'downloading' && ( +
+ +
+ + {t('speechModelUpdate.progress', '{{done}} of {{total}} MB', { + done: doneMb, + total: totalMb + })} + + {percent}% +
+
+ )} + {actions &&
{actions}
} +
+
+ ) +} diff --git a/src/renderer/src/components/dictation/dictation-toasts.ts b/src/renderer/src/components/dictation/dictation-toasts.ts index a196f050220..4d16b528431 100644 --- a/src/renderer/src/components/dictation/dictation-toasts.ts +++ b/src/renderer/src/components/dictation/dictation-toasts.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { compactIpcErrorMessage } from '@/lib/ipc-error' import { hostDictationErrorMessage } from '../../../../shared/host-dictation-error-messages' +import { SPEECH_MODEL_NEEDS_UPDATE_ERROR } from '../../../../shared/speech-model-retirement' export function openVoiceSettings(): void { useAppStore.getState().openSettingsTarget({ pane: 'voice', repoId: null }) @@ -137,6 +138,14 @@ export function showDictationStartErrorToast(message: string): void { 'Microphone access denied. Grant access in system settings, then restart Orca.' ) ) + } else if (message.includes(SPEECH_MODEL_NEEDS_UPDATE_ERROR)) { + toast( + translate( + 'auto.components.dictation.DictationController.modelNeedsUpdate', + 'Your speech model needs an update to keep working. Update it to the faster version in Settings > Voice.' + ), + { action: openVoiceSettingsAction() } + ) } else if (kind === 'model-not-ready') { toast( translate( diff --git a/src/renderer/src/components/dictation/speech-model-update-target.test.ts b/src/renderer/src/components/dictation/speech-model-update-target.test.ts new file mode 100644 index 00000000000..aa89f1f7983 --- /dev/null +++ b/src/renderer/src/components/dictation/speech-model-update-target.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types' +import { findSpeechModelUpdateTarget } from './speech-model-update-target' + +function model(id: string, extra: Partial = {}): SpeechModelManifest { + return { + id, + label: id, + description: '', + type: 'transducer', + provider: 'local', + language: 'en', + sampleRate: 16000, + streaming: false, + ...extra + } +} + +const catalog = [ + model('parakeet'), + model('nemotron'), + model('zipformer-ko', { replacedBy: 'nemotron' }), + model('gpt', { provider: 'openai' }) +] + +function target(sttModel: string, states: SpeechModelState[], enabled = true) { + const found = findSpeechModelUpdateTarget({ enabled, sttModel }, catalog, states) + return found && { model: found.model.id, retired: found.retired?.id } +} + +describe('findSpeechModelUpdateTarget', () => { + it('offers the update when only the old files of the selected model are on disk', () => { + const states: SpeechModelState[] = [ + { id: 'parakeet', status: 'not-downloaded', needsUpdate: true } + ] + expect(target('parakeet', states)).toEqual({ model: 'parakeet', retired: undefined }) + expect(target('parakeet', states, false)).toBeNull() + }) + + it('offers the replacement when the retired model it replaced is still downloaded', () => { + const states: SpeechModelState[] = [ + { id: 'nemotron', status: 'not-downloaded' }, + { id: 'zipformer-ko', status: 'ready' } + ] + expect(target('nemotron', states)).toEqual({ model: 'nemotron', retired: 'zipformer-ko' }) + expect( + target('nemotron', [ + { id: 'nemotron', status: 'ready' }, + { id: 'zipformer-ko', status: 'ready' } + ]) + ).toBeNull() + }) + + it('stays hidden for cloud models and for people who never downloaded one', () => { + expect(target('gpt', [{ id: 'zipformer-ko', status: 'ready' }])).toBeNull() + expect(target('nemotron', [{ id: 'nemotron', status: 'not-downloaded' }])).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dictation/speech-model-update-target.ts b/src/renderer/src/components/dictation/speech-model-update-target.ts new file mode 100644 index 00000000000..4b948a07b7d --- /dev/null +++ b/src/renderer/src/components/dictation/speech-model-update-target.ts @@ -0,0 +1,37 @@ +import type { + SpeechModelManifest, + SpeechModelState, + VoiceSettings +} from '../../../../shared/speech-types' + +export type SpeechModelUpdateTarget = { + /** The model to download: the selection itself. */ + model: SpeechModelManifest + /** The retired model the selection replaced on load, still on disk. */ + retired?: SpeechModelManifest +} + +/** Whether the engine change left the selected local model needing a download, and why. */ +export function findSpeechModelUpdateTarget( + voice: Pick, + catalog: readonly SpeechModelManifest[], + states: readonly SpeechModelState[] +): SpeechModelUpdateTarget | null { + const model = catalog.find((m) => m.id === voice.sttModel) + if (!voice.enabled || !model || model.provider !== 'local') { + return null + } + const stateOf = (id: string): SpeechModelState | undefined => states.find((s) => s.id === id) + const selected = stateOf(model.id) + if (selected?.needsUpdate) { + return { model } + } + if (selected?.status === 'ready') { + return null + } + // Settings load already moved the selection off the retired id, so find it by its files. + const retired = catalog.find( + (m) => m.replacedBy === model.id && stateOf(m.id)?.status === 'ready' + ) + return retired ? { model, retired } : null +} diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx new file mode 100644 index 00000000000..4a93339066f --- /dev/null +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx @@ -0,0 +1,59 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { getDefaultVoiceSettings } from '../../../../../shared/constants' +import type { SpeechModelManifest } from '../../../../../shared/speech-types' +import { VoiceSpeechModelSection } from './VoiceSpeechModelSection' + +function model(id: string, label: string, sizeBytes: number, description: string) { + return { + id, + label, + description, + type: 'transducer', + provider: 'local', + language: 'en', + sizeBytes, + sampleRate: 16000, + streaming: false + } satisfies SpeechModelManifest +} + +const catalog = [ + model('v3', 'Parakeet TDT v3', 739_508_576, 'Highest accuracy for 25 European languages.'), + model( + 'v2', + 'Parakeet TDT v2', + 729_574_912, + 'English only. Faster than v3 with similar accuracy.' + ), + model( + 'nemotron', + 'Nemotron 3.5', + 751_094_240, + '28 languages including Korean, Chinese and Japanese.' + ) +] + +export const updateAvailable = defineFixture({ + width: 640, + render: () => ( + {}} + onOpenCloudSetup={() => {}} + onRefreshModelStates={() => {}} + /> + ), + interact: (frame) => { + frame + .querySelector('button') + ?.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' }) + ) + } +}) diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx index 30424f99bf1..aabb070722e 100644 --- a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx @@ -93,6 +93,8 @@ export function VoiceSpeechModelSection({ const isDownloading = mState?.status === 'downloading' || mState?.status === 'extracting' const isActive = voiceSettings.sttModel === manifest.id + // Only the old sherpa-onnx files are on disk; downloading replaces them. + const needsUpdate = mState?.needsUpdate === true && !isDownloading const deletePending = pendingDeleteModelIds.has(manifest.id) const sizeMb = manifest.sizeBytes ? Math.round(manifest.sizeBytes / 1_000_000) : null @@ -165,8 +167,17 @@ export function VoiceSpeechModelSection({

{speechModelDescription(manifest)}

+ {needsUpdate && ( +

+ {t( + 'auto.components.settings.VoicePane.updateToFasterVersion', + 'Update to the faster version ({{value0}} MB)', + { value0: sizeMb } + )} +

+ )} - {!isCloud && isReady ? ( + {!isCloud && (isReady || needsUpdate) ? ( ` - : `Open` - document.body.append(frame) - }, - { sandbox, destination, kind } - ) -} - -async function exerciseSandboxControls( - path: CreationPath, - kind: 'script' | 'link' -): Promise[]> { - const page = guest('probe-one') - await configure(page, path) - const records: Record[] = [] - for (const sandbox of ['restricted', 'escape', 'unsandboxed']) { - const destination = `${origin}/sandbox-${path}-${kind}-${sandbox}` - const offset = status().adapterProbe?.length ?? 0 - await installSandboxFrame(page, sandbox, destination, kind) - const open = page.frameLocator('#sandbox-probe').locator('#open') - await waitForHiddenFrameInput(open) - await open.click(kind === 'link' ? { modifiers: ['Shift'] } : {}) - const child = await childAt(destination) - expect(await child.evaluate('window.opener === null')).toBe(true) - const before = receipts.length - await child.evaluate((action) => { - const form = document.createElement('form') - form.action = action - form.method = 'POST' - document.body.append(form) - form.submit() - }, `${origin}/sandbox-form-${path}-${kind}-${sandbox}`) - if (sandbox === 'restricted') { - await expect - .poll(() => eventsSince(offset).some((event) => event.event === 'sandbox-form-blocked')) - .toBe(true) - expect( - receipts.slice(before).some((receipt) => receipt.path.startsWith('/sandbox-form-')) - ).toBe(false) - expect(child.url()).toBe(destination) - } else { - await expect - .poll(() => - receipts.slice(before).some((receipt) => receipt.path.startsWith('/sandbox-form-')) - ) - .toBe(true) - } - await page.evaluate('console.log("orca-popup-fixture:snapshot")') - const creation = eventsSince(offset).find( - (event) => event.event === (path === 'adopted' ? 'createWindow' : 'did-create-window') - ) - if (!creation) { - throw new Error('Missing creation event') - } - records.push(creation) - expect(status().windowsHidden).toBe(true) - snapshots.push(status()) - await closeChildren(page) - } - return records -} diff --git a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-adapter-probe-fixture.ts b/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-adapter-probe-fixture.ts deleted file mode 100644 index bd50bc01288..00000000000 --- a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-adapter-probe-fixture.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { app, BrowserWindow } from 'electron' -import type { Session, WebContents } from 'electron' -import { openPopupWithOriginBar } from '../presentation/popup-origin-bar-window' -import type { PopupChildWindowOptions } from '../presentation/browser-popup-window-contract' -import { allowNativeBrowserPopup } from '../presentation/browser-popup-native-window' - -export class BrowserPopupAdapterProbeFixture { - readonly events: Record[] = [] - private sequence = 0 - private readonly unrelatedWindows = new Set() - private readonly observedSessions = new WeakSet() - private readonly observedContents = new Set() - - constructor(private readonly writeStatus: () => void) { - app.on('browser-window-created', (_event, window) => { - this.record('browser-window-created', window.webContents) - }) - app.on('web-contents-created', (_event, contents) => { - this.record('web-contents-created', contents) - contents.once('will-frame-navigate', () => this.record('will-frame-navigate', contents)) - contents.once('did-start-navigation', () => this.record('did-start-navigation', contents)) - contents.on('console-message', ({ message }) => { - if (message.includes('Blocked form submission')) { - this.record('sandbox-form-blocked', contents, { message }) - } - }) - }) - } - - private record(event: string, contents: WebContents, extra: Record = {}): void { - const preferences = - 'getLastWebPreferences' in contents && typeof contents.getLastWebPreferences === 'function' - ? contents.getLastWebPreferences() - : null - this.events.push({ - event, - contentsId: contents.id, - url: contents.getURL(), - opener: contents.opener?.top?.url ?? null, - frameName: contents.mainFrame.name, - frameOrigin: contents.mainFrame.origin, - preferences, - ...extra - }) - this.writeStatus() - } - - install(guest: WebContents): void { - if (!this.observedSessions.has(guest.session)) { - this.observedSessions.add(guest.session) - guest.session.webRequest.onBeforeRequest((details, callback) => { - this.events.push({ - event: 'before-request', - contentsId: details.webContentsId, - url: details.url, - observedBeforeRequest: - details.webContentsId !== undefined && this.observedContents.has(details.webContentsId), - webRtcPolicy: details.webContents?.getWebRTCIPHandlingPolicy() - }) - this.writeStatus() - callback({}) - }) - } - let adopted = true - let unrelatedDuringAdmission = false - guest.on('console-message', ({ message }) => { - if (message === 'orca-popup-fixture:probe-native') { - adopted = false - } - if (message === 'orca-popup-fixture:probe-adopted') { - adopted = true - } - if (message === 'orca-popup-fixture:probe-unrelated') { - unrelatedDuringAdmission = true - } - if (message === 'orca-popup-fixture:snapshot') { - this.writeStatus() - } - if (message === 'orca-popup-fixture:probe-cleanup') { - for (const window of this.unrelatedWindows) { - if (!window.isDestroyed()) { - window.destroy() - } - } - this.unrelatedWindows.clear() - } - }) - guest.on('did-create-window', (window, details) => { - this.record('did-create-window', window.webContents, { - owner: guest.id, - optionsArguments: details.options.webPreferences?.additionalArguments, - optionsPreferences: details.options.webPreferences - }) - }) - guest.setWindowOpenHandler((details) => { - const request = ++this.sequence - this.events.push({ event: 'admission', request, owner: guest.id, details }) - this.writeStatus() - if (unrelatedDuringAdmission) { - unrelatedDuringAdmission = false - const response = allowNativeBrowserPopup({ - opener: guest, - options: { show: false }, - onCreated: ({ nativeWindow }) => { - if (!nativeWindow) { - throw new Error('Missing native child') - } - this.record('production-adapter-created', nativeWindow.webContents, { - request, - owner: guest.id - }) - return nativeWindow.webContents - } - }) - const unrelated = new BrowserWindow({ - show: false, - webPreferences: { session: guest.session } - }) - this.unrelatedWindows.add(unrelated) - this.record('unrelated-window', unrelated.webContents, { request }) - unrelated.webContents.once('destroyed', () => - this.events.push({ event: 'unrelated-destroyed', request }) - ) - return response - } - const options = { - show: false, - webPreferences: { - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - session: guest.session, - additionalArguments: [`--orca-popup-probe=${request}`] - } - } - return { - action: 'allow', - overrideBrowserWindowOptions: options, - ...(adopted - ? { - createWindow: (childOptions: PopupChildWindowOptions) => { - const child = childOptions.webContents - if (child) { - this.record('createWindow', child, { - callbackRequest: request, - owner: guest.id, - optionsPreferences: childOptions.webPreferences - }) - } else { - this.events.push({ - event: 'createWindow-missing', - request, - owner: guest.id, - optionsPreferences: childOptions.webPreferences - }) - } - let contents: WebContents - if (child) { - contents = openPopupWithOriginBar( - { ...childOptions, show: false }, - details.url, - () => true - ).contentWebContents - } else { - const window = new BrowserWindow({ ...childOptions, show: false }) - this.unrelatedWindows.add(window) - contents = window.webContents - } - this.observe(contents, guest.id) - this.writeStatus() - return contents - } - } - : {}) - } - }) - // Observe creation without replacing Chromium's navigation. - guest.prependListener('did-create-window', (window) => - this.observe(window.webContents, guest.id) - ) - } - - private observe(contents: WebContents, owner: number): void { - contents.setWebRTCIPHandlingPolicy('disable_non_proxied_udp') - this.observedContents.add(contents.id) - this.record('observer-attached', contents, { owner }) - contents.once('did-start-navigation', () => - this.record('first-navigation', contents, { owner }) - ) - contents.once('did-finish-load', () => this.record('loaded', contents, { owner })) - } -} diff --git a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-cdp-fixture.ts b/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-cdp-fixture.ts index 742e76bdbed..5f975d1f148 100644 --- a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-cdp-fixture.ts +++ b/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-cdp-fixture.ts @@ -88,9 +88,7 @@ const server = createServer((request, response) => { } response.setHeader('Content-Type', 'text/html') response.end( - ['/local', '/route', '/local-control', '/route-control', '/probe-one', '/probe-two'].includes( - path - ) + ['/local', '/route', '/local-control', '/route-control'].includes(path) ? popupFixtureHtml(origin) : `Hostile synthetic title${path}` ) @@ -168,8 +166,7 @@ beforeAll(async () => { ORCA_BACKGROUND_LAUNCH: '1', ORCA_POPUP_FIXTURE_ORIGIN: origin, ORCA_POPUP_FIXTURE_STATUS: statusPath, - ORCA_POPUP_FIXTURE_NATIVE_CONTROLS: nativeControls ? '1' : '0', - ORCA_POPUP_FIXTURE_ADAPTER_PROBE: adapterProbe ? '1' : '0' + ORCA_POPUP_FIXTURE_NATIVE_CONTROLS: nativeControls ? '1' : '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -190,12 +187,7 @@ beforeAll(async () => { return current.ready === true }) expect(status()).toMatchObject({ ready: true, windowsHidden: true }) - const names = [ - 'local', - 'route', - ...(nativeControls ? ['local-control', 'route-control'] : []), - ...(adapterProbe ? ['probe-one', 'probe-two'] : []) - ] + const names = ['local', 'route', ...(nativeControls ? ['local-control', 'route-control'] : [])] for (const name of names) { await waitForFixture(() => Boolean( @@ -283,10 +275,6 @@ export async function closeChildren(page: Page): Promise { let evidenceName = 'electron-evidence.json' let nativeControls = false -let adapterProbe = false -export function enablePopupAdapterProbe(): void { - adapterProbe = true -} export function enablePopupFixtureNativeControls(): void { nativeControls = true } diff --git a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-electron-fixture.ts b/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-electron-fixture.ts index 4f8a7f81e70..bfd564a762f 100644 --- a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-electron-fixture.ts +++ b/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-electron-fixture.ts @@ -21,7 +21,6 @@ import type { BrowserRouteGuestLifecycleClaim, BrowserRoutePageGuestIdentity } from '../../../routing/guest-authority/browser-route-page-authority' -import { BrowserPopupAdapterProbeFixture } from './browser-popup-adapter-probe-fixture' const origin = process.env.ORCA_POPUP_FIXTURE_ORIGIN const statusPath = process.env.ORCA_POPUP_FIXTURE_STATUS @@ -50,10 +49,6 @@ let fixtureAuthority: symbol | null = authority let fixtureAuthorityThrows = false let revision = 0 let failure: string | undefined -const adapterProbe = - process.env.ORCA_POPUP_FIXTURE_ADAPTER_PROBE === '1' - ? new BrowserPopupAdapterProbeFixture(() => writeStatus()) - : null const native = new BrowserPopupNativeFixture({ routeSession: () => fixtureRouteSession, failAuthority: (throwOnRead) => { @@ -91,7 +86,6 @@ function writeStatus(error?: string): void { blocked, inputs, electronVersion: process.versions.electron, - adapterProbe: adapterProbe?.events, windowsHidden: BaseWindow.getAllWindows().every((window) => !window.isVisible()), guestIds: Object.fromEntries([...guests].map(([name, guest]) => [name, guest.id])) }) @@ -127,11 +121,7 @@ async function createHost(name: string, partition: string, nativeControl = false writeStatus() }) if (nativeControl) { - if (adapterProbe) { - adapterProbe.install(guest) - } else { - native.installControlHandler(guest) - } + native.installControlHandler(guest) await guest.loadURL(`${fixtureOrigin}/${name}`) resolve() return @@ -265,10 +255,6 @@ async function run(): Promise { await createHost('local-control', localPartition, true) await createHost('route-control', routePartition, true) } - if (adapterProbe) { - await createHost('probe-one', localPartition, true) - await createHost('probe-two', localPartition, true) - } ready = true writeStatus() } diff --git a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-status.ts b/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-status.ts index 446732becea..5d42166dc50 100644 --- a/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-status.ts +++ b/src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility-status.ts @@ -6,7 +6,6 @@ type PopupFixtureSecurity = { export type PopupCompatibilityStatus = { electronVersion: string - adapterProbe?: Record[] revision: number ready?: boolean error?: string diff --git a/src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts b/src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts index e3d42272402..567a3ad3c39 100644 --- a/src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts +++ b/src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts @@ -43,13 +43,12 @@ afterAll(() => { } }) -type ProbeArm = 'clean' | 'late-session-setter' | 'mobile' | 'mixed-mobile' | 'native' +type ProbeArm = 'clean' type ProbeResult = Readonly<{ arm: ProbeArm rawUserAgent: string cleanUserAgent: string - mobileUserAgent: string sessionUserAgent: string navigatorUserAgent: string fallbackAfterReadyNameChange: string @@ -96,24 +95,6 @@ describe('browser session wire identity under Electron', () => { expect(distinctUserAgents(result.cdpRequests)).toEqual([result.cleanUserAgent]) expect(distinctJavaScriptUserAgents(result.identities)).toEqual([result.cleanUserAgent]) }, 40_000) - - it('keeps the process-native identity across documents, frames, and workers', async () => { - const result = await runProbe('native') - assertCoverage(result) - expect(identityForContext(result.identities, 'document').userAgent).toBe(result.rawUserAgent) - expect(identityForContext(result.identities, 'blob').userAgent).toBe(result.rawUserAgent) - expect(identityForContext(result.identities, 'shared-worker').userAgent).toBe( - result.rawUserAgent - ) - expect(identityForContext(result.identities, 'service-worker').userAgent).toBe( - result.rawUserAgent - ) - expect(userAgentForPath(result.receipts, '/')).toBe(result.rawUserAgent) - expect(userAgentForPath(result.receipts, '/blob-fetch')).toBe(result.rawUserAgent) - expect(userAgentForPath(result.receipts, '/shared-worker-fetch-a')).toBe(result.rawUserAgent) - expect(userAgentForPath(result.receipts, '/service-worker-fetch')).toBe(result.rawUserAgent) - expect(userAgentForPath(result.receipts, '/no-header-fill')).toBe(result.rawUserAgent) - }, 40_000) }) async function runProbe(arm: ProbeArm): Promise { @@ -238,11 +219,8 @@ const arm = ${JSON.stringify(options.arm)} const startupMarks = [] app.setName('OrcaWireIdentityFixture') const preReadyNativeUserAgent = app.userAgentFallback -let identity -if (arm !== 'late-session-setter') { - identity = processIdentity.initializeBrowserProcessUserAgent(arm === 'native' ? 'native' : 'clean') - startupMarks.push('fallback') -} +processIdentity.initializeBrowserProcessUserAgent('clean') +startupMarks.push('fallback') const waitForBarrier = async () => { const deadline = Date.now() + 15000 while (!existsSync(${JSON.stringify(options.barrierPath)})) { @@ -265,47 +243,22 @@ async function run() { await waitForBarrier() const sess = session.fromPartition('persist:wire-identity-test') startupMarks.push('session') - const rawUserAgent = arm === 'clean' ? preReadyNativeUserAgent : app.userAgentFallback - const cleanUserAgent = identity?.cleanUserAgent ?? cleanElectronUserAgent(rawUserAgent) - if (arm === 'late-session-setter') sess.setUserAgent(cleanUserAgent) + const rawUserAgent = preReadyNativeUserAgent + const cleanUserAgent = cleanElectronUserAgent(rawUserAgent) sess.setCertificateVerifyProc((_request, callback) => callback(0)) - const chromeVersion = cleanUserAgent.match(/Chrome\/([\d.]+)/)?.[1] || process.versions.chrome - const major = chromeVersion.split('.')[0] - const mobileUserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/' + chromeVersion + ' Mobile/15E148 Safari/604.1' - let mainWebContentsId - if (arm === 'clean' || arm === 'mobile' || arm === 'mixed-mobile') { - sess.webRequest.onBeforeSendHeaders( - { urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, - (details, callback) => { - const userAgentKey = Object.keys(details.requestHeaders).find( - key => key.toLowerCase() === 'user-agent' - ) || 'User-Agent' - if (arm !== 'mobile' && arm !== 'mixed-mobile') { - callback({ requestHeaders: details.requestHeaders }) - return - } - // Shared/service workers lack a guest id and keep their session identity. - if (details.webContentsId !== mainWebContentsId) { - callback({ requestHeaders: details.requestHeaders }) - return - } - details.requestHeaders[userAgentKey] = mobileUserAgent - callback({ requestHeaders: details.requestHeaders }) - } - ) - } + sess.webRequest.onBeforeSendHeaders( + { urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, + (details, callback) => callback({ requestHeaders: details.requestHeaders }) + ) const windows = [] const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-identity-test', sandbox: true } }) windows.push(window) startupMarks.push('webContents') - mainWebContentsId = window.webContents.id - const pageIdentity = arm === 'native' ? rawUserAgent : arm === 'mobile' || arm === 'mixed-mobile' ? mobileUserAgent : cleanUserAgent - if (arm === 'native' || arm === 'mobile' || arm === 'mixed-mobile') window.webContents.setUserAgent(pageIdentity) window.webContents.setWindowOpenHandler(() => ({ action: 'allow', createWindow: options => { const popup = new BrowserWindow({ ...options, show: false }) - popup.webContents.setUserAgent(arm === 'native' ? rawUserAgent : cleanUserAgent) + popup.webContents.setUserAgent(cleanUserAgent) windows.push(popup) return popup.webContents } @@ -316,12 +269,6 @@ async function run() { window.webContents.executeJavaScript('window.probePromise'), requestWithoutUserAgent(sess, ${JSON.stringify(options.httpOrigin)} + '/no-header-fill') ]) - if (arm === 'mixed-mobile') { - const peer = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-identity-test', sandbox: true } }) - windows.push(peer) - await peer.loadURL(${JSON.stringify(options.httpOrigin)} + '/desktop-peer') - await peer.webContents.executeJavaScript('window.peerProbePromise') - } const defaultWindow = new BrowserWindow({ show: false, webPreferences: { sandbox: true } }) windows.push(defaultWindow) await defaultWindow.loadURL(${JSON.stringify(options.httpOrigin)} + '/default-window') @@ -334,7 +281,7 @@ async function run() { ]) await new Promise(resolve => setTimeout(resolve, 250)) clearTimeout(timeout) - writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent, mobileUserAgent, sessionUserAgent: sess.getUserAgent(), navigatorUserAgent, fallbackAfterReadyNameChange, startupMarks })) + writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent, sessionUserAgent: sess.getUserAgent(), navigatorUserAgent, fallbackAfterReadyNameChange, startupMarks })) for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy() app.exit(0) } @@ -356,9 +303,6 @@ function assertCoverage(result: ProbeResult): void { true ) const expectedContexts = ['blob', 'document', 'frame', 'popup', 'service-worker', 'shared-worker'] - if (result.arm === 'mixed-mobile') { - expectedContexts.push('desktop-peer', 'shared-worker') - } expect(result.identities.map(({ context }) => context).sort()).toEqual(expectedContexts.sort()) } @@ -370,21 +314,6 @@ function distinctJavaScriptUserAgents(records: readonly WireProbeJavaScriptIdent return [...new Set(records.map(({ userAgent }) => userAgent))].sort() } -function userAgentForPath(receipts: readonly WireProbeReceipt[], path: string): string | null { - const values = distinctUserAgents(receipts.filter((receipt) => receipt.path === path)) - expect(values, path).toHaveLength(1) - return values[0] ?? null -} - -function identityForContext( - identities: readonly WireProbeJavaScriptIdentity[], - context: string -): WireProbeJavaScriptIdentity { - const matches = identities.filter((identity) => identity.context === context) - expect(matches, context).toHaveLength(1) - return matches[0]! -} - async function reservePort(): Promise { const server = createServer() await new Promise((resolve, reject) => { diff --git a/src/main/managed-server/migration/linking/orcad-migration-repository-link.test.ts b/src/main/managed-server/migration/linking/orcad-migration-repository-link.test.ts index 9ef3706d191..f936524ddd1 100644 --- a/src/main/managed-server/migration/linking/orcad-migration-repository-link.test.ts +++ b/src/main/managed-server/migration/linking/orcad-migration-repository-link.test.ts @@ -472,20 +472,6 @@ describe('moving the host again after its server was stopped', () => { expect(groupName(server, parent.id)).toBe('Parent renamed on the server') }) - // Hiding server-owned groups must preserve inherited host ownership for later folders. - it('still moves a later folder workspace that takes its host from the server’s group', async () => { - const groupId = groupDesktopRepo() - await convert() - stopServer() - await convert() - const folder = store.createFolderWorkspace({ - projectGroupId: groupId, - folderPath: '/root/notes' - }) - await expect(runDelta()).resolves.toMatchObject({ outcome: 'moved' }) - expect(server.getFolderWorkspace(folder.id)?.projectGroupId).toBe(groupId) - }) - // Groups absent from the server still move, even when only a linked project used them. it('moves a linked project’s group the server does not hold, so later rows can use it', async () => { otherDesktopAddsSameCheckout() diff --git a/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts b/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts deleted file mode 100644 index 65c88c243f4..00000000000 --- a/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { activityThreadMatchesSearchQuery } from './activity-thread-grouping' - -import type { AgentPaneThread } from './activity-thread-types' -import { makeWorktree, makeTab } from './ActivityPrototypePage-test-fixtures' - -{ - function makeThread(paneKey: string, paneTitle: string): AgentPaneThread { - return { - paneKey, - tab: makeTab(), - worktree: makeWorktree(), - repo: null, - currentAgentState: null, - currentAgentEntry: null, - latestEvent: null, - latestTimestamp: 1_000, - agentType: 'claude', - unread: false, - paneTitle, - responsePreview: 'x'.repeat(2_000), - events: [] - } - } - - describe('activity thread search', () => { - it('searches rebuilt thread data when the pane key stays the same', () => { - const first = makeThread('tab-1:leaf-1', 'First title') - const rebuilt = makeThread('tab-1:leaf-1', 'Second title') - expect(activityThreadMatchesSearchQuery({ thread: first, searchQuery: 'first' })).toBe(true) - expect(activityThreadMatchesSearchQuery({ thread: rebuilt, searchQuery: 'second' })).toBe( - true - ) - expect(activityThreadMatchesSearchQuery({ thread: rebuilt, searchQuery: 'first' })).toBe( - false - ) - }) - }) -} diff --git a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx deleted file mode 100644 index 0c57accc254..00000000000 --- a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import { ActivityThreadListPane } from './activity-thread-list-pane' -import type { ActivityThreadGroup, AgentPaneThread } from './activity-thread-types' -import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' - -globalThis.IS_REACT_ACT_ENVIRONMENT = true - -const mockThread: AgentPaneThread = { - paneKey: 'tab-1:agent-1', - tab: makeTab(), - worktree: makeWorktree(), - repo: null, - currentAgentState: null, - currentAgentEntry: null, - latestEvent: null, - latestTimestamp: 1000, - agentType: 'claude', - unread: false, - paneTitle: 'Test agent', - responsePreview: 'Done testing', - events: [] -} - -const mockGroup: ActivityThreadGroup = { - key: 'done', - label: 'Done', - state: 'done', - threads: [mockThread] -} - -describe('ActivityThreadListPane collapsible sections', () => { - let container: HTMLDivElement - let root: Root - - beforeEach(() => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - }) - - afterEach(() => { - act(() => root.unmount()) - container.remove() - document.body.replaceChildren() - }) - - it('toggles thread visibility when header is clicked in uncontrolled mode', () => { - const inputRef = { current: null } - act(() => { - root.render( - - true} - showFilterControls={false} - showOptionsMenu={false} - /> - - ) - }) - - expect(container.textContent).toContain('Test agent') - - const header = container.querySelector('[role="button"]') as HTMLElement - act(() => { - header.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(container.textContent).not.toContain('Test agent') - expect(header.getAttribute('aria-expanded')).toBe('false') - - act(() => { - header.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(container.textContent).toContain('Test agent') - expect(header.getAttribute('aria-expanded')).toBe('true') - }) -}) diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx deleted file mode 100644 index bfb526c2ecb..00000000000 --- a/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// @vitest-environment happy-dom -import { act } from 'react' -import { createRoot } from 'react-dom/client' -import { expect, it, vi } from 'vitest' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { TooltipProvider } from '@/components/ui/tooltip' -import { i18n } from '@/i18n/i18n' -import es from '@/i18n/locales/es.json' -import { AgentLaunchPickerMenu } from './AgentLaunchPickerMenu' -import type { AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker' - -vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) - -it('refreshes translated row labels on a language change with unchanged props', async () => { - const language = i18n.language - const host = document.createElement('div') - document.body.append(host) - const root = createRoot(host) - const entry: AgentPickerEntry = { - agent: 'codex', - label: 'Codex', - rows: [{ agent: 'codex', kind: 'chat-unavailable', reason: 'ssh' }], - surfaces: ['terminal'], - defaultSurface: 'terminal', - terminal: {}, - structured: {}, - catalog: { origin: 'probe', fetchedAt: 1, models: [] } - } - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - try { - i18n.addResourceBundle('es', 'translation', es) - await i18n.changeLanguage('en') - act(() => - root.render( - - - New tab - - - - - - ) - ) - const row = document.querySelector('[role="menuitem"]')! - expect(row.textContent).toContain('No chat over SSH') - await act(async () => { - await i18n.changeLanguage('es') - }) - expect(row.textContent).toContain(es.auto.components.tab.bar.QuickLaunchButton.chatNotOverSsh) - expect(row.textContent).not.toContain('No chat over SSH') - } finally { - act(() => root.unmount()) - host.remove() - await i18n.changeLanguage(language) - vi.unstubAllGlobals() - } -}) diff --git a/src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts b/src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts deleted file mode 100644 index e39f852ed01..00000000000 --- a/src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// @vitest-environment happy-dom - -import { describe, expect, it } from 'vitest' -import { - getAutomationDetailNextTab, - shouldHandleAutomationDetailEscapeKey, - shouldHandleAutomationDetailTabArrowKey -} from './automation-detail-tab-navigation' - -describe('shouldHandleAutomationDetailTabArrowKey', () => { - function makeEvent( - overrides: Partial<{ - key: string - altKey: boolean - ctrlKey: boolean - metaKey: boolean - shiftKey: boolean - isComposing: boolean - target: EventTarget | null - }> = {} - ) { - return { - key: 'ArrowRight', - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - nativeEvent: { isComposing: overrides.isComposing ?? false }, - target: overrides.target ?? document.body, - ...overrides - } - } - - it('ignores modified or composing arrow keys', () => { - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ metaKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ ctrlKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ altKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ shiftKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ isComposing: true }))).toBe(false) - }) - - it('ignores keys when focus is inside text input, textarea, or contentEditable', () => { - const input = document.createElement('input') - const textarea = document.createElement('textarea') - const select = document.createElement('select') - const editable = document.createElement('div') - editable.contentEditable = 'true' - - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: input }))).toBe(false) - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: textarea }))).toBe(false) - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: select }))).toBe(false) - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: editable }))).toBe(false) - }) - - it.each(['dialog', 'menu', 'listbox'])('ignores keys inside a %s', (role) => { - const dialog = document.createElement('div') - dialog.setAttribute('role', role) - const childButton = document.createElement('button') - dialog.appendChild(childButton) - document.body.appendChild(dialog) - - expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: childButton }))).toBe(false) - - dialog.remove() - }) -}) - -describe('getAutomationDetailNextTab', () => { - it('prevents switching to runs if canAccessRuns is false', () => { - expect( - getAutomationDetailNextTab({ - currentTab: 'overview', - key: 'ArrowRight', - canAccessRuns: false - }) - ).toBeNull() - }) -}) - -describe('shouldHandleAutomationDetailEscapeKey', () => { - function makeEvent( - overrides: Partial<{ - key: string - defaultPrevented: boolean - altKey: boolean - ctrlKey: boolean - metaKey: boolean - shiftKey: boolean - isComposing: boolean - target: EventTarget | null - }> = {} - ) { - return { - key: 'Escape', - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - nativeEvent: { isComposing: overrides.isComposing ?? false }, - target: overrides.target ?? document.body, - ...overrides - } - } - - it('ignores Escape already consumed while returning from a run', () => { - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ defaultPrevented: true }))).toBe(false) - }) - - it('ignores modified or composing Escape keys', () => { - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ metaKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ ctrlKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ altKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ shiftKey: true }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ isComposing: true }))).toBe(false) - }) - - it('ignores Escape when focus is inside text input, textarea, contentEditable, or escapeClearsValue', () => { - const input = document.createElement('input') - const textarea = document.createElement('textarea') - const select = document.createElement('select') - const editable = document.createElement('div') - editable.contentEditable = 'true' - const clearsValue = document.createElement('div') - clearsValue.setAttribute('data-escape-clears-value', 'true') - - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: input }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: textarea }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: select }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: editable }))).toBe(false) - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: clearsValue }))).toBe(false) - }) - - it.each(['dialog', 'menu', 'listbox'])('ignores Escape inside a %s', (role) => { - const dialog = document.createElement('div') - dialog.setAttribute('role', role) - const childButton = document.createElement('button') - dialog.appendChild(childButton) - document.body.appendChild(dialog) - - expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: childButton }))).toBe(false) - - dialog.remove() - }) -}) diff --git a/src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts b/src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts deleted file mode 100644 index 4d0de0e150a..00000000000 --- a/src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - shouldHandleAutomationListSearchArrowKey, - shouldHandleAutomationListSearchEnterKey -} from './automation-list-keyboard-navigation' - -describe('shouldHandleAutomationListSearchArrowKey', () => { - function event( - overrides: Partial<{ - key: string - altKey: boolean - ctrlKey: boolean - metaKey: boolean - shiftKey: boolean - isComposing: boolean - }> = {} - ) { - return { - key: 'ArrowDown', - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - nativeEvent: { isComposing: overrides.isComposing ?? false }, - ...overrides - } - } - - it('ignores composing, modified, and non-arrow keys', () => { - expect(shouldHandleAutomationListSearchArrowKey(event({ isComposing: true }))).toBe(false) - expect(shouldHandleAutomationListSearchArrowKey(event({ metaKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchArrowKey(event({ ctrlKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchArrowKey(event({ altKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchArrowKey(event({ shiftKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchArrowKey(event({ key: 'Escape' }))).toBe(false) - }) -}) - -describe('shouldHandleAutomationListSearchEnterKey', () => { - function event( - overrides: Partial<{ - key: string - altKey: boolean - ctrlKey: boolean - metaKey: boolean - shiftKey: boolean - isComposing: boolean - }> = {} - ) { - return { - key: 'Enter', - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - nativeEvent: { isComposing: overrides.isComposing ?? false }, - ...overrides - } - } - - it('ignores composing, modified, and non-enter keys', () => { - expect(shouldHandleAutomationListSearchEnterKey(event({ isComposing: true }))).toBe(false) - expect(shouldHandleAutomationListSearchEnterKey(event({ metaKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchEnterKey(event({ ctrlKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchEnterKey(event({ altKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchEnterKey(event({ shiftKey: true }))).toBe(false) - expect(shouldHandleAutomationListSearchEnterKey(event({ key: 'ArrowDown' }))).toBe(false) - expect(shouldHandleAutomationListSearchEnterKey(event({ key: 'Escape' }))).toBe(false) - }) -}) diff --git a/src/renderer/src/components/automations/automation-project-groups.test.ts b/src/renderer/src/components/automations/automation-project-groups.test.ts deleted file mode 100644 index 740c6d39485..00000000000 --- a/src/renderer/src/components/automations/automation-project-groups.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../../shared/repo-types' -import { - getAutomationProjectGroupForRepo, - getAutomationProjectGroups, - getAutomationProjectSelectedSource -} from './automation-project-groups' - -function repo(overrides: Partial): Repo { - return { - id: overrides.id ?? 'repo-1', - displayName: overrides.displayName ?? 'repo', - path: overrides.path ?? '/repo', - kind: 'git', - addedAt: overrides.addedAt ?? 1, - badgeColor: overrides.badgeColor ?? '#777777', - connectionId: overrides.connectionId ?? null, - executionHostId: overrides.executionHostId, - upstream: overrides.upstream, - repoIcon: overrides.repoIcon - } as Repo -} - -describe('getAutomationProjectGroups', () => { - it('finds and preserves the selected concrete source', () => { - const groups = getAutomationProjectGroups( - [ - repo({ id: 'local', upstream: { owner: 'stablyai', repo: 'orca' } }), - repo({ - id: 'ssh', - connectionId: 'builder', - upstream: { owner: 'stablyai', repo: 'orca' } - }) - ], - 'ssh' - ) - const group = getAutomationProjectGroupForRepo(groups, 'ssh') - - expect(group).not.toBeNull() - expect(group ? getAutomationProjectSelectedSource(group, 'ssh').id : null).toBe('ssh') - }) -}) diff --git a/src/renderer/src/components/automations/automation-setup-decision.test.ts b/src/renderer/src/components/automations/automation-setup-decision.test.ts index ac3e4e18935..e99e6edfb46 100644 --- a/src/renderer/src/components/automations/automation-setup-decision.test.ts +++ b/src/renderer/src/components/automations/automation-setup-decision.test.ts @@ -37,34 +37,6 @@ function setup(overrides: Partial = {}): ProjectHostSetup { } describe('automation setup decision defaults', () => { - it('hides setup choice outside Orca new-run automations with setup', () => { - const baseArgs = { - repoId: 'repo-1', - repos: [repo], - projectHostSetups: [], - yamlHooks: null - } - - expect( - getVisibleAutomationSetupDecision({ - ...baseArgs, - workspaceMode: 'existing' - }) - ).toBeUndefined() - expect( - getVisibleAutomationSetupDecision({ - ...baseArgs, - workspaceMode: 'new_per_run', - repos: [ - { - ...repo, - hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } - } - ] - }) - ).toBeUndefined() - }) - it('uses only the selected repo host setup and refuses an ambiguous repo table', () => { const remote = { ...repo, connectionId: 'builder' } const localSetup = setup({ @@ -91,26 +63,6 @@ describe('automation setup decision defaults', () => { expect(getVisibleAutomationSetupDecision({ ...args, repos: [repo, remote] })).toBeUndefined() }) - it('uses ready project-host setup hook settings before repo fallback', () => { - expect( - getVisibleAutomationSetupDecision({ - workspaceMode: 'new_per_run', - repoId: 'repo-1', - repos: [repo], - projectHostSetups: [ - setup({ - hookSettings: { - mode: 'override', - setupRunPolicy: 'skip-by-default', - scripts: { setup: 'bun install', archive: '' } - } - }) - ], - yamlHooks: null - }) - ).toBe('skip') - }) - it('shows setup choice for shared orca.yaml setup and default tabs', () => { expect( getVisibleAutomationSetupDecision({ diff --git a/src/renderer/src/components/automations/external-automation-scope-gating.test.ts b/src/renderer/src/components/automations/external-automation-scope-gating.test.ts index 49e2620f825..6060de28186 100644 --- a/src/renderer/src/components/automations/external-automation-scope-gating.test.ts +++ b/src/renderer/src/components/automations/external-automation-scope-gating.test.ts @@ -7,8 +7,7 @@ import type { AutomationHostCatalogEntry } from './automation-host-catalog-types import { externalAutomationProbeOwners, externalAutomationScopeGateFromError, - parseExternalAutomationScopeCode, - resolveExternalAutomationScopeGate + parseExternalAutomationScopeCode } from './external-automation-scope-gating' function entry(overrides: Partial = {}): AutomationHostCatalogEntry { @@ -39,36 +38,6 @@ const runtimeEntry = entry({ }) describe('external automation scope gating', () => { - it('never presents a runtime-owned host as clean', () => { - const gate = resolveExternalAutomationScopeGate(runtimeEntry) - expect(gate.status).toBe('not-listed') - expect(gate.code).toBe(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) - expect(gate.probeOwner).toBeNull() - }) - - it('fails closed when no owner was captured, so nothing is probed', () => { - const gate = resolveExternalAutomationScopeGate(entry({ owner: null })) - expect(gate.status).toBe('not-listed') - expect(gate.probeOwner).toBeNull() - }) - - it('treats the orphan bucket as scope limited rather than empty', () => { - const orphan = entry({ - stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }, - kind: 'orphan', - owner: null, - stableKey: 'host:desktop:orphan' - }) - expect(resolveExternalAutomationScopeGate(orphan).status).toBe('not-listed') - }) - - it('makes no claim for an unresolved host', () => { - const gate = resolveExternalAutomationScopeGate(null) - expect(gate.status).toBe('unknown') - expect(gate.probeOwner).toBeNull() - // Why: 'unknown' is not a limitation, so it must not print the scope note. - }) - it('retains probe owners only for hosts that are actually listed', () => { expect(externalAutomationProbeOwners([entry(), runtimeEntry, entry({ owner: null })])).toEqual([ { authority: { kind: 'desktop' }, selector: { kind: 'self' } } From 3aee704b8bb84eab19c934f3cddf139c36ce2417 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:39:23 +0000 Subject: [PATCH 033/138] fix(speech): keep words spoken while the model loads (#40454) Stopping a CPU dictation closed the renderer's audio port while frames still waited behind a decode in the speech worker; the close ended the stream at once and the queued frames were dropped. A close after 'end' now leaves them to decode. A model slower than 30 s to load also lost the start of the dictation, since the renderer kept only 30 s of startup audio; it now keeps as much as the host waits for the load. --- src/main/speech/stt-service.ts | 2 +- src/main/speech/stt-session-start.ts | 2 +- src/main/speech/stt-session-timeouts.ts | 1 - src/main/speech/stt-worker.ts | 14 +++++++++++--- src/renderer/src/hooks/use-audio-capture.ts | 4 +++- src/shared/dictation-finish-deadlines.ts | 2 ++ 6 files changed, 18 insertions(+), 7 deletions(-) delete mode 100644 src/main/speech/stt-session-timeouts.ts diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index df0233b4a64..e1023185d16 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -17,7 +17,7 @@ import { teardownIdleSttEngine } from './stt-session-stop' -export { START_DICTATION_TIMEOUT_MS } from './stt-session-timeouts' +export { START_DICTATION_TIMEOUT_MS } from '../../shared/dictation-finish-deadlines' export type SttEvent = | { type: 'ready' } diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index 5f80b0cd2f9..619869ab03a 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -26,7 +26,7 @@ import { initializeSttWorker, waitForSttWorkerReady } from './stt-worker-startup' -import { START_DICTATION_TIMEOUT_MS } from './stt-session-timeouts' +import { START_DICTATION_TIMEOUT_MS } from '../../shared/dictation-finish-deadlines' import { STT_WORKER_EXITED_ERROR } from './stt-worker-process' export async function startSttDictation( diff --git a/src/main/speech/stt-session-timeouts.ts b/src/main/speech/stt-session-timeouts.ts deleted file mode 100644 index 1e9b06f7e10..00000000000 --- a/src/main/speech/stt-session-timeouts.ts +++ /dev/null @@ -1 +0,0 @@ -export const START_DICTATION_TIMEOUT_MS = 60_000 diff --git a/src/main/speech/stt-worker.ts b/src/main/speech/stt-worker.ts index 4e9eb66a22e..0eb2d2d8c64 100644 --- a/src/main/speech/stt-worker.ts +++ b/src/main/speech/stt-worker.ts @@ -9,6 +9,8 @@ import type { SttWorkerRequest } from './stt-worker-process' type AudioPortInput = { port: MessagePortMain onEnded: (() => void) | null + /** 'end' arrived; its frames may still wait behind a decode. */ + ending: boolean } const parentPort = connectSttWorkerParentPort() @@ -46,8 +48,7 @@ function attachAudioPort(streamId: string, port: MessagePortMain | undefined): v if (!port) { return } - const input: AudioPortInput = { port, onEnded: null } - // The renderer closes its end after 'end'; a close alone means it went away. + const input: AudioPortInput = { port, onEnded: null, ending: false } const end = (): void => { if (audioPorts.get(streamId) !== input) { return @@ -59,6 +60,7 @@ function attachAudioPort(streamId: string, port: MessagePortMain | undefined): v port.on('message', ({ data }) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the desktop preload holds the other end, and it sends SpeechAudioPortMessage. const msg = data as SpeechAudioPortMessage + input.ending ||= msg.type === 'end' enqueue(() => { // A discard or end ran first; a late frame must not reopen the stream. if (audioPorts.get(streamId) !== input) { @@ -69,7 +71,13 @@ function attachAudioPort(streamId: string, port: MessagePortMain | undefined): v : handleFeed({ type: 'feed', streamId, samples: msg.samples, sampleRate: msg.sampleRate }) }) }) - port.on('close', end) + // The renderer closes its end right after 'end', so that close must leave the queued frames to decode. + // A close alone means it canceled or went away. + port.on('close', () => { + if (!input.ending) { + end() + } + }) audioPorts.set(streamId, input) port.start() } diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts index b98211188d4..f6b965ff8c3 100644 --- a/src/renderer/src/hooks/use-audio-capture.ts +++ b/src/renderer/src/hooks/use-audio-capture.ts @@ -8,6 +8,7 @@ import { } from '@/components/dictation/dictation-capture-worklet-protocol' import { DICTATION_SAMPLE_RATE } from '../../../shared/rpc-contract/speech-params' import { DICTATION_METER_WINDOW_MS } from '../../../shared/dictation-audio-meter' +import { START_DICTATION_TIMEOUT_MS } from '../../../shared/dictation-finish-deadlines' import { useAudioCaptureMeter, type DictationMeterPublisher } from './use-audio-capture-meter' import { useAudioCaptureStopTail, type StopCaptureOptions } from './use-audio-capture-stop-tail' @@ -35,7 +36,8 @@ export type StartAudioCaptureResult = { fellBackToDefaultMicrophone: boolean } -const MAX_BUFFERED_AUDIO_SECONDS = 30 +// Holds everything said while the model loads, for as long as the host waits for it to load. +const MAX_BUFFERED_AUDIO_SECONDS = START_DICTATION_TIMEOUT_MS / 1000 const MAX_BUFFERED_AUDIO_BYTES = 8 * 1024 * 1024 const CAPTURE_WORKLET_OPTIONS: DictationCaptureWorkletOptions = { outputSampleRate: DICTATION_SAMPLE_RATE, diff --git a/src/shared/dictation-finish-deadlines.ts b/src/shared/dictation-finish-deadlines.ts index e355e364d1b..c5f32f00fe6 100644 --- a/src/shared/dictation-finish-deadlines.ts +++ b/src/shared/dictation-finish-deadlines.ts @@ -7,6 +7,8 @@ const CLOUD_RESPONSE_ALLOWANCE_MS = 30_000 const UPLOAD_BYTES_PER_MS = 125 export const LOCAL_DICTATION_STOP_TIMEOUT_MS = 60_000 +// How long an on-device model may take to load before its dictation fails. +export const START_DICTATION_TIMEOUT_MS = 60_000 // The host's answer still has to cross the network after its own deadline. const PAIRED_CLIENT_REPLY_MARGIN_MS = 15_000 From 1bf2276bdd8630106d7c92cef6633a1eae2e285f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:39:34 +0000 Subject: [PATCH 034/138] fix(dictation): match the pill's Chinese and Japanese phrases to the inserted text, and space Korean ones (#40455) * fix(dictation): join Chinese and Japanese phrases without a space, and space Korean ones The shared transcript fold joined every phrase with a space, so the pill and phone replies showed Chinese and Japanese text with spaces the inserted text doesn't have. It now joins with the same rule as insertion. That rule treated Hangul like Chinese and Japanese, so Korean phrases were inserted with no space between sentences; Korean spaces words, so Hangul now spaces like Latin text. * fix(dictation): don't space after Chinese and Japanese full-width punctuation * fix(dictation): join retried phrases with the same spacing rule * fix(dictation): escape the spacing rule's non-ASCII marks for the orcad bundle The shared fold now pulls the spacing rule into the orcad server bundle, which must stay ASCII. A rich-draft test pinned Hangul joining without a space; it now expects the Korean spacing. --- .../dictation/DictationIndicator.tsx | 3 +- .../dictation/dictation-kept-recording.ts | 6 ++- .../dictation-transcript-delivery.ts | 6 +-- .../inline-rich-draft-consumers.test.tsx | 2 +- src/shared/dictation-transcript-spacing.ts | 23 ++++++---- src/shared/dictation-transcript.test.ts | 44 +++++++++++++++++++ src/shared/dictation-transcript.ts | 11 ++--- 7 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 src/shared/dictation-transcript.test.ts diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index 9edba96cef8..f8559b598ec 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { ArrowUp, Check, CornerDownLeft, Loader2 } from 'lucide-react' import { isDictationPillShown } from '../../../../shared/dictation-session-machine' +import { dictationSeparators } from '../../../../shared/dictation-transcript-spacing' import { CLOUD_DICTATION_MAX_SECONDS } from '../../../../shared/rpc-contract/speech-params' import type { SpeechModelManifest } from '../../../../shared/speech-types' import { dispatchDictationControl } from './dictation-control-events' @@ -207,7 +208,7 @@ function DictationPill({ captureLive, anchor, failure, queued }: DictationIndica > {committed} - {committed && tentative ? ' ' : null} + {dictationSeparators(committed, tentative, '').leading} {tentative} diff --git a/src/renderer/src/components/dictation/dictation-kept-recording.ts b/src/renderer/src/components/dictation/dictation-kept-recording.ts index f0b7ca42b0d..7981c21c90f 100644 --- a/src/renderer/src/components/dictation/dictation-kept-recording.ts +++ b/src/renderer/src/components/dictation/dictation-kept-recording.ts @@ -6,6 +6,7 @@ import type { DictationContext } from '../../../../shared/speech-types' import type { DictationInsertionTarget } from './dictation-insertion-target' import type { DictationTranscriptDelivery } from './dictation-transcript-delivery' import { trimLandedOverlap } from './dictation-retry-overlap' +import { appendDictationText } from '../../../../shared/dictation-transcript-spacing' import { showRetryOverlapUnmatchedToast } from './dictation-toasts' // The cloud cap, ten minutes, is about 38 MB of 16 kHz float audio. @@ -92,7 +93,10 @@ function* joinIntoBlocks(chunks: Float32Array[]): Generator { // The retry repeats words that landed before the failure; only what follows them is typed. function insertAfterLanded(recording: DictationKeptRecording, retried: string[]): void { - const { text, matched } = trimLandedOverlap(recording.deliver.landedText(), retried.join(' ')) + const { text, matched } = trimLandedOverlap( + recording.deliver.landedText(), + retried.reduce(appendDictationText, '') + ) if (text) { recording.deliver(text, recording.target) } diff --git a/src/renderer/src/components/dictation/dictation-transcript-delivery.ts b/src/renderer/src/components/dictation/dictation-transcript-delivery.ts index 9680bded9e1..0b890d3bf31 100644 --- a/src/renderer/src/components/dictation/dictation-transcript-delivery.ts +++ b/src/renderer/src/components/dictation/dictation-transcript-delivery.ts @@ -1,7 +1,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { describeClipboardWriteFailure } from '@/lib/clipboard-write-failure' -import { dictationSeparators } from '../../../../shared/dictation-transcript-spacing' +import { appendDictationText } from '../../../../shared/dictation-transcript-spacing' import { insertText, type DictationInsertionTarget } from './dictation-insertion-target' export type DictationTranscriptDelivery = (( @@ -25,10 +25,10 @@ export function createDictationTranscriptDelivery(): DictationTranscriptDelivery const landed = target ? await insertText(text, target, lastLanded).catch(() => false) : false if (landed) { lastLanded = text - allLanded += `${dictationSeparators(allLanded, text, '').leading}${text}` + allLanded = appendDictationText(allLanded, text) return } - missed += `${dictationSeparators(missed, text, '').leading}${text}` + missed = appendDictationText(missed, text) await copyMissedDictation(missed, target !== null) }) } diff --git a/src/renderer/src/components/native-chat/composer/editor/inline-rich-draft-consumers.test.tsx b/src/renderer/src/components/native-chat/composer/editor/inline-rich-draft-consumers.test.tsx index fa1ef624ae0..7cb534c294d 100644 --- a/src/renderer/src/components/native-chat/composer/editor/inline-rich-draft-consumers.test.tsx +++ b/src/renderer/src/components/native-chat/composer/editor/inline-rich-draft-consumers.test.tsx @@ -563,7 +563,7 @@ it.each(['mounted', 'success', 'unmounted', 'ime', 'cancel'] as const)( act(() => bottom.view.dispatch(bottom.state.tr.insertText('가', bottom.state.doc.content.size - 1)) ) - expect(nextSend().blocks).toEqual([{ type: 'text', text: 'first\n\necho가echo' }]) + expect(nextSend().blocks).toEqual([{ type: 'text', text: 'first\n\necho가 echo' }]) } else { expect(nextSend().blocks).toEqual([ { diff --git a/src/shared/dictation-transcript-spacing.ts b/src/shared/dictation-transcript-spacing.ts index 8af1a19cf3d..8aefa5f3374 100644 --- a/src/shared/dictation-transcript-spacing.ts +++ b/src/shared/dictation-transcript-spacing.ts @@ -2,10 +2,12 @@ export const DICTATION_TEXT_BLOCK_SELECTOR = 'p, li, h1, h2, h3, h4, h5, h6, pre, blockquote, td, th, div' -const CJK_BOUNDARY_CHAR_RE = - /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]$/u -const NO_SPACE_BEFORE_CHAR_RE = /^[,.;:!?%。,、!?;:))\]}”’»]$/u -const NO_SPACE_AFTER_CHAR_RE = /^[([{(《「『“‘«„]$/u +// Chinese and Japanese don't space words or after their full-width punctuation; Korean does, so Hangul spaces like Latin text. +const UNSPACED_SCRIPT_CHAR_RE = + /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\u3000-\u303f\u30fb\u30fc\uff00-\uff9f\uffe0-\uffef]$/u +const NO_SPACE_BEFORE_CHAR_RE = + /^[,.;:!?%\u3002\uff0c\u3001\uff01\uff1f\uff1b\uff1a\uff09)\]}\u201d\u2019\u00bb]$/u +const NO_SPACE_AFTER_CHAR_RE = /^[([{\uff08\u300a\u300c\u300e\u201c\u2018\u00ab\u201e]$/u const STRAIGHT_QUOTE_RE = /["'`]/ // A straight quote opens after a space or the start, and closes before a space, punctuation or the end; anything else is an apostrophe. const opensQuote = (before: string): boolean => /(^|[\s([{])["'`]$/.test(before) @@ -20,7 +22,7 @@ function getLastChar(text: string): string { return Array.from(text.slice(-2)).at(-1) ?? '' } -// Dictated text and its neighbour touch with no space between: one is empty or already spaced, either is CJK, or punctuation binds them. +// Dictated text and its neighbour touch with no space between: one is empty or already spaced, either is Chinese or Japanese, or punctuation binds them. function dictationJoinsWithoutSpace(previousText: string, nextText: string): boolean { if (!previousText || !nextText || /\s$/.test(previousText) || /^\s/.test(nextText)) { return true @@ -28,14 +30,19 @@ function dictationJoinsWithoutSpace(previousText: string, nextText: string): boo const previousChar = getLastChar(previousText) const nextChar = getFirstChar(nextText) return ( - CJK_BOUNDARY_CHAR_RE.test(previousChar) || - CJK_BOUNDARY_CHAR_RE.test(nextChar) || + UNSPACED_SCRIPT_CHAR_RE.test(previousChar) || + UNSPACED_SCRIPT_CHAR_RE.test(nextChar) || NO_SPACE_BEFORE_CHAR_RE.test(nextChar) || NO_SPACE_AFTER_CHAR_RE.test(previousChar) ) } -/** The spaces dictated text needs between the text before and after where it lands, unless punctuation, CJK or a quote it sits just inside joins them. */ +/** `before` and `text` joined with the space dictation puts between them, if any. */ +export function appendDictationText(before: string, text: string): string { + return `${before}${dictationSeparators(before, text, '').leading}${text}` +} + +/** The spaces dictated text needs between the text before and after where it lands, unless punctuation, Chinese or Japanese, or a quote it sits just inside joins them. */ export function dictationSeparators( before: string, text: string, diff --git a/src/shared/dictation-transcript.test.ts b/src/shared/dictation-transcript.test.ts new file mode 100644 index 00000000000..2857d42a824 --- /dev/null +++ b/src/shared/dictation-transcript.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { DictationTranscript } from './dictation-transcript' + +function fold(events: { type: 'partial' | 'final'; text: string }[]) { + const transcript = new DictationTranscript() + events.forEach((event) => transcript.accept(event)) + return transcript.snapshot() +} + +describe('DictationTranscript', () => { + it('joins Chinese and Japanese phrases without a space, as they are inserted', () => { + expect( + fold([ + { type: 'final', text: '西班牙人开始了殖民时期。' }, + { type: 'final', text: '2023年他们来自欧洲' }, + { type: 'partial', text: '然后' } + ]) + ).toMatchObject({ + committed: '西班牙人开始了殖民时期。2023年他们来自欧洲', + text: '西班牙人开始了殖民时期。2023年他们来自欧洲然后' + }) + expect( + fold([ + { type: 'final', text: '今日は晴れです。' }, + { type: 'final', text: '散歩に行きます。' } + ]).committed + ).toBe('今日は晴れです。散歩に行きます。') + }) + + it('spaces Korean and Latin phrases', () => { + expect( + fold([ + { type: 'final', text: '안녕하세요.' }, + { type: 'final', text: '감사합니다.' } + ]).committed + ).toBe('안녕하세요. 감사합니다.') + expect( + fold([ + { type: 'final', text: 'Open the file.' }, + { type: 'partial', text: 'then save' } + ]).text + ).toBe('Open the file. then save') + }) +}) diff --git a/src/shared/dictation-transcript.ts b/src/shared/dictation-transcript.ts index 2117e82fee8..1f85170f433 100644 --- a/src/shared/dictation-transcript.ts +++ b/src/shared/dictation-transcript.ts @@ -1,3 +1,5 @@ +import { appendDictationText } from './dictation-transcript-spacing' + /** The text so far mid-dictation, for the desktop pill and phone replies: committed text never changes, tentative text may still be revised. */ export type DictationTranscriptSnapshot = { /** Grows with every change, so a client keeps the newest snapshot when replies arrive out of order. */ @@ -10,7 +12,7 @@ export type DictationTranscriptSnapshot = { /** One dictation's speech events folded into the snapshot its chunk replies report. */ export class DictationTranscript { - private readonly committed: string[] = [] + private committed = '' private tentative = '' private revisionCount = 0 @@ -25,7 +27,7 @@ export class DictationTranscript { } else if (event.type === 'final') { const text = event.text?.trim() if (text) { - this.committed.push(text) + this.committed = appendDictationText(this.committed, text) this.tentative = '' this.revisionCount += 1 } @@ -33,12 +35,11 @@ export class DictationTranscript { } snapshot(): DictationTranscriptSnapshot { - const committed = this.committed.join(' ') return { revision: this.revisionCount, - committed, + committed: this.committed, tentative: this.tentative, - text: [committed, this.tentative].join(' ').trim() + text: appendDictationText(this.committed, this.tentative.trim()) } } } From 8ef468014252a4f399f9554d88b5427a88ed4495 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:40:51 -0800 Subject: [PATCH 035/138] Improve project group ordering across hosts (#39816) * Improve project group ordering across hosts * fix(project-groups): restore each group order after a failed drop A deleted group made the aggregate rollback stop early, leaving other groups at the ranks from the failed drop. Restore each group independently and log the swallowed single-update failures again. * refactor(main): give project group RPC methods their own module Keeps repo.ts under the 300-line cap as the project group surface grows. --- src/main/ipc/repos.ts | 1 + src/main/ipc/repos/project-group-handlers.ts | 14 +++ src/main/ipc/repos/repo-ipc-arg-schemas.ts | 4 + .../project-collection-operations.ts | 6 + .../project-group-batch-update.test.ts | 61 ++++++++++ .../project-group-operations.ts | 30 ++++- .../construction/runtime-store-contract.ts | 1 + .../runtime-project-group-controller.ts | 18 ++- .../runtime-repository-command-surface.ts | 2 + .../projects/project-group-rpc-methods.ts | 78 +++++++++++++ src/main/runtime/rpc/methods/projects/repo.ts | 64 +---------- src/preload/api/project-groups-bridge.ts | 1 + src/preload/api/repository-api.ts | 4 + .../drag/root-project-header-drop.test.ts | 22 +++- .../drag/root-project-header-drop.ts | 52 ++++++--- .../drag/root-project-order-commit.test.ts | 106 ++++++++++++++++++ .../drag/root-project-order-commit.ts | 72 +++++++----- .../worktree-list/drag/use-header-drag.ts | 17 ++- .../worktree-list/rows/SectionHeader.tsx | 23 ++-- .../rows/section-header-create-host.test.tsx | 46 ++++++++ .../header-folder-composition.test.tsx | 1 + ...worktree-header-section-boundaries.test.ts | 38 +++++++ .../worktree-header-section-boundaries.ts | 77 +++++-------- .../project-group-batch-update.ts | 93 +++++++++++++++ .../project-groups/project-group-mutations.ts | 32 +----- .../project-group-update-persistence.ts | 82 ++++++++++++-- .../src/store/repos/create-repo-slice.ts | 2 + src/renderer/src/store/repos/repo-state.ts | 4 + src/shared/project-group-order-capability.ts | 1 + src/shared/project-group-types.ts | 2 + src/shared/protocol-version.ts | 6 +- src/shared/rpc-contract/repo-params.ts | 4 + .../rpc-params-catalog.generated.ts | 2 + 33 files changed, 749 insertions(+), 217 deletions(-) create mode 100644 src/main/persistence/tracking-repos/project-group-batch-update.test.ts create mode 100644 src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.test.ts create mode 100644 src/renderer/src/store/project-groups/project-group-batch-update.ts diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index af8b9c5fe8f..68ae1b3aed9 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -38,6 +38,7 @@ export function registerRepoHandlers( ipcMain.removeHandler('projectGroups:list') ipcMain.removeHandler('projectGroups:create') ipcMain.removeHandler('projectGroups:update') + ipcMain.removeHandler('projectGroups:updateMany') ipcMain.removeHandler('projectGroups:delete') ipcMain.removeHandler('projectGroups:moveProject') ipcMain.removeHandler('projectGroups:scanNested') diff --git a/src/main/ipc/repos/project-group-handlers.ts b/src/main/ipc/repos/project-group-handlers.ts index 1dd1ccfe7e4..bb9462b3349 100644 --- a/src/main/ipc/repos/project-group-handlers.ts +++ b/src/main/ipc/repos/project-group-handlers.ts @@ -12,6 +12,7 @@ import { ProjectGroupScanNestedArgs, ProjectGroupSelectorArgs, ProjectGroupUpdateArgs, + ProjectGroupUpdateManyArgs, parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' import { @@ -54,6 +55,19 @@ export function registerProjectGroupHandlers(mainWindow: BrowserWindow, store: S return updated }) + ipcMain.handle('projectGroups:updateMany', (_event, rawArgs: unknown): ProjectGroup[] | null => { + const args = parseProjectGroupIpcArgs( + ProjectGroupUpdateManyArgs, + rawArgs, + 'invalid_project_group_update_many_args' + ) + const updated = store.updateProjectGroups(args.mutations) + if (updated?.length) { + notifyReposChanged(mainWindow) + } + return updated + }) + ipcMain.handle('projectGroups:delete', (_event, rawArgs: unknown): boolean => { const args = parseProjectGroupIpcArgs( ProjectGroupSelectorArgs, diff --git a/src/main/ipc/repos/repo-ipc-arg-schemas.ts b/src/main/ipc/repos/repo-ipc-arg-schemas.ts index 32e99ac2f1a..8e3d46c8b44 100644 --- a/src/main/ipc/repos/repo-ipc-arg-schemas.ts +++ b/src/main/ipc/repos/repo-ipc-arg-schemas.ts @@ -30,6 +30,10 @@ export const ProjectGroupSelectorArgs = z.object({ groupId: z.string().min(1) }) +export const ProjectGroupUpdateManyArgs = z.object({ + mutations: z.array(ProjectGroupUpdateArgs) +}) + export const ProjectHostSetupExistingFolderIpcArgs = z.object({ projectId: z.string().min(1), projectProviderIdentity: z diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts index e6d90ede3b8..2e67007eefc 100644 --- a/src/main/persistence/loading-store/project-collection-operations.ts +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -138,6 +138,12 @@ export class ProjectCollectionOperations { return getProjectGroupOperations(this).deleteProjectGroup(groupId) } + updateProjectGroups( + mutations: Parameters[0] + ): ProjectGroup[] | null { + return getProjectGroupOperations(this).updateProjectGroups(mutations) + } + getFolderWorkspaces(): FolderWorkspace[] { return getFolderWorkspaceOperations(this).getFolderWorkspaces() } diff --git a/src/main/persistence/tracking-repos/project-group-batch-update.test.ts b/src/main/persistence/tracking-repos/project-group-batch-update.test.ts new file mode 100644 index 00000000000..a5867523bba --- /dev/null +++ b/src/main/persistence/tracking-repos/project-group-batch-update.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { createProjectGroup } from '../../../shared/project-groups' +import { ProjectGroupPersistenceOperations } from './project-group-operations' + +function fixture() { + const state = getDefaultPersistedState('/workspace') + state.projectGroups = ['a', 'b', 'other'].map((id, tabOrder) => ({ + ...createProjectGroup({ name: id, createdFrom: 'manual', tabOrder }), + id + })) + const operations = new ProjectGroupPersistenceOperations({ + state, + scheduleSave: () => {}, + removeWorkspaceLineageForRemovedFolder: () => {}, + pruneMobileClientTabSelections: () => {} + }) + return { state, operations } +} + +describe('batch project group updates', () => { + it('updates and clears ranks for selected groups without changing unrelated groups', () => { + const { state, operations } = fixture() + const other = { ...state.projectGroups![2] } + + expect( + operations.updateProjectGroups([ + { groupId: 'b', updates: { rootProjectOrder: 2000, tabOrder: 4 } }, + { groupId: 'a', updates: { rootProjectOrder: 1000, isCollapsed: true } } + ]) + ).toMatchObject([ + { id: 'b', rootProjectOrder: 2000, tabOrder: 4 }, + { id: 'a', rootProjectOrder: 1000, isCollapsed: true } + ]) + operations.updateProjectGroups([ + { groupId: 'a', updates: { rootProjectOrder: null } }, + { groupId: 'b', updates: { rootProjectOrder: 3000 } } + ]) + + expect(state.projectGroups).toMatchObject([ + { id: 'a', tabOrder: 0, isCollapsed: true }, + { id: 'b', rootProjectOrder: 3000, tabOrder: 4 }, + other + ]) + expect(state.projectGroups![0].rootProjectOrder).toBeUndefined() + expect(state.projectGroups![2]).toEqual(other) + }) + + it('rejects the entire batch when a group has disappeared', () => { + const { state, operations } = fixture() + const before = structuredClone(state.projectGroups) + + expect( + operations.updateProjectGroups([ + { groupId: 'a', updates: { rootProjectOrder: 1000 } }, + { groupId: 'missing', updates: { rootProjectOrder: 2000 } } + ]) + ).toBeNull() + expect(state.projectGroups).toEqual(before) + }) +}) diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts index e0ff47e67fd..cd7d55bf99c 100644 --- a/src/main/persistence/tracking-repos/project-group-operations.ts +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -1,5 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' -import type { ProjectGroup, ProjectGroupUpdate } from '../../../shared/project-group-types' +import type { + ProjectGroup, + ProjectGroupMutation, + ProjectGroupUpdate +} from '../../../shared/project-group-types' import { createProjectGroup, getProjectGroupSubtreeIds, @@ -73,6 +77,28 @@ export class ProjectGroupPersistenceOperations { if (!group) { return null } + this.applyGroupUpdate(group, updates) + this.scheduleSave() + return group + } + + updateProjectGroups(mutations: readonly ProjectGroupMutation[]): ProjectGroup[] | null { + const byId = new Map((this.state.projectGroups ?? []).map((group) => [group.id, group])) + if (mutations.some(({ groupId }) => !byId.has(groupId))) { + return null + } + const updated = mutations.map(({ groupId, updates }) => { + const group = byId.get(groupId)! + this.applyGroupUpdate(group, updates) + return group + }) + if (updated.length > 0) { + this.scheduleSave() + } + return updated + } + + private applyGroupUpdate(group: ProjectGroup, updates: ProjectGroupUpdate): void { if (updates.name !== undefined) { group.name = normalizeProjectGroupName(updates.name, group.name) } @@ -94,8 +120,6 @@ export class ProjectGroupPersistenceOperations { group.color = typeof updates.color === 'string' ? updates.color : null } group.updatedAt = Date.now() - this.scheduleSave() - return group } deleteProjectGroup(groupId: string): boolean { diff --git a/src/main/runtime/construction/runtime-store-contract.ts b/src/main/runtime/construction/runtime-store-contract.ts index 429ecba0b77..e65b65778b7 100644 --- a/src/main/runtime/construction/runtime-store-contract.ts +++ b/src/main/runtime/construction/runtime-store-contract.ts @@ -21,6 +21,7 @@ export type RuntimeStore = { getProjectGroups?: Store['getProjectGroups'] createProjectGroup?: Store['createProjectGroup'] updateProjectGroup?: Store['updateProjectGroup'] + updateProjectGroups?: Store['updateProjectGroups'] deleteProjectGroup?: Store['deleteProjectGroup'] moveProjectToGroup?: Store['moveProjectToGroup'] getFolderWorkspace?: Store['getFolderWorkspace'] diff --git a/src/main/runtime/repositories/runtime-project-group-controller.ts b/src/main/runtime/repositories/runtime-project-group-controller.ts index a2552da93d5..175110e036b 100644 --- a/src/main/runtime/repositories/runtime-project-group-controller.ts +++ b/src/main/runtime/repositories/runtime-project-group-controller.ts @@ -1,7 +1,11 @@ import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachments/workspace-attachment-mutation' import { refuseWslHostWindowsDrivePath } from '../terminal/launch/wsl-host-windows-drive-guard' import type { FolderWorkspace } from '../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup, ProjectGroupUpdate } from '../../../shared/project-group-types' +import type { + ProjectGroup, + ProjectGroupMutation, + ProjectGroupUpdate +} from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' import type { FolderWorkspacePathStatus, @@ -137,6 +141,18 @@ export class RuntimeProjectGroupController { return { deleted } } + async updateGroups(mutations: readonly ProjectGroupMutation[]): Promise { + const store = this.deps.getStore() + if (!store?.updateProjectGroups) { + throw new Error('runtime_unavailable') + } + const updated = store.updateProjectGroups(mutations) + if (updated?.length) { + this.deps.notifyReposChanged() + } + return updated + } + async moveProject( repoSelector: string, groupId: string | null, diff --git a/src/main/runtime/repositories/runtime-repository-command-surface.ts b/src/main/runtime/repositories/runtime-repository-command-surface.ts index 928c9142fc4..8cc6306f3fb 100644 --- a/src/main/runtime/repositories/runtime-repository-command-surface.ts +++ b/src/main/runtime/repositories/runtime-repository-command-surface.ts @@ -49,6 +49,7 @@ export type RuntimeRepositoryCommandSurface = { listVisibleFolderWorkspaces: RuntimeProjectGroupController['listVisibleFolderWorkspaces'] createProjectGroup: RuntimeProjectGroupController['createGroup'] updateProjectGroup: RuntimeProjectGroupController['updateGroup'] + updateProjectGroups: RuntimeProjectGroupController['updateGroups'] deleteProjectGroup: RuntimeProjectGroupController['deleteGroup'] moveProjectToGroup: RuntimeProjectGroupController['moveProject'] createFolderWorkspace: RuntimeProjectGroupController['createFolderWorkspace'] @@ -128,6 +129,7 @@ export function installRuntimeRepositoryCommandSurface( listVisibleFolderWorkspaces: groups.listVisibleFolderWorkspaces.bind(groups), createProjectGroup: groups.createGroup.bind(groups), updateProjectGroup: groups.updateGroup.bind(groups), + updateProjectGroups: groups.updateGroups.bind(groups), deleteProjectGroup: groups.deleteGroup.bind(groups), moveProjectToGroup: groups.moveProject.bind(groups), createFolderWorkspace: groups.createFolderWorkspace.bind(groups), diff --git a/src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts b/src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts new file mode 100644 index 00000000000..4b1f86d9b2c --- /dev/null +++ b/src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts @@ -0,0 +1,78 @@ +import { defineMethod } from '../../core' +import { projectRepoVisibilityForClient } from '../../repo-visibility-projection' +import { + ProjectGroupCreate, + ProjectGroupImportNested, + ProjectGroupMoveProject, + ProjectGroupScanNested, + ProjectGroupSelector, + ProjectGroupUpdate, + ProjectGroupUpdateMany +} from '../../../../../shared/rpc-contract/repo-params' + +export const PROJECT_GROUP_METHODS = [ + defineMethod({ + name: 'projectGroup.list', + permission: 'workspace', + params: null, + handler: (_params, { runtime }) => ({ groups: runtime.listVisibleProjectGroups() }) + }), + defineMethod({ + name: 'projectGroup.create', + permission: 'workspace', + params: ProjectGroupCreate, + handler: async (params, { runtime }) => ({ + group: await runtime.createProjectGroup(params) + }) + }), + defineMethod({ + name: 'projectGroup.update', + permission: 'workspace', + params: ProjectGroupUpdate, + handler: async (params, { runtime }) => ({ + group: await runtime.updateProjectGroup(params.groupId, params.updates) + }) + }), + defineMethod({ + name: 'projectGroup.delete', + permission: 'workspace', + params: ProjectGroupSelector, + handler: async (params, { runtime }) => runtime.deleteProjectGroup(params.groupId) + }), + defineMethod({ + name: 'projectGroup.updateMany', + permission: 'workspace', + params: ProjectGroupUpdateMany, + handler: async (params, { runtime }) => ({ + groups: await runtime.updateProjectGroups(params.mutations) + }) + }), + defineMethod({ + name: 'projectGroup.moveProject', + permission: 'workspace', + params: ProjectGroupMoveProject, + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient( + await context.runtime.moveProjectToGroup( + params.repo, + params.groupId ?? null, + params.order, + params.connectionId + ), + context + ) + }) + }), + defineMethod({ + name: 'projectGroup.scanNested', + permission: 'workspace', + params: ProjectGroupScanNested, + handler: async (params, { runtime }) => runtime.scanNestedRepos(params.path) + }), + defineMethod({ + name: 'projectGroup.importNested', + permission: 'workspace', + params: ProjectGroupImportNested, + handler: async (params, { runtime }) => runtime.importNestedRepos(params) + }) +] diff --git a/src/main/runtime/rpc/methods/projects/repo.ts b/src/main/runtime/rpc/methods/projects/repo.ts index e3987d8a38d..dd1bfdbb411 100644 --- a/src/main/runtime/rpc/methods/projects/repo.ts +++ b/src/main/runtime/rpc/methods/projects/repo.ts @@ -1,5 +1,6 @@ import { defineMethod } from '../../core' import { resolveReplySnapshot } from '../../content-snapshot-id' +import { PROJECT_GROUP_METHODS } from './project-group-rpc-methods' import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from '../workspaces/folder-workspace' import { @@ -18,12 +19,6 @@ import { projectRepoVisibilityForClient } from '../../repo-visibility-projection' import { - ProjectGroupCreate, - ProjectGroupImportNested, - ProjectGroupMoveProject, - ProjectGroupScanNested, - ProjectGroupSelector, - ProjectGroupUpdate, RepoClone, RepoCreate, RepoHooksCheck, @@ -54,63 +49,8 @@ export const REPO_METHODS = [ } }), ...PROJECT_RUNTIME_METHODS, - defineMethod({ - name: 'projectGroup.list', - permission: 'workspace', - params: null, - handler: (_params, { runtime }) => ({ groups: runtime.listVisibleProjectGroups() }) - }), - defineMethod({ - name: 'projectGroup.create', - permission: 'workspace', - params: ProjectGroupCreate, - handler: async (params, { runtime }) => ({ - group: await runtime.createProjectGroup(params) - }) - }), - defineMethod({ - name: 'projectGroup.update', - permission: 'workspace', - params: ProjectGroupUpdate, - handler: async (params, { runtime }) => ({ - group: await runtime.updateProjectGroup(params.groupId, params.updates) - }) - }), - defineMethod({ - name: 'projectGroup.delete', - permission: 'workspace', - params: ProjectGroupSelector, - handler: async (params, { runtime }) => runtime.deleteProjectGroup(params.groupId) - }), - defineMethod({ - name: 'projectGroup.moveProject', - permission: 'workspace', - params: ProjectGroupMoveProject, - handler: async (params, context) => ({ - repo: projectRepoVisibilityForClient( - await context.runtime.moveProjectToGroup( - params.repo, - params.groupId ?? null, - params.order, - params.connectionId - ), - context - ) - }) - }), + ...PROJECT_GROUP_METHODS, ...FOLDER_WORKSPACE_METHODS, - defineMethod({ - name: 'projectGroup.scanNested', - permission: 'workspace', - params: ProjectGroupScanNested, - handler: async (params, { runtime }) => runtime.scanNestedRepos(params.path) - }), - defineMethod({ - name: 'projectGroup.importNested', - permission: 'workspace', - params: ProjectGroupImportNested, - handler: async (params, { runtime }) => runtime.importNestedRepos(params) - }), defineMethod({ name: 'repo.sparsePresets', permission: 'workspace', diff --git a/src/preload/api/project-groups-bridge.ts b/src/preload/api/project-groups-bridge.ts index c14d58b7d00..b7748aca427 100644 --- a/src/preload/api/project-groups-bridge.ts +++ b/src/preload/api/project-groups-bridge.ts @@ -6,6 +6,7 @@ export const projectGroupsApi = { list: () => ipcRenderer.invoke('projectGroups:list'), create: (args) => ipcRenderer.invoke('projectGroups:create', args), update: (args) => ipcRenderer.invoke('projectGroups:update', args), + updateMany: (args) => ipcRenderer.invoke('projectGroups:updateMany', args), delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index e74b1a80af9..16c91ac61d3 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -6,6 +6,7 @@ import type { NestedRepoScanResult, ProjectGroup, ProjectGroupUpdate, + ProjectGroupMutation, ProjectGroupImportMode, ProjectGroupImportResult } from '../../shared/project-group-types' @@ -131,6 +132,9 @@ export type ProjectGroupsApi = { createdFrom?: ProjectGroup['createdFrom'] }) => Promise update: (args: { groupId: string; updates: ProjectGroupUpdate }) => Promise + updateMany: (args: { + mutations: readonly ProjectGroupMutation[] + }) => Promise delete: (args: { groupId: string }) => Promise moveProject: (args: { projectId: string diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.test.ts index f47e4cb68bd..9aa470f600f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' import { + getRootProjectHeaderCountByOccurrenceKey, getRootProjectHeaderDragKeys, getSidebarRootProjectHeaderKeys, measureRootProjectHeaderDragRects, @@ -204,6 +205,16 @@ describe('root project header drops', () => { hostId: 'runtime:remote', repeated: true }, + { + type: 'header', + key: 'repo:remote-x', + count: 1, + icon: { kind: 'project' }, + repo: repo('remote-x', { executionHostId: 'runtime:remote' }), + anchorRepo: repo('x'), + hostId: 'runtime:remote', + repeated: true + }, { type: 'header', key: 'repo:y', @@ -220,13 +231,22 @@ describe('root project header drops', () => { 'repo:x', 'host:runtime:remote', 'group:a', + 'repo:x', 'repo:y' ]) expect(getRootProjectHeaderDragKeys(keys, 'group:a', 'runtime:remote')).toEqual([ 'group:a', + 'repo:x', 'repo:y' ]) - expect(getRootProjectHeaderDragKeys(keys, 'repo:x', 'runtime:remote')).toBeUndefined() + expect(getRootProjectHeaderDragKeys(keys, 'repo:y', 'local')).toBeUndefined() + expect([...getRootProjectHeaderCountByOccurrenceKey(keys)]).toEqual([ + ['["local","group:a"]', 2], + ['["local","repo:x"]', 2], + ['["runtime:remote","group:a"]', 3], + ['["runtime:remote","repo:x"]', 3], + ['["runtime:remote","repo:y"]', 3] + ]) const container = document.createElement('div') for (const [hostId, key] of [ diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.ts b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.ts index 2a129bf1efa..033f3bdd9c4 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-header-drop.ts @@ -10,6 +10,7 @@ import { import { getDistinctProjectGroups } from '../../../../../../shared/project-groups' import { getLogicalRepoOrderRankById } from '../../../../../../shared/project-section-order' import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows' +import type { GroupHeaderRow } from '../../../../../../shared/sidebar-list/row-types' export type RootProjectHeaderDrag = { rootProjectHeaderKeys?: readonly string[] @@ -49,6 +50,18 @@ export function getRootProjectHeaderDragKeys( return keys.slice(start, end) } +export function getRootProjectHeaderKey(row: GroupHeaderRow): string | undefined { + if ((row.projectGroupDepth ?? 0) !== 0) { + return undefined + } + const repo = row.repo && (row.anchorRepo ?? row.repo) + return repo + ? `repo:${repo.id}` + : row.projectGroup?.id + ? `group:${row.projectGroup.id}` + : undefined +} + export function getSidebarRootProjectHeaderKeys(rows: readonly HostSectionRow[]): string[] { const keys: string[] = [] const seen = new Set() @@ -58,21 +71,12 @@ export function getSidebarRootProjectHeaderKeys(rows: readonly HostSectionRow[]) seen.clear() continue } - if (row.type !== 'header' || (row.projectGroupDepth ?? 0) !== 0) { + if (row.type !== 'header') { continue } - const repo = row.repo - if (repo) { - const key = `repo:${repo.id}` - if (!seen.has(key)) { - keys.push(key) - } - seen.add(key) - } else if (row.projectGroup?.id) { - const key = `group:${row.projectGroup.id}` - if (!seen.has(key)) { - keys.push(key) - } + const key = getRootProjectHeaderKey(row) + if (key && !seen.has(key)) { + keys.push(key) seen.add(key) } } @@ -83,6 +87,28 @@ export function getRootProjectHeaderOccurrenceKey(key: string, hostId?: string): return JSON.stringify([hostId ?? '', key]) } +export function getRootProjectHeaderCountByOccurrenceKey( + keys: readonly string[] | undefined +): Map { + const counts = new Map() + if (!keys) { + return counts + } + let start = 0 + let hostId: string | undefined + for (let end = 0; end <= keys.length; end += 1) { + if (end < keys.length && !keys[end].startsWith('host:')) { + continue + } + for (let index = start; index < end; index += 1) { + counts.set(getRootProjectHeaderOccurrenceKey(keys[index], hostId), end - start) + } + hostId = keys[end]?.slice('host:'.length) + start = end + 1 + } + return counts +} + export function measureRootProjectHeaderDragRects( container: HTMLElement, keys: readonly string[], diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.test.ts index 2d185f3dfa5..42768a2431d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.test.ts @@ -28,10 +28,63 @@ function group(id: string, overrides: Partial = {}): ProjectGroup function repo(id: string, overrides: Partial = {}): Repo { return { id, path: `/${id}`, displayName: id, badgeColor: '', addedAt: 1, ...overrides } } +function applyGroupUpdate(base: ProjectGroup, updates: ProjectGroupUpdate): ProjectGroup { + const { rootProjectOrder, ...rest } = updates + const next: ProjectGroup = { ...base, ...rest } + if (rootProjectOrder === null) { + delete next.rootProjectOrder + } else if (rootProjectOrder !== undefined) { + next.rootProjectOrder = rootProjectOrder + } + return next +} beforeEach(() => clearRuntimeCompatibilityCache()) describe('saving root project order', () => { + it('inserts a project among legacy groups and preserves their relative order', async () => { + const groups = [group('a'), group('b', { tabOrder: 1 }), group('c', { tabOrder: 2 })] + const project = repo('x') + const store = createTestStore() + store.setState({ projectGroups: groups, repos: [project] }) + vi.stubGlobal('window', { + api: { + projectGroups: { + updateMany: async ({ + mutations + }: { + mutations: { groupId: string; updates: ProjectGroupUpdate }[] + }) => + mutations + .map(({ groupId, updates }) => ({ + ...groups.find((entry) => entry.id === groupId), + ...updates + })) + .toReversed() + }, + repos: { + update: async ({ updates }: { updates: Partial }) => ({ ...project, ...updates }) + } + } + }) + + await expect( + saveRootProjectOrderForSidebarDrop({ + state: store.getState(), + visibleKeys: ['group:a', 'group:b', 'group:c', 'repo:x'], + draggedKey: 'repo:x', + dropIndex: 1, + onCommit: () => {} + }) + ).resolves.toBe(true) + expect(store.getState().projectGroups).toMatchObject([ + { id: 'a', tabOrder: 0 }, + { id: 'b', rootProjectOrder: 1000, tabOrder: 1 }, + { id: 'c', rootProjectOrder: 2000, tabOrder: 2 } + ]) + expect(store.getState().repos).toMatchObject([{ id: 'x', projectGroupOrder: 0 }]) + }) + it('persists the moved logical group on both owners and leaves unrelated roots intact', async () => { const local = group('a', { rootProjectOrder: 0 }) const remote = { ...local, executionHostId: 'runtime:remote' } @@ -254,6 +307,59 @@ describe('saving root project order', () => { expect(store.getState().repos).toEqual([project]) }) + it('restores surviving groups when another group disappears before rollback', async () => { + const groups = [group('a'), group('b', { tabOrder: 1 }), group('c', { tabOrder: 2 })] + const persisted = new Map(groups.map((entry) => [entry.id, { ...entry }])) + const store = createTestStore() + store.setState({ projectGroups: groups, repos: [repo('x')] }) + vi.stubGlobal('window', { + api: { + projectGroups: { + updateMany: async ({ + mutations + }: { + mutations: { groupId: string; updates: ProjectGroupUpdate }[] + }) => { + const updated = mutations.map(({ groupId, updates }) => { + const entry = applyGroupUpdate(persisted.get(groupId)!, updates) + persisted.set(groupId, entry) + return entry + }) + store.setState({ projectGroups: groups.filter((entry) => entry.id !== 'b') }) + return updated + }, + update: async ({ + groupId, + updates + }: { + groupId: string + updates: ProjectGroupUpdate + }) => { + const entry = applyGroupUpdate(persisted.get(groupId)!, updates) + persisted.set(groupId, entry) + return entry + } + }, + repos: { update: async () => null } + } + }) + + await expect( + saveRootProjectOrderForSidebarDrop({ + state: store.getState(), + visibleKeys: ['group:a', 'group:b', 'group:c', 'repo:x'], + draggedKey: 'repo:x', + dropIndex: 1, + onCommit: () => {} + }) + ).resolves.toBe(false) + expect(persisted.get('c')).toEqual(groups[2]) + expect(store.getState().projectGroups).toEqual([ + groups[0], + { ...groups[2], executionHostId: 'local' } + ]) + }) + it('merges confirmed initial ranks into a group already refreshed by the catalog', async () => { const remote = group('remote', { rootProjectOrder: 4000, tabOrder: 4 }) const created = group('new') diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.ts b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.ts index fcd94e5e64e..2849a1c1c20 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/root-project-order-commit.ts @@ -4,10 +4,11 @@ import { runtimeEnvironmentSupportsCapability } from '@/runtime/rpc/compatibilit import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target' import { getProjectGroupHostId } from '@/store/project-groups/project-group-owner-routing' import type { RepoSlice } from '@/store/repos/repo-state' +import { getIndexedReposById } from '@/store/repos/repository-catalog-index' import { getRootProjectOrderUpdatesForSidebarDrop } from './root-project-header-drop' export async function saveRootProjectOrderForSidebarDrop(args: { - state: Pick + state: Pick visibleKeys: readonly string[] draggedKey: string dropIndex: number @@ -22,26 +23,22 @@ export async function saveRootProjectOrderForSidebarDrop(args: { if (updates.length === 0) { return true } + const groupCopiesById = Map.groupBy(state.projectGroups, (group) => group.id) const groupUpdates = updates.flatMap((update) => { if (update.entry.kind !== 'group') { return [] } const groupId = update.entry.group.id - return state.projectGroups - .filter((group) => group.id === groupId) - .map((group) => ({ ...update, group })) + return (groupCopiesById.get(groupId) ?? []).map((group) => ({ ...update, group })) }) - const groupIds = new Set(state.projectGroups.map((group) => group.id)) const repoUpdates = updates.flatMap((update) => { if (update.entry.kind !== 'repo' || update.rootProjectOrder === undefined) { return [] } const repoId = update.entry.repo.id const order = update.rootProjectOrder - return state.repos - .filter( - (repo) => repo.id === repoId && (!repo.projectGroupId || !groupIds.has(repo.projectGroupId)) - ) + return getIndexedReposById(state.repos, repoId) + .filter((repo) => !repo.projectGroupId || !groupCopiesById.has(repo.projectGroupId)) .map((repo) => ({ repo, order })) }) const targets = groupUpdates.flatMap((update) => { @@ -68,28 +65,41 @@ export async function saveRootProjectOrderForSidebarDrop(args: { return false } args.onCommit() - const mutations = groupUpdates.map(({ group, rootProjectOrder, tabOrder }) => ({ - save: () => - state.updateProjectGroup( - group.id, - { - ...(rootProjectOrder !== undefined ? { rootProjectOrder } : {}), - ...(tabOrder !== undefined ? { tabOrder } : {}) - }, - { hostId: getProjectGroupHostId(group) } - ), - restore: () => - state.updateProjectGroup( - group.id, - { - ...(rootProjectOrder !== undefined - ? { rootProjectOrder: group.rootProjectOrder ?? null } - : {}), - ...(tabOrder !== undefined ? { tabOrder: group.tabOrder } : {}) - }, - { hostId: getProjectGroupHostId(group) } - ) - })) + const mutations: { save: () => Promise; restore: () => Promise }[] = [] + if (groupUpdates.length > 0) { + mutations.push({ + save: () => + state.updateProjectGroups( + groupUpdates.map(({ group, rootProjectOrder, tabOrder }) => ({ + groupId: group.id, + hostId: getProjectGroupHostId(group), + updates: { + ...(rootProjectOrder !== undefined ? { rootProjectOrder } : {}), + ...(tabOrder !== undefined ? { tabOrder } : {}) + } + })) + ), + restore: () => + Promise.all( + groupUpdates.map(({ group, rootProjectOrder, tabOrder }) => + state + .updateProjectGroups([ + { + groupId: group.id, + hostId: getProjectGroupHostId(group), + updates: { + ...(rootProjectOrder !== undefined + ? { rootProjectOrder: group.rootProjectOrder ?? null } + : {}), + ...(tabOrder !== undefined ? { tabOrder: group.tabOrder } : {}) + } + } + ]) + .catch(() => false) + ) + ).then((results) => results.every(Boolean)) + }) + } for (const { repo, order } of repoUpdates) { const hostId = getRepoExecutionHostId(repo) mutations.push({ diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts index c3d18bc3ccf..42e2b6e0154 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts @@ -24,7 +24,10 @@ import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' import { useProjectGroupHeaderDrag } from './project-group-header-drag' import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from '../viewport/use-scroll-suppression' -import { getSidebarRootProjectHeaderKeys } from './root-project-header-drop' +import { + getRootProjectHeaderCountByOccurrenceKey, + getSidebarRootProjectHeaderKeys +} from './root-project-header-drop' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -105,6 +108,10 @@ export function useWorktreeSidebarHeaderDrag(args: { () => new Map(projectGroups.map((group) => [group.id, group])), [projectGroups] ) + const rootProjectHeaderCountByOccurrenceKey = useMemo( + () => getRootProjectHeaderCountByOccurrenceKey(rootProjectHeaderKeys), + [rootProjectHeaderKeys] + ) // Why: an id shared by two hosts has no single owner, so leave it unrouted rather than guess. const projectGroupOwnerHostIdByGroupId = useMemo(() => { const byGroupId = new Map() @@ -259,11 +266,15 @@ export function useWorktreeSidebarHeaderDrag(args: { ) const rootProjectHeaderSectionEndByKey = useMemo( - () => getRootProjectHeaderSectionEndByKey(renderRows, firstHeaderIndex), - [renderRows, firstHeaderIndex] + () => + hasProjectGroups + ? getRootProjectHeaderSectionEndByKey(renderRows, firstHeaderIndex) + : new Map(), + [hasProjectGroups, renderRows, firstHeaderIndex] ) return { + rootProjectHeaderCountByOccurrenceKey, rootProjectHeaderSectionEndByKey, canReorderRepoHeaders, canReorderProjectGroupHeaders, diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index dc4df2b769f..cd13338ba46 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -1,5 +1,5 @@ import { - getRootProjectHeaderDragKeys, + getRootProjectHeaderKey, getRootProjectHeaderOccurrenceKey } from '../drag/root-project-header-drop' import React from 'react' @@ -79,7 +79,7 @@ export function renderWorktreeSectionHeaderRow( ) const headerRepo = isRepoHeader ? usesRootProjectDrag - ? row.repo + ? (row.anchorRepo ?? row.repo) : getHeaderDragRepo(row) : undefined const projectIdForHeader = headerRepo?.id @@ -104,19 +104,14 @@ export function renderWorktreeSectionHeaderRow( projectGroupDragIdForHeader !== undefined ? headerDrag.projectGroupHeaderBucketByGroupId.get(projectGroupDragIdForHeader) : undefined - const rootProjectHeaderKey = usesRootProjectDrag - ? projectIdForHeader - ? `repo:${projectIdForHeader}` - : projectGroupDragIdForHeader - ? `group:${projectGroupDragIdForHeader}` - : undefined - : undefined + const rootProjectHeaderKey = usesRootProjectDrag ? getRootProjectHeaderKey(row) : undefined const rootProjectHeaderCount = rootProjectHeaderKey - ? getRootProjectHeaderDragKeys( - headerDrag.rootProjectHeaderKeys, - rootProjectHeaderKey, - row.hostId - )?.length + ? (headerDrag.rootProjectHeaderCountByOccurrenceKey.get( + getRootProjectHeaderOccurrenceKey(rootProjectHeaderKey, row.hostId) + ) ?? + headerDrag.rootProjectHeaderCountByOccurrenceKey.get( + getRootProjectHeaderOccurrenceKey(rootProjectHeaderKey) + )) : undefined const isDraggableRepoHeader = Boolean( headerDrag.canReorderRepoHeaders && diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/section-header-create-host.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/section-header-create-host.test.tsx index e9f0b80ba5b..810b4bbb9c8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/section-header-create-host.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/section-header-create-host.test.tsx @@ -103,6 +103,7 @@ function headerContext(): SectionHeaderRowContext { repoHeaderSectionEndByRepoId: new Map(), projectGroupHeaderSectionEndByGroupId: new Map(), rootProjectHeaderKeys: undefined, + rootProjectHeaderCountByOccurrenceKey: new Map(), rootProjectHeaderSectionEndByKey: new Map() }, projectActions: { @@ -162,3 +163,48 @@ it("leaves an empty twin header to the composer's focused host, not the registra expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') expect(useAppStore.getState().modalData).not.toHaveProperty('initialWorkspaceTarget') }) + +it('arms a remote root header with its ordering anchor in a view without host headers', () => { + const remote = { ...serverTwin, id: 'remote-twin' } + useAppStore.setState({ repos: [localTwin, remote] }) + const ctx = headerContext() + ctx.headerDrag = { + ...ctx.headerDrag, + canReorderRepoHeaders: true, + rootProjectHeaderKeys: ['group:projects', 'repo:repo-twin'], + rootProjectHeaderCountByOccurrenceKey: new Map([ + ['["","group:projects"]', 2], + ['["","repo:repo-twin"]', 2] + ]), + sidebarRepoHeaderIdsByBucket: new Map([['ungrouped', ['repo-twin']]]), + repoHeaderIndexByRepoId: new Map([['repo-twin', 0]]), + repoHeaderBucketByRepoId: new Map([['repo-twin', 'ungrouped']]) + } + const view = render( + + {renderWorktreeSectionHeaderRow({ + ctx, + row: { + type: 'header', + key: 'remote-header', + count: 1, + icon: { kind: 'project' }, + repo: remote, + anchorRepo: localTwin, + hostId: 'runtime:home', + repeated: true + }, + vItem: { key: 'header', index: 0, start: 0, size: 35, end: 35, lane: 0 }, + isActiveStickyHeader: false, + hasStickyHost: false, + hasHeaderTopSpacing: false, + measureVirtualRowElement: vi.fn() + })} + + ) + const header = view.container.querySelector('[data-root-project-header-key]') + expect(header?.getAttribute('data-root-project-header-key')).toBe('repo:repo-twin') + expect(header?.getAttribute('data-repo-header-id')).toBe('repo-twin') + expect(header?.getAttribute('data-root-project-header-host')).toBe('runtime:home') + expect(header?.getAttribute('data-repo-header-drag-handle')).toBe('') +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx index 5c6841fce7b..f8f77fea67f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx @@ -163,6 +163,7 @@ it('composes real dispatched headers, current repo gates and folder cache refres repoHeaderSectionEndByRepoId: new Map(), projectGroupHeaderSectionEndByGroupId: new Map(), rootProjectHeaderKeys: undefined, + rootProjectHeaderCountByOccurrenceKey: new Map(), rootProjectHeaderSectionEndByKey: new Map() }, projectActions: { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.test.ts new file mode 100644 index 00000000000..1b014b043ec --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from 'vitest' +import type { RenderRow } from '../listing/render-row' +import { getRootProjectHeaderSectionEndByKey } from './worktree-header-section-boundaries' + +it('ends a root group before a removed project header and its pending creation', () => { + const rows: RenderRow[] = [ + { + type: 'header', + key: 'group:projects', + count: 1, + icon: { kind: 'project' }, + projectGroup: { + id: 'projects', + name: 'Projects', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + }, + { + type: 'header', + key: 'repo:member', + count: 0, + icon: { kind: 'project' }, + repo: { id: 'member', path: '/member', displayName: 'Member', badgeColor: '', addedAt: 1 }, + projectGroupDepth: 1 + }, + { type: 'header', key: 'repo:removed', count: 0, icon: { kind: 'project' } }, + { type: 'pending-creation', key: 'creation', creationId: 'creation', repo: undefined } + ] + + expect([...getRootProjectHeaderSectionEndByKey(rows, 0).values()]).toEqual([60]) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.ts index 790550554e6..416fa784afc 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/worktree-header-section-boundaries.ts @@ -1,4 +1,7 @@ -import { getRootProjectHeaderOccurrenceKey } from '../drag/root-project-header-drop' +import { + getRootProjectHeaderKey, + getRootProjectHeaderOccurrenceKey +} from '../drag/root-project-header-drop' import { estimateRenderRowSize } from './virtual-rows' import type { RenderRow } from '../listing/render-row' import { getHeaderDragRepo } from '../../../../../../shared/sidebar-list/row-types' @@ -27,30 +30,6 @@ function findNextHeaderRenderRowIndex(rows: readonly RenderRow[], startIndex: nu return rows.length } -function findProjectGroupSectionEndIndex( - rows: readonly RenderRow[], - startIndex: number, - depth: number -): number { - for (let index = startIndex; index < rows.length; index++) { - const row = rows[index] - if (!row) { - continue - } - if (row.type === 'host-header') { - return index - } - if (row.type !== 'header') { - continue - } - const rowDepth = row.projectGroupDepth ?? 0 - if (rowDepth <= depth || (!row.repo && !row.projectGroup)) { - return index - } - } - return rows.length -} - export function getRepoHeaderSectionEndByRepoId(args: { rows: readonly RenderRow[] firstHeaderIndex: number @@ -78,26 +57,29 @@ export function getProjectGroupHeaderSectionEndByGroupId(args: { }): Map { const rowStarts = getEstimatedRenderRowStarts(args.rows, args.firstHeaderIndex) const sectionEndByGroupId = new Map() + const pendingGroups: { groupId: string; depth: number }[] = [] for (let index = 0; index < args.rows.length; index++) { const row = args.rows[index] - const projectGroupHeader = - row?.type === 'header' && - !row.repo && - !row.repeated && - row.projectGroup && - typeof row.projectGroup.id === 'string' - ? { row, groupId: row.projectGroup.id } - : null - const groupId = projectGroupHeader?.groupId - if (!groupId) { + if (row.type !== 'header' && row.type !== 'host-header') { continue } - const depth = projectGroupHeader.row.projectGroupDepth ?? 0 - const endIndex = findProjectGroupSectionEndIndex(args.rows, index + 1, depth) - sectionEndByGroupId.set( - groupId, - rowStarts[endIndex >= 0 ? endIndex : args.rows.length] ?? rowStarts[args.rows.length] ?? 0 - ) + const depth = + row.type === 'host-header' || (!row.repo && !row.projectGroup) + ? Number.NEGATIVE_INFINITY + : (row.projectGroupDepth ?? 0) + while (pendingGroups.length > 0) { + const pending = pendingGroups.at(-1) + if (!pending || pending.depth < depth) { + break + } + pendingGroups.pop() + sectionEndByGroupId.set(pending.groupId, rowStarts[index]) + } + if (row.type === 'header' && !row.repo && !row.repeated && row.projectGroup?.id) { + const groupId = row.projectGroup.id + sectionEndByGroupId.set(groupId, rowStarts[args.rows.length]) + pendingGroups.push({ groupId, depth: row.projectGroupDepth ?? 0 }) + } } return sectionEndByGroupId } @@ -110,15 +92,10 @@ export function getRootProjectHeaderSectionEndByKey( const ends = new Map() let pendingKey: string | undefined for (const [index, row] of rows.entries()) { - const key = - row.type === 'header' && (row.projectGroupDepth ?? 0) === 0 - ? row.repo - ? `repo:${row.repo.id}` - : row.projectGroup?.id - ? `group:${row.projectGroup.id}` - : undefined - : undefined - if (key || row.type === 'host-header') { + const key = row.type === 'header' ? getRootProjectHeaderKey(row) : undefined + const closesSection = + key || row.type === 'host-header' || (row.type === 'header' && !row.repo && !row.projectGroup) + if (closesSection) { if (pendingKey) { ends.set(pendingKey, rowStarts[index]) } diff --git a/src/renderer/src/store/project-groups/project-group-batch-update.ts b/src/renderer/src/store/project-groups/project-group-batch-update.ts new file mode 100644 index 00000000000..967e3cef0fc --- /dev/null +++ b/src/renderer/src/store/project-groups/project-group-batch-update.ts @@ -0,0 +1,93 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { RepoSlice } from '../repos/repo-state' +import type { ProjectGroup, ProjectGroupMutation } from '../../../../shared/project-group-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { + catalogOwnsHost, + getProjectGroupHostId, + resolveProjectGroupOwnerHostId, + runtimeTargetForProjectGroupOwner +} from './project-group-owner-routing' +import { getRuntimeTargetHostId } from '../../runtime/rpc/targets/runtime-client-target' +import { adoptFromEndpoint } from '../host-catalog/endpoint-catalog-adoption' +import { persistProjectGroupUpdates } from './project-group-update-persistence' + +export function createProjectGroupBatchUpdateAction( + set: Parameters>[0], + get: Parameters>[1] +): RepoSlice['updateProjectGroups'] { + return async (mutations) => { + const state = get() + const batches = new Map< + string, + { + target: RuntimeClientTarget + mutations: (ProjectGroupMutation & { ownerHostId: string | null })[] + } + >() + for (const { groupId, updates, hostId } of mutations) { + const target = runtimeTargetForProjectGroupOwner(state, groupId, hostId) + if (!target) { + return false + } + const ownerHostId = resolveProjectGroupOwnerHostId(state, groupId, hostId) + const key = getRuntimeTargetHostId(target) + let batch = batches.get(key) + if (!batch) { + batch = { target, mutations: [] } + batches.set(key, batch) + } + batch.mutations.push({ groupId, updates, ownerHostId }) + } + const results = await Promise.all( + [...batches.values()].map(async ({ target, mutations }) => { + try { + const groups = await persistProjectGroupUpdates(target, mutations) + return groups.map((group, index) => ({ + group: group ? adoptFromEndpoint(target, { kind: 'projectGroup', row: group }) : null, + ownerHostId: mutations[index].ownerHostId, + groupId: mutations[index].groupId + })) + } catch (err) { + console.error('Failed to update project groups:', err) + return mutations.map(({ ownerHostId, groupId }) => ({ + group: null, + ownerHostId, + groupId + })) + } + }) + ) + const updatedById = new Map>() + let saved = true + for (const { group, groupId, ownerHostId } of results.flat()) { + if (!group) { + saved = false + continue + } + let owners = updatedById.get(groupId) + if (!owners) { + owners = new Map() + updatedById.set(groupId, owners) + } + owners.set(ownerHostId, group) + } + if (updatedById.size > 0) { + set((s) => ({ + projectGroups: s.projectGroups.map((group) => { + const owners = updatedById.get(group.id) + const hostId = getProjectGroupHostId(group) + return ( + owners?.get(hostId) ?? + (catalogOwnsHost('local', hostId) ? owners?.get('local') : undefined) ?? + owners?.get(null) ?? + group + ) + }), + folderWorkspacePathStatuses: {} + })) + } + return saved + } +} diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index a2b648bcf48..9c4ae9d3a42 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -1,7 +1,4 @@ -import { - initializeNewRootProjectGroupOrder, - persistProjectGroupUpdate -} from './project-group-update-persistence' +import { initializeNewRootProjectGroupOrder } from './project-group-update-persistence' import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { ProjectGroup } from '../../../../shared/project-group-types' @@ -101,31 +98,8 @@ export function createProjectGroupMutationActions( } }, - updateProjectGroup: async (groupId, updates, options) => { - try { - // Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host. - const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) - const target = runtimeTargetForProjectGroupOwner(get(), groupId, options?.hostId) - if (!target) { - return false - } - const updated = await persistProjectGroupUpdate(target, groupId, updates) - if (!updated) { - return false - } - const ownedGroup = adoptFromEndpoint(target, { kind: 'projectGroup', row: updated }) - set((s) => ({ - projectGroups: s.projectGroups.map((group) => - projectGroupMatchesOwnerHost(group, groupId, ownerHostId) ? ownedGroup : group - ), - folderWorkspacePathStatuses: {} - })) - return true - } catch (err) { - console.error('Failed to update project group:', err) - return false - } - }, + updateProjectGroup: (groupId, updates, options) => + get().updateProjectGroups([{ groupId, updates, hostId: options?.hostId }]), deleteProjectGroup: async (groupId, options) => { try { diff --git a/src/renderer/src/store/project-groups/project-group-update-persistence.ts b/src/renderer/src/store/project-groups/project-group-update-persistence.ts index e530ce04c01..09f469d8e1c 100644 --- a/src/renderer/src/store/project-groups/project-group-update-persistence.ts +++ b/src/renderer/src/store/project-groups/project-group-update-persistence.ts @@ -1,11 +1,25 @@ import { getNextRootProjectOrder } from '../../../../shared/root-project-order' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { ProjectGroup, ProjectGroupMutation } from '../../../../shared/project-group-types' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import type { RepoSlice } from '../repos/repo-state' -import { PROJECT_ROOT_ORDER_RUNTIME_CAPABILITY } from '../../../../shared/project-group-order-capability' +import { + PROJECT_GROUP_UPDATE_MANY_RUNTIME_CAPABILITY, + PROJECT_ROOT_ORDER_RUNTIME_CAPABILITY +} from '../../../../shared/project-group-order-capability' import { runtimeEnvironmentSupportsCapability } from '@/runtime/rpc/compatibility/runtime-capability-proof' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +function groupOrderMatchesUpdate( + group: ProjectGroup, + updates: ProjectGroupMutation['updates'] +): boolean { + return ( + (updates.rootProjectOrder === undefined || + (group.rootProjectOrder ?? null) === updates.rootProjectOrder) && + (updates.tabOrder === undefined || group.tabOrder === updates.tabOrder) + ) +} + export async function persistProjectGroupUpdate( target: RuntimeClientTarget, groupId: string, @@ -32,17 +46,64 @@ export async function persistProjectGroupUpdate( { timeoutMs: 15_000 } ) ).group - if ( - !updated || - (updates.rootProjectOrder !== undefined && - (updated.rootProjectOrder ?? null) !== updates.rootProjectOrder) || - (updates.tabOrder !== undefined && updated.tabOrder !== updates.tabOrder) - ) { + if (!updated || !groupOrderMatchesUpdate(updated, updates)) { return null } return updated } +export async function persistProjectGroupUpdates( + target: RuntimeClientTarget, + mutations: readonly ProjectGroupMutation[] +): Promise<(ProjectGroup | null)[]> { + if (mutations.length === 0) { + return [] + } + if ( + mutations.length === 1 || + (target.kind === 'environment' && + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + PROJECT_GROUP_UPDATE_MANY_RUNTIME_CAPABILITY + ))) + ) { + return Promise.all( + mutations.map(({ groupId, updates }) => + persistProjectGroupUpdate(target, groupId, updates).catch((err) => { + console.error('Failed to update project group:', err) + return null + }) + ) + ) + } + if ( + target.kind === 'environment' && + mutations.some(({ updates }) => updates.rootProjectOrder !== undefined) && + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + PROJECT_ROOT_ORDER_RUNTIME_CAPABILITY + )) + ) { + return mutations.map(() => null) + } + const updated = + target.kind === 'local' + ? await window.api.projectGroups.updateMany({ mutations }) + : ( + await callRuntimeRpc<{ groups: ProjectGroup[] | null }>( + target, + 'projectGroup.updateMany', + { mutations }, + { timeoutMs: 15_000 } + ) + ).groups + const byId = new Map(updated?.map((group) => [group.id, group])) + return mutations.map(({ groupId, updates }) => { + const group = byId.get(groupId) + return group && groupOrderMatchesUpdate(group, updates) ? group : null + }) +} + export async function initializeNewRootProjectGroupOrder( target: RuntimeClientTarget, group: ProjectGroup, @@ -65,6 +126,9 @@ export async function initializeNewRootProjectGroupOrder( (await persistProjectGroupUpdate(target, group.id, { rootProjectOrder, tabOrder: tabOrder + 1000 - }).catch(() => null)) ?? group + }).catch((err) => { + console.error('Failed to initialize project group order:', err) + return null + })) ?? group ) } diff --git a/src/renderer/src/store/repos/create-repo-slice.ts b/src/renderer/src/store/repos/create-repo-slice.ts index a87c926a9ad..ceb5d7fe6ac 100644 --- a/src/renderer/src/store/repos/create-repo-slice.ts +++ b/src/renderer/src/store/repos/create-repo-slice.ts @@ -9,6 +9,7 @@ import { createFolderWorkspaceCatalogActions } from '../folder-workspaces/folder import { createFolderPathStatusActions } from '../folder-workspaces/folder-path-status-actions' import { createNestedRepositoryActions } from '../project-groups/nested-repository-operations' import { createProjectGroupMutationActions } from '../project-groups/project-group-mutations' +import { createProjectGroupBatchUpdateAction } from '../project-groups/project-group-batch-update' import { createFolderWorkspaceMutationActions } from '../folder-workspaces/folder-workspace-mutations' import { createRepoAddActions } from './repo-add-actions' import { createProjectHostSetupActions } from '../projects/project-host-setup-actions' @@ -73,6 +74,7 @@ export const createRepoSlice: StateCreator = (set, updateFolderWorkspace: folderWorkspaceMutations.updateFolderWorkspace, deleteFolderWorkspace: folderWorkspaceMutations.deleteFolderWorkspace, updateProjectGroup: projectGroupMutations.updateProjectGroup, + updateProjectGroups: createProjectGroupBatchUpdateAction(set, get), deleteProjectGroup: projectGroupMutations.deleteProjectGroup, deleteProjectGroupWithContainedProjects: projectGroupMutations.deleteProjectGroupWithContainedProjects, diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 683001aec29..b4f327ed498 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -7,6 +7,7 @@ import type { NestedRepoScanResult, ProjectGroup, ProjectGroupUpdate, + ProjectGroupMutation, ProjectGroupImportResult } from '../../../../shared/project-group-types' import type { @@ -273,6 +274,9 @@ export type RepoSlice = { updates: ProjectGroupUpdate, options?: { hostId?: ExecutionHostId } ) => Promise + updateProjectGroups: ( + mutations: readonly (ProjectGroupMutation & { hostId?: ExecutionHostId })[] + ) => Promise deleteProjectGroup: (groupId: string, options?: { hostId?: ExecutionHostId }) => Promise deleteProjectGroupWithContainedProjects: ( groupId: string, diff --git a/src/shared/project-group-order-capability.ts b/src/shared/project-group-order-capability.ts index 63a2fa8df9c..ae1fe9db71f 100644 --- a/src/shared/project-group-order-capability.ts +++ b/src/shared/project-group-order-capability.ts @@ -1 +1,2 @@ export const PROJECT_ROOT_ORDER_RUNTIME_CAPABILITY = 'projectGroup.root-project-order.v1' as const +export const PROJECT_GROUP_UPDATE_MANY_RUNTIME_CAPABILITY = 'projectGroup.update-many.v1' as const diff --git a/src/shared/project-group-types.ts b/src/shared/project-group-types.ts index 24b22b80ea3..034868f823a 100644 --- a/src/shared/project-group-types.ts +++ b/src/shared/project-group-types.ts @@ -22,6 +22,8 @@ export type ProjectGroupUpdate = Partial< Pick > & { rootProjectOrder?: number | null } +export type ProjectGroupMutation = { groupId: string; updates: ProjectGroupUpdate } + export type NestedRepoScanOptions = { maxDepth?: number maxRepos?: number diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 3ff46af38e3..9a66b201341 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,4 +1,7 @@ -import { PROJECT_ROOT_ORDER_RUNTIME_CAPABILITY } from './project-group-order-capability' +import { + PROJECT_GROUP_UPDATE_MANY_RUNTIME_CAPABILITY, + PROJECT_ROOT_ORDER_RUNTIME_CAPABILITY +} from './project-group-order-capability' import { EMULATOR_RUNTIME_CAPABILITIES } from './emulator-runtime-capabilities' import { ACCOUNT_RUNTIME_CAPABILITIES } from './account-runtime-capabilities' import * as agentLaunchCapabilities from './agent-launch/contracts/agent-launch-runtime-capability' @@ -103,6 +106,7 @@ export const RUNTIME_CAPABILITIES = /* @__PURE__ */ (() => ...AUTOMATION_RUNTIME_CAPABILITIES, ...WORKTREE_RUNTIME_CAPABILITIES, PROJECT_ROOT_ORDER_RUNTIME_CAPABILITY, + PROJECT_GROUP_UPDATE_MANY_RUNTIME_CAPABILITY, ...PREFLIGHT_RUNTIME_CAPABILITIES, ...WORKSPACE_PORTS_RUNTIME_CAPABILITIES, ...SESSION_TABS_RUNTIME_CAPABILITIES, diff --git a/src/shared/rpc-contract/repo-params.ts b/src/shared/rpc-contract/repo-params.ts index bb913ea2fc1..2e68770b6b7 100644 --- a/src/shared/rpc-contract/repo-params.ts +++ b/src/shared/rpc-contract/repo-params.ts @@ -81,6 +81,10 @@ export const ProjectGroupSelector = z.object({ groupId: requiredString('Missing group id') }) +export const ProjectGroupUpdateMany = z.object({ + mutations: z.array(ProjectGroupUpdate) +}) + // Why `connectionId` is optional: older clients omit it, and older hosts strip it (bare selector). export const ProjectGroupMoveProject = z.object({ repo: requiredString('Missing repo selector'), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 605a277feb6..3ee7e258cce 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -575,6 +575,7 @@ import { ProjectGroupScanNested, ProjectGroupSelector, ProjectGroupUpdate, + ProjectGroupUpdateMany, RepoClone, RepoCreate, RepoHooksCheck, @@ -1432,6 +1433,7 @@ export const RPC_PARAMS_BY_METHOD = { 'projectGroup.moveProject': ProjectGroupMoveProject, 'projectGroup.scanNested': ProjectGroupScanNested, 'projectGroup.update': ProjectGroupUpdate, + 'projectGroup.updateMany': ProjectGroupUpdateMany, 'projectHostSetup.clone': ProjectHostSetupClone, 'projectHostSetup.create': ProjectHostSetupCreate, 'projectHostSetup.delete': ProjectHostSetupDelete, From 0680d9753ebd9c788ef15551f22f1ed039a74f04 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 11:44:48 -0800 Subject: [PATCH 036/138] test: remove low-value browser, CLI and budget checks (#40459) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 5 - src/cli/browser/browser.test.ts | 91 --------------- .../worker-list-run-scope.test.ts | 96 --------------- src/cli/index-worktree-wsl-selection.test.ts | 14 --- src/cli/worktree-selector-caller-host.test.ts | 30 ----- .../ReopenBrowserPageOnServerButton.test.tsx | 42 ------- .../annotate/markup-drawing-model.test.ts | 26 ----- .../annotate/useMarkupEditor.test.ts | 8 -- .../use-browser-chrome-tool-fold.test.tsx | 21 +--- ...er-client-hosted-download-notices.test.tsx | 103 ---------------- ...rowser-client-page-paint-retention.test.ts | 55 --------- .../browser-annotation-geometry.test.ts | 7 -- .../host-guest/browser-focus.test.ts | 20 ---- ...k-listener-command-code-transcript.test.ts | 110 +----------------- .../providers/jcode-turn-prompt.test.ts | 13 --- 15 files changed, 2 insertions(+), 639 deletions(-) delete mode 100644 src/cli/browser/browser.test.ts delete mode 100644 src/cli/handlers/orchestration/worker-list-run-scope.test.ts delete mode 100644 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx delete mode 100644 src/renderer/src/components/browser-pane/browser-client-hosted-download-notices.test.tsx delete mode 100644 src/renderer/src/components/browser-pane/browser-client-page-paint-retention.test.ts delete mode 100644 src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 396968458f5..ee0501b0119 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -311,7 +311,6 @@ "src/cli/automation-owner-conflict-recovery.test.ts": 448, "src/cli/automation-runs-paging.test.ts": 569, "src/cli/browser/browser-empty-values.test.ts": 313, - "src/cli/browser/browser.test.ts": 431, "src/cli/codex-command-classification.test.ts": 65, "src/cli/command-suggestion-budget.test.ts": 132, "src/cli/command-suggestion-destructive-families.test.ts": 226, @@ -353,7 +352,6 @@ "src/cli/handlers/orchestration/concise-list-row-size.test.ts": 492, "src/cli/handlers/orchestration/mutation-request.test.ts": 4146, "src/cli/handlers/orchestration/older-runtime-run-binding.test.ts": 547, - "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 495, "src/cli/handlers/orchestration/worker-output.test.ts": 79, "src/cli/handlers/orchestration/worker-start-implicit-coordinator.test.ts": 522, "src/cli/handlers/plugin-dev-watch.test.ts": 942, @@ -4735,7 +4733,6 @@ "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 3085, "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.markup.test.tsx": 4575, "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 6541, - "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 497, "src/renderer/src/components/browser-pane/annotate/MarkupOverlay.test.tsx": 1124, "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 74, "src/renderer/src/components/browser-pane/annotate/browser-grab-toast-owner-cleanup.test.tsx": 1007, @@ -4753,7 +4750,6 @@ "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 3808, "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx": 307, "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.test.tsx": 1874, - "src/renderer/src/components/browser-pane/browser-client-hosted-download-notices.test.tsx": 1020, "src/renderer/src/components/browser-pane/browser-client-page-attachment-scan-budget.test.ts": 464, "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 315, "src/renderer/src/components/browser-pane/browser-client-page-profile-staging.test.ts": 434, @@ -4769,7 +4765,6 @@ "src/renderer/src/components/browser-pane/history/use-browser-history-shortcut.test.tsx": 1990, "src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.test.ts": 228, "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 45, - "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 180, "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 1724, "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-policy.test.ts": 2035, "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 1839, diff --git a/src/cli/browser/browser.test.ts b/src/cli/browser/browser.test.ts deleted file mode 100644 index 6684f20fdeb..00000000000 --- a/src/cli/browser/browser.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RuntimeTypes from '../runtime/types' - -const callMock = vi.fn() - -vi.mock('../runtime/types', () => { - class RuntimeClientError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.code = code - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - - return { RuntimeClientError, RuntimeRpcFailureError } -}) - -vi.mock('../runtime-client', async () => { - class RuntimeClient { - call = callMock - userDataPath = '/tmp/orca-dev-profile' - getCliStatus = vi.fn() - openOrca = vi.fn() - } - - const { RuntimeClientError, RuntimeRpcFailureError } = - await vi.importMock('../runtime/types') - - return { - RuntimeClient, - RuntimeClientError, - RuntimeRpcFailureError - } -}) - -import { main } from '../index' -import { okFixture, queueFixtures } from '../test-fixtures' - -describe('orca cli browser page targeting', () => { - beforeEach(() => { - callMock.mockReset() - }) - - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllEnvs() - }) - - it('saves screenshots where the CLI runs and prints the path instead of base64', async () => { - const screenshotDir = mkdtempSync(join(tmpdir(), 'orca-browser-shot-')) - vi.stubEnv('ORCA_BROWSER_SCREENSHOT_TMPDIR', screenshotDir) - queueFixtures( - callMock, - okFixture('req_shot', { data: Buffer.from('png-bytes').toString('base64'), format: 'png' }), - okFixture('req_unchanged', { data: '', format: 'png', changed: false }) - ) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - - try { - await main(['screenshot', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree') - await main(['screenshot', '--page', 'page-1', '--if-changed'], '/tmp/not-an-orca-worktree') - - const saved = JSON.parse(String(log.mock.calls[0][0])).result - expect(saved.data).toBeUndefined() - expect(saved.path).toBe(join(screenshotDir, 'req_shot-screenshot.png')) - expect(readFileSync(saved.path, 'utf8')).toBe('png-bytes') - expect(callMock).toHaveBeenLastCalledWith('browser.screenshot', { - page: 'page-1', - ifChanged: true - }) - expect(log).toHaveBeenLastCalledWith( - 'Screenshot unchanged since the last --if-changed capture; nothing saved' - ) - } finally { - rmSync(screenshotDir, { recursive: true, force: true }) - } - }) -}) diff --git a/src/cli/handlers/orchestration/worker-list-run-scope.test.ts b/src/cli/handlers/orchestration/worker-list-run-scope.test.ts deleted file mode 100644 index 265fd666e04..00000000000 --- a/src/cli/handlers/orchestration/worker-list-run-scope.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ORCHESTRATION_HANDLERS } from '../orchestration' -import { okFixture } from '../../test-fixtures' - -type Call = { name: string; params: Record } - -describe('orchestration worker-list Run scope (CLI handler)', () => { - let calls: Call[] - let logged: string[] - let boundRun: string | null - - const client = { - call: async (name: string, params: Record) => { - calls.push({ name, params }) - if (name === 'orchestration.runCurrent') { - return okFixture('req_current', { run: boundRun ? { id: boundRun } : null }) - } - if (name === 'orchestration.workerList') { - return okFixture('req_list', { - workers: [], - counts: {}, - page: { hasMore: false, nextCursor: null, total: 0 } - }) - } - throw new Error(`unexpected call ${name}`) - } - } - - beforeEach(() => { - calls = [] - logged = [] - boundRun = null - vi.stubEnv('ORCA_TERMINAL_HANDLE', undefined) - vi.stubEnv('ORCA_AGENT_SESSION_ID', undefined) - vi.stubEnv('ORCA_STRUCTURED_SESSION', undefined) - vi.stubEnv('ORCA_PANE_KEY', undefined) - vi.spyOn(console, 'log').mockImplementation((line: string) => { - logged.push(line) - }) - }) - - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllEnvs() - }) - - async function list(flags = new Map(), json = true) { - await ORCHESTRATION_HANDLERS['orchestration worker-list']({ - flags, - client, - cwd: '/tmp/repo', - json - } as never) - expect(calls.map((call) => call.name)).toEqual( - flags.has('run') - ? ['orchestration.workerList'] - : ['orchestration.runCurrent', 'orchestration.workerList'] - ) - expect(logged).toHaveLength(1) - const listCall = calls.find((call) => call.name === 'orchestration.workerList') - const receipt: unknown = json ? JSON.parse(logged[0]) : null - return { listCall, receipt } - } - - it('defaults an unscoped list to the Run bound to the calling terminal', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_coord' - boundRun = 'run_bound' - - const { listCall, receipt } = await list() - - expect(calls[0]).toEqual({ name: 'orchestration.runCurrent', params: { from: 'term_coord' } }) - expect(listCall?.params.run).toBe('run_bound') - expect(receipt).toHaveProperty('result.scope', { run: 'run_bound', source: 'bound' }) - }) - - it('keeps --run as the override and never asks for the binding', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_coord' - boundRun = 'run_bound' - - const { listCall, receipt } = await list(new Map([['run', 'run_other']])) - - expect(calls.map((call) => call.name)).not.toContain('orchestration.runCurrent') - expect(listCall?.params.run).toBe('run_other') - expect(receipt).toHaveProperty('result.scope', { run: 'run_other', source: 'flag' }) - }) - - it('still lists every Run when the caller has no bound Run', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_unbound_shell' - boundRun = null - - const { listCall, receipt } = await list() - - expect(listCall?.params.run).toBeUndefined() - expect(receipt).toHaveProperty('result.scope', { source: 'all' }) - }) -}) diff --git a/src/cli/index-worktree-wsl-selection.test.ts b/src/cli/index-worktree-wsl-selection.test.ts index 119e8b12285..0da84286a57 100644 --- a/src/cli/index-worktree-wsl-selection.test.ts +++ b/src/cli/index-worktree-wsl-selection.test.ts @@ -21,20 +21,6 @@ const upper = buildWorktree(UPPER, 'main', 'abc', 'repo-a') const lower = buildWorktree(LOWER, 'main', 'abc', 'repo-b') describe('WSL enclosing selection with Windows path resolution on every platform', () => { - it.each([[UPPER, String.raw`\\wsl$\ubuntu\home\Alice\App\src`]])( - 'retains original identity for uppercase-tail aliases: %s', - (root, cwd) => { - const row = buildWorktree(root, 'main', 'abc', 'repo-a') - for (const caller of [undefined, row.id]) { - expect(selectEnclosingWorktreeSelectionOnHost([row], cwd, 'local', caller)).toEqual({ - selector: `id:${row.id}`, - worktreeId: row.id - }) - expect(selectEnclosingRepoOnHost([row], cwd, 'local', caller)).toBe('repo-a') - } - } - ) - it('excludes case-distinct directories before caller preference in either order', () => { for (const rows of [ [upper, lower], diff --git a/src/cli/worktree-selector-caller-host.test.ts b/src/cli/worktree-selector-caller-host.test.ts index d68e1d0b544..7dac5e4eca7 100644 --- a/src/cli/worktree-selector-caller-host.test.ts +++ b/src/cli/worktree-selector-caller-host.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - getCallerExecutionHostId, selectEnclosingWorktreeOnHost, selectWorktreeIdOnHost } from './caller-host-worktree-selection' @@ -16,13 +15,6 @@ function row(hostId: 'local' | `ssh:${string}` | undefined, id: string, identity } } -const SSH_CALLER_D = { - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'host d', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'inc-1', - ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'att-1' -} - const TWINS = [ row('ssh:host-a', `repo-a::${PATH}`), row('ssh:host%20d', `repo-d::${PATH}`), @@ -30,28 +22,6 @@ const TWINS = [ ] describe('implicit worktree selection is scoped to the caller host (#21210)', () => { - it('reads the caller host from the relay stamp, encoding the SSH target', () => { - expect(getCallerExecutionHostId(SSH_CALLER_D)).toBe('ssh:host%20d') - expect(getCallerExecutionHostId({})).toBe('local') - expect( - getCallerExecutionHostId({ - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' - }) - ).toBe('local') - }) - - it("resolves an SSH caller's cwd to its own host's twin", () => { - const hostId = getCallerExecutionHostId(SSH_CALLER_D) - - expect(selectEnclosingWorktreeOnHost(TWINS, `${PATH}/src`, hostId)).toBe(`id:repo-d::${PATH}`) - }) - - it('resolves a caller with no host stamp to the local row only', () => { - expect(selectEnclosingWorktreeOnHost(TWINS, PATH, 'local')).toBe(`id:repo-l::${PATH}`) - }) - it('keeps a runtime-stamped local checkout for a local caller', () => { const rows = [ TWINS[0]!, diff --git a/src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx b/src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx deleted file mode 100644 index 7d802303a96..00000000000 --- a/src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const reopenMocks = vi.hoisted(() => ({ reopenBrowserPageOnServer: vi.fn() })) - -vi.mock('./browser-reopen-on-server', () => ({ - reopenBrowserPageOnServer: reopenMocks.reopenBrowserPageOnServer -})) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) - -import { ReopenBrowserPageOnServerButton } from './ReopenBrowserPageOnServerButton' - -afterEach(() => { - cleanup() - vi.useRealTimers() - vi.clearAllMocks() -}) - -function renderButton(): HTMLElement { - render( - - ) - return screen.getByRole('button') -} - -describe('ReopenBrowserPageOnServerButton', () => { - it('rejects a second click while the first is still running', () => { - reopenMocks.reopenBrowserPageOnServer.mockImplementation(() => new Promise(() => {})) - const button = renderButton() - - fireEvent.click(button) - fireEvent.click(button) - - expect(reopenMocks.reopenBrowserPageOnServer).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts b/src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts index 94844bd25a2..5ae19dbdab9 100644 --- a/src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts +++ b/src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts @@ -3,10 +3,7 @@ import { canRedo, commitShape, createMarkupDocument, - redoShape, - setShapes, undoShape, - type MarkupShape, type PenShape } from './markup-drawing-model' @@ -15,18 +12,6 @@ function pen(id: string): PenShape { } describe('markup document undo/redo', () => { - it('undoes and redoes in order', () => { - let doc = createMarkupDocument() - doc = commitShape(doc, pen('a')) - doc = commitShape(doc, pen('b')) - doc = undoShape(doc) - expect(doc.shapes.map((s) => s.id)).toEqual(['a']) - expect(canRedo(doc)).toBe(true) - doc = redoShape(doc) - expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'b']) - expect(canRedo(doc)).toBe(false) - }) - it('forks history: committing after undo discards the redo stack', () => { let doc = createMarkupDocument() doc = commitShape(doc, pen('a')) @@ -36,15 +21,4 @@ describe('markup document undo/redo', () => { expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'c']) expect(canRedo(doc)).toBe(false) }) - - it('undoes an edit (not just an add) via whole-list snapshots', () => { - let doc = commitShape(createMarkupDocument(), pen('a')) - doc = setShapes(doc, []) - expect(doc.shapes).toHaveLength(0) - doc = undoShape(doc) - expect(doc.shapes.map((s) => s.id)).toEqual(['a']) - }) }) - -const _exhaustive: MarkupShape['kind'][] = ['pen', 'highlight', 'arrow', 'rect', 'ellipse', 'text'] -void _exhaustive diff --git a/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts b/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts index a5c73a46513..579bbae50f0 100644 --- a/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts +++ b/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts @@ -63,14 +63,6 @@ describe('useMarkupEditor gestures', () => { return result } - it('commits a drawn stroke exactly once under StrictMode', () => { - const result = renderEditor({ wrapper: StrictMode }) - - drawLine(result, 0) - - expect(result.current.shapes).toHaveLength(1) - }) - it('erases the touched mark, hiding it from the export before release, and undo restores it', () => { const result = renderEditor({ wrapper: StrictMode }) drawLine(result, 0) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx index d72076ce9f7..2a8db973059 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx @@ -3,11 +3,7 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useRef } from 'react' import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot' -import { - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX, - BROWSER_CHROME_FOLD_ORDER, - useBrowserChromeToolFold -} from './use-browser-chrome-tool-fold' +import { BROWSER_CHROME_FOLD_ORDER, useBrowserChromeToolFold } from './use-browser-chrome-tool-fold' const FIXED_WIDTH = 100 const TOOL_WIDTH = 30 @@ -70,22 +66,7 @@ function resizeTo(width: number): void { act(() => resizeCallbacks.forEach((callback) => callback())) } -function expectedFolded(width: number): number { - const room = width - FIXED_WIDTH - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX - const fits = Math.max(0, Math.floor(room / TOOL_WIDTH)) - return Math.max(0, BROWSER_CHROME_FOLD_ORDER.length - fits) -} - describe('useBrowserChromeToolFold', () => { - it('measures when a hidden row becomes visible', () => { - let folded = -1 - layout.rowWidth = 0 - render( (folded = count)} />) - - resizeTo(300) - expect(folded).toBe(expectedFolded(300)) - }) - it('discards measurements when the available tools change', () => { let folded = -1 layout.rowWidth = 300 diff --git a/src/renderer/src/components/browser-pane/browser-client-hosted-download-notices.test.tsx b/src/renderer/src/components/browser-pane/browser-client-hosted-download-notices.test.tsx deleted file mode 100644 index 0408984c7c7..00000000000 --- a/src/renderer/src/components/browser-pane/browser-client-hosted-download-notices.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment happy-dom -import { act, cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const toastMocks = vi.hoisted(() => ({ - loading: vi.fn(), - success: vi.fn(), - error: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: toastMocks })) - -const store = vi.hoisted(() => { - const remoteBrowserPageHandlesByPageId: Record = {} - return { state: { remoteBrowserPageHandlesByPageId } } -}) -vi.mock('@/store', () => ({ useAppStore: { getState: () => store.state } })) - -import type { - BrowserDownloadFinishedEvent, - BrowserDownloadProgressEvent, - BrowserDownloadRequestedEvent -} from '../../../../shared/browser/guest-lifecycle/browser-guest-events' -import { paneChannel } from './client-hosted-browser-pane-test-rig' -import { useBrowserClientHostedDownloadNotices } from './browser-client-hosted-download-notices' - -let requested = paneChannel() -let progress = paneChannel() -let finished = paneChannel() - -beforeEach(() => { - requested = paneChannel() - progress = paneChannel() - finished = paneChannel() - store.state.remoteBrowserPageHandlesByPageId = { - 'page-a': { - environmentId: 'environment-a', - remotePageId: 'remote-page-a', - placement: { kind: 'client' } - } - } - Object.defineProperty(window, 'api', { - configurable: true, - value: { - browser: { - onDownloadRequested: requested.subscribe, - onDownloadProgress: progress.subscribe, - onDownloadFinished: finished.subscribe - } - } - }) -}) - -afterEach(() => { - cleanup() - vi.clearAllMocks() -}) - -function renderNotices(): void { - renderHook(useBrowserClientHostedDownloadNotices) -} - -function emitRequested(overrides: Partial = {}): void { - const event = { - browserPageId: 'page-a', - downloadId: 'download-1', - origin: 'https://example.internal', - filename: 'report.pdf', - totalBytes: 1024, - mimeType: 'application/pdf', - savePath: '/tmp/staging/download', - status: 'downloading', - ...overrides - } as BrowserDownloadRequestedEvent - act(() => requested.emit(event)) -} - -function emitFinished(overrides: Partial = {}): void { - const event = { - browserPageId: 'page-a', - downloadId: 'download-1', - status: 'completed', - savePath: null, - error: null, - ...overrides - } as BrowserDownloadFinishedEvent - act(() => finished.emit(event)) -} - -describe('client-hosted browser download notices', () => { - // Downloads outlive their panes; an unsettled loading toast has no close button. - it('settles the toast after the page that started the download is gone', () => { - renderNotices() - emitRequested() - store.state.remoteBrowserPageHandlesByPageId = {} - - emitFinished({ status: 'canceled' }) - - expect(toastMocks.error).toHaveBeenCalledWith('Download canceled.', { - id: 'browser-download:download-1' - }) - }) -}) diff --git a/src/renderer/src/components/browser-pane/browser-client-page-paint-retention.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-paint-retention.test.ts deleted file mode 100644 index a216d32f047..00000000000 --- a/src/renderer/src/components/browser-pane/browser-client-page-paint-retention.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, expect, it, vi } from 'vitest' -import { setRemoteViewersForBrowserPage } from '../../lib/pane-manager/browser-remote-viewer-state' -import { - createRetainedHostFixture, - disposeRetainedHostFixtures -} from './browser-client-page-retained-host-fixture' - -afterEach(() => { - disposeRetainedHostFixtures() - setRemoteViewersForBrowserPage('page-a', false) - document.body.innerHTML = '' -}) - -it('keeps a streamed retained guest invisible and inert across desktop attach and detach', async () => { - const rig = createRetainedHostFixture() - await rig.mount() - setRemoteViewersForBrowserPage('page-a', true) - const host = rig.host() - expect(host.style.left).toBe('0px') - expect(Number.parseInt(host.style.width)).toBeGreaterThanOrEqual(320) - expect(host.style.opacity).toBe('0') - expect(host.inert).toBe(true) - expect(host.style.pointerEvents).toBe('none') - - const observer = new MutationObserver(() => {}) - observer.observe(host, { attributes: true }) - setRemoteViewersForBrowserPage('page-a', true) - expect(observer.takeRecords()).toHaveLength(0) - observer.disconnect() - - const attachment = rig.attach() - expect(host.style.opacity).toBe('') - expect(host.inert).toBe(false) - vi.spyOn(rig.container, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 0, 0)) - setRemoteViewersForBrowserPage('page-a', true) - expect(Number.parseInt(host.style.width)).toBeGreaterThanOrEqual(320) - expect(host.style.opacity).toBe('0') - attachment.detach() - host - .querySelector('webview')! - .dispatchEvent( - Object.assign(new Event('load-commit'), { url: 'about:blank', isMainFrame: true }) - ) - expect(host.style.visibility).toBe('') - expect(host.style.opacity).toBe('0') - expect(host.inert).toBe(true) - expect(host.style.pointerEvents).toBe('none') - - setRemoteViewersForBrowserPage('page-a', false) - expect(host.style.width).toBe('1px') - rig.registry.dispose() - setRemoteViewersForBrowserPage('page-a', true) - expect(host.style.width).toBe('1px') -}) diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts b/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts index df05e189e5c..e8117da4faf 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import type { BrowserGrabPayload } from '../../../../../shared/browser/element-grab/browser-grab-types' import { applyBrowserAnnotationViewportReport, - createBrowserAnnotationPayload, getBrowserOverlayAnchor, getLiveBrowserAnnotationRect } from './browser-annotation-geometry' @@ -80,12 +79,6 @@ describe('browser annotation geometry', () => { } ) }) - - it('strips screenshot bytes from persisted annotation payloads', () => { - const source = payload() - expect(createBrowserAnnotationPayload(source).screenshot).toBeNull() - expect(createBrowserAnnotationPayload(source).target.selector).toBe('button') - }) }) describe('browser annotation card follows the live element', () => { diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts deleted file mode 100644 index 71e1783f093..00000000000 --- a/src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @vitest-environment happy-dom - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { consumeBrowserFocusRequest, queueBrowserFocusRequest } from './browser-focus' - -describe('browser-focus', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('expires unconsumed requests for pages that never mount', () => { - vi.useFakeTimers() - - queueBrowserFocusRequest({ pageId: 'page-stale', target: 'webview' }) - - vi.advanceTimersByTime(30_000) - - expect(consumeBrowserFocusRequest('page-stale')).toBeNull() - }) -}) diff --git a/src/shared/agent-hook-listener/normalization/agent-hook-listener-command-code-transcript.test.ts b/src/shared/agent-hook-listener/normalization/agent-hook-listener-command-code-transcript.test.ts index 5a2f0cbd836..33ee3c443a6 100644 --- a/src/shared/agent-hook-listener/normalization/agent-hook-listener-command-code-transcript.test.ts +++ b/src/shared/agent-hook-listener/normalization/agent-hook-listener-command-code-transcript.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHookListenerState, type HookListenerState } from '../listener-state' @@ -20,54 +20,6 @@ describe('shared agent-hook-listener', () => { vi.unstubAllEnvs() }) - it('reads the last assistant message behind an oversized line without quadratic copying', () => { - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-assistant-huge-line-')) - const transcriptPath = join(tmpDir, 'transcript.jsonl') - const originalConcat = Buffer.concat - let concatenatedBytes = 0 - try { - // Multi-block lines must join once; per-block carry joins copy quadratic bytes. - const lineBytes = 2 * 1024 * 1024 - writeFileSync( - transcriptPath, - `${JSON.stringify({ - role: 'assistant', - content: [{ type: 'text', text: 'answer behind a huge line' }] - })}\n${JSON.stringify({ - role: 'user', - content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] - })}\n` - ) - - Buffer.concat = ((list: readonly Uint8Array[], totalLength?: number) => { - const joined = originalConcat(list as Uint8Array[], totalLength) - concatenatedBytes += joined.length - return joined - }) as typeof Buffer.concat - - const done = normalizeHookPayload( - state, - 'claude', - { - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt', - env: 'production', - version: '1', - payload: { hook_event_name: 'Stop', transcript_path: transcriptPath } - }, - 'production' - ) - - expect(done?.payload.lastAssistantMessage).toBe('answer behind a huge line') - // Linear copies once (~lineBytes); the quadratic form copied many times that. - expect(concatenatedBytes).toBeLessThan(lineBytes * 4) - } finally { - Buffer.concat = originalConcat - rmSync(tmpDir, { recursive: true, force: true }) - } - }) - // Backward scanning must preserve boundary-spanning prompts, latest-user selection and absolute interaction offsets. it('reads a Command Code prompt that straddles the backward-scan chunk boundary', () => { const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-chunk-straddle-')) @@ -122,64 +74,4 @@ describe('shared agent-hook-listener', () => { rmSync(tmpDir, { recursive: true, force: true }) } }) - - it('ignores a Command Code prompt older than the transcript scan cap', () => { - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-over-cap-')) - const transcriptPath = join(tmpDir, 'transcript.jsonl') - try { - // Put the only user line beyond 4MB so removing the read cap fails. - const filler = JSON.stringify({ - role: 'assistant', - content: [{ type: 'text', text: 'f'.repeat(64 * 1024) }] - }) - const lines = [ - JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'ancient prompt' }] }) - ] - for (let index = 0; index < 80; index += 1) { - lines.push(filler) - } - writeFileSync(transcriptPath, `${lines.join('\n')}\n`) - expect(statSync(transcriptPath).size).toBeGreaterThan(4 * 1024 * 1024) - - const tool = normalizeHookPayload( - state, - 'command-code', - { - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt', - env: 'production', - version: '1', - payload: { - hook_event_name: 'PreToolUse', - transcript_path: transcriptPath, - tool_name: 'shell_command', - tool_input: { command: 'pwd' } - } - }, - 'production' - ) - - expect(tool?.payload).toMatchObject({ - state: 'working', - agentType: 'command-code', - prompt: '', - toolName: 'shell_command', - toolInput: 'pwd' - }) - writeFileSync(transcriptPath, `${lines[0]}\n`, { flag: 'a' }) - const recent = normalizeHookPayload( - createHookListenerState(), - 'command-code', - { - paneKey: PANE_KEY, - payload: { hook_event_name: 'PreToolUse', transcript_path: transcriptPath } - }, - 'production' - ) - expect(recent?.payload.prompt).toBe('ancient prompt') - } finally { - rmSync(tmpDir, { recursive: true, force: true }) - } - }) }) diff --git a/src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts b/src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts index 8244e0bf94b..a1ec6b923b6 100644 --- a/src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts +++ b/src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts @@ -23,19 +23,6 @@ describe('jcode turn prompt cache', () => { readMock.mockReturnValue({ text: 'ship it', interactionKey: 'k1' }) }) - it('reads the journal once per turn, not once per tool', () => { - readJcodeTurnPrompt(state, 'turn_start', 'pane-a', PAYLOAD) - for (let call = 0; call < 4; call += 1) { - readJcodeTurnPrompt(state, 'pre_tool', 'pane-a', PAYLOAD) - readJcodeTurnPrompt(state, 'post_tool', 'pane-a', PAYLOAD) - } - expect(readJcodeTurnPrompt(state, 'turn_end', 'pane-a', PAYLOAD)).toEqual({ - text: 'ship it', - interactionKey: 'k1' - }) - expect(readMock).toHaveBeenCalledTimes(1) - }) - it('re-reads on the next turn, so a new prompt is not served from the old turn', () => { const first = readJcodeTurnPrompt(state, 'turn_start', 'pane-a', PAYLOAD) expect(first).toEqual({ text: 'ship it', interactionKey: 'k1' }) From 6a69b2140e013e7389198a5012f8a3a93ff9aa45 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:52:19 -0800 Subject: [PATCH 037/138] fix(i18n): preserve web runtime error details (#40267) * fix(web): preserve runtime failure details * fix(i18n): update chat behavior settings hint --- src/renderer/src/i18n/locales/de.json | 2 +- src/renderer/src/i18n/locales/ru.json | 2 +- src/renderer/src/i18n/locales/tr.json | 2 +- src/renderer/src/i18n/locales/zh-Hant.json | 2 +- .../web-runtime-calls-connection-pin.test.ts | 27 +++++++++++++++++-- .../src/web/preload-api/web-runtime-calls.ts | 12 ++++----- 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 65028d093a3..22a6ae4268b 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -2784,7 +2784,7 @@ "untitled": "Unbenannter Chat", "listLabel": "Chats, die fortgesetzt würden", "dontAskAgain": "Nicht erneut fragen (automatisch fortsetzen)", - "dontAskAgainHint": "Lässt sich unter Einstellungen → Experimentell → Chat-Oberfläche deaktivieren.", + "dontAskAgainHint": "Lässt sich unter Einstellungen → Chat → Verhalten deaktivieren.", "selectAgent": "{{value0}}-Chat „{{value1}}“ in {{value2}} fortsetzen", "dismissAll": "Alle verwerfen", "viewChats": "Chats anzeigen", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index b46a9cc644d..ada9141abcb 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -2846,7 +2846,7 @@ "untitled": "Чат без названия", "listLabel": "Чаты, которые будут возобновлены", "dontAskAgain": "Больше не спрашивать (продолжать автоматически)", - "dontAskAgainHint": "Это можно отключить: Настройки → Экспериментальные → Интерфейс чата.", + "dontAskAgainHint": "Это можно отключить: Настройки → Чат → Поведение.", "selectAgent": "Возобновить чат {{value0}} «{{value1}}» в {{value2}}", "dismissAll": "Отклонить все", "viewChats": "Посмотреть чаты", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index fc3aadad3ff..79b1047667c 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -2760,7 +2760,7 @@ "untitled": "Başlıksız sohbet", "listLabel": "Devam edilecek sohbetler", "dontAskAgain": "Bir daha sorma (otomatik devam et)", - "dontAskAgainHint": "Bu seçeneği Ayarlar → Deneysel → Sohbet Arayüzü bölümünden kapatabilirsiniz.", + "dontAskAgainHint": "Bu seçeneği Ayarlar → Sohbet → Davranış bölümünden kapatabilirsiniz.", "selectAgent": "{{value2}} içindeki \"{{value1}}\" adlı {{value0}} sohbetine devam et", "dismissAll": "Tümünü kapat", "viewChats": "Sohbetleri görüntüle", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 1ab7c26c873..98f2098d45f 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -2726,7 +2726,7 @@ "untitled": "無標題聊天", "listLabel": "將恢復的聊天", "dontAskAgain": "不再詢問(自動恢復)", - "dontAskAgainHint": "您可以在「設定」→「實驗」→「聊天 UI」中關閉此功能。", + "dontAskAgainHint": "您可以在「設定」→「聊天」→「行為」中關閉此功能。", "selectAgent": "在 {{value2}} 中恢復 {{value0}} 聊天「{{value1}}」", "dismissAll": "全部關閉", "viewChats": "檢視聊天", diff --git a/src/renderer/src/web/preload-api/web-runtime-calls-connection-pin.test.ts b/src/renderer/src/web/preload-api/web-runtime-calls-connection-pin.test.ts index 4f25defa420..51ef1ad4cca 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls-connection-pin.test.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls-connection-pin.test.ts @@ -19,8 +19,11 @@ vi.mock('./web-runtime-session', () => ({ updateEnvironmentFromResponse: () => undefined })) -import { callEnvironmentEnvelope } from './web-runtime-calls' -import { RuntimeConnectionChangedBeforeSendError } from '../../runtime/rpc/calls/runtime-rpc-result' +import { callEnvironmentEnvelope, callRuntimeResult } from './web-runtime-calls' +import { + RuntimeConnectionChangedBeforeSendError, + RuntimeRpcCallError +} from '../../runtime/rpc/calls/runtime-rpc-result' describe('web runtime call pinned to the probed runtime', () => { beforeEach(() => { @@ -65,3 +68,23 @@ describe('web runtime call pinned to the probed runtime', () => { ) }) }) + +it('preserves runtime failure details for web callers', async () => { + session.client.call.mockResolvedValueOnce({ + id: 'rpc', + ok: false, + error: { + code: 'runtime_error', + message: 'Cannot add project', + data: { reason: 'windows_drive' } + } + }) + + const error = await callRuntimeResult('repo.add').catch((failure: unknown) => failure) + expect(error).toBeInstanceOf(RuntimeRpcCallError) + expect(error).toMatchObject({ + code: 'runtime_error', + message: 'Cannot add project', + response: { error: { data: { reason: 'windows_drive' } } } + }) +}) diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts index d59c7abeb3b..e476a1d42af 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -1,4 +1,7 @@ -import { RuntimeConnectionChangedBeforeSendError } from '../../runtime/rpc/calls/runtime-rpc-result' +import { + RuntimeConnectionChangedBeforeSendError, + unwrapRuntimeRpcResult +} from '../../runtime/rpc/calls/runtime-rpc-result' import type { PreloadApi } from '../../../../preload/api-types' import type { WebRuntimeSubscribeOptions } from '../web-runtime-subscription-contract' import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' @@ -159,12 +162,7 @@ export async function callRuntimeResult( params?: unknown, timeoutMs?: number ): Promise { - const response = await callRuntimeEnvelope(method, params, timeoutMs) - if (!response.ok) { - // Preserve error codes for recoverable-failure classification; human messages need not contain the token. - throw Object.assign(new Error(response.error.message), { code: response.error.code }) - } - return response.result as TResult + return unwrapRuntimeRpcResult(await callRuntimeEnvelope(method, params, timeoutMs)) } export async function callRuntimeResultWithOwner( From f5da9a9ff53ddd58860d2211c98c5761a7354682 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:55:11 +0000 Subject: [PATCH 038/138] feat(speech): commit dictation text at each pause with Silero VAD (#39378) Offline models write out the speech before each pause while recording goes on. Short noises and slow window-padded decodes are held off the pause cuts, a VAD error falls back to bounded chunks, and slow phrase decodes are judged per dictation against the time spoken. The VAD download carries legacy files and fetches after any GGUF model. --- config/main-sync-fs-baseline.txt | 1 + src/main/speech/model-download-catalog.ts | 28 ++- src/main/speech/model-manager.ts | 23 +-- src/main/speech/speech-vad-model.ts | 41 +++++ src/main/speech/stt-offline-audio-chunker.ts | 5 + src/main/speech/stt-recognizer.ts | 2 + src/main/speech/stt-service.test.ts | 38 +++-- src/main/speech/stt-session-start.ts | 6 +- .../speech/stt-transcribe-cpp-recognizer.ts | 160 +++++++++++++++--- .../speech/stt-vad-pause-segmenter.test.ts | 108 ++++++++++++ src/main/speech/stt-vad-pause-segmenter.ts | 159 +++++++++++++++++ 11 files changed, 517 insertions(+), 54 deletions(-) create mode 100644 src/main/speech/speech-vad-model.ts create mode 100644 src/main/speech/stt-vad-pause-segmenter.test.ts create mode 100644 src/main/speech/stt-vad-pause-segmenter.ts diff --git a/config/main-sync-fs-baseline.txt b/config/main-sync-fs-baseline.txt index 55df3070cc5..a4efe1beedc 100644 --- a/config/main-sync-fs-baseline.txt +++ b/config/main-sync-fs-baseline.txt @@ -498,6 +498,7 @@ 1 src/main/speech/speech-model-download-transport.ts 1 src/main/speech/speech-model-files.ts 2 src/main/speech/speech-model-http-download.ts +1 src/main/speech/speech-vad-model.ts 2 src/main/speech/stt-gpu-failure-marker.ts 2 src/main/sqlite/harden-database-files.ts 1 src/main/sqlite/row-cache/keyed-json-row-cache.ts diff --git a/src/main/speech/model-download-catalog.ts b/src/main/speech/model-download-catalog.ts index 0c5e4e73968..6d4b740976a 100644 --- a/src/main/speech/model-download-catalog.ts +++ b/src/main/speech/model-download-catalog.ts @@ -1,4 +1,4 @@ -import type { SpeechModelDownloadFile } from '../../shared/speech-types' +import type { SpeechModelDownloadFile, SpeechModelManifest } from '../../shared/speech-types' type DownloadFileSpec = readonly [name: string, sizeBytes: number, sha256: string] @@ -216,6 +216,32 @@ const MODEL_DOWNLOAD_FILES = { export type DownloadableSpeechModelId = keyof typeof MODEL_DOWNLOAD_FILES +/** What the model manager needs to download, verify and place one model's files. */ +export type SpeechModelDownload = Pick< + SpeechModelManifest, + 'id' | 'provider' | 'downloadFiles' | 'legacyDownloadFiles' | 'sizeBytes' +> + +const SILERO_VAD_FILES = huggingFaceFiles( + 'handy-computer/silero-vad-v6.2-gguf', + '97bbd75179f535e4708bccdf61e9278b8b60dfc7', + [ + [ + 'silero-vad-v6.2-F32.gguf', + 1_241_056, + '0d871a800c420fdd5d1b17a3aea4296d1c39608f0ff4a9295eb76457eb1c88e6' + ] + ] +) + +/** Silero VAD (MIT), which transcribe-cpp uses to find pauses; a helper download, never a dictation model. */ +export const SILERO_VAD_MODEL = { + id: 'silero-vad-v6.2', + provider: 'local', + downloadFiles: SILERO_VAD_FILES, + sizeBytes: SILERO_VAD_FILES.reduce((total, { sizeBytes }) => total + sizeBytes, 0) +} satisfies SpeechModelDownload + export function getSpeechModelDownloadMetadata(modelId: DownloadableSpeechModelId): { downloadFiles: SpeechModelDownloadFile[] files: string[] diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 522e4525891..8e530065f6d 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -1,12 +1,10 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs' import { rename, rm } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' -import type { - SpeechModelManifest, - SpeechModelState, - SpeechModelStatus -} from '../../shared/speech-types' +import type { SpeechModelState, SpeechModelStatus } from '../../shared/speech-types' import { SPEECH_MODEL_CATALOG, getCatalogModel, isLocalSpeechModel } from './model-catalog' +import type { SpeechModelDownload } from './model-download-catalog' +import { downloadSileroVadInBackground, speechModelDownload } from './speech-vad-model' import { hasCloudSpeechCredentials } from './cloud-transcription-target' import { getSpeechModelCacheDirCandidates, @@ -99,12 +97,12 @@ export class ModelManager extends SpeechModelDownloadTransport { return cached } - const manifest = getCatalogModel(modelId) + const manifest = speechModelDownload(modelId) if (!manifest) { return { id: modelId, status: 'error', error: 'Unknown model' } } - if (!isLocalSpeechModel(manifest)) { + if (manifest.provider !== 'local') { const ready = this.hasCloudCredentials(manifest.provider) return { id: modelId, status: ready ? 'ready' : 'not-downloaded' } } @@ -127,7 +125,7 @@ export class ModelManager extends SpeechModelDownloadTransport { } private getSafeModelDir(modelId: string, root: string = this.modelsDir): string { - const manifest = getCatalogModel(modelId) + const manifest = speechModelDownload(modelId) if (!manifest) { throw new Error(`Unknown model: ${modelId}`) } @@ -146,11 +144,11 @@ export class ModelManager extends SpeechModelDownloadTransport { return } - const manifest = getCatalogModel(modelId) + const manifest = speechModelDownload(modelId) if (!manifest) { throw new Error(`Unknown model: ${modelId}`) } - if (!isLocalSpeechModel(manifest)) { + if (manifest.provider !== 'local') { throw new Error(`Model does not support downloads: ${modelId}`) } if (!manifest.downloadFiles?.length || !manifest.sizeBytes) { @@ -203,6 +201,9 @@ export class ModelManager extends SpeechModelDownloadTransport { await rm(modelDir, { recursive: true, force: true }) await rename(stagingDir, modelDir) this.updateState(modelId, 'ready') + if (getCatalogModel(modelId)?.files?.some((file) => file.endsWith('.gguf'))) { + downloadSileroVadInBackground(this) + } } catch (err) { if (!aborted) { console.error('[speech] Model download failed:', modelId, err) @@ -283,7 +284,7 @@ export class ModelManager extends SpeechModelDownloadTransport { } private async downloadModelFiles( - manifest: SpeechModelManifest, + manifest: SpeechModelDownload, stagingDir: string, modelId: string, isAborted: () => boolean, diff --git a/src/main/speech/speech-vad-model.ts b/src/main/speech/speech-vad-model.ts new file mode 100644 index 00000000000..5aa954a01b0 --- /dev/null +++ b/src/main/speech/speech-vad-model.ts @@ -0,0 +1,41 @@ +import { statSync } from 'node:fs' +import { join } from 'node:path' +import { getCatalogModel } from './model-catalog' +import { SILERO_VAD_MODEL, type SpeechModelDownload } from './model-download-catalog' +import type { ModelManager } from './model-manager' + +/** A catalog model, or the VAD helper the model manager downloads the same way but never lists. */ +export function speechModelDownload(modelId: string): SpeechModelDownload | undefined { + return modelId === SILERO_VAD_MODEL.id ? SILERO_VAD_MODEL : getCatalogModel(modelId) +} + +/** Fetch the VAD for later dictations; until it lands, offline text arrives at stop as before. */ +export function downloadSileroVadInBackground(models: Pick): void { + models.downloadModel(SILERO_VAD_MODEL.id).catch((error: unknown) => { + console.warn('[speech] Silero VAD download failed:', error) + }) +} + +// Settings never lists the VAD, so once its file is in place it stays; only a new models folder rechecks. +let verifiedVadPath: string | undefined + +/** The downloaded Silero VAD file; when it's missing, starts fetching it and returns undefined. */ +export function sileroVadModelPath( + models: Pick +): string | undefined { + const [file] = SILERO_VAD_MODEL.downloadFiles + const path = join(models.getModelDir(SILERO_VAD_MODEL.id), file.name) + if (path === verifiedVadPath) { + return path + } + try { + if (statSync(path).size === file.sizeBytes) { + verifiedVadPath = path + return path + } + } catch { + // Not downloaded yet, e.g. the model came before VAD support. + } + downloadSileroVadInBackground(models) + return undefined +} diff --git a/src/main/speech/stt-offline-audio-chunker.ts b/src/main/speech/stt-offline-audio-chunker.ts index 09b45586aac..d34be24ec33 100644 --- a/src/main/speech/stt-offline-audio-chunker.ts +++ b/src/main/speech/stt-offline-audio-chunker.ts @@ -18,6 +18,11 @@ export class OfflineAudioChunker { this.energyWindowSamples = Math.max(1, Math.round(SPLIT_ENERGY_WINDOW_SECONDS * sampleRate)) } + /** Samples pushed but not yet returned. */ + get heldSamples(): number { + return this.bufferedSamples + } + /** Return complete bounded chunks as samples arrive. */ push(samples: Float32Array): Float32Array[] { if (samples.length === 0) { diff --git a/src/main/speech/stt-recognizer.ts b/src/main/speech/stt-recognizer.ts index 66379b1ff3d..95a16492673 100644 --- a/src/main/speech/stt-recognizer.ts +++ b/src/main/speech/stt-recognizer.ts @@ -6,6 +6,8 @@ export type SttRecognizerInit = { sampleRate: number /** Absent = auto-detect. */ language?: string + /** Silero VAD GGUF; when present, offline models commit text at each pause. */ + vadModelPath?: string } export type SttRecognizerEvent = diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index aa2e80fd3a9..0990e7806de 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -1,4 +1,5 @@ import type { ModelManager } from './model-manager' +import type { SpeechModelState } from '../../shared/speech-types' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -136,12 +137,15 @@ const stoppedReply = (worker: InstanceType) => ({ streamId: worker.messages.findLast((message) => message.type === 'stop')?.streamId }) +const testModels = (models: Pick): ModelManager => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SttService reads getModelState and getModelDir, and fetches the VAD with downloadModel. + ({ ...models, downloadModel: vi.fn().mockResolvedValue(undefined) }) as never + const readyModels = (): ModelManager => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SttService reads only getModelState and getModelDir. - ({ + testModels({ getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), getModelDir: vi.fn().mockReturnValue('/tmp/model-a') - }) as never + }) // Starting a session awaits the model check before it spawns the process. async function flushMicrotasks(): Promise { @@ -350,12 +354,11 @@ describe('SttService', () => { }) it('rebuilds the warm worker when the dictation language changes', async () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a local start reads only getModelState and getModelDir. const service = new SttService( - { + testModels({ getModelState: vi.fn().mockResolvedValue({ id: 'whisper-model', status: 'ready' }), getModelDir: vi.fn().mockReturnValue('/tmp/whisper-model') - } as never, + }), spawnMockWorker ) @@ -490,15 +493,15 @@ describe('SttService', () => { }) it('rejects deletion prep while the target model is starting', async () => { - let resolveModelState: (state: { id: string; status: string }) => void = () => {} - const modelStatePromise = new Promise<{ id: string; status: string }>((resolve) => { + let resolveModelState: (state: SpeechModelState) => void = () => {} + const modelStatePromise = new Promise((resolve) => { resolveModelState = resolve }) const service = new SttService( - { + testModels({ getModelState: vi.fn(() => modelStatePromise), getModelDir: vi.fn().mockReturnValue('/tmp/model-a') - } as never, + }), spawnMockWorker ) @@ -528,10 +531,10 @@ describe('SttService', () => { it('uses the OpenAI transcription session without creating a worker', async () => { const sink = vi.fn() const service = new SttService( - { + testModels({ getModelState: vi.fn().mockResolvedValue({ id: 'openai-model', status: 'ready' }), getModelDir: vi.fn().mockReturnValue('/tmp/model-a') - } as never, + }), spawnMockWorker ) @@ -815,10 +818,10 @@ describe('SttService', () => { status: modelStatus })) const service = new SttService( - { + testModels({ getModelState, getModelDir: vi.fn().mockReturnValue('/tmp/model-a') - } as never, + }), spawnMockWorker ) @@ -845,14 +848,15 @@ describe('SttService', () => { async (event) => { vi.useFakeTimers() try { - const models: Pick = { + const models: Pick = { getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), - getModelDir: vi.fn().mockReturnValue('/tmp/model-a') + getModelDir: vi.fn().mockReturnValue('/tmp/model-a'), + downloadModel: vi.fn().mockResolvedValue(undefined) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This lifecycle reads only getModelState/getModelDir on the model manager. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This lifecycle reads getModelState/getModelDir and downloadModel (VAD) on the model manager. const service = new SttService(models as ModelManager, spawnMockWorker) const sink = vi.fn() await service.startDictation('model-a', sink, undefined, 'desktop') diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index 619869ab03a..cd815fbc920 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -28,6 +28,7 @@ import { } from './stt-worker-startup' import { START_DICTATION_TIMEOUT_MS } from '../../shared/dictation-finish-deadlines' import { STT_WORKER_EXITED_ERROR } from './stt-worker-process' +import { sileroVadModelPath } from './speech-vad-model' export async function startSttDictation( state: SttSessionState, @@ -186,6 +187,8 @@ export function spawnSttEngine( if (!speechModulesDir) { throw new Error(LOCAL_SPEECH_UNAVAILABLE_ERROR) } + // A VAD fetched now serves the next engine start. + const vadModelPath = sileroVadModelPath(state.modelManager) clearSttIdleTeardownTimer(state) const worker = state.spawnWorker() const readyPromise = waitForSttWorkerReady(worker, START_DICTATION_TIMEOUT_MS) @@ -221,7 +224,8 @@ export function spawnSttEngine( modelDir: state.modelManager.getModelDir(config.modelId), sampleRate: manifest.sampleRate, files: manifest.files ?? [], - language: config.language + language: config.language, + vadModelPath }) return engine } diff --git a/src/main/speech/stt-transcribe-cpp-recognizer.ts b/src/main/speech/stt-transcribe-cpp-recognizer.ts index 24bed924fc2..578be1cc57e 100644 --- a/src/main/speech/stt-transcribe-cpp-recognizer.ts +++ b/src/main/speech/stt-transcribe-cpp-recognizer.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url' import type * as TranscribeCpp from 'transcribe-cpp' import { resampleToRate } from './stt-audio-resample' import { OfflineAudioChunker } from './stt-offline-audio-chunker' +import { VadPauseSegmenter } from './stt-vad-pause-segmenter' import type { SttRecognizer, SttRecognizerInit, SttRecognizerPost } from './stt-recognizer' // Past ~8 threads decode slowed 2-4x under load in the 2026-11 benchmark; E-cores and SMT siblings @@ -13,13 +14,69 @@ function performanceCoreThreads(): number { return Math.min(logical, 8, Math.max(4, Math.floor(logical / 2))) } +// Without the detector, audio is still cut at the decode bound, just not at pauses. +type AudioSegmenter = { + readonly heldSamples: number + push(samples: Float32Array): Float32Array[] | Promise + flush(): Float32Array[] | Promise +} + +function boundedChunks(sampleRate: number): AudioSegmenter { + const chunker = new OfflineAudioChunker(sampleRate) + return { + get heldSamples() { + return chunker.heldSamples + }, + push: (samples) => chunker.push(samples), + flush: () => { + const rest = chunker.flush() + return rest ? [rest] : [] + } + } +} + // Totals, not the nominal rate ratio: per-feed resampling rounds, and the drift would replay decoded audio. type StreamCoverage = { options: TranscribeCpp.TranscribeOptions - chunker: OfflineAudioChunker + segmenter: AudioSegmenter + vad: TranscribeCpp.VadSession | null inputSamples: number resampledSamples: number - decodedSamples: number + reportedSamples: number + slowPhraseDecodes: number +} + +// Whisper pads every decode to a 30 s window, so each phrase costs a full decode. Where that takes longer than the +// speech (and silence) it covers, per-pause decodes queue up behind the speaker, so that dictation stops cutting. +// Two in a row, so one slow first run or load spike doesn't. +const SLOW_PHRASE_DECODES_BEFORE_NO_CUTS = 2 + +// Dropped silence still counts as covered; report it so the host's replay buffer and stall watch don't grow through long pauses. +const SILENCE_COVERAGE_REPORT_SECONDS = 5 + +// Optional: without it dictation still works, with text only at the decode bound and at stop. +async function loadSileroVad( + transcribe: typeof TranscribeCpp, + path: string | undefined, + sampleRate: number +): Promise { + if (!path) { + return null + } + try { + // Tiny and per-frame; the CPU keeps it off a GPU that may fail over. + const model = await transcribe.TranscribeModel.load(path, { backend: 'cpu' }) + if (model.vadInfo.sampleRate === sampleRate) { + return model + } + console.warn( + `[speech] Silero VAD needs ${model.vadInfo.sampleRate} Hz; this model takes ${sampleRate} Hz` + ) + model.dispose() + } catch (error) { + console.warn('[speech] Silero VAD unavailable; text arrives at stop:', error) + } + return null } export async function createTranscribeCppRecognizer( @@ -52,7 +109,32 @@ export async function createTranscribeCppRecognizer( ? { ...baseOptions, prompt: `${vocabulary.toReversed().join(', ')}.` } : baseOptions const modelRate = model.capabilities.nativeSampleRate || init.sampleRate + const vadModel = await loadSileroVad(transcribe, init.vadModelPath, modelRate) const streams = new Map() + const openStream = (streamId: string, vocabulary: readonly string[]): StreamCoverage => { + // A VAD stream is per-dictation state, so each dictation gets its own session. + const vad = vadModel?.createVadSession({ nThreads: 1 }) ?? null + const stream: StreamCoverage = { + options: streamOptions(vocabulary), + segmenter: boundedChunks(modelRate), + vad, + inputSamples: 0, + resampledSamples: 0, + reportedSamples: 0, + slowPhraseDecodes: 0 + } + if (vad && vadModel) { + stream.segmenter = new VadPauseSegmenter( + vad, + modelRate, + vadModel.vadInfo.frameSamples, + () => stream.slowPhraseDecodes < SLOW_PHRASE_DECODES_BEFORE_NO_CUTS + ) + } + streams.get(streamId)?.vad?.dispose() + streams.set(streamId, stream) + return stream + } const transcribeChunk = async ( chunk: Float32Array, @@ -73,26 +155,55 @@ export async function createTranscribeCppRecognizer( await transcribeChunk(new Float32Array(Math.round(modelRate / 2)), baseOptions) // Coverage is in input-rate samples so the host can trim the audio it keeps for a CPU replay. - const decode = async (streamId: string, stream: StreamCoverage, chunk: Float32Array) => { - const text = await transcribeChunk(chunk, stream.options) - stream.decodedSamples += chunk.length + const report = (streamId: string, stream: StreamCoverage, text: string, undecoded: number) => { + const covered = stream.resampledSamples - stream.segmenter.heldSamples - undecoded + stream.reportedSamples = covered post({ type: 'chunk', streamId, - text: text.trim(), - samples: Math.round((stream.decodedSamples * stream.inputSamples) / stream.resampledSamples) + text, + samples: Math.round((covered * stream.inputSamples) / stream.resampledSamples) }) } - const openStream = (streamId: string, vocabulary: readonly string[]): StreamCoverage => { - const stream: StreamCoverage = { - options: streamOptions(vocabulary), - chunker: new OfflineAudioChunker(modelRate), - inputSamples: 0, - resampledSamples: 0, - decodedSamples: 0 + const decode = async (streamId: string, stream: StreamCoverage, chunks: Float32Array[]) => { + let undecoded = chunks.reduce((total, chunk) => total + chunk.length, 0) + for (const chunk of chunks) { + const startedAt = performance.now() + const text = await transcribeChunk(chunk, stream.options) + const decodeMs = performance.now() - startedAt + const spokenSince = stream.reportedSamples + undecoded -= chunk.length + report(streamId, stream, text.trim(), undecoded) + if (stream.vad) { + noteDecodeSpeed( + stream, + decodeMs, + ((stream.reportedSamples - spokenSince) / modelRate) * 1000 + ) + } } - streams.set(streamId, stream) + const dropped = stream.resampledSamples - stream.segmenter.heldSamples - stream.reportedSamples + if (dropped >= SILENCE_COVERAGE_REPORT_SECONDS * modelRate) { + report(streamId, stream, '', 0) + } + } + + const noteDecodeSpeed = (stream: StreamCoverage, decodeMs: number, spokenMs: number) => { + if (stream.slowPhraseDecodes >= SLOW_PHRASE_DECODES_BEFORE_NO_CUTS) { + return + } + stream.slowPhraseDecodes = decodeMs > spokenMs ? stream.slowPhraseDecodes + 1 : 0 + if (stream.slowPhraseDecodes === SLOW_PHRASE_DECODES_BEFORE_NO_CUTS) { + console.warn( + '[speech] Phrases decode slower than they are spoken; this dictation cuts only at 30 s' + ) + } + } + + const closeStream = (streamId: string): StreamCoverage | undefined => { + const stream = streams.get(streamId) + streams.delete(streamId) return stream } @@ -108,20 +219,21 @@ export async function createTranscribeCppRecognizer( ) stream.inputSamples += samples.length stream.resampledSamples += resampled.length - for (const chunk of stream.chunker.push(resampled)) { - await decode(streamId, stream, chunk) - } + await decode(streamId, stream, await stream.segmenter.push(resampled)) }, async finish(streamId) { - const stream = streams.get(streamId) - streams.delete(streamId) - const remaining = stream?.chunker.flush() - if (stream && remaining) { - await decode(streamId, stream, remaining) + const stream = closeStream(streamId) + if (!stream) { + return + } + try { + await decode(streamId, stream, await stream.segmenter.flush()) + } finally { + stream.vad?.dispose() } }, discard(streamId) { - streams.delete(streamId) + closeStream(streamId)?.vad?.dispose() } } } diff --git a/src/main/speech/stt-vad-pause-segmenter.test.ts b/src/main/speech/stt-vad-pause-segmenter.test.ts new file mode 100644 index 00000000000..0c9015361b2 --- /dev/null +++ b/src/main/speech/stt-vad-pause-segmenter.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { VadPauseSegmenter, type VadStream } from './stt-vad-pause-segmenter' + +// 100 Hz audio in 10-sample (0.1 s) frames; each sample holds its frame's speech probability, so outputs show which frames survived. +const RATE = 100 +const FRAME = 10 + +function fakeVad(): VadStream { + let pending: number[] = [] + let frames = 0 + const score = (flush: boolean) => { + const probs: number[] = [] + while (pending.length >= FRAME || (flush && pending.length > 0)) { + probs.push(pending[0]) + pending = pending.slice(FRAME) + } + const firstFrame = frames + frames = flush ? 0 : frames + probs.length + return { probs, firstFrame, segments: [] } + } + return { + streamFeed: async (pcm) => { + if (!(pcm instanceof Float32Array)) { + throw new Error('the segmenter feeds Float32Array') + } + pending.push(...pcm) + return score(false) + }, + streamFlush: async () => score(true) + } +} + +function audio(frames: number[]): Float32Array { + return Float32Array.from(frames.flatMap((probability) => Array(FRAME).fill(probability))) +} + +function framesOf(chunks: Float32Array[]): number[][] { + return chunks.map((chunk) => + Array.from( + chunk.filter((_, index) => index % FRAME === 0), + (value) => Math.round(value * 100) / 100 + ) + ) +} + +describe('VadPauseSegmenter', () => { + it('commits a phrase at a 0.6 s pause, keeps 0.4 s ahead of the next, and drops the silence between', async () => { + const segmenter = new VadPauseSegmenter(fakeVad(), RATE, FRAME) + const first = await segmenter.push(audio([0.1, 0.9, 0.8, 0.7, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2])) + expect(framesOf(first)).toEqual([[0.1, 0.9, 0.8, 0.7, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2]]) + expect(segmenter.heldSamples).toBe(0) + + const second = await segmenter.push(audio([0.1, 0.1, 0.1, 0.1, 0.1, 0.11, 0.12, 0.13, 0.14])) + expect(second).toEqual([]) + // Only the last 0.4 s of that silence is held as the next phrase's lead-in. + expect(segmenter.heldSamples).toBe(4 * FRAME) + + expect(await segmenter.push(audio([0.7, 0.6]))).toEqual([]) + expect(framesOf(await segmenter.flush())).toEqual([[0.11, 0.12, 0.13, 0.14, 0.7, 0.6]]) + }) + + it('keeps a phrase whole across a pause shorter than 0.6 s', async () => { + const segmenter = new VadPauseSegmenter(fakeVad(), RATE, FRAME) + const ready = await segmenter.push( + audio([0.9, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2]) + ) + expect(framesOf(ready)).toEqual([ + [0.9, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2] + ]) + }) + + it('holds a click shorter than 0.25 s of speech until real speech joins it', async () => { + const segmenter = new VadPauseSegmenter(fakeVad(), RATE, FRAME) + expect(await segmenter.push(audio([0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2]))).toEqual([]) + const ready = await segmenter.push(audio([0.8, 0.8, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2])) + expect(framesOf(ready)).toEqual([ + [0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.8, 0.8, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2] + ]) + }) + + it('returns nothing for a dictation that never had speech', async () => { + const segmenter = new VadPauseSegmenter(fakeVad(), RATE, FRAME) + expect(await segmenter.push(audio([0.1, 0.2, 0.3, 0.1]))).toEqual([]) + expect(await segmenter.flush()).toEqual([]) + expect(segmenter.heldSamples).toBe(0) + }) + + it('keeps every held sample when the detector fails mid-dictation', async () => { + const vad = fakeVad() + const segmenter = new VadPauseSegmenter( + { + streamFeed: async (pcm) => { + if (pcm instanceof Float32Array && pcm[0] === 0.5) { + throw new Error('vad failed') + } + return vad.streamFeed(pcm) + }, + streamFlush: vad.streamFlush + }, + RATE, + FRAME + ) + expect(await segmenter.push(audio([0.1, 0.2, 0.1, 0.1, 0.1, 0.9]))).toEqual([]) + expect(await segmenter.push(audio([0.5, 0.1]))).toEqual([]) + expect(await segmenter.push(audio([0.2]))).toEqual([]) + expect(framesOf(await segmenter.flush())).toEqual([[0.2, 0.1, 0.1, 0.1, 0.9, 0.5, 0.1, 0.2]]) + }) +}) diff --git a/src/main/speech/stt-vad-pause-segmenter.ts b/src/main/speech/stt-vad-pause-segmenter.ts new file mode 100644 index 00000000000..0212724b223 --- /dev/null +++ b/src/main/speech/stt-vad-pause-segmenter.ts @@ -0,0 +1,159 @@ +import type * as TranscribeCpp from 'transcribe-cpp' +import { OfflineAudioChunker } from './stt-offline-audio-chunker' + +// Silero's own defaults: a frame at or above this is speech; inside speech, one below the second is silence. +const SPEECH_PROBABILITY = 0.5 +const SILENCE_PROBABILITY = 0.35 +// A pause this long commits the phrase before it. Shorter cuts split phrases, and each piece gets its own capital and full stop. +export const VAD_PAUSE_SECONDS = 0.6 +// Silence kept ahead of detected speech so a soft first syllable isn't clipped. +const SPEECH_PREROLL_SECONDS = 0.4 +// Silero's minimum speech: a click or cough alone doesn't end a phrase; it waits for the speech after it, as without a detector. +const MIN_PHRASE_SPEECH_SECONDS = 0.25 + +export type VadStream = Pick + +/** + * Cuts one dictation's audio at pauses so an offline model can decode each phrase as it ends, and drops the + * silence between phrases. Phrases longer than the decode bound still split at their quietest point. + */ +export class VadPauseSegmenter { + private readonly phrase: OfflineAudioChunker + private readonly pauseFrames: number + private readonly minSpeechFrames: number + private readonly prerollSamples: number + private unscored: Float32Array = new Float32Array(0) + private preroll: Float32Array[] = [] + private prerollLength = 0 + private inSpeech = false + private speechFrames = 0 + private silentFrames = 0 + private vadFailed = false + + /** `cutsAtPauses` false: phrases end only at the decode bound and at stop, as without a detector. */ + constructor( + private readonly vad: VadStream, + sampleRate: number, + private readonly frameSamples: number, + private readonly cutsAtPauses: () => boolean = () => true + ) { + this.phrase = new OfflineAudioChunker(sampleRate) + this.pauseFrames = Math.ceil((VAD_PAUSE_SECONDS * sampleRate) / frameSamples) + this.minSpeechFrames = Math.ceil((MIN_PHRASE_SPEECH_SECONDS * sampleRate) / frameSamples) + this.prerollSamples = Math.round(SPEECH_PREROLL_SECONDS * sampleRate) + } + + /** Samples pushed but neither returned nor dropped as silence. */ + get heldSamples(): number { + return this.unscored.length + this.prerollLength + this.phrase.heldSamples + } + + /** Return the phrases that ended (or reached the decode bound) within these samples. */ + async push(samples: Float32Array): Promise { + if (this.vadFailed) { + return this.phrase.push(samples) + } + this.unscored = concat(this.unscored, samples) + try { + // The binding borrows the PCM until the call settles, so it gets its own copy. + return this.route((await this.vad.streamFeed(new Float32Array(samples))).probs) + } catch (error) { + return this.stopSegmenting(error) + } + } + + /** Return the rest of an open phrase; trailing silence is dropped. */ + async flush(): Promise { + let ready: Float32Array[] = [] + if (!this.vadFailed) { + try { + ready = this.route((await this.vad.streamFlush()).probs, true) + } catch (error) { + ready = this.stopSegmenting(error) + } + } + const rest = this.inSpeech ? this.phrase.flush() : null + this.inSpeech = false + this.preroll = [] + this.prerollLength = 0 + return rest ? [...ready, rest] : ready + } + + // A detector error must not cost the dictation: keep every held sample and finish as bounded chunks. + private stopSegmenting(error: unknown): Float32Array[] { + console.warn('[speech] Silero VAD failed mid-dictation; text arrives at stop:', error) + this.vadFailed = true + this.inSpeech = true + const ready: Float32Array[] = [] + for (const held of [...this.preroll, this.unscored]) { + ready.push(...this.phrase.push(held)) + } + this.preroll = [] + this.prerollLength = 0 + this.unscored = new Float32Array(0) + return ready + } + + private route(probs: readonly number[], last = false): Float32Array[] { + const ready: Float32Array[] = [] + let offset = 0 + for (const probability of probs) { + // The flushed last frame is zero padded past the real audio. + const end = Math.min(this.unscored.length, offset + this.frameSamples) + const frame = this.unscored.subarray(offset, end) + offset = end + if (!this.inSpeech && probability >= SPEECH_PROBABILITY) { + this.inSpeech = true + this.speechFrames = 0 + this.silentFrames = 0 + for (const held of this.preroll) { + ready.push(...this.phrase.push(held)) + } + this.preroll = [] + this.prerollLength = 0 + } + if (!this.inSpeech) { + this.holdPreroll(frame) + continue + } + ready.push(...this.phrase.push(frame)) + this.speechFrames += probability >= SPEECH_PROBABILITY ? 1 : 0 + this.silentFrames = probability < SILENCE_PROBABILITY ? this.silentFrames + 1 : 0 + if ( + this.silentFrames >= this.pauseFrames && + this.speechFrames >= this.minSpeechFrames && + this.cutsAtPauses() + ) { + this.inSpeech = false + const rest = this.phrase.flush() + if (rest) { + ready.push(rest) + } + } + } + this.unscored = last ? new Float32Array(0) : this.unscored.slice(offset) + return ready + } + + private holdPreroll(frame: Float32Array): void { + this.preroll.push(frame.slice()) + this.prerollLength += frame.length + while ( + this.preroll.length > 1 && + this.prerollLength - this.preroll[0].length >= this.prerollSamples + ) { + this.prerollLength -= this.preroll[0].length + this.preroll.shift() + } + } +} + +function concat(a: Float32Array, b: Float32Array): Float32Array { + if (a.length === 0) { + return b.slice() + } + const out = new Float32Array(a.length + b.length) + out.set(a) + out.set(b, a.length) + return out +} From cff5a151fcdb80b118d06c32d8d44c671f50d087 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 11:58:10 -0800 Subject: [PATCH 039/138] test: remove low-value CLI, browser and E2E checks (#40466) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 6 - .../handlers/data-account-commands.test.ts | 36 +-- .../orchestration-windows-ask-cli.test.ts | 109 --------- src/cli/index-file-open-headless.test.ts | 45 ---- src/cli/index-vm-recipe-doctor.test.ts | 107 --------- ...ion-structured-session-no-identity.test.ts | 11 - .../plugin-editor-check-loader-rules.test.ts | 8 - .../plugin-editor-check.test.ts | 10 +- .../plugin-release-entry.test.ts | 16 +- .../environment-pairing-exchange.test.ts | 7 - src/cli/runtime/environments.test.ts | 29 +-- .../orchestration-recovery-command.test.ts | 94 +------- src/cli/runtime/serve-install-hold.test.ts | 8 - .../runtime/serve-pairing-redaction.test.ts | 9 +- .../runtime/serve-supervisor-policy.test.ts | 17 -- ...pt-mutation-recovery-plugin-denied.test.ts | 22 -- .../browser-guest-paint-policy.test.ts | 40 ---- .../browser-page-guest-recovery.test.ts | 67 ------ .../windows-cmd-shim-resolution.test.ts | 28 --- src/shared/abort-signal-reason.test.ts | 27 +-- .../agent-cli-install-dir-fallback.test.ts | 22 -- .../reverse-file-region-scan.test.ts | 15 -- .../transcript-reader.test.ts | 29 --- .../agent-hook-endpoint-file.test.ts | 27 --- ...github-created-issue-start-prefill.spec.ts | 206 ------------------ 25 files changed, 7 insertions(+), 988 deletions(-) delete mode 100644 src/cli/handlers/orchestration-windows-ask-cli.test.ts delete mode 100644 src/cli/index-vm-recipe-doctor.test.ts delete mode 100644 src/cli/runtime/terminal-prompt-mutation-recovery-plugin-denied.test.ts delete mode 100644 src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-policy.test.ts delete mode 100644 src/shared/agent-hook-listener/transport/agent-hook-endpoint-file.test.ts delete mode 100644 tests/e2e/github-created-issue-start-prefill.spec.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index ee0501b0119..afd1ef5bf40 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -345,7 +345,6 @@ "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 555, "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 781, "src/cli/handlers/orchestration-task-list-brief.test.ts": 494, - "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 801, "src/cli/handlers/orchestration-worker-cli.test.ts": 706, "src/cli/handlers/orchestration.test.ts": 543, "src/cli/handlers/orchestration/agent-view.test.ts": 511, @@ -380,7 +379,6 @@ "src/cli/index-omitted-host-scope-selectors.test.ts": 6719, "src/cli/index-orchestration.test.ts": 659, "src/cli/index-terminal-commands.test.ts": 466, - "src/cli/index-vm-recipe-doctor.test.ts": 459, "src/cli/index-workspace-owner-choice-scaling.test.ts": 10154, "src/cli/index-workspace-owner-choice.test.ts": 1072, "src/cli/index-worktree-create-agent.test.ts": 582, @@ -441,7 +439,6 @@ "src/cli/runtime/serve-update-supervisor-gate.test.ts": 73, "src/cli/runtime/status-caller.test.ts": 488, "src/cli/runtime/status.test.ts": 1406, - "src/cli/runtime/terminal-prompt-mutation-recovery-plugin-denied.test.ts": 125, "src/cli/runtime/transport-framing.test.ts": 194, "src/cli/runtime/transport.test.ts": 462, "src/cli/runtime/types.test.ts": 62, @@ -4766,7 +4763,6 @@ "src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.test.ts": 228, "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 45, "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 1724, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-policy.test.ts": 2035, "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 1839, "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 515, "src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts": 2197, @@ -7102,7 +7098,6 @@ "src/shared/agent-cli-install-dir-fallback.test.ts": 130, "src/shared/agent-launch/startup/agent-command-line-intent.test.ts": 95, "src/shared/agent-status/title/agent-detection.test.ts": 188, - "src/shared/agent-hook-listener/transport/agent-hook-endpoint-file.test.ts": 58, "src/shared/agent-hook-listener/transport/agent-hook-endpoint-temp-cleanup.test.ts": 55, "src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-elicitation.test.ts": 229, "src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-approval-ownership.test.ts": 258, @@ -8262,7 +8257,6 @@ "tests/e2e/git-history-tooltip-wrap.spec.ts": 22900, "tests/e2e/git-no-upstream-polling-churn.spec.ts": 22600, "tests/e2e/github-cli-stall-repro.spec.ts": 18200, - "tests/e2e/github-created-issue-start-prefill.spec.ts": 43100, "tests/e2e/github-url-smart-input-transition.spec.ts": 58200, "tests/e2e/golden-agent-tui-launch.spec.ts": 27300, "tests/e2e/golden-core-flows.spec.ts": 91100, diff --git a/src/cli/handlers/data-account-commands.test.ts b/src/cli/handlers/data-account-commands.test.ts index 7a4ae5d7bad..b0d881fe4e6 100644 --- a/src/cli/handlers/data-account-commands.test.ts +++ b/src/cli/handlers/data-account-commands.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' -import { parseArgs, REPEATED_FLAG_SEPARATOR, specPaths } from '../args' +import { parseArgs, specPaths } from '../args' import { ACCOUNT_COMMAND_SPECS } from '../specs/account' import { RuntimeClient } from '../runtime-client' import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/account-runtime-capabilities' @@ -61,40 +61,6 @@ describe('managed data account enrollment', () => { expect(existsSync(directory)).toBe(false) }) - it('passes OpenCode login method and answers before the standalone flag', async () => { - vi.spyOn(client, 'call') - .mockResolvedValueOnce({ - id: 'test', - ok: true, - result: { capabilities: [DATA_ACCOUNT_RUNTIME_CAPABILITY] }, - _meta: { runtimeId: 'test' } - }) - .mockRejectedValueOnce(new Error('capture failed')) - const login = vi.fn(async (_command: string, args: string[]) => { - expect(args).toEqual([ - 'auth', - 'login', - 'github-copilot', - '--method', - ' device ', - '--answer', - 'deploymentType=github.com', - '--answer', - 'note name=a=b', - '--standalone' - ]) - }) - const flags = new Map([ - ['integration', 'github-copilot'], - ['method', ' device '], - ['answer', ['deploymentType=github.com', 'note name=a=b'].join(REPEATED_FLAG_SEPARATOR)] - ]) - await expect(addDataAccount({ ...context, flags }, 'opencode', login)).rejects.toThrow( - 'capture failed' - ) - expect(login).toHaveBeenCalledOnce() - }) - it.each([ ['devin', new Map([['method', 'device']]), 'only supported with --agent opencode'], ['opencode', new Map([['method', ' ']]), '--method requires'], diff --git a/src/cli/handlers/orchestration-windows-ask-cli.test.ts b/src/cli/handlers/orchestration-windows-ask-cli.test.ts deleted file mode 100644 index 70cfc10c35f..00000000000 --- a/src/cli/handlers/orchestration-windows-ask-cli.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const callMock = vi.fn() -const originalCliCommand = process.env.ORCA_CLI_COMMAND -const originalPackagedLauncher = process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER -const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE -const originalExitCode = process.exitCode - -vi.mock('../format', () => ({ printResult: vi.fn() })) -vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) - -import { ORCHESTRATION_HANDLERS } from './orchestration' - -describe('packaged Windows legacy ask protocol', () => { - beforeEach(() => { - callMock.mockReset() - process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER = '1' - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - process.exitCode = undefined - }) - - afterEach(() => { - vi.restoreAllMocks() - process.exitCode = originalExitCode - restoreEnv('ORCA_CLI_COMMAND', originalCliCommand) - restoreEnv('ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER', originalPackagedLauncher) - restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle) - }) - - it.each(['orca', 'orca-ide'] as const)( - 'commits with the %s launcher and exits 75 before resume', - async (command) => { - process.env.ORCA_CLI_COMMAND = command - callMock.mockResolvedValue({ - result: { - answer: null, - messageId: 'msg_question', - threadId: 'msg_question', - timedOut: false, - legacyCompatibility: { - resumeRequired: true, - resumeCommand: `${command} orchestration ask --resume msg_question` - } - } - }) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk(new Map([['question', 'Proceed?']])) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ - question: 'Proceed?', - resume: undefined, - compatibilityWindowsCommand: command - }), - expect.any(Object) - ) - expect(log.mock.calls.map(([line]) => line)).toEqual([ - `Question msg_question committed.\nNext: Resume with: ${command} orchestration ask --resume msg_question` - ]) - expect(process.exitCode).toBe(75) - } - ) - - it('resumes the committed question without another exit-75 handoff', async () => { - process.env.ORCA_CLI_COMMAND = 'orca' - callMock.mockResolvedValue({ - result: { - answer: 'yes', - messageId: 'msg_question', - threadId: 'msg_question', - timedOut: false - } - }) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk(new Map([['resume', 'msg_question']])) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ - question: undefined, - resume: 'msg_question', - compatibilityWindowsCommand: 'orca' - }), - expect.any(Object) - ) - expect(log).toHaveBeenCalledWith('yes') - expect(process.exitCode).toBeUndefined() - }) -}) - -function invokeAsk(flags: Map): Promise { - return ORCHESTRATION_HANDLERS['orchestration ask']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: false - } as never) -} - -function restoreEnv(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name] - } else { - process.env[name] = value - } -} diff --git a/src/cli/index-file-open-headless.test.ts b/src/cli/index-file-open-headless.test.ts index 58bf2e6b061..3c0a9044a47 100644 --- a/src/cli/index-file-open-headless.test.ts +++ b/src/cli/index-file-open-headless.test.ts @@ -38,36 +38,6 @@ describe('file commands on a server with no app window', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) }) - it('explains the refusal instead of printing the raw token', async () => { - mocks.callMock.mockRejectedValueOnce(headlessRefusal()) - - await main(['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/repo') - - const printed = String(vi.mocked(console.error).mock.calls[0]?.[0]) - expect(printed).toContain('This Orca server has no app window') - expect(printed).toContain('Next step: Open the file from the file explorer') - expect(printed.split('\n')[0]).not.toBe('renderer_unavailable') - expect(process.exitCode).toBe(1) - }) - - it('keeps a stable code and recovery in --json output', async () => { - mocks.callMock.mockRejectedValueOnce(headlessRefusal()) - - await main( - ['file', 'diff', 'src/App.tsx', '--worktree', 'id:wt-1', '--focus', '--json'], - '/tmp/repo' - ) - - const output = JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])) - expect(output.ok).toBe(false) - expect(output.error.code).toBe('renderer_unavailable') - expect(output.error.message).toContain('no app window') - expect(output.error.data.nextSteps).toEqual([ - expect.stringContaining('file explorer'), - expect.stringContaining('path in your reply') - ]) - }) - it('stops open-changed at the first refusal with the same explanation', async () => { mocks.callMock .mockResolvedValueOnce( @@ -94,19 +64,4 @@ describe('file commands on a server with no app window', () => { 'This Orca server has no app window' ) }) - - it('leaves other runtime errors untouched', async () => { - mocks.callMock.mockRejectedValueOnce( - new RuntimeRpcFailureError({ - id: 'req_open', - ok: false, - error: { code: 'runtime_error', message: 'chat_scratch_not_openable' }, - _meta: { runtimeId: 'runtime-1' } - }) - ) - - await main(['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/repo') - - expect(String(vi.mocked(console.error).mock.calls[0]?.[0])).toBe('chat_scratch_not_openable') - }) }) diff --git a/src/cli/index-vm-recipe-doctor.test.ts b/src/cli/index-vm-recipe-doctor.test.ts deleted file mode 100644 index f78075805b5..00000000000 --- a/src/cli/index-vm-recipe-doctor.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import path from 'node:path' -import { chmodSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { describe, expect, it, vi } from 'vitest' - -const { - callMock, - runtimeClientConstructorMock, - serveOrcaAppMock, - getDefaultUserDataPathMock, - addEnvironmentFromPairingCodeMock, - listEnvironmentsMock, - spawnMock -} = vi.hoisted(() => ({ - callMock: vi.fn(), - runtimeClientConstructorMock: vi.fn(), - serveOrcaAppMock: vi.fn(), - getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), - addEnvironmentFromPairingCodeMock: vi.fn(), - listEnvironmentsMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('./runtime-client', async () => { - const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') - return createRuntimeClientModuleMock({ - callMock, - runtimeClientConstructorMock, - serveOrcaAppMock, - getDefaultUserDataPathMock - }) -}) - -vi.mock('./runtime/environments', () => ({ - addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, - removeEnvironment: vi.fn(), - resolveEnvironment: vi.fn() -})) - -vi.mock('../shared/remote-runtime/host-store/runtime-environment-store', () => ({ - listEnvironments: listEnvironmentsMock -})) - -vi.mock('child_process', async () => { - const { createChildProcessModuleMock } = await import('./index-test-harness.js') - return createChildProcessModuleMock(spawnMock) -}) - -import { main } from './index' -import { useWorktreeAwarenessEnvironment } from './index-test-harness' - -describe('orca cli worktree awareness', () => { - useWorktreeAwarenessEnvironment({ - callMock, - serveOrcaAppMock, - getDefaultUserDataPathMock, - addEnvironmentFromPairingCodeMock, - listEnvironmentsMock, - spawnMock - }) - - it('runs vm recipe doctor locally without contacting the app runtime', async () => { - const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-')) - try { - mkdirSync(path.join(repoPath, 'scripts', 'orca-vm'), { recursive: true }) - const startScript = path.join(repoPath, 'scripts', 'orca-vm', 'start.sh') - const cleanupScript = path.join(repoPath, 'scripts', 'orca-vm', 'cleanup.sh') - writeFileSync(startScript, '#!/bin/sh\n') - writeFileSync(cleanupScript, '#!/bin/sh\n') - chmodSync(startScript, 0o755) - chmodSync(cleanupScript, 0o755) - writeFileSync( - path.join(repoPath, 'orca.yaml'), - [ - 'environmentRecipes:', - ' - id: cloud-sandbox', - ' name: Cloud Sandbox', - ' create: ./scripts/orca-vm/start.sh', - ' destroy: ./scripts/orca-vm/cleanup.sh' - ].join('\n') - ) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['vm', 'recipe', 'doctor', 'cloud-sandbox', '--repo-path', repoPath, '--json']) - - const output = JSON.parse(String(logSpy.mock.calls[0][0])) as { - ok: boolean - checks: { id: string; status: string }[] - } - if (!output.ok) { - throw new Error(JSON.stringify(output)) - } - expect(output.ok).toBe(true) - expect(output.checks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: 'orca_yaml.parse', status: 'pass' }), - expect.objectContaining({ id: 'recipe.exists', status: 'pass' }), - expect.objectContaining({ id: 'recipe.create', status: 'pass' }), - expect.objectContaining({ id: 'recipe.destroy', status: 'pass' }) - ]) - ) - expect(callMock).not.toHaveBeenCalled() - } finally { - rmSync(repoPath, { recursive: true, force: true }) - } - }) -}) diff --git a/src/cli/orchestration-structured-session-no-identity.test.ts b/src/cli/orchestration-structured-session-no-identity.test.ts index 2a268aeec50..566dd31e6ab 100644 --- a/src/cli/orchestration-structured-session-no-identity.test.ts +++ b/src/cli/orchestration-structured-session-no-identity.test.ts @@ -75,15 +75,4 @@ describe('a structured chat session with no orchestration identity', () => { expect.objectContaining({ terminal: 'structworker_self' }) ) }) - - it('leaves an ordinary shell alone, which has no marker and may still guess', async () => { - delete process.env[ORCA_STRUCTURED_SESSION_ENV] - callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) - await invoke('orchestration check') - expect(getTerminalHandleMock).toHaveBeenCalled() - expect(callMock).toHaveBeenCalledWith( - 'orchestration.check', - expect.objectContaining({ terminal: 'term_sibling' }) - ) - }) }) diff --git a/src/cli/plugin-authoring/plugin-editor-check-loader-rules.test.ts b/src/cli/plugin-authoring/plugin-editor-check-loader-rules.test.ts index 83061a44e06..1cc3b0ae3ac 100644 --- a/src/cli/plugin-authoring/plugin-editor-check-loader-rules.test.ts +++ b/src/cli/plugin-authoring/plugin-editor-check-loader-rules.test.ts @@ -109,12 +109,4 @@ describe('orca plugin check: editors load as the frame protocol serves them', () expect(result.issues).toEqual([]) expect(result.files).toContain('editor/my script.js') }) - - it('lets a dependency reach the 16 MiB the protocol serves, past the 10 MiB entry cap', async () => { - const root = await editorWith({ - 'editor/index.html': page(''), - 'editor/vendor.mjs': `export const x = 1\n//${'x'.repeat(11 * 1024 * 1024)}\n` - }) - expect((await check(root)).issues).toEqual([]) - }) }) diff --git a/src/cli/plugin-authoring/plugin-editor-check.test.ts b/src/cli/plugin-authoring/plugin-editor-check.test.ts index 44e53ba2bdb..f39e207ba25 100644 --- a/src/cli/plugin-authoring/plugin-editor-check.test.ts +++ b/src/cli/plugin-authoring/plugin-editor-check.test.ts @@ -2,7 +2,7 @@ import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/pro import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { checkPluginEditors, formatPluginEditorCheck } from './plugin-editor-check' +import { checkPluginEditors } from './plugin-editor-check' import { createPluginFromTemplate } from './plugin-create' import { validatePluginTarget } from './plugin-folder-validation' import { checkPluginDevFolder } from '../plugin-dev-watch/plugin-dev-watch-check' @@ -37,14 +37,6 @@ async function editorPlugin(): Promise { } describe('orca plugin check: custom editors', () => { - it('passes the editor template and follows its script and imports', async () => { - const dir = await editorPlugin() - const [check] = await checkPluginEditors(dir) - expect(check.issues).toEqual([]) - expect(check.files).toEqual(['editor/index.html', 'editor/main.ts', 'editor/sync.ts']) - expect(formatPluginEditorCheck(check)[0]).toMatch(/: ok, 3 file\(s\) load$/) - }) - it('fails on a syntax error that validate lets through', async () => { const dir = await editorPlugin() await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n') diff --git a/src/cli/plugin-authoring/plugin-release-entry.test.ts b/src/cli/plugin-authoring/plugin-release-entry.test.ts index 9ee3494257f..99c95095830 100644 --- a/src/cli/plugin-authoring/plugin-release-entry.test.ts +++ b/src/cli/plugin-authoring/plugin-release-entry.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { runProcess } from '@orca/process-host' @@ -90,18 +90,4 @@ describe('orca plugin release-entry', () => { expect(result.entry).toBeUndefined() expect(result.note).toContain('themes/dark.json') }) - - it('explains why there is no entry outside Git or before the first commit', async () => { - const outside = join(await tempRoot(), 'loose') - await createPluginFromTemplate({ dir: outside, template: 'theme', publisher: 'acme' }) - expect((await releaseFor(outside)).note).toContain('not in a Git repository') - - const repo = await tempRoot() - await git(repo, ['init', '--quiet']) - await mkdir(join(repo, 'p')) - await createPluginFromTemplate({ dir: join(repo, 'p'), template: 'theme', publisher: 'acme' }) - const uncommitted = await releaseFor(join(repo, 'p')) - expect(uncommitted.entry).toBeUndefined() - expect(uncommitted.note).toContain('commit the plugin first') - }) }) diff --git a/src/cli/runtime/environment-pairing-exchange.test.ts b/src/cli/runtime/environment-pairing-exchange.test.ts index 90b284230a4..874c952a4dd 100644 --- a/src/cli/runtime/environment-pairing-exchange.test.ts +++ b/src/cli/runtime/environment-pairing-exchange.test.ts @@ -11,13 +11,6 @@ const pairingCode = encodePairingOffer({ }) describe('exchangeCliPairingCode', () => { - it('saves the code unchanged when the host is unreachable or the code is invalid', async () => { - const send = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) - await expect(exchangeCliPairingCode(pairingCode, send)).resolves.toBe(pairingCode) - await expect(exchangeCliPairingCode('not a code', send)).resolves.toBe('not a code') - expect(send).toHaveBeenCalledOnce() - }) - it('refuses to save a code the host rejects, such as one another device already used', async () => { for (const send of [ vi diff --git a/src/cli/runtime/environments.test.ts b/src/cli/runtime/environments.test.ts index 31d1f263593..95b9bce3b7f 100644 --- a/src/cli/runtime/environments.test.ts +++ b/src/cli/runtime/environments.test.ts @@ -3,11 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { encodePairingOffer } from '../../shared/remote-runtime/pairing/pairing' -import { - addEnvironmentFromPairingCode, - removeEnvironment, - resolveEnvironmentPairingOffer -} from './environments' +import { addEnvironmentFromPairingCode, resolveEnvironmentPairingOffer } from './environments' import { getEnvironmentStorePath } from '../../shared/remote-runtime/host-store/runtime-environment-store-records' import { listEnvironments } from '../../shared/remote-runtime/host-store/runtime-environment-store' @@ -37,29 +33,6 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768', keyFill = 1): string { describe('CLI runtime environments', () => { const posixModeIt = process.platform === 'win32' ? it.skip : it - it('saves, resolves, and removes a paired environment', () => { - const userDataPath = temporaryProfile() - const saved = addEnvironmentFromPairingCode(userDataPath, { - name: 'workstation', - pairingCode: pairingCode(), - now: 100 - }) - - expect(listEnvironments(userDataPath)).toHaveLength(1) - expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation')).toMatchObject({ - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'device-token' - }) - expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({ - endpoint: 'ws://127.0.0.1:6768' - }) - expect(statSync(getEnvironmentStorePath(userDataPath)).isFile()).toBe(true) - - const removed = removeEnvironment(userDataPath, 'workstation') - expect(removed.id).toBe(saved.id) - expect(listEnvironments(userDataPath)).toEqual([]) - }) - posixModeIt('stores paired environments with owner-only POSIX permissions', () => { const userDataPath = temporaryProfile() diff --git a/src/cli/runtime/orchestration-recovery-command.test.ts b/src/cli/runtime/orchestration-recovery-command.test.ts index 7f8873f6831..17919ae2197 100644 --- a/src/cli/runtime/orchestration-recovery-command.test.ts +++ b/src/cli/runtime/orchestration-recovery-command.test.ts @@ -1,77 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - buildOrchestrationRecoveryCommand, - resolveOrchestrationCliExecutable -} from './orchestration-recovery-command' +import { buildOrchestrationRecoveryCommand } from './orchestration-recovery-command' describe('orchestration recovery command identity', () => { - it.each([ - ['configured dev', { ORCA_CLI_COMMAND: 'orca-dev' }, 'darwin', 'orca-dev'], - ['configured WSL', { ORCA_CLI_COMMAND: 'orca-ide' }, 'linux', 'orca-ide'], - ['dev checkout', { ORCA_DEV_REPO_ROOT: '/repo' }, 'darwin', 'orca-dev'], - ['packaged Linux', {}, 'linux', 'orca-ide'], - ['local macOS', {}, 'darwin', 'orca'], - ['local Windows', {}, 'win32', 'orca'] - ] as const)('resolves the %s CLI identity', (_name, env, platform, expected) => { - expect(resolveOrchestrationCliExecutable(env, platform)).toBe(expected) - }) - - it('reconstructs the keyed worker-start command from its RPC params', () => { - expect( - buildOrchestrationRecoveryCommand( - 'orchestration.workerStart', - { - task: 'task_1', - on: 'windows', - worktree: 'new-top-level', - timeoutMs: 90_000, - devMode: false - }, - 'orca' - ) - ).toEqual([ - 'orca', - 'orchestration', - 'worker-start', - '--task', - 'task_1', - '--on', - 'windows', - '--worktree', - 'new-top-level', - '--timeout-ms', - '90000' - ]) - }) - - it('preserves the selected executable and raw command arguments', () => { - expect( - buildOrchestrationRecoveryCommand( - 'orchestration.workerStart', - { task: 'task_1' }, - 'orca-dev', - [ - 'orchestration', - 'worker-start', - '--task', - 'task_1', - '--comment', - 'literal $(do-not-run) "quoted"', - '--json' - ] - ) - ).toEqual([ - 'orca-dev', - 'orchestration', - 'worker-start', - '--task', - 'task_1', - '--comment', - 'literal $(do-not-run) "quoted"', - '--json' - ]) - }) - it.each([ ['split', ['orchestration', 'send', '--pairing-code', 'secret', '--subject', 'status']], ['equals', ['orchestration', 'send', '--pairing-code=secret', '--subject', 'status']], @@ -89,28 +19,6 @@ describe('orchestration recovery command identity', () => { ).toBeUndefined() }) - it('supports the explicit executable-first form', () => { - expect( - buildOrchestrationRecoveryCommand('orca-ide', 'orchestration.workerStop', { - dispatch: 'dispatch_1' - }) - ).toEqual(['orca-ide', 'orchestration', 'worker-stop', '--dispatch', 'dispatch_1']) - }) - - it('reconstructs worker-keep-open when raw argv is unavailable', () => { - expect( - buildOrchestrationRecoveryCommand('orchestration.workerRetain', { - dispatch: 'dispatch_1' - }) - ).toEqual([ - resolveOrchestrationCliExecutable(), - 'orchestration', - 'worker-keep-open', - '--dispatch', - 'dispatch_1' - ]) - }) - it('does not fabricate identity for unsupported mutations or malformed params', () => { expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined() expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined() diff --git a/src/cli/runtime/serve-install-hold.test.ts b/src/cli/runtime/serve-install-hold.test.ts index 461bb10c1e6..ad7a85848dc 100644 --- a/src/cli/runtime/serve-install-hold.test.ts +++ b/src/cli/runtime/serve-install-hold.test.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { readServeInstallEvidence } from './serve-install-hold' -import { isShipItRunningForBundle } from '../../shared/shipit-process-listing' describe('readServeInstallEvidence', () => { let root: string @@ -60,11 +59,4 @@ describe('readServeInstallEvidence', () => { }) ).resolves.toEqual({ kind: 'clear' }) }) - - it('matches only the ShipIt binary path', () => { - expect(isShipItRunningForBundle(` ${shipIt('/A.app')}`, '/A.app')).toBe(true) - expect( - isShipItRunningForBundle('/A.app/Contents/Frameworks/Squirrel.framework/Squirrel', '/A.app') - ).toBe(false) - }) }) diff --git a/src/cli/runtime/serve-pairing-redaction.test.ts b/src/cli/runtime/serve-pairing-redaction.test.ts index 72e634f40a3..4dba64bb3b6 100644 --- a/src/cli/runtime/serve-pairing-redaction.test.ts +++ b/src/cli/runtime/serve-pairing-redaction.test.ts @@ -5,8 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createPairingRedactor, PAIRING_HIDDEN_LINE, - redactingSpawn, - shouldRedactSupervisedStdout + redactingSpawn } from './serve-pairing-redaction' const READY = [ @@ -49,12 +48,6 @@ describe('supervised pairing redaction', () => { }) }) - it('redacts only supervised output that is not a terminal', () => { - expect(shouldRedactSupervisedStdout(true, false)).toBe(true) - expect(shouldRedactSupervisedStdout(true, true)).toBe(false) - expect(shouldRedactSupervisedStdout(false, false)).toBe(false) - }) - it('pipes only stdout through the redactor and keeps the ipc slot', async () => { const stdout = new PassThrough() const child = new ChildProcess() diff --git a/src/cli/runtime/serve-supervisor-policy.test.ts b/src/cli/runtime/serve-supervisor-policy.test.ts index 2c12a9611e0..12ae3333e62 100644 --- a/src/cli/runtime/serve-supervisor-policy.test.ts +++ b/src/cli/runtime/serve-supervisor-policy.test.ts @@ -21,23 +21,6 @@ function exit(overrides: Partial = {}): ServeChildExit { } describe('decideAfterServeChildExit', () => { - it('passes a normal exit code through, and explains an unforwarded signal', () => { - const base = { expectedHandoff: null, handoff: null, hasHandoffPath: true } - expect(decideAfterServeChildExit({ ...base, exit: exit({ code: 3 }) })).toEqual({ - kind: 'exit', - code: 3 - }) - expect( - decideAfterServeChildExit({ - ...base, - exit: exit({ code: null, signal: 'SIGTERM', signalWasForwarded: true }) - }) - ).toEqual({ kind: 'exit', code: 0 }) - expect( - decideAfterServeChildExit({ ...base, exit: exit({ code: null, signal: 'SIGABRT' }) }) - ).toEqual({ kind: 'signal-exit', signal: 'SIGABRT' }) - }) - it('waits for the new bundle only when this very child requested the install', () => { expect( decideAfterServeChildExit({ diff --git a/src/cli/runtime/terminal-prompt-mutation-recovery-plugin-denied.test.ts b/src/cli/runtime/terminal-prompt-mutation-recovery-plugin-denied.test.ts deleted file mode 100644 index bb2b2d78760..00000000000 --- a/src/cli/runtime/terminal-prompt-mutation-recovery-plugin-denied.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { attachDurableMutationRecovery } from './terminal-prompt-mutation-recovery' -import { RuntimeClientError } from './types' - -describe('a plugin hook denial in the CLI', () => { - it('gets no retry advice in the CLI', () => { - const denied = new RuntimeClientError( - 'denied_by_plugin', - 'terminal.send denied by plugin a.b: x' - ) - expect( - attachDurableMutationRecovery(denied, 'req-1', ['terminal', 'send'], 'terminal.send') - ).toBe(denied) - const other = attachDurableMutationRecovery( - new RuntimeClientError('runtime_error', 'boom'), - 'req-1', - ['terminal', 'send'], - 'terminal.send' - ) - expect(String(other)).toMatch(/--retry-request req-1/) - }) -}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-policy.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-policy.test.ts deleted file mode 100644 index 0b7830ca9fe..00000000000 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-policy.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { browserPageNeedsPaintRetention } from './browser-guest-paint-retention' -import { hydrateBrowserDrivers } from '../../../lib/pane-manager/browser-mobile-driver-state' -import { hydrateBrowserRemoteViewerPages } from '../../../lib/pane-manager/browser-remote-viewer-state' -import { - acquireBrowserAutomationVisibility, - releaseBrowserAutomationVisibility -} from './browser-automation-visibility' - -// The imperative twin, for retention decisions taken outside a render (the eviction budget). -describe('browserPageNeedsPaintRetention', () => { - afterEach(() => { - hydrateBrowserDrivers([]) - hydrateBrowserRemoteViewerPages([]) - }) - - it('answers for each term on its own', () => { - expect(browserPageNeedsPaintRetention('page-a')).toBe(false) - - hydrateBrowserRemoteViewerPages(['page-a']) - expect(browserPageNeedsPaintRetention('page-a')).toBe(true) - hydrateBrowserRemoteViewerPages([]) - - hydrateBrowserDrivers([ - { browserPageId: 'page-a', driver: { kind: 'mobile', clientId: 'phone-1' } } - ]) - expect(browserPageNeedsPaintRetention('page-a')).toBe(true) - hydrateBrowserDrivers([]) - - const token = acquireBrowserAutomationVisibility('page-a') - expect(browserPageNeedsPaintRetention('page-a')).toBe(true) - releaseBrowserAutomationVisibility(token) - expect(browserPageNeedsPaintRetention('page-a')).toBe(false) - }) - - it('stays scoped to the page asked about', () => { - hydrateBrowserRemoteViewerPages(['page-a']) - expect(browserPageNeedsPaintRetention('page-b')).toBe(false) - }) -}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts index efff82bd6cf..33989c229ad 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts @@ -62,36 +62,6 @@ afterEach(() => { }) describe('browser page guest recovery', () => { - it('reloads a lost renderer in place and cancels the failure surface after recovery', () => { - vi.useFakeTimers() - const state = createRecovery() - - state.recovery.recoverRenderer() - - expect(state.reload).toHaveBeenCalledOnce() - expect(state.pending()).toBe(true) - - state.recovery.finish() - vi.advanceTimersByTime(BROWSER_GUEST_RECOVERY_TIMEOUT_MS) - - expect(state.pending()).toBe(false) - expect(state.onRecoveryFailed).not.toHaveBeenCalled() - }) - - it('recreates a guest when in-place reload is unavailable', async () => { - const state = createRecovery({ - reload: () => { - throw new Error('guest destroyed') - } - }) - - state.recovery.recoverRenderer() - await vi.waitFor(() => expect(state.onReplacementReady).toHaveBeenCalledOnce()) - - expect(state.replaceGuest).toHaveBeenCalledOnce() - expect(state.pending()).toBe(true) - }) - it('surfaces guest replacement rejection without marking it ready', async () => { const replacementError = new Error('replacement failed') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -139,32 +109,6 @@ describe('browser page guest recovery', () => { expect(state.onReplacementReady).toHaveBeenCalledOnce() }) - it('recreates an active guest missing from the authoritative registry after resume', async () => { - const state = createRecovery({ registered: false }) - - state.recovery.validateAfterResume() - await vi.waitFor(() => expect(state.onReplacementReady).toHaveBeenCalledOnce()) - - expect(state.validateRegistration).toHaveBeenCalledOnce() - expect(state.replaceGuest).toHaveBeenCalledOnce() - }) - - it('waits for an attaching guest instead of replacing it as missing', async () => { - vi.useFakeTimers() - const state = createRecovery() - state.validateRegistration.mockResolvedValueOnce(null).mockResolvedValueOnce(true) - - state.recovery.validateAfterResume() - await vi.advanceTimersByTimeAsync(0) - - expect(state.replaceGuest).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(BROWSER_GUEST_VALIDATION_RETRY_DELAY_MS) - - expect(state.validateRegistration).toHaveBeenCalledTimes(2) - expect(state.onRecoverySucceeded).toHaveBeenCalledOnce() - expect(state.replaceGuest).not.toHaveBeenCalled() - }) - it('surfaces an attaching guest that never becomes identifiable', async () => { vi.useFakeTimers() const state = createRecovery() @@ -251,17 +195,6 @@ describe('browser page guest recovery', () => { expect(state.onRecoveryFailed).not.toHaveBeenCalled() }) - it('surfaces an explicit recovery failure instead of leaving a permanent blank page', () => { - vi.useFakeTimers() - const state = createRecovery() - - state.recovery.recoverRenderer() - vi.advanceTimersByTime(BROWSER_GUEST_RECOVERY_TIMEOUT_MS) - - expect(state.pending()).toBe(false) - expect(state.onRecoveryFailed).toHaveBeenCalledOnce() - }) - it('keeps the original recovery deadline across repeated renderer loss events', () => { vi.useFakeTimers() const state = createRecovery() diff --git a/src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts b/src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts index 505294d3fcc..ad875e9f8d9 100644 --- a/src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts +++ b/src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts @@ -190,34 +190,6 @@ describeOnWindows('resolveWindowsCmdShim', () => { expect(resolveWindowsCmdShim(shim, env)?.prefixArgs).toEqual([join(dir, 'ci2.js')]) }) - it('walks PATH once per shim directory, not once per spawn', () => { - // Adding an earlier interpreter after first resolution proves cached PATH walks stay cached; without the cache this returns early. - const early = mkdtempSync(join(tmpdir(), 'orca-shim-path-early-')) - const late = mkdtempSync(join(tmpdir(), 'orca-shim-path-late-')) - const shimDir = mkdtempSync(join(tmpdir(), 'orca-shim-path-')) - try { - writeFileSync(join(late, 'node.exe'), '') - writeFileSync(join(shimDir, 'cli.js'), '') - const shim = join(shimDir, 'walk.cmd') - writeFileSync(shim, npmProgNodeShim('cli.js')) - const pathEnv = { ...env, Path: undefined, PATH: `${early};${late}` } - - expect(resolveWindowsCmdShim(shim, pathEnv)?.program).toBe(join(late, 'node.exe')) - - writeFileSync(join(early, 'node.exe'), '') - expect(resolveWindowsCmdShim(shim, pathEnv)?.program).toBe(join(late, 'node.exe')) - - // A PATH edit must miss: caching the walk must not outlive its input. - expect(resolveWindowsCmdShim(shim, { ...pathEnv, PATH: `${early};${late};` })?.program).toBe( - join(early, 'node.exe') - ) - } finally { - removeTreeSync(early) - removeTreeSync(late) - removeTreeSync(shimDir) - } - }) - it('gives up when cmd would resolve `node` to a non-.exe PATHEXT spelling', () => { // cmd stops at the first PATHEXT match, including .COM before .EXE; skipping it would launch the wrong binary. const first = mkdtempSync(join(tmpdir(), 'orca-shim-ext-first-')) diff --git a/src/shared/abort-signal-reason.test.ts b/src/shared/abort-signal-reason.test.ts index 514ce5233ba..5992a47e2ee 100644 --- a/src/shared/abort-signal-reason.test.ts +++ b/src/shared/abort-signal-reason.test.ts @@ -1,7 +1,5 @@ -import { getEventListeners } from 'node:events' -import { expect, it, vi } from 'vitest' +import { expect, it } from 'vitest' import { waitForPromiseWithSignal } from './abort-signal-reason' -import { sharedPromiseSettlementWaiters } from './promise-settlement-waiters' it('observes work that rejects after its caller has already canceled', async () => { const controller = new AbortController() @@ -15,29 +13,6 @@ it('observes work that rejects after its caller has already canceled', async () await new Promise((resolve) => setImmediate(resolve)) }) -it('detaches cancelled callers from shared stalled work while another caller waits', async () => { - let finish = (_value: number): void => {} - const work = new Promise((resolve) => { - finish = resolve - }) - const observed = vi.spyOn(work, 'then') - const anchorController = new AbortController() - const anchor = waitForPromiseWithSignal(work, anchorController.signal) - for (let index = 0; index < 100; index += 1) { - const controller = new AbortController() - const cancelled = waitForPromiseWithSignal(work, controller.signal) - controller.abort(new Error('cancelled')) - await expect(cancelled).rejects.toThrow('cancelled') - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - } - expect(observed).toHaveBeenCalledOnce() - expect(sharedPromiseSettlementWaiters(work).waiterCount).toBe(1) - finish(42) - await expect(anchor).resolves.toBe(42) - expect(sharedPromiseSettlementWaiters(work).waiterCount).toBe(0) - expect(getEventListeners(anchorController.signal, 'abort')).toHaveLength(0) -}) - it('allows synchronous cancellation before a cached settlement is delivered', async () => { const work = Promise.resolve(42) await expect(waitForPromiseWithSignal(work, new AbortController().signal)).resolves.toBe(42) diff --git a/src/shared/agent-cli-install-dir-fallback.test.ts b/src/shared/agent-cli-install-dir-fallback.test.ts index 127057c14b2..66e97c004f2 100644 --- a/src/shared/agent-cli-install-dir-fallback.test.ts +++ b/src/shared/agent-cli-install-dir-fallback.test.ts @@ -99,28 +99,6 @@ describe('agent CLI install-dir fallback', () => { }) }) - // Match packaged PATH seeding and guest prelude order so all resolvers select the same binary. - it('ranks system install dirs in the same order as the PATH seed', () => { - const home = '/home/tester' - const dirs = [ - '/usr/local/bin', - '/snap/bin', - '/home/linuxbrew/.linuxbrew/bin', - '/nix/var/nix/profiles/default/bin', - join(home, '.nix-profile', 'bin'), - join(home, '.opencode', 'bin'), - join(home, '.vite-plus', 'bin'), - join(home, '.grok', 'bin') - ] - stage(...dirs.map((dir) => join(dir, 'opencode'))) - for (const expected of dirs) { - expect(resolveAll(['opencode'], { platform: 'linux', homePath: home })).toEqual({ - opencode: join(expected, 'opencode') - }) - fsFixture.executables.delete(join(expected, 'opencode')) - } - }) - // Both resolver entrypoints must prefer version-manager binaries over same-named system installs. describe.each([ { platform: 'darwin' as const, home: '/Users/tester', systemDir: '/opt/homebrew/bin' }, diff --git a/src/shared/agent-hook-listener/reverse-file-region-scan.test.ts b/src/shared/agent-hook-listener/reverse-file-region-scan.test.ts index c13eb8d9632..e4f12108195 100644 --- a/src/shared/agent-hook-listener/reverse-file-region-scan.test.ts +++ b/src/shared/agent-hook-listener/reverse-file-region-scan.test.ts @@ -42,21 +42,6 @@ describe('scanFileRegionsBackward', () => { } }) - it('stops at the first defined result without reading the rest of the file', () => { - const file = writeFixture(`${Array.from({ length: 200 }, (_, i) => `line-${i}`).join('\n')}\n`) - let regions = 0 - const found = scanFileRegionsBackward( - file, - { chunkBytes: 32, maxScanBytes: 1024 * 1024 }, - () => { - regions += 1 - return 'first' - } - ) - expect(found).toBe('first') - expect(regions).toBe(1) - }) - it('reports the byte offset each region starts at', () => { const file = writeFixture('😀alpha\nbravo\ncharlie\n') const offsets: { text: string; regionPosition: number }[] = [] diff --git a/src/shared/agent-hook-listener/transcript-reader.test.ts b/src/shared/agent-hook-listener/transcript-reader.test.ts index a4b9acf7a65..d0f482cac54 100644 --- a/src/shared/agent-hook-listener/transcript-reader.test.ts +++ b/src/shared/agent-hook-listener/transcript-reader.test.ts @@ -61,36 +61,7 @@ it('reads the same bounded first-record prefix with N and 10N later records and } }) -function expectedLines(text: string): string[] { - return text - .split('\n') - .toReversed() - .map((line) => line.trim()) - .filter((line) => line.length > 0) -} - describe('backward transcript line extraction', () => { - it('preserves line order and early return across generated delimiters', () => { - let seed = 29 - const fragments = ['a', '\n', '\r\n', ' ', '\t', '😀', '\u2028', '\0'] - for (let sample = 0; sample < 1000; sample++) { - let text = '' - for (let i = 0; i < sample % 100; i++) { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 - text += fragments[seed % fragments.length] - } - const lines = expectedLines(text) - const stop = sample % (lines.length + 1) - const seen: string[] = [] - const result = findLastExtractedTranscriptLineText(text, (line) => { - seen.push(line) - return seen.length === stop + 1 ? line : undefined - }) - expect(seen).toEqual(lines.slice(0, stop + 1)) - expect(result).toBe(lines[stop]) - } - }) - it('returns the newest assistant message behind a long tool line', () => { const message = JSON.stringify({ role: 'assistant', content: 'latest 😀' }) const tool = JSON.stringify({ role: 'tool', content: 'x'.repeat(256 * 1024) }) diff --git a/src/shared/agent-hook-listener/transport/agent-hook-endpoint-file.test.ts b/src/shared/agent-hook-listener/transport/agent-hook-endpoint-file.test.ts deleted file mode 100644 index afaa23fe816..00000000000 --- a/src/shared/agent-hook-listener/transport/agent-hook-endpoint-file.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parseAgentHookEndpointFile } from './agent-hook-endpoint-file' -import { getEndpointFileName, writeEndpointFile } from './endpoint-publication' -const ENDPOINT = { port: '12345', token: 'token-123', env: 'production', version: '1' } - -describe('agent hook endpoint files', () => { - it('parses the endpoint bytes published by the platform writer', () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-hook-endpoint-parser-')) - try { - const path = join(directory, getEndpointFileName()) - expect( - writeEndpointFile(directory, path, { - port: 12345, - token: 'token-123', - env: 'production', - version: '1' - }) - ).toBe(true) - expect(parseAgentHookEndpointFile(readFileSync(path, 'utf8'))).toEqual(ENDPOINT) - } finally { - rmSync(directory, { recursive: true, force: true }) - } - }) -}) diff --git a/tests/e2e/github-created-issue-start-prefill.spec.ts b/tests/e2e/github-created-issue-start-prefill.spec.ts deleted file mode 100644 index 3badbabde96..00000000000 --- a/tests/e2e/github-created-issue-start-prefill.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { test as base, expect } from './helpers/orca-app' -import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { getTerminalContent } from './helpers/terminal' - -const ISSUE_NUMBER = 6613 -const ISSUE_TITLE = 'Start a newly created issue without losing its context' -const ISSUE_URL = `https://github.com/acme/repo/issues/${ISSUE_NUMBER}` -const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-created-issue-prefill-')) - -const fakeGhSource = ` -const args = process.argv.slice(2) -const joined = args.join(' ') -const issue = { - number: ${ISSUE_NUMBER}, - title: ${JSON.stringify(ISSUE_TITLE)}, - state: 'open', - html_url: ${JSON.stringify(ISSUE_URL)}, - labels: [], - assignees: [], - user: { login: 'e2e' }, - updated_at: '2026-07-22T12:00:00.000Z' -} - -if (args[0] === 'auth' && args[1] === 'status') { - console.error('github.com\\n ✓ Logged in to github.com account e2e (GITHUB_TOKEN)') - process.exit(0) -} -if (args[0] === 'api' && args[1] === 'user') { - console.log(JSON.stringify({ login: 'e2e' })) - process.exit(0) -} -if (args[0] === 'api' && args.includes('rate_limit')) { - console.log(JSON.stringify({ resources: { core: { limit: 5000, remaining: 5000, reset: 0 }, graphql: { limit: 5000, remaining: 5000, reset: 0 }, search: { limit: 30, remaining: 30, reset: 0 } } })) - process.exit(0) -} -if (args[0] === 'api' && args.includes('-X') && args.includes('POST') && joined.includes('repos/acme/repo/issues')) { - console.log(JSON.stringify(issue)) - process.exit(0) -} -if (args[0] === 'api' && joined.includes('/labels')) { - process.exit(0) -} -if (args[0] === 'api' && joined.includes('/assignees')) { - process.exit(0) -} -if (args[0] === 'api' && joined.includes('repos/acme/repo/issues/${ISSUE_NUMBER}')) { - console.log(JSON.stringify(issue)) - process.exit(0) -} -if (args[0] === 'api' && joined.includes('search/issues')) { - console.log(JSON.stringify({ total_count: 0, incomplete_results: false, items: [] })) - process.exit(0) -} -if (args[0] === 'issue' && args[1] === 'list') { - console.log('[]') - process.exit(0) -} -if (args[0] === 'pr' && args[1] === 'list') { - console.log('[]') - process.exit(0) -} -if (args[0] === 'api' && args[1] === 'graphql') { - console.log(JSON.stringify({ data: { search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] } } })) - process.exit(0) -} -console.error('fake gh: unhandled ' + joined) -process.exit(1) -` - -const fakeClaudeSource = ` -const args = process.argv.slice(2) -process.stdout.write('E2E_CLAUDE_ARGV ' + JSON.stringify(args) + '\\n') -setInterval(() => {}, 60_000) -` - -function installFakeCli(name: 'gh' | 'claude', source: string): void { - if (process.platform === 'win32') { - writeFileSync(path.join(fakeCliDir, `fake-${name}.js`), source) - writeFileSync( - path.join(fakeCliDir, `${name}.cmd`), - `@echo off\r\nnode "%~dp0\\fake-${name}.js" %*\r\n` - ) - return - } - const executable = path.join(fakeCliDir, name) - writeFileSync(executable, `#!/usr/bin/env node\n${source}`) - chmodSync(executable, 0o755) -} - -installFakeCli('gh', fakeGhSource) -installFakeCli('claude', fakeClaudeSource) - -const test = base.extend({ - launchEnv: { - PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}` - } -}) - -test.afterAll(() => { - rmSync(fakeCliDir, { recursive: true, force: true }) -}) - -function configureGitHubRemote(repoPath: string): void { - try { - execFileSync('git', ['remote', 'remove', 'origin'], { cwd: repoPath, stdio: 'ignore' }) - } catch { - // The disposable E2E repo does not have an origin on its first run. - } - execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/acme/repo.git'], { - cwd: repoPath, - stdio: 'pipe' - }) -} - -// Add origin before repos.add caches no-remote for five minutes, which would disable GitHub issue creation. -test.beforeAll(({ testRepoPath }) => { - configureGitHubRemote(testRepoPath) -}) - -test('starting a just-created GitHub issue launches Claude with its URL prefilled', async ({ - orcaPage, - electronApp -}) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - // Why: the gh preflight merges the login-shell PATH ahead of launchEnv, so the fake gh can lose to a real one. - await electronApp.evaluate( - ({ ipcMain }, created) => { - ipcMain.removeHandler('gh:createIssue') - ipcMain.handle('gh:createIssue', () => ({ ok: true, ...created })) - }, - { number: ISSUE_NUMBER, url: ISSUE_URL } - ) - - await orcaPage.evaluate(async () => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const preparedWorkspace = state - .allWorktrees() - .find((worktree) => worktree.branch?.endsWith('e2e-secondary')) - if (!preparedWorkspace) { - throw new Error('Seeded secondary E2E worktree is not available') - } - // Why: this regression owns renderer-to-PTY command propagation, while the shared fixture already covers Git worktree creation. - store.setState({ - createWorktree: async () => ({ worktree: preparedWorkspace }) - }) - await state.updateSettings({ - defaultTuiAgent: 'claude', - disabledTuiAgents: [], - ...(navigator.userAgent.includes('Windows') ? { terminalWindowsShell: 'git-bash' } : {}) - }) - store.getState().openTaskPage({ taskSource: 'github' }) - }) - - const newIssueButton = orcaPage.getByRole('button', { name: 'New GitHub issue' }) - await expect(newIssueButton).toBeEnabled({ timeout: 15_000 }) - await newIssueButton.click() - - const createDialog = orcaPage.getByRole('dialog', { name: 'New GitHub issue' }) - await expect(createDialog).toBeVisible() - await createDialog.getByPlaceholder('Short summary').fill(ISSUE_TITLE) - await createDialog.getByRole('button', { name: 'Create issue' }).click() - - await expect(createDialog).toBeHidden({ timeout: 10_000 }) - await expect(orcaPage.getByRole('heading', { name: ISSUE_TITLE })).toBeVisible({ - timeout: 10_000 - }) - - await orcaPage.getByRole('button', { name: 'Start workspace from issue' }).click() - - // Issue start opens the quick-create composer, which must carry its URL into the submitted agent command. - const composer = orcaPage.getByRole('dialog', { name: /Create (workspace|worktree)/i }) - await expect(composer).toBeVisible({ timeout: 15_000 }) - const createWorkspaceButton = composer.getByRole('button', { - name: /Create (workspace|worktree)/i - }) - await expect(createWorkspaceButton).toBeEnabled({ timeout: 15_000 }) - await createWorkspaceButton.click() - await expect(composer).toBeHidden({ timeout: 20_000 }) - - let terminalText = '' - await expect - .poll( - async () => { - terminalText = await getTerminalContent(orcaPage, 12_000) - return terminalText - }, - { - timeout: 30_000, - message: 'Claude prefill command did not reach the active terminal buffer' - } - ) - .toContain('--prefill') - expect(terminalText).toContain('--dangerously-skip-permissions') - expect(terminalText).toContain('--prefill') - expect(terminalText).toContain(ISSUE_URL) - expect(terminalText).not.toMatch(/(?:^|\n)claude '--dangerously-skip-permissions'(?:\r?\n|$)/) -}) From c4b54195f10214e9ac8bfcf1d2f8bec27df1df4d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:01:48 -0800 Subject: [PATCH 040/138] Show the setup-script choice instead of greying out Create (#40341) * Show the setup-script choice instead of greying out Create With a project's setup script set to "Ask every time", Create stayed disabled until the user picked Run or Skip, but that choice only renders inside the collapsed Advanced section, so nothing explained the greyed-out button. Keep the choice out of the disabled gate (as the gate's own comment already intends) and have Create open Advanced to show it. * Annotate the partial submit fixture cast * Bring the setup choice into view when Create needs it Create now scrolls the Run/Skip choice into the dialog's view and focuses it on every attempt, including when Advanced is already open, and the late-discovered setup path uses the same reveal. A submit that only jumps to an existing pending creation no longer asks for a setup choice. * Reveal the setup choice once per Create attempt The choice clears the reveal request after scrolling and focusing, so a later remount (for example after switching projects) cannot pull focus without a new Create. * Drop a pending setup reveal once the user has chosen Choosing Skip in the moment before the reveal fired could still move focus onto Run, so a later Enter switched the choice. --- ...wWorkspaceComposerModal.retention.test.tsx | 2 + .../NewWorkspaceComposerAdvancedSection.tsx | 6 ++ .../WorkspaceSetupChoice.test.tsx | 53 +++++++++++++++++ .../new-workspace/WorkspaceSetupChoice.tsx | 30 +++++++++- .../new-workspace/WorkspaceSetupSection.tsx | 6 ++ .../new-workspace-composer-card-props.ts | 2 + .../composer-state/async-composer-state.ts | 14 ++++- .../src/hooks/composer-state/async-model.ts | 3 + .../composer-state/composer-card-contract.ts | 2 + .../composer-state/composer-card-props.ts | 6 +- .../composer-submit-orchestration.ts | 3 +- .../quick-submit-action.test.tsx | 57 +++++++++++++++++++ .../composer-state/quick-submit-action.ts | 10 +++- .../quick-submit-input-contract.ts | 2 +- .../quick-submit-preparation.ts | 6 +- .../src/lib/new-workspace-create-gates.ts | 3 - 16 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/new-workspace/WorkspaceSetupChoice.test.tsx create mode 100644 src/renderer/src/hooks/composer-state/quick-submit-action.test.tsx diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.retention.test.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.retention.test.tsx index 2dbb0447b44..d01c63cd408 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.retention.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.retention.test.tsx @@ -176,6 +176,8 @@ vi.mock('@/hooks/useComposerState', () => ({ onReuseSelectedBranchChange: noOp, forkPushWarning: null, advancedOpen: false, + setupChoiceRevealRequest: 0, + onSetupChoiceRevealed: noOp, onToggleAdvanced: noOp, parentWorktreeId: null, onParentWorktreeIdChange: noOp, diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx index ecbc518222c..d71d3d057e4 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx @@ -22,6 +22,8 @@ import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-car type NewWorkspaceComposerAdvancedSectionProps = Pick< NewWorkspaceComposerCardProps, | 'advancedOpen' + | 'setupChoiceRevealRequest' + | 'onSetupChoiceRevealed' | 'smartNameSelection' | 'name' | 'onNameValueChange' @@ -61,6 +63,8 @@ type NewWorkspaceComposerAdvancedSectionProps = Pick< export function NewWorkspaceComposerAdvancedSection({ advancedOpen, + setupChoiceRevealRequest, + onSetupChoiceRevealed, sparseEditing, onSparseEditingChange, smartNameSelection, @@ -223,6 +227,8 @@ export function NewWorkspaceComposerAdvancedSection({ setupConfig={setupConfig} copy={selectWorkspaceSetupCopy(setupConfig, getComposerWorkspaceSetupLabels(t))} requiresExplicitSetupChoice={requiresExplicitSetupChoice} + revealRequest={setupChoiceRevealRequest} + onRevealed={onSetupChoiceRevealed} resolvedSetupDecision={resolvedSetupDecision} setupDecision={setupDecision} onSetupDecisionChange={onSetupDecisionChange} diff --git a/src/renderer/src/components/new-workspace/WorkspaceSetupChoice.test.tsx b/src/renderer/src/components/new-workspace/WorkspaceSetupChoice.test.tsx new file mode 100644 index 00000000000..d9e83d5d164 --- /dev/null +++ b/src/renderer/src/components/new-workspace/WorkspaceSetupChoice.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceSetupChoice } from './WorkspaceSetupChoice' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('WorkspaceSetupChoice', () => { + it('brings the choice into view and focuses it on each reveal request', () => { + vi.useFakeTimers() + const scrollIntoView = vi.fn() + Element.prototype.scrollIntoView = scrollIntoView + const onRevealed = vi.fn() + const props = { + decision: null, + onChange: vi.fn(), + runLabel: 'Run', + skipLabel: 'Skip', + onRevealed + } + const { rerender } = render() + vi.runAllTimers() + expect(scrollIntoView).not.toHaveBeenCalled() + + // A second request matters too: Advanced may already be open from the first. + for (const revealRequest of [1, 2]) { + rerender() + vi.runAllTimers() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Run' })) + } + expect(scrollIntoView).toHaveBeenCalledTimes(2) + // Each handled request is cleared so a remount after a project switch cannot replay it. + expect(onRevealed).toHaveBeenCalledTimes(2) + }) + + it('does not move focus onto Run when the user chose before the reveal fired', () => { + vi.useFakeTimers() + Element.prototype.scrollIntoView = vi.fn() + const onRevealed = vi.fn() + const props = { onChange: vi.fn(), runLabel: 'Run', skipLabel: 'Skip', onRevealed } + const { rerender } = render( + + ) + rerender() + vi.runAllTimers() + + expect(document.activeElement).not.toBe(screen.getByRole('button', { name: 'Run' })) + expect(onRevealed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/new-workspace/WorkspaceSetupChoice.tsx b/src/renderer/src/components/new-workspace/WorkspaceSetupChoice.tsx index edc754b3e78..70eafc944cd 100644 --- a/src/renderer/src/components/new-workspace/WorkspaceSetupChoice.tsx +++ b/src/renderer/src/components/new-workspace/WorkspaceSetupChoice.tsx @@ -1,21 +1,49 @@ +import { useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' +// Matches the Advanced section's expand transition so the choice is laid out before scrolling. +const REVEAL_AFTER_EXPAND_MS = 220 + export function WorkspaceSetupChoice({ decision, onChange, disabled = false, runLabel, - skipLabel + skipLabel, + revealRequest = 0, + onRevealed }: { decision: 'run' | 'skip' | null onChange: (decision: 'run' | 'skip') => void disabled?: boolean runLabel: string skipLabel: string + revealRequest?: number + onRevealed?: () => void }): React.JSX.Element { + const runButtonRef = useRef(null) + useEffect(() => { + if (revealRequest === 0) { + return + } + if (decision !== null) { + // Chosen before the reveal fired: drop it rather than move focus onto Run. + onRevealed?.() + return + } + // A submit without a choice lands here; scroll the choice into the dialog's view and focus it. + const timer = setTimeout(() => { + runButtonRef.current?.scrollIntoView({ block: 'nearest' }) + runButtonRef.current?.focus() + onRevealed?.() + }, REVEAL_AFTER_EXPAND_MS) + return () => clearTimeout(timer) + }, [revealRequest, onRevealed, decision]) + return (
-
+
{isUpdate ? ( ) : ( @@ -123,7 +123,7 @@ function BrowserPasswordSavePopover({ {t('browser.passwords.savePrompt.never', 'Never for this site')} )} -
+
+ + {t('components.tabGroup.splitPaneLanding.closePane', 'Close pane')} + + ) : null}
@@ -201,10 +202,17 @@ type LandingRow = { function LandingRowButton({ row }: { row: LandingRow }): React.JSX.Element { const Icon = row.icon return ( - + ) } diff --git a/src/renderer/src/components/tab-group/SplitPaneLandingAgentRow.tsx b/src/renderer/src/components/tab-group/SplitPaneLandingAgentRow.tsx index 5f3be9f4759..5e6a95c40ed 100644 --- a/src/renderer/src/components/tab-group/SplitPaneLandingAgentRow.tsx +++ b/src/renderer/src/components/tab-group/SplitPaneLandingAgentRow.tsx @@ -2,7 +2,8 @@ import { Bot, ChevronDown } from 'lucide-react' import { useAppStore } from '../../store' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { useTranslate } from '@/i18n/use-translate' -import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' import { getAgentCatalog } from '@/lib/agent-catalog/agent-catalog' import { AgentIcon } from '@/components/agent/icons/AgentIcon' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -18,9 +19,9 @@ import { resolveTerminalAgentTabShortcut } from '../terminal-agent-tab-shortcut' import { launchAgentTabShortcut } from '../agent-tab-shortcut-launch' -import { LANDING_ROW_CLASS, LandingRowShortcut } from './split-pane-landing-row' +import { LandingRowLabel, LandingRowShortcut } from './split-pane-landing-row' -/** The default agent as one row; the chevron opens the + menu's agent list for any other. */ +/** The default agent as a split button; the chevron opens the + menu's agent list for any other. */ export function SplitPaneLandingAgentRow({ worktreeId, groupId, @@ -59,36 +60,32 @@ export function SplitPaneLandingAgentRow({ } return ( - // Why: a stretched button keeps the row one click target while the chevron stays its own button. -
- - + + - + - - - -
+ ) } diff --git a/src/renderer/src/components/tab-group/split-pane-landing-row.tsx b/src/renderer/src/components/tab-group/split-pane-landing-row.tsx index ab31be8e758..63b53e93b04 100644 --- a/src/renderer/src/components/tab-group/split-pane-landing-row.tsx +++ b/src/renderer/src/components/tab-group/split-pane-landing-row.tsx @@ -1,8 +1,9 @@ import type { ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '../ShortcutKeyCombo' -export const LANDING_ROW_CLASS = - 'flex h-9 w-full items-center gap-3 rounded-md px-2.5 text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none' +export function LandingRowLabel({ label }: { label: string }): React.JSX.Element { + return {label} +} export function LandingRowShortcut({ shortcut From b602376e8b3bb36a9e6af519e01ef4650ca0dffc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:52 -0800 Subject: [PATCH 054/138] Show AskUserQuestion option previews on desktop and phone (#40109) * Show AskUserQuestion option previews on desktop and phone Claude can attach a preview (a mockup or snippet) to each option of a single-select question. Orca dropped it, so users chose between bare labels. Options now carry the preview through the journal, terminal asks and the phone, and the question card shows the selected option's preview as plain monospace text. Questions with previews confirm explicitly instead of auto-advancing, so the preview stays readable. * Keep phone previews bounded and digit focus on the named option Phone previews get a capped height with their own vertical scroll, and opening one never scrolls past its top. A digit always focuses its row before the chosen-row check, so Enter confirms the option the user named. Tighter preview line height, a stable phone row root, and a persistence test whose expected previews come from the fixed input. * Narrow the reopened question with a type guard instead of a cast --- .../native-chat/input/MobileNativeChatAsk.tsx | 21 ++-- .../input/MobileNativeChatAskOptionRow.tsx | 48 +++++++++- .../use-mobile-native-chat-reply-scroll.ts | 37 ++++++- .../claude-structured-prompt-items.test.ts | 47 +++++++++ .../journal/claude-structured-prompt-items.ts | 7 +- .../journal-prompt-body-bounds.ts | 6 +- ...journal-prompt-preview-persistence.test.ts | 96 +++++++++++++++++++ .../NativeChatQuestionCard.fixture.tsx | 41 ++++++++ .../prompts/NativeChatQuestionCard.test.tsx | 40 ++++++++ .../prompts/NativeChatQuestionCard.tsx | 25 ++++- .../prompts/NativeChatQuestionOption.tsx | 87 ++++++++++------- .../prompts/use-native-chat-question-keys.ts | 6 +- .../journal/agent-session-journal-schemas.ts | 3 +- .../journal/agent-session-journal-types.ts | 2 + .../agent-status-field-normalization.test.ts | 49 ++++++++++ .../agent-status-field-normalization.ts | 20 +++- .../input/native-chat-ask-card-rules.ts | 15 ++- .../input/native-chat-ask-dismiss-key.ts | 3 +- .../input/native-chat-ask-option-preview.ts | 11 +++ .../input/native-chat-ask-types.ts | 2 +- .../native-chat/input/native-chat-ask.ts | 29 +++--- src/shared/structured-agent-question-card.ts | 3 +- 22 files changed, 514 insertions(+), 84 deletions(-) create mode 100644 src/main/claude/structured/journal/claude-structured-prompt-items.test.ts create mode 100644 src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts create mode 100644 src/shared/agent-status/contracts/agent-status-field-normalization.test.ts create mode 100644 src/shared/native-chat/input/native-chat-ask-option-preview.ts diff --git a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx index d201f87b06d..2ad2426c74b 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx @@ -12,6 +12,7 @@ import type { AgentJournalFreeTextInput } from '../../../../../src/shared/agent- import { askAcceptsEmptyAnswer, askConfirmAction, + askQuestionShowsPreviews, askConfirmLabel, askTypedText, toggleAskOption @@ -65,7 +66,8 @@ export function MobileNativeChatAsk({ }: Props): React.JSX.Element { useMobileColorScheme() const t = useMobileTranslation() - const { scrollRef, inputRef, revealReply, onViewportLayout } = useMobileNativeChatReplyScroll() + const { scrollRef, inputRef, revealReply, scrollViewProps, revealContent } = + useMobileNativeChatReplyScroll() const [index, setIndex] = useState(0) const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => @@ -109,11 +111,15 @@ export function MobileNativeChatAsk({ return } const cur = selections[qi] ?? [] + // A pick reveals its preview, so a re-tap keeps it and the user confirms. + const keep = optIndex !== OTHER && askQuestionShowsPreviews(prompt.questions[qi]) const picked = !cur.includes(optIndex) - const next = selections.map((s, i) => (i === qi ? toggleAskOption(cur, optIndex, multi) : s)) + const next = selections.map((s, i) => + i === qi ? toggleAskOption(cur, optIndex, multi, keep) : s + ) setSelections(next) autoAdvance.cancel() - if (picked && !multi && optIndex !== OTHER) { + if (picked && !multi && optIndex !== OTHER && !keep) { autoAdvance.schedule(() => void advance(next)) } } @@ -246,12 +252,7 @@ export function MobileNativeChatAsk({ ) : null} - + void selected: boolean multi?: boolean disabled: boolean onPress: () => void }): React.JSX.Element { useMobileColorScheme() - return ( + const row = ( ) + // One root shape whether or not the preview shows, so selecting never remounts the row. + return ( + <> + {row} + {preview && selected ? ( + { + const { y, height } = event.nativeEvent.layout + onPreviewLayout?.(y, y + height) + }} + > + + {/* Mockups are drawn in columns, so they scroll sideways instead of wrapping at phone width. */} + + + {preview} + + + + + ) : null} + + ) } const styles = themedStyleSheet(() => ({ @@ -106,5 +136,19 @@ const styles = themedStyleSheet(() => ({ optionDescription: { color: colors.textSecondary, fontSize: typography.metaSize + }, + preview: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: concentricRadius(radii.surface, spacing.xs), + padding: spacing.sm, + marginBottom: spacing.xs, + maxHeight: 220 + }, + previewText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize } })) diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts index fe9fd376050..9226a6f7f2f 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts @@ -1,11 +1,18 @@ import { useRef } from 'react' -import type { LayoutChangeEvent, ScrollView, TextInput } from 'react-native' +import type { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + TextInput +} from 'react-native' // Keep a focused reply in view when its card shrinks above the keyboard. export function useMobileNativeChatReplyScroll() { const scrollRef = useRef(null) const inputRef = useRef(null) const viewportHeight = useRef(0) + const scrollOffset = useRef(0) const revealReply = (): void => { if (inputRef.current?.isFocused()) { scrollRef.current?.scrollToEnd({ animated: false }) @@ -20,5 +27,31 @@ export function useMobileNativeChatReplyScroll() { revealReply() } } - return { scrollRef, inputRef, revealReply, onViewportLayout } + const onScroll = (event: NativeSyntheticEvent): void => { + scrollOffset.current = event.nativeEvent.contentOffset.y + } + // Scroll just enough to show content that opened below the visible edge, never past its top. + const revealContent = (top: number, bottom: number): void => { + const hidden = bottom - (scrollOffset.current + viewportHeight.current) + if (viewportHeight.current > 0 && hidden > 0) { + scrollRef.current?.scrollTo({ + y: Math.min(scrollOffset.current + hidden, top), + animated: true + }) + } + } + const scrollViewProps = { + ref: scrollRef, + onLayout: onViewportLayout, + onScroll, + scrollEventThrottle: 16 + } + return { + scrollRef, + inputRef, + revealReply, + onViewportLayout, + scrollViewProps, + revealContent + } } diff --git a/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts b/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts new file mode 100644 index 00000000000..0be0dd589c7 --- /dev/null +++ b/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { claudeQuestionItems } from './claude-structured-prompt-items' + +const LAYOUT_A = '┌──────┬────────┐\n│ nav │ main │\n└──────┴────────┘' + +describe('claudeQuestionItems', () => { + it('keeps option previews on single-select questions only', () => { + const [item] = claudeQuestionItems({ + sessionId: 'session-1', + prompt: { + requestId: 'req-1', + promptKey: 'req-1', + toolUseId: 'toolu-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: 'Which layout?', + header: 'Layout', + multiSelect: false, + options: [ + { label: 'Sidebar', description: 'Nav on the left', preview: LAYOUT_A }, + { label: 'Top bar', description: 'Nav on top', preview: ' \n ' } + ] + }, + { + question: 'Which panes?', + header: 'Panes', + multiSelect: true, + options: [ + { label: 'Files', description: 'File tree', preview: 'files' }, + { label: 'Git', description: 'Changes', preview: 'git' } + ] + } + ] + }, + suggestions: [], + questionIds: ['q1', 'q2'], + settle: vi.fn() + } + }) + const questions = item!.body.questions! + expect(questions[0]!.options.map((option) => option.preview)).toEqual([LAYOUT_A, undefined]) + expect(questions[1]!.options.map((option) => option.preview)).toEqual([undefined, undefined]) + }) +}) diff --git a/src/main/claude/structured/journal/claude-structured-prompt-items.ts b/src/main/claude/structured/journal/claude-structured-prompt-items.ts index ef669549d3b..adefb290d77 100644 --- a/src/main/claude/structured/journal/claude-structured-prompt-items.ts +++ b/src/main/claude/structured/journal/claude-structured-prompt-items.ts @@ -10,6 +10,7 @@ import { truncateToolDetail } from '../../../../shared/native-chat/tools/native-chat-tool-summary' import { isPlanApprovalSubject } from '../../../../shared/agent-session/agent-session-approval-subject' +import { askOptionPreview } from '../../../../shared/native-chat/input/native-chat-ask-option-preview' import { boundJournalPromptBody } from '../../../native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' @@ -77,16 +78,20 @@ function questionOptions( if (!Array.isArray(question.options)) { return [] } + // Claude draws previews only on single-select questions. + const singleSelect = question.multiSelect !== true return question.options.flatMap((value, index) => { const option = claudeRecord(value) const label = claudeText(option?.label) const description = claudeText(option?.description) + const preview = singleSelect ? askOptionPreview(option) : null return label ? [ { id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`), label, - ...(description ? { description } : {}) + ...(description ? { description } : {}), + ...(preview ? { preview } : {}) } ] : [] diff --git a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts index 34945214586..d8114bb097d 100644 --- a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts @@ -13,6 +13,7 @@ export const MAX_JOURNAL_PROMPT_OPTIONS = 64 export const MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS = 4 const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 } +const JOURNAL_PROMPT_OPTION_PREVIEW_LIMITS = { inlineHeadBytes: 8 * 1024 } // Agent prompt content permits larger bounds than client replies but must still fit settlement batches. const JOURNAL_PROMPT_TEXT_LIMITS = { inlineHeadBytes: 128 * 1024 } const JOURNAL_PROMPT_ID_MAX_BYTES = 1024 @@ -133,7 +134,10 @@ function boundPromptOptions( label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text, ...(option.description === undefined ? {} - : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }) + : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }), + ...(option.preview === undefined + ? {} + : { preview: boundInlineText(option.preview, JOURNAL_PROMPT_OPTION_PREVIEW_LIMITS).text }) })) } diff --git a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts new file mode 100644 index 00000000000..e4f2a12f4f8 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalQuestionItem +} from '../../../../shared/agent-session/journal/agent-session-journal-types' +import { codexProviderHandle } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' +import { openAgentSessionJournal } from '../journal-store-factory' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../store-lifecycle/journal-host-database-test-support' +import { boundJournalPromptBody, cancelledJournalPromptBody } from './journal-prompt-body-bounds' + +let root: string | undefined + +afterEach(async () => { + await closeTestJournalHostDatabases() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +// Control characters escape to six bytes each, so this question only fits a journal row on its own. +const CONTROL = '\u0001' +// Each preview fits the 8 KiB bound, so it must come back exactly as written. +const PREVIEWS = [1, 2, 3, 4].map((q) => + [1, 2, 3, 4].map((o) => `layout ${q}.${o}${CONTROL.repeat(8_192 - 12)}`) +) + +function largestQuestion(): AgentJournalQuestionItem { + const questions = [1, 2, 3, 4].map((q) => ({ + id: `q${q}`, + question: `Question ${q}${CONTROL.repeat(30_000)}`, + multiSelect: false, + options: [1, 2, 3, 4].map((o) => ({ + id: `q${q}:choice-${o}`, + label: `Layout ${o}`, + preview: PREVIEWS[q - 1]![o - 1]! + })) + })) + return boundJournalPromptBody({ + kind: 'question', + question: '4 grouped questions from Claude', + options: [], + questions, + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) +} + +it('saves, cancels and reopens a question whose previews make it larger than one batch', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-prompt-previews-')) + const open = () => + openAgentSessionJournal({ + identity: { + sessionId: 'session-previews', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + database: openTestJournalHostDatabase(root!), + now: () => 1_000 + }) + const identity = { provider: 'orca' as const, clientMessageId: 'claude-prompt:session:req-1' } + const body = largestQuestion() + const journal = await open() + await journal.appendItem(identity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await journal.appendLifecycleBatch({ + settlementId: 'cancel-prompt', + fence: 1, + mutations: [ + { + kind: 'item', + identity, + body: cancelledJournalPromptBody(body)!, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + await journal.close() + + const reopened = await open() + const saved = reopened + .snapshot() + .items.map((item) => item.body) + .find((item): item is AgentJournalQuestionItem => item.kind === 'question') + await reopened.close() + expect(saved).toMatchObject({ kind: 'question', resolution: { state: 'cancelled' } }) + const previews = saved!.questions!.flatMap((question) => + question.options.map((option) => option.preview) + ) + expect(previews).toEqual(PREVIEWS.flat()) +}) diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx index df35dd0e807..5e68681bfc9 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx @@ -231,3 +231,44 @@ export const historyLinks = defineFixture({ ), interact: (frame) => frame.querySelector('button[aria-expanded]')?.click() }) + +export const previews = defineFixture({ + width: 560, + render: () => ( +
+ {}} + onCancel={() => {}} + /> +
+ ), + interact: async (frame) => { + frame.querySelectorAll('button[aria-pressed]')[0]?.click() + await new Promise(requestAnimationFrame) + } +}) diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx index cf50c7ec441..1e89b19d5aa 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx @@ -86,4 +86,44 @@ describe('NativeChatQuestionCard', () => { expect(pressAt(10, 10, 'ArrowDown')).toBe(true) expect(document.activeElement).toBe(rows[0]) }) + + it('shows a picked preview, keeps it on a second click, and confirms the digit-named option with Enter', () => { + vi.useFakeTimers() + container.setAttribute('data-native-chat-root', 'true') + const onAnswer = vi.fn() + act(() => { + root.render( + {}} + shouldFocus + /> + ) + }) + const rows = container.querySelectorAll('button[aria-pressed]') + act(() => rows[0]!.focus()) + pressKey({ key: '2' }, rows[0]!) + act(() => rows[1]!.click()) + act(() => vi.runAllTimers()) + expect(container.querySelector('pre')?.textContent).toBe('| nav |\n| main |') + expect(document.activeElement).toBe(rows[1]) + expect(onAnswer).not.toHaveBeenCalled() + pressKey({ key: 'ArrowUp' }, rows[1]!) + pressKey({ key: '2' }, rows[0]!) + expect(document.activeElement).toBe(rows[1]) + pressKey({ key: 'Enter' }, rows[1]!) + expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }]) + }) }) diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx index 91986107d6e..0f2e27541f1 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx @@ -18,6 +18,7 @@ import type { AgentJournalFreeTextInput } from '../../../../../shared/agent-sess import { askAcceptsEmptyAnswer, askConfirmAction, + askQuestionShowsPreviews, askConfirmLabel, askTypedText, toggleAskOption @@ -82,6 +83,7 @@ export function NativeChatQuestionCard({ const questionAllowsOther = Array.isArray(allowOther) ? (allowOther[index] ?? false) : allowOther const freeTextInput = freeTextInputs?.[index] const fieldTakesFocus = q.options.length === 0 || undefined + const showsPreviews = askQuestionShowsPreviews(q) const answerPlaceholder = freeTextInput?.placeholder ?? t('components.native-chat.question.otherPlaceholder', 'Type your answer') @@ -156,15 +158,31 @@ export function NativeChatQuestionCard({ } } - // Briefly show a single-select pick before advancing; picking again retracts it. - const pickOption = (optionIndex: number): void => { + // Briefly show a single-select pick before advancing; picking again retracts it. With previews, + // a pick stays put: a digit focuses its row, and Enter on the chosen row confirms. + const pickOption = ( + optionIndex: number, + via: 'pointer' | 'digit' | 'enter' = 'pointer' + ): void => { const cur = selections[index] ?? [] + // Focus first, so Enter after any digit acts on the row that digit named. + if (showsPreviews && via === 'digit') { + cardRef.current + ?.querySelectorAll('button[aria-pressed]') + [optionIndex]?.focus() + } + if (showsPreviews && cur.includes(optionIndex)) { + if (via === 'enter') { + confirm(true) + } + return + } const picked = !cur.includes(optionIndex) const chosen = toggleAskOption(cur, optionIndex, q.multiSelect) const next = selections.map((s, i) => (i === index ? chosen : s)) setSelections(next) autoAdvance.cancel() - if (picked && !q.multiSelect) { + if (picked && !q.multiSelect && !showsPreviews) { autoAdvance.schedule(() => advanceOrSubmit(next, otherText)) } } @@ -298,6 +316,7 @@ export function NativeChatQuestionCard({ initialFocus={i === 0} label={opt.label} description={opt.description} + preview={opt.preview} selected={(selections[index] ?? []).includes(i)} disabled={isSubmitting} onSelect={() => pickOption(i)} diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx index 9887464590f..ef575dc5ba4 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx @@ -1,11 +1,17 @@ import { Check } from 'lucide-react' import { cn } from '@/lib/utils' +// Stable, so it runs when the preview opens rather than on every render; the option list is height-capped. +function revealPreview(element: HTMLPreElement | null): void { + element?.scrollIntoView?.({ block: 'nearest' }) +} + export function NativeChatQuestionOption({ badge, initialFocus, label, description, + preview, selected, disabled, onSelect @@ -14,47 +20,60 @@ export function NativeChatQuestionOption({ initialFocus: boolean label: string description?: string + /** Shown under the row while it is selected, as the agent wrote it. */ + preview?: string selected: boolean disabled: boolean onSelect: () => void }): React.JSX.Element { return ( - + + {selected ? : badge} + + + {label} + {description ? ( + + {description} + + ) : null} + + + {preview && selected ? ( +
+          {preview}
+        
+ ) : null} +
) } diff --git a/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts b/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts index e0696c342d2..39f41cdb290 100644 --- a/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts +++ b/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts @@ -7,7 +7,7 @@ export function useNativeChatQuestionKeys( cardRef: RefObject, enabled: boolean, optionCount: number, - onPick: (optionIndex: number) => void, + onPick: (optionIndex: number, via: 'digit' | 'enter') => void, onStep: (delta: 1 | -1) => boolean ): void { const onKeyDown = useEffectEvent((event: KeyboardEvent): void => { @@ -48,7 +48,7 @@ export function useNativeChatQuestionKeys( return } event.preventDefault() - onPick(optionIndex) + onPick(optionIndex, 'digit') } else if (isArrow) { const up = event.key === 'ArrowUp' if ( @@ -82,7 +82,7 @@ export function useNativeChatQuestionKeys( } } else if (event.key === 'Enter' && focusedIndex !== -1) { event.preventDefault() - onPick(focusedIndex) + onPick(focusedIndex, 'enter') } }) useEffect(() => { diff --git a/src/shared/agent-session/journal/agent-session-journal-schemas.ts b/src/shared/agent-session/journal/agent-session-journal-schemas.ts index 0538c3616b3..d8d5be4abfd 100644 --- a/src/shared/agent-session/journal/agent-session-journal-schemas.ts +++ b/src/shared/agent-session/journal/agent-session-journal-schemas.ts @@ -116,7 +116,8 @@ const Block = openDiscriminatedUnion( const PromptOption = z.object({ id: z.string(), label: z.string(), - description: z.string().optional() + description: z.string().optional(), + preview: z.string().optional() }) const FreeTextInput = z.object({ diff --git a/src/shared/agent-session/journal/agent-session-journal-types.ts b/src/shared/agent-session/journal/agent-session-journal-types.ts index 6ee590b3aec..5b9658505a3 100644 --- a/src/shared/agent-session/journal/agent-session-journal-types.ts +++ b/src/shared/agent-session/journal/agent-session-journal-types.ts @@ -137,6 +137,8 @@ export type AgentJournalPromptOption = { id: string label: string description?: string + /** A block the user compares before choosing (a mockup or snippet), shown as plain monospace text. */ + preview?: string } export type AgentJournalFreeTextInput = { diff --git a/src/shared/agent-status/contracts/agent-status-field-normalization.test.ts b/src/shared/agent-status/contracts/agent-status-field-normalization.test.ts new file mode 100644 index 00000000000..1f08034e25c --- /dev/null +++ b/src/shared/agent-status/contracts/agent-status-field-normalization.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { normalizeInteractivePromptField } from './agent-status-field-normalization' +import { parseAskFromStatus } from '../../native-chat/input/native-chat-ask' + +function layoutQuestion(preview: (index: number) => string): string { + return JSON.stringify({ + questions: [ + { + question: 'Which layout?', + header: 'Layout', + multiSelect: false, + options: ['Sidebar', 'Top bar', 'Split', 'Tabs'].map((label, index) => ({ + label, + description: `Layout ${index + 1}`, + preview: preview(index) + })) + } + ] + }) +} + +describe('normalizeInteractivePromptField', () => { + it('shortens oversize previews and keeps every option and the preview layout', () => { + const fitted = normalizeInteractivePromptField( + layoutQuestion((index) => `+--${index}--+\n`.repeat(600)), + 4000 + ) + const ask = parseAskFromStatus(fitted, 'AskUserQuestion') + const question = ask!.questions[0]! + expect(question.options.map((option) => option.label)).toEqual([ + 'Sidebar', + 'Top bar', + 'Split', + 'Tabs' + ]) + expect(question.hasPreviews).toBe(true) + expect(question.options.every((option) => option.preview?.endsWith('…'))).toBe(true) + }) + + it('never turns blank previews into previews', () => { + const fitted = normalizeInteractivePromptField( + layoutQuestion(() => ' '.repeat(1200)), + 4000 + ) + const question = parseAskFromStatus(fitted, 'AskUserQuestion')!.questions[0]! + expect(question.options).toHaveLength(4) + expect(question.hasPreviews).toBeUndefined() + }) +}) diff --git a/src/shared/agent-status/contracts/agent-status-field-normalization.ts b/src/shared/agent-status/contracts/agent-status-field-normalization.ts index 984658fcbcc..06a5982f306 100644 --- a/src/shared/agent-status/contracts/agent-status-field-normalization.ts +++ b/src/shared/agent-status/contracts/agent-status-field-normalization.ts @@ -154,7 +154,7 @@ function isEcmaTrimWhitespace(code: number): boolean { ) } -// Only provided strings update optional fields; absence must not erase cached state. Preserve option labels and shorten presentation fields only. +// Only provided strings update optional fields; absence must not erase cached state. Preserve option labels and shorten presentation fields (and previews) only. const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([ 'description', 'detail', @@ -163,6 +163,14 @@ const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([ 'summary' ]) +// A cut preview stays non-blank: whether Claude drew its preview layout decides the answer keystrokes. +function compactPreview(preview: string, maxLength: number): string { + if (preview.length <= maxLength || !/\S/.test(preview)) { + return truncatePreservingSurrogates(preview, maxLength) + } + return `${truncatePreservingSurrogates(preview, Math.max(maxLength - 1, 0))}…` +} + function compactInteractivePromptValue(value: unknown, maxStringLength: number): unknown { if (Array.isArray(value)) { return value.map((item) => compactInteractivePromptValue(item, maxStringLength)) @@ -173,9 +181,13 @@ function compactInteractivePromptValue(value: unknown, maxStringLength: number): return Object.fromEntries( Object.entries(value).map(([key, item]) => [ key, - typeof item === 'string' && COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key) - ? truncatePreservingSurrogates(item, maxStringLength) - : compactInteractivePromptValue(item, maxStringLength) + typeof item !== 'string' + ? compactInteractivePromptValue(item, maxStringLength) + : key === 'preview' + ? compactPreview(item, maxStringLength) + : COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key) + ? truncatePreservingSurrogates(item, maxStringLength) + : item ]) ) } diff --git a/src/shared/native-chat/input/native-chat-ask-card-rules.ts b/src/shared/native-chat/input/native-chat-ask-card-rules.ts index 98dab1c2ff8..397cebca85c 100644 --- a/src/shared/native-chat/input/native-chat-ask-card-rules.ts +++ b/src/shared/native-chat/input/native-chat-ask-card-rules.ts @@ -3,14 +3,23 @@ import type { AskQuestion } from './native-chat-ask-types' // How a stepped question card picks, labels and confirms; desktop and mobile draw it differently. -/** Multi-select keeps picks in option order so the answer reads as the options do. */ +/** A single-select whose pick reveals a preview: picking only selects, and the user confirms. */ +export function askQuestionShowsPreviews( + question: { multiSelect: boolean; options: readonly { preview?: string }[] } | undefined +): boolean { + return question !== undefined && !question.multiSelect && question.options.some((o) => o.preview) +} + +/** Multi-select keeps picks in option order so the answer reads as the options do. `keepChosen`: + * picking the chosen option again keeps it, as a preview question's pick does. */ export function toggleAskOption( picked: readonly number[], optionIndex: number, - multiSelect: boolean + multiSelect: boolean, + keepChosen = false ): number[] { if (picked.includes(optionIndex)) { - return picked.filter((index) => index !== optionIndex) + return keepChosen ? [...picked] : picked.filter((index) => index !== optionIndex) } return multiSelect ? [...picked, optionIndex].sort((a, b) => a - b) : [optionIndex] } diff --git a/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts b/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts index 27be080a71a..6e2e3261c47 100644 --- a/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts +++ b/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts @@ -19,7 +19,8 @@ export function nativeChatAskDismissKey(prompt: AskPrompt | null): string | null ...question, question: askDismissKeyText(question.question), header: askDismissKeyText(question.header), - options: question.options.map((option) => ({ + // Status and transcript copies shorten previews differently; labels already identify the option. + options: question.options.map(({ preview: _preview, ...option }) => ({ ...option, description: askDismissKeyText(option.description) })) diff --git a/src/shared/native-chat/input/native-chat-ask-option-preview.ts b/src/shared/native-chat/input/native-chat-ask-option-preview.ts new file mode 100644 index 00000000000..f33053c6d31 --- /dev/null +++ b/src/shared/native-chat/input/native-chat-ask-option-preview.ts @@ -0,0 +1,11 @@ +/** The option's preview as Claude shows it: a non-blank string, kept verbatim. */ +export function askOptionPreview(option: unknown): string | null { + return option && + typeof option === 'object' && + 'preview' in option && + typeof option.preview === 'string' && + // Why not trim(): history rescans run this per option, and trim copies the whole preview. + /\S/.test(option.preview) + ? option.preview + : null +} diff --git a/src/shared/native-chat/input/native-chat-ask-types.ts b/src/shared/native-chat/input/native-chat-ask-types.ts index 66db1b23082..b978c6a0e43 100644 --- a/src/shared/native-chat/input/native-chat-ask-types.ts +++ b/src/shared/native-chat/input/native-chat-ask-types.ts @@ -1,4 +1,4 @@ -export type AskOption = { label: string; description?: string } +export type AskOption = { label: string; description?: string; preview?: string } export type AskQuestion = { question: string header?: string diff --git a/src/shared/native-chat/input/native-chat-ask.ts b/src/shared/native-chat/input/native-chat-ask.ts index a402c3b0236..a76ee2cf177 100644 --- a/src/shared/native-chat/input/native-chat-ask.ts +++ b/src/shared/native-chat/input/native-chat-ask.ts @@ -1,4 +1,5 @@ import { nativeChatAskScanOrder } from './native-chat-ask-order' +import { askOptionPreview } from './native-chat-ask-option-preview' import type { AskOption, AskPrompt, @@ -39,12 +40,12 @@ function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null { } const text = 'question' in raw && typeof raw.question === 'string' ? raw.question : '' const rawOptions = 'options' in raw ? raw.options : undefined - const options = parseOptions(rawOptions) + const multiSelect = + 'multiSelect' in raw + ? raw.multiSelect === true + : ('multiple' in raw && raw.multiple === true) || ('multi' in raw && raw.multi === true) + const options = parseOptions(rawOptions, multiSelect) if (text || options.length > 0) { - const multiSelect = - 'multiSelect' in raw - ? raw.multiSelect === true - : ('multiple' in raw && raw.multiple === true) || ('multi' in raw && raw.multi === true) questions.push({ question: text, header: 'header' in raw && typeof raw.header === 'string' ? raw.header : undefined, @@ -59,20 +60,10 @@ function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null { /** Claude only switches a single-select to its preview layout for a non-blank preview. */ export function askOptionsHavePreview(rawOptions: unknown): boolean { - return ( - Array.isArray(rawOptions) && - rawOptions.some( - (option) => - option && - typeof option === 'object' && - 'preview' in option && - typeof option.preview === 'string' && - option.preview.trim().length > 0 - ) - ) + return Array.isArray(rawOptions) && rawOptions.some((option) => askOptionPreview(option) !== null) } -function parseOptions(raw: unknown): AskOption[] { +function parseOptions(raw: unknown, multiSelect: boolean): AskOption[] { if (!Array.isArray(raw)) { return [] } @@ -87,12 +78,14 @@ function parseOptions(raw: unknown): AskOption[] { 'label' in option && typeof option.label === 'string' ) { + const preview = multiSelect ? null : askOptionPreview(option) return { label: option.label, description: 'description' in option && typeof option.description === 'string' ? option.description - : undefined + : undefined, + ...(preview ? { preview } : {}) } } return null diff --git a/src/shared/structured-agent-question-card.ts b/src/shared/structured-agent-question-card.ts index 3c98b68c5f2..19487c33e7b 100644 --- a/src/shared/structured-agent-question-card.ts +++ b/src/shared/structured-agent-question-card.ts @@ -25,7 +25,8 @@ export function structuredQuestionCard( multiSelect: question.multiSelect, options: question.options.map((option) => ({ label: option.label, - ...(option.description ? { description: option.description } : {}) + ...(option.description ? { description: option.description } : {}), + ...(option.preview ? { preview: option.preview } : {}) })) })) }, From 3ee128db1a3e0128229cbab5ecc16a2c81f7a9e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:33:19 -0800 Subject: [PATCH 055/138] Keep Claude background tasks attached to their subagent after restart (#39958) * Keep Claude background tasks linked to their subagent across restarts * Keep task ownership scoped to each Claude invocation * Keep partial task events scoped to their invocation * Distinguish pending task facts from admitted invocations * Wait for saved task ownership before interpreting Claude events * Verify journal binding order and saved task liveness * Keep owned task updates from reopening Claude root turns * Exercise owned historical notifications with provider identity --- .../claude-parent-task-stop-2.1.319.jsonl | 10 + .../claude-background-stop-records.test.ts | 41 + .../claude/claude-background-task-frames.ts | 12 + .../claude/claude-background-task-identity.ts | 107 +++ .../claude/claude-background-task-roster.ts | 33 +- .../claude-background-task-row-journal.ts | 103 +-- .../claude-background-task-row-lifecycle.ts | 21 + .../claude-background-task-row-writer.ts | 36 + .../claude/claude-background-task-rows.ts | 143 ++-- .../claude/claude-background-task-start.ts | 18 +- ...de-background-task-turn-resumption.test.ts | 75 ++ src/main/claude/claude-child-work-decoder.ts | 183 ++-- src/main/claude/claude-child-work-evidence.ts | 66 +- ...aude-child-work-roster-performance.test.ts | 89 ++ .../claude-journal-bound-delivery.test.ts | 112 +++ .../claude/claude-journal-bound-delivery.ts | 135 +++ .../claude-journal-translator-contract.ts | 5 + src/main/claude/claude-message-journaling.ts | 5 +- .../claude-parent-task-frames.test-fixture.ts | 11 + .../claude-parent-task-recovery.test.ts | 785 ++++++++++++++++++ .../claude-stream-json-connection.test.ts | 14 + src/main/claude/claude-subagent-identities.ts | 14 +- .../claude-subagent-journaled-identities.ts | 35 +- src/main/claude/claude-subagent-linkage.ts | 11 +- ...aude-subagent-resume-after-restart.test.ts | 90 +- .../claude/claude-subagent-task-frames.ts | 8 + src/main/claude/claude-task-invocations.ts | 165 ++++ .../structured/claude-owned-task-send.test.ts | 116 +++ ...claude-structured-dispatch-test-support.ts | 2 + .../claude-structured-event-delivery.ts | 28 +- .../claude-structured-session-acquisition.ts | 20 +- .../claude-structured-session-adapter.ts | 11 +- .../claude-structured-session-close.ts | 2 + ...laude-structured-session-exit-lifecycle.ts | 1 + .../claude-structured-session-publication.ts | 8 +- .../claude-structured-session-state.ts | 17 +- .../claude-structured-journal-translation.ts | 199 ++--- ...aude-structured-session-journal-control.ts | 36 +- .../launch/claude-structured-options.test.ts | 2 + ...ructured-agent-session-event-sink-queue.ts | 19 + .../structured-agent-session-event-sink.ts | 3 + ...nt-session-history-subagent-roster.test.ts | 43 + .../agent-session-journal-producer.test.ts | 32 + .../journal/agent-session-journal-producer.ts | 21 +- .../native-chat-transcript-projection.test.ts | 31 + 45 files changed, 2551 insertions(+), 367 deletions(-) create mode 100644 src/main/claude/__fixtures__/claude-parent-task-stop-2.1.319.jsonl create mode 100644 src/main/claude/claude-background-task-identity.ts create mode 100644 src/main/claude/claude-child-work-roster-performance.test.ts create mode 100644 src/main/claude/claude-journal-bound-delivery.test.ts create mode 100644 src/main/claude/claude-journal-bound-delivery.ts create mode 100644 src/main/claude/claude-parent-task-frames.test-fixture.ts create mode 100644 src/main/claude/claude-parent-task-recovery.test.ts create mode 100644 src/main/claude/claude-task-invocations.ts create mode 100644 src/main/claude/structured/claude-owned-task-send.test.ts create mode 100644 src/main/native-chat/agent-session-wire/publication/history/agent-session-history-subagent-roster.test.ts create mode 100644 src/shared/agent-session/journal/agent-session-journal-producer.test.ts diff --git a/src/main/claude/__fixtures__/claude-parent-task-stop-2.1.319.jsonl b/src/main/claude/__fixtures__/claude-parent-task-stop-2.1.319.jsonl new file mode 100644 index 00000000000..f61bb00f483 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-parent-task-stop-2.1.319.jsonl @@ -0,0 +1,10 @@ +{"at":4444,"frame":{"type":"system","subtype":"background_tasks_changed","tasks":[{"task_id":"agent-parent","run_id":"0mw894hmc-1120e0bd","task_type":"local_agent","subagent_type":"general-purpose","description":"Parent ownership probe"}],"uuid":"parent-task-frame-1","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":4446,"frame":{"type":"system","subtype":"task_started","task_id":"agent-parent","run_id":"0mw894hmc-1120e0bd","tool_use_id":"toolu_parent","description":"Parent ownership probe","subagent_type":"general-purpose","is_backgrounded":true,"spawn_depth":1,"task_type":"local_agent","prompt":"First use Bash with run_in_background true to run `sleep 100`. Then use Bash in the foreground to run `sleep 100`. Do not run any other commands. When both are done, briefly report that you finished.","uuid":"parent-task-frame-2","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7437,"frame":{"type":"system","subtype":"background_tasks_changed","tasks":[{"task_id":"agent-parent","run_id":"0mw894hmc-1120e0bd","task_type":"local_agent","subagent_type":"general-purpose","description":"Parent ownership probe"},{"task_id":"shell-child","run_id":"0mw894jxj-fd21549d","task_type":"local_bash","description":"Sleep 100 seconds in background","parent_task_id":"agent-parent"}],"uuid":"parent-task-frame-3","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7439,"frame":{"type":"system","subtype":"task_started","task_id":"shell-child","run_id":"0mw894jxj-fd21549d","owned_by_subagent":true,"tool_use_id":"toolu_shell","description":"Sleep 100 seconds in background","is_backgrounded":true,"parent_task_id":"agent-parent","task_type":"local_bash","uuid":"parent-task-frame-4","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7446,"frame":{"type":"system","subtype":"task_updated","task_id":"agent-parent","run_id":"0mw894hmc-1120e0bd","patch":{"status":"killed","end_time":1794167703280},"uuid":"parent-task-frame-5","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7446,"frame":{"type":"system","subtype":"task_notification","task_id":"agent-parent","run_id":"0mw894hmc-1120e0bd","tool_use_id":"toolu_parent","status":"stopped","output_file":"/tmp/claude/tasks/agent-parent.output","summary":"Parent ownership probe","uuid":"parent-task-frame-6","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7446,"frame":{"type":"system","subtype":"background_tasks_changed","tasks":[{"task_id":"shell-child","run_id":"0mw894jxj-fd21549d","task_type":"local_bash","description":"Sleep 100 seconds in background","parent_task_id":"agent-parent"}],"uuid":"parent-task-frame-7","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7449,"frame":{"type":"system","subtype":"task_updated","task_id":"shell-child","run_id":"0mw894jxj-fd21549d","patch":{"status":"killed","end_time":1794167703284},"uuid":"parent-task-frame-8","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7450,"frame":{"type":"system","subtype":"task_notification","task_id":"shell-child","run_id":"0mw894jxj-fd21549d","tool_use_id":"toolu_shell","status":"stopped","output_file":"/tmp/claude/tasks/shell-child.output","summary":"Sleep 100 seconds in background","uuid":"parent-task-frame-9","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} +{"at":7450,"frame":{"type":"system","subtype":"background_tasks_changed","tasks":[],"uuid":"parent-task-frame-10","session_id":"819cf9f8-e43c-4ad7-b50f-54aa158a726a"}} diff --git a/src/main/claude/claude-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts index 31e7a477a0c..699d30ca2d6 100644 --- a/src/main/claude/claude-background-stop-records.test.ts +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -8,6 +8,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session/lifecycle/ag import { agentChildWorkLiveness } from '../../shared/agent-status/child-work/agent-status-child-work-liveness' import { conversationCommandBlocked } from '../native-chat/agent-session-wire/operations/structured-conversation-command-admission' import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' +import { PARENT_TASK_STOP } from './claude-parent-task-frames.test-fixture' import { RESUMED_BY_MESSAGE } from './claude-captured-task-frames.test-fixture' import { hostWithParent, parent, producer } from './claude-child-work-producer-harness.test-fixture' @@ -78,6 +79,46 @@ async function droppedFromRoster() { } describe('a background Stop acts on the host child records', () => { + it('keeps a stopped owner and its independently stoppable shell attached from real task frames', async () => { + const host = hostWithParent() + const run = await producer(host) + run.replay(PARENT_TASK_STOP.slice(0, 7)) + const owner = run.records().find((record) => record.kind === 'agent') + expect(owner).toMatchObject({ membership: 'settled', outcome: 'cancelled' }) + expect(run.records().find((record) => record.kind === 'command')).toMatchObject({ + membership: 'live', + stoppable: true, + parentChildWorkId: owner?.childWorkId + }) + const targets = agentChildWorkStopTargets( + host.getStructuredChildWorkViews(parent), + 'shell-child' + ) + expect(targets.map((target) => target.providerId)).toEqual(['shell-child']) + await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: targets.map((target) => target.providerId) + }) + expect(run.claude.connections[0]!.calls.filter((call) => call.subtype === 'stop_task')).toEqual( + [{ subtype: 'stop_task', params: { taskId: 'shell-child' } }] + ) + run.replay(PARENT_TASK_STOP.slice(7)) + expect(run.records().find((record) => record.kind === 'command')).toMatchObject({ + membership: 'settled', + outcome: 'cancelled' + }) + + // Start-only delivery in reverse owner order must converge too; no original tool traffic is replayed. + const childFirst = await producer() + childFirst.replay([PARENT_TASK_STOP[3]!, PARENT_TASK_STOP[1]!]) + const resolvedOwner = childFirst.records().find((record) => record.kind === 'agent') + expect(childFirst.records().find((record) => record.kind === 'command')).toMatchObject({ + membership: 'live', + parentChildWorkId: resolvedOwner?.childWorkId + }) + }) + it("stops the row's own task, which the records keep after the roster dropped it", async () => { const { records, blocked, stopCalls, stop, basis } = await droppedFromRoster() diff --git a/src/main/claude/claude-background-task-frames.ts b/src/main/claude/claude-background-task-frames.ts index 5b776d6af04..c2948a166e9 100644 --- a/src/main/claude/claude-background-task-frames.ts +++ b/src/main/claude/claude-background-task-frames.ts @@ -88,3 +88,15 @@ export function liveClaudeTaskRunState(status: unknown): AgentSessionBackgroundT return null } } + +export const CLAUDE_BACKGROUND_TASK_ROW_SUBTYPES: ReadonlySet = new Set([ + 'task_started', + 'task_updated', + 'task_progress', + 'task_notification' +]) + +export const CLAUDE_BACKGROUND_TASK_SILENT_REFRESH_SUBTYPES: ReadonlySet = new Set([ + 'task_progress', + 'background_tasks_changed' +]) diff --git a/src/main/claude/claude-background-task-identity.ts b/src/main/claude/claude-background-task-identity.ts new file mode 100644 index 00000000000..2d19b843dde --- /dev/null +++ b/src/main/claude/claude-background-task-identity.ts @@ -0,0 +1,107 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session/journal/agent-session-journal-identity' +import { parseAgentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' +import type { AgentJournalProducerLinkage } from '../../shared/agent-session/journal/agent-session-journal-types' +import { isBackgroundTaskBlock } from '../../shared/native-chat/native-chat-types' +import type { StructuredAgentSessionLifecycleJournal } from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-journal-views' + +export type ClaudeBackgroundTaskDescriptor = { + identity: AgentJournalItemIdentity + generation: number + toolUseId?: string + linkage: AgentJournalProducerLinkage +} + +export type ClaudeBackgroundTaskIndex = { + latest?: ClaudeBackgroundTaskDescriptor + unresolved?: ClaudeBackgroundTaskDescriptor + aliases: Map +} + +export function claudeBackgroundTaskIdentity( + taskId: string, + generation = 1 +): AgentJournalItemIdentity { + return { + provider: 'orca', + clientMessageId: `claude-background-task:${taskId}${generation > 1 ? `#${generation}` : ''}` + } +} + +export function indexClaudeBackgroundTask( + index: ClaudeBackgroundTaskIndex, + itemId: string, + taskId: string, + toolUseId: string | undefined, + linkage: AgentJournalProducerLinkage +): void { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'orca') { + return + } + const base = `claude-background-task:${taskId}` + const generation = + identity.clientMessageId === base + ? 1 + : identity.clientMessageId.startsWith(`${base}#`) + ? Number(identity.clientMessageId.slice(base.length + 1)) + : Number.NaN + if (!Number.isSafeInteger(generation) || generation < 1) { + return + } + const descriptor = { identity, generation, toolUseId, linkage } + if (!index.latest || generation >= index.latest.generation) { + index.latest = descriptor + } + if (toolUseId === undefined) { + if (!index.unresolved || generation >= index.unresolved.generation) { + index.unresolved = descriptor + } + } else { + const prior = index.aliases.get(toolUseId) + if (!prior || generation >= prior.generation) { + index.aliases.set(toolUseId, descriptor) + } + } +} + +export function selectClaudeBackgroundTask( + index: ClaudeBackgroundTaskIndex | undefined, + toolUseId?: string +): ClaudeBackgroundTaskDescriptor | undefined { + return toolUseId === undefined + ? index?.latest + : (index?.aliases.get(toolUseId) ?? index?.unresolved) +} + +export function resolveClaudeBackgroundTaskDescriptor( + journal: StructuredAgentSessionLifecycleJournal, + id: string, + toolUseId?: string +): ClaudeBackgroundTaskDescriptor { + const index: ClaudeBackgroundTaskIndex = { aliases: new Map() } + journal.visitItems((itemId, _sequence, body) => { + if (body.kind !== 'message') { + return + } + const block = body.blocks.find(isBackgroundTaskBlock) + if (block?.taskId === id) { + indexClaudeBackgroundTask(index, itemId, id, block.parentToolUseId, {}) + } + }) + return ( + selectClaudeBackgroundTask(index, toolUseId) ?? { + identity: claudeBackgroundTaskIdentity(id, (index.latest?.generation ?? 0) + 1), + generation: (index.latest?.generation ?? 0) + 1, + toolUseId, + linkage: {} + } + ) +} + +export function resolveClaudeBackgroundTaskIdentity( + journal: StructuredAgentSessionLifecycleJournal, + id: string, + toolUseId: string | undefined +): AgentJournalItemIdentity { + return resolveClaudeBackgroundTaskDescriptor(journal, id, toolUseId).identity +} diff --git a/src/main/claude/claude-background-task-roster.ts b/src/main/claude/claude-background-task-roster.ts index 422ec533fca..80e89a514e6 100644 --- a/src/main/claude/claude-background-task-roster.ts +++ b/src/main/claude/claude-background-task-roster.ts @@ -1,3 +1,4 @@ +import type { ClaudeTaskInvocations } from './claude-task-invocations' import { isSettledBackgroundTaskState } from '../../shared/native-chat/input/native-chat-background-task-row' import { classifyClaudeBackgroundTaskKind, @@ -15,16 +16,23 @@ import type { export function observeClaudeBackgroundTaskRoster( tasks: unknown[], rows: Map, - revise: (id: string, change: ClaudeBackgroundTaskChange) => void + revise: (id: string, change: ClaudeBackgroundTaskChange) => void, + admitOwned?: (id: string, task: Record) => boolean | void ): void { for (const entry of tasks) { const task = record(entry) const id = task === null ? null : taskId(task) + if (task && id && task.ambient !== true && task.skip_transcript !== true) { + if (admitOwned?.(id, task) === false) { + continue + } + } const row = id === null ? undefined : rows.get(id) if ( task === null || id === null || task.ambient === true || + task.skip_transcript === true || !row || isSettledBackgroundTaskState(row.block.state) ) { @@ -37,3 +45,26 @@ export function observeClaudeBackgroundTaskRoster( }) } } + +export function observeOwnedClaudeBackgroundTaskRoster(input: { + tasks: unknown[] + rows: Map + invocations: ClaudeTaskInvocations + revise: (id: string, change: ClaudeBackgroundTaskChange) => void + attribute: (id: string, row: ClaudeBackgroundTaskRow) => void + admit: (id: string, task: Record) => void +}): void { + observeClaudeBackgroundTaskRoster(input.tasks, input.rows, input.revise, (id, task) => { + const selected = input.invocations.selection(task, id) + if (!selected?.acceptsStart || selected.invocation.excluded || selected.invocation.monitor) { + return false + } + const row = input.rows.get(id) + if (row) { + input.attribute(id, row) + } else if (selected.invocation.parentTaskId !== undefined) { + input.admit(id, task) + } + return true + }) +} diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index e78bf4f0c09..3ea4197ff06 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -8,60 +8,22 @@ import type { } from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink' import type { StructuredAgentSessionLifecycleJournal } from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-journal-views' import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' -import { parseAgentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' +import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' +import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink-estimate' +import { + claudeBackgroundTaskIdentity, + resolveClaudeBackgroundTaskIdentity +} from './claude-background-task-identity' +export { + claudeBackgroundTaskIdentity, + resolveClaudeBackgroundTaskIdentity +} from './claude-background-task-identity' const MAX_RESOLVED_TASK_IDENTITIES = 512 const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } -/** Generation suffixes preserve restarted runs; generation 1 retains the legacy journal key. */ -export function claudeBackgroundTaskIdentity( - taskId: string, - generation = 1 -): AgentJournalItemIdentity { - const key = - generation > 1 - ? `claude-background-task:${taskId}#${generation}` - : `claude-background-task:${taskId}` - return { provider: 'orca', clientMessageId: key } -} - export const claudeBackgroundTaskBody = backgroundTaskJournalBody -export function resolveClaudeBackgroundTaskIdentity( - journal: StructuredAgentSessionLifecycleJournal, - id: string, - toolUseId: string | undefined -): AgentJournalItemIdentity { - let maxGeneration = 0 - let matchingGeneration: number | undefined - let parentlessGeneration: number | undefined - journal.visitItems((itemId, _sequence, body) => { - const identity = parseAgentJournalItemKey(itemId) - if (!identity || identity.provider !== 'orca') { - return - } - const taskBlock = body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined - if (!taskBlock || taskBlock.taskId !== id) { - return - } - const generation = persistedTaskGeneration(identity.clientMessageId, id) - if (generation === null) { - return - } - maxGeneration = Math.max(maxGeneration, generation) - if (taskBlock.parentToolUseId === toolUseId) { - matchingGeneration = Math.max(matchingGeneration ?? 0, generation) - } else if (taskBlock.parentToolUseId === undefined) { - parentlessGeneration = Math.max(parentlessGeneration ?? 0, generation) - } - }) - // A prior parentless row cannot be proved distinct from the later aliased outcome. - return claudeBackgroundTaskIdentity( - id, - matchingGeneration ?? parentlessGeneration ?? (maxGeneration === 0 ? 1 : maxGeneration + 1) - ) -} - export class ClaudeBackgroundTaskIdentityResolver { private journal: StructuredAgentSessionLifecycleJournal | null = null private epoch: string | null = null @@ -102,19 +64,6 @@ export class ClaudeBackgroundTaskIdentityResolver { } } -function persistedTaskGeneration(clientMessageId: string, taskId: string): number | null { - const base = `claude-background-task:${taskId}` - if (clientMessageId === base) { - return 1 - } - const prefix = `${base}#` - if (!clientMessageId.startsWith(prefix)) { - return null - } - const generation = Number(clientMessageId.slice(prefix.length)) - return Number.isSafeInteger(generation) && generation > 1 ? generation : null -} - export function writeClaudeBackgroundTaskRow( sink: StructuredAgentSessionEventSink, identities: ClaudeBackgroundTaskIdentityResolver, @@ -127,7 +76,7 @@ export function writeClaudeBackgroundTaskRow( lifecycle = false ): StructuredAgentSessionSinkAdmission { const body = claudeBackgroundTaskBody(row.block) - const serialized = JSON.stringify(body) + const serialized = JSON.stringify([body, row.linkage]) if (serialized === row.lastSerialized) { return ADMITTED } @@ -135,16 +84,42 @@ export function writeClaudeBackgroundTaskRow( const identity = claudeBackgroundTaskIdentity(id, row.generation) // A reused row belongs to the current run, so it must count as executing. const appendOptions = { + ...row.linkage, turnScope: turnScope(), executes: true as const, ...(lifecycle ? { lifecycle: true } : {}) } const publishOptions = lifecycle ? { lifecycle: true } : {} + const toolUseId = row.block.parentToolUseId const resolveIdentity = (journal: StructuredAgentSessionLifecycleJournal) => - identities.resolve(journal, id, row.toolUseId) + identities.resolve(journal, id, toolUseId) const appendAndPublish = sink.tryAppendResolvedItemAndPublish let admission: StructuredAgentSessionSinkAdmission - if (appendAndPublish) { + if (sink.tryReviseResolvedItemAndPublish) { + const identitySizeBound = claudeBackgroundTaskIdentity(id, Number.MAX_SAFE_INTEGER) + // A saved alias is at most512 characters, each at most6 JSON bytes. + const reservedBytes = + estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + 512 * 6 + 64 + admission = sink.tryReviseResolvedItemAndPublish( + reservedBytes, + (journal) => { + const resolved = resolveIdentity(journal) + const saved = journal.itemBody(agentJournalItemKey(resolved)) + const savedBlock = + saved?.kind === 'message' ? saved.blocks.find(isBackgroundTaskBlock) : undefined + const block = body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined + const alias = block?.parentToolUseId ?? savedBlock?.parentToolUseId + return { + identity: resolved, + body: + block && alias && block.parentToolUseId === undefined + ? claudeBackgroundTaskBody({ ...block, parentToolUseId: alias }) + : body + } + }, + appendOptions + ) + } else if (appendAndPublish) { // Budget for any safe generation suffix before the deferred sink resolves its durable identity. const identitySizeBound = claudeBackgroundTaskIdentity(id, Number.MAX_SAFE_INTEGER) admission = appendAndPublish(identitySizeBound, body, resolveIdentity, appendOptions) diff --git a/src/main/claude/claude-background-task-row-lifecycle.ts b/src/main/claude/claude-background-task-row-lifecycle.ts index e612b26ffa2..112157ec7d7 100644 --- a/src/main/claude/claude-background-task-row-lifecycle.ts +++ b/src/main/claude/claude-background-task-row-lifecycle.ts @@ -1,3 +1,5 @@ +import type { ClaudeTaskInvocation } from './claude-task-invocations' +import type { AgentJournalProducerLinkage } from '../../shared/agent-session/journal/agent-session-journal-types' import { canReplaceBackgroundTaskState, isSettledBackgroundTaskState @@ -19,6 +21,8 @@ import { export type ClaudeBackgroundTaskRow = { block: NativeChatBackgroundTaskBlock + linkage?: AgentJournalProducerLinkage + invocation?: ClaudeTaskInvocation lastSerialized: string | null /** Immutable alias for persistence/coalescing; the final frame may enrich the block's parent. */ toolUseId?: string @@ -208,3 +212,20 @@ export function reviseClaudeBackgroundTaskRow( } row.block = next } + +export function attributeClaudeBackgroundTaskRow(id: string, row: ClaudeBackgroundTaskRow): void { + const invocation = row.invocation + const toolUseId = invocation?.toolUseId + if (toolUseId && row.block.parentToolUseId === undefined) { + row.block = { ...row.block, parentToolUseId: toolUseId } + } + const parent = invocation?.reportedParentTaskId + if (parent) { + row.linkage = { + agentId: id, + parentAgentId: parent, + producerKind: 'background', + ...(toolUseId ? { providerParentRef: toolUseId } : {}) + } + } +} diff --git a/src/main/claude/claude-background-task-row-writer.ts b/src/main/claude/claude-background-task-row-writer.ts index d1f71279e70..b4551e1e480 100644 --- a/src/main/claude/claude-background-task-row-writer.ts +++ b/src/main/claude/claude-background-task-row-writer.ts @@ -1,3 +1,6 @@ +import { CLAUDE_BACKGROUND_TASK_SILENT_REFRESH_SUBTYPES } from './claude-background-task-frames' +import type { ClaudeTaskInvocations } from './claude-task-invocations' +import { claudeBackgroundTaskToolUseId } from './claude-background-task-row-lifecycle' import type { AgentJournalTurnScope } from '../../shared/agent-session/journal/agent-session-journal-turn-scope' import type { StructuredAgentSessionEventSink, @@ -94,3 +97,36 @@ export class ClaudeBackgroundTaskRowWriter { this.identities.clear() } } + +export function createClaudeBackgroundTaskTurnOpener( + openOutputTurn: ((frame: Record, observedAt: number) => void) | undefined, + invocations: ClaudeTaskInvocations +) { + return ( + journaling: { frame: Record; observedAt: number } | null, + open: boolean, + id: string | null, + row?: ClaudeBackgroundTaskRow + ): (() => void) => { + const frame = journaling?.frame + const invocation = + row?.invocation ?? + (frame && id !== null + ? (invocations.selection(frame, id)?.invocation ?? + invocations.get(id, claudeBackgroundTaskToolUseId(frame))) + : undefined) + return () => { + // Task inventory from a known child cannot assert execution of the root. + const subtype = frame?.subtype + if ( + journaling && + open && + invocation?.parentTaskId === undefined && + (typeof subtype !== 'string' || + !CLAUDE_BACKGROUND_TASK_SILENT_REFRESH_SUBTYPES.has(subtype)) + ) { + openOutputTurn?.(journaling.frame, journaling.observedAt) + } + } + } +} diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts index be03348a337..bc5dca47ebc 100644 --- a/src/main/claude/claude-background-task-rows.ts +++ b/src/main/claude/claude-background-task-rows.ts @@ -1,9 +1,15 @@ +import { ClaudeTaskInvocations } from './claude-task-invocations' import type { AgentJournalTurnScope } from '../../shared/agent-session/journal/agent-session-journal-turn-scope' import { isSettledBackgroundTaskState } from '../../shared/native-chat/input/native-chat-background-task-row' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink' import { BoundedMap } from '../../shared/bounded-map' -import { record, taskAliasId } from './claude-background-task-frames' import { + record, + taskAliasId, + CLAUDE_BACKGROUND_TASK_ROW_SUBTYPES +} from './claude-background-task-frames' +import { + attributeClaudeBackgroundTaskRow, claudeBackgroundTaskPatchChange, claudeBackgroundTaskToolUseId, canonicalClaudeBackgroundTaskId, @@ -19,31 +25,23 @@ import { ensureClaudeBackgroundTaskRowSlot, type ClaudeBackgroundTaskLedgerSizes } from './claude-background-task-memory' -import { ClaudeBackgroundTaskRowWriter } from './claude-background-task-row-writer' +import { + ClaudeBackgroundTaskRowWriter, + createClaudeBackgroundTaskTurnOpener +} from './claude-background-task-row-writer' import { observeClaudeBackgroundTaskStart } from './claude-background-task-start' -import { observeClaudeBackgroundTaskRoster } from './claude-background-task-roster' +import { observeOwnedClaudeBackgroundTaskRoster } from './claude-background-task-roster' import { ClaudeSubagentIds } from './claude-subagent-id-aliases' import { ClaudeOverflowTerminalRows } from './claude-overflow-terminal-rows' const MAX_TASK_ROWS = 64 -const TASK_SUBTYPES: ReadonlySet = new Set([ - 'task_started', - 'task_updated', - 'task_progress', - 'task_notification' -]) - -const SILENT_ROW_REFRESH_SUBTYPES: ReadonlySet = new Set([ - 'task_progress', - 'background_tasks_changed' -]) - export type ClaudeBackgroundTaskRowsDeps = { sink: StructuredAgentSessionEventSink /** Require a forwarded top-level invocation on first admission to exclude nested children. */ isForwardedParentTool: (toolUseId: string) => boolean /** Recover earlier-run subagent ownership when Claude does not repeat its announcement. */ + taskInvocations?: ClaudeTaskInvocations rosteredByEarlierRun?: (taskId: string) => boolean /** Reopen provider-resumed turns before journaling output so the session reports working. */ openOutputTurn?: (frame: Record, observedAt: number) => void @@ -56,13 +54,17 @@ export type ClaudeBackgroundTaskRowsDeps = { export class ClaudeBackgroundTaskRows { private readonly rows = new Map() private readonly writer: ClaudeBackgroundTaskRowWriter + private readonly openTurn: ReturnType private readonly overflowTerminalRows: ClaudeOverflowTerminalRows private readonly ledgers = new ClaudeBackgroundTaskLedgers() private readonly ids = new ClaudeSubagentIds() private readonly now: () => number + private readonly taskInvocations: ClaudeTaskInvocations constructor(private readonly deps: ClaudeBackgroundTaskRowsDeps) { + this.taskInvocations = deps.taskInvocations ?? new ClaudeTaskInvocations() this.now = deps.now ?? (() => Date.now()) + this.openTurn = createClaudeBackgroundTaskTurnOpener(deps.openOutputTurn, this.taskInvocations) this.writer = new ClaudeBackgroundTaskRowWriter( deps.sink, deps.turnScope, @@ -87,6 +89,7 @@ export class ClaudeBackgroundTaskRows { private journaling: { frame: Record; observedAt: number } | null = null observe(message: Record, observedAt: number = this.now()): boolean { + this.taskInvocations.observe(message) this.journaling = { frame: message, observedAt } // Claude wakes the lead for any task the lead launched, rowless subagents included; open that turn at the notification, once per frame. const toolUseId = @@ -101,7 +104,8 @@ export class ClaudeBackgroundTaskRows { if (frameId) { this.wakeFrames.set(frameId, true) } - this.deps.openOutputTurn?.(message, observedAt) + const id = canonicalClaudeBackgroundTaskId(message, this.ids) + this.openTurn(this.journaling, true, id)() } try { return this.observeFrame(message) @@ -111,10 +115,10 @@ export class ClaudeBackgroundTaskRows { } /** Monitor results may precede announcements; their notifications belong to the tool call. */ - observeMonitorToolResult(taskId: unknown): void { + observeMonitorToolResult(taskId: unknown, toolUseId?: string): void { const id = taskAliasId(taskId) if (id !== undefined) { - this.ledgers.rememberForeign(id, 'ambient') + this.taskInvocations.excludeMonitor(id, toolUseId) } } @@ -126,28 +130,46 @@ export class ClaudeBackgroundTaskRows { if (!Array.isArray(message.tasks)) { return false } - observeClaudeBackgroundTaskRoster(message.tasks, this.rows, (id, change) => - this.revise(id, change) - ) + observeOwnedClaudeBackgroundTaskRoster({ + tasks: message.tasks, + rows: this.rows, + invocations: this.taskInvocations, + revise: (id, change) => this.revise(id, change), + attribute: attributeClaudeBackgroundTaskRow, + admit: (id, task) => { + if (!this.ledgers.terminalTaskIds.has(id)) { + this.observeStart(id, task, true) + } + } + }) return true } - if (typeof message.subtype !== 'string' || !TASK_SUBTYPES.has(message.subtype)) { + if ( + typeof message.subtype !== 'string' || + !CLAUDE_BACKGROUND_TASK_ROW_SUBTYPES.has(message.subtype) + ) { return false } const id = canonicalClaudeBackgroundTaskId(message, this.ids) if (id === null) { return false } + const selected = this.taskInvocations.selection(message, id) + if (selected?.invocation.excluded || selected?.invocation.monitor) { + return true + } + if (selected?.current === false) { + if (message.subtype !== 'task_notification') { + return true + } + const generation = this.ledgers.generations.next(id) + const row = newClaudeBackgroundTaskRowFromNotification(id, message, this.now(), generation) + row.invocation = selected.invocation + this.writeRow(id, row) + return true + } if (message.subtype === 'task_started') { - return observeClaudeBackgroundTaskStart({ - id, - message, - rows: this.rows, - ledgers: this.ledgers, - isForwardedParentTool: this.deps.isForwardedParentTool, - openRow: (taskId, frame) => this.openRow(taskId, frame), - maxRows: MAX_TASK_ROWS - }) + return this.observeStart(id, message) } if (this.ledgers.foreign.has(id)) { return true @@ -162,6 +184,28 @@ export class ClaudeBackgroundTaskRows { return this.observePatch(id, message) } + private observeStart(id: string, message: Record, roster = false): boolean { + const selected = this.taskInvocations.selection(message, id) + if (!selected?.acceptsStart || selected.invocation.excluded || selected.invocation.monitor) { + return true + } + return observeClaudeBackgroundTaskStart({ + id, + message: { + ...message, + ...(roster ? { is_backgrounded: true } : {}), + tool_use_id: selected.invocation.toolUseId + }, + owned: selected.invocation.parentTaskId !== undefined, + rows: this.rows, + ledgers: this.ledgers, + isForwardedParentTool: this.deps.isForwardedParentTool, + openRow: (taskId, frame) => this.openRow(taskId, frame), + refresh: (id) => this.write(id), + maxRows: MAX_TASK_ROWS + }) + } + settleSession(): void { for (const [id, row] of this.rows) { if (!isSettledBackgroundTaskState(row.block.state)) { @@ -180,13 +224,13 @@ export class ClaudeBackgroundTaskRows { this.ids.clear() } - retryPendingWrites() { - return this.writer.retryPendingWrites() - } + retryPendingWrites = () => this.writer.retryPendingWrites() private openRow(id: string, message: Record): void { const generation = this.ledgers.generations.next(id) - this.rows.set(id, newClaudeBackgroundTaskRow(id, message, this.now(), generation)) + const row = newClaudeBackgroundTaskRow(id, message, this.now(), generation) + row.invocation = this.taskInvocations.get(id, claudeBackgroundTaskToolUseId(message)) + this.rows.set(id, row) this.write(id) } @@ -218,10 +262,9 @@ export class ClaudeBackgroundTaskRows { return true } const generation = this.ledgers.generations.next(id) - this.rows.set( - id, - newClaudeBackgroundTaskRowFromNotification(id, message, this.now(), generation) - ) + const row = newClaudeBackgroundTaskRowFromNotification(id, message, this.now(), generation) + row.invocation = this.taskInvocations.get(id, claudeBackgroundTaskToolUseId(message)) + this.rows.set(id, row) this.write(id) return true } @@ -230,6 +273,7 @@ export class ClaudeBackgroundTaskRows { if (this.ledgers.fallbackTaskIds.has(id)) { const change = claudeBackgroundTaskPatchChange(message) if (change.state && isSettledBackgroundTaskState(change.state)) { + this.taskInvocations.settleFrame(id, message) this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message)) this.overflowTerminalRows.observePatch(id, message, change) return true @@ -248,6 +292,7 @@ export class ClaudeBackgroundTaskRows { } const change = claudeBackgroundTaskPatchChange(message) if (change.state && isSettledBackgroundTaskState(change.state)) { + this.taskInvocations.settleFrame(id, message) this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message)) } if (row) { @@ -280,23 +325,7 @@ export class ClaudeBackgroundTaskRows { openOutputTurn = true, lifecycle = false ): void { - const journaling = this.journaling - this.writer.write( - id, - row, - () => { - // Why: progress and roster ticks refresh the row only. Opening a turn - // here flips an idle chat back to working. - const subtype = journaling?.frame.subtype - if ( - journaling && - openOutputTurn && - (typeof subtype !== 'string' || !SILENT_ROW_REFRESH_SUBTYPES.has(subtype)) - ) { - this.deps.openOutputTurn?.(journaling.frame, journaling.observedAt) - } - }, - lifecycle - ) + attributeClaudeBackgroundTaskRow(id, row) + this.writer.write(id, row, this.openTurn(this.journaling, openOutputTurn, id, row), lifecycle) } } diff --git a/src/main/claude/claude-background-task-start.ts b/src/main/claude/claude-background-task-start.ts index ed70d1b2da3..76f6ad07b1b 100644 --- a/src/main/claude/claude-background-task-start.ts +++ b/src/main/claude/claude-background-task-start.ts @@ -12,7 +12,7 @@ import { } from './claude-background-task-memory' import { isClaudeSubagentTask } from './claude-subagent-task-frames' -export function observeClaudeBackgroundTaskStart(input: { +type ClaudeBackgroundTaskStartInput = { id: string message: Record rows: Map @@ -20,7 +20,14 @@ export function observeClaudeBackgroundTaskStart(input: { isForwardedParentTool: (toolUseId: string) => boolean openRow: (id: string, message: Record) => void maxRows: number -}): boolean { +} + +export function observeClaudeBackgroundTaskStart( + input: ClaudeBackgroundTaskStartInput & { + owned: boolean + refresh: (id: string, row: ClaudeBackgroundTaskRow) => void + } +): boolean { const { id, message, rows, ledgers } = input if (ledgers.fallbackTaskIds.has(id)) { if (ledgers.terminalTaskIds.has(id)) { @@ -39,10 +46,6 @@ export function observeClaudeBackgroundTaskStart(input: { return false } } - if (message.ambient === true || message.skip_transcript === true) { - ledgers.rememberForeign(id, 'ambient') - return true - } if (isClaudeSubagentTask(message)) { ledgers.rememberForeign(id, 'roster') return true @@ -57,6 +60,7 @@ export function observeClaudeBackgroundTaskStart(input: { ledgers.foreign.delete(id) // A live task's duplicate announcement is redelivery, not a new run. if (!isSettledBackgroundTaskState(existing.block.state)) { + input.refresh(id, existing) return true } if (shouldRestartClaudeBackgroundTaskRow(existing, message)) { @@ -81,7 +85,7 @@ export function observeClaudeBackgroundTaskStart(input: { ledgers.foreign.delete(id) const toolUseId = claudeBackgroundTaskToolUseId(message) // Absence of a parent is not evidence of an unforwarded parent. - if (toolUseId !== undefined && !input.isForwardedParentTool(toolUseId)) { + if (toolUseId !== undefined && !input.owned && !input.isForwardedParentTool(toolUseId)) { ledgers.rememberForeign(id, 'sidechain') return true } diff --git a/src/main/claude/claude-background-task-turn-resumption.test.ts b/src/main/claude/claude-background-task-turn-resumption.test.ts index 9ae77f35372..13a2d59ba17 100644 --- a/src/main/claude/claude-background-task-turn-resumption.test.ts +++ b/src/main/claude/claude-background-task-turn-resumption.test.ts @@ -161,3 +161,78 @@ describe('a typed background-task row opens the turn it resumes', () => { expect(projected(items())).toBe('idle') }) }) + +it('keeps an alias-only owned notification idle even when its alias was forwarded at the root', () => { + const { translator, items } = harness() + spawnToolCall(translator) + translator.handle( + systemFrame('owned-start', { + subtype: 'task_started', + task_id: 'owned', + tool_use_id: TOOL, + parent_task_id: 'owner', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + settleTurn(translator) + translator.handle( + systemFrame('owned-terminal', { + subtype: 'task_notification', + tool_use_id: TOOL, + status: 'completed', + summary: 'Finished child' + }) + ) + expect(items().find((item) => item.itemId.includes('owned'))?.body).toMatchObject({ + kind: 'message', + blocks: expect.arrayContaining([ + expect.objectContaining({ type: 'background-task', taskId: 'owned', state: 'done' }) + ]) + }) + expect(projected(items())).toBe('idle') + translator.dispose() +}) + +it('keeps owned terminal overflow writes idle without a retained row invocation', () => { + const { translator, items } = harness() + for (let index = 0; index < 65; index += 1) { + translator.handle( + systemFrame(`owned-start-${index}`, { + subtype: 'task_started', + task_id: `owned-${index}`, + parent_task_id: 'owner', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + } + settleTurn(translator) + translator.handle( + systemFrame('overflow-owned-patch', { + subtype: 'task_updated', + task_id: 'owned-64', + patch: { status: 'failed' } + }) + ) + translator.handle( + systemFrame('overflow-owned-orphan', { + subtype: 'task_notification', + task_id: 'owned-terminal-only', + parent_task_id: 'owner', + status: 'completed' + }) + ) + const terminal = items().filter( + (item) => + item.body.kind === 'message' && + item.body.blocks.some( + (block) => + block.type === 'background-task' && + ['owned-64', 'owned-terminal-only'].includes(block.taskId) + ) + ) + expect(terminal).toHaveLength(2) + expect(projected(items())).toBe('idle') + translator.dispose() +}) diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index 67dd220f3fa..d155db75b49 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -1,14 +1,7 @@ // Roster changes, turn endings and spawn results cannot settle a child. -import type { - AgentChildWorkKind, - AgentChildWorkOperation, - AgentChildWorkOutcome -} from '../../shared/agent-status/child-work/agent-status-child-work' -import type { - AgentChildWorkEvidence, - AgentChildWorkLiveObservation -} from '../../shared/agent-status/child-work/agent-status-child-work-evidence' +import { ClaudeTaskInvocations } from './claude-task-invocations' +import type { AgentChildWorkEvidence } from '../../shared/agent-status/child-work/agent-status-child-work-evidence' import { classifyClaudeBackgroundTaskKind, liveClaudeTaskRunState, @@ -21,83 +14,34 @@ import { taskUsageTotalTokens, terminalClaudeTaskRunState } from './claude-background-task-frames' -import { isClaudeSubagentTask } from './claude-subagent-task-frames' +import { claudeStartedTaskKind } from './claude-subagent-task-frames' import { ClaudeAgentWaitCalls } from './claude-agent-wait-calls' -import { ORCA_ORCHESTRATION_WAIT_COMMAND } from '../../shared/orca-cli-invocations' import { claudeChildWorkOutcome, + claudeChildWorkObservation, + type DecodedClaudeTask, + type EndedClaudeTask, claudeTaskProgressFacts, type ClaudeTaskFacts } from './claude-child-work-evidence' -type DecodedClaudeTask = { - kind: AgentChildWorkKind - backgrounded: boolean - /** Monitors stay monitoring until the provider reports them running. */ - running: boolean - name?: string - description?: string - toolUseId?: string - /** Runs `orca orchestration check --wait`: waiting on worker agents, so agent work. */ - awaitsAgents?: true -} - type ClaudeToolOwner = (toolUseId: string) => string | null type PendingEdge = (observedAt: number) => AgentChildWorkEvidence -function observation( - id: string, - task: DecodedClaudeTask, - facts: ClaudeTaskFacts, - observedAt: number, - waiting: boolean -): AgentChildWorkLiveObservation { - const operation: AgentChildWorkOperation | undefined = facts.toolName - ? { toolName: facts.toolName, basis: 'reported', observedAt } - : task.awaitsAgents - ? { - toolName: 'Bash', - input: ORCA_ORCHESTRATION_WAIT_COMMAND, - basis: 'reported', - observedAt - } - : undefined - return { - handle: { - idKind: 'task_id', - id, - ...(task.toolUseId !== undefined ? { runId: task.toolUseId } : {}) - }, - kind: task.kind, - residency: task.backgrounded ? 'background' : 'foreground', - state: waiting ? 'waiting' : task.running || task.kind !== 'monitor' ? 'working' : 'monitoring', - ...(task.name ? { name: task.name, agentType: task.name } : {}), - ...(task.description ? { description: task.description } : {}), - ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), - ...(operation ? { operation } : {}), - ...(facts.lastMessage ? { lastMessage: facts.lastMessage } : {}), - // Only background tasks expose a stop target. - stoppable: task.backgrounded - } -} - export class ClaudeChildWorkDecoder { private readonly live = new Map() /** Retain ended runs for the session to distinguish wakes from late starts. */ - private readonly ended = new Map< - string, - { - run: string | undefined - outcome: AgentChildWorkOutcome - task?: DecodedClaudeTask - } - >() + private readonly ended = new Map() + private readonly childrenByOwner = new Map>() private waiting: ReadonlySet = new Set() private pending: PendingEdge[] = [] private readonly agentWaits = new ClaudeAgentWaitCalls() /** `ownerOf` names the subagent that made a child tool call; an ending task wakes it. */ + constructor(readonly taskInvocations = new ClaudeTaskInvocations()) {} + observe(message: Record, ownerOf?: ClaudeToolOwner): void { + this.taskInvocations.observe(message) if (message.type === 'assistant') { this.agentWaits.observe(message) return @@ -105,10 +49,31 @@ export class ClaudeChildWorkDecoder { if (message.type !== 'system') { return } + if (message.subtype === 'background_tasks_changed') { + if (Array.isArray(message.tasks)) { + for (const entry of message.tasks) { + const task = record(entry) + const id = task && taskId(task) + if ( + task && + id && + !this.ended.has(id) && + this.taskInvocations.parentOf(id, taskAliasId(task.tool_use_id)) + ) { + this.started(id, task, true) + } + } + } + return + } const id = taskId(message) if (!id) { return } + const selected = this.taskInvocations.selection(message, id) + if (selected && (!selected.current || selected.invocation.excluded)) { + return + } switch (message.subtype) { case 'task_started': this.started(id, message) @@ -145,8 +110,10 @@ export class ClaudeChildWorkDecoder { } clear(): void { + this.taskInvocations.clear() this.live.clear() this.ended.clear() + this.childrenByOwner.clear() this.agentWaits.clear() this.waiting = new Set() this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) @@ -158,12 +125,14 @@ export class ClaudeChildWorkDecoder { return pending.map((edge) => edge(observedAt)) } - private started(id: string, message: Record): void { - const toolUseId = taskAliasId(message.tool_use_id) - const awaitsAgents = this.agentWaits.take(toolUseId) - if (message.ambient === true || message.skip_transcript === true) { + private started(id: string, message: Record, roster = false): void { + const selection = this.taskInvocations.selection(message, id) + if (!selection?.acceptsStart || selection.invocation.excluded) { return } + const invocation = selection.invocation + const toolUseId = invocation.toolUseId + const awaitsAgents = this.agentWaits.take(toolUseId) const existing = this.live.get(id) const ended = existing ? undefined : this.ended.get(id) const restart = ended !== undefined @@ -171,12 +140,13 @@ export class ClaudeChildWorkDecoder { if (ended?.run !== undefined && ended.run === toolUseId && ended.outcome !== 'succeeded') { return } - const kind = startedTaskKind(message) + const kind = claudeStartedTaskKind(message) this.upsert( id, { kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), backgrounded: + roster || message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor' || @@ -185,16 +155,22 @@ export class ClaudeChildWorkDecoder { name: taskName(message) ?? existing?.name, description: taskDescription(message.description) ?? existing?.description, toolUseId: toolUseId ?? existing?.toolUseId, + parentTaskId: invocation.parentTaskId, ...(awaitsAgents || existing?.awaitsAgents ? { awaitsAgents: true as const } : {}) }, {}, - restart + restart, + roster ) } /** Claude resumes a finished subagent when a task it launched notifies; show it working from that frame, not its restart a moment later. */ - private wakeOwner(toolUseId: string | undefined, ownerOf: ClaudeToolOwner | undefined): void { - const ownerId = toolUseId ? ownerOf?.(toolUseId) : null + private wakeOwner( + toolUseId: string | undefined, + ownerOf: ClaudeToolOwner | undefined, + parentTaskId?: string + ): void { + const ownerId = parentTaskId ?? (toolUseId ? ownerOf?.(toolUseId) : null) const owner = ownerId ? this.ended.get(ownerId) : undefined if (ownerId && owner?.outcome === 'succeeded' && owner.task?.kind === 'agent') { this.upsert(ownerId, { ...owner.task, running: true }, {}, true) @@ -207,11 +183,8 @@ export class ClaudeChildWorkDecoder { return } // Progress descriptions describe the current activity, not the task. - this.upsert( - id, - { ...task, name: task.name ?? taskName(message) }, - claudeTaskProgressFacts(message) - ) + const name = task.name ?? taskName(message) + this.upsert(id, { ...task, name }, claudeTaskProgressFacts(message)) } private updated( @@ -230,7 +203,6 @@ export class ClaudeChildWorkDecoder { } return } - // Late updates cannot reopen an ended task. if (!existing && this.ended.has(id)) { return } @@ -251,6 +223,7 @@ export class ClaudeChildWorkDecoder { name: name ?? existing?.name, description: description ?? existing?.description, toolUseId: existing?.toolUseId, + parentTaskId: existing?.parentTaskId, ...(existing?.awaitsAgents ? { awaitsAgents: true as const } : {}) }, {}, @@ -262,11 +235,44 @@ export class ClaudeChildWorkDecoder { id: string, task: DecodedClaudeTask, facts: ClaudeTaskFacts, - restart = false + restart = false, + unchangedRoster = false ): void { + if (unchangedRoster && JSON.stringify(task) === JSON.stringify(this.live.get(id))) { + return + } + const ownerBecameLive = !this.live.has(id) || restart this.ended.delete(id) + this.removeOwnerIndex(id) this.live.set(id, task) + if (task.parentTaskId) { + const children = this.childrenByOwner.get(task.parentTaskId) ?? new Set() + children.add(id) + this.childrenByOwner.set(task.parentTaskId, children) + } this.report(id, task, facts, restart) + // Admission can resolve a child's raw owner only after that owner's live edge. + if (!ownerBecameLive) { + return + } + for (const childId of this.childrenByOwner.get(id) ?? []) { + const child = this.live.get(childId) + if (child && childId !== id) { + this.report(childId, child, {}) + } + } + } + + private removeOwnerIndex(id: string): void { + const parent = this.live.get(id)?.parentTaskId + if (parent === undefined) { + return + } + const children = this.childrenByOwner.get(parent) + children?.delete(id) + if (children?.size === 0) { + this.childrenByOwner.delete(parent) + } } /** Read waiting state at drain so all queued live edges agree. */ @@ -274,7 +280,7 @@ export class ClaudeChildWorkDecoder { this.pending.push((observedAt) => ({ type: 'live', observedAt, - child: observation(id, task, facts, observedAt, this.waiting.has(id)), + child: claudeChildWorkObservation(id, task, facts, observedAt, this.waiting.has(id)), ...(restart ? { restart: true } : {}) })) } @@ -290,6 +296,8 @@ export class ClaudeChildWorkDecoder { ownerOf?: ClaudeToolOwner ): void { const task = this.live.get(id) + this.taskInvocations.settle(id, status, reported.runId ?? task?.toolUseId) + this.removeOwnerIndex(id) this.live.delete(id) const outcome = claudeChildWorkOutcome(status) this.ended.set(id, { @@ -300,7 +308,7 @@ export class ClaudeChildWorkDecoder { }) // Every ending but a stop notifies, which wakes the subagent that launched the task. if (outcome !== 'cancelled') { - this.wakeOwner(reported.runId ?? task?.toolUseId, ownerOf) + this.wakeOwner(reported.runId ?? task?.toolUseId, ownerOf, task?.parentTaskId) } this.pending.push((observedAt) => ({ type: 'ended', @@ -316,10 +324,3 @@ export class ClaudeChildWorkDecoder { })) } } - -/** Releases predating `task_type` still name an agent in `subagent_type`. */ -function startedTaskKind(message: Record): AgentChildWorkKind { - return isClaudeSubagentTask(message) - ? 'agent' - : classifyClaudeBackgroundTaskKind(message.task_type) -} diff --git a/src/main/claude/claude-child-work-evidence.ts b/src/main/claude/claude-child-work-evidence.ts index 33ff92e4267..7110cd50489 100644 --- a/src/main/claude/claude-child-work-evidence.ts +++ b/src/main/claude/claude-child-work-evidence.ts @@ -1,4 +1,9 @@ -import type { AgentChildWorkOutcome } from '../../shared/agent-status/child-work/agent-status-child-work' +import { ORCA_ORCHESTRATION_WAIT_COMMAND } from '../../shared/orca-cli-invocations' +import type { + AgentChildWorkKind, + AgentChildWorkOperation, + AgentChildWorkOutcome +} from '../../shared/agent-status/child-work/agent-status-child-work' import type { AgentChildWorkEvidence, AgentChildWorkLiveObservation @@ -17,6 +22,62 @@ import { type ClaudeToolUse } from './structured/journal/claude-structured-item-translation' +export type EndedClaudeTask = { + run: string | undefined + outcome: AgentChildWorkOutcome + task?: DecodedClaudeTask +} + +export type DecodedClaudeTask = { + kind: AgentChildWorkKind + backgrounded: boolean + /** Monitors stay monitoring until the provider reports them running. */ + running: boolean + name?: string + description?: string + toolUseId?: string + parentTaskId?: string + /** Runs `orca orchestration check --wait`: waiting on worker agents, so agent work. */ + awaitsAgents?: true +} + +export function claudeChildWorkObservation( + id: string, + task: DecodedClaudeTask, + facts: ClaudeTaskFacts, + observedAt: number, + waiting: boolean +): AgentChildWorkLiveObservation { + const operation: AgentChildWorkOperation | undefined = facts.toolName + ? { toolName: facts.toolName, basis: 'reported', observedAt } + : task.awaitsAgents + ? { + toolName: 'Bash', + input: ORCA_ORCHESTRATION_WAIT_COMMAND, + basis: 'reported', + observedAt + } + : undefined + return { + handle: { + idKind: 'task_id', + id, + ...(task.toolUseId !== undefined ? { runId: task.toolUseId } : {}) + }, + ...(task.parentTaskId ? { ownerId: task.parentTaskId } : {}), + kind: task.kind, + residency: task.backgrounded ? 'background' : 'foreground', + state: waiting ? 'waiting' : task.running || task.kind !== 'monitor' ? 'working' : 'monitoring', + ...(task.name ? { name: task.name, agentType: task.name } : {}), + ...(task.description ? { description: task.description } : {}), + ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), + ...(operation ? { operation } : {}), + ...(facts.lastMessage ? { lastMessage: facts.lastMessage } : {}), + // Only background tasks expose a stop target. + stoppable: task.backgrounded + } +} + export type ClaudeTaskFacts = { toolName?: string lastMessage?: string @@ -57,6 +118,9 @@ export function withClaudeChildWorkOwners( return evidence } const owned = (child: AgentChildWorkLiveObservation): AgentChildWorkLiveObservation => { + if (child.ownerId !== undefined) { + return child + } const ownerId = child.handle.runId === undefined ? null : ownerOf(child.handle.runId) return ownerId !== null && ownerId !== child.handle.id ? { ...child, ownerId } : child } diff --git a/src/main/claude/claude-child-work-roster-performance.test.ts b/src/main/claude/claude-child-work-roster-performance.test.ts new file mode 100644 index 00000000000..36f68abfa97 --- /dev/null +++ b/src/main/claude/claude-child-work-roster-performance.test.ts @@ -0,0 +1,89 @@ +import { expect, it } from 'vitest' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' +import { createAgentStatusStore } from '../../shared/agent-status/store/agent-status-store' +import { createAgentChildWorkAdmission } from '../../shared/agent-status/child-work/agent-status-child-work-admission' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status/child-work/agent-status-child-work-reconciliation' +import { parent } from './claude-child-work-producer-harness.test-fixture' + +it('emits only new or changed roster facts at10 and100 live tasks', () => { + for (const count of [10, 100]) { + const decoder = new ClaudeChildWorkDecoder() + const store = createAgentStatusStore({ epoch: 'roster-perf', mode: 'authority' }) + store.applyMutation({ parent: { subject: parent } }) + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + let clock = 1_700_000_000_000 + let rosterEdges = 0 + let rosterWrites = 0 + const feed = (message: Record, rosterBound = 0) => { + decoder.observe({ type: 'system', ...message }) + const evidence = decoder.drain(++clock) + const before = store.getSnapshot().revision + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + const writes = store.getSnapshot().revision - before + if (message.subtype === 'background_tasks_changed') { + const live = evidence.filter((edge) => edge.type === 'live').length + expect(live).toBeLessThanOrEqual(rosterBound) + expect(writes).toBeLessThanOrEqual(rosterBound) + rosterEdges += live + rosterWrites += writes + } + } + const owner = { + task_id: 'owner', + task_type: 'local_agent', + tool_use_id: 'tool-owner', + is_backgrounded: true + } + feed({ subtype: 'task_started', ...owner }) + let shellOwner = 'owner' + const shell = (id: number) => ({ + task_id: `shell-${id}`, + task_type: 'local_bash', + description: `shell${id}`, + parent_task_id: shellOwner + }) + const live: number[] = [] + for (let id = 0; id < count; id++) { + live.push(id) + const roster = { subtype: 'background_tasks_changed', tasks: [owner, ...live.map(shell)] } + // Only one task is newly present; unchanged replacement rosters need no host writes. + feed(roster, 1) + feed({ + subtype: 'task_started', + ...shell(id), + tool_use_id: `tool-${id}`, + is_backgrounded: true + }) + feed(roster) + } + // The introductions above need at most one edge/write each, independent of full-roster size. + expect(rosterEdges).toBeLessThanOrEqual(count) + expect(rosterWrites).toBeLessThanOrEqual(count) + rosterEdges = 0 + rosterWrites = 0 + feed({ subtype: 'task_started', ...owner, task_id: 'owner-second', tool_use_id: 'tool-second' }) + shellOwner = 'owner-second' + // A real owner change invalidates exactly the affected children, then its duplicate invalidates none. + feed({ subtype: 'background_tasks_changed', tasks: live.map(shell) }, count) + feed({ subtype: 'background_tasks_changed', tasks: live.map(shell) }) + for (let id = 0; id < count; id++) { + feed({ + subtype: 'task_notification', + task_id: `shell-${id}`, + tool_use_id: `tool-${id}`, + status: 'completed' + }) + live.shift() + feed({ subtype: 'background_tasks_changed', tasks: [owner, ...live.map(shell)] }) + } + // N introductions require at most N live edges and N store writes, independent of roster size. + expect(rosterEdges).toBeLessThanOrEqual(count) + expect(rosterWrites).toBeLessThanOrEqual(count) + expect(store.getChildren(parent).filter((child) => child.kind === 'command')).toHaveLength( + count + ) + } +}) diff --git a/src/main/claude/claude-journal-bound-delivery.test.ts b/src/main/claude/claude-journal-bound-delivery.test.ts new file mode 100644 index 00000000000..6c14507c9cf --- /dev/null +++ b/src/main/claude/claude-journal-bound-delivery.test.ts @@ -0,0 +1,112 @@ +import { expect, it, vi } from 'vitest' +import { ClaudeJournalBoundDelivery } from './claude-journal-bound-delivery' +import { bindClaudeJournalReadingControl } from './structured/journal/claude-structured-session-journal-control' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionReadingControl +} from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink' + +function unboundSink() { + let notify: (() => void) | undefined + let control: StructuredAgentSessionReadingControl | undefined + let bound = false + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + onJournalBound: (listener) => { + notify = listener + return () => { + notify = undefined + } + }, + journalLinkage: () => + bound + ? { + epoch: 'test', + visitItemsWithLinkage: () => {}, + threadGoal: () => null, + threadGoalSource: () => ({ goal: null, row: null }) + } + : null, + bindReadingControl: (next) => { + control = next + return () => {} + } + } + return { + sink, + bind: () => { + bound = true + notify?.() + }, + pressure: (paused: boolean) => control?.[paused ? 'pauseReading' : 'resumeReading']() + } +} + +it('holds ordered interpretation until binding while preserving independent sink pressure', () => { + const state = unboundSink() + let published = false + const delivery = new ClaudeJournalBoundDelivery(state.sink, () => published) + const connection = { pauseReading: vi.fn(), resumeReading: vi.fn() } + bindClaudeJournalReadingControl(state.sink, connection, null, delivery) + const observed: string[] = [] + delivery.deliver(() => { + observed.push('start') + delivery.deliver(() => observed.push('later')) + }) + delivery.deliver(() => observed.push('terminal')) + published = true + delivery.flush() + state.pressure(true) + expect(observed).toEqual([]) + state.bind() + expect(observed).toEqual(['start', 'terminal', 'later']) + expect(connection.resumeReading).not.toHaveBeenCalled() + state.pressure(false) + expect(connection.resumeReading).toHaveBeenCalledOnce() +}) + +it('terminates the uncommitted prefix on disposal and ignores a late binding', () => { + const state = unboundSink() + const delivery = new ClaudeJournalBoundDelivery(state.sink) + const interpret = vi.fn() + delivery.deliver(interpret) + delivery.dispose() + state.bind() + delivery.deliver(interpret) + expect(interpret).not.toHaveBeenCalled() +}) + +it('fails a bounded unbound prefix instead of silently losing or replaying observations', () => { + const state = unboundSink() + const delivery = new ClaudeJournalBoundDelivery(state.sink) + const failure = vi.fn() + const interpret = vi.fn() + delivery.onFailure = failure + const payload = 'x'.repeat(1024 * 1024) + expect(() => { + for (let index = 0; index < 33; index++) { + delivery.deliver(interpret, payload) + } + }).toThrow('capacity exceeded') + state.bind() + expect(failure).toHaveBeenCalledOnce() + expect(interpret).not.toHaveBeenCalled() +}) + +it('reports binding interpretation failure through its owning failure path', () => { + const state = unboundSink() + const delivery = new ClaudeJournalBoundDelivery(state.sink) + const failure = vi.fn() + delivery.onFailure = failure + const error = new Error('invalid saved identity') + delivery.deliver(() => { + throw error + }) + const later = vi.fn() + delivery.deliver(later) + expect(() => state.bind()).not.toThrow() + expect(failure).toHaveBeenCalledWith(error) + expect(later).not.toHaveBeenCalled() +}) diff --git a/src/main/claude/claude-journal-bound-delivery.ts b/src/main/claude/claude-journal-bound-delivery.ts new file mode 100644 index 00000000000..f095cc7576d --- /dev/null +++ b/src/main/claude/claude-journal-bound-delivery.ts @@ -0,0 +1,135 @@ +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionReadingControl +} from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink' + +/** Own provider observations until their saved identity input is available. */ +export class ClaudeJournalBoundDelivery { + private pending: ({ run: () => void; bytes: number } | undefined)[] = [] + private entries = 0 + private bytes = 0 + private draining = false + private closed = false + private unsubscribe?: () => void + private control?: StructuredAgentSessionReadingControl + private paused = false + onFailure?: (error: Error) => void + + constructor( + private sink?: StructuredAgentSessionEventSink, + private readonly published: () => boolean = () => true + ) { + this.bind(sink) + } + + bind(sink: StructuredAgentSessionEventSink | undefined): void { + this.unsubscribe?.() + this.sink = sink + this.unsubscribe = sink?.onJournalBound?.(() => this.flush()) + } + + get ready(): boolean { + return ( + !this.closed && + this.published() && + (!this.sink?.onJournalBound || this.sink.journalLinkage?.() !== null) + ) + } + + bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { + this.control = control + if (this.paused) { + control.pauseReading() + } + return () => { + if (this.control === control) { + this.control = undefined + } + } + } + + deliver(run: () => void, payload?: unknown): void { + if (this.closed) { + return + } + if (this.ready && !this.draining) { + run() + return + } + const bytes = Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') + 64 + if (this.entries >= 1_024 || this.bytes + bytes > 32 * 1024 * 1024) { + const error = new Error('Claude event delivery capacity exceeded before journal binding') + this.dispose() + this.onFailure?.(error) + throw error + } + this.pending.push({ run, bytes }) + this.entries++ + this.bytes += bytes + this.updateReading() + } + + interpret(event: T, handle: (event: T) => void): void { + if (event.type === 'ended' || event.type === 'retiring') { + this.dispose() + handle(event) + } else if (this.ready) { + handle(event) + } else { + this.deliver(() => handle(event), event) + } + } + + flush(): void { + if (!this.ready || this.draining) { + this.updateReading() + return + } + this.draining = true + try { + while (this.pending.length && !this.closed) { + const prefix = this.pending + this.pending = [] + for (let index = 0; index < prefix.length; index++) { + if (this.closed) { + break + } + const entry = prefix[index]! + prefix[index] = undefined + this.entries-- + this.bytes -= entry.bytes + entry.run() + } + } + } catch (cause) { + this.dispose() + const error = cause instanceof Error ? cause : new Error(String(cause)) + if (!this.onFailure) { + throw error + } + this.onFailure(error) + } finally { + this.draining = false + this.updateReading() + } + } + + /** Retirement ends uncommitted delivery; it must never replay into a disposed translator. */ + dispose(): void { + this.closed = true + this.pending = [] + this.entries = 0 + this.bytes = 0 + this.unsubscribe?.() + this.unsubscribe = undefined + this.updateReading() + } + + private updateReading(): void { + const paused = !this.closed && this.published() && !this.ready + if (paused !== this.paused) { + this.paused = paused + this.control?.[paused ? 'pauseReading' : 'resumeReading']() + } + } +} diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 96af6b7a240..21f4057ca02 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -1,3 +1,5 @@ +import type { ClaudeTaskInvocations } from './claude-task-invocations' +import type { ClaudeJournalBoundDelivery } from './claude-journal-bound-delivery' import type { AgentSessionContextReport } from '../../shared/agent-session/agent-session-context-usage' import type { AgentJournalTurnScope } from '../../shared/agent-session/journal/agent-session-journal-turn-scope' import type { @@ -16,6 +18,7 @@ import type { AgentSessionAccountKind } from '../../shared/agent-session/agent-s export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink + delivery?: ClaudeJournalBoundDelivery bindPromptItemId?: (journalItemId: string, promptKey: string) => void coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] @@ -29,6 +32,7 @@ export type ClaudeJournalTranslatorDeps = { } export type ClaudeJournalTranslator = { + readonly taskInvocations: ClaudeTaskInvocations handle: (event: ClaudeStructuredSessionEvent) => void journalPrompts: Pick< ClaudeJournalPrompts, @@ -45,6 +49,7 @@ export type ClaudeJournalTranslator = { readonly openTurnInLiveProviderCycle: boolean readonly openTurnHasInputWork: boolean flush: () => void + childTaskParent?: (taskId: string, run?: string) => string | undefined childToolOwner?: ClaudeChildToolQueries['childToolOwner'] childActivity?: ClaudeChildToolQueries['childActivity'] retryPendingTaskRows?: () => StructuredAgentSessionSinkAdmission diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 86c36ee9394..b25929a818a 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -196,7 +196,10 @@ export function journalClaudeMessage( tool.name === 'Monitor' && ctx.toolOrigins.has(result.toolUseId) ) { - ctx.backgroundTasks.observeMonitorToolResult(claudeRecord(message.tool_use_result)?.taskId) + ctx.backgroundTasks.observeMonitorToolResult( + claudeRecord(message.tool_use_result)?.taskId, + result.toolUseId + ) } ctx.tools.delete(result.toolUseId) changed = true diff --git a/src/main/claude/claude-parent-task-frames.test-fixture.ts b/src/main/claude/claude-parent-task-frames.test-fixture.ts new file mode 100644 index 00000000000..a90897291da --- /dev/null +++ b/src/main/claude/claude-parent-task-frames.test-fixture.ts @@ -0,0 +1,11 @@ +// CLI 2.1.319, isolated real run: parent Stop, live child roster, then separate child Stop; IDs/paths sanitized. +import { readFileSync } from 'node:fs' +import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' + +export const PARENT_TASK_STOP: CapturedFrame[] = readFileSync( + new URL('./__fixtures__/claude-parent-task-stop-2.1.319.jsonl', import.meta.url), + 'utf8' +) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) diff --git a/src/main/claude/claude-parent-task-recovery.test.ts b/src/main/claude/claude-parent-task-recovery.test.ts new file mode 100644 index 00000000000..c5812f98b32 --- /dev/null +++ b/src/main/claude/claude-parent-task-recovery.test.ts @@ -0,0 +1,785 @@ +import { afterEach, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + producer, + frame, + system, + toolUse, + toolResult +} from './claude-child-work-producer-harness.test-fixture' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/store-lifecycle/journal-host-database-test-support' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/host/testing/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' +import { + claudeBackgroundTaskBody, + claudeBackgroundTaskIdentity +} from './claude-background-task-row-journal' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session/journal/agent-session-journal-types' +import { + adapterAtPublishFor, + fakeClaude, + identityFor +} from './structured/claude-structured-session-test-support' +import type { AgentChildWorkEvidence } from '../../shared/agent-status/child-work/agent-status-child-work-evidence' +import { ClaudeSubagentIdentities } from './claude-subagent-identities' +import { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities' + +const journals = createTrackedJournalOpener() +const cleanup: string[] = [] +afterEach(async () => { + await journals.closeAll() + for (const path of cleanup.splice(0)) { + await rm(path, { recursive: true, force: true }) + } +}) + +it('retains explicit ambient and skip-transcript exclusion when roster omits those optional flags', async () => { + for (const flag of ['ambient', 'skip_transcript']) { + const run = await producer() + run.send( + system('task_started', { + task_id: 'excluded', + task_type: 'local_bash', + parent_task_id: 'owner', + is_backgrounded: true, + tool_use_id: 'tool-old', + [flag]: true + }) + ) + run.send( + system('background_tasks_changed', { + tasks: [{ task_id: 'excluded', task_type: 'local_bash', parent_task_id: 'owner' }] + }) + ) + run.send( + system('task_updated', { + task_id: 'excluded', + patch: { status: 'running', is_backgrounded: true, description: 'Excluded shell' } + }) + ) + expect.soft(run.records(), flag).toEqual([]) + expect + .soft( + [...run.journalItems.values()].filter( + (row) => + row.body.kind === 'message' && + row.body.blocks.some((block) => block.type === 'background-task') + ), + flag + ) + .toEqual([]) + run.send( + system('task_notification', { + task_id: 'excluded', + tool_use_id: 'tool-old', + status: 'stopped' + }) + ) + run.send(toolUse('tool-new', 'Bash', { command: 'sleep 1', run_in_background: true })) + run.send( + system('task_started', { + task_id: 'excluded', + task_type: 'local_bash', + tool_use_id: 'tool-new', + is_backgrounded: true + }) + ) + expect(run.records().find((row) => row.kind === 'command')).toMatchObject({ + membership: 'live' + }) + run.send( + system('task_progress', { + task_id: 'excluded', + tool_use_id: 'tool-pending', + parent_task_id: 'pending-owner', + [flag]: true + }) + ) + run.send( + system('task_notification', { + task_id: 'excluded', + tool_use_id: 'tool-new', + status: 'stopped' + }) + ) + run.send( + system('task_started', { + task_id: 'excluded', + task_type: 'local_bash', + tool_use_id: 'tool-pending', + is_backgrounded: true + }) + ) + expect(run.records().filter((row) => row.membership === 'live')).toEqual([]) + expect( + [...run.journalItems.values()].some( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.parentToolUseId === 'tool-pending' + ) + ) + ).toBe(false) + } +}) + +it('uses the matching durable generation ownership when an earlier run receives a terminal revision', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-parent-review-codex-')) + cleanup.push(root) + const journal = await journals.open({ + identity: { + sessionId: 'orca-session', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle('claude-session', null) + }, + now: () => 9000, + stateDirectory: root + }) + for (const [generation, tool, owner] of [ + [1, 'tool-first', 'owner-first'], + [2, 'tool-second', 'owner-second'], + [3, 'tool-root', undefined] + ] as const) { + await journal.appendItem( + claudeBackgroundTaskIdentity('reused', generation), + claudeBackgroundTaskBody({ + type: 'background-task', + taskId: 'reused', + kind: 'command', + label: 'sleep', + parentToolUseId: tool, + state: 'unverifiable', + startedAt: 1 + }), + { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + ...(owner + ? { + agentId: 'reused', + parentAgentId: owner, + producerKind: 'background' as const, + providerParentRef: tool + } + : {}) + } + ) + } + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + deferred.bind({ journal, fence: 1, publish: () => {} }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'system', + subtype: 'task_notification', + task_id: 'reused', + tool_use_id: 'tool-first', + session_id: 'claude-session', + uuid: 'historical-owned-completion', + status: 'completed' + } + }) + expect(await deferred.drained()).toEqual({ ok: true }) + expect(journal.runningTurn()).toBeNull() + const prior = journal + .snapshot() + .items.find( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.parentToolUseId === 'tool-first' + ) + ) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'system', + subtype: 'task_notification', + task_id: 'reused', + status: 'completed' + } + }) + expect(await deferred.drained()).toEqual({ ok: true }) + const rootRow = journal + .snapshot() + .items.find( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.parentToolUseId === 'tool-root' + ) + ) + expect(rootRow?.agentId).toBeUndefined() + expect(rootRow?.body.kind === 'message' ? rootRow.body.blocks : []).toContainEqual( + expect.objectContaining({ taskId: 'reused', parentToolUseId: 'tool-root', state: 'done' }) + ) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: system('task_started', { + task_id: 'reused', + task_type: 'local_bash', + tool_use_id: 'tool-first', + parent_task_id: 'owner-first', + is_backgrounded: true + }) + }) + expect(await deferred.drained()).toEqual({ ok: true }) + const oldBody = journal + .snapshot() + .items.find((row) => row.providerParentRef === 'tool-first')?.body + expect(oldBody?.kind === 'message' ? oldBody.blocks : []).toContainEqual( + expect.objectContaining({ parentToolUseId: 'tool-first', state: 'done' }) + ) + translator.dispose() + deferred.close() + const queued = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + const pending = createClaudeJournalTranslator({ sink: queued.sink, coalesceMs: 0 }) + const feed = (subtype: string, fields: Record) => + pending.handle({ + type: 'message', + sessionId: 'orca-session', + message: system(subtype, fields) + }) + feed('task_started', { + task_id: 'queued', + task_type: 'local_bash', + tool_use_id: 'queued-old', + parent_task_id: 'owner-first', + is_backgrounded: true + }) + feed('task_notification', { task_id: 'queued', tool_use_id: 'queued-old', status: 'stopped' }) + feed('task_progress', { + task_id: 'queued', + tool_use_id: 'queued-new', + description: 'New operation before announcement' + }) + feed('task_started', { + task_id: 'queued', + task_type: 'local_bash', + tool_use_id: 'queued-new', + parent_task_id: 'owner-second', + is_backgrounded: true + }) + feed('task_updated', { + task_id: 'queued', + tool_use_id: 'queued-old', + patch: { status: 'completed' } + }) + feed('task_progress', { + task_id: 'queued', + tool_use_id: 'queued-old', + description: 'Old operation' + }) + queued.bind({ journal, fence: 1, publish: () => {} }) + expect(await queued.drained()).toEqual({ ok: true }) + const queuedRows = journal + .snapshot() + .items.filter( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.taskId === 'queued' + ) + ) + expect(queuedRows).toHaveLength(2) + const current = queuedRows.find((row) => row.providerParentRef === 'queued-new') + expect(current).toMatchObject({ parentAgentId: 'owner-second' }) + expect(current?.body.kind === 'message' ? current.body.blocks : []).toContainEqual( + expect.objectContaining({ parentToolUseId: 'queued-new', state: 'working' }) + ) + pending.dispose() + queued.close() + expect(prior).toMatchObject({ parentAgentId: 'owner-first', providerParentRef: 'tool-first' }) +}) + +it('advances a journaled nested agent attempt rather than replacing it with the parent placeholder', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-nested-parent-recovery-')) + cleanup.push(root) + const journal = await journals.open({ + identity: { + sessionId: 'orca-session', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle('claude-session', null) + }, + now: () => 9000, + stateDirectory: root + }) + await journal.appendItem( + { provider: 'claude', sessionId: 'claude-session', uuid: 'saved-attempt' }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'previous attempt' }] }, + { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + agentId: 'nested', + parentAgentId: 'owner', + producerKind: 'agent', + providerParentRef: 'tool-second', + attempt: 2 + } + ) + const journaled = new ClaudeJournaledSubagents(() => journal) + const identities = new ClaudeSubagentIdentities({ journaled }) + identities.observeSystemFrame({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [{ task_id: 'nested', task_type: 'local_agent', parent_task_id: 'owner' }] + }) + identities.observeSystemFrame({ + type: 'system', + subtype: 'task_started', + task_id: 'nested', + tool_use_id: 'tool-third', + task_type: 'local_agent', + parent_task_id: 'owner' + }) + expect(identities.linkage.settledLinkageFor('tool-third')).toMatchObject({ + kind: 'linked', + linkage: { attempt: 3 } + }) +}) + +it.each(['notification', 'killed', 'failed'])( + 'keeps distinct shell invocations isolated after %s', + async (ending) => { + const run = await producer() + run.send( + system('task_started', { + task_id: 'owner', + task_type: 'local_agent', + tool_use_id: 'spawn-owner', + is_backgrounded: true + }) + ) + run.send( + system('task_started', { + task_id: 'reused', + task_type: 'local_bash', + tool_use_id: 'tool-first', + parent_task_id: 'owner', + is_backgrounded: true + }) + ) + run.send( + ending === 'notification' + ? system('task_notification', { + task_id: 'reused', + tool_use_id: 'tool-first', + status: 'stopped' + }) + : system('task_updated', { task_id: 'reused', patch: { status: ending } }) + ) + run.send( + system('task_progress', { + task_id: 'reused', + tool_use_id: 'tool-root', + description: 'New operation before announcement' + }) + ) + expect(run.records().find((row) => row.kind === 'command')).toMatchObject({ + membership: 'settled', + stoppable: false + }) + expect( + [...run.journalItems.values()].filter( + (row) => + row.body.kind === 'message' && + row.body.blocks.some((block) => block.type === 'background-task') + ) + ).toHaveLength(1) + run.send(toolUse('tool-root', 'Bash', { command: 'sleep 1', run_in_background: true })) + run.send( + system('task_started', { + task_id: 'reused', + task_type: 'local_bash', + tool_use_id: 'tool-root', + is_backgrounded: true + }) + ) + run.send( + system('task_updated', { + task_id: 'reused', + tool_use_id: 'tool-first', + patch: { status: 'completed' } + }) + ) + run.send( + system('task_progress', { + task_id: 'reused', + tool_use_id: 'tool-first', + description: 'Old operation' + }) + ) + const newer = [...run.journalItems.values()].find( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.parentToolUseId === 'tool-root' + ) + ) + expect.soft(newer).toBeDefined() + expect.soft(newer?.agentId).toBeUndefined() + const owner = run.records().find((row) => row.kind === 'agent') + const child = run.records().find((row) => row.kind === 'command') + expect(child?.parentChildWorkId).not.toBe(owner?.childWorkId) + expect(child).toMatchObject({ membership: 'live', stoppable: true }) + expect(JSON.stringify(child)).not.toContain('Old operation') + expect(newer?.body.kind === 'message' ? newer.body.blocks : []).toContainEqual( + expect.objectContaining({ parentToolUseId: 'tool-root', state: 'working' }) + ) + } +) + +it.each([false, true])( + 'keeps Monitor invocation reports out of the transcript, reused ID: %s', + async (reused) => { + const run = await producer() + if (reused) { + run.send(toolUse('tool-old', 'Bash', { command: 'sleep 1', run_in_background: true })) + run.send( + system('task_started', { + task_id: 'monitor-task', + task_type: 'local_bash', + tool_use_id: 'tool-old', + is_backgrounded: true + }) + ) + run.send( + system('task_notification', { + task_id: 'monitor-task', + tool_use_id: 'tool-old', + status: 'completed' + }) + ) + } + run.send(toolUse('tool-monitor', 'Monitor', { command: 'watch' })) + run.send({ + ...toolResult('tool-monitor', 'Started'), + tool_use_result: { taskId: 'monitor-task' } + }) + run.send( + system('task_started', { + task_id: 'monitor-task', + task_type: 'local_bash', + tool_use_id: 'tool-monitor', + is_backgrounded: true + }) + ) + run.send( + system('background_tasks_changed', { + tasks: [{ task_id: 'monitor-task', task_type: 'local_bash', parent_task_id: 'owner' }] + }) + ) + expect( + [...run.journalItems.values()].filter( + (row) => + row.body.kind === 'message' && + row.body.blocks.some((block) => block.type === 'background-task') + ) + ).toHaveLength(reused ? 1 : 0) + expect(run.records().some((row) => row.membership === 'live')).toBe(true) + } +) + +it('retains pending ownership and terminal evidence without granting a partial frame live authority', async () => { + const run = await producer() + run.send( + system('task_started', { + task_id: 'reused', + task_type: 'local_bash', + tool_use_id: 'tool-old', + is_backgrounded: true + }) + ) + run.send( + system('task_progress', { + task_id: 'reused', + tool_use_id: 'tool-owned', + parent_task_id: 'owner' + }) + ) + run.send( + system('task_notification', { + task_id: 'reused', + tool_use_id: 'tool-old', + status: 'stopped' + }) + ) + run.send( + system('task_started', { + task_id: 'reused', + task_type: 'local_bash', + tool_use_id: 'tool-owned', + is_backgrounded: true + }) + ) + expect(run.records().find((row) => row.kind === 'command')).toMatchObject({ + membership: 'live', + stoppable: true + }) + expect( + [...run.journalItems.values()].find( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.parentToolUseId === 'tool-owned' + ) + ) + ).toMatchObject({ agentId: 'reused' }) + run.send( + system('task_notification', { + task_id: 'reused', + tool_use_id: 'tool-stopped-before-start', + status: 'stopped' + }) + ) + run.send( + system('task_notification', { + task_id: 'reused', + tool_use_id: 'tool-owned', + status: 'stopped' + }) + ) + run.send( + system('task_started', { + task_id: 'reused', + task_type: 'local_bash', + tool_use_id: 'tool-stopped-before-start', + is_backgrounded: true + }) + ) + expect(run.records().filter((row) => row.membership === 'live')).toEqual([]) + const stoppedBody = [...run.journalItems.values()].find( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => + block.type === 'background-task' && block.parentToolUseId === 'tool-stopped-before-start' + ) + )?.body + expect(stoppedBody?.kind === 'message' ? stoppedBody.blocks : []).toContainEqual( + expect.objectContaining({ parentToolUseId: 'tool-stopped-before-start', state: 'idle' }) + ) +}) + +it.each(['alias', 'no-alias', 'partial-alias', 'known-terminal'])( + 'admits a resumed agent only when the terminal did not identify that invocation: %s', + async (shape) => { + const run = await producer() + run.send( + system('task_notification', { + task_id: 'owner', + status: 'stopped', + ...(shape === 'known-terminal' ? { tool_use_id: 'spawn-owner' } : {}) + }) + ) + if (shape === 'partial-alias') { + run.send(system('task_progress', { task_id: 'owner', tool_use_id: 'spawn-owner' })) + } + run.send( + system('task_started', { + task_id: 'owner', + task_type: 'local_agent', + is_backgrounded: true, + ...(shape === 'no-alias' ? {} : { tool_use_id: 'spawn-owner' }) + }) + ) + run.send( + system('task_started', { + task_id: 'shell', + task_type: 'local_bash', + tool_use_id: 'shell-tool', + parent_task_id: 'owner', + is_backgrounded: true + }) + ) + const owner = run.records().find((row) => row.kind === 'agent') + const shell = run.records().find((row) => row.kind === 'command') + if (shape === 'known-terminal') { + expect(owner?.membership).not.toBe('live') + } else { + expect(owner).toMatchObject({ membership: 'live', stoppable: true }) + expect(shell).toMatchObject({ + membership: 'live', + stoppable: true, + parentChildWorkId: owner?.childWorkId + }) + } + } +) + +it('releases an alias-less roster against reopened ownership on binding without another provider frame', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-parent-binding-')) + cleanup.push(root) + const identity = identityFor() + const open = () => journals.open({ identity, now: () => 9000, stateDirectory: root }) + let journal = await open() + for (const id of ['saved-shell', 'saved-only']) { + await journal.appendItem( + claudeBackgroundTaskIdentity(id, 1), + claudeBackgroundTaskBody({ + type: 'background-task', + taskId: id, + kind: 'command', + label: 'sleep', + parentToolUseId: 'saved-tool', + state: 'unverifiable', + startedAt: 1 + }), + { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + agentId: id, + parentAgentId: 'saved-owner', + producerKind: 'background', + providerParentRef: 'saved-tool' + } + ) + } + const originalId = journal.snapshot().items[0]!.itemId + await journals.closeAll() + journal = await open() + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + deferred.sink.appendItem( + { provider: 'claude', sessionId: 'saved-session', uuid: 'before-bind' }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Earlier queued write' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const claude = fakeClaude() + const evidence: AgentChildWorkEvidence[] = [] + const adapter = adapterAtPublishFor( + claude, + {}, + [], + [], + undefined, + undefined, + (_sessionId, batch) => evidence.push(...batch) + ) + await adapter.acquire({ identity, fence: 1, spawnToken: 'binding-test', events: deferred.sink }) + const roster = () => + claude.connections[0]!.handlers.onMessage?.( + system('background_tasks_changed', { + tasks: [{ task_id: 'saved-shell', task_type: 'local_bash' }] + }) + ) + roster() + claude.connections[0]!.handlers.onMessage?.( + frame({ + type: 'assistant', + message: { + id: 'held-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Later provider write' }] + } + }) + ) + expect(evidence.filter((edge) => edge.type === 'live')).toEqual([]) + deferred.bind({ journal, fence: 1, publish: () => {} }) + expect(await deferred.drained()).toEqual({ ok: true }) + expect(evidence).toContainEqual( + expect.objectContaining({ + type: 'live', + child: expect.objectContaining({ + ownerId: 'saved-owner', + stoppable: true + }) + }) + ) + const liveIds = evidence + .filter((edge) => edge.type === 'live') + .map((edge) => edge.child.handle.id) + expect(liveIds).toContain('saved-shell') + expect(liveIds).not.toContain('saved-only') + const queuedMessages = journal + .snapshot() + .items.filter( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => + block.type === 'text' && + (block.text === 'Earlier queued write' || block.text === 'Later provider write') + ) + ) + expect(queuedMessages.map((row) => row.body)).toMatchObject([ + { kind: 'message', blocks: [{ type: 'text', text: 'Earlier queued write' }] }, + { kind: 'message', blocks: [{ type: 'text', text: 'Later provider write' }] } + ]) + expect(queuedMessages[0]!.sequence).toBeLessThan(queuedMessages[1]!.sequence) + expect(journal.item(originalId)).toMatchObject({ + agentId: 'saved-shell', + parentAgentId: 'saved-owner', + providerParentRef: 'saved-tool' + }) + expect(journal.item(originalId)?.body).toMatchObject({ + kind: 'message', + blocks: expect.arrayContaining([ + expect.objectContaining({ + taskId: 'saved-shell', + parentToolUseId: 'saved-tool', + state: 'working' + }) + ]) + }) + roster() + expect(await deferred.drained()).toEqual({ ok: true }) + expect( + journal + .snapshot() + .items.filter( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.taskId === 'saved-shell' + ) + ) + ).toHaveLength(1) + expect(journal.snapshot().items.find((row) => row.agentId === 'saved-only')?.body).toMatchObject({ + kind: 'message', + blocks: expect.arrayContaining([expect.objectContaining({ state: 'unverifiable' })]) + }) + const retired = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + const replacement = adapterAtPublishFor(claude) + await replacement.acquire({ + identity, + fence: 1, + spawnToken: 'retired-test', + events: retired.sink + }) + claude.connections[1]!.handlers.onMessage?.( + system('task_started', { + task_id: 'retired-shell', + task_type: 'local_bash', + is_backgrounded: true + }) + ) + await replacement.releaseAcquisition({ sessionId: identity.sessionId }) + retired.bind({ journal, fence: 1, publish: () => {} }) + expect(await retired.drained()).toEqual({ ok: true }) + expect( + journal + .snapshot() + .items.some( + (row) => + row.body.kind === 'message' && + row.body.blocks.some( + (block) => block.type === 'background-task' && block.taskId === 'retired-shell' + ) + ) + ).toBe(false) + retired.close() + await adapter.releaseAcquisition({ sessionId: identity.sessionId }) + deferred.close() +}) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 17b63de60f7..95c7ec08f6a 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -147,6 +147,20 @@ function processState(pid: number): 'running' | 'exited' { } describe('Claude stream-json connection', () => { + it('completes task Stop through the real SDK while semantic reading is paused', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open(launchFor(scenario)) + await connection.initializationResult() + connection.pauseReading?.() + await connection.stopTask('owned-shell') + expect(scenario.readReport().controlRequests).toContainEqual( + expect.objectContaining({ + request: expect.objectContaining({ subtype: 'stop_task', task_id: 'owned-shell' }) + }) + ) + connection.resumeReading?.() + }) + it('settles a send only once the frame reached the child, and replays reach onMessage', async () => { const replay = { type: 'user', diff --git a/src/main/claude/claude-subagent-identities.ts b/src/main/claude/claude-subagent-identities.ts index 7cdd45d5c6c..ebafff0d1da 100644 --- a/src/main/claude/claude-subagent-identities.ts +++ b/src/main/claude/claude-subagent-identities.ts @@ -1,3 +1,4 @@ +import { ClaudeTaskInvocations } from './claude-task-invocations' import { BoundedMap } from '../../shared/bounded-map' import { ClaudeSubagentIds } from './claude-subagent-id-aliases' import type { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities' @@ -19,6 +20,7 @@ export type ClaudeSubagentIdentitiesDeps = { /** Resolve grandchildren through the child that journaled their spawn call. */ childOwnerRefOf?: (toolUseId: string) => string | null journaled?: ClaudeJournaledSubagents + taskInvocations?: ClaudeTaskInvocations } export class ClaudeSubagentIdentities { @@ -26,9 +28,11 @@ export class ClaudeSubagentIdentities { private readonly known = new BoundedMap({ maxEntries: MAX_KNOWN_SUBAGENTS }) + readonly taskInvocations: ClaudeTaskInvocations readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource constructor(private readonly deps: ClaudeSubagentIdentitiesDeps) { + this.taskInvocations = deps.taskInvocations ?? new ClaudeTaskInvocations(deps.journaled) this.ids = new ClaudeSubagentIds(deps.journaled?.canonical) this.linkage = new ClaudeSubagentLinkage({ ids: this.ids, @@ -39,11 +43,16 @@ export class ClaudeSubagentIdentities { }, isForwardedParentTool: deps.isForwardedParentTool, childOwnerRefOf: deps.childOwnerRefOf, + parentTaskOf: this.parentOf, spawnRefOf: (canonicalId) => this.known.get(canonicalId)?.run ?? null }) } + parentOf = (id: string, run?: string): string | undefined => + this.taskInvocations.parentOf(id, run) + observeSystemFrame(message: Record): boolean { + this.taskInvocations.observe(message) const frame = readClaudeSubagentTaskFrame(message) if (!frame) { return false @@ -62,7 +71,10 @@ export class ClaudeSubagentIdentities { if (frame.toolUseId) { this.ids.alias(frame.toolUseId, frame.taskId) } - if (frame.announcesSubagent) { + if ( + frame.announcesSubagent && + this.taskInvocations.selection(message, frame.taskId)?.acceptsStart !== false + ) { this.announce(frame.taskId, frame.toolUseId) } return true diff --git a/src/main/claude/claude-subagent-journaled-identities.ts b/src/main/claude/claude-subagent-journaled-identities.ts index 7094a9b49c5..82b51b7d0f3 100644 --- a/src/main/claude/claude-subagent-journaled-identities.ts +++ b/src/main/claude/claude-subagent-journaled-identities.ts @@ -1,3 +1,10 @@ +import { + indexClaudeBackgroundTask, + selectClaudeBackgroundTask, + type ClaudeBackgroundTaskIndex, + type ClaudeBackgroundTaskDescriptor +} from './claude-background-task-identity' +import { isBackgroundTaskBlock } from '../../shared/native-chat/native-chat-types' import type { AgentJournalProducerLinkage } from '../../shared/agent-session/journal/agent-session-journal-types' import { namedSubagents } from '../../shared/native-chat/transcript/native-chat-subagent-items' import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/journal-ingress/structured-agent-session-journal-views' @@ -6,6 +13,8 @@ import { isBoundedClaudeTaskId } from './claude-background-task-tracker' type Reading = { canonicalByToolUse: Map attemptByAgent: Map + parents: Map> + backgrounds: Map subagents: Set } @@ -23,6 +32,15 @@ export class ClaudeJournaledSubagents { /** Zero means no journaled attempt. */ attempt = (agentId: string): number => this.current()?.attemptByAgent.get(agentId) ?? 0 + parent = (taskId: string, run?: string): string | undefined => + (run === undefined + ? [...(this.current()?.parents.get(taskId)?.values() ?? [])].at(-1) + : this.current()?.parents.get(taskId)?.get(run) + )?.parentAgentId + + background = (taskId: string, run?: string): ClaudeBackgroundTaskDescriptor | undefined => + selectClaudeBackgroundTask(this.current()?.backgrounds.get(taskId), run) + isSubagent = (taskId: string): boolean => this.current()?.subagents.has(taskId) === true private current(): Reading | null { @@ -43,9 +61,24 @@ function read(journal: StructuredAgentSessionLinkageJournal): Reading { const reading: Reading = { canonicalByToolUse: new Map(), attemptByAgent: new Map(), + parents: new Map(), + backgrounds: new Map(), subagents: new Set() } - journal.visitItemsWithLinkage((_itemId, _sequence, body, linkage) => { + journal.visitItemsWithLinkage((itemId, _sequence, body, linkage) => { + if (linkage.agentId !== undefined && linkage.producerKind === 'agent') { + const parents = reading.parents.get(linkage.agentId) ?? new Map() + parents.delete(linkage.providerParentRef) + parents.set(linkage.providerParentRef, linkage) + reading.parents.set(linkage.agentId, parents) + } + if (body.kind === 'message') { + for (const block of body.blocks.filter(isBackgroundTaskBlock)) { + const index = reading.backgrounds.get(block.taskId) ?? { aliases: new Map() } + indexClaudeBackgroundTask(index, itemId, block.taskId, block.parentToolUseId, linkage) + reading.backgrounds.set(block.taskId, index) + } + } readAgentRow(reading, linkage) if (body.kind === 'message' && linkage.agentId === undefined) { for (const entry of body.blocks.flatMap(namedSubagents)) { diff --git a/src/main/claude/claude-subagent-linkage.ts b/src/main/claude/claude-subagent-linkage.ts index 8d6b221959d..ba8eed43498 100644 --- a/src/main/claude/claude-subagent-linkage.ts +++ b/src/main/claude/claude-subagent-linkage.ts @@ -31,6 +31,7 @@ export type ClaudeSubagentLinkageDeps = { isForwardedParentTool?: (toolUseId: string) => boolean /** Map sidechain tool calls to their child owner to identify grandchildren's parents. */ childOwnerRefOf?: (toolUseId: string) => string | null + parentTaskOf?: (canonicalId: string) => string | undefined spawnRefOf?: (canonicalId: string) => string | null } @@ -62,14 +63,16 @@ export class ClaudeSubagentLinkage linkageForAgent = (agentId: string): AgentJournalProducerLinkage => { const spawnRef = this.deps.spawnRefOf?.(agentId) ?? null if (spawnRef === null) { - return { agentId, producerKind: 'agent' } + const parentAgentId = this.deps.parentTaskOf?.(agentId) + return { agentId, producerKind: 'agent', ...(parentAgentId ? { parentAgentId } : {}) } } const { linkage } = this.settledLinkageFor(spawnRef) const { parentAgentId, ...rest } = linkage + const parent = this.deps.parentTaskOf?.(agentId) ?? parentAgentId return { ...rest, agentId, - ...(parentAgentId === undefined || parentAgentId === agentId ? {} : { parentAgentId }) + ...(parent === undefined || parent === agentId ? {} : { parentAgentId: parent }) } } @@ -125,6 +128,10 @@ export class ClaudeSubagentLinkage settled: boolean, depth: number ): ParentAgentVerdict { + const explicit = this.deps.parentTaskOf?.(this.deps.ids.canonical(parentToolUseId)) + if (explicit !== undefined) { + return { kind: 'known', agentId: explicit } + } const ownerRef = this.deps.childOwnerRefOf?.(parentToolUseId) ?? null if (ownerRef === null || depth >= MAX_PARENT_RESOLUTION_DEPTH) { return { kind: 'known' } diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index d2cc875ba42..049cc308d76 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -1,3 +1,5 @@ +import { PARENT_TASK_STOP } from './claude-parent-task-frames.test-fixture' +import { blockOf } from './claude-background-task-row-test-support' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -102,10 +104,13 @@ async function openJournal(): Promise { }) } -function acquire(journal: AgentSessionJournal) { +function acquire(journal: AgentSessionJournal, delayed = false) { const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) - deferred.bind({ journal, fence: 1, publish: () => {} }) + const bind = () => deferred.bind({ journal, fence: 1, publish: () => {} }) + if (!delayed) { + bind() + } const settle = async (): Promise => { await expect(deferred.drained()).resolves.toEqual({ ok: true }) } @@ -115,7 +120,7 @@ function acquire(journal: AgentSessionJournal) { translator.dispose() deferred.close() } - return { translator, settle, exit } + return { translator, settle, exit, bind } } const proseRow = (journal: AgentSessionJournal, text: string): AgentJournalRenderItem | undefined => @@ -128,6 +133,85 @@ const proseRow = (journal: AgentSessionJournal, text: string): AgentJournalRende ) describe('a Claude subagent resumed after its provider restarted', () => { + it.each(['terminal-only', 'roster-first', 'unbound-roster-first', 'parent-omitting-roster'])( + 'retains one owned shell across close/reopen: %s', + async (order) => { + let journal = await openJournal() + const first = acquire(journal) + first.translator.handle(userTurn('parent-task-turn')) + // Contract: a parentless admission can learn ownership from a later roster without a body change. + first.translator.handle( + frame({ ...PARENT_TASK_STOP[3]!.frame, parent_task_id: undefined, tool_use_id: undefined }) + ) + await first.settle() + const beforeOwnership = journal + .snapshot() + .items.find((item) => blockOf(item.body)?.taskId === 'shell-child') + expect(beforeOwnership?.agentId).toBeUndefined() + first.translator.handle(frame(PARENT_TASK_STOP[2]!.frame)) + await first.settle() + expect(journal.item(beforeOwnership!.itemId)).toMatchObject({ + agentId: 'shell-child', + parentAgentId: 'agent-parent' + }) + first.translator.handle(frame(PARENT_TASK_STOP[3]!.frame)) + await first.settle() + const shell = journal + .snapshot() + .items.find((item) => blockOf(item.body)?.taskId === 'shell-child') + expect(shell).toMatchObject({ + agentId: 'shell-child', + parentAgentId: 'agent-parent', + producerKind: 'background', + providerParentRef: 'toolu_shell' + }) + await first.exit() + expect(blockOf(journal.item(shell!.itemId)?.body)?.state).toBe('unverifiable') + await journals.closeAll() + + journal = await openJournal() + const delayed = order === 'unbound-roster-first' + const resumed = acquire(journal, delayed) + if (!delayed) { + expect(resumed.translator.childTaskParent?.('shell-child')).toBe('agent-parent') + } + if (order === 'parent-omitting-roster') { + resumed.translator.handle( + frame({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [{ task_id: 'shell-child', task_type: 'local_bash' }] + }) + ) + await resumed.settle() + expect(journal.item(shell!.itemId)).toMatchObject({ parentAgentId: 'agent-parent' }) + expect(blockOf(journal.item(shell!.itemId)?.body)?.state).toBe('working') + } else if (order !== 'terminal-only') { + resumed.translator.handle(frame(PARENT_TASK_STOP[2]!.frame)) + resumed.translator.handle(frame(PARENT_TASK_STOP[3]!.frame)) + } + if (delayed) { + resumed.bind() + } + // Contract: a terminal revision must retain saved ownership even without a repeated start. + resumed.translator.handle(frame(PARENT_TASK_STOP[8]!.frame)) + await resumed.settle() + const shells = journal + .snapshot() + .items.filter((item) => blockOf(item.body)?.taskId === 'shell-child') + expect(shells).toHaveLength(1) + expect(shells[0]).toMatchObject({ + itemId: shell!.itemId, + agentId: 'shell-child', + parentAgentId: 'agent-parent', + producerKind: 'background', + providerParentRef: 'toolu_shell' + }) + expect(blockOf(shells[0]?.body)?.state).toBe('idle') + await resumed.exit() + } + ) + it('keeps the canonical id its first run was given, so one child stays one child', async () => { const journal = await openJournal() diff --git a/src/main/claude/claude-subagent-task-frames.ts b/src/main/claude/claude-subagent-task-frames.ts index 487756f83c4..89602d480ce 100644 --- a/src/main/claude/claude-subagent-task-frames.ts +++ b/src/main/claude/claude-subagent-task-frames.ts @@ -1,3 +1,4 @@ +import type { AgentChildWorkKind } from '../../shared/agent-status/child-work/agent-status-child-work' // Task IDs alone cannot distinguish agents from shells or workflows; task_type identifies the kind. import { @@ -63,3 +64,10 @@ export function readClaudeSubagentTaskFrame( excluded: announcement && !subagent } } + +/** Releases predating `task_type` still name an agent in `subagent_type`. */ +export function claudeStartedTaskKind(message: Record): AgentChildWorkKind { + return isClaudeSubagentTask(message) + ? 'agent' + : classifyClaudeBackgroundTaskKind(message.task_type) +} diff --git a/src/main/claude/claude-task-invocations.ts b/src/main/claude/claude-task-invocations.ts new file mode 100644 index 00000000000..fd417cb0f32 --- /dev/null +++ b/src/main/claude/claude-task-invocations.ts @@ -0,0 +1,165 @@ +import { record, taskAliasId, taskId } from './claude-background-task-frames' +import type { ClaudeBackgroundTaskDescriptor } from './claude-background-task-identity' + +export type ClaudeTaskInvocation = { + toolUseId?: string + parentTaskId?: string + /** Only current provider evidence may correct durable producer attribution. */ + reportedParentTaskId?: string + excluded: boolean + monitor: boolean + ended?: { status: string; toolUseId?: string } + /** Only accepted current evidence proves an alias is historical after replacement. */ + observed: boolean +} + +type Selection = { invocation: ClaudeTaskInvocation; current: boolean; acceptsStart: boolean } +type Task = { current: ClaudeTaskInvocation; aliases: Map } + +/** Acquisition-local provider facts; saved ownership never establishes a live task. */ +export class ClaudeTaskInvocations { + private readonly tasks = new Map() + private readonly observations = new WeakMap>() + + constructor( + private readonly saved?: { + background: (id: string, run?: string) => ClaudeBackgroundTaskDescriptor | undefined + parent: (id: string, run?: string) => string | undefined + } + ) {} + + observe(message: Record): void { + if (message.type !== 'system' || this.observations.has(message)) { + return + } + const selections = new Map() + this.observations.set(message, selections) + const roster = message.subtype === 'background_tasks_changed' + const entries = roster && Array.isArray(message.tasks) ? message.tasks : [message] + for (const entry of entries) { + const frame = record(entry) + const id = frame && taskId(frame) + if (!frame || !id) { + continue + } + const run = taskAliasId(frame.tool_use_id) ?? taskAliasId(record(frame.patch)?.tool_use_id) + const start = message.subtype === 'task_started' + const selection = this.select(id, run, start) + const { invocation, acceptsStart } = selection + if ((!start && !roster) || acceptsStart) { + invocation.observed ||= selection.current + invocation.toolUseId ??= run + invocation.reportedParentTaskId = + taskAliasId(frame.parent_task_id) ?? invocation.reportedParentTaskId + invocation.parentTaskId = invocation.reportedParentTaskId ?? invocation.parentTaskId + invocation.excluded ||= frame.ambient === true || frame.skip_transcript === true + if (start && acceptsStart) { + invocation.ended = undefined + } + } + if (message.subtype === 'task_notification') { + invocation.ended = { status: String(frame.status), toolUseId: invocation.toolUseId } + } + if (invocation.toolUseId) { + this.tasks.get(id)!.aliases.set(invocation.toolUseId, invocation) + } + selections.set(id, selection) + if (roster) { + this.observations.set(frame, new Map([[id, selection]])) + } + } + } + + selection(message: Record, id: string): Selection | undefined { + this.observe(message) + return this.observations.get(message)?.get(id) + } + + get(id: string, run?: string): ClaudeTaskInvocation | undefined { + const task = this.tasks.get(id) + return run === undefined + ? task?.current + : (task?.aliases.get(run) ?? + (task?.current.toolUseId === undefined ? task?.current : undefined)) + } + + parentOf = (id: string, run?: string): string | undefined => { + const task = this.tasks.get(id) + const invocation = run === undefined ? task?.current : task?.aliases.get(run) + return invocation + ? invocation.parentTaskId + : (this.saved?.background(id, run)?.linkage.parentAgentId ?? this.saved?.parent(id, run)) + } + + excludeMonitor(id: string, run?: string): void { + this.select(id, taskAliasId(run), true).invocation.monitor = true + } + + settle(id: string, status: unknown, run?: string): void { + const invocation = this.get(id, run) + if (invocation) { + invocation.ended = { status: String(status), toolUseId: invocation.toolUseId } + } + } + + settleFrame(id: string, message: Record): void { + this.settle( + id, + record(message.patch)?.status ?? message.status, + taskAliasId(message.tool_use_id) ?? taskAliasId(record(message.patch)?.tool_use_id) + ) + } + + clear(): void { + this.tasks.clear() + } + + private select(id: string, run: string | undefined, start: boolean): Selection { + let task = this.tasks.get(id) + if (!task) { + const latest = this.saved?.background(id) + const current = this.recover(id, latest ? undefined : run) + task = { current, aliases: new Map() } + if (current.toolUseId) { + task.aliases.set(current.toolUseId, current) + } + this.tasks.set(id, task) + } + let invocation = task.current + if (run !== undefined && invocation.toolUseId !== undefined && invocation.toolUseId !== run) { + const saved = this.saved?.background(id, run) + invocation = task.aliases.get(run) ?? this.recover(id, run) + // A known historical alias cannot replace the current invocation; a pending tool result can. + if ( + start && + !invocation.observed && + saved?.toolUseId !== run && + (task.current.ended !== undefined || !task.current.observed) + ) { + task.current = invocation + } + task.aliases.set(run, invocation) + } + const current = task.current === invocation + return { + invocation, + current, + acceptsStart: + current && + (invocation.ended === undefined || + (start && + (invocation.ended.status === 'completed' || invocation.ended.toolUseId === undefined))) + } + } + + private recover(id: string, run?: string): ClaudeTaskInvocation { + const descriptor = this.saved?.background(id, run) + return { + toolUseId: run ?? descriptor?.toolUseId, + parentTaskId: descriptor?.linkage.parentAgentId ?? this.saved?.parent(id, run), + observed: false, + excluded: false, + monitor: false + } + } +} diff --git a/src/main/claude/structured/claude-owned-task-send.test.ts b/src/main/claude/structured/claude-owned-task-send.test.ts new file mode 100644 index 00000000000..1f842127849 --- /dev/null +++ b/src/main/claude/structured/claude-owned-task-send.test.ts @@ -0,0 +1,116 @@ +import { expect, it } from 'vitest' +import { + adapterFor, + claudeFrame, + fakeClaude, + identityFor +} from './claude-structured-session-test-support' +import { PARENT_TASK_STOP } from '../claude-parent-task-frames.test-fixture' +import { createQueuedMessageTestRig } from '../../native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig.test-fixture' +import { eventually } from '../../native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig-provider.test-fixture' +import { HOST_TEST_SESSION as SESSION } from '../../native-chat/agent-session-wire/host/testing/structured-agent-session-host-test-data' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status/child-work/agent-status-child-work-evidence' + +it('delivers an ordinary send after an owned roster/start refresh without inventing root work', async () => { + const claude = fakeClaude() + const evidence: AgentChildWorkEvidence[] = [] + const rig = await createQueuedMessageTestRig({ agent: 'claude' }) + const adapter = adapterFor( + claude, + {}, + [], + [], + undefined, + undefined, + (_sessionId, batch) => evidence.push(...batch), + (outcome) => { + void rig.host.settleLateDispatch({ ...outcome, sessionId: SESSION }) + } + ) + let acquired = false + try { + rig.dispatch.mockImplementation(async (input) => { + if (!acquired) { + await adapter.acquire({ + identity: identityFor(SESSION), + fence: 1, + spawnToken: 'spawn-1', + events: rig.providerEvents() + }) + acquired = true + } + return adapter.dispatch({ ...input, sessionId: SESSION, fence: 1 }) + }) + const first = rig.send('start the work') + expect(await first.result).toMatchObject({ ok: true }) + await eventually(async () => + expect((await rig.submission(first.id))?.dispatchState).toBe('accepted') + ) + const connection = claude.connections[0]! + claudeFrame(connection, { + type: 'assistant', + uuid: 'root-answer', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'Started.' }] } + }) + claudeFrame(connection, { + type: 'result', + subtype: 'success', + uuid: 'root-result', + parent_tool_use_id: null, + duration_ms: 1000 + }) + await rig.host.collaboratorsForTests().runtimeState.flushAllEventSinks() + expect(rig.host.currentWork(SESSION)!.working()).toBe(false) + + // Recorded CLI roster has no tool alias; the following start enriches that same task. + const roster = PARENT_TASK_STOP[2]!.frame + claudeFrame(connection, { + ...roster, + tasks: Array.isArray(roster.tasks) ? roster.tasks.slice(1) : [] + }) + claudeFrame(connection, PARENT_TASK_STOP[3]!.frame) + await rig.host.collaboratorsForTests().runtimeState.flushAllEventSinks() + const snapshot = await rig.host.journalSnapshot(SESSION) + const children = snapshot.items.filter( + (item) => + item.body.kind === 'message' && + item.body.blocks.some( + (block) => block.type === 'background-task' && block.taskId === 'shell-child' + ) + ) + expect(children).toHaveLength(1) + expect(children[0]).toMatchObject({ agentId: 'shell-child', parentAgentId: 'agent-parent' }) + expect(evidence).toContainEqual( + expect.objectContaining({ + type: 'live', + child: expect.objectContaining({ + handle: expect.objectContaining({ id: 'shell-child' }), + ownerId: 'agent-parent', + stoppable: true + }) + }) + ) + const rootAfterChild = rig.host + .collaboratorsForTests() + .sessions.get(SESSION)! + .journal.runningTurn() + + const followup = rig.send('continue normally') + expect(await followup.result).toMatchObject({ ok: true }) + await eventually(async () => + expect((await rig.submission(followup.id))?.handedOverAt).toBeDefined() + ) + expect(rootAfterChild).toBeNull() + expect(connection.sent).toHaveLength(2) + expect(connection.sent[1]).toMatchObject({ + message: { + content: [{ type: 'text', text: 'continue normally' }] + } + }) + } finally { + await adapter.closeAll() + await rig.dispose() + claude.dispose() + } +}) diff --git a/src/main/claude/structured/claude-structured-dispatch-test-support.ts b/src/main/claude/structured/claude-structured-dispatch-test-support.ts index 63850f38e96..28964e3371a 100644 --- a/src/main/claude/structured/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/structured/claude-structured-dispatch-test-support.ts @@ -1,3 +1,4 @@ +import { ClaudeJournalBoundDelivery } from '../claude-journal-bound-delivery' import { vi, type Mock } from 'vitest' import type { AgentJournalMessageItem } from '../../../shared/agent-session/journal/agent-session-journal-types' import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' @@ -21,6 +22,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C dispatchWaiters: [], retiredDispatchWaiters: [], replayContentFallbackBlocked: false, + delivery: new ClaudeJournalBoundDelivery(), backgroundTasks: new ClaudeBackgroundTaskTracker(), childWork: new ClaudeChildWorkDecoder(), commands: new ClaudeSlashCommandCatalog(), diff --git a/src/main/claude/structured/claude-structured-event-delivery.ts b/src/main/claude/structured/claude-structured-event-delivery.ts index bf79ece6d23..0bd7e9189bf 100644 --- a/src/main/claude/structured/claude-structured-event-delivery.ts +++ b/src/main/claude/structured/claude-structured-event-delivery.ts @@ -30,6 +30,7 @@ export function emitClaudeStructuredSessionEvent({ deps.onEvent?.(event) } if (event.type === 'ended' || event.type === 'retiring') { + session?.delivery.dispose() session?.childWork.clear() session?.backgroundTasks.clear() } else if (event.type === 'message') { @@ -56,19 +57,18 @@ export function deliverClaudeConnectionEvent( exits: ReadonlyMap, attempt: ClaudeAcquisitionAttempt, sessionId: string, - event: () => void + event: () => void, + payload?: unknown ): void { - if (!attempt.published) { - attempt.buffered.push(event) - return - } - if (exits.get(sessionId)?.error instanceof ClaudePluginInputError) { - return - } - if ( - sessions.get(sessionId)?.connection === attempt.connection || - exits.get(sessionId)?.connection === attempt.connection - ) { - event() - } + attempt.delivery.deliver(() => { + if (attempt.cancelled || exits.get(sessionId)?.error instanceof ClaudePluginInputError) { + return + } + if ( + sessions.get(sessionId)?.connection === attempt.connection || + exits.get(sessionId)?.connection === attempt.connection + ) { + event() + } + }, payload) } diff --git a/src/main/claude/structured/claude-structured-session-acquisition.ts b/src/main/claude/structured/claude-structured-session-acquisition.ts index 47bb00a664d..68dc786dbb0 100644 --- a/src/main/claude/structured/claude-structured-session-acquisition.ts +++ b/src/main/claude/structured/claude-structured-session-acquisition.ts @@ -27,10 +27,7 @@ import { readClaudeTranscriptEntryUuid } from '../claude-transcript-entry-uuid' import { claudeTurnResumePointWriter } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' import { resolveClaudeAcquisitionLaunch } from './launch/claude-structured-acquisition-launch' -import { - bindClaudeConnectionJournalControls, - createClaudeSessionJournalTranslator -} from './journal/claude-structured-session-journal-control' +import * as journal from './journal/claude-structured-session-journal-control' import { isRootClaudeFrame } from '../claude-turn-opening' import { createClaudeStartupOutcome, endsClaudeStartup } from './claude-structured-startup-outcome' import { claudeStartupResultError } from '../claude-stream-json-exit-error' @@ -70,7 +67,7 @@ export async function acquireClaudeSession({ initProof.reject(error) } const delivery = { attempt, initProof, callbacks, sessionId, sessions } - const translator = createClaudeSessionJournalTranslator( + const translator = journal.createClaudeSessionJournalTranslator( input.events, String(input.fence), delivery, @@ -81,6 +78,8 @@ export async function acquireClaudeSession({ const recordTurnEnd = claudeTurnResumePointWriter(sessionId, sessions, deps) const observedOptions: sessionState.ClaudeSession['reportedOptions'] = {} const handlePluginUi = claudePluginUiRecovery(delivery, () => liveSession) + const deliver = (payload: unknown, run: () => void): void => + callbacks.deliver(attempt, sessionId, run, payload) const onMessage = (message: Record): void => { if ( ignoresClaudeRetiredConnection(liveSession, sessions.get(sessionId), exits.get(sessionId)) @@ -144,7 +143,7 @@ export async function acquireClaudeSession({ liveSession, settlements, () => - callbacks.deliver(attempt, sessionId, () => { + deliver(message, () => { if (liveSession && (modelReport || permissionChanged)) { callbacks.emit(liveSession, input.events, { type: 'options-reported', @@ -175,7 +174,7 @@ export async function acquireClaudeSession({ ) } const emit = (event: Parameters[2]): void => - callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) + deliver(event, () => callbacks.emit(liveSession, input.events, event)) try { const launch = await resolveClaudeAcquisitionLaunch({ @@ -230,11 +229,12 @@ export async function acquireClaudeSession({ ) attempt.connection = connection naming?.attach(connection) - unbindReadingControl = bindClaudeConnectionJournalControls( + unbindReadingControl = journal.bindClaudeConnectionJournalControls( input.events, connection, translator, - deps.now ? { now: deps.now } : {} + deps.now ? { now: deps.now } : {}, + attempt.delivery ) acquisitions.assertCurrent(sessionId, attempt) if (connection.pid === undefined) { @@ -262,6 +262,7 @@ export async function acquireClaudeSession({ leafUuid: observedLeafUuid, prompts, translator, + delivery: attempt.delivery, ...(unbindReadingControl ? { unbindReadingControl } : {}), process, initialization: { @@ -293,6 +294,7 @@ export async function acquireClaudeSession({ }) return acquisition } catch (error) { + attempt.delivery.dispose() unbindReadingControl?.() throw await resolveClaudeAcquisitionError({ error: await claudeStartupError(error, attempt.connection), diff --git a/src/main/claude/structured/claude-structured-session-adapter.ts b/src/main/claude/structured/claude-structured-session-adapter.ts index c72b025529e..08a6f150dfc 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.ts @@ -91,8 +91,15 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ...(naming ? { naming } : {}), callbacks: { publish: start.publish, - deliver: (attempt, sessionId, event) => - deliverClaudeConnectionEvent(this.sessions, this.exits, attempt, sessionId, event), + deliver: (attempt, sessionId, event, payload) => + deliverClaudeConnectionEvent( + this.sessions, + this.exits, + attempt, + sessionId, + event, + payload + ), emit: (session, _events, event) => this.emit(session, event), handleExit: (sessionId, attempt, error) => observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error), diff --git a/src/main/claude/structured/claude-structured-session-close.ts b/src/main/claude/structured/claude-structured-session-close.ts index 4eb98131f6d..ff1154cdca3 100644 --- a/src/main/claude/structured/claude-structured-session-close.ts +++ b/src/main/claude/structured/claude-structured-session-close.ts @@ -86,6 +86,7 @@ export async function resolveClaudeAcquisitionError(input: { } export function settleClaudeExitedSession(session: ClaudeSession): void { + session.delivery.dispose() retireClaudeDispatchWaiters(session) settleClaudeTurnEndWaiters(session) for (const prompt of session.prompts.clear()) { @@ -113,6 +114,7 @@ async function finalizeClaudePublishedSession( input: CloseClaudePublishedSessionInput, session: ClaudeSession ): Promise { + session.delivery.dispose() settleClaudeTurnEndWaiters(session) // Null settles callbacks without writing responses during SDK cleanup. for (const prompt of session.prompts.clear()) { diff --git a/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts b/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts index f25a3e8465a..f8ca501b502 100644 --- a/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts @@ -58,6 +58,7 @@ export function observeClaudeSessionExit( return } const observedAt = lifecycle.deps.now?.() ?? Date.now() + session.delivery.dispose() session.events?.observeExit?.(claudeHeldSends(session), observedAt) lifecycle.endings.record(sessionId, session.acquisitionGeneration, claudeExitEnding(error)) lifecycle.sessions.delete(sessionId) diff --git a/src/main/claude/structured/claude-structured-session-publication.ts b/src/main/claude/structured/claude-structured-session-publication.ts index b45af38b353..30aac7965ca 100644 --- a/src/main/claude/structured/claude-structured-session-publication.ts +++ b/src/main/claude/structured/claude-structured-session-publication.ts @@ -93,6 +93,7 @@ export function createClaudeSessionPublication(input: { leafUuid: string | null prompts: ClaudePromptRegistry translator: ClaudeJournalTranslator | null + delivery: ClaudeAcquisitionAttempt['delivery'] unbindReadingControl?: () => void process: AgentSessionAcquisition['process'] initialization: Omit< @@ -130,7 +131,7 @@ export function createClaudeSessionPublication(input: { retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), - childWork: new ClaudeChildWorkDecoder(), + childWork: new ClaudeChildWorkDecoder(input.translator?.taskInvocations), // An unread catalog is unavailable, not empty. commands: new ClaudeSlashCommandCatalog(), dispatchSequence: 0, @@ -148,6 +149,7 @@ export function createClaudeSessionPublication(input: { ), fastModeAtStart: false, translator: input.translator, + delivery: input.delivery, events: input.request.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), link, @@ -208,9 +210,7 @@ export async function publishClaudeSessionAttempt({ await withAgentSessionCreatePhase('publish', recordPhase, async () => { callbacks.publish(session) attempt.published = true - for (const event of attempt.buffered.splice(0)) { - event() - } + attempt.delivery.flush() }) // Buffered exit delivery can remove the session during publication. if (sessions.get(sessionId) !== session) { diff --git a/src/main/claude/structured/claude-structured-session-state.ts b/src/main/claude/structured/claude-structured-session-state.ts index 5ced67286d1..c0fb4d1f620 100644 --- a/src/main/claude/structured/claude-structured-session-state.ts +++ b/src/main/claude/structured/claude-structured-session-state.ts @@ -13,6 +13,7 @@ import type { ClaudeStructuredLaunch } from './launch/claude-structured-launch-r import type { StructuredLaunchRequest } from '../../runtime/agent-session/launch/structured-agent-launch-composition' import type { ClaudeJournalTranslator } from '../claude-journal-translator-contract' import type { ClaudePromptRegistry } from '../claude-prompt-registry' +import { ClaudeJournalBoundDelivery } from '../claude-journal-bound-delivery' import { cancelProcessAcquisition } from '@orca/process-host/cancel-process-acquisition' import type { AgentSessionFastModeState } from '../../../shared/agent-session/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status/child-work/agent-status-child-work-evidence' @@ -172,6 +173,7 @@ export type ClaudeSession = { closeEnded?: boolean translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined + delivery: ClaudeJournalBoundDelivery unbindReadingControl?: () => void /** Provider identity and initialize proof retained for the host-awaited open. */ link: AgentSessionProviderHandleLink @@ -207,7 +209,7 @@ export type ClaudeSessionExit = { export type ClaudeAcquisitionAttempt = { connection: ClaudeStreamJsonConnection | null prompts: ClaudePromptRegistry - buffered: (() => void)[] + delivery: ClaudeJournalBoundDelivery published: boolean cancelled: boolean exitProven: boolean @@ -224,16 +226,17 @@ export function createClaudeAcquisitionAttempt( const finished = new Promise((resolve) => { finish = resolve }) - return { + const attempt: ClaudeAcquisitionAttempt = { connection: null, prompts, - buffered: [], + delivery: new ClaudeJournalBoundDelivery(undefined, () => attempt.published), published: false, cancelled: false, exitProven: false, finished, finish } + return attempt } export class ClaudeAcquisitionRegistry { @@ -304,6 +307,7 @@ export async function cancelClaudeAcquisitionAttempt( return cancelProcessAcquisition({ cancel: () => { attempt.cancelled = true + attempt.delivery.dispose() }, connection: () => attempt.connection, exitProven: () => attempt.exitProven, @@ -314,7 +318,12 @@ export async function cancelClaudeAcquisitionAttempt( export type ClaudeAcquireCallbacks = { /** Publishes the session, its start ending admitted first so its exit is never missed. */ publish: (session: ClaudeSession) => void - deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void + deliver: ( + attempt: ClaudeAcquisitionAttempt, + sessionId: string, + event: () => void, + payload?: unknown + ) => void emit: ( session: ClaudeSession | null, events: StructuredAgentSessionEventSink | undefined, diff --git a/src/main/claude/structured/journal/claude-structured-journal-translation.ts b/src/main/claude/structured/journal/claude-structured-journal-translation.ts index 5fa4f06cf27..b9a7cd8de4e 100644 --- a/src/main/claude/structured/journal/claude-structured-journal-translation.ts +++ b/src/main/claude/structured/journal/claude-structured-journal-translation.ts @@ -1,7 +1,6 @@ -import type { - ClaudeJournalTranslator, - ClaudeJournalTranslatorDeps -} from '../../claude-journal-translator-contract' +import { ClaudeTaskInvocations } from '../../claude-task-invocations' +import { ClaudeJournalBoundDelivery } from '../../claude-journal-bound-delivery' +import type * as contract from '../../claude-journal-translator-contract' import { claudeStreamingMessageBody, type ClaudeToolUse @@ -46,10 +45,11 @@ import { } from '../../claude-plugin-input-failure-journal' export function createClaudeJournalTranslator( - deps: ClaudeJournalTranslatorDeps -): ClaudeJournalTranslator { + deps: contract.ClaudeJournalTranslatorDeps +): contract.ClaudeJournalTranslator { const coverage = createClaudeFinalOutputCoverage(deps.sink) deps = { ...deps, sink: coverage.sink } + const delivery = deps.delivery ?? new ClaudeJournalBoundDelivery(deps.sink) const tools = new Map() const turnScope = () => turn.turnScope const prompts = new ClaudeJournalPrompts({ @@ -70,7 +70,9 @@ export function createClaudeJournalTranslator( const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope) const toolOrigins = new ClaudeToolOriginRegistry() const journaled = new ClaudeJournaledSubagents(() => deps.sink.journalLinkage?.() ?? null) + const taskInvocations = new ClaudeTaskInvocations(journaled) const subagents = new ClaudeSubagentIdentities({ + taskInvocations, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId), journaled @@ -94,6 +96,7 @@ export function createClaudeJournalTranslator( sink: deps.sink, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), rosteredByEarlierRun: journaled.isSubagent, + taskInvocations, // Task output must open a resumed turn so the session does not appear idle while producing rows. openOutputTurn: (frame, observedAt) => turn.ensureOpen(frame, claudeStreamTurnSource(frame), observedAt), @@ -158,99 +161,100 @@ export function createClaudeJournalTranslator( backgroundTasks.settleSession() settleClaudeInterruptedTurn(turn, observedAt, failed) } - return { - handle: withClaudeConversationContent((event) => { - if (event.type === 'retiring') { - journalClaudePluginInputFailure({ event, turn, sink: deps.sink, finish }) - return - } - if (event.type === 'ended') { - finish(event.observedAt ?? Date.now(), event.failure?.kind === 'pluginInputRequired') - return - } - turn.observeStartedSend(event.type === 'message' ? (event.startedSend ?? null) : null) - if (event.type === 'message') { - context.observe(event.message, event.observedAt ?? Date.now()) - // Root init starts a cycle; replays after root work fold into it, while task frames do not mark cycle work. - if (isRootClaudeFrame(event.message)) { - if (event.message.type === 'system' && event.message.subtype === 'init') { - turn.observeProviderCycleStart() - } else if ( - event.startsTurn === true || - event.message.type === 'assistant' || - event.message.type === 'stream_event' - ) { - turn.observeProviderCycleWork() - turn.inputChoiceWorkObserved ||= - event.message.type !== 'assistant' || - event.message.is_api_error_message !== true || - event.startedSend != null - } - } - } - if (event.type === 'message' && absorbClaudeCommandFrame(messageContext, event.message)) { - return - } - if ( - event.type === 'message' && - journalClaudeStream(messageContext, event.message, event.observedAt ?? Date.now()) - ) { - return - } - // Observe announcements before flushing so buffered rows receive their resolved producer stamp. - const announced = event.type === 'message' && subagents.observeSystemFrame(event.message) - // Only ahead of a frame that can write a row: one per thinking token rewrote the whole row. - if (!(event.type === 'message' && isClaudeProgressFrame(event.message))) { - flush() - } - if (announced) { - corrections.retry() - streamedText.reattribute() - streamedThinking.reattribute() - } - if (event.type === 'prompt') { - prompts.handle(event) - } else if (event.type === 'prompt-cancelled') { - prompts.retryPendingCancellations() - prompts.cancel(event.promptKey) - } else if (event.type === 'message' && event.message.type === 'result') { - journalClaudeResult(resultContext, event.message, event.observedAt ?? Date.now()) - } else if (event.type === 'message') { - backgroundTasks.observe(event.message, event.observedAt ?? Date.now()) - messageContext.usageLimits.observe(event.message, turn.command?.rejections) - const kind = claudeProviderFrameKind(event.message) - if ( - !journalClaudeMessage( - messageContext, - event.message, - event.startsTurn === true, - event.observedAt ?? Date.now(), - event.requestedAt, - event.clientMessageId - ) + const handle = withClaudeConversationContent((event) => { + if (event.type === 'retiring') { + journalClaudePluginInputFailure({ event, turn, sink: deps.sink, finish }) + return + } + if (event.type === 'ended') { + finish(event.observedAt ?? Date.now(), event.failure?.kind === 'pluginInputRequired') + return + } + turn.observeStartedSend(event.type === 'message' ? (event.startedSend ?? null) : null) + if (event.type === 'message') { + context.observe(event.message, event.observedAt ?? Date.now()) + // Root init starts a cycle; replays after root work fold into it, while task frames do not mark cycle work. + if (isRootClaudeFrame(event.message)) { + if (event.message.type === 'system' && event.message.subtype === 'init') { + turn.observeProviderCycleStart() + } else if ( + event.startsTurn === true || + event.message.type === 'assistant' || + event.message.type === 'stream_event' ) { - providerFallback.append( - kind, - event.message, - undefined, - undefined, - undefined, - corrections.stampFor(claudeFrameParentRef(event.message)) - ) + turn.observeProviderCycleWork() + turn.inputChoiceWorkObserved ||= + event.message.type !== 'assistant' || + event.message.is_api_error_message !== true || + event.startedSend != null } - context.observeResponse(event.message, event.observedAt ?? Date.now()) - publishClaudeTurnActivity(turn, deps.sink, event) - // Idle also closes faults without a result; the live cause decides failed reopening. - if (claudeSessionStateEndsTurn(event.message)) { - corrections.abandon() - // No verdict: the CLI said the turn is over, not how it ended. - turn.settle({ state: 'completed', completedAt: event.observedAt ?? Date.now() }) - } - } else if (event.type === 'provider-frame') { - providerFallback.append(event.kind, event.payload) - publishClaudeTurnActivity(turn, deps.sink, event) } - }), + } + if (event.type === 'message' && absorbClaudeCommandFrame(messageContext, event.message)) { + return + } + if ( + event.type === 'message' && + journalClaudeStream(messageContext, event.message, event.observedAt ?? Date.now()) + ) { + return + } + // Observe announcements before flushing so buffered rows receive their resolved producer stamp. + const announced = event.type === 'message' && subagents.observeSystemFrame(event.message) + // Only ahead of a frame that can write a row: one per thinking token rewrote the whole row. + if (!(event.type === 'message' && isClaudeProgressFrame(event.message))) { + flush() + } + if (announced) { + corrections.retry() + streamedText.reattribute() + streamedThinking.reattribute() + } + if (event.type === 'prompt') { + prompts.handle(event) + } else if (event.type === 'prompt-cancelled') { + prompts.retryPendingCancellations() + prompts.cancel(event.promptKey) + } else if (event.type === 'message' && event.message.type === 'result') { + journalClaudeResult(resultContext, event.message, event.observedAt ?? Date.now()) + } else if (event.type === 'message') { + backgroundTasks.observe(event.message, event.observedAt ?? Date.now()) + messageContext.usageLimits.observe(event.message, turn.command?.rejections) + const kind = claudeProviderFrameKind(event.message) + if ( + !journalClaudeMessage( + messageContext, + event.message, + event.startsTurn === true, + event.observedAt ?? Date.now(), + event.requestedAt, + event.clientMessageId + ) + ) { + providerFallback.append( + kind, + event.message, + undefined, + undefined, + undefined, + corrections.stampFor(claudeFrameParentRef(event.message)) + ) + } + context.observeResponse(event.message, event.observedAt ?? Date.now()) + publishClaudeTurnActivity(turn, deps.sink, event) + // Idle also closes faults without a result; the live cause decides failed reopening. + if (claudeSessionStateEndsTurn(event.message)) { + corrections.abandon() + // No verdict: the CLI said the turn is over, not how it ended. + turn.settle({ state: 'completed', completedAt: event.observedAt ?? Date.now() }) + } + } else if (event.type === 'provider-frame') { + providerFallback.append(event.kind, event.payload) + publishClaudeTurnActivity(turn, deps.sink, event) + } + }) + return { + handle: (event) => delivery.interpret(event, handle), journalPrompts: prompts, get currentTurnId() { return turn.id @@ -269,6 +273,8 @@ export function createClaudeJournalTranslator( return turn.isOpen && turn.inputChoiceWorkObserved }, flush, + taskInvocations, + childTaskParent: subagents.parentOf, childToolOwner: childQueries.childToolOwner, childActivity: childQueries.childActivity, retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(), @@ -288,6 +294,7 @@ export function createClaudeJournalTranslator( modelMayHaveChanged: () => context.modelMayHaveChanged(), modelWritten: (model) => context.modelWritten(model), dispose: () => { + delivery.dispose() flush() context.dispose() streamedText.dispose() diff --git a/src/main/claude/structured/journal/claude-structured-session-journal-control.ts b/src/main/claude/structured/journal/claude-structured-session-journal-control.ts index 06cd38da791..374e73737dc 100644 --- a/src/main/claude/structured/journal/claude-structured-session-journal-control.ts +++ b/src/main/claude/structured/journal/claude-structured-session-journal-control.ts @@ -10,6 +10,7 @@ import type { ClaudeStreamJsonConnection } from '../../claude-stream-json-connec import type { ClaudeJournalTranslator } from '../../claude-journal-translator-contract' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeInitProof } from '../claude-structured-init-proof' +import type { ClaudeJournalBoundDelivery } from '../../claude-journal-bound-delivery' import type { ClaudeAcquisitionAttempt, ClaudeSession, @@ -24,9 +25,12 @@ export function createClaudeSessionJournalTranslator( acceptStartupFailure: (message: Record) => boolean ): ClaudeJournalTranslator | null { const { attempt } = failure + attempt.delivery.onFailure = createClaudeJournalFailureHandler(failure) + attempt.delivery.bind(sink) return sink ? createClaudeJournalTranslator({ sink, + delivery: attempt.delivery, account: () => attempt.account, model: () => { const session = currentSession() @@ -83,13 +87,30 @@ function retryOwedWritesOnDrain( export function bindClaudeJournalReadingControl( sink: StructuredAgentSessionEventSink | undefined, connection: Pick, - translator: ClaudeJournalTranslator | null + translator: ClaudeJournalTranslator | null, + delivery?: ClaudeJournalBoundDelivery ): (() => void) | undefined { if (!connection.pauseReading || !connection.resumeReading) { return undefined } let sinkPaused = false - return sink?.bindReadingControl?.({ + let journalPaused = false + const resume = () => { + if (!sinkPaused && !journalPaused) { + connection.resumeReading?.() + } + } + const unbindDelivery = delivery?.bindReadingControl({ + pauseReading: () => { + journalPaused = true + connection.pauseReading?.() + }, + resumeReading: () => { + journalPaused = false + resume() + } + }) + const unbindSink = sink?.bindReadingControl?.({ pauseReading: () => { sinkPaused = true connection.pauseReading?.() @@ -98,20 +119,25 @@ export function bindClaudeJournalReadingControl( sinkPaused = false const retried = retryOwedWritesOnDrain(translator) if (!sinkPaused && (retried.accepted || retried.reason !== 'backpressure')) { - connection.resumeReading?.() + resume() } } }) + return () => { + unbindDelivery?.() + unbindSink?.() + } } export function bindClaudeConnectionJournalControls( sink: StructuredAgentSessionEventSink | undefined, connection: ClaudeStreamJsonConnection, translator: ClaudeJournalTranslator | null, - capture: ClaudeContextUsageCaptureOptions + capture: ClaudeContextUsageCaptureOptions, + delivery?: ClaudeJournalBoundDelivery ): (() => void) | undefined { const unbinds = [ - bindClaudeJournalReadingControl(sink, connection, translator), + bindClaudeJournalReadingControl(sink, connection, translator, delivery), bindClaudeContextUsageCapture(connection, translator, capture) ].filter((unbind): unbind is () => void => unbind !== undefined) return unbinds.length === 0 diff --git a/src/main/claude/structured/launch/claude-structured-options.test.ts b/src/main/claude/structured/launch/claude-structured-options.test.ts index e9954a2d159..0caf086fbf8 100644 --- a/src/main/claude/structured/launch/claude-structured-options.test.ts +++ b/src/main/claude/structured/launch/claude-structured-options.test.ts @@ -1,3 +1,4 @@ +import { ClaudeJournalBoundDelivery } from '../../claude-journal-bound-delivery' import { describe, expect, it, vi } from 'vitest' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from '../claude-structured-session-state' @@ -25,6 +26,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe dispatchWaiters: [], retiredDispatchWaiters: [], replayContentFallbackBlocked: false, + delivery: new ClaudeJournalBoundDelivery(), backgroundTasks: new ClaudeBackgroundTaskTracker(), childWork: new ClaudeChildWorkDecoder(), commands: new ClaudeSlashCommandCatalog(), diff --git a/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink-queue.ts index 40c82e530c5..777553f8c7f 100644 --- a/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink-queue.ts @@ -49,6 +49,7 @@ export class StructuredAgentSessionSinkQueue { private readonly buffered: Admitted[] = [] private readonly waitingPublications = new Map() private readonly waiters: StructuredAgentSessionDrainWaiter[] = [] + private readonly boundListeners = new Set<() => void>() constructor( private readonly deps: { @@ -76,6 +77,16 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null + onJournalBound = (listener: () => void): (() => void) => { + if (!this.closed) { + this.boundListeners.add(listener) + if (this.target) { + listener() + } + } + return () => this.boundListeners.delete(listener) + } + journalHistory = (): StructuredAgentSessionHistoryJournal | null => this.target?.journal ?? null journalItems = (): StructuredAgentSessionTransitionJournal | null => this.target?.journal ?? null @@ -104,6 +115,13 @@ export class StructuredAgentSessionSinkQueue { for (const operation of this.buffered.splice(0)) { this.handOver(operation, target) } + for (const listener of this.boundListeners) { + try { + listener() + } catch (error) { + this.fail(error) + } + } } unbind(): void { @@ -112,6 +130,7 @@ export class StructuredAgentSessionSinkQueue { close(): void { this.closed = true + this.boundListeners.clear() this.dropBuffered() this.updateBackpressure() } diff --git a/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink.ts index 1f3fec98a57..31a4e93c384 100644 --- a/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink.ts @@ -125,6 +125,8 @@ export type StructuredAgentSessionEventSink = StructuredAgentSessionTransitionSi ): StructuredAgentSessionSinkAdmission journalEpoch?(): string | null journalLinkage?(): StructuredAgentSessionLinkageJournal | null + /** Deferred sinks notify after binding; an already-bound listener runs immediately. */ + onJournalBound?(listener: () => void): () => void journalHistory?(): StructuredAgentSessionHistoryJournal | null /** Apply the journal’s personal-Stop cancellation rule; false until bound. */ journalStopDecidesTurn?(turnId: string, endedAt: number): boolean @@ -268,6 +270,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { ...createStructuredAgentSessionTransitionMembers(queue), journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, + onJournalBound: queue.onJournalBound, journalHistory: queue.journalHistory, journalStopDecidesTurn: queue.journalStopDecidesTurn, appendLifecycleBatch: (settlementId, mutations) => { diff --git a/src/main/native-chat/agent-session-wire/publication/history/agent-session-history-subagent-roster.test.ts b/src/main/native-chat/agent-session-wire/publication/history/agent-session-history-subagent-roster.test.ts new file mode 100644 index 00000000000..57c49408903 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/publication/history/agent-session-history-subagent-roster.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../../shared/agent-session/journal/agent-session-journal-types' +import { offPageSubagentRoster } from './agent-session-history-subagent-roster' + +it('supplies the off-page owner name for a page containing only its shell task', () => { + const entry = { id: 'owner', label: 'Audit', state: 'stopped' as const } + const owner: AgentJournalRenderItem = { + itemId: 'owner-row', + sequence: 1, + revision: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ ...entry, type: 'subagent', groupId: 'turn' }] + } + } + const shell: AgentJournalRenderItem = { + itemId: 'shell-row', + sequence: 2, + revision: 1, + observedAt: 2, + turnScope: { kind: 'thread' }, + agentId: 'shell', + parentAgentId: 'owner', + producerKind: 'background', + body: { + kind: 'message', + role: 'assistant', + blocks: [ + { + type: 'background-task', + taskId: 'shell', + kind: 'command', + label: 'sleep', + state: 'working' + } + ] + } + } + expect(offPageSubagentRoster([owner, shell], [shell])).toMatchObject([{ entry }]) +}) diff --git a/src/shared/agent-session/journal/agent-session-journal-producer.test.ts b/src/shared/agent-session/journal/agent-session-journal-producer.test.ts new file mode 100644 index 00000000000..07106f8d23e --- /dev/null +++ b/src/shared/agent-session/journal/agent-session-journal-producer.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from 'vitest' +import { + agentJournalItemSubagentId, + isRootAgentJournalItem +} from './agent-session-journal-producer' +import type { NativeChatBlock } from '../../native-chat/native-chat-types' + +it('groups only a background producer own task row under its owner on both journal and message readers', () => { + const linkage = { agentId: 'shell', parentAgentId: 'owner', producerKind: 'background' as const } + const task: NativeChatBlock = { + type: 'background-task', + taskId: 'shell', + kind: 'command', + label: 'sleep', + state: 'working' + } + const output: NativeChatBlock = { type: 'text', text: 'workflow output' } + for (const [blocks, expected] of [ + [[task], 'owner'], + [[output], 'shell'], + [[{ ...task, taskId: 'another' }], 'shell'] + ] as const) { + expect(agentJournalItemSubagentId({ ...linkage, blocks })).toBe(expected) + const journal = { + ...linkage, + body: { kind: 'message' as const, role: 'assistant' as const, blocks: [...blocks] } + } + expect(agentJournalItemSubagentId(journal)).toBe(expected) + expect(isRootAgentJournalItem(journal)).toBe(false) + } + expect(isRootAgentJournalItem({})).toBe(true) +}) diff --git a/src/shared/agent-session/journal/agent-session-journal-producer.ts b/src/shared/agent-session/journal/agent-session-journal-producer.ts index ffdb3867fa4..18e06e7d3fe 100644 --- a/src/shared/agent-session/journal/agent-session-journal-producer.ts +++ b/src/shared/agent-session/journal/agent-session-journal-producer.ts @@ -1,5 +1,6 @@ // Root means no producer id, never a matching value. First-write attribution survives revisions without linkage; all producers share one session timeline. +import type { NativeChatBlock } from '../../native-chat/native-chat-types' import type { AgentJournalProducerLinkage, AgentJournalRenderItem @@ -12,11 +13,23 @@ export function isRootAgentJournalItem( return agentJournalItemSubagentId(item) === null } -/** The subagent that produced a row, or null when the session's own agent did. +type ProducerItem = AgentJournalProducerLinkage & { + blocks?: readonly NativeChatBlock[] + body?: AgentJournalRenderItem['body'] +} + +/** The subagent section for a row, or null when the session's own agent produced it. * Same reading of absence as {@link isRootAgentJournalItem}. */ -export function agentJournalItemSubagentId( - item: Pick | undefined -): string | null { +export function agentJournalItemSubagentId(item: ProducerItem | undefined): string | null { + const blocks = item?.blocks ?? (item?.body?.kind === 'message' ? item.body.blocks : []) + if ( + item?.agentId !== undefined && + item.producerKind === 'background' && + item.parentAgentId !== undefined && + blocks.some((block) => block.type === 'background-task' && block.taskId === item.agentId) + ) { + return item.parentAgentId + } return item?.agentId ?? null } diff --git a/src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts b/src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts index 232893a127f..0128029e0f6 100644 --- a/src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts +++ b/src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts @@ -78,6 +78,37 @@ describe("a subagent's rows are not the conversation's", () => { row('answer', say('The review found nothing.')) ] + it('places background work inside its owner even when the owner output is windowed out', () => { + const shell = row( + 'shell', + [ + { + type: 'background-task', + taskId: 'shell-child', + kind: 'command', + label: 'sleep', + state: 'working', + startedAt: 1 + } + ], + { agentId: 'shell-child', parentAgentId: 'agent-parent', producerKind: 'background' } + ) + for (const ownerRows of [ + [], + [row('owner', say('working'), { agentId: 'agent-parent', producerKind: 'agent' })] + ]) { + const projected = projectNativeChatTranscript([...ownerRows, shell]) + expect(projected.conversation).toEqual([]) + expect([...projected.subagentRows.keys()]).toEqual(['agent-parent']) + expect( + projected.subagentRows + .get('agent-parent') + ?.flatMap(({ message }) => message.blocks) + .some((block) => block.type === 'background-task' && block.taskId === 'shell-child') + ).toBe(true) + } + }) + it("keeps the session's own rows as the conversation, and none of the subagent's", () => { const { conversation, subagentRows } = projectNativeChatTranscript(transcript) expect(conversation.map((message) => message.id)).toEqual(['ask', 'delegate', 'answer']) From c804008e4f464a563d152b0b353d3a67a158d542 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:34:12 -0800 Subject: [PATCH 056/138] Show friendly advice for Codex turn failures (#39959) * Show friendly Codex turn failure advice * Match terminal failure callback to status row type * Keep terminal diagnostics in Details and clarify unknown failure advice * Preserve usage-limit diagnostic metadata * Validate custom host advice in phone renderer fixture * Check categorized compaction failures and retained diagnostics * Complete localized model-unavailable notices * test(mobile): keep the failure notice helpers main's test cleanup removed --- mobile/src/i18n/en-runtime-required.json | 6 +- mobile/src/i18n/locales/de.json | 4 + mobile/src/i18n/locales/en.json | 4 + mobile/src/i18n/locales/es.json | 4 + mobile/src/i18n/locales/fr.json | 4 + mobile/src/i18n/locales/ja.json | 4 + mobile/src/i18n/locales/ko.json | 4 + mobile/src/i18n/locales/pt.json | 4 + mobile/src/i18n/locales/ru.json | 4 + mobile/src/i18n/locales/tr.json | 4 + mobile/src/i18n/locales/zh-Hant.json | 4 + mobile/src/i18n/locales/zh.json | 4 + .../agent-session-send-failure-copy.ts | 21 ++ ...leNativeChatMessage.status-notices.test.ts | 70 +++++ .../transcript/MobileNativeChatNoticeRow.tsx | 10 +- .../structured/catalog/codex-usage-limits.ts | 8 +- .../journal/codex-provider-retry-row.test.ts | 12 +- ...codex-structured-journal-generic-frames.ts | 61 +++-- .../codex-structured-journal-translation.ts | 18 +- .../journal/codex-turn-failures.test.ts | 216 ++++++++++++++++ .../structured/journal/codex-turn-failures.ts | 240 ++++++++++++++++++ .../turns/codex-command-turn-claim.test.ts | 22 +- .../NativeChatNoticeRow.failures.test.tsx | 69 +++++ .../notices/NativeChatNoticeRow.fixture.tsx | 45 ++++ .../messages/notices/NativeChatNoticeRow.tsx | 13 +- .../agent-session-failure-words-text.ts | 2 + ...ent-session-provider-failure-words-text.ts | 31 +++ .../src/i18n/en-runtime-required.json | 4 + src/renderer/src/i18n/locales/de.json | 5 + src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 5 + src/renderer/src/i18n/locales/fr.json | 5 + src/renderer/src/i18n/locales/ja.json | 5 + src/renderer/src/i18n/locales/ko.json | 5 + src/renderer/src/i18n/locales/pt.json | 5 + src/renderer/src/i18n/locales/ru.json | 5 + src/renderer/src/i18n/locales/tr.json | 5 + src/renderer/src/i18n/locales/zh-Hant.json | 4 + src/renderer/src/i18n/locales/zh.json | 4 + .../failures/agent-session-failure-copy.ts | 7 + .../failures/agent-session-failure-words.ts | 14 +- .../failures/agent-session-failure.ts | 6 + .../agent-session-model-failure-words.ts | 19 ++ 43 files changed, 932 insertions(+), 58 deletions(-) create mode 100644 src/main/codex/structured/journal/codex-turn-failures.test.ts create mode 100644 src/main/codex/structured/journal/codex-turn-failures.ts create mode 100644 src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.failures.test.tsx create mode 100644 src/renderer/src/components/native-chat/messages/notices/agent-session-provider-failure-words-text.ts create mode 100644 src/shared/agent-session/failures/agent-session-model-failure-words.ts diff --git a/mobile/src/i18n/en-runtime-required.json b/mobile/src/i18n/en-runtime-required.json index acba365e2f1..ff6c952994d 100644 --- a/mobile/src/i18n/en-runtime-required.json +++ b/mobile/src/i18n/en-runtime-required.json @@ -315,7 +315,11 @@ "forkOriginalMissingNamed": "{{agent}} could not find this response in \"{{detail}}\". Open that chat and fork from there.", "forkOriginalUnavailable": "{{agent}} cannot fork this response in the original chat. Open it and fork from there.", "forkOriginalUnavailableNamed": "{{agent}} cannot fork this response in \"{{detail}}\". Open that chat and fork from there.", - "pluginInputRequired": "{{agent}} stopped because the \"{{plugin}}\" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a {{agent}} terminal." + "pluginInputRequired": "{{agent}} stopped because the \"{{plugin}}\" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a {{agent}} terminal.", + "providerResponseFailed": "{{agent}} couldn't finish this response. Open Details for more information.", + "providerSafetyStopped": "{{agent}} stopped this response after a safety check. Review your request and send again.", + "providerUnavailable": "{{agent}} couldn't respond because the service is busy. Pick another model, or wait a little and send again.", + "selectedModel": "the selected model" }, "fileReader": { "reviewNotes_one": "{{count}} review note", diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 48043e5b8b3..93c90b1859d 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -2153,6 +2153,10 @@ "providerExitedRejection": "{{agent}} wurde gestoppt, bevor diese Nachricht gesendet wurde.", "providerExitedRow": "{{agent}} wurde während dieser Antwort gestoppt. Die Unterhaltung kann fortgesetzt werden.", "providerRateLimited": "{{agent}} hat das Ratenlimit erreicht und versucht es erneut.", + "providerUnavailable": "{{agent}} konnte nicht antworten, weil der Dienst ausgelastet ist. Wähle ein anderes Modell oder warte kurz und sende erneut.", + "providerSafetyStopped": "{{agent}} hat diese Antwort nach einer Sicherheitsprüfung gestoppt. Überprüfe deine Anfrage und sende sie erneut.", + "providerResponseFailed": "{{agent}} konnte diese Antwort nicht abschließen. Öffnen Sie die Details für weitere Informationen.", + "selectedModel": "das ausgewählte Modell", "providerRejected": "{{agent}} hat diese Nachricht nicht angenommen.", "providerRejectedQuoted": "{{agent}} hat diese Nachricht nicht angenommen: {{detail}}.", "providerRetryLastError": "Letzter Fehler: {{detail}}.", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 46765ee3df1..5e37b9c0859 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -2638,6 +2638,10 @@ "modelUnavailable": "{{agent}} couldn't use {{detail}}. Pick another model and send again.", "modelUnavailableUpdate": "{{agent}} couldn't use {{detail}}. Update {{agent}} to {{minimumVersion}} or later to keep it after a restart, or pick another model and send again.", "modelNotSelected": "{{agent}} has no model selected. Check your model selection and {{agent}} sign-in, then send again.", + "providerUnavailable": "{{agent}} couldn't respond because the service is busy. Pick another model, or wait a little and send again.", + "providerSafetyStopped": "{{agent}} stopped this response after a safety check. Review your request and send again.", + "providerResponseFailed": "{{agent}} couldn't finish this response. Open Details for more information.", + "selectedModel": "the selected model", "providerRejected": "{{agent}} didn't accept this message.", "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index 158cd39f958..23bea482416 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -2221,6 +2221,10 @@ "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", "providerExitedRow": "{{agent}} se detuvo mientras esta respuesta estaba en curso. Puede continuar en esta conversación.", "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", + "providerUnavailable": "{{agent}} no pudo responder porque el servicio está ocupado. Elige otro modelo o espera un poco y vuelve a enviar el mensaje.", + "providerSafetyStopped": "{{agent}} detuvo esta respuesta tras una comprobación de seguridad. Revisa tu solicitud y vuelve a enviarla.", + "providerResponseFailed": "{{agent}} no pudo terminar esta respuesta. Abre los detalles para obtener más información.", + "selectedModel": "el modelo seleccionado", "providerRejected": "{{agent}} no aceptó este mensaje.", "providerRejectedQuoted": "{{agent}} no aceptó este mensaje: {{detail}}.", "providerRetryLastError": "Último error: {{detail}}.", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index c7f2225a033..030a9037c45 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -2222,6 +2222,10 @@ "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", "providerExitedRow": "{{agent}} s'est arrêté pendant que cette réponse était en cours. Vous pouvez continuer dans cette conversation.", "providerRateLimited": "{{agent}} est limité en débit et réessaie.", + "providerUnavailable": "{{agent}} n’a pas pu répondre car le service est occupé. Choisissez un autre modèle ou patientez un peu, puis renvoyez le message.", + "providerSafetyStopped": "{{agent}} a arrêté cette réponse après un contrôle de sécurité. Vérifiez votre demande et renvoyez-la.", + "providerResponseFailed": "{{agent}} n’a pas pu terminer cette réponse. Ouvrez les détails pour en savoir plus.", + "selectedModel": "le modèle sélectionné", "providerRejected": "{{agent}} n'a pas accepté ce message.", "providerRejectedQuoted": "{{agent}} n'a pas accepté ce message : {{detail}}.", "providerRetryLastError": "Dernière erreur : {{detail}}.", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index ed93625b9f0..c6410cd1e67 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -2149,6 +2149,10 @@ "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。", "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", + "providerUnavailable": "サービスが混雑しているため、{{agent}} は応答できませんでした。別のモデルを選ぶか、しばらく待ってから再送してください。", + "providerSafetyStopped": "{{agent}} は安全確認の後、この応答を停止しました。リクエストを見直して再送してください。", + "providerResponseFailed": "{{agent}} はこの応答を完了できませんでした。詳細を開いて確認してください。", + "selectedModel": "選択されたモデル", "providerRejected": "{{agent}} はこのメッセージを受け付けませんでした。", "providerRejectedQuoted": "{{agent}} はこのメッセージを受け付けませんでした: {{detail}}。", "providerRetryLastError": "直近のエラー: {{detail}}。", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 108a7a20015..2b166821479 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -2149,6 +2149,10 @@ "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.", "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", + "providerUnavailable": "서비스가 혼잡하여 {{agent}}이(가) 응답하지 못했습니다. 다른 모델을 선택하거나 잠시 기다린 후 다시 보내세요.", + "providerSafetyStopped": "{{agent}}이(가) 안전 검사 후 이 응답을 중단했습니다. 요청을 검토한 후 다시 보내세요.", + "providerResponseFailed": "{{agent}}이(가) 이 응답을 완료하지 못했습니다. 자세한 내용을 열어 확인하세요.", + "selectedModel": "선택한 모델", "providerRejected": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다.", "providerRejectedQuoted": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다: {{detail}}.", "providerRetryLastError": "마지막 오류: {{detail}}.", diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index 675f0e6c313..ea08a696d58 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -2221,6 +2221,10 @@ "providerExitedRejection": "{{agent}} parou antes de esta mensagem ser enviada.", "providerExitedRow": "{{agent}} parou enquanto esta resposta estava em andamento. Você pode continuar nesta conversa.", "providerRateLimited": "{{agent}} atingiu o limite de requisições e está tentando novamente.", + "providerUnavailable": "{{agent}} não conseguiu responder porque o serviço está ocupado. Escolha outro modelo ou aguarde um pouco e envie novamente.", + "providerSafetyStopped": "{{agent}} interrompeu esta resposta após uma verificação de segurança. Revise sua solicitação e envie novamente.", + "providerResponseFailed": "{{agent}} não conseguiu concluir esta resposta. Abra os detalhes para obter mais informações.", + "selectedModel": "o modelo selecionado", "providerRejected": "{{agent}} não aceitou esta mensagem.", "providerRejectedQuoted": "{{agent}} não aceitou esta mensagem: {{detail}}.", "providerRetryLastError": "Último erro: {{detail}}.", diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 97b5c0be44f..b3753c99614 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -2289,6 +2289,10 @@ "providerExitedRejection": "{{agent}} остановился до отправки этого сообщения.", "providerExitedRow": "{{agent}} остановился во время ответа. Вы можете продолжить этот разговор.", "providerRateLimited": "{{agent}} повторяет попытку из-за ограничения частоты запросов.", + "providerUnavailable": "{{agent}} не смог ответить, поскольку сервис занят. Выберите другую модель или немного подождите и отправьте сообщение снова.", + "providerSafetyStopped": "{{agent}} остановил этот ответ после проверки безопасности. Проверьте запрос и отправьте его снова.", + "providerResponseFailed": "{{agent}} не удалось завершить этот ответ. Откройте подробности, чтобы узнать больше.", + "selectedModel": "выбранную модель", "providerRejected": "{{agent}} не принял это сообщение.", "providerRejectedQuoted": "{{agent}} не принял это сообщение: {{detail}}.", "providerRetryLastError": "Последняя ошибка: {{detail}}.", diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index 9fd904d49c4..69e6eb64810 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -2153,6 +2153,10 @@ "providerExitedRejection": "Bu mesaj gönderilmeden önce {{agent}} durdu.", "providerExitedRow": "Bu yanıt hazırlanırken {{agent}} durdu. Bu konuşmada devam edebilirsiniz.", "providerRateLimited": "{{agent}} istek hızı sınırına ulaştı ve yeniden deniyor.", + "providerUnavailable": "Hizmet meşgul olduğu için {{agent}} yanıt veremedi. Başka bir model seçin veya biraz bekleyip yeniden gönderin.", + "providerSafetyStopped": "{{agent}}, bir güvenlik kontrolünden sonra bu yanıtı durdurdu. İsteğinizi gözden geçirip yeniden gönderin.", + "providerResponseFailed": "{{agent}} bu yanıtı tamamlayamadı. Daha fazla bilgi için ayrıntıları açın.", + "selectedModel": "seçilen modeli", "providerRejected": "{{agent}} bu mesajı kabul etmedi.", "providerRejectedQuoted": "{{agent}} bu mesajı kabul etmedi: {{detail}}.", "providerRetryLastError": "Son hata: {{detail}}.", diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 6512122f33a..7fad8bd6227 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -2149,6 +2149,10 @@ "providerExitedRejection": "{{agent}} 在傳送此訊息前已停止。", "providerExitedRow": "{{agent}} 在產生此回覆時停止了。您可以在此對話中繼續。", "providerRateLimited": "{{agent}} 受到速率限制,正在重試。", + "providerUnavailable": "{{agent}} 因服務忙碌而無法回覆。請選擇其他模型,或稍候片刻後重新傳送。", + "providerSafetyStopped": "{{agent}} 在安全檢查後停止了此次回覆。請檢查您的請求後重新傳送。", + "providerResponseFailed": "{{agent}} 未能完成此回覆。開啟詳細資料以取得更多資訊。", + "selectedModel": "所選模型", "providerRejected": "{{agent}} 未接受此訊息。", "providerRejectedQuoted": "{{agent}} 未接受此訊息:{{detail}}。", "providerRetryLastError": "上次錯誤:{{detail}}。", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 9982692931e..1d9441b494c 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -2149,6 +2149,10 @@ "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", "providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。", "providerRateLimited": "{{agent}} 已被限流,正在重试。", + "providerUnavailable": "{{agent}} 因服务繁忙而无法回复。请选择其他模型,或稍等片刻后重新发送。", + "providerSafetyStopped": "{{agent}} 在安全检查后停止了此次回复。请检查您的请求后重新发送。", + "providerResponseFailed": "{{agent}} 未能完成此回复。打开详情了解更多信息。", + "selectedModel": "所选模型", "providerRejected": "{{agent}} 未接受此消息。", "providerRejectedQuoted": "{{agent}} 未接受此消息:{{detail}}。", "providerRetryLastError": "上次错误:{{detail}}。", diff --git a/mobile/src/session/agent-session-send-failure-copy.ts b/mobile/src/session/agent-session-send-failure-copy.ts index 1564774d712..0a10fcac96f 100644 --- a/mobile/src/session/agent-session-send-failure-copy.ts +++ b/mobile/src/session/agent-session-send-failure-copy.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_FAILURE_COPY } from '../../../src/shared/agent-session/failures/agent-session-failure-copy' import type { AgentSessionFailureCopyId, AgentSessionFailureCopyValues @@ -53,6 +54,26 @@ export const SEND_FAILURE_COPY = { "{{agent}} couldn't use {{detail}}. Update {{agent}} to {{minimumVersion}} or later to keep it after a restart, or pick another model and send again.", values ), + selectedModel: (values) => + translate('session.failure.selectedModel', AGENT_SESSION_FAILURE_COPY.selectedModel, values), + providerUnavailable: (values) => + translate( + 'session.failure.providerUnavailable', + AGENT_SESSION_FAILURE_COPY.providerUnavailable, + values + ), + providerSafetyStopped: (values) => + translate( + 'session.failure.providerSafetyStopped', + AGENT_SESSION_FAILURE_COPY.providerSafetyStopped, + values + ), + providerResponseFailed: (values) => + translate( + 'session.failure.providerResponseFailed', + AGENT_SESSION_FAILURE_COPY.providerResponseFailed, + values + ), providerRejected: (values) => translate('session.failure.providerRejected', "{{agent}} didn't accept this message.", values), providerRejectedQuoted: (values) => diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts index a6e395f3ff7..8adab75b691 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts @@ -2,11 +2,18 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalStatusItem } from '../../../../../src/shared/agent-session/journal/agent-session-journal-types' +import { isAdmissibleAgentJournalItemBody } from '../../../../../src/shared/agent-session/journal/agent-session-journal-schemas' import { projectStructuredItemToNativeChat } from '../../../../../src/shared/agent-session/view/structured-agent-session-projection' import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types' +import { agentSessionFailureFact } from '../../../../../src/shared/agent-session/failures/agent-session-failure' +import { agentSessionFailureWords } from '../../../../../src/shared/agent-session/failures/agent-session-failure-words' import { colors } from '../../../theme/mobile-theme' import { activateMobileUiLocale } from '../../../i18n/mobile-i18n' import { MarkdownLinkHandlerContext } from '../../../components/markdown-link-handler' +import { + MobileNativeChatNoticeContext, + type MobileNativeChatNoticeView +} from '../../mobile-native-chat-notice-context' vi.mock('react-native', async () => { const React = await import('react') @@ -95,6 +102,69 @@ describe('MobileNativeChatMessage status notices', () => { observedAt: 2, body })! + const renderNotice = (body: AgentJournalStatusItem, names: MobileNativeChatNoticeView) => { + act(() => { + renderer = create( + createElement( + MobileNativeChatNoticeContext.Provider, + { value: names }, + createElement(MobileNativeChatMessage, { message: statusRow(body) }) + ) + ) + }) + return renderer!.root.findAll( + (node) => String(node.type) === 'Text' && node.props.selectable === true + )[0]! + } + + it('localizes a canonical failure and discloses its diagnostic only on request', async () => { + await activateMobileUiLocale('fr') + const diagnostic = 'Internal provider diagnostic: modelNotFound' + const notice = renderNotice( + { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(agentSessionFailureFact('modelUnavailable'), { + agentName: 'Codex', + surface: 'row' + }), + providerFrame: { + provider: 'codex', + kind: 'notification:error', + payload: { + head: diagnostic, + byteLength: diagnostic.length, + digest: 'fixture', + truncated: false + } + } + }, + { hostName: null, agentName: 'Codex' } + ) + expect(String(notice.props.children)).toContain('le modèle sélectionné') + expect(String(notice.props.children)).not.toContain(diagnostic) + const details = () => + renderer!.root.findAll( + (node) => String(node.type) === 'Text' && String(node.props.children).includes(diagnostic) + ) + expect(details()).toHaveLength(0) + const disclosure = renderer!.root.find( + (node) => String(node.type) === 'Pressable' && node.props['aria-expanded'] === false + ) + act(() => disclosure.props.onPress()) + expect(details().length).toBeGreaterThan(0) + act(() => renderer?.unmount()) + const hostAdvice: unknown = { + kind: 'status', + text: 'Custom host advice: use the enterprise model.', + failure: agentSessionFailureFact('modelUnavailable') + } + if (!isAdmissibleAgentJournalItemBody(hostAdvice) || hostAdvice.kind !== 'status') { + throw new Error('Expected host status row') + } + const custom = renderNotice(hostAdvice, { hostName: null, agentName: 'Codex' }) + expect(custom.props.children).toBe('Custom host advice: use the enterprise model.') + }) it('links a CI result line to its pull request and commit through the link sheet', () => { const openLink = vi.fn() diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx index b849b08da44..ac094b90491 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx @@ -73,7 +73,15 @@ function noticeLineText( // Only reword text fully described by its fact; other host text stays as written. const context = { agentName: names.agentName } const surface = - failure?.kind === 'savedConversationMissing' || failure?.kind === 'pluginInputRequired' + failure && + [ + 'savedConversationMissing', + 'pluginInputRequired', + 'modelUnavailable', + 'providerUnavailable', + 'providerSafetyStopped', + 'providerResponseFailed' + ].includes(failure.kind) ? (['row', 'rejection'] as const).find( (candidate) => notice.text === agentSessionFailureSentence(failure, candidate, context) ) diff --git a/src/main/codex/structured/catalog/codex-usage-limits.ts b/src/main/codex/structured/catalog/codex-usage-limits.ts index 491657029dc..ca4bfd0c639 100644 --- a/src/main/codex/structured/catalog/codex-usage-limits.ts +++ b/src/main/codex/structured/catalog/codex-usage-limits.ts @@ -49,8 +49,12 @@ export class CodexUsageLimits { } failure(payload: unknown) { - const error = readCodexErrorInfo(payload)?.error - if (!error || !USAGE_LIMIT_ERRORS.includes(error)) { + const info = readCodexErrorInfo(payload) + if ( + !info || + (!USAGE_LIMIT_ERRORS.includes(info.error) && + !(info.error === 'responseTooManyFailedAttempts' && info.status === 429)) + ) { return null } const message = readCodexErrorMessage(payload) diff --git a/src/main/codex/structured/journal/codex-provider-retry-row.test.ts b/src/main/codex/structured/journal/codex-provider-retry-row.test.ts index d6d98dffaf3..22510166c37 100644 --- a/src/main/codex/structured/journal/codex-provider-retry-row.test.ts +++ b/src/main/codex/structured/journal/codex-provider-retry-row.test.ts @@ -166,14 +166,16 @@ describe('a Codex stream error it is about to retry', () => { 'stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)' const errorFrameRows = reduced(rows).flatMap((row) => row.body.kind === 'status' && row.body.providerFrame?.kind === 'notification:error' - ? [{ tone: row.body.tone, text: row.body.text }] + ? [row.body] : [] ) - expect(errorFrameRows).toEqual([ - { tone: 'warning', text: `Codex is retrying: Reconnecting... 1/2.\n${cause}` }, - { tone: 'warning', text: `Codex is retrying: Reconnecting... 2/2.\n${cause}` }, - { tone: 'error', text: cause } + expect(errorFrameRows).toMatchObject([ + { tone: 'warning', failure: { kind: 'providerRetrying' } }, + { tone: 'warning', failure: { kind: 'providerRetrying' } }, + { tone: 'error', failure: { kind: 'providerResponseFailed' } } ]) + expect(errorFrameRows[2]?.text).not.toContain(cause) + expect(errorFrameRows[2]?.providerFrame?.payload.head).toContain(cause) expect(reduced(rows).map((row) => row.body)).toContainEqual( expect.objectContaining({ kind: 'turn', state: 'completed', outcome: 'failure' }) ) diff --git a/src/main/codex/structured/journal/codex-structured-journal-generic-frames.ts b/src/main/codex/structured/journal/codex-structured-journal-generic-frames.ts index 4e4c73c68e6..55f95da2325 100644 --- a/src/main/codex/structured/journal/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/structured/journal/codex-structured-journal-generic-frames.ts @@ -28,9 +28,7 @@ import { isWordlessProviderFrame } from '../../../../shared/native-chat/native-chat-provider-frame-summary' import type { CodexRowAttribution } from '../background-work/codex-subagent-linkage' -import { codexAuthenticationFailure } from '../session-lifecycle/codex-authentication-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session/failures/agent-session-failure-words' -import { CodexUsageLimits } from '../catalog/codex-usage-limits' +import { CodexTurnFailures } from './codex-turn-failures' const OVERFLOW_BUCKET = '__codex-generic-overflow__' type TurnRows = { drawn: number; wordless: number } @@ -74,7 +72,7 @@ export class CodexJournalGenericFrames { private bookkeepingBytes = 0 private fallbackSequence = 0 private cancelSuppressionFlush: (() => void) | null = null - private readonly usageLimits = new CodexUsageLimits() + private readonly failures: CodexTurnFailures constructor( private readonly deps: Pick< @@ -85,6 +83,11 @@ export class CodexJournalGenericFrames { }, private readonly activeTurn: (threadId: string) => string | null ) { + this.failures = new CodexTurnFailures({ + account: deps.account, + activeTurn, + append: (threadId, payload, frame) => this.appendFrameRow(threadId, payload, frame) + }) this.schedule = deps.schedule ?? defaultSchedule this.suppressionCoalesceMs = deps.coalesceMs ?? 60 } @@ -94,31 +97,26 @@ export class CodexJournalGenericFrames { payload: unknown, threadId: string ): CodexJournalTranslationAdmission { - if (this.usageLimits.observe(kind, payload)) { + if (this.failures.observe(kind, payload)) { return CODEX_JOURNAL_ADMITTED } + if (kind === 'notification:error' && !readCodexErrorWillRetry(payload)) { + return this.failures.appendTerminal(kind, payload, payload, threadId) + } const translated = unhandledProviderFrameJournalItem('codex', kind, payload) - // Declined frames count as success; rejection would force-close the provider. - const ended = kind === 'notification:error' && !readCodexErrorWillRetry(payload) - const usageLimit = ended ? this.usageLimits.failure(payload) : null - const failure = - usageLimit ?? (ended ? codexAuthenticationFailure(payload, this.deps.account?.()) : null) - return translated - ? this.appendFrameRow(threadId, payload, { - ...translated, - body: failure - ? { - ...translated.body, - ...(usageLimit ? { tone: 'warning' } : {}), - ...agentSessionFailureWords(failure, { - provider: 'codex', - agentName: 'Codex', - surface: 'row' - }) - } - : translated.body - }) - : CODEX_JOURNAL_ADMITTED + return translated ? this.appendFrameRow(threadId, payload, translated) : CODEX_JOURNAL_ADMITTED + } + + appendCompletionFailure( + payload: unknown, + threadId: string, + onShown: (turnId: string) => void + ): CodexJournalTranslationAdmission { + return this.failures.appendCompletion(payload, threadId, onShown) + } + + forgetTurnFailure(threadId: string, turnId: string): void { + this.failures.forget(threadId, turnId) } appendFrameRow( @@ -159,7 +157,9 @@ export class CodexJournalGenericFrames { const identity = { provider: 'orca' as const, clientMessageId: - translated.body.failure?.kind === 'usageLimitReached' && frameTurnId + translated.body.failure && + translated.body.failure.kind !== 'providerRetrying' && + frameTurnId ? `terminal-failure:codex:${JSON.stringify([threadId, frameTurnId])}` : `provider-frame:codex:${this.deps.acquisitionId ?? 'acquisition'}:${this.fallbackSequence}` } @@ -181,7 +181,11 @@ export class CodexJournalGenericFrames { : { ...rows, drawn: rows.drawn + 1 } ) this.recordBucket(bucket) - return publish(this.deps.sink) + const publication = publish(this.deps.sink) + if (publication.accepted && translated.body.failure?.kind === 'providerRetrying') { + this.failures.observeRetry(payload, threadId) + } + return publication } suppress(threadId: string, turnId: string, count = 1): void { @@ -243,6 +247,7 @@ export class CodexJournalGenericFrames { dispose(): void { this.cancelSuppressionFlush?.() + this.failures.dispose() this.genericRowsByTurn.clear() this.suppressedRowsByTurn.clear() this.bucketOrder.clear() diff --git a/src/main/codex/structured/journal/codex-structured-journal-translation.ts b/src/main/codex/structured/journal/codex-structured-journal-translation.ts index 142a8a16d9a..a5cfa72d0bb 100644 --- a/src/main/codex/structured/journal/codex-structured-journal-translation.ts +++ b/src/main/codex/structured/journal/codex-structured-journal-translation.ts @@ -210,9 +210,21 @@ function createCodexTranslation( return CODEX_JOURNAL_ADMITTED } if (event.method === 'turn/started' || event.method === 'turn/completed') { - return event.method === 'turn/started' - ? turnBoundaries.start(event) - : turnBoundaries.complete(event) + if (event.method === 'turn/started') { + return turnBoundaries.start(event) + } + const turnId = readCodexTurnId(event.params) ?? activeTurns.current(event.threadId) + const failure = genericFrames.appendCompletionFailure(event.params, event.threadId, (id) => + commands.errorShown({ turnId: id }) + ) + if (!failure.accepted) { + return failure + } + const admission = turnBoundaries.complete(event) + if (admission.accepted && turnId) { + genericFrames.forgetTurnFailure(event.threadId, turnId) + } + return admission } const compaction = compactions.handle(event) if (compaction) { diff --git a/src/main/codex/structured/journal/codex-turn-failures.test.ts b/src/main/codex/structured/journal/codex-turn-failures.test.ts new file mode 100644 index 00000000000..9a89ea774a8 --- /dev/null +++ b/src/main/codex/structured/journal/codex-turn-failures.test.ts @@ -0,0 +1,216 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { + LIFECYCLE_KEY, + THREAD_ID, + TURN_ID, + notification, + recorder, + reduced, + translatorFor +} from './codex-turn-lifecycle-contract-test-fixture' + +const DIAGNOSTIC = 'Provider diagnostic: internal request failure' + +function error(code: unknown, extra: Record = {}) { + return { + threadId: THREAD_ID, + turnId: TURN_ID, + willRetry: false, + error: { message: DIAGNOSTIC, codexErrorInfo: code }, + ...extra + } +} + +function statuses(tap: ReturnType) { + return reduced(tap.rows).flatMap((row) => + row.body.kind === 'status' && row.body.failure?.kind !== 'providerRetrying' ? [row.body] : [] + ) +} + +describe('Codex terminal failure categories', () => { + it.each([ + ['rateLimitExceeded', 'usageLimitReached'], + ['flexUnavailable', 'providerUnavailable'], + ['modelNotFound', 'modelUnavailable'], + ['policyCapacityUnavailable', 'providerUnavailable'], + ['misalignmentPolicyViolation', 'providerSafetyStopped'], + ['serverOverloaded', 'providerUnavailable'], + ['cyberPolicy', 'providerSafetyStopped'] + ])('categorizes %s without exposing its diagnostic', (code, kind) => { + for (const info of [code, { [code]: { httpStatusCode: 401 } }]) { + const tap = recorder() + const translator = translatorFor(tap) + translator.handle(notification('error', error(info))) + expect(statuses(tap)).toMatchObject([{ failure: { kind } }]) + expect(statuses(tap)[0]?.text).not.toContain(DIAGNOSTIC) + expect(statuses(tap)[0]?.providerFrame?.payload.head).toContain(DIAGNOSTIC) + translator.dispose() + } + }) + + it('uses the existing usage-limit category for HTTP 429 retry exhaustion', () => { + const tap = recorder() + const translator = translatorFor(tap) + translator.handle( + notification('error', error({ responseTooManyFailedAttempts: { httpStatusCode: 429 } })) + ) + expect(statuses(tap)).toMatchObject([ + { tone: 'warning', failure: { kind: 'usageLimitReached' } } + ]) + }) + + it('ends a recorded-shape failed completion carrying an unknown future code', () => { + const frames: { + case: string + method: string + params: { + threadId: string + turn?: { id: string; status: string; error?: Record } + } + }[] = readFileSync( + join(__dirname, '../../__fixtures__/codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + const captured = frames.find( + (frame) => frame.case === '0.141.0-conn-refused' && frame.method === 'turn/completed' + ) + if (!captured?.params.turn) { + throw new Error('Missing captured failed completion') + } + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + sessionId: 'session-1', + primaryThreadId: () => captured.params.threadId + }) + const on = (method: string, params: unknown) => + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: captured.params.threadId, + method, + params + }) + on('turn/started', { turn: { id: captured.params.turn.id } }) + expect(reduced(tap.rows).map((row) => row.body)).toMatchObject([ + { kind: 'turn', state: 'running' } + ]) + expect( + on('turn/completed', { + ...captured.params, + turn: { + ...captured.params.turn, + error: { ...captured.params.turn.error, codexErrorInfo: 'futureFailureAfter0167' } + } + }) + ).toEqual({ accepted: true }) + expect(reduced(tap.rows).map((row) => row.body)).toMatchObject([ + { kind: 'turn', state: 'completed', outcome: 'failure' }, + { kind: 'status', failure: { kind: 'providerResponseFailed' } } + ]) + }) + + it('shows a completion-only safety stop while preserving an ordinary Stop', () => { + for (const error of [{ message: DIAGNOSTIC, codexErrorInfo: 'tooManyDenials' }, null]) { + const tap = recorder() + const translator = translatorFor(tap) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + translator.handle( + notification('turn/completed', { turn: { id: TURN_ID, status: 'interrupted', error } }) + ) + expect(reduced(tap.rows).find((row) => row.key === LIFECYCLE_KEY)?.body).toMatchObject({ + kind: 'turn', + state: 'interrupted', + outcome: 'cancellation' + }) + expect(statuses(tap)).toMatchObject( + error ? [{ failure: { kind: 'providerSafetyStopped' } }] : [] + ) + } + }) + + it('keeps completion-only sign-in diagnostics behind Details', () => { + const tap = recorder() + const translator = translatorFor(tap) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + translator.handle( + notification('turn/completed', { + turn: { + id: TURN_ID, + status: 'failed', + error: error({ responseStreamDisconnected: { httpStatusCode: 401 } }).error + } + }) + ) + expect(statuses(tap)).toMatchObject([{ failure: { kind: 'notSignedIn' } }]) + expect(statuses(tap)[0]?.text).not.toContain(DIAGNOSTIC) + expect(statuses(tap)[0]?.providerFrame?.payload.head).toContain(DIAGNOSTIC) + }) + + it('keeps recovered sign-in advice through completion backpressure without another failure row', () => { + const tap = recorder() + let rejectCompletion = true + tap.sink.tryAppendItem = (identity, body, options) => { + if (body.kind === 'turn' && body.state === 'completed' && rejectCompletion) { + rejectCompletion = false + return { accepted: false, reason: 'backpressure' } + } + tap.sink.appendItem(identity, body, options) + return { accepted: true } + } + const translator = translatorFor(tap) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + translator.handle( + notification('error', { + ...error({ responseStreamDisconnected: { httpStatusCode: 401 } }, { willRetry: true }), + error: { + message: 'Reconnecting... 1/2', + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 401 } }, + additionalDetails: DIAGNOSTIC + } + }) + ) + translator.handle(notification('error', error('other'))) + const completion = notification('turn/completed', { + turn: { id: TURN_ID, status: 'failed', error: error('other').error } + }) + expect(translator.handle(completion)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(translator.handle(completion)).toEqual({ accepted: true }) + const terminal = tap.rows.filter( + (row) => row.body.kind === 'status' && row.body.failure?.kind !== 'providerRetrying' + ) + expect(terminal.map((row) => row.body)).toMatchObject([{ failure: { kind: 'notSignedIn' } }]) + expect(statuses(tap)[0]?.text).not.toContain(DIAGNOSTIC) + expect(statuses(tap)[0]?.providerFrame?.payload.head).toContain(DIAGNOSTIC) + expect(reduced(tap.rows).find((row) => row.key === LIFECYCLE_KEY)?.body).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + }) + + it('uses the last non-other retry only for matching text and scope, including empty details', () => { + const tap = recorder() + const translator = translatorFor(tap) + const retry = (code: unknown, details: string) => + notification('error', { + ...error(code, { willRetry: true }), + error: { message: DIAGNOSTIC, codexErrorInfo: code, additionalDetails: details } + }) + translator.handle(retry({ responseStreamDisconnected: { httpStatusCode: 401 } }, '')) + translator.handle(notification('error', error('other'))) + expect(statuses(tap).at(-1)?.failure?.kind).toBe('providerResponseFailed') + translator.handle(retry({ responseStreamDisconnected: { httpStatusCode: 401 } }, DIAGNOSTIC)) + translator.handle(retry('futureRetryCode', DIAGNOSTIC)) + translator.handle(notification('error', error('other'))) + expect(statuses(tap).at(-1)?.failure?.kind).toBe('providerResponseFailed') + translator.handle(retry({ responseStreamDisconnected: { httpStatusCode: 401 } }, DIAGNOSTIC)) + translator.handle(notification('error', error('other', { turnId: 'another-turn' }))) + expect(statuses(tap).at(-1)?.failure?.kind).toBe('providerResponseFailed') + }) +}) diff --git a/src/main/codex/structured/journal/codex-turn-failures.ts b/src/main/codex/structured/journal/codex-turn-failures.ts new file mode 100644 index 00000000000..48b30d08edf --- /dev/null +++ b/src/main/codex/structured/journal/codex-turn-failures.ts @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto' +import { agentSessionFailureFact } from '../../../../shared/agent-session/failures/agent-session-failure' +import type { AgentSessionAccountKind } from '../../../../shared/agent-session/agent-session-availability' +import { + readCodexErrorInfo, + readCodexTurnId, + readCodexTurnStatus +} from '../../app-server/codex-structured-thread-facts' +import { readRecord } from '../../app-server/codex-item-field-readers' +import { codexAuthenticationFailure } from '../session-lifecycle/codex-authentication-failure' +import { CodexUsageLimits } from '../catalog/codex-usage-limits' +import { agentSessionFailureWords } from '../../../../shared/agent-session/failures/agent-session-failure-words' +import { + unhandledProviderFrameJournalItem, + type UnhandledProviderFrameJournalItem +} from '../../../native-chat/agent-session-wire/providers/unhandled-provider-frame' +import { + CODEX_JOURNAL_ADMITTED, + type CodexJournalTranslationAdmission +} from './codex-structured-journal-contracts' +import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' +import type { AgentJournalStatusItem } from '../../../../shared/agent-session/journal/agent-session-journal-types' + +type ErrorInfo = { error: string; status?: number } +type TerminalSignature = { code?: string; message?: string; status?: number } +type FailureEvidence = + | { phase: 'retry'; info: ErrorInfo; text: string } + | { phase: 'terminal'; signature: TerminalSignature } + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function scopeKey(threadId: string, turnId: string | null): string | null { + return turnId === null ? null : digest(JSON.stringify([threadId, turnId])) +} + +function errorStatus(payload: unknown): number | undefined { + const status = readCodexErrorInfo(payload)?.status + return typeof status === 'number' && Number.isFinite(status) ? status : undefined +} + +function failureText(payload: unknown): string | undefined { + const error = readRecord(readRecord(payload).error) + return typeof error.additionalDetails === 'string' + ? error.additionalDetails + : typeof error.message === 'string' + ? error.message + : undefined +} + +function terminalSignature(payload: unknown): TerminalSignature { + const info = readCodexErrorInfo(payload) + const message = readRecord(readRecord(payload).error).message + const status = errorStatus(payload) + return { + ...(info ? { code: digest(info.error) } : {}), + ...(typeof message === 'string' ? { message: digest(message) } : {}), + ...(status !== undefined ? { status } : {}) + } +} + +export class CodexTurnFailures { + private readonly usageLimits = new CodexUsageLimits() + private readonly evidence = new Map() + + constructor( + private readonly deps: { + account?: () => AgentSessionAccountKind | undefined + activeTurn: (threadId: string) => string | null + append: ( + threadId: string, + payload: unknown, + frame: Omit & { body: AgentJournalStatusItem } + ) => CodexJournalTranslationAdmission + } + ) {} + + appendTerminal( + kind: string, + payload: unknown, + errorPayload: unknown, + threadId: string + ): CodexJournalTranslationAdmission { + const translated = unhandledProviderFrameJournalItem('codex', kind, payload, undefined, { + reportsFailure: true + }) + if (!translated) { + return CODEX_JOURNAL_ADMITTED + } + const failure = this.failure(errorPayload, threadId, this.deps.account?.()) + const admission = this.deps.append(threadId, payload, { + ...translated, + body: { + ...translated.body, + ...(failure.kind === 'usageLimitReached' ? { tone: 'warning' } : {}), + ...agentSessionFailureWords(failure, { + provider: 'codex', + agentName: 'Codex', + surface: 'row' + }) + } + }) + if (admission.accepted) { + this.admittedTerminal( + errorPayload, + threadId, + readCodexTurnId(payload) ?? this.deps.activeTurn(threadId) + ) + } + return admission + } + + appendCompletion( + payload: unknown, + threadId: string, + onShown: (turnId: string) => void + ): CodexJournalTranslationAdmission { + const turn = readRecord(readRecord(payload).turn) + const status = readCodexTurnStatus(payload) + const turnId = readCodexTurnId(payload) ?? this.deps.activeTurn(threadId) + if (!turnId || (status !== 'failed' && !(status === 'interrupted' && turn.error))) { + return CODEX_JOURNAL_ADMITTED + } + const errorPayload = { turnId, error: turn.error, willRetry: false } + const admission = this.repeatsTerminal(errorPayload, threadId, turnId) + ? CODEX_JOURNAL_ADMITTED + : this.appendTerminal('notification:turn/completed', payload, errorPayload, threadId) + if (admission.accepted) { + onShown(turnId) + } + return admission + } + + observe(kind: string, payload: unknown): boolean { + return this.usageLimits.observe(kind, payload) + } + + observeRetry(payload: unknown, threadId: string): void { + const info = readCodexErrorInfo(payload) + const text = failureText(payload) + const key = scopeKey(threadId, readCodexTurnId(payload)) + if (!key || !info || info.error === 'other' || text === undefined) { + return + } + this.remember(key, { + phase: 'retry', + info: { + error: info.error.length <= 128 ? info.error : 'other', + status: errorStatus(payload) + }, + text: digest(text) + }) + } + + private failure(payload: unknown, threadId: string, account?: AgentSessionAccountKind) { + let info = readCodexErrorInfo(payload) + const key = scopeKey(threadId, readCodexTurnId(payload)) + const previous = key ? this.evidence.get(key) : undefined + const text = failureText(payload) + if ( + readRecord(payload).willRetry === false && + info?.error === 'other' && + previous?.phase === 'retry' && + text !== undefined && + previous.text === digest(text) + ) { + info = previous.info + } + const effective = { + error: { + ...readRecord(readRecord(payload).error), + codexErrorInfo: info ? { [info.error]: { httpStatusCode: info.status } } : undefined + } + } + const existing = + this.usageLimits.failure(effective) ?? codexAuthenticationFailure(effective, account) + if (existing) { + // Sign-in advice must not quote provider text; the bounded frame retains diagnostics. + return existing.kind === 'notSignedIn' ? { ...existing, detail: undefined } : existing + } + switch (info?.error ?? 'other') { + case 'modelNotFound': + return agentSessionFailureFact('modelUnavailable') + case 'serverOverloaded': + case 'flexUnavailable': + case 'policyCapacityUnavailable': + return agentSessionFailureFact('providerUnavailable') + case 'cyberPolicy': + case 'misalignmentPolicyViolation': + case 'tooManyDenials': + return agentSessionFailureFact('providerSafetyStopped') + default: + return agentSessionFailureFact('providerResponseFailed') + } + } + + private repeatsTerminal(payload: unknown, threadId: string, turnId: string): boolean { + const key = scopeKey(threadId, turnId) + const previous = key ? this.evidence.get(key) : undefined + if (previous?.phase !== 'terminal') { + return false + } + const next = terminalSignature(payload) + return ( + (next.code === undefined || next.code === previous.signature.code) && + (next.message === undefined || next.message === previous.signature.message) && + (next.status === undefined || next.status === previous.signature.status) + ) + } + + private admittedTerminal(payload: unknown, threadId: string, turnId: string | null): void { + const key = scopeKey(threadId, turnId) + if (key) { + this.remember(key, { phase: 'terminal', signature: terminalSignature(payload) }) + } + } + + forget(threadId: string, turnId: string): void { + const key = scopeKey(threadId, turnId) + if (key) { + this.evidence.delete(key) + } + } + + dispose(): void { + this.evidence.clear() + } + + private remember(key: string, evidence: FailureEvidence): void { + this.evidence.delete(key) + this.evidence.set(key, evidence) + if (this.evidence.size > MAX_CODEX_GENERIC_TURN_BUCKETS) { + const oldest = this.evidence.keys().next().value + if (oldest !== undefined) { + this.evidence.delete(oldest) + } + } + } +} diff --git a/src/main/codex/structured/turns/codex-command-turn-claim.test.ts b/src/main/codex/structured/turns/codex-command-turn-claim.test.ts index 4a5a638401c..0895b51afb3 100644 --- a/src/main/codex/structured/turns/codex-command-turn-claim.test.ts +++ b/src/main/codex/structured/turns/codex-command-turn-claim.test.ts @@ -160,14 +160,20 @@ describe('a Codex turn a conversation command claims', () => { agentJournalItemKey(COMMAND.resultIdentity) ) expect(rows.map((write) => write.turnScope)).toEqual(rows.map(() => scope)) - expect( - rows.some( - (write) => - write.body.kind === 'status' && - write.body.tone === 'error' && - write.body.text.includes('Selected model is at capacity') - ) - ).toBe(true) + const failures = rows.filter( + (write) => write.body.kind === 'status' && write.body.tone === 'error' + ) + expect(failures.map((write) => write.body)).toMatchObject([ + { + kind: 'status', + failure: { kind: 'providerUnavailable' }, + providerFrame: { + payload: { + head: expect.stringContaining('Selected model is at capacity') + } + } + } + ]) }) it('leaves the next turn to write its own record after a failed compaction', () => { diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.failures.test.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.failures.test.tsx new file mode 100644 index 00000000000..f8d2ab5778e --- /dev/null +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.failures.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../../../../shared/agent-session/failures/agent-session-failure' +import { agentSessionFailureWords } from '../../../../../../shared/agent-session/failures/agent-session-failure-words' +import { NativeChatNoticeRow } from './NativeChatNoticeRow' +import { i18n } from '@/i18n/i18n' + +afterEach(async () => { + cleanup() + await i18n.changeLanguage('en') +}) + +it('localizes canonical failure facts while keeping host advice and diagnostics intact', async () => { + await i18n.changeLanguage('fr') + const diagnostic = 'Internal provider diagnostic: modelNotFound' + const payload = { + head: diagnostic, + byteLength: diagnostic.length, + digest: 'fixture', + truncated: false + } + const kinds = [ + 'modelUnavailable', + 'providerUnavailable', + 'providerSafetyStopped', + 'providerResponseFailed' + ] as const + const { container } = render( + <> + {kinds.map((kind) => ( + + ))} + + + ) + const visibleLines = [...container.querySelectorAll('p')] + .map((node) => node.textContent) + .join('\n') + expect(visibleLines).toContain('le modèle sélectionné') + expect(visibleLines).not.toContain('the selected model') + expect(visibleLines).not.toContain(diagnostic) + expect(screen.getByText('Custom host advice: use the enterprise model.')).toBeInTheDocument() + const details = container.querySelector('details')! + expect(details.open).toBe(false) + fireEvent.click(details.querySelector('summary')!) + expect(details.open).toBe(true) + expect(details.querySelector('pre')).toHaveTextContent(diagnostic) +}) diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx index 7dbb128be4a..54ff875e949 100644 --- a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx @@ -1,7 +1,52 @@ import { defineFixture } from '../../../../component-fixtures/component-fixture' +import { i18n } from '../../../../i18n/i18n' +import { agentSessionFailureFact } from '../../../../../../shared/agent-session/failures/agent-session-failure' +import { agentSessionFailureWords } from '../../../../../../shared/agent-session/failures/agent-session-failure-words' import type { NativeChatTextBlock } from '../../../../../../shared/native-chat/native-chat-types' import { NativeChatNoticeRow } from './NativeChatNoticeRow' +export const codexFailures = defineFixture({ + width: 440, + interact: async () => { + await i18n.changeLanguage('fr') + }, + render: () => ( +
+ {( + [ + 'providerUnavailable', + 'providerSafetyStopped', + 'providerResponseFailed', + 'modelUnavailable' + ] as const + ).map((kind) => ( + + ))} +
+ ) +}) + function guardianReview(text: string): NativeChatTextBlock { const head = JSON.stringify({ message: text }) return { diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx index e24e3385ffe..d77038317c9 100644 --- a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx @@ -239,9 +239,16 @@ export function NativeChatNoticeRow({ block.usageLimitTurnItemId === orcaStopView.usageLimitTurnItemId // Reword only text fully described by its fact; other host text may carry command advice. const factSurface = - failure?.kind === 'notSignedIn' || - failure?.kind === 'savedConversationMissing' || - failure?.kind === 'pluginInputRequired' + failure && + [ + 'notSignedIn', + 'savedConversationMissing', + 'pluginInputRequired', + 'modelUnavailable', + 'providerUnavailable', + 'providerSafetyStopped', + 'providerResponseFailed' + ].includes(failure.kind) ? (['row', 'rejection'] as const).find( (surface) => block.text === agentSessionFailureSentence(failure, surface, { agentName }) ) diff --git a/src/renderer/src/components/native-chat/messages/notices/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/messages/notices/agent-session-failure-words-text.ts index da067b3b101..aa57f4261bb 100644 --- a/src/renderer/src/components/native-chat/messages/notices/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/messages/notices/agent-session-failure-words-text.ts @@ -13,6 +13,7 @@ import { STOP_FAILURE_PIECES } from './agent-session-failure-stop-words-text' import { PROVIDER_EXIT_PIECES } from './agent-session-provider-exit-words-text' import { PROVIDER_RETRY_PIECES } from './agent-session-provider-retry-words-text' import { USAGE_LIMIT_PIECES } from './agent-session-usage-limit-words-text' +import { PROVIDER_FAILURE_PIECES } from './agent-session-provider-failure-words-text' import { START_FAILURE_PIECES } from './agent-session-start-failure-words-text' // The pieces a refusal notice says too keep the notice's keys, so each has one translation. @@ -181,6 +182,7 @@ const PIECES: Record translate( 'components.native-chat.failureWords.providerRejected', diff --git a/src/renderer/src/components/native-chat/messages/notices/agent-session-provider-failure-words-text.ts b/src/renderer/src/components/native-chat/messages/notices/agent-session-provider-failure-words-text.ts new file mode 100644 index 00000000000..0aa86372ed6 --- /dev/null +++ b/src/renderer/src/components/native-chat/messages/notices/agent-session-provider-failure-words-text.ts @@ -0,0 +1,31 @@ +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_FAILURE_COPY as COPY, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues +} from '../../../../../../shared/agent-session/failures/agent-session-failure-copy' + +export const PROVIDER_FAILURE_PIECES = { + selectedModel: (values) => + translate('components.native-chat.failureWords.selectedModel', COPY.selectedModel, values), + providerUnavailable: (values) => + translate( + 'components.native-chat.failureWords.providerUnavailable', + COPY.providerUnavailable, + values + ), + providerSafetyStopped: (values) => + translate( + 'components.native-chat.failureWords.providerSafetyStopped', + COPY.providerSafetyStopped, + values + ), + providerResponseFailed: (values) => + translate( + 'components.native-chat.failureWords.providerResponseFailed', + COPY.providerResponseFailed, + values + ) +} satisfies Partial< + Record string> +> diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8633e53f48e..85fbf332a0d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1623,17 +1623,21 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRejected": "{{agent}} didn't accept this message.", "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", + "providerResponseFailed": "{{agent}} couldn't finish this response. Open Details for more information.", "providerRetryLastError": "Last error: {{detail}}.", "providerRetryNumber": "Retry {{attempt}}.", "providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", + "providerSafetyStopped": "{{agent}} stopped this response after a safety check. Review your request and send again.", "providerStartFailed": "{{agent}} stopped before it finished starting.", + "providerUnavailable": "{{agent}} couldn't respond because the service is busy. Pick another model, or wait a little and send again.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", "savedConversationMissing": "{{agent}} couldn't find this chat's saved conversation.", + "selectedModel": "the selected model", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index e8b5fce4702..b7d13c0a29d 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -19287,6 +19287,11 @@ "providerExitedRow": "{{agent}} wurde während dieser Antwort gestoppt. Die Unterhaltung kann fortgesetzt werden.", "providerExitedRejection": "{{agent}} wurde gestoppt, bevor diese Nachricht gesendet wurde.", "providerExitedAnswer": "{{agent}} wurde gestoppt, daher wurde die Antwort nicht gesendet. Zum Fortfahren eine Nachricht senden.", + "providerUnavailable": "{{agent}} konnte nicht antworten, weil der Dienst ausgelastet ist. Wähle ein anderes Modell oder warte kurz und sende erneut.", + "providerSafetyStopped": "{{agent}} hat diese Antwort nach einer Sicherheitsprüfung gestoppt. Überprüfe deine Anfrage und sende sie erneut.", + "providerResponseFailed": "{{agent}} konnte diese Antwort nicht abschließen. Öffnen Sie die Details für weitere Informationen.", + "modelUnavailable": "{{agent}} konnte {{detail}} nicht verwenden. Ein anderes Modell auswählen und erneut senden.", + "selectedModel": "das ausgewählte Modell", "providerRejected": "{{agent}} hat diese Nachricht nicht angenommen.", "providerRejectedQuoted": "{{agent}} hat diese Nachricht nicht angenommen: {{detail}}.", "attachmentEmpty": "Ein Bild in dieser Nachricht ist leer, daher wurde die Nachricht nicht gesendet.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 03b11ac3bb5..cbda726753e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19869,6 +19869,10 @@ "modelUnavailable": "{{agent}} couldn't use {{detail}}. Pick another model and send again.", "modelUnavailableUpdate": "{{agent}} couldn't use {{detail}}. Update {{agent}} to {{minimumVersion}} or later to keep it after a restart, or pick another model and send again.", "modelNotSelected": "{{agent}} has no model selected. Check your model selection and {{agent}} sign-in, then send again.", + "providerUnavailable": "{{agent}} couldn't respond because the service is busy. Pick another model, or wait a little and send again.", + "providerSafetyStopped": "{{agent}} stopped this response after a safety check. Review your request and send again.", + "providerResponseFailed": "{{agent}} couldn't finish this response. Open Details for more information.", + "selectedModel": "the selected model", "providerRejected": "{{agent}} didn't accept this message.", "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b5d46135395..91adf61cfd7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19021,6 +19021,11 @@ "providerExitedRow": "{{agent}} se detuvo mientras esta respuesta estaba en curso. Puede continuar en esta conversación.", "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", "providerExitedAnswer": "{{agent}} se detuvo, así que su respuesta no se envió. Envíe un mensaje para continuar.", + "providerUnavailable": "{{agent}} no pudo responder porque el servicio está ocupado. Elige otro modelo o espera un poco y vuelve a enviar el mensaje.", + "providerSafetyStopped": "{{agent}} detuvo esta respuesta tras una comprobación de seguridad. Revisa tu solicitud y vuelve a enviarla.", + "providerResponseFailed": "{{agent}} no pudo terminar esta respuesta. Abre los detalles para obtener más información.", + "modelUnavailable": "{{agent}} no pudo usar {{detail}}. Elige otro modelo y vuelve a enviar el mensaje.", + "selectedModel": "el modelo seleccionado", "providerRejected": "{{agent}} no aceptó este mensaje.", "providerRejectedQuoted": "{{agent}} no aceptó este mensaje: {{detail}}.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c9887b7ca7f..7a8bbf7ad7d 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19260,6 +19260,11 @@ "providerExitedRow": "{{agent}} s'est arrêté pendant que cette réponse était en cours. Vous pouvez continuer dans cette conversation.", "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", "providerExitedAnswer": "{{agent}} s'est arrêté, donc votre réponse n'a pas été envoyée. Envoyez un message pour continuer.", + "providerUnavailable": "{{agent}} n’a pas pu répondre car le service est occupé. Choisissez un autre modèle ou patientez un peu, puis renvoyez le message.", + "providerSafetyStopped": "{{agent}} a arrêté cette réponse après un contrôle de sécurité. Vérifiez votre demande et renvoyez-la.", + "providerResponseFailed": "{{agent}} n’a pas pu terminer cette réponse. Ouvrez les détails pour en savoir plus.", + "modelUnavailable": "{{agent}} n'a pas pu utiliser {{detail}}. Choisissez un autre modèle, puis renvoyez le message.", + "selectedModel": "le modèle sélectionné", "providerRejected": "{{agent}} n'a pas accepté ce message.", "providerRejectedQuoted": "{{agent}} n'a pas accepté ce message : {{detail}}.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 613f7cac950..b3d7e56f49c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18604,6 +18604,11 @@ "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。", "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", "providerExitedAnswer": "{{agent}} が停止したため、回答は送信されませんでした。続けるにはメッセージを送信してください。", + "providerUnavailable": "サービスが混雑しているため、{{agent}} は応答できませんでした。別のモデルを選ぶか、しばらく待ってから再送してください。", + "providerSafetyStopped": "{{agent}} は安全確認の後、この応答を停止しました。リクエストを見直して再送してください。", + "providerResponseFailed": "{{agent}} はこの応答を完了できませんでした。詳細を開いて確認してください。", + "modelUnavailable": "{{agent}} は {{detail}} を使用できませんでした。別のモデルを選択して、もう一度送信してください。", + "selectedModel": "選択されたモデル", "providerRejected": "{{agent}} はこのメッセージを受け付けませんでした。", "providerRejectedQuoted": "{{agent}} はこのメッセージを受け付けませんでした: {{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0f99b624163..bd509836c0f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18604,6 +18604,11 @@ "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.", "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", "providerExitedAnswer": "{{agent}}이(가) 중지되어 답변이 전송되지 않았습니다. 계속하려면 메시지를 보내세요.", + "providerUnavailable": "서비스가 혼잡하여 {{agent}}이(가) 응답하지 못했습니다. 다른 모델을 선택하거나 잠시 기다린 후 다시 보내세요.", + "providerSafetyStopped": "{{agent}}이(가) 안전 검사 후 이 응답을 중단했습니다. 요청을 검토한 후 다시 보내세요.", + "providerResponseFailed": "{{agent}}이(가) 이 응답을 완료하지 못했습니다. 자세한 내용을 열어 확인하세요.", + "modelUnavailable": "{{agent}}이(가) {{detail}}을(를) 사용하지 못했습니다. 다른 모델을 선택하고 다시 보내세요.", + "selectedModel": "선택한 모델", "providerRejected": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다.", "providerRejectedQuoted": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index d7b928d3ad3..35b47a935ef 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -20693,6 +20693,11 @@ "providerExitedRow": "{{agent}} parou enquanto esta resposta estava em andamento. Você pode continuar nesta conversa.", "providerExitedRejection": "{{agent}} parou antes de esta mensagem ser enviada.", "providerExitedAnswer": "{{agent}} parou, por isso sua resposta não foi enviada. Envie uma mensagem para continuar.", + "providerUnavailable": "{{agent}} não conseguiu responder porque o serviço está ocupado. Escolha outro modelo ou aguarde um pouco e envie novamente.", + "providerSafetyStopped": "{{agent}} interrompeu esta resposta após uma verificação de segurança. Revise sua solicitação e envie novamente.", + "providerResponseFailed": "{{agent}} não conseguiu concluir esta resposta. Abra os detalhes para obter mais informações.", + "modelUnavailable": "{{agent}} não conseguiu usar {{detail}}. Selecione outro modelo e envie novamente.", + "selectedModel": "o modelo selecionado", "providerRejected": "{{agent}} não aceitou esta mensagem.", "providerRejectedQuoted": "{{agent}} não aceitou esta mensagem: {{detail}}.", "attachmentEmpty": "Uma imagem nesta mensagem está vazia, por isso a mensagem não foi enviada.", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 466dfb0eaf1..8f07a41b469 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -19857,6 +19857,11 @@ "providerExitedRow": "{{agent}} остановился во время ответа. Вы можете продолжить этот разговор.", "providerExitedRejection": "{{agent}} остановился до отправки этого сообщения.", "providerExitedAnswer": "{{agent}} остановился, поэтому ваш ответ не отправлен. Отправьте сообщение, чтобы продолжить.", + "providerUnavailable": "{{agent}} не смог ответить, поскольку сервис занят. Выберите другую модель или немного подождите и отправьте сообщение снова.", + "providerSafetyStopped": "{{agent}} остановил этот ответ после проверки безопасности. Проверьте запрос и отправьте его снова.", + "providerResponseFailed": "{{agent}} не удалось завершить этот ответ. Откройте подробности, чтобы узнать больше.", + "modelUnavailable": "{{agent}} не смог использовать {{detail}}. Выберите другую модель и отправьте сообщение снова.", + "selectedModel": "выбранную модель", "providerRejected": "{{agent}} не принял это сообщение.", "providerRejectedQuoted": "{{agent}} не принял это сообщение: {{detail}}.", "attachmentEmpty": "В сообщении пустое изображение, поэтому оно не отправлено.", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 3b7a679fe24..95304867e99 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -19271,6 +19271,11 @@ "providerExitedRow": "Bu yanıt hazırlanırken {{agent}} durdu. Bu konuşmada devam edebilirsiniz.", "providerExitedRejection": "Bu mesaj gönderilmeden önce {{agent}} durdu.", "providerExitedAnswer": "{{agent}} durduğu için yanıtınız gönderilmedi. Devam etmek için bir mesaj gönderin.", + "providerUnavailable": "Hizmet meşgul olduğu için {{agent}} yanıt veremedi. Başka bir model seçin veya biraz bekleyip yeniden gönderin.", + "providerSafetyStopped": "{{agent}}, bir güvenlik kontrolünden sonra bu yanıtı durdurdu. İsteğinizi gözden geçirip yeniden gönderin.", + "providerResponseFailed": "{{agent}} bu yanıtı tamamlayamadı. Daha fazla bilgi için ayrıntıları açın.", + "modelUnavailable": "{{agent}}, {{detail}} modelini kullanamadı. Başka bir model seçip yeniden gönderin.", + "selectedModel": "seçilen modeli", "providerRejected": "{{agent}} bu mesajı kabul etmedi.", "providerRejectedQuoted": "{{agent}} bu mesajı kabul etmedi: {{detail}}.", "attachmentEmpty": "Bu mesajdaki bir görsel boş olduğu için mesaj gönderilmedi.", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 51bfeb5d8b5..6c6ffa4853d 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -19029,6 +19029,10 @@ "modelUnavailable": "{{agent}} 無法使用 {{detail}}。請選擇其他模型並重新傳送。", "modelUnavailableUpdate": "{{agent}} 無法使用 {{detail}}。請將 {{agent}} 更新到 {{minimumVersion}} 或更新版本,以便重新啟動後繼續使用;或選擇其他模型並重新傳送。", "modelNotSelected": "{{agent}} 沒有選擇模型。請檢查模型選擇和 {{agent}} 登入狀態,然後重新傳送。", + "providerUnavailable": "{{agent}} 因服務忙碌而無法回覆。請選擇其他模型,或稍候片刻後重新傳送。", + "providerSafetyStopped": "{{agent}} 在安全檢查後停止了此次回覆。請檢查您的請求後重新傳送。", + "providerResponseFailed": "{{agent}} 未能完成此回覆。開啟詳細資料以取得更多資訊。", + "selectedModel": "所選模型", "providerRejected": "{{agent}} 未接受此訊息。", "providerRejectedQuoted": "{{agent}} 未接受此訊息:{{detail}}。", "attachmentEmpty": "此訊息中的一張圖片為空,因此訊息未傳送。", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 48c96ba1076..c410a72c92d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18547,6 +18547,10 @@ "modelUnavailable": "{{agent}} 无法使用 {{detail}}。请选择其他模型并重新发送。", "modelUnavailableUpdate": "{{agent}} 无法使用 {{detail}}。请将 {{agent}} 更新到 {{minimumVersion}} 或更高版本,以便重启后继续使用;或者选择其他模型并重新发送。", "modelNotSelected": "{{agent}} 没有选择模型。请检查模型选择和 {{agent}} 登录状态,然后重新发送。", + "providerUnavailable": "{{agent}} 因服务繁忙而无法回复。请选择其他模型,或稍等片刻后重新发送。", + "providerSafetyStopped": "{{agent}} 在安全检查后停止了此次回复。请检查您的请求后重新发送。", + "providerResponseFailed": "{{agent}} 未能完成此回复。打开详情了解更多信息。", + "selectedModel": "所选模型", "providerRejected": "{{agent}} 未接受此消息。", "providerRejectedQuoted": "{{agent}} 未接受此消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", diff --git a/src/shared/agent-session/failures/agent-session-failure-copy.ts b/src/shared/agent-session/failures/agent-session-failure-copy.ts index c8ddccf4cab..67f16c264eb 100644 --- a/src/shared/agent-session/failures/agent-session-failure-copy.ts +++ b/src/shared/agent-session/failures/agent-session-failure-copy.ts @@ -116,6 +116,13 @@ export const AGENT_SESSION_FAILURE_COPY = { claudeEffortInvalid: 'Claude effort is invalid; choose low, medium, high, xhigh, or max and send again.', modelUnavailable: "{{agent}} couldn't use {{detail}}. Pick another model and send again.", + selectedModel: 'the selected model', + providerUnavailable: + "{{agent}} couldn't respond because the service is busy. Pick another model, or wait a little and send again.", + providerSafetyStopped: + '{{agent}} stopped this response after a safety check. Review your request and send again.', + providerResponseFailed: + "{{agent}} couldn't finish this response. Open Details for more information.", modelNotSelected: '{{agent}} has no model selected. Check your model selection and {{agent}} sign-in, then send again.', modelUnavailableUpdate: diff --git a/src/shared/agent-session/failures/agent-session-failure-words.ts b/src/shared/agent-session/failures/agent-session-failure-words.ts index fe50b8a9e02..a9a14e3868a 100644 --- a/src/shared/agent-session/failures/agent-session-failure-words.ts +++ b/src/shared/agent-session/failures/agent-session-failure-words.ts @@ -23,6 +23,7 @@ import { import { commandRefusedByReason } from './agent-session-command-refusal-words' import { agent, attachmentProblemSentence } from './agent-session-attachment-failure-words' import { joinSentences } from '../../sentence-joining' +import { modelUnavailableSentence } from './agent-session-model-failure-words' import { quotingPersonDetail } from './agent-session-failure-quoted-detail' import { requestTimeoutSeconds } from '../agent-session-request-timeout' import { @@ -31,6 +32,7 @@ import { DISPATCH_REJECTED_QUEUE_FULL, DISPATCH_REJECTED_WRITE_FAILED } from '../requests/structured-agent-session-dispatch-rejection' + // Build legacy text/reason/error and newer typed facts together. Quote provider text only when authored for a person; shared sentence pieces let desktop translate the same fact. declare const failureSentence: unique symbol @@ -196,13 +198,11 @@ const FAILURE_SENTENCES = { ), restartFailed: couldNot('couldNotRestart'), claudeEffortInvalid: (_context, _fact, _surface, say) => say('claudeEffortInvalid'), - modelUnavailable: (context, fact, _surface, say) => { - const words = { ...agent(say, context), detail: fact.detail?.text ?? 'this model' } - return fact.agentUpdate - ? say('modelUnavailableUpdate', { ...words, minimumVersion: fact.agentUpdate }) - : say('modelUnavailable', words) - }, + modelUnavailable: modelUnavailableSentence, modelNotSelected: (context, _fact, _surface, say) => say('modelNotSelected', agent(say, context)), + providerUnavailable: namingAgent('providerUnavailable'), + providerSafetyStopped: namingAgent('providerSafetyStopped'), + providerResponseFailed: namingAgent('providerResponseFailed'), providerRejected: (context, fact, _surface, say) => quotingPersonDetail( say, @@ -286,7 +286,7 @@ export function agentSessionFailureSentence( fact: AgentSessionFailureFact, surface: AgentSessionFailureSurface, context: AgentSessionFailureWordsContext = {}, - /** Desktop passes its translations; the host and the phone keep English. */ + /** Desktop and phone pass their translations; the host keeps English. */ say: AgentSessionFailureSay = sayAgentSessionFailureEnglish ): string { const sentence: Sentence = FAILURE_SENTENCES[fact.kind] diff --git a/src/shared/agent-session/failures/agent-session-failure.ts b/src/shared/agent-session/failures/agent-session-failure.ts index 60981877dbc..0fd673b2bea 100644 --- a/src/shared/agent-session/failures/agent-session-failure.ts +++ b/src/shared/agent-session/failures/agent-session-failure.ts @@ -52,6 +52,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'providerExited', 'restartFailed', 'providerRejected', + 'providerUnavailable', + 'providerSafetyStopped', + 'providerResponseFailed', 'pluginInputRequired', 'modelUnavailable', /** The agent had no model when the message reached it; why (selection, sign-in) is unknown. */ @@ -101,6 +104,9 @@ export function isAgentSessionFailureKind(value: unknown): value is AgentSession /** Kinds only a status row reports: none is ever why a message was not sent. */ const STATUS_ROW_ONLY_FAILURE_KINDS = [ + 'providerUnavailable', + 'providerSafetyStopped', + 'providerResponseFailed', 'compactionFailed', 'compactionUnconfirmed', 'cancelUnconfirmed', diff --git a/src/shared/agent-session/failures/agent-session-model-failure-words.ts b/src/shared/agent-session/failures/agent-session-model-failure-words.ts new file mode 100644 index 00000000000..660566ebbda --- /dev/null +++ b/src/shared/agent-session/failures/agent-session-model-failure-words.ts @@ -0,0 +1,19 @@ +import type { AgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureSay } from './agent-session-failure-copy' +import type { + AgentSessionFailureSurface, + AgentSessionFailureWordsContext +} from './agent-session-failure-words' +import { agent } from './agent-session-attachment-failure-words' + +export function modelUnavailableSentence( + context: AgentSessionFailureWordsContext, + fact: AgentSessionFailureFact, + _surface: AgentSessionFailureSurface, + say: AgentSessionFailureSay +): string { + const words = { ...agent(say, context), detail: fact.detail?.text ?? say('selectedModel') } + return fact.agentUpdate + ? say('modelUnavailableUpdate', { ...words, minimumVersion: fact.agentUpdate }) + : say('modelUnavailable', words) +} From 2e1c6475ee1147829868239a9a1e5394ab1b6ecf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:35:26 -0800 Subject: [PATCH 057/138] Use Claude CLI titles for default native chat naming (#40069) * Reuse Claude CLI titles for default chat naming * Preserve independent naming while fencing Claude title reuse * Keep optional title account lookup from blocking chat naming * Clarify deferred preparation in naming tests * Bind first chat naming to its ready handover * Exercise title cleanup through a real queued command * Use the timestamped operation fixture for compaction * test: exercise naming fallback through Send now * test: exercise terminal naming fallback after final starts --- .../claude-agent-sdk-control-requests.test.ts | 15 + .../claude-agent-sdk-control-requests.ts | 10 + .../claude-conversation-title-owner.test.ts | 111 +++ .../claude-conversation-title-owner.ts | 88 +++ .../claude-structured-session-adapter.ts | 2 + ...ctured-agent-session-delivery-loop-deps.ts | 5 + .../structured-agent-session-delivery-loop.ts | 4 + .../structured-agent-session-host-delivery.ts | 12 +- .../structured-agent-session-conversations.ts | 5 +- .../structured-agent-session-host-types.ts | 4 + .../host/structured-agent-session-host.ts | 6 +- ...ueued-message-rig-provider.test-fixture.ts | 14 +- ...session-queued-message-rig.test-fixture.ts | 19 +- ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 4 + .../structured-chat-name-publication.ts | 50 ++ .../structured-chat-naming-title.test.ts | 134 ++++ .../structured-chat-naming-types.ts | 52 ++ .../structured-chat-naming-work.ts | 69 +- .../native-chat/structured-chat-naming.ts | 140 ++-- .../structured-chat-title-handover.ts | 183 +++++ .../structured-chat-naming-runtime.test.ts | 643 +++++++++++++++++- .../naming/structured-chat-naming-runtime.ts | 170 ++++- .../agent-session-record-conversation-name.ts | 7 +- .../records/agent-session-record-store.ts | 16 +- 25 files changed, 1636 insertions(+), 130 deletions(-) create mode 100644 src/main/claude/structured/claude-conversation-title-owner.test.ts create mode 100644 src/main/claude/structured/claude-conversation-title-owner.ts create mode 100644 src/main/native-chat/structured-chat-name-publication.ts create mode 100644 src/main/native-chat/structured-chat-naming-title.test.ts create mode 100644 src/main/native-chat/structured-chat-naming-types.ts create mode 100644 src/main/native-chat/structured-chat-title-handover.ts diff --git a/src/main/claude/claude-agent-sdk-control-requests.test.ts b/src/main/claude/claude-agent-sdk-control-requests.test.ts index f2e7f19fff6..ccec589542b 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.test.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.test.ts @@ -6,6 +6,21 @@ afterEach(() => { vi.useRealTimers() }) +it('bounds the optional persisted title request and ignores its late response', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers() + const generateSessionTitle = vi.fn(() => pending.promise) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the feature-detected method is called. + const controls = createClaudeControlSurface({ generateSessionTitle } as unknown as Query) + const result = controls.generateSessionTitle!('Repair login') + const expired = expect(result).rejects.toThrow('claude generate_session_title request timed out') + await vi.advanceTimersByTimeAsync(30_000) + await expired + pending.resolve('Late title') + await expect(result).rejects.toThrow('timed out') + expect(generateSessionTitle).toHaveBeenCalledWith('Repair login', { persist: true }) +}) + describe('createClaudeControlSurface stopTask', () => { it('bounds a lost reply and permits a later stop request', async () => { vi.useFakeTimers() diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index 980093a4e9c..addeba66e28 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -114,6 +114,7 @@ export type ClaudeControlSurface = { getContextUsage: (options?: ClaudeControlOptions) => Promise /** Names `sessionId` as the user's rename; refused once the CLI has moved to another session. */ renameSession: (title: string, sessionId: string, options?: ClaudeControlOptions) => Promise + generateSessionTitle?: (description: string) => Promise } type InterruptingQuery = { @@ -123,7 +124,16 @@ type InterruptingQuery = { } export function createClaudeControlSurface(query: Query): ClaudeControlSurface { + const title = 'generateSessionTitle' in query ? query.generateSessionTitle : null return { + ...(typeof title === 'function' + ? { + generateSessionTitle: (description: string) => + runClaudeControl('generate_session_title', () => + Promise.resolve(title.call(query, description, { persist: true })) + ) + } + : {}), interrupt: (options) => runClaudeControl( 'interrupt', diff --git a/src/main/claude/structured/claude-conversation-title-owner.test.ts b/src/main/claude/structured/claude-conversation-title-owner.test.ts new file mode 100644 index 00000000000..5a31653bbd5 --- /dev/null +++ b/src/main/claude/structured/claude-conversation-title-owner.test.ts @@ -0,0 +1,111 @@ +import { ClaudeProfileRouter } from '../../claude-accounts/claude-profile-router' +import * as installedRouter from '../../claude-accounts/claude-profile-installed-router' +import { afterEach, expect, it, vi } from 'vitest' +import { acquired, fakeClaude, PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +import { agentSessionRecordFixture } from '../../../shared/agent-session/lifecycle/agent-session-record.test-fixture' + +afterEach(() => vi.restoreAllMocks()) + +it('reads only cached support and rejects a retired acquisition or changed account/context', async () => { + const cliFlag = vi.fn(async () => 'supported' as const) + const cli = fakeClaude() + const router = new ClaudeProfileRouter({ + getSettings: () => ({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + }), + dataRoot: '/isolated', + userHome: '/users/test', + env: {} + }) + const preparation = vi.spyOn(router, 'preparation') + vi.spyOn(installedRouter, 'getClaudeProfileRouter').mockReturnValue(router) + const adapter = await acquired(cli, { + cliFlag, + account: 'system', + claudeConfigDir: '/accounts/claude', + accountRecipe: { route: 'system', home: '/accounts/claude', configDir: '/accounts/claude' } + }) + try { + const record = agentSessionRecordFixture() + record.sessionId = 'session-1' + record.accountHome = { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' } + record.providerHandleChain[0]!.handle.nativeId = PROVIDER_SESSION_ID + const generate = vi.fn(async () => 'Broken chat title') + cli.connections[0]!.generateSessionTitle = generate + const owner = adapter.captureConversationTitleOwner(record)! + expect(owner.isCurrent(record)).toBe(true) + expect(await owner.generate('Repair login', () => owner.isCurrent(record))).toBe( + 'Broken chat title' + ) + expect(cliFlag).toHaveBeenLastCalledWith( + { option: 'generate_session_title', firstVersion: '2.1.310' }, + 250, + false + ) + expect( + owner.isCurrent({ ...record, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/other' } }) + ).toBe(false) + expect( + owner.isCurrent({ + ...record, + providerContextBoundary: { operationId: 'clear-1', afterFence: 7, clearedAt: 1 } + }) + ).toBe(false) + const system = router.preparation() + preparation.mockReturnValue({ + ...system, + provenance: 'profile:other', + configDir: '/accounts/other' + }) + expect(owner.isCurrent(record)).toBe(false) + preparation.mockReturnValue(system) + expect(owner.isCurrent(record)).toBe(true) + await adapter.closeAll() + expect(owner.isCurrent(record)).toBe(false) + } finally { + await adapter.closeAll() + cli.dispose() + } +}) + +it.each(['unknown', 'unsupported'] as const)( + 'keeps %s CLIs on the existing generator path', + async (support) => { + const cli = fakeClaude() + const adapter = await acquired(cli, { cliFlag: async () => support }) + try { + const record = agentSessionRecordFixture() + record.sessionId = 'session-1' + record.providerHandleChain[0]!.handle.nativeId = PROVIDER_SESSION_ID + const generate = vi.fn(async () => 'Do not use') + cli.connections[0]!.generateSessionTitle = generate + const owner = adapter.captureConversationTitleOwner(record)! + expect(await owner.generate('Repair login', () => true)).toBeNull() + expect(generate).not.toHaveBeenCalled() + } finally { + await adapter.closeAll() + cli.dispose() + } + } +) + +it('retains retirement knowledge when the optional title method is missing', async () => { + const cli = fakeClaude() + const adapter = await acquired(cli) + try { + const record = agentSessionRecordFixture() + record.sessionId = 'session-1' + record.providerHandleChain[0]!.handle.nativeId = PROVIDER_SESSION_ID + const owner = adapter.captureConversationTitleOwner(record)! + expect(owner.isCurrent(record)).toBe(true) + expect(await owner.generate('Repair login', () => owner.isCurrent(record))).toBeNull() + await adapter.closeAll() + expect(owner.isCurrent(record)).toBe(false) + } finally { + await adapter.closeAll() + cli.dispose() + } +}) diff --git a/src/main/claude/structured/claude-conversation-title-owner.ts b/src/main/claude/structured/claude-conversation-title-owner.ts new file mode 100644 index 00000000000..640b72c668c --- /dev/null +++ b/src/main/claude/structured/claude-conversation-title-owner.ts @@ -0,0 +1,88 @@ +import { resolve } from 'node:path' +import { getClaudeProfileRouter } from '../../claude-accounts/claude-profile-installed-router' +import { agentSessionAccountHomesEqual } from '../../../shared/agent-session/lifecycle/agent-session-account-home' +import { claudeNamingContextId } from '../claude-conversation-naming' +import { CLAUDE_CACHED_FLAG_CHECK_BUDGET_MS } from '../claude-cli-flag-support' +import type { StructuredAgentSessionAdapter } from '../../native-chat/agent-session-wire/providers/structured-agent-session-adapter' +import type { ClaudeSession } from './claude-structured-session-state' + +export const captureClaudeConversationTitleOwner = + ( + sessions: Map + ): NonNullable => + (record) => { + const session = sessions.get(record.sessionId) + if (!session) { + return null + } + const { connection, acquisitionGeneration, fence, providerSessionId, initialization } = session + const boundary = record.providerContextBoundary?.operationId + const account = () => { + try { + const policy = getClaudeProfileRouter()?.preparation() + const recipe = initialization.accountRecipe + if (policy) { + if ((recipe?.route ?? initialization.account) === 'managed') { + if ( + !policy.provenance.startsWith('profile:') || + (recipe?.route === 'managed' && + policy.provenance !== `profile:${recipe.accountId}`) || + resolve(initialization.cwd, policy.configDir) !== initialization.claudeConfigDir + ) { + return null + } + } else if (policy.provenance !== 'system') { + return null + } + } + return JSON.stringify([ + policy?.provenance, + policy?.provenance.startsWith('profile:') ? policy.configDir : null + ]) + } catch { + return null + } + } + const selectedAccount = account() + const isCurrent: NonNullable< + ReturnType> + >['isCurrent'] = (current) => + sessions.get(record.sessionId) === session && + session.connection === connection && + !connection.closed && + !session.closeFinalization && + session.acquisitionGeneration === acquisitionGeneration && + session.fence === fence && + current.lease.runtimeFence === fence && + current.provider === 'claude' && + session.providerSessionId === providerSessionId && + claudeNamingContextId(current) === providerSessionId && + current.providerContextBoundary?.operationId === boundary && + agentSessionAccountHomesEqual(current.accountHome, record.accountHome) && + current.location.workspaceId === record.location.workspaceId && + current.location.executionHostId === record.location.executionHostId && + session.initialization === initialization && + selectedAccount !== null && + account() === selectedAccount + return { + isCurrent, + generate: async (firstPrompt, shouldExecute) => { + if ( + !session.initialization.startup.opened() || + !connection.generateSessionTitle || + !shouldExecute() + ) { + return null + } + // Verified support floor, not the request's introduction version; never start a probe here. + const supported = await session.cliFlag?.( + { option: 'generate_session_title', firstVersion: '2.1.310' }, + CLAUDE_CACHED_FLAG_CHECK_BUDGET_MS, + false + ) + return supported === 'supported' && shouldExecute() + ? connection.generateSessionTitle?.(firstPrompt) + : null + } + } + } diff --git a/src/main/claude/structured/claude-structured-session-adapter.ts b/src/main/claude/structured/claude-structured-session-adapter.ts index 08a6f150dfc..45ea207c93a 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.ts @@ -50,9 +50,11 @@ import { claudePromptCancelRoute } from './journal/claude-structured-prompt-repl import { claudeForkForRewind } from '../claude-rewind-fork' import { copyClaudeConversation } from '../claude-conversation-copy' import { ClaudeConversationNaming } from '../claude-conversation-naming' +import { captureClaudeConversationTitleOwner } from './claude-conversation-title-owner' export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { private readonly sessions = new Map() + captureConversationTitleOwner = captureClaudeConversationTitleOwner(this.sessions) private readonly acquisitions = new ClaudeAcquisitionRegistry() private readonly exits = new Map() private readonly settledExitErrors = new Map() diff --git a/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop-deps.ts b/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop-deps.ts index b8502b87fe4..040de3949c9 100644 --- a/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop-deps.ts +++ b/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop-deps.ts @@ -1,4 +1,5 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session/journal/agent-session-journal-types' +import type { AgentSessionJournal } from '../../agent-session-journal/journal-store' import type { UnsentSendHold } from '../../agent-session-journal/queued-messages/journal-unsent-send-hold' import type { AgentChatPermissionChoice } from '../../../../shared/agent-launch/permissions/agent-chat-permission-mode' import type { StructuredAgentSessionStartFlights } from '../startup/structured-agent-session-start-flight' @@ -20,6 +21,10 @@ import type { } from '../host/structured-agent-session-host-types' export type StructuredAgentSessionDeliveryLoopDeps = { + captureTitleSourceUnavailable?: ( + sessionId: string, + journal: AgentSessionJournal + ) => (() => void) | undefined startFlights?: Pick deliveryOrder: StructuredAgentSessionDeliveryOrder sessions: ReadonlyMap diff --git a/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop.ts index 392fab76744..584b12e8095 100644 --- a/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-delivery-loop.ts @@ -299,6 +299,10 @@ export class StructuredAgentSessionDeliveryLoop { /** Clear under serialization so the next accept can wake a new loop. */ private stop(sessionId: string): 'stop' { + const journal = this.deps.sessions.get(sessionId)?.journal + if (journal && this.running.has(sessionId)) { + this.deps.captureTitleSourceUnavailable?.(sessionId, journal)?.() + } this.running.delete(sessionId) this.deps.deliveryOrder.parkSends(sessionId) return 'stop' diff --git a/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-host-delivery.ts index 4d22aeb5e8a..7537b242ae6 100644 --- a/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/delivery/structured-agent-session-host-delivery.ts @@ -87,6 +87,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { > }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input + const captureTitleSourceUnavailable = (sessionId: string, journal: AgentSessionJournal) => + deps.conversationNaming?.captureTitleSourceUnavailable?.(sessionId, journal) const retainedWork = (sessionId: string, continuingId?: string) => ({ currentWork: () => hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), @@ -101,6 +103,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { [...(sessions.get(sessionId)?.child?.heldSends?.() ?? [])].some((id) => id !== continuingId) }) const loop = new StructuredAgentSessionDeliveryLoop({ + captureTitleSourceUnavailable, sessions, adapter: deps.adapter, agents: deps.agents, @@ -239,13 +242,19 @@ export function createStructuredAgentSessionConversationDelivery(input: { const wakesQueued = new Set() const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { input.deliveryOrder.reconcile(sessionId, journal) + if (wakesQueued.has(sessionId)) { + return + } + const sourceUnavailable = captureTitleSourceUnavailable(sessionId, journal) const work = hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId) if ( - wakesQueued.has(sessionId) || !work || structuredAgentSessionCommandRunning(work) || !selectableStructuredSubmission(journal, work) ) { + if (!loop.isRunning(sessionId)) { + sourceUnavailable?.() + } return } wakesQueued.add(sessionId) @@ -256,6 +265,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) .catch((error: unknown) => { wakesQueued.delete(sessionId) + sourceUnavailable?.() deps.logger.warn('waking the delivery loop after a commit failed', { scope: 'delivery-wake', sessionId, diff --git a/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversations.ts index dba13c59687..69faf212bc2 100644 --- a/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/host/structured-agent-session-conversations.ts @@ -138,7 +138,10 @@ export class StructuredAgentSessionConversations extends Map< return adopted } - observeConversationPublications(sessionId: string, observer: PublicationObserver): () => void { + observeConversationPublications = ( + sessionId: string, + observer: PublicationObserver + ): (() => void) => { if (this.ended) { this.deliverHint(observer, { kind: 'end', source: null, journal: null }, () => {}) return () => {} diff --git a/src/main/native-chat/agent-session-wire/host/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/host/structured-agent-session-host-types.ts index 47a2ed7ede4..9673d8f4f10 100644 --- a/src/main/native-chat/agent-session-wire/host/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/host/structured-agent-session-host-types.ts @@ -245,6 +245,10 @@ export type SetUserConversationName = StructuredAgentSessionConversationNaming[' export type StructuredChatNamingFailure = AgentSessionNamingError & { forName: string } export type StructuredAgentSessionConversationNaming = { + captureTitleSourceUnavailable?: ( + sessionId: string, + journal: AgentSessionJournal + ) => (() => void) | undefined publishName: (workspaceId: string, sessionId: string) => void activeSessionIds: () => string[] failure: (sessionId: string) => StructuredChatNamingFailure | undefined diff --git a/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts index 2413f4f7a98..1d4ed8ab39b 100644 --- a/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/host/structured-agent-session-host.ts @@ -304,9 +304,9 @@ export class StructuredAgentSessionHost { readonly continuationContext: ReturnType - observeConversationPublications: typeof this.sessions.observeConversationPublications = ( - ...args - ) => this.sessions.observeConversationPublications(...args) + observeConversationPublications = this.sessions.observeConversationPublications + beginConversationTitleWait = (sessionId: string) => + this.runtimeState.acquireAborts.begin(sessionId) journalSnapshot = (sessionId: string) => this.backgroundTasks.journalSnapshot(sessionId) journalCurrentRead = this.reads.journalCurrentRead queuedSendAnswer = this.reads.queuedSendAnswer diff --git a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 49d54ab848a..8bd8ec50fd5 100644 --- a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -1,5 +1,5 @@ import type { AgentMessageSource } from '../../../../../shared/agent-session/agent-session-message-source' -import { vi, type Mock } from 'vitest' +import { expect, vi, type Mock } from 'vitest' import { parseAgentJournalItemKey } from '../../../../../shared/agent-session/journal/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../../shared/agent-session/journal/agent-session-journal-types' import { activeProviderContext } from '../../../../../shared/agent-session/agent-session-provider-context' @@ -35,6 +35,18 @@ export type QueuedRigProviderOptions = { /** How long a winding-down child is given after the interrupt before the Stop ends it. */ export const QUEUED_RIG_STOP_GRACE_MS = 2_000 +export async function proveQueuedRigStart( + host: StructuredAgentSessionHost, + sessionId: string, + proveOpen: () => void +): Promise { + await eventually(() => + expect(host.collaboratorsForTests().sessions.get(sessionId)?.child?.phase).toBe('starting') + ) + proveOpen() + await Promise.resolve() +} + export function createQueuedRigProvider( store: Pick, options: QueuedRigProviderOptions, diff --git a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig.test-fixture.ts index d5bc36ec59d..7325f03f268 100644 --- a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig.test-fixture.ts @@ -32,11 +32,13 @@ import { seedQueuedRigLegacyJournal } from './structured-agent-session-queued-le import { createStructuredAgentSessionLogger } from '../structured-agent-session-logger' import { createQueuedRigProvider, + proveQueuedRigStart, eventually, type RigSendOptions, type QueuedRigProviderOptions } from './structured-agent-session-queued-message-rig-provider.test-fixture' import { claudeAndCodexDeclared } from '../../providers/testing/structured-agent-session-adapter-router-test-support' +import { createTestParams } from './structured-agent-session-create-test-fixture' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } export type QueuedMessageTestRig = Awaited> @@ -44,6 +46,7 @@ export type QueuedMessageTestRig = Awaited number @@ -97,7 +100,11 @@ export async function createQueuedMessageTestRig( : {}) }) const pinLegacy = options.legacyInputs && seedQueuedRigLegacyJournal(root, SESSION, options.agent) - expect(await host.attach(QUEUED_RIG_CALLER, firstAttach)).toMatchObject({ ok: true }) + expect( + options.atRest + ? await host.create(QUEUED_RIG_CALLER, createTestParams(undefined, firstAttach)) + : await host.attach(QUEUED_RIG_CALLER, firstAttach) + ).toMatchObject({ ok: true }) if (pinLegacy) { pinLegacy(host) } @@ -274,14 +281,6 @@ export async function createQueuedMessageTestRig( }) } - async function proveStart(): Promise { - await eventually(() => - expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - ) - provider.proveOpen() - await Promise.resolve() - } - async function dispose(): Promise { await host.flushAllStreamedEvents() await closeTestJournalHostDatabase(root) @@ -325,7 +324,7 @@ export async function createQueuedMessageTestRig( queuePause, restartOffers, resume, - proveStart, + proveStart: () => proveQueuedRigStart(host, SESSION, provider.proveOpen), dispose } } diff --git a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts index 3427d6bf0c6..492ff9bc30c 100644 --- a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter-router.ts @@ -96,6 +96,9 @@ export class StructuredAgentSessionAdapterRouter implements Adapter { } } + captureConversationTitleOwner: NonNullable = (record) => + this.liveOwnerOrNull(record.sessionId)?.captureConversationTitleOwner?.(record) ?? null + renameProviderSession: NonNullable = (input) => this.capabilityOwner( input.record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts index d327cb5c9d3..31a3adcbf6b 100644 --- a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts @@ -347,6 +347,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & inheritance?: boolean }): Promise | undefined stopProviderNaming?(): void + captureConversationTitleOwner?(record: AgentSessionRecord): { + isCurrent: (record: AgentSessionRecord) => boolean + generate: (firstPrompt: string, shouldExecute: () => boolean) => Promise + } | null /** True proves child exit; root/processless verdicts throw only after finalization (stopAgentSessionProviderRoot). */ closeSession?( sessionId: string, diff --git a/src/main/native-chat/structured-chat-name-publication.ts b/src/main/native-chat/structured-chat-name-publication.ts new file mode 100644 index 00000000000..f344d0fc605 --- /dev/null +++ b/src/main/native-chat/structured-chat-name-publication.ts @@ -0,0 +1,50 @@ +import type { AgentSessionRecord } from '../../shared/agent-session/lifecycle/agent-session-record' +import type { StructuredChatNamingDeps } from './structured-chat-naming' + +export async function runStructuredChatNameGeneration(input: { + automaticTitle?: Parameters[3] + getRecord: () => AgentSessionRecord | null | undefined + applicable: (current?: AgentSessionRecord) => boolean + generate: ( + stillCurrent: () => boolean, + automaticTitle?: Parameters[3] + ) => Promise<{ name: string; shouldApply?: (record?: AgentSessionRecord) => boolean } | null> + setName: ( + name: string, + applicable: (record: AgentSessionRecord) => boolean + ) => Promise +}): Promise { + let owner: ((record: AgentSessionRecord) => boolean) | undefined + const stillCurrent = (latest?: AgentSessionRecord) => { + const record = latest ?? input.getRecord() + return !!record && input.applicable(record) && (owner?.(record) ?? true) + } + if (!stillCurrent()) { + return + } + const name = await input.generate( + stillCurrent, + input.automaticTitle + ? { + ...input.automaticTitle, + retainOwnership: (check) => { + owner = check + } + } + : undefined + ) + if (!name || !stillCurrent()) { + return + } + const named = await input.setName( + name.name, + (current) => stillCurrent(current) && (name.shouldApply?.(current) ?? true) + ) + // Independent fallback retains the canonical name/context owner; only timeout retains live ownership. + if (!named && name.shouldApply && stillCurrent()) { + const fallback = await input.generate(stillCurrent) + if (fallback && stillCurrent()) { + await input.setName(fallback.name, stillCurrent) + } + } +} diff --git a/src/main/native-chat/structured-chat-naming-title.test.ts b/src/main/native-chat/structured-chat-naming-title.test.ts new file mode 100644 index 00000000000..c83dbaab30a --- /dev/null +++ b/src/main/native-chat/structured-chat-naming-title.test.ts @@ -0,0 +1,134 @@ +import { expect, it, vi } from 'vitest' +import { + createStructuredChatNamingHandler, + type StructuredChatNamingDeps +} from './structured-chat-naming' +import { agentSessionRecordFixture } from './agent-session-record-test-fixture' +import { + compareAndSetAgentSessionRecordName, + setAgentSessionRecordUserName +} from '../runtime/agent-session/records/agent-session-record-conversation-name' + +function rig( + generate: StructuredChatNamingDeps['generate'], + captureAutomaticTitle: StructuredChatNamingDeps['captureAutomaticTitle'] = () => ({ owner: null }) +) { + let record = agentSessionRecordFixture({ sessionId: 'chat-1' }) + let beforeWrite: (() => Promise) | undefined + const handler = createStructuredChatNamingHandler({ + getStore: () => ({ + getRecord: () => record, + compareAndSetConversationName: async (_id, name, expected, context, applicable) => + compareAndSetAgentSessionRecordName( + async (apply) => { + if (expected !== null) { + await beforeWrite?.() + } + record = apply(record) + return record + }, + name, + expected, + context, + applicable + ), + setUserConversationName: async (_id, name, write) => { + const result = setAgentSessionRecordUserName(record, name, write, 1) + record = result.record + return result + } + }), + getSettings: () => ({ nativeChatAutoName: true }), + readFirstPrompt: async () => 'Repair login', + readJournalFirstPrompt: async () => 'Repair login', + workerTaskTitle: () => null, + generate, + captureAutomaticTitle, + wait: async () => {}, + onNamed: () => {}, + logger: { warn: vi.fn(), error: vi.fn() } + }) + return { + handler, + record: () => record, + beforeWrite: (run: () => Promise) => { + beforeWrite = run + }, + start: () => + handler( + { + sessionId: 'chat-1', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'working', + latestPrompt: 'Repair login', + updatedAt: 1 + }, + { replay: false, firstInputSubmissionKey: 'input-1' } + ), + done: () => vi.waitFor(() => expect(handler.activeSessionIds()).toEqual([])) + } +} + +it('tries CLI reuse once while independent generation retries, and skips it on manual regeneration', async () => { + const reuse = vi.fn() + const generate = vi.fn( + async (_record, _prompt, _current, automatic) => { + if (automatic) { + reuse() + } + return generate.mock.calls.length === 1 + ? { error: 'Retry independent generation' } + : { name: 'Login repair' } + } + ) + const state = rig(generate) + state.start() + await state.done() + expect(state.record().conversationName).toBe('Login repair') + expect(reuse).toHaveBeenCalledOnce() + state.handler.regenerate('chat-1') + await state.done() + expect(reuse).toHaveBeenCalledOnce() +}) + +it.each(['owner', 'same-text rename'] as const)( + 'rejects a queued title write after %s changes', + async (change) => { + let owned = true + const state = rig( + async (_record, _prompt, _current, automatic) => + automatic ? { name: 'CLI title', shouldApply: () => owned } : { name: 'Independent title' }, + () => ({ + owner: { isCurrent: () => owned, generate: async () => null } + }) + ) + state.beforeWrite(async () => { + if (change === 'owner') { + owned = false + } else { + await state.handler.setUserName('chat-1', 'Repair login', 'rename') + } + }) + state.start() + await state.done() + expect(state.record().conversationName).toBe( + change === 'owner' ? 'Independent title' : 'Repair login' + ) + } +) + +it('uses current naming settings if they change while the title write is queued', async () => { + let eligible = true + const generate = vi.fn( + async (_record, _prompt, _current, automatic) => + automatic ? { name: 'CLI title', shouldApply: () => eligible } : { name: 'Configured title' } + ) + const state = rig(generate) + state.beforeWrite(async () => { + eligible = false + }) + state.start() + await state.done() + expect(state.record().conversationName).toBe('Configured title') +}) diff --git a/src/main/native-chat/structured-chat-naming-types.ts b/src/main/native-chat/structured-chat-naming-types.ts new file mode 100644 index 00000000000..9f8b4c8fb34 --- /dev/null +++ b/src/main/native-chat/structured-chat-naming-types.ts @@ -0,0 +1,52 @@ +import type { AgentSessionRecord } from '../../shared/agent-session/lifecycle/agent-session-record' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { AgentSessionRecordStore } from '../runtime/agent-session/records/agent-session-record-store' +import type { AgentGenerationFailureOutput } from '../text-generation/agent-failure-output' +import type { StructuredAgentSessionLogger } from './agent-session-wire/host/structured-agent-session-logger' +import type { StructuredAgentSessionAdapter } from './agent-session-wire/providers/structured-agent-session-adapter' +import type { AgentSessionJournal } from './agent-session-journal/journal-store' + +export type AutomaticChatTitle = { + owner: ReturnType> + pending?: { + ready: Promise + ended: () => boolean + recheck: () => void + unavailable: (journal: AgentSessionJournal) => void + dispose: () => void + } + retainOwnership?: (isCurrent: (record: AgentSessionRecord) => boolean) => void +} + +/** null means unavailable/canceled: no failure to display or retry. */ +export type StructuredChatNameGeneration = + | { name: string; shouldApply?: (record?: AgentSessionRecord) => boolean } + | { error: string; errorCode?: string; failureOutput?: AgentGenerationFailureOutput } + | null + +export type StructuredChatNamingDeps = { + getStore: () => Pick< + AgentSessionRecordStore, + 'getRecord' | 'compareAndSetConversationName' | 'setUserConversationName' + > | null + getSettings: () => Pick + readFirstPrompt: (sessionId: string, hostStartedAt: number) => Promise + /** The current context's first prompt, for a name the user asked for again. */ + readJournalFirstPrompt: (sessionId: string) => Promise + now?: () => number + workerTaskTitle: (record: AgentSessionRecord) => { title: string } | null + captureAutomaticTitle?: ( + record: AgentSessionRecord, + firstInputSubmissionKey?: string, + applicable?: () => boolean + ) => AutomaticChatTitle | undefined + generate: ( + record: AgentSessionRecord, + firstPrompt: string, + shouldExecute?: () => boolean, + automaticTitle?: AutomaticChatTitle + ) => Promise + onNamed: (workspaceId: string, sessionId: string) => void + logger: StructuredAgentSessionLogger + wait?: (ms: number) => Promise +} diff --git a/src/main/native-chat/structured-chat-naming-work.ts b/src/main/native-chat/structured-chat-naming-work.ts index 0772d0f3446..c3a9ab0f345 100644 --- a/src/main/native-chat/structured-chat-naming-work.ts +++ b/src/main/native-chat/structured-chat-naming-work.ts @@ -1,8 +1,45 @@ import type { AgentSessionRecord } from '../../shared/agent-session/lifecycle/agent-session-record' +import type { AutomaticChatTitle, StructuredChatNamingDeps } from './structured-chat-naming-types' +import type { AgentSessionJournal } from './agent-session-journal/journal-store' -export type NamingRequest = { record: AgentSessionRecord; automatic: boolean; firstPrompt?: string } +export type NamingRequest = { + record: AgentSessionRecord + automatic: boolean + firstPrompt?: string + automaticTitle?: AutomaticChatTitle + seedingName?: string | null +} type NamingJob = { desired?: NamingRequest; executing?: NamingRequest; preparing: boolean } +export function namingRequestOwnsRecord(request: NamingRequest, current: AgentSessionRecord) { + return ( + !request.automaticTitle?.pending?.ended() && + current.conversationNameSource !== 'user' && + (current.conversationName === request.record.conversationName || + (request.seedingName !== undefined && current.conversationName === request.seedingName)) && + (current.providerContextBoundary?.operationId ?? null) === + (request.record.providerContextBoundary?.operationId ?? null) + ) +} + +export function automaticChatNamingRequest( + deps: StructuredChatNamingDeps, + record: AgentSessionRecord, + firstInputSubmissionKey: string | undefined, + applicable: (request: NamingRequest) => boolean +): NamingRequest { + const request: NamingRequest = { record, automatic: true } + if ( + record.conversationName === undefined && + !(!record.providerContextBoundary && deps.workerTaskTitle(record)) + ) { + request.automaticTitle = deps.captureAutomaticTitle?.(record, firstInputSubmissionKey, () => + applicable(request) + ) + } + return request +} + export function createStructuredChatNamingWork(deps: { applicable: (request: NamingRequest) => boolean prepare: (request: NamingRequest) => Promise @@ -21,6 +58,7 @@ export function createStructuredChatNamingWork(deps: { .execute(request, firstPrompt) .catch((error: unknown) => deps.warn(sessionId, error)) .finally(() => { + request.automaticTitle?.pending?.dispose() job.executing = undefined drain(sessionId, job) deps.changed(request.record) @@ -31,7 +69,8 @@ export function createStructuredChatNamingWork(deps: { } } return { - run(record: AgentSessionRecord, automatic: boolean) { + run(request: NamingRequest) { + const { record, automatic } = request const { sessionId } = record const job: NamingJob = jobs.get(sessionId) ?? { preparing: false } const pending = job.desired ?? job.executing @@ -42,9 +81,12 @@ export function createStructuredChatNamingWork(deps: { record.providerContextBoundary?.operationId && !(automatic && !pending.automatic && pending.firstPrompt === undefined) ) { + request.automaticTitle?.pending?.dispose() return } - job.desired = { record, automatic } + job.desired?.automaticTitle?.pending?.dispose() + job.executing?.automaticTitle?.pending?.dispose() + job.desired = request jobs.set(sessionId, job) if (!job.preparing) { job.preparing = true @@ -53,10 +95,14 @@ export function createStructuredChatNamingWork(deps: { const request = job.desired try { const prepared = await deps.prepare(request) + if (!prepared) { + request.automaticTitle?.pending?.dispose() + } if (job.desired === request) { job.desired = prepared ?? undefined } } catch (error) { + request.automaticTitle?.pending?.dispose() if (job.desired === request) { job.desired = undefined } @@ -73,6 +119,23 @@ export function createStructuredChatNamingWork(deps: { } deps.changed(record) }, + recheck(sessionId: string) { + const job = jobs.get(sessionId) + job?.desired?.automaticTitle?.pending?.recheck() + job?.executing?.automaticTitle?.pending?.recheck() + }, + captureSourceUnavailable(sessionId: string, journal: AgentSessionJournal) { + const job = jobs.get(sessionId) + const desired = job?.desired?.automaticTitle?.pending + const executing = job?.executing?.automaticTitle?.pending + if (!desired && !executing) { + return undefined + } + return () => { + desired?.unavailable(journal) + executing?.unavailable(journal) + } + }, activeSessionIds: () => [...jobs] .filter( diff --git a/src/main/native-chat/structured-chat-naming.ts b/src/main/native-chat/structured-chat-naming.ts index 98604d411f9..ec4e230b256 100644 --- a/src/main/native-chat/structured-chat-naming.ts +++ b/src/main/native-chat/structured-chat-naming.ts @@ -1,5 +1,19 @@ +import type { + StructuredChatNameGeneration, + StructuredChatNamingDeps +} from './structured-chat-naming-types' +export type { + StructuredChatNameGeneration, + StructuredChatNamingDeps +} from './structured-chat-naming-types' +import { runStructuredChatNameGeneration } from './structured-chat-name-publication' import { chatNamingFailure } from '../../shared/chat-naming-failure-copy' -import { createStructuredChatNamingWork, type NamingRequest } from './structured-chat-naming-work' +import { + automaticChatNamingRequest, + createStructuredChatNamingWork, + namingRequestOwnsRecord, + type NamingRequest +} from './structured-chat-naming-work' import type { StructuredAgentSessionStatusObserverOptions } from './agent-session-wire/observations/structured-agent-session-status-observation' import type { AgentSessionRecord } from '../../shared/agent-session/lifecycle/agent-session-record' import type { AgentSessionStatusSummary } from '../../shared/agent-session/agent-session-wire' @@ -9,8 +23,6 @@ import { } from '../../shared/source-control/ai/generation/conversation-name-generation' import { hasStructuredChatPromptText } from '../../shared/agent-session/requests/structured-agent-session-first-prompt' import { sliceAtCodeUnitLimit } from '../../shared/surrogate-safe-text-slice' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { AgentSessionRecordStore } from '../runtime/agent-session/records/agent-session-record-store' import { formatAgentGenerationFailureOutputForDisplay, type AgentGenerationFailureOutput @@ -19,37 +31,7 @@ import type { StructuredAgentSessionConversationNaming, StructuredChatNamingFailure } from './agent-session-wire/host/structured-agent-session-host-types' -import { - neverThrowingStructuredAgentSessionLogger, - type StructuredAgentSessionLogger -} from './agent-session-wire/host/structured-agent-session-logger' - -/** null means unavailable/canceled: no failure to display or retry. */ -export type StructuredChatNameGeneration = - | { name: string } - | { error: string; errorCode?: string; failureOutput?: AgentGenerationFailureOutput } - | null - -export type StructuredChatNamingDeps = { - getStore: () => Pick< - AgentSessionRecordStore, - 'getRecord' | 'compareAndSetConversationName' | 'setUserConversationName' - > | null - getSettings: () => Pick - readFirstPrompt: (sessionId: string, hostStartedAt: number) => Promise - /** The current context's first prompt, for a name the user asked for again. */ - readJournalFirstPrompt: (sessionId: string) => Promise - now?: () => number - workerTaskTitle: (record: AgentSessionRecord) => { title: string } | null - generate: ( - record: AgentSessionRecord, - firstPrompt: string, - shouldExecute?: () => boolean - ) => Promise - onNamed: (workspaceId: string, sessionId: string) => void - logger: StructuredAgentSessionLogger - wait?: (ms: number) => Promise -} +import { neverThrowingStructuredAgentSessionLogger } from './agent-session-wire/host/structured-agent-session-logger' const GENERATION_RETRY_DELAYS_MS = [2000, 4000] const NAMING_ERROR_LIMIT = 1024 @@ -79,6 +61,7 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps error }) const refresh = (workspaceId: string, sessionId: string) => { + work.recheck(sessionId) try { deps.onNamed(workspaceId, sessionId) } catch (error) { @@ -89,33 +72,36 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps const record = deps.getStore()?.getRecord(sessionId) return !!record && contextId(record) === context } - const applicable = (request: NamingRequest) => { - const store = deps.getStore() + const applicable = (request: NamingRequest, latest?: AgentSessionRecord) => { + const current = latest ?? deps.getStore()?.getRecord(request.record.sessionId) return ( - !!store && - generationStillCurrent(store, request.record) && + !!current && + namingRequestOwnsRecord(request, current) && (!request.automatic || deps.getSettings().nativeChatAutoName !== false) ) } const work = createStructuredChatNamingWork({ applicable, prepare, - execute: async (request, firstPrompt) => { - if (!applicable(request)) { - return - } - const name = await generateName( - request.record, - firstPrompt, - request.record.conversationName ?? '', - request.automatic ? GENERATION_RETRY_DELAYS_MS : [], - () => applicable(request) - ) - const store = deps.getStore() - if (name && store && applicable(request)) { - await setName(store, request.record, name) - } - }, + execute: (request, firstPrompt) => + runStructuredChatNameGeneration({ + automaticTitle: request.automaticTitle, + getRecord: () => deps.getStore()?.getRecord(request.record.sessionId), + applicable: (current) => applicable(request, current), + generate: (stillCurrent, automaticTitle) => + generateName( + request.record, + firstPrompt, + request.record.conversationName ?? '', + request.automatic ? GENERATION_RETRY_DELAYS_MS : [], + stillCurrent, + automaticTitle + ), + setName: async (name, applicable) => { + const store = deps.getStore() + return store ? setName(store, request.record, name, applicable) : null + } + }), changed: (record) => refresh(record.location.workspaceId, record.sessionId), warn }) @@ -153,18 +139,24 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps firstPrompt: string, forName: string, retryDelays: readonly number[], - stillCurrent: () => boolean - ): Promise { + stillCurrent: () => boolean, + automaticTitle?: Parameters[3] + ): Promise<{ name: string; shouldApply?: (record?: AgentSessionRecord) => boolean } | null> { attempted.set(record.sessionId, contextId(record)) for (let attempt = 0; stillCurrent(); attempt += 1) { let failure: NamingRunFailure try { - const result = await deps.generate(record, firstPrompt, stillCurrent) + const result = await deps.generate( + record, + firstPrompt, + stillCurrent, + attempt === 0 ? automaticTitle : undefined + ) if (!result) { return null } if ('name' in result && result.name) { - return result.name + return result } failure = 'error' in result ? result : chatNamingFailure('noName') } catch (error) { @@ -189,27 +181,24 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps return null } - async function setName(store: NamingStore, record: AgentSessionRecord, name: string) { + async function setName( + store: NamingStore, + record: AgentSessionRecord, + name: string, + applicable?: (record: AgentSessionRecord) => boolean + ) { const named = await store.compareAndSetConversationName( record.sessionId, name, record.conversationName ?? null, - contextId(record) + contextId(record), + applicable ) if (named) { forget(record.sessionId) refresh(named.location.workspaceId, named.sessionId) } - } - - const generationStillCurrent = (store: NamingStore, record: AgentSessionRecord) => { - const current = store.getRecord(record.sessionId) - return ( - current !== null && - current.conversationNameSource !== 'user' && - current.conversationName === record.conversationName && - contextId(current) === contextId(record) - ) + return named } async function prepare(request: NamingRequest): Promise { @@ -241,9 +230,10 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps return { ...request, firstPrompt } } // Seeding is independent of the expensive execution owner. + request.seedingName = conversationNameSeed(firstPrompt) const seeded = await store.compareAndSetConversationName( record.sessionId, - conversationNameSeed(firstPrompt), + request.seedingName, null, contextId(record) ) @@ -251,6 +241,7 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps return null } request.record = seeded + delete request.seedingName request.firstPrompt = firstPrompt refresh(seeded.location.workspaceId, seeded.sessionId) return request @@ -274,7 +265,9 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps return } attempted.set(summary.sessionId, contextId(record)) - work.run(record, true) + work.run( + automaticChatNamingRequest(deps, record, options.firstInputSubmissionKey, applicable) + ) } catch (error) { warn(summary.sessionId, error) } @@ -282,6 +275,7 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps const naming: StructuredAgentSessionConversationNaming = { publishName: refresh, + captureTitleSourceUnavailable: work.captureSourceUnavailable, activeSessionIds: work.activeSessionIds, failure, failureOutput: (sessionId) => { @@ -294,7 +288,7 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps if (!store || !record) { return false } - work.run(record, false) + work.run({ record, automatic: false }) return true }, // Not a job: a pending generation must not swallow the user's rename. The record's 'user' diff --git a/src/main/native-chat/structured-chat-title-handover.ts b/src/main/native-chat/structured-chat-title-handover.ts new file mode 100644 index 00000000000..ecd72d6daad --- /dev/null +++ b/src/main/native-chat/structured-chat-title-handover.ts @@ -0,0 +1,183 @@ +import type { AgentSessionRecord } from '../../shared/agent-session/lifecycle/agent-session-record' +import { sessionCrewChanges } from '../runtime/orchestration/crew/crew-changes' +import type { StructuredAgentSessionHost } from './agent-session-wire/host/structured-agent-session-host' +import type { JournalPublication } from './agent-session-wire/host/structured-agent-session-conversations' +import type { AutomaticChatTitle } from './structured-chat-naming-types' + +export function deferredStructuredChatTitle(input: { + record: AgentSessionRecord + firstInputSubmissionKey: string + host: StructuredAgentSessionHost + currentHost: () => StructuredAgentSessionHost | null + applicable: () => boolean + eligible: () => boolean + subscribeSettings: (changed: () => void) => () => void +}): NonNullable { + const { sessionId } = input.record + const ready = Promise.withResolvers() + const releases: (() => void)[] = [] + let settled = false + let ended = false + let publication: JournalPublication | undefined + const release = (dispose: () => void) => (settled ? dispose() : releases.push(dispose)) + const settle = (owner: AutomaticChatTitle['owner'] = null) => { + if (settled) { + return + } + settled = true + for (const dispose of releases.splice(0)) { + dispose() + } + ready.resolve(owner) + } + const dispose = () => { + ended = true + settle() + } + const store = input.host.deps.store + const recheck = () => { + if (settled) { + return + } + try { + if ( + input.currentHost() !== input.host || + input.host.deps.store !== store || + !input.applicable() + ) { + dispose() + } else if (!input.eligible()) { + settle() + } + } catch { + settle() + } + } + const pending: NonNullable = { + ready: ready.promise, + ended: () => ended, + recheck, + unavailable: (journal) => { + if (publication?.journal === journal) { + settle() + } + }, + dispose + } + let key: unknown + try { + key = JSON.parse(input.firstInputSubmissionKey) + } catch { + settle() + } + if ( + !Array.isArray(key) || + key.length !== 2 || + typeof key[0] !== 'string' || + !key[0] || + typeof key[1] !== 'string' || + !key[1] + ) { + settle() + return pending + } + const [epoch, clientMessageId] = key + const abort = input.host.beginConversationTitleWait(sessionId) + release(abort.end) + const aborted = () => settle() + abort.signal.addEventListener('abort', aborted, { once: true }) + release(() => abort.signal.removeEventListener('abort', aborted)) + if (abort.signal.aborted) { + settle() + } + if (settled) { + return pending + } + try { + release(input.subscribeSettings(recheck)) + release( + sessionCrewChanges.subscribe((facets) => { + if (facets.has('topology') || facets.has('records')) { + recheck() + } + }) + ) + release( + input.host.observeConversationPublications(sessionId, { + next: (hint) => { + if (settled) { + return + } + if (hint.kind === 'end') { + dispose() + return + } + recheck() + if (settled) { + return + } + if ( + hint.kind === 'detach' || + !hint.source || + !hint.journal || + hint.cursor?.epoch !== epoch + ) { + settle() + return + } + if (publication && (publication.source !== hint.source || hint.kind === 'reset')) { + settle() + return + } + const submission = hint.journal.submission(clientMessageId) + const card = submission?.queuedMessageId + ? hint.journal.queuedMessages.get(submission.queuedMessageId) + : undefined + if ( + !submission || + submission.dispatchState !== 'pending' || + card?.holdReason || + card?.state === 'withdrawn' || + card?.state === 'returned' + ) { + settle() + return + } + if (!publication) { + publication = hint + // A reset cannot prove which child first received an already handed-over input. + if ( + hint.kind !== 'reset' || + submission.handedOverAt !== undefined || + !submission.handoverRecorded + ) { + settle() + } + return + } + for (const row of hint.rows ?? []) { + if ( + row.kind !== 'dispatch' || + row.state !== 'pending' || + row.epoch !== hint.journal.physicalCursor().epoch || + row.clientMessageId !== clientMessageId + ) { + continue + } + const record = store.getRecord(sessionId) + const owner = + record && record.lease.runtimeFence === row.fence + ? input.host.deps.adapter.captureConversationTitleOwner?.(record) + : null + settle(owner && record && owner.isCurrent(record) ? owner : null) + return + } + }, + error: () => settle() + }) + ) + } catch { + settle() + } + return pending +} diff --git a/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.test.ts b/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.test.ts index f4082664bd6..b2bad3c6433 100644 --- a/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.test.ts +++ b/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.test.ts @@ -1,3 +1,19 @@ +import { ClaudeProfileRouter } from '../../../claude-accounts/claude-profile-router' +import { + acquired, + fakeClaude, + identityFor, + recordingJournalSink, + PROVIDER_SESSION_ID +} from '../../../claude/structured/claude-structured-session-test-support' +import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record' +import type { Query } from '@anthropic-ai/claude-agent-sdk' +import { createClaudeControlSurface } from '../../../claude/claude-agent-sdk-control-requests' +import { + createStructuredChatNamingHandler, + type StructuredChatNamingDeps +} from '../../../native-chat/structured-chat-naming' +import { compareAndSetAgentSessionRecordName } from '../records/agent-session-record-conversation-name' import { rm } from 'node:fs/promises' import { installClaudeProfileRouter } from '../../../claude-accounts/claude-profile-installed-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,6 +23,20 @@ import { normalizeSourceControlAiSettings } from '../../../../shared/source-cont import { structuredChatNamingDeps } from './structured-chat-naming-runtime' import type { AgentJournalSnapshot } from '../../../../shared/agent-session/journal/agent-session-journal-types' import type * as TextGeneration from '../../../text-generation/commit-message-text-generation' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/host/structured-agent-session-host' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER +} from '../../../native-chat/agent-session-wire/host/testing/structured-agent-session-queued-message-rig.test-fixture' +import { holdDelivery } from '../../../native-chat/agent-session-wire/host/testing/structured-agent-session-delivery-hold.test-fixture' +import { holdUnsentSends } from '../../../native-chat/agent-session-journal/queued-messages/journal-unsent-send-hold' +import { AgentSessionAcquisitionRootExitObservedError } from '../../../native-chat/agent-session-wire/acquisition/structured-agent-session-acquisition-errors' +import { AgentSessionStartFailure } from '../../../native-chat/agent-session-wire/startup/structured-agent-session-start-failure' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session/journal/agent-session-journal-types' +import { + HOST_TEST_SESSION, + hostTestOperationId +} from '../../../native-chat/agent-session-wire/host/testing/structured-agent-session-host-test-data' const mocks = vi.hoisted(() => ({ account: vi.fn((_provider: string, id: string) => ({ @@ -14,7 +44,13 @@ const mocks = vi.hoisted(() => ({ XDG_STATE_HOME: `/profiles/${id}/state` })), native: vi.fn(async () => 'Native title'), - generate: vi.fn(async () => ({ success: true as const, name: 'Login repair' })), + titleOwner: vi.fn(), + host: vi.fn<() => StructuredAgentSessionHost | undefined>(), + record: vi.fn(), + generate: vi.fn(async () => ({ + success: true as const, + name: 'Login repair' + })), target: vi.fn(async (cwd: string) => ({ kind: 'local' as const, cwd })), publishConversationName: vi.fn<(sessionId: string) => void>(), journalCurrentRead: vi.fn<(sessionId: string) => Promise>() @@ -28,11 +64,15 @@ vi.mock('../../../agent-hooks/first-work/first-work-generation-target', () => ({ resolveGenerationTarget: mocks.target })) vi.mock('../../../native-chat/agent-session-wire/host/structured-agent-session-registry', () => ({ - getStructuredAgentSessionHost: () => ({ - deps: { adapter: { generateText: mocks.native } }, - publishConversationName: mocks.publishConversationName, - journalCurrentRead: mocks.journalCurrentRead - }) + getStructuredAgentSessionHost: () => + mocks.host() ?? { + deps: { + store: { getRecord: mocks.record }, + adapter: { generateText: mocks.native, captureConversationTitleOwner: mocks.titleOwner } + }, + publishConversationName: mocks.publishConversationName, + journalCurrentRead: mocks.journalCurrentRead + } })) vi.mock('../../../managed-data-accounts/service', () => ({ @@ -43,13 +83,20 @@ vi.mock('../../../managed-data-accounts/service', () => ({ })) const roots: string[] = [] afterEach(async () => { + vi.useRealTimers() installClaudeProfileRouter(undefined) await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -beforeEach(() => vi.clearAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + mocks.titleOwner.mockReset() + mocks.host.mockReset() + mocks.record.mockReset() +}) function rig() { const settings = getDefaultSettings('/isolated') + const settingsListeners = new Set<() => void>() settings.defaultTuiAgent = 'codex' settings.sourceControlAi = normalizeSourceControlAiSettings(settings.sourceControlAi) const runtime = { @@ -67,13 +114,99 @@ function rig() { const logger = { warn: vi.fn(), error: vi.fn() } return { settings, + settingsChanged: () => settingsListeners.forEach((changed) => changed()), runtime, logger, - deps: structuredChatNamingDeps(() => ({ getSettings: () => settings }), runtime, logger) + deps: structuredChatNamingDeps( + () => ({ + getSettings: () => settings, + onSettingsChanged: (changed) => { + settingsListeners.add(changed) + return () => { + settingsListeners.delete(changed) + } + } + }), + runtime, + logger + ) } } describe('structured chat naming runtime', () => { + it('uses the CLI title only for automatic default naming and honors changed settings', async () => { + const state = rig() + const record = agentSessionRecordFixture() + const pending = Promise.withResolvers() + const generate = vi.fn(() => pending.promise) + mocks.titleOwner.mockReturnValue({ isCurrent: () => true, generate }) + const result = state.deps.generate( + record, + 'Repair login', + () => true, + state.deps.captureAutomaticTitle!(record) + ) + await vi.waitFor(() => expect(generate).toHaveBeenCalled()) + pending.resolve('Broken chat title') + expect(await result).toMatchObject({ name: 'Broken chat title' }) + expect(mocks.generate).not.toHaveBeenCalled() + await state.deps.generate(record, 'Repair login') + expect(mocks.generate).toHaveBeenCalledOnce() + + generate.mockImplementationOnce(async () => { + state.settings.sourceControlAi!.actions!.conversationName = { + commandInputTemplate: 'Custom {basePrompt}' + } + return 'Wrong settings title' + }) + expect( + await state.deps.generate( + record, + 'Repair login', + () => true, + state.deps.captureAutomaticTitle!(record) + ) + ).toEqual({ name: 'Login repair' }) + }) + + it.each(['action', 'shared', 'legacy', 'seeded-default', 'effort', 'template'] as const)( + 'preserves authored %s naming choices without consulting the CLI', + async (choice) => { + const state = rig() + const source = state.settings.sourceControlAi! + if (choice === 'action') { + source.modelOverridesByOperation = { + conversationName: { selectedModelByAgent: { claude: 'opus' } } + } + } + if (choice === 'shared') { + source.selectedModelByAgent = { claude: 'opus' } + } + if (choice === 'legacy') { + state.settings.commitMessageAi!.selectedModelByAgent = { claude: 'opus' } + } + if (choice === 'seeded-default') { + state.settings.commitMessageAi!.selectedModelByAgent = { claude: 'sonnet' } + state.settings.commitMessageAi!.selectedThinkingByModel = { sonnet: 'high' } + } + if (choice === 'effort') { + source.selectedThinkingByModel = { sonnet: 'high' } + } + if (choice === 'template') { + source.actions!.conversationName = { commandInputTemplate: 'Custom {basePrompt}' } + } + const record = agentSessionRecordFixture() + expect( + await state.deps.generate( + record, + 'Repair login', + () => true, + state.deps.captureAutomaticTitle!(record) + ) + ).toEqual({ name: 'Login repair' }) + expect(mocks.titleOwner).not.toHaveBeenCalled() + } + ) it.each(['grok', 'antigravity'] as const)( 'codes %s native naming failures without exposing provider text', async (provider) => { @@ -94,3 +227,497 @@ describe('structured chat naming runtime', () => { } ) }) + +function automaticNaming(state: ReturnType, before?: 'prompt' | 'seed') { + let record = agentSessionRecordFixture() + record.sessionId = 'session-1' + record.lease.sessionId = 'session-1' + record.providerHandleChain[0]!.handle.nativeId = PROVIDER_SESSION_ID + const blocked = Promise.withResolvers() + const entered = Promise.withResolvers() + const awaitPreparationRelease = async (stage: 'prompt' | 'seed') => { + if (before === stage) { + entered.resolve() + await blocked.promise + } + } + const store = { + getRecord: () => record, + compareAndSetConversationName: async (_id, name, expected, context, applicable) => + compareAndSetAgentSessionRecordName( + async (apply) => { + if (expected === null) { + await awaitPreparationRelease('seed') + } + record = apply(record) + return record + }, + name, + expected, + context, + applicable + ), + setUserConversationName: vi.fn() + } satisfies NonNullable> + mocks.record.mockImplementation(() => record) + const handler = createStructuredChatNamingHandler({ + ...state.deps, + getStore: () => store, + readFirstPrompt: async () => { + await awaitPreparationRelease('prompt') + return 'Repair login' + }, + wait: async () => {} + }) + return { + handler, + entered: entered.promise, + release: () => blocked.resolve(), + record: () => record, + replace: () => { + record = { ...record, lease: { ...record.lease, runtimeFence: 8 } } + }, + start: () => + handler( + { + sessionId: record.sessionId, + workspaceId: 'workspace-1', + agent: 'claude', + status: 'working', + latestPrompt: 'Repair login', + updatedAt: 1 + }, + { replay: false, firstInputSubmissionKey: 'input-1' } + ) + } +} + +it.each(['prompt', 'seed', 'workspace', 'support'] as const)( + 'retains the accepted input owner while %s preparation is pending', + async (stage) => { + const state = rig() + const blocked = Promise.withResolvers() + const entered = Promise.withResolvers() + if (stage === 'workspace') { + state.runtime.resolveWorkspace.mockImplementationOnce(async () => { + entered.resolve() + await blocked.promise + return { path: '/workspace/folder', executionHostId: 'local' } + }) + } + const naming = automaticNaming( + state, + stage === 'prompt' || stage === 'seed' ? stage : undefined + ) + const control = vi.fn(async () => 'Successor title') + mocks.titleOwner.mockImplementation(() => { + const fence = naming.record().lease.runtimeFence + return { + isCurrent: (current: AgentSessionRecord) => current.lease.runtimeFence === fence, + generate: async (_prompt: string, shouldExecute: () => boolean) => { + if (stage === 'support') { + entered.resolve() + await blocked.promise + } + return shouldExecute() ? control() : null + } + } + }) + naming.start() + await (stage === 'workspace' || stage === 'support' ? entered.promise : naming.entered) + naming.replace() + naming.release() + blocked.resolve() + await vi.waitFor(() => expect(naming.handler.activeSessionIds()).toEqual([])) + expect(control).not.toHaveBeenCalled() + expect(mocks.generate).toHaveBeenCalledOnce() + expect(naming.record().conversationName).toBe('Login repair') + } +) + +it.each([false, true])( + 'bounds timeout fallback to its owner (retired=%s), retries independently, and ignores late CLI results', + async (retired) => { + const state = rig() + const pending = Promise.withResolvers() + const generateSessionTitle = vi.fn(() => pending.promise) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this flow calls only the feature-detected title method. + const controls = createClaudeControlSurface({ generateSessionTitle } as unknown as Query) + let owned = true + mocks.titleOwner.mockReturnValue({ + isCurrent: () => owned, + generate: controls.generateSessionTitle + }) + mocks.generate + .mockResolvedValueOnce({ success: false, error: 'Retry naming' }) + .mockImplementationOnce(async () => { + if (retired) { + owned = false + } + return { success: true, name: 'Recovered name' } + }) + const naming = automaticNaming(state) + vi.useFakeTimers() + naming.start() + await vi.advanceTimersByTimeAsync(30_000) + expect(naming.record().conversationName).toBe(retired ? 'Repair login' : 'Recovered name') + pending.resolve('Late CLI name') + await vi.advanceTimersByTimeAsync(0) + expect(naming.record().conversationName).toBe(retired ? 'Repair login' : 'Recovered name') + expect(generateSessionTitle).toHaveBeenCalledOnce() + expect(mocks.generate).toHaveBeenCalledTimes(2) + expect(naming.handler.activeSessionIds()).toEqual([]) + } +) + +it.each([ + { support: 'unsupported' as const, transition: 'close', response: 'unused' }, + { support: 'unknown' as const, transition: 'replacement', response: 'unused' }, + { support: 'supported' as const, transition: 'replacement', response: 'error' }, + { support: 'supported' as const, transition: 'close', response: 'null' } +])( + 'finishes independent naming after $transition with support=$support and response=$response', + async ({ support, transition, response }) => { + const cli = fakeClaude() + const adapter = await acquired(cli, { cliFlag: async () => support }) + const state = rig() + const naming = automaticNaming(state) + const entered = Promise.withResolvers() + const independent = + Promise.withResolvers< + Awaited> + >() + const control = vi.fn(async () => { + if (response === 'error') { + throw new Error('Optional control rejected') + } + return response === 'null' ? null : 'Wrong live title' + }) + cli.connections[0]!.generateSessionTitle = control + mocks.titleOwner.mockImplementation((record) => adapter.captureConversationTitleOwner(record)) + mocks.generate.mockImplementationOnce(async () => { + entered.resolve() + return independent.promise + }) + try { + naming.start() + await entered.promise + await adapter.closeSession('session-1') + if (transition === 'replacement') { + naming.replace() + await adapter.acquire({ + identity: identityFor(), + fence: 8, + spawnToken: 'replacement', + events: recordingJournalSink() + }) + cli.connections[1]!.generateSessionTitle = control + } + independent.resolve({ success: true, name: 'Independent completion' }) + await vi.waitFor(() => expect(naming.handler.activeSessionIds()).toEqual([])) + expect(naming.record().conversationName).toBe('Independent completion') + expect(control).toHaveBeenCalledTimes(response === 'unused' ? 0 : 1) + } finally { + await adapter.closeAll() + cli.dispose() + } + } +) + +it('discards a retired CLI result and publishes only independent fallback text', async () => { + const cli = fakeClaude() + const adapter = await acquired(cli) + const state = rig() + const naming = automaticNaming(state) + const pending = Promise.withResolvers() + const started = Promise.withResolvers() + cli.connections[0]!.generateSessionTitle = async () => { + started.resolve() + return pending.promise + } + mocks.titleOwner.mockImplementation((record) => adapter.captureConversationTitleOwner(record)) + try { + naming.start() + await started.promise + await adapter.closeSession('session-1') + pending.resolve('Stale CLI title') + await vi.waitFor(() => expect(naming.handler.activeSessionIds()).toEqual([])) + expect(naming.record().conversationName).toBe('Login repair') + } finally { + await adapter.closeAll() + cli.dispose() + } +}) + +it.each(['capture', 'pending result'] as const)( + 'keeps the seed and existing naming failure when account lookup fails at %s', + async (stage) => { + const cli = fakeClaude() + const adapter = await acquired(cli) + const state = rig() + const naming = automaticNaming(state) + const router = new ClaudeProfileRouter({ + getSettings: () => ({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + }), + dataRoot: '/isolated', + userHome: '/users/test', + env: {} + }) + const preparation = vi.spyOn(router, 'preparation') + installClaudeProfileRouter(router) + const unavailable = () => { + throw new Error('Selected account folder missing') + } + const pending = Promise.withResolvers() + const started = Promise.withResolvers() + cli.connections[0]!.generateSessionTitle = async () => { + started.resolve() + return pending.promise + } + mocks.titleOwner.mockImplementation((record) => adapter.captureConversationTitleOwner(record)) + state.runtime.resolveNamingEnvironment.mockImplementation(async () => unavailable()) + try { + if (stage === 'capture') { + preparation.mockImplementation(unavailable) + } + naming.start() + if (stage === 'pending result') { + await started.promise + preparation.mockImplementation(unavailable) + pending.resolve('Unusable account title') + } + await vi.waitFor(() => expect(naming.handler.activeSessionIds()).toEqual([])) + expect(naming.record().conversationName).toBe('Repair login') + expect(naming.handler.failure('session-1')?.messageCode).toBe('agentNotRun') + } finally { + await adapter.closeAll() + cli.dispose() + } + } +) + +async function atRestNaming() { + const state = rig() + const chat = await createQueuedMessageTestRig({ + agent: 'claude', + atRest: true, + starting: true, + restartable: true + }) + await vi.waitFor(() => + expect( + chat.host.collaboratorsForTests().conversationDelivery.loop.isRunning(HOST_TEST_SESSION) + ).toBe(false) + ) + mocks.host.mockReturnValue(chat.host) + const title = vi.fn(async () => 'CLI first handover title') + chat.host.deps.adapter.captureConversationTitleOwner = (_record) => { + const child = chat.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child + if (!child || child.phase !== 'ready') { + return null + } + const isCurrent = (current: AgentSessionRecord) => + current.lease.runtimeFence === child.fence && + chat.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child === child + return { isCurrent, generate: title } + } + const handler = createStructuredChatNamingHandler({ ...state.deps, now: () => 0 }) + chat.host.deps.onSessionStatusChanged = handler + chat.host.deps.conversationNaming = handler + const done = () => vi.waitFor(() => expect(handler.activeSessionIds()).toEqual([])) + const seeded = () => + vi.waitFor(() => + expect(chat.store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('Repair login') + ) + return { state, chat, title, handler, done, seeded } +} + +it.each(['fresh', 'startup retry', 'projected context'] as const)( + 'names an at-rest chat from its first ready handover (%s)', + async (scenario) => { + const { chat, title, done, seeded } = await atRestNaming() + try { + expect(chat.starts).not.toHaveBeenCalled() + const journal = chat.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal + if (scenario === 'projected context') { + await journal.replaceEpochItems('handle_forked', 1, []) + expect(journal.cursor().epoch).not.toBe(journal.physicalCursor().epoch) + } + if (scenario === 'startup retry') { + chat.failNextStart( + new AgentSessionAcquisitionRootExitObservedError( + new AgentSessionStartFailure(new Error('Temporary start failure'), 'safe') + ) + ) + } + const sent = chat.send('Repair login') + expect(await sent.result).toMatchObject({ ok: true }) + await seeded() + expect(title).not.toHaveBeenCalled() + expect(mocks.generate).not.toHaveBeenCalled() + await journal.resolveDispatch({ + clientMessageId: 'different-input', + state: 'pending', + fence: chat.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(title).not.toHaveBeenCalled() + await chat.proveStart() + await done() + expect(chat.dispatch).toHaveBeenCalled() + expect(chat.store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe( + 'CLI first handover title' + ) + expect(title).toHaveBeenCalledOnce() + expect(chat.starts).toHaveBeenCalledTimes(scenario === 'startup retry' ? 2 : 1) + expect(mocks.generate).not.toHaveBeenCalled() + } finally { + await chat.dispose() + } + } +) + +it.each(['same-text rename', 'settings', 'auto-off', 'shutdown', 'Stop', 'close', 'held'] as const)( + 'releases a pending first-input title on %s without adopting a future child', + async (exit) => { + const { chat, state, title, handler, done, seeded } = await atRestNaming() + const preclaim = exit === 'Stop' || exit === 'close' || exit === 'held' ? holdDelivery() : null + let stopping: Promise | undefined + try { + expect(await chat.send('Repair login').result).toMatchObject({ ok: true }) + await seeded() + if (exit === 'same-text rename') { + await handler.setUserName(HOST_TEST_SESSION, 'Repair login', 'rename') + } + if (exit === 'settings') { + state.settings.sourceControlAi!.actions!.conversationName = { + commandInputTemplate: 'Custom {basePrompt}' + } + state.settingsChanged() + } + if (exit === 'auto-off') { + state.settings.nativeChatAutoName = false + state.settingsChanged() + } + if (exit === 'shutdown') { + chat.host.stopDelivery() + } + if (preclaim) { + await preclaim.held + expect(chat.starts).not.toHaveBeenCalled() + if (exit === 'Stop') { + stopping = chat.stop() + } + if (exit === 'close') { + stopping = chat.host.close(HOST_TEST_SESSION, 'user-close') + } + if (exit === 'held') { + await holdUnsentSends( + chat.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal, + { + fence: chat.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + hold: { cause: 'chatClosed' } + } + ) + } + } + await done() + const independent = + exit === 'settings' || exit === 'Stop' || exit === 'close' || exit === 'held' + expect(chat.store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe( + independent ? 'Login repair' : 'Repair login' + ) + expect(mocks.generate).toHaveBeenCalledTimes(independent ? 1 : 0) + expect(title).not.toHaveBeenCalled() + preclaim?.release() + await stopping + if (exit !== 'shutdown' && !preclaim) { + await chat.proveStart() + } + expect(title).not.toHaveBeenCalled() + } finally { + preclaim?.release() + await stopping + await chat.dispose() + } + } +) + +it('retires the selected first-handover source without adopting a replacement child', async () => { + const { chat, title, done } = await atRestNaming() + const entered = Promise.withResolvers() + const result = Promise.withResolvers() + title.mockImplementation(async () => { + entered.resolve() + return result.promise + }) + try { + expect(await chat.send('Repair login').result).toMatchObject({ ok: true }) + await chat.proveStart() + await entered.promise + await chat.host.close(HOST_TEST_SESSION, 'user-close') + const successor = chat.send('A follow-up') + expect(await successor.result).toMatchObject({ ok: true }) + await chat.proveStart() + result.resolve('Stale first CLI title') + await done() + expect(chat.store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('Login repair') + expect(title).toHaveBeenCalledOnce() + } finally { + result.resolve('Late') + await chat.dispose() + } +}) + +it('finishes Send now naming during delivery even if recording its failure cannot commit', async () => { + const { chat, title, done, seeded } = await atRestNaming() + const release = chat.holdNextStart() + let releaseSecond: (() => void) | undefined + chat.failNextStart(new AgentSessionStartFailure(new Error('Startup refused'), 'never')) + const fields = { command: 'compact' as const } + const command = chat.host.conversationCommand(QUEUED_RIG_CALLER, { + ...fields, + envelope: chat.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()) + }) + try { + await vi.waitFor(() => expect(chat.starts).toHaveBeenCalledOnce()) + const sent = chat.send('Repair login') + const queued = await sent.result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const messageId = queued.value.queued.messageId + expect(await chat.submission(sent.id)).toBeUndefined() + expect(await chat.sendNow(messageId)).toMatchObject({ ok: true }) + expect(await chat.handoff(messageId)).toMatchObject({ + queuedMessageId: messageId, + dispatchState: 'pending' + }) + await seeded() + const journal = chat.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal + const failure = vi + .spyOn(journal, 'appendLifecycleBatch') + .mockRejectedValue(new Error('Journal write unavailable')) + releaseSecond = chat.holdNextStart() + release() + await vi.waitFor(() => expect(chat.starts).toHaveBeenCalledTimes(2)) + chat.failNextStart(new AgentSessionStartFailure(new Error('Startup refused'), 'never')) + releaseSecond() + await done() + expect(chat.store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('Login repair') + expect((await chat.handoff(messageId))?.dispatchState).toBe('pending') + expect(title).not.toHaveBeenCalled() + failure.mockRestore() + } finally { + release() + releaseSecond?.() + vi.restoreAllMocks() + await chat.stop() + await command + await chat.dispose() + } +}) diff --git a/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts b/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts index 8a1e7918a54..10a29046d2f 100644 --- a/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts +++ b/src/main/runtime/agent-session/naming/structured-chat-naming-runtime.ts @@ -1,5 +1,10 @@ +import { ClaudeControlRequestTimeoutError } from '../../../claude/claude-agent-sdk-control-requests' import { normalizeSourceControlAiSettings } from '../../../../shared/source-control/ai/settings/source-control-ai-settings' -import { resolveSourceControlActionRecipe } from '../../../../shared/source-control/ai/source-control-ai-operation-policy' +import { + resolveSourceControlActionRecipe, + resolveInstructionsFromNormalized +} from '../../../../shared/source-control/ai/source-control-ai-operation-policy' +import { readSourceControlAiModelChoiceForHost } from '../../../../shared/source-control/ai/settings/source-control-ai-model-selection' import { normalizeSourceControlActionRecipe } from '../../../../shared/source-control/ai/recipes/source-control-ai-actions' import { renderConversationNamePrompt, @@ -22,6 +27,7 @@ import { resolveTextGenerationParams } from '../../../text-generation/commit-message-text-generation' import { resolveGenerationTarget } from '../../../agent-hooks/first-work/first-work-generation-target' +import { deferredStructuredChatTitle } from '../../../native-chat/structured-chat-title-handover' type StructuredChatNamingRuntime = { resolveWorkspace: ( @@ -33,11 +39,68 @@ type StructuredChatNamingRuntime = { retitleOpenTab: StructuredChatNamingDeps['onNamed'] } +function canReuseClaudeTitle( + current: Parameters[0], + model: string +): boolean { + const source = normalizeSourceControlAiSettings(current.sourceControlAi, current.commitMessageAi) + const recipe = resolveSourceControlActionRecipe({ + settings: current, + actionId: 'conversationName' + }) + const override = source.modelOverridesByOperation?.conversationName + const choices = [override, source, current.commitMessageAi] + return ( + !recipe.agentId && + !recipe.agentArgs?.trim() && + recipe.commandInputTemplate === '{basePrompt}' && + !resolveInstructionsFromNormalized(source, null, 'conversationName', undefined) && + !choices.some( + (choice) => + readSourceControlAiModelChoiceForHost(choice, LOCAL_COMMIT_MESSAGE_HOST_KEY, 'claude') !== + undefined + ) && + (override?.selectedThinkingByModel?.[model] ?? + source.selectedThinkingByModel[model] ?? + current.commitMessageAi?.selectedThinkingByModel?.[model]) === undefined + ) +} + +function resolveChatNamingParams( + current: Parameters[0], + provider: Parameters[0]['provider'], + recipe = resolveSourceControlActionRecipe({ settings: current, actionId: 'conversationName' }) +) { + const source = normalizeSourceControlAiSettings(current.sourceControlAi, current.commitMessageAi) + const useChatAgent = !recipe.agentId && !recipe.agentArgs?.trim() + return resolveTextGenerationParams( + useChatAgent + ? { + ...current, + sourceControlAi: { + ...source, + actions: { + ...source.actions, + conversationName: normalizeSourceControlActionRecipe({ + ...recipe, + agentId: provider + }) + } + } + } + : current, + LOCAL_COMMIT_MESSAGE_HOST_KEY, + 'conversationName', + null + ) +} + export function structuredChatNamingDeps( getStore: () => { getSettings: () => Parameters[0] & AgentLaunchProfileSettings & ReturnType + onSettingsChanged?: (changed: () => void) => () => void }, runtime: StructuredChatNamingRuntime, logger: StructuredAgentSessionLogger @@ -60,7 +123,49 @@ export function structuredChatNamingDeps( : '' }, workerTaskTitle: runtime.workerTaskTitle, - generate: async (record, firstPrompt, shouldExecute) => { + captureAutomaticTitle: (record, firstInputSubmissionKey, applicable) => { + const current = getStore().getSettings() + if ( + record.provider !== 'claude' || + record.location.wslDistro !== null || + current.nativeChatAutoName === false + ) { + return undefined + } + const settings = resolveChatNamingParams(current, record.provider) + if (!settings.ok || !canReuseClaudeTitle(current, settings.params.model)) { + return undefined + } + const host = getStructuredAgentSessionHost() + const owner = host?.deps.adapter.captureConversationTitleOwner?.(record) ?? null + return { + owner, + pending: + !owner && host && firstInputSubmissionKey && applicable + ? deferredStructuredChatTitle({ + record, + firstInputSubmissionKey, + host, + currentHost: getStructuredAgentSessionHost, + applicable, + eligible: () => { + const current = getStore().getSettings() + const resolved = resolveChatNamingParams(current, record.provider) + return resolved.ok && canReuseClaudeTitle(current, resolved.params.model) + }, + subscribeSettings: (changed) => + getStore().onSettingsChanged?.(changed) ?? (() => {}) + }) + : undefined + } + }, + generate: async function generate(record, firstPrompt, shouldExecute, automaticTitle) { + const titleOwner = automaticTitle?.pending + ? await automaticTitle.pending.ready + : automaticTitle?.owner + if (shouldExecute?.() === false || automaticTitle?.pending?.ended()) { + return null + } // Unsupported execution contexts keep the seed regardless of native naming settings. if (record.location.wslDistro !== null) { return null @@ -70,10 +175,6 @@ export function structuredChatNamingDeps( return null } const current = getStore().getSettings() - const source = normalizeSourceControlAiSettings( - current.sourceControlAi, - current.commitMessageAi - ) const recipe = resolveSourceControlActionRecipe({ settings: current, actionId: 'conversationName' @@ -108,30 +209,47 @@ export function structuredChatNamingDeps( return chatNamingFailure(record.provider === 'grok' ? 'grokFailed' : 'antigravityFailed') } } - const settings = resolveTextGenerationParams( - useChatAgent - ? { - ...current, - sourceControlAi: { - ...source, - actions: { - ...source.actions, - conversationName: normalizeSourceControlActionRecipe({ - ...recipe, - agentId: record.provider - }) - } - } - } - : current, - LOCAL_COMMIT_MESSAGE_HOST_KEY, - 'conversationName', - null - ) + const settings = resolveChatNamingParams(current, record.provider, recipe) if (!settings.ok) { logger.warn(settings.error, log) return { error: settings.error } } + if ( + automaticTitle && + record.provider === 'claude' && + current.nativeChatAutoName !== false && + canReuseClaudeTitle(current, settings.params.model) + ) { + const owner = titleOwner + if (owner) { + const isCurrent = ( + current = getStructuredAgentSessionHost()?.deps.store?.getRecord(record.sessionId) ?? + record + ) => (shouldExecute?.() ?? true) && owner.isCurrent(current) + const eligible = () => + getStore().getSettings().nativeChatAutoName !== false && + canReuseClaudeTitle(getStore().getSettings(), settings.params.model) + let output: unknown + try { + if (isCurrent()) { + output = await owner.generate(firstPrompt, () => isCurrent() && eligible()) + } + } catch (error) { + if (error instanceof ClaudeControlRequestTimeoutError) { + // A timed-out control is still in flight; its fallback keeps the original live owner. + automaticTitle.retainOwnership?.(owner.isCurrent) + if (!isCurrent()) { + return null + } + } + } + const name = typeof output === 'string' ? sanitizeGeneratedConversationName(output) : '' + if (name && isCurrent() && eligible()) { + return { name, shouldApply: (current) => isCurrent(current) && eligible() } + } + return generate(record, firstPrompt, shouldExecute, undefined) + } + } const target = useChatAgent ? await chatNamingAgentTarget( record, diff --git a/src/main/runtime/agent-session/records/agent-session-record-conversation-name.ts b/src/main/runtime/agent-session/records/agent-session-record-conversation-name.ts index 3b9df841fbe..96c62725300 100644 --- a/src/main/runtime/agent-session/records/agent-session-record-conversation-name.ts +++ b/src/main/runtime/agent-session/records/agent-session-record-conversation-name.ts @@ -77,11 +77,13 @@ export async function compareAndSetAgentSessionRecordName( ) => Promise, name: string | null, expected: string | null, - contextId: string | null = null + contextId: string | null = null, + applicable?: (record: AgentSessionRecord) => boolean ): Promise { let matched = false const record = await mutate((current) => { matched = + (applicable?.(current) ?? true) && current.conversationNameSource !== 'user' && (current.conversationName ?? null) === expected && (current.providerContextBoundary?.operationId ?? null) === contextId @@ -94,5 +96,6 @@ export type CompareAndSetConversationName = ( sessionId: string, name: string | null, expected: string | null, - contextId?: string | null + contextId?: string | null, + applicable?: (record: AgentSessionRecord) => boolean ) => Promise diff --git a/src/main/runtime/agent-session/records/agent-session-record-store.ts b/src/main/runtime/agent-session/records/agent-session-record-store.ts index 9462f27c275..5dc57674663 100644 --- a/src/main/runtime/agent-session/records/agent-session-record-store.ts +++ b/src/main/runtime/agent-session/records/agent-session-record-store.ts @@ -175,8 +175,20 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs { setUserConversationName = (sessionId: string, name: string, write: AgentSessionUserNameWrite) => setAgentSessionRecordUserNameThrough((apply) => this.mutate(sessionId, apply), name, write) - compareAndSetConversationName: CompareAndSetConversationName = (id, name, expected, context) => - compareAndSetAgentSessionRecordName((apply) => this.mutate(id, apply), name, expected, context) + compareAndSetConversationName: CompareAndSetConversationName = ( + id, + name, + expected, + context, + applicable + ) => + compareAndSetAgentSessionRecordName( + (apply) => this.mutate(id, apply), + name, + expected, + context, + applicable + ) /** Launch history is metadata and does not contend with the writer lease. */ pinLaunchDirectory = (sessionId: string, launchDirectory: string): Promise => From 387fe35bfb3977ccb8895363e580f50c9ce08ee2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 9 Nov 2026 12:36:29 -0800 Subject: [PATCH 058/138] test: remove low-value mobile and attachment cases (#40475) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../use-mobile-accounts-snapshot.test.tsx | 46 +----------- ...bile-native-chat-transcript-writer.test.ts | 75 ------------------- .../native-chat-image-references.test.tsx | 27 ------- ...-native-chat-composer-attachments.test.tsx | 35 +-------- .../native-chat-composer-composition.test.tsx | 12 ++- 5 files changed, 7 insertions(+), 188 deletions(-) delete mode 100644 mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts diff --git a/mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx b/mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx index 56c8d293793..a64c3166b1b 100644 --- a/mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx +++ b/mobile/src/accounts/use-mobile-accounts-snapshot.test.tsx @@ -5,15 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' // Also tells TanStack observers on a microtask, so act() sees replies. import '../test-support/query-client-test-renderer' import { settleQueryNotifications } from '../test-support/rpc-recording/hook-mount' -import type { OrcaQueryStoreBackend } from '../device-store/query-storage' import { createOrcaQueryClient } from '../query/orca-query-client' -import { createHostQueryPersistence, persistedHostQueryKey } from '../query/host-query-persistence' import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { - accountsSnapshotFamily, - accountsSnapshotKey, - accountsSnapshotOptions -} from './accounts-snapshot-stream' +import { accountsSnapshotKey } from './accounts-snapshot-stream' import { useMobileAccountsSnapshot } from './use-mobile-accounts-snapshot' const HOST = 'host-1' @@ -89,24 +83,6 @@ async function mountAccounts(queryClient: QueryClient, client: RpcClient) { } } -function memoryStore(): OrcaQueryStoreBackend { - const rows = new Map() - return { - hostId: HOST, - read: async (keys) => ({ status: 'ok', values: keys.map((key) => rows.get(key) ?? null) }), - write: async (ops) => { - for (const op of ops) { - if (op.value === null) { - rows.delete(op.key) - } else { - rows.set(op.key, op.value) - } - } - return { written: true } - } - } -} - describe('accounts snapshot', () => { it('still shows usage when the stream fails and accounts.list answers', async () => { const port = accountsClient(SNAPSHOT) @@ -128,24 +104,4 @@ describe('accounts snapshot', () => { 'claude@example.test' ]) }) - - it('never counts a stored snapshot as live, even one written after the connect', async () => { - const store = memoryStore() - // Native Home's own stream stores the snapshot after the connect the page then reads. - const home = createOrcaQueryClient() - const homePersistence = createHostQueryPersistence(home, store) - home.getQueryCache().build(home, accountsSnapshotOptions(HOST)) - home.setQueryData(accountsSnapshotKey(HOST), SNAPSHOT, { updatedAt: CONNECTED_AT + 1 }) - await settleQueryNotifications() - await homePersistence.flush() - home.clear() - - const page = createOrcaQueryClient() - await createHostQueryPersistence(page, store).restore([ - persistedHostQueryKey(accountsSnapshotFamily, accountsSnapshotKey(HOST))! - ]) - const read = await mountAccounts(page, accountsClient(SNAPSHOT).client) - expect(read().snapshot?.claude.accounts.map((account) => account.id)).toEqual(['claude-1']) - expect(read().live).toBe(false) - }) }) diff --git a/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts b/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts deleted file mode 100644 index 08a181f1f7d..00000000000 --- a/mobile/src/session/native-chat/mobile-native-chat-transcript-writer.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types' -import { publishPageDeviceStore } from '../../mobile-web-shell/bridge/page-device-store' -import { emitPageAppInactive } from '../../mobile-web-shell/bridge/bridge-app-inactive' -import { noteChatTranscript } from './mobile-native-chat-transcript-writer' - -function message(id: string): NativeChatMessage { - return { - id, - role: 'assistant', - blocks: [{ type: 'text', text: id }], - timestamp: 1, - source: 'transcript' - } -} - -describe('chat copies when the app leaves the foreground', () => { - const writes: string[] = [] - - beforeEach(() => { - writes.length = 0 - publishPageDeviceStore({ - available: () => true, - read: async (_table, keys) => ({ status: 'ok', values: keys.map(() => null) }), - write: async (_table, ops) => { - writes.push(...ops.map((op) => op.key)) - return { written: true } - } - }) - }) - - afterEach(() => { - vi.useRealTimers() - publishPageDeviceStore(null) - }) - - function note(rows: NativeChatMessage[]): void { - noteChatTranscript( - 'chat-leaving', - rows, - () => true, - (savedAt) => ({ - kind: 'terminal', - savedAt, - messages: rows, - hasMore: false, - beforeOffset: null - }) - ) - } - - it('saves new rows on leaving and writes nothing when a flap brings none', async () => { - vi.useFakeTimers() - const [m1, m2, m3] = [message('m1'), message('m2'), message('m3')] - note([m1, m2, m3]) - emitPageAppInactive() - await vi.runAllTimersAsync() - expect(writes).toHaveLength(1) - - // Two minutes later, Notification Centre and back: the rows on screen are a new list with the same rows. - vi.setSystemTime(Date.now() + 120_000) - note([m1, m2, m3]) - emitPageAppInactive() - note([m1, m2, m3]) - emitPageAppInactive() - await vi.runAllTimersAsync() - expect(writes).toHaveLength(1) - - // A middle row changed (a tool result landed), with the last row the same. - note([m1, { ...m2, blocks: [{ type: 'text', text: 'm2 with its result' }] }, m3]) - emitPageAppInactive() - await vi.runAllTimersAsync() - expect(writes).toHaveLength(2) - }) -}) diff --git a/src/renderer/src/components/native-chat/attachments/chips/native-chat-image-references.test.tsx b/src/renderer/src/components/native-chat/attachments/chips/native-chat-image-references.test.tsx index 34c3b2bc7ac..f9db36dcad0 100644 --- a/src/renderer/src/components/native-chat/attachments/chips/native-chat-image-references.test.tsx +++ b/src/renderer/src/components/native-chat/attachments/chips/native-chat-image-references.test.tsx @@ -199,17 +199,6 @@ describe('composer image chips', () => { expect(promptValue(composer.textbox())).toBe(`look ${chip('missing')}`) }) - it('leaves a file path that reads like a marker as typed when a message comes back', () => { - const scope = structuredAgentSessionDraftScopeKey('s1') - const file = formatNativeChatFileReference('/repo/[Image #1].md') - handBackStructuredAgentSessionMessage( - 's1', - 'm1', - message(`see ${file} and [Image #1]`, ['/up/a.png']) - ) - expect(readNativeChatComposerDraft(scope).text).toBe(`see ${file} and ${chip('returned-m1-1')}`) - }) - it('keeps a stored draft’s file reference when a message comes back', async () => { const scope = structuredAgentSessionDraftScopeKey('s1') const composer = renderComposer(scope) @@ -224,22 +213,6 @@ describe('composer image chips', () => { await waitFor(() => expect(back.chips()).toEqual(['Image #1'])) }) - it('adds a returned message once even after typing, by its image ids', () => { - const scope = structuredAgentSessionDraftScopeKey('s1') - const returned = message('see [Image #1]', ['/up/a.png']) - handBackStructuredAgentSessionMessage('s1', 'm1', returned) - updateNativeChatComposerDraft( - scope, - { text: `${readNativeChatComposerDraft(scope).text} and more` }, - 'immediate' - ) - handBackStructuredAgentSessionMessage('s1', 'm1', returned) - expect(readNativeChatComposerDraft(scope)).toMatchObject({ - text: `see ${chip('returned-m1-1')} and more`, - images: [{ id: 'returned-m1-1' }] - }) - }) - it('settles a late upload into its own conversation, bound by the same id, while another is shown', async () => { updateNativeChatComposerDraft('chat-b', { text: 'B draft' }, 'immediate') const composer = renderComposer('chat-a') diff --git a/src/renderer/src/components/native-chat/attachments/chips/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/attachments/chips/use-native-chat-composer-attachments.test.tsx index c297c00bcf0..313dcf03174 100644 --- a/src/renderer/src/components/native-chat/attachments/chips/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/attachments/chips/use-native-chat-composer-attachments.test.tsx @@ -57,30 +57,6 @@ vi.mock( }) ) -function publishUnrelatedStatuses(): void { - for (let tick = 1; tick <= 24; tick += 1) { - useAppStore.setState((state) => ({ - runtimeStatusByEnvironmentId: new Map(state.runtimeStatusByEnvironmentId).set('other-host', { - status: null, - checkedAt: tick - }) - })) - useAppStore.setState((state) => ({ - agentStatusByPaneKey: { - ...state.agentStatusByPaneKey, - 'other-tab:other-pane': { - paneKey: 'other-tab:other-pane', - state: 'working', - prompt: 'background work', - updatedAt: tick, - stateStartedAt: 1, - stateHistory: [] - } - } - })) - } -} - describe('useNativeChatComposerAttachments', () => { afterEach(() => { useAppStore.setState(initialStoreState, true) @@ -100,17 +76,15 @@ describe('useNativeChatComposerAttachments', () => { storage.drafts.set(scope, { text: '', images: [image], owner: storedOwner, savedAt: 1 }) draftStorage.setNativeChatComposerDraftStorageForTests(storage) publishOwner(scope, storedOwner) - const resolveOwner = vi.fn((key: string) => + draftStoreOwner1.setNativeChatComposerDraftOwnerResolver((key) => resolveNativeChatDraftOwner(useAppStore.getState(), key) ) - draftStoreOwner1.setNativeChatComposerDraftOwnerResolver(resolveOwner) const old = Promise.withResolvers() const next = Promise.withResolvers() const pathExists = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise) const originalApi = window.api Object.assign(window, { api: { fs: { pathExists } } }) let probe: Awaited> | undefined - const originalState = useAppStore.getState() try { await draftStoreOwner2.hydrateNativeChatComposerDrafts() probe = await renderProbe(scope, true) @@ -130,15 +104,8 @@ describe('useNativeChatComposerAttachments', () => { { id: image.id, path: '', unavailableName: 'shot.png' } ]) expect(draftStoreOwner3.readNativeChatComposerDraft(scope).owner).toEqual(storedOwner) - resolveOwner.mockClear() - act(publishUnrelatedStatuses) - expect(resolveOwner).not.toHaveBeenCalled() } finally { act(() => probe?.root.unmount()) - useAppStore.setState({ - runtimeStatusByEnvironmentId: originalState.runtimeStatusByEnvironmentId, - agentStatusByPaneKey: originalState.agentStatusByPaneKey - }) Object.assign(window, { api: originalApi }) } }) diff --git a/src/renderer/src/components/native-chat/composer/editor/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/composer/editor/native-chat-composer-composition.test.tsx index ca88b74749a..8b4e1ccc070 100644 --- a/src/renderer/src/components/native-chat/composer/editor/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/composer/editor/native-chat-composer-composition.test.tsx @@ -108,7 +108,7 @@ function textarea(): HTMLTextAreaElement { } describe('native chat composer composition ownership', () => { - it('preserves the focused browser preedit through 120 stale streaming rerenders', () => { + it('preserves the focused preedit through a stale streaming rerender', () => { const textareaRef = createRef() const onImeSettled = vi.fn() const props = fieldProps({ textareaRef, onImeSettled }) @@ -118,12 +118,10 @@ describe('native chat composer composition ownership', () => { fireEvent.compositionStart(input) changePrompt(input, '가') - for (let index = 0; index < 120; index += 1) { - view.rerender() - expect(textarea()).toBe(input) - expect(document.activeElement).toBe(input) - expect(promptValue(input)).toBe('가') - } + view.rerender() + expect(textarea()).toBe(input) + expect(document.activeElement).toBe(input) + expect(promptValue(input)).toBe('가') fireEvent.compositionEnd(input, { data: '가' }) expect(onImeSettled).toHaveBeenCalledOnce() From fe1620cdf569d52fac978cd264cd1b081bda95b6 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:39:58 +0000 Subject: [PATCH 059/138] fix(chat): always show copy and fork under a finished agent turn (#40474) --- .../messages/actions/NativeChatAgentControls.tsx | 2 +- .../messages/actions/use-native-chat-reveal-latest.ts | 2 +- .../messages/rows/NativeChatAssistantMessageRow.tsx | 9 ++------- .../native-chat/messages/rows/NativeChatMessageRow.tsx | 2 +- .../pane/actions/NativeChatContextMenuItems.tsx | 2 +- .../viewport/find/use-native-chat-find-matches.ts | 2 +- 6 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/native-chat/messages/actions/NativeChatAgentControls.tsx b/src/renderer/src/components/native-chat/messages/actions/NativeChatAgentControls.tsx index 34e6b81cbf3..5db891afcaf 100644 --- a/src/renderer/src/components/native-chat/messages/actions/NativeChatAgentControls.tsx +++ b/src/renderer/src/components/native-chat/messages/actions/NativeChatAgentControls.tsx @@ -21,7 +21,7 @@ export function NativeChatAgentControls({ }): React.JSX.Element { const t = useTranslate() return ( - // Hover-only row chrome: find skips it rather than counting what the mouse happens to show. + // Row chrome, not transcript: find skips it.
{/* A visual line means nothing pasted outside Orca, so the copy leaves it out. An image-only response has nothing to copy, so it has no Copy. */} diff --git a/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts b/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts index 00c476f9091..19b8e0e927f 100644 --- a/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts +++ b/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts @@ -56,7 +56,7 @@ export type NativeChatMessageListHandle = { readerAtEnd: () => boolean /** Bring a find match into view, clear of the find bar; the reader stops following the latest. */ revealFindMatch: (match: Range, bar: DOMRectReadOnly | null) => void - /** What the row drawing this message copies from its hover button; null when none draws it. */ + /** What the row drawing this message copies from its Copy button; null when none draws it. */ messageCopyText: (messageId: string) => string | null /** Open a subagent's activity where it sits in the conversation and bring it into view. */ revealSubagent: (agentId: string) => void diff --git a/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx b/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx index 8f7fc2045ae..9af89d8fddb 100644 --- a/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx +++ b/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx @@ -76,16 +76,11 @@ export function NativeChatAssistantMessageRow({ !message.agentStart && !inSubagentSection && isRootAgentJournalItem(message) - // A row whose fork is opening keeps its controls up so the spinner stays visible. - const revealOnHover = - fork?.pendingItemId === message.id - ? 'mt-1 w-fit select-none' - : 'mt-1 w-fit select-none transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100' return (
0 ? message.id : undefined} data-native-chat-message-tone={isSystem ? 'faint' : undefined} className={cn( @@ -126,7 +121,7 @@ export function NativeChatAssistantMessageRow({ ) : null } className={cn( - revealOnHover, + 'mt-1 w-fit select-none', // They hang into the gap below; a section keeps them inside its border instead. !inSubagentSection && '-mb-5' )} diff --git a/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx index fbf666c74ab..11c9bbda907 100644 --- a/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx @@ -55,7 +55,7 @@ export type MessageRowProps = { activeTurnIsWorking?: boolean /** This row's tool run is the turn's last, so it is the one still live. */ trailingRun?: boolean - /** Hover controls would overlap the next row. */ + /** Response controls would overlap the next row. */ continuesTurn?: boolean /** In a turn: whether this row holds the response's actions (its last words, else its last images). */ endsResponse?: boolean diff --git a/src/renderer/src/components/native-chat/pane/actions/NativeChatContextMenuItems.tsx b/src/renderer/src/components/native-chat/pane/actions/NativeChatContextMenuItems.tsx index bd19901886f..c39e388ffc4 100644 --- a/src/renderer/src/components/native-chat/pane/actions/NativeChatContextMenuItems.tsx +++ b/src/renderer/src/components/native-chat/pane/actions/NativeChatContextMenuItems.tsx @@ -9,7 +9,7 @@ import type { NativeChatContextMenuTarget } from './native-chat-context-menu-tar import type { NativeChatRewindSurface } from '../../messages/actions/use-native-chat-rewind-host' function copyText(text: string): void { - // Best-effort, as the hover copy buttons are: the clipboard can reject when unfocused. + // Best-effort, as the row Copy buttons are: the clipboard can reject when unfocused. window.api.ui.writeClipboardText(text).catch(() => {}) } diff --git a/src/renderer/src/components/native-chat/viewport/find/use-native-chat-find-matches.ts b/src/renderer/src/components/native-chat/viewport/find/use-native-chat-find-matches.ts index b0dcadc3168..2801dd93655 100644 --- a/src/renderer/src/components/native-chat/viewport/find/use-native-chat-find-matches.ts +++ b/src/renderer/src/components/native-chat/viewport/find/use-native-chat-find-matches.ts @@ -15,7 +15,7 @@ const chatFindHighlights = createDomTextSearchHighlights({ const TRANSCRIPT_COLUMN_SELECTOR = '[data-native-chat-transcript-column]' const TRANSCRIPT_SCROLL_SELECTOR = '[data-native-chat-scroll]' -/** Row chrome that only shows on hover (timestamps, copy): marked, so counts never follow the mouse. */ +/** Row chrome, not transcript (timestamps, copy): marked, so find counts only message text. */ const SKIPPED_TEXT_SELECTOR = '.sr-only, [hidden], [data-native-chat-find-skip]' /** A streamed reply's text node while its words fade in: each new word is its own element. */ const WORD_GROUP_SELECTOR = '[data-word-group]' From acf3aead78ff8d368b6f7d7533287cf36df9784b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:42:22 +0000 Subject: [PATCH 060/138] fix(agent-status): show Codex's mid-turn async question as waiting for the user (#39087) * fix(agent-status): show Codex's mid-turn async question as waiting for the user * fix(agent-status): keep Codex's async question waiting on paired hosts under its working title The phone's PTY-title renewal turned the waiting row back into working, because Codex keeps its working title while the question waits. * fix(agent-status): clear Codex's open async questions when the user sends a plain message Orca's chat card answers an async question as a quoted message, not Codex's reply wrapper, so the pane stayed waiting until the turn ended. * refactor(agent-status): move the legacy status test seed out of listener-state --- src/main/agent-hooks/server/server-cleanup.ts | 1 + ...server-ingest-codex-async-question.test.ts | 140 ++++++++++++++++++ .../codex-async-question-title-hold.ts | 18 +++ .../runtime-mobile-agent-status-projection.ts | 8 +- ...mobile-codex-async-question-status.test.ts | 38 +++++ ...tion-background-turn-notifications.test.ts | 6 +- .../legacy-agent-status-test-seed.ts | 20 +++ .../agent-hook-listener/listener-state.ts | 25 +--- .../agent-hook-listener-test-harness.ts | 3 +- .../agent-hook-listener/pane-state-claims.ts | 3 +- .../claude-helper-kill.test-fixture.ts | 3 +- .../providers/codex-async-questions.ts | 137 +++++++++++++++++ .../providers/codex-events.ts | 19 ++- .../providers/codex-state.ts | 12 +- .../native-chat-interactive-prompt.test.ts | 13 ++ .../input/native-chat-interactive-prompt.ts | 4 +- 16 files changed, 414 insertions(+), 36 deletions(-) create mode 100644 src/main/agent-hooks/server/testing/providers/codex/server-ingest-codex-async-question.test.ts create mode 100644 src/main/runtime/session-lifecycle/projection/codex-async-question-title-hold.ts create mode 100644 src/main/runtime/session-lifecycle/projection/runtime-mobile-codex-async-question-status.test.ts create mode 100644 src/shared/agent-hook-listener/legacy-agent-status-test-seed.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-async-questions.ts diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 0e19a595502..be3d6c2b5d1 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -265,6 +265,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen // A replacement remote process may reuse the pane; discard the lost owner's children. this.state.codexSubagentRosterByPaneKey.delete(paneKey) this.state.codexLeadStateByPaneKey.delete(paneKey) + this.state.codexAsyncQuestionsByPaneKey.delete(paneKey) } else if (deleted.payload.agentType === 'claude') { this.state.claudeSubagentRosterByPaneKey.delete(paneKey) this.state.claudeLeadStateByPaneKey.delete(paneKey) diff --git a/src/main/agent-hooks/server/testing/providers/codex/server-ingest-codex-async-question.test.ts b/src/main/agent-hooks/server/testing/providers/codex/server-ingest-codex-async-question.test.ts new file mode 100644 index 00000000000..e38f537c8ce --- /dev/null +++ b/src/main/agent-hooks/server/testing/providers/codex/server-ingest-codex-async-question.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeHookPayload } from '../../../../../../shared/agent-hook-listener/normalization/normalize-hook-payload' +import { createHookListenerState } from '../../../../../../shared/agent-hook-listener/listener-state' +import { buildRelayHookEnvelope } from '../../../../../../wsl-guest/agent-hook-envelope-build' +import { AgentHookServer, _internals } from '../../../agent-hook-server' +import { PANE, buildBody } from '../../server.test-fixtures' + +vi.mock('../../../../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../../../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) + +// Shapes captured from a live Codex 0.166 run. +const QUESTIONS = { + questions: [ + { title: 'Which database should the probe use?', options: ['Postgres', 'SQLite', 'MySQL'] }, + { title: 'What should the probe name the new table?' } + ] +} +const ASK = { + hook_event_name: 'PreToolUse', + turn_id: 'turn-1', + tool_name: 'request_user_input_async', + tool_input: QUESTIONS, + tool_use_id: 'call_3_1' +} +const ACCEPTED = { + ...ASK, + hook_event_name: 'PostToolUse', + tool_response: '{"accepted":true}' +} +const reply = (index: number) => ({ + hook_event_name: 'UserPromptSubmit', + turn_id: 'turn-1', + prompt: `\n${JSON.stringify([ + { + answer: 'SQLite', + question: QUESTIONS.questions[index]!.title, + questionItemId: JSON.stringify(['request_user_input_async', 'call_3_1', index]) + } + ])}\n` +}) + +beforeEach(() => { + _internals.resetCachesForTests() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// The execution host normalizes each hook, then main ingests the relayed row, as for SSH and WSL. +function codexPane() { + const host = createHookListenerState() + const server = new AgentHookServer() + return { + report(payload: Record) { + const event = normalizeHookPayload( + host, + 'codex', + buildBody(payload, { worktreeId: 'folder-workspace' }), + 'production' + ) + if (!event) { + throw new Error('Expected a Codex hook event') + } + server.ingestRemote(buildRelayHookEnvelope(event, 'codex'), 'wsl:Ubuntu') + return { host: event.payload, main: server.getStatusSnapshotForPane(PANE)[0] } + } + } +} + +const waitingOnQuestion = { + state: 'waiting', + toolName: 'request_user_input_async', + interactivePrompt: JSON.stringify(QUESTIONS) +} + +describe('Codex async questions', () => { + it('waits on an open question through the turn until every question is answered', () => { + const pane = codexPane() + pane.report({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Set up a db' }) + + expect(pane.report(ASK).main).toMatchObject(waitingOnQuestion) + // Codex accepts the call at once and keeps working; the question is still open. + const accepted = pane.report(ACCEPTED) + expect(accepted.host).toMatchObject(waitingOnQuestion) + expect(accepted.main).toMatchObject(waitingOnQuestion) + pane.report({ hook_event_name: 'PreToolUse', turn_id: 'turn-1', tool_name: 'Bash' }) + // An approval on screen wins over the open question. + const approval = pane.report({ + hook_event_name: 'PermissionRequest', + turn_id: 'turn-1', + tool_name: 'Bash', + tool_input: { command: 'pnpm tc' } + }).main + expect(approval?.state).toBe('waiting') + expect(approval?.interactivePrompt).toContain('"approval"') + pane.report({ hook_event_name: 'PostToolUse', turn_id: 'turn-1', tool_name: 'Bash' }) + expect(pane.report(reply(0)).main).toMatchObject(waitingOnQuestion) + + const answered = pane.report(reply(1)).main + expect(answered).toMatchObject({ state: 'working' }) + expect(answered?.interactivePrompt).toBeUndefined() + }) + + it('drops open questions when the user sends a plain message instead of a reply', () => { + const pane = codexPane() + pane.report({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Set up a db' }) + pane.report(ASK) + expect(pane.report(ACCEPTED).main).toMatchObject(waitingOnQuestion) + + // Orca's chat card answers an async question as a quoted message, as Codex clients do. + const steered = pane.report({ + hook_event_name: 'UserPromptSubmit', + turn_id: 'turn-1', + prompt: '> Which database should the probe use?\n\nSQLite' + }).main + expect(steered).toMatchObject({ state: 'working' }) + expect(steered?.interactivePrompt).toBeUndefined() + }) + + it('drops an unanswered question when the turn ends', () => { + const pane = codexPane() + pane.report({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Set up a db' }) + pane.report(ASK) + expect(pane.report(ACCEPTED).main).toMatchObject(waitingOnQuestion) + + expect(pane.report({ hook_event_name: 'Stop', turn_id: 'turn-1' }).main).toMatchObject({ + state: 'done' + }) + const next = pane.report({ + hook_event_name: 'UserPromptSubmit', + turn_id: 'turn-2', + prompt: 'Next' + }).main + expect(next).toMatchObject({ state: 'working' }) + expect(next?.interactivePrompt).toBeUndefined() + }) +}) diff --git a/src/main/runtime/session-lifecycle/projection/codex-async-question-title-hold.ts b/src/main/runtime/session-lifecycle/projection/codex-async-question-title-hold.ts new file mode 100644 index 00000000000..846a071b2c1 --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/codex-async-question-title-hold.ts @@ -0,0 +1,18 @@ +import { agentStatusAuthorityObservedAt } from '../../../../shared/agent-status/contracts/agent-status-freshness' +import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' +import { CODEX_ASYNC_QUESTION_TOOL } from '../../../../shared/codex-async-question-input' +import type { RuntimePtyWorktreeRecord } from '../../terminal/records/runtime-terminal-state-records' + +/** Codex's title stays working while its async question waits; only the hook sees the question. */ +export function codexAsyncQuestionOutlivesWorkingTitle( + status: AgentStatusEntry, + pty: RuntimePtyWorktreeRecord +): boolean { + return ( + status.state === 'waiting' && + status.toolName === CODEX_ASYNC_QUESTION_TOOL && + status.interactivePrompt != null && + pty.lastAgentStatus === 'working' && + agentStatusAuthorityObservedAt(status) > (pty.providerGenerationResetAtEpochMs ?? 0) + ) +} diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-agent-status-projection.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-agent-status-projection.ts index 6529fe2eb65..17761ac1130 100644 --- a/src/main/runtime/session-lifecycle/projection/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-agent-status-projection.ts @@ -13,6 +13,7 @@ import type { AgentProviderSessionMetadata } from '../../../../shared/agent-sess import { terminalTitleBlocksExplicitAgentStatus } from '../../workspaces/process-listing/runtime-worktree-status-projection' import type { HookLiveAgentRow } from '../../workspaces/process-listing/hook-live-agent-row' import type { RuntimePtyWorktreeRecord } from '../../terminal/records/runtime-terminal-state-records' +import { codexAsyncQuestionOutlivesWorkingTitle } from './codex-async-question-title-hold' type HookTurnFacts = Pick< AgentStatusEntry, @@ -120,9 +121,10 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( // Same-class Claude title repaints can postdate a fresh permission hook without // contradicting it; only a working or released-pane title retires the question (#11761). if ( - (status.state === 'waiting' || status.state === 'blocked') && - pty.lastAgentStatus === 'idle' && - Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS + ((status.state === 'waiting' || status.state === 'blocked') && + pty.lastAgentStatus === 'idle' && + Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS) || + codexAsyncQuestionOutlivesWorkingTitle(status, pty) ) { return status } diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-codex-async-question-status.test.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-codex-async-question-status.test.ts new file mode 100644 index 00000000000..37ead87f35a --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-codex-async-question-status.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' +import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' +import { makeTuiIdlePty } from '../../terminal/wait/tui-idle-wait-test-harness' + +const QUESTION = '{"questions":[{"title":"Which database should the probe use?"}]}' + +function waitingOnAsyncQuestion(hookAt: number): AgentStatusEntry { + return { + state: 'waiting', + prompt: 'Set up a db', + updatedAt: hookAt, + stateStartedAt: hookAt, + agentType: 'codex', + toolName: 'request_user_input_async', + interactivePrompt: QUESTION, + mainAgent: { state: 'waiting', stateStartedAt: hookAt }, + paneKey: 'tab-1:leaf-1', + stateHistory: [] + } +} + +describe('paired/mobile Codex status while an async question waits', () => { + it('keeps the question when Codex repaints its working title after the hook', () => { + const hookAt = 1_700_000_000_000 + const out = renewRuntimeMobileAgentStatusFromPtyTitle( + waitingOnAsyncQuestion(hookAt), + makeTuiIdlePty({ + lastOscTitle: '⠋ repo', + lastOscTitleEpochMs: hookAt + 2_000, + lastAgentStatus: 'working', + lastAgentStatusStartedAtEpochMs: hookAt - 10_000, + lastAgentStatusRichInvalidatedAtEpochMs: hookAt - 10_000 + }) + ) + expect(out).toMatchObject({ state: 'waiting', interactivePrompt: QUESTION }) + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts index 4bf38b045af..e2e46eb7d36 100644 --- a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -3,10 +3,8 @@ import { mkdtemp, mkdir, readFile, writeFile, appendFile, rm } from 'node:fs/pro import { tmpdir } from 'node:os' import { join } from 'node:path' import { ClaudeHelperKillPoller } from '../../../shared/agent-hook-listener/providers/claude-helper-kill-poller' -import { - createHookListenerState, - seedLegacyAgentStatusForTests -} from '../../../shared/agent-hook-listener/listener-state' +import { createHookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import { seedLegacyAgentStatusForTests } from '../../../shared/agent-hook-listener/legacy-agent-status-test-seed' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { createTestStore } from '../store/testing/app-store-test-fixture' import { makeTab } from '../store/terminals/terminal-tab.test-fixture' diff --git a/src/shared/agent-hook-listener/legacy-agent-status-test-seed.ts b/src/shared/agent-hook-listener/legacy-agent-status-test-seed.ts new file mode 100644 index 00000000000..56f3a32424a --- /dev/null +++ b/src/shared/agent-hook-listener/legacy-agent-status-test-seed.ts @@ -0,0 +1,20 @@ +import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../agent-status/transport/agent-status-legacy-adapter' +import type { AgentHookEventPayload } from './listener-event' +import { admitLegacyAgentStatus, type HookListenerState } from './listener-state' + +/** Test harnesses seed the same compatibility region without exposing a mutable Map. */ +export function seedLegacyAgentStatusForTests( + state: HookListenerState, + entry: AgentHookEventPayload +): void { + if ( + !admitLegacyAgentStatus( + state, + 'main-status-update', + entry, + AGENT_STATUS_2A_CURRENT_PRODUCER_MODE + ) + ) { + throw new Error('Test legacy agent-status seed was refused') + } +} diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 0d19068e10b..634cf60ce5b 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -3,7 +3,6 @@ import type { AgentChildWorkKind } from '../agent-status/child-work/agent-status import type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' import { - AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, createAgentStatusLegacyAdapter, type AgentStatusLegacyAdapter, type AgentStatusLegacyAdapterOptions, @@ -15,6 +14,7 @@ import type { ClaudeSubagentRoster } from '../claude-subagent-roster' import type { ClaudeToolRequest } from './claude-tool-request-identity' import type { CodexSubagentRoster } from '../codex-subagent-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' +import type { CodexAsyncQuestions } from './providers/codex-async-questions' import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' import { @@ -74,6 +74,8 @@ export type HookListenerState = { codexSubagentTranscriptByPaneKey: Map /** Root Codex state/model, kept separate from child hook traffic. */ codexLeadStateByPaneKey: Map + /** Mid-turn async questions Codex's TUI holds open; its own status keeps saying working. */ + codexAsyncQuestionsByPaneKey: Map /** Ephemeral forked Codex threads (/side) per pane; they never own the pane's status. */ codexEphemeralForkSessionsByPaneKey: Map /** A rollout-less Codex start held until its prompt shows a new conversation or the hidden title thread. */ @@ -157,6 +159,7 @@ export function createHookListenerState( codexSubagentRosterByPaneKey: new Map(), codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), + codexAsyncQuestionsByPaneKey: new Map(), codexEphemeralForkSessionsByPaneKey: new Map(), codexHeldEphemeralStartByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), @@ -213,23 +216,6 @@ export function getLegacyStatusListingOrder( return legacyStatusAdapter(state).listingOrder(paneKey) } -/** Test harnesses seed the same compatibility region without exposing a mutable Map. */ -export function seedLegacyAgentStatusForTests( - state: HookListenerState, - entry: AgentHookEventPayload -): void { - if ( - !admitLegacyAgentStatus( - state, - 'main-status-update', - entry, - AGENT_STATUS_2A_CURRENT_PRODUCER_MODE - ) - ) { - throw new Error('Test legacy agent-status seed was refused') - } -} - export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { deletePaneScopedCacheEntry(state.lastPromptByPaneKey, paneKey) deletePaneScopedCacheEntry(state.lastToolByPaneKey, paneKey) @@ -255,6 +241,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentRosterByPaneKey.delete(paneKey) state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) + state.codexAsyncQuestionsByPaneKey.delete(paneKey) state.codexEphemeralForkSessionsByPaneKey.delete(paneKey) state.codexHeldEphemeralStartByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) @@ -295,6 +282,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexAsyncQuestionsByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexEphemeralForkSessionsByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexHeldEphemeralStartByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) @@ -340,6 +328,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexSubagentRosterByPaneKey.clear() state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() + state.codexAsyncQuestionsByPaneKey.clear() state.codexEphemeralForkSessionsByPaneKey.clear() state.codexHeldEphemeralStartByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() diff --git a/src/shared/agent-hook-listener/normalization/agent-hook-listener-test-harness.ts b/src/shared/agent-hook-listener/normalization/agent-hook-listener-test-harness.ts index cfa104bb293..1d34ef4a795 100644 --- a/src/shared/agent-hook-listener/normalization/agent-hook-listener-test-harness.ts +++ b/src/shared/agent-hook-listener/normalization/agent-hook-listener-test-harness.ts @@ -1,5 +1,6 @@ import { normalizeHookPayload } from './normalize-hook-payload' -import { seedLegacyAgentStatusForTests, type HookListenerState } from '../listener-state' +import type { HookListenerState } from '../listener-state' +import { seedLegacyAgentStatusForTests } from '../legacy-agent-status-test-seed' import { makePaneKey } from '../../workspace-layout/stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/shared/agent-hook-listener/pane-state-claims.ts b/src/shared/agent-hook-listener/pane-state-claims.ts index 3d161b6a6d4..228eda7fa1a 100644 --- a/src/shared/agent-hook-listener/pane-state-claims.ts +++ b/src/shared/agent-hook-listener/pane-state-claims.ts @@ -11,6 +11,7 @@ export function paneHasStateClaims(state: HookListenerState, paneKey: string): b state.claudeLaunchedBackgroundTasksByPaneKey.has(paneKey) || state.claudeSessionOwnerByPaneKey.has(paneKey) || state.codexSubagentRosterByPaneKey.has(paneKey) || - state.codexLeadStateByPaneKey.has(paneKey) + state.codexLeadStateByPaneKey.has(paneKey) || + state.codexAsyncQuestionsByPaneKey.has(paneKey) ) } diff --git a/src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts b/src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts index 17799a9f92f..f5b7993c56b 100644 --- a/src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts +++ b/src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts @@ -2,7 +2,8 @@ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, vi } from 'vitest' -import { createHookListenerState, seedLegacyAgentStatusForTests } from '../listener-state' +import { createHookListenerState } from '../listener-state' +import { seedLegacyAgentStatusForTests } from '../legacy-agent-status-test-seed' import { normalizeHookPayload } from '../normalization/normalize-hook-payload' import { ClaudeHelperKillPoller } from './claude-helper-kill-poller' import { readClaudeTranscriptChunk } from './claude-transcript-cursor' diff --git a/src/shared/agent-hook-listener/providers/codex-async-questions.ts b/src/shared/agent-hook-listener/providers/codex-async-questions.ts new file mode 100644 index 00000000000..8e13c291fc2 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-async-questions.ts @@ -0,0 +1,137 @@ +import { + CODEX_ASYNC_QUESTION_TOOL, + parseCodexAsyncQuestionsInput +} from '../../codex-async-question-input' +import type { HookListenerState } from '../listener-state' +import { readString } from '../tool-input-preview' + +/** One `request_user_input_async` call and the question indexes it still waits on. */ +type CodexAsyncQuestionCall = { + /** The tool input as Codex sent it, published as the row's interactivePrompt. */ + prompt: string + unanswered: Set +} + +/** Pending mid-turn async questions in one Codex pane, by tool call id, oldest first. */ +export type CodexAsyncQuestions = Map + +const REPLY_OPEN = '' +const REPLY_CLOSE = '' + +/** The questions Codex's TUI is holding open for the user; the newest call is shown. */ +export type CodexPendingAsyncQuestion = { toolName: string; prompt: string } + +/** + * Codex asks a non-blocking question mid-turn: its status stays working, PostToolUse lands ~20 ms + * after PreToolUse, and each answer arrives as a UserPromptSubmit reply. Only the hooks show it, + * so track it here. A skip (Ctrl+]) sends no hook and stays pending until the turn ends or the + * user sends a plain message. + */ +export function trackCodexAsyncQuestions( + state: HookListenerState, + paneKey: string, + eventName: unknown, + hookPayload: Record +): CodexPendingAsyncQuestion | undefined { + // The TUI drops unanswered questions when the turn ends. + if (eventName === 'Stop' || eventName === 'Interrupt' || eventName === 'SessionStart') { + state.codexAsyncQuestionsByPaneKey.delete(paneKey) + return undefined + } + if ( + eventName === 'PreToolUse' && + readString(hookPayload, 'tool_name') === CODEX_ASYNC_QUESTION_TOOL + ) { + openCall(state, paneKey, hookPayload) + } else if (eventName === 'UserPromptSubmit') { + answerCalls(state, paneKey, readString(hookPayload, 'prompt')) + } + return pendingCodexAsyncQuestion(state, paneKey) +} + +export function pendingCodexAsyncQuestion( + state: HookListenerState, + paneKey: string +): CodexPendingAsyncQuestion | undefined { + const calls = state.codexAsyncQuestionsByPaneKey.get(paneKey) + const newest = calls ? [...calls.values()].at(-1) : undefined + return newest ? { toolName: CODEX_ASYNC_QUESTION_TOOL, prompt: newest.prompt } : undefined +} + +function openCall( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): void { + const callId = readString(hookPayload, 'tool_use_id') + const input = hookPayload['tool_input'] + const questions = + input && typeof input === 'object' && 'questions' in input && Array.isArray(input.questions) + ? input.questions + : null + if (!callId || !questions || !parseCodexAsyncQuestionsInput(input)) { + return + } + let calls = state.codexAsyncQuestionsByPaneKey.get(paneKey) + if (!calls) { + calls = new Map() + state.codexAsyncQuestionsByPaneKey.set(paneKey, calls) + } + calls.set(callId, { + prompt: JSON.stringify(input), + unanswered: new Set(questions.keys()) + }) +} + +// Each answer is `[{answer, question, questionItemId: '["request_user_input_async", callId, index]'}]`. +function answerCalls(state: HookListenerState, paneKey: string, prompt: string | undefined): void { + const calls = state.codexAsyncQuestionsByPaneKey.get(paneKey) + const body = prompt?.trim() + if (!calls) { + return + } + // A plain message answers or abandons them all, as native chat's transcript scan reads it. + if (!body?.startsWith(REPLY_OPEN) || !body.endsWith(REPLY_CLOSE)) { + state.codexAsyncQuestionsByPaneKey.delete(paneKey) + return + } + for (const [callId, index] of readReplyItems( + body.slice(REPLY_OPEN.length, -REPLY_CLOSE.length) + )) { + const call = calls.get(callId) + call?.unanswered.delete(index) + if (call?.unanswered.size === 0) { + calls.delete(callId) + } + } + if (calls.size === 0) { + state.codexAsyncQuestionsByPaneKey.delete(paneKey) + } +} + +function readReplyItems(json: string): [string, number][] { + const items = parseJson(json) + if (!Array.isArray(items)) { + return [] + } + return items.flatMap((item): [string, number][] => { + const id = + item && typeof item === 'object' && 'questionItemId' in item + ? parseJson(String(item.questionItemId)) + : null + return Array.isArray(id) && + id[0] === CODEX_ASYNC_QUESTION_TOOL && + typeof id[1] === 'string' && + typeof id[2] === 'number' + ? [[id[1], id[2]]] + : [] + }) +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index 2e1b1e423e8..18bfd176914 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -40,6 +40,7 @@ import { resolveCodexPaneStatus, setCodexMainAgentTurnState } from './codex-state' +import { pendingCodexAsyncQuestion, trackCodexAsyncQuestions } from './codex-async-questions' export function buildCodexStatusPayload( state: HookListenerState, @@ -55,6 +56,11 @@ export function buildCodexStatusPayload( }) : (state.lastToolByPaneKey.get(paneKey) ?? {}) const lead = state.codexLeadStateByPaneKey.get(paneKey) + // An approval or blocking question on screen wins; otherwise a waiting row shows the open async question. + const asyncQuestion = + options.stateName === 'waiting' && !snapshot.interactivePrompt + ? pendingCodexAsyncQuestion(state, paneKey) + : undefined return normalizeAgentStatusPayload({ state: options.stateName, @@ -64,9 +70,9 @@ export function buildCodexStatusPayload( }), agentType: 'codex', model: lead?.model, - toolName: snapshot.toolName, + toolName: asyncQuestion?.toolName ?? snapshot.toolName, toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, + interactivePrompt: asyncQuestion?.prompt ?? snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, interrupted: mainAgentTurnInterrupted(lead), @@ -242,6 +248,11 @@ export function normalizeCodexEvent( transcriptPath, stateName ) + // An open async question needs the user even while Codex keeps working. + const leadState = + trackCodexAsyncQuestions(state, paneKey, eventName, hookPayload) && ownedState === 'working' + ? 'waiting' + : ownedState const previousLead = state.codexLeadStateByPaneKey.get(paneKey) const transcript = state.codexSubagentTranscriptByPaneKey.get(paneKey) const turnId = readString(hookPayload, 'turn_id') @@ -260,10 +271,10 @@ export function normalizeCodexEvent( !previousLead?.turnId || codexTranscriptTurnEnd(transcript, previousLead.turnId) !== undefined)) const record = setCodexMainAgentTurnState(state, paneKey, { - state: ownedState, + state: leadState, ...(failedTurn ? { outcome: 'failure' as const } - : codexLeadOutcomeForEvent(eventName, previousLead, ownedState)), + : codexLeadOutcomeForEvent(eventName, previousLead, leadState)), model: normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model), diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 34e43c2143b..e96db368fbb 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -1,6 +1,7 @@ import type { AgentMainAgentStatus } from '../../agent-status/lifecycle/main-agent-status' import type { ParsedAgentStatusPayload } from '../../agent-status/contracts/agent-status-types' import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' +import { CODEX_ASYNC_QUESTION_TOOL } from '../../codex-async-question-input' import { continueMainAgentStatus, foldAgentLeadStatus, @@ -191,9 +192,14 @@ export function codexLeadStateForHookEvent( toolName?: string ): CodexLeadTurnState['state'] | undefined { // Preserve execution-host reviewer ownership; event names alone would turn every relayed approval into waiting. - return eventName === 'PermissionRequest' && normalizedState === 'working' - ? 'working' - : codexHookEventState(eventName, toolName) + if (eventName === 'PermissionRequest' && normalizedState === 'working') { + return 'working' + } + // The execution host tracks Codex's async questions; its waiting outlasts the events around it. + if (normalizedState === 'waiting' && toolName === CODEX_ASYNC_QUESTION_TOOL) { + return 'waiting' + } + return codexHookEventState(eventName, toolName) } /** Which children an unlabelled relayed poll may retire by omitting them. */ diff --git a/src/shared/native-chat/input/native-chat-interactive-prompt.test.ts b/src/shared/native-chat/input/native-chat-interactive-prompt.test.ts index 99ba0cc39b5..12eb8b61f4d 100644 --- a/src/shared/native-chat/input/native-chat-interactive-prompt.test.ts +++ b/src/shared/native-chat/input/native-chat-interactive-prompt.test.ts @@ -58,4 +58,17 @@ describe('resolveNativeChatInteractivePromptCard', () => { expect(resolve(JSON.stringify({ approval: { tool: 'Bash' } }))?.kind).toBe('approval') expect(resolve(null)?.kind).toBe('question') }) + + it("keeps Codex's async question on the transcript's copy, which drops answered questions", () => { + const card = resolveNativeChatInteractivePromptCard({ + interactivePrompt: JSON.stringify({ + questions: [{ title: 'Which database?', options: ['Postgres'] }, { title: 'Table name?' }] + }), + toolName: 'request_user_input_async', + agent: 'codex', + messages: [pendingAsk], + transcriptSettled: true + }) + expect(card?.kind === 'question' && card.prompt.questions[0]?.question).toBe('Which path?') + }) }) diff --git a/src/shared/native-chat/input/native-chat-interactive-prompt.ts b/src/shared/native-chat/input/native-chat-interactive-prompt.ts index c542dcd430e..6b782f0481e 100644 --- a/src/shared/native-chat/input/native-chat-interactive-prompt.ts +++ b/src/shared/native-chat/input/native-chat-interactive-prompt.ts @@ -120,8 +120,10 @@ export function resolveNativeChatInteractivePromptCard(args: { if (statusCard?.kind === 'approval') { return statusCard } + // Codex's async questions come from the transcript, which knows which ones were answered. + const liveAsk = statusCard?.prompt.replyAsMessage ? null : (statusCard?.prompt ?? null) const prompt = resolveNativeChatAsk({ - liveAsk: statusCard?.prompt ?? null, + liveAsk, messages: args.messages, transcriptSettled: args.transcriptSettled, transcriptAsk: args.transcriptAsk From 81e5e5a9ef83e015f19bcc079a03414c09605898 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:46:25 +0000 Subject: [PATCH 061/138] feat(settings): redesign Voice settings around a setup card and a rated model list (#40452) --- src/main/speech/model-catalog.ts | 24 -- .../speech-model-update-target.test.ts | 1 - .../settings-capability-section-renderers.tsx | 4 +- .../VoiceDictationLanguageSetting.fixture.tsx | 1 - .../VoiceDictationLanguageSetting.test.tsx | 25 -- .../voice/VoiceDictationLanguageSetting.tsx | 15 +- .../voice/VoiceDictationSettingsSection.tsx | 101 ------ .../settings/voice/VoiceInputSettings.tsx | 96 ++++++ .../settings/voice/VoiceMicrophoneSetting.tsx | 60 +--- .../settings/voice/VoicePane.test.tsx | 9 +- .../components/settings/voice/VoicePane.tsx | 228 ++++++------ .../settings/voice/VoiceSetupCard.fixture.tsx | 60 ++++ .../settings/voice/VoiceSetupCard.tsx | 177 ++++++++++ .../voice/VoiceSpeechModelQuality.tsx | 103 ++++++ .../settings/voice/VoiceSpeechModelRow.tsx | 191 ++++++++++ .../voice/VoiceSpeechModelSection.fixture.tsx | 128 +++++-- .../voice/VoiceSpeechModelSection.test.tsx | 41 +-- .../voice/VoiceSpeechModelSection.tsx | 325 ++++++------------ .../voice/speech-model-description.ts | 69 ---- .../settings/voice/speech-model-download.ts | 26 ++ .../voice/use-voice-microphone-devices.ts | 67 ++++ .../voice/voice-speech-model-rows.test.ts | 42 +++ .../settings/voice/voice-speech-model-rows.ts | 47 +++ src/renderer/src/i18n/locales/de.json | 81 +++-- src/renderer/src/i18n/locales/en.json | 82 +++-- src/renderer/src/i18n/locales/es.json | 81 +++-- src/renderer/src/i18n/locales/fr.json | 81 +++-- src/renderer/src/i18n/locales/ja.json | 81 +++-- src/renderer/src/i18n/locales/ko.json | 81 +++-- src/renderer/src/i18n/locales/pt.json | 81 +++-- src/renderer/src/i18n/locales/ru.json | 81 +++-- src/renderer/src/i18n/locales/tr.json | 81 +++-- src/renderer/src/i18n/locales/zh-Hant.json | 81 +++-- src/renderer/src/i18n/locales/zh.json | 81 +++-- src/shared/speech-types.ts | 1 - 35 files changed, 1833 insertions(+), 900 deletions(-) delete mode 100644 src/renderer/src/components/settings/voice/VoiceDictationSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/voice/VoiceInputSettings.tsx create mode 100644 src/renderer/src/components/settings/voice/VoiceSetupCard.fixture.tsx create mode 100644 src/renderer/src/components/settings/voice/VoiceSetupCard.tsx create mode 100644 src/renderer/src/components/settings/voice/VoiceSpeechModelQuality.tsx create mode 100644 src/renderer/src/components/settings/voice/VoiceSpeechModelRow.tsx delete mode 100644 src/renderer/src/components/settings/voice/speech-model-description.ts create mode 100644 src/renderer/src/components/settings/voice/speech-model-download.ts create mode 100644 src/renderer/src/components/settings/voice/use-voice-microphone-devices.ts create mode 100644 src/renderer/src/components/settings/voice/voice-speech-model-rows.test.ts create mode 100644 src/renderer/src/components/settings/voice/voice-speech-model-rows.ts diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index 97ab776a901..258a129f940 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -18,8 +18,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'parakeet-tdt-0.6b-v3-int8', label: 'Parakeet TDT v3', - description: - 'Highest accuracy for 25 European languages. Punctuation, capitalization, and word-level timestamps.', type: 'transducer', provider: 'local', language: 'multilingual', @@ -35,8 +33,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'parakeet-tdt-0.6b-v2-int8', label: 'Parakeet TDT v2', - description: - 'English only. Faster than v3 with similar accuracy. Punctuation and capitalization.', type: 'transducer', provider: 'local', language: 'en', @@ -51,7 +47,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'zipformer-bilingual-zh-en', label: 'Zipformer Bilingual', - description: 'Chinese + English with code-switching. Low-latency real-time streaming.', type: 'transducer', provider: 'local', language: 'zh-en', @@ -65,8 +60,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'paraformer-bilingual-zh-en', label: 'Paraformer Bilingual', - description: - 'Chinese (Mandarin + dialects) + English. Strong on accented and regional Chinese.', type: 'paraformer', provider: 'local', language: 'zh-en', @@ -80,7 +73,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'zipformer-streaming-en-20m', label: 'Zipformer Streaming EN', - description: 'English only. Lightweight 20M-param model, good balance of speed and size.', type: 'transducer', provider: 'local', language: 'en', @@ -94,7 +86,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'zipformer-streaming-zh-14m', label: 'Zipformer Streaming ZH', - description: 'Chinese only. Ultra-lightweight 14M-param model, ideal for low-resource devices.', type: 'transducer', provider: 'local', language: 'zh', @@ -108,7 +99,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'zipformer-streaming-korean', label: 'Zipformer Streaming KO', - description: 'Korean only. Low-latency real-time streaming.', type: 'transducer', provider: 'local', language: 'ko', @@ -122,7 +112,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'parakeet-tdt-ctc-0.6b-ja-int8', label: 'Parakeet TDT-CTC JA', - description: 'Japanese only. Trained on 35k+ hours of natural speech. Punctuation included.', type: 'nemo-ctc', provider: 'local', language: 'ja', @@ -136,7 +125,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'whisper-tiny', label: 'Whisper Tiny', - description: '90+ languages. Lower accuracy than Parakeet but broadest language coverage.', type: 'whisper', provider: 'local', language: 'multilingual', @@ -152,8 +140,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'whisper-large-v3-turbo', label: 'Whisper Large v3 Turbo', - description: - 'Best accuracy for Japanese, Korean, and Chinese, and 90+ languages. Slower than Parakeet.', type: 'whisper', provider: 'local', language: 'multilingual', @@ -168,8 +154,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'sense-voice-zh-en-ja-ko-yue', label: 'SenseVoice', - description: - 'Chinese, English, Japanese, Korean, and Cantonese with automatic language detection.', type: 'senseVoice', provider: 'local', language: 'multilingual', @@ -185,8 +169,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'nemotron-3.5-asr-streaming-0.6b', label: 'Nemotron 3.5', - description: - 'English, Spanish, German and many more languages, with automatic language detection.', type: 'transducer', provider: 'local', language: 'multilingual', @@ -202,7 +184,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'openai-gpt-transcribe', label: 'GPT Transcribe', - description: 'Cloud transcription with high accuracy. Requires an OpenAI API key.', type: 'openai', provider: 'openai', language: 'multilingual', @@ -214,8 +195,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'openai-compatible', label: 'OpenAI-compatible', - description: - 'Any service with an OpenAI-style transcription API, such as Groq, Mistral or a local server.', type: 'openai', provider: 'openai-compatible', language: 'multilingual', @@ -227,8 +206,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'openai-gpt-4o-mini-transcribe', label: 'GPT-4o mini Transcribe', - description: - 'Cloud transcription with strong accuracy and low cost. Requires an OpenAI API key.', type: 'openai', provider: 'openai', language: 'multilingual', @@ -242,7 +219,6 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'openai-gpt-4o-transcribe', label: 'GPT-4o Transcribe', - description: 'Cloud transcription with higher accuracy. Requires an OpenAI API key.', type: 'openai', provider: 'openai', language: 'multilingual', diff --git a/src/renderer/src/components/dictation/speech-model-update-target.test.ts b/src/renderer/src/components/dictation/speech-model-update-target.test.ts index c59e4e8bdd6..538bf45e94d 100644 --- a/src/renderer/src/components/dictation/speech-model-update-target.test.ts +++ b/src/renderer/src/components/dictation/speech-model-update-target.test.ts @@ -6,7 +6,6 @@ function model(id: string, extra: Partial = {}): SpeechMode return { id, label: id, - description: '', type: 'transducer', provider: 'local', language: 'en', diff --git a/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx index e3ec3097cca..5cddf1f09a0 100644 --- a/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-capability-section-renderers.tsx @@ -177,8 +177,8 @@ export function renderDesktopCapabilitySettingsSections( id="voice" title={t('auto.components.settings.Settings.5063bb47a5', 'Voice')} description={t( - 'auto.components.settings.Settings.eb1176a14e', - 'Local speech-to-text dictation with on-device models.' + 'components.settings.voice.sectionDescription', + 'Talk instead of typing in any pane. Speech is recognized on this computer unless you pick a cloud model.' )} searchEntries={navigation.getSectionSearchEntries('voice')} > diff --git a/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.fixture.tsx b/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.fixture.tsx index 7e048404a80..9fde64dff1f 100644 --- a/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.fixture.tsx +++ b/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.fixture.tsx @@ -15,7 +15,6 @@ function model( return { id: label, label, - description: '', type, provider: 'local', language: 'multilingual', diff --git a/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.test.tsx b/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.test.tsx index 94d39441b7f..fea193fe3bf 100644 --- a/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.test.tsx +++ b/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.test.tsx @@ -35,7 +35,6 @@ it('keeps a selected language visible when the chosen model does not support it' const selectedModel: SpeechModelManifest = { id: 'limited', label: 'Limited model', - description: '', type: 'whisper', provider: 'local', language: 'multilingual', @@ -59,27 +58,3 @@ it('keeps a selected language visible when the chosen model does not support it' fireEvent.click(screen.getByRole('combobox', { name: 'Dictation Language' })) expect(screen.getByRole('option', { name: 'Korean' })).toBeTruthy() }) - -it('keeps the language control disabled for models that ignore language settings', () => { - const selectedModel: SpeechModelManifest = { - id: 'parakeet', - label: 'Parakeet', - description: '', - type: 'transducer', - provider: 'local', - language: 'multilingual', - languages: ['en', 'es'], - sampleRate: 16000, - streaming: false - } - render( - - ) - - const trigger = screen.getByRole('combobox', { name: 'Dictation Language' }) - expect(trigger.getAttribute('disabled')).not.toBeNull() -}) diff --git a/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.tsx b/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.tsx index 22553593c17..8d0072f6688 100644 --- a/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.tsx +++ b/src/renderer/src/components/settings/voice/VoiceDictationLanguageSetting.tsx @@ -55,12 +55,14 @@ export function VoiceDictationLanguageSetting({ const intlLocale = useIntlLocale() const modelIgnoresLanguage = selectedModel !== undefined && !selectedModel.dictationLanguages const language = voiceSettings.dictationLanguage || DICTATION_LANGUAGE_AUTO - const choices = selectedModel?.dictationLanguages ?? DICTATION_LANGUAGES - // Keep a choice the model lacks selectable rather than showing a blank trigger. - const codes = - language === DICTATION_LANGUAGE_AUTO || choices.includes(language) - ? choices - : [language, ...choices] + // Every language, even ones this model lacks: the choice also rates the other models. + const codes = [ + ...new Set([ + ...(language === DICTATION_LANGUAGE_AUTO ? [] : [language]), + ...DICTATION_LANGUAGES, + ...(selectedModel?.dictationLanguages ?? []) + ]) + ] const collator = uiCollator(NAME_COLLATOR, intlLocale) const options = codes .map((code) => ({ code, name: languageDisplayName(code, intlLocale) })) @@ -99,7 +101,6 @@ export function VoiceDictationLanguageSetting({ 'auto.components.settings.SettingsSearchableSelect.searchLanguages', 'Search languages…' )} - disabled={!voiceSettings.enabled || modelIgnoresLanguage} onValueChange={(value) => onUpdateVoiceSettings({ dictationLanguage: value })} /> } diff --git a/src/renderer/src/components/settings/voice/VoiceDictationSettingsSection.tsx b/src/renderer/src/components/settings/voice/VoiceDictationSettingsSection.tsx deleted file mode 100644 index 9edcdec2532..00000000000 --- a/src/renderer/src/components/settings/voice/VoiceDictationSettingsSection.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { VoiceSettings } from '../../../../../shared/speech-types' -import { - dictationShortcutMode, - dictationShortcutModeSettings -} from '../../../../../shared/dictation-shortcut-mode' -import { Label } from '../../ui/label' -import { Separator } from '../../ui/separator' -import { Switch } from '../../ui/switch' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' -import { useTranslate } from '@/i18n/use-translate' -import { VoiceMicrophoneSetting } from './VoiceMicrophoneSetting' - -type VoiceDictationSettingsSectionProps = { - voiceSettings: VoiceSettings - permissionPending: boolean - onToggleVoiceDictation: () => void - onUpdateVoiceSettings: (updates: Partial) => void -} - -export function VoiceDictationSettingsSection({ - voiceSettings, - permissionPending, - onToggleVoiceDictation, - onUpdateVoiceSettings -}: VoiceDictationSettingsSectionProps): React.JSX.Element { - const t = useTranslate() - const shortcutLabel = useShortcutLabel('voice.dictation') - const selectedMode = dictationShortcutMode(voiceSettings) - const modeLabels = { - auto: t('auto.components.settings.VoicePane.dictationModeAuto', 'Auto'), - hold: t('auto.components.settings.VoicePane.174da92062', 'Hold'), - toggle: t('auto.components.settings.VoicePane.118b3c2dee', 'Toggle') - } - - return ( - <> -
-
- -

- {t( - 'auto.components.settings.VoicePane.dictationShortcutDescription', - 'Press {{shortcut}} to dictate text into any focused pane.', - { shortcut: shortcutLabel } - )} -

-
- void onToggleVoiceDictation()} - className="disabled:cursor-wait disabled:opacity-70" - /> -
- - - -
-
- -

- {t( - 'auto.components.settings.VoicePane.dictationModesWithAutoDescription', - 'Auto: hold {{shortcut}} to dictate until you let go, or tap it to start and stop. Hold: dictate while {{shortcut}} is held. Toggle: press {{shortcut}} once to start, again to stop.', - { shortcut: shortcutLabel } - )} -

-
-
- {(['auto', 'hold', 'toggle'] as const).map((mode) => ( - - ))} -
-
- - - - - - - - ) -} diff --git a/src/renderer/src/components/settings/voice/VoiceInputSettings.tsx b/src/renderer/src/components/settings/voice/VoiceInputSettings.tsx new file mode 100644 index 00000000000..11645289e91 --- /dev/null +++ b/src/renderer/src/components/settings/voice/VoiceInputSettings.tsx @@ -0,0 +1,96 @@ +import type { VoiceSettings } from '../../../../../shared/speech-types' +import { + dictationShortcutMode, + dictationShortcutModeSettings, + type DictationShortcutMode +} from '../../../../../shared/dictation-shortcut-mode' +import { useAppStore } from '@/store' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { useShortcutKeyDetails, useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useTranslate } from '@/i18n/use-translate' +import { Button } from '../../ui/button' +import { + SettingsRow, + SettingsSegmentedControl, + SettingsSubsectionHeader +} from '../form-controls/SettingsFormControls' +import { VoiceMicrophoneSetting } from './VoiceMicrophoneSetting' + +export const VOICE_MICROPHONE_ROW_ID = 'voice-microphone' + +type VoiceInputSettingsProps = { + voiceSettings: VoiceSettings + onUpdateVoiceSettings: (updates: Partial) => void +} + +export function VoiceInputSettings({ + voiceSettings, + onUpdateVoiceSettings +}: VoiceInputSettingsProps): React.JSX.Element { + const t = useTranslate() + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const shortcutLabel = useShortcutLabel('voice.dictation') + const shortcutKeys = useShortcutKeyDetails('voice.dictation') + + return ( +
+ +
+ + + +
+ } + /> + + value={dictationShortcutMode(voiceSettings)} + onChange={(mode) => onUpdateVoiceSettings(dictationShortcutModeSettings(mode))} + ariaLabel={t('auto.components.settings.VoicePane.ba4a900d1d', 'Dictation Mode')} + options={[ + { + value: 'auto', + label: t('auto.components.settings.VoicePane.dictationModeAuto', 'Auto') + }, + { + value: 'hold', + label: t('auto.components.settings.VoicePane.174da92062', 'Hold') + }, + { + value: 'toggle', + label: t('auto.components.settings.VoicePane.118b3c2dee', 'Toggle') + } + ]} + /> + } + /> +
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/voice/VoiceMicrophoneSetting.tsx b/src/renderer/src/components/settings/voice/VoiceMicrophoneSetting.tsx index 38fc213baf7..4a7fb26886c 100644 --- a/src/renderer/src/components/settings/voice/VoiceMicrophoneSetting.tsx +++ b/src/renderer/src/components/settings/voice/VoiceMicrophoneSetting.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { toast } from 'sonner' import type { VoiceSettings } from '../../../../../shared/speech-types' import { Button } from '../../ui/button' @@ -6,15 +6,14 @@ import { Label } from '../../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select' import { buildVoiceMicrophoneSelectOptions, - listVoiceMicrophoneDevices, - microphoneDeviceIdFromSelectValue, - type VoiceMicrophoneDevice + microphoneDeviceIdFromSelectValue } from '@/components/dictation/microphone-devices' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { useVoiceMicrophoneDevices } from './use-voice-microphone-devices' type VoiceMicrophoneSettingProps = { voiceSettings: VoiceSettings @@ -62,66 +61,17 @@ function microphoneAccessErrorMessage(error: unknown): string { ) } -function sameDeviceList( - a: readonly VoiceMicrophoneDevice[], - b: readonly VoiceMicrophoneDevice[] -): boolean { - return ( - a.length === b.length && - a.every((device, index) => { - const other = b[index] - return device.deviceId === other?.deviceId && device.label === other.label - }) - ) -} - export function VoiceMicrophoneSetting({ voiceSettings, onUpdateVoiceSettings }: VoiceMicrophoneSettingProps): React.JSX.Element { const t = useTranslate() const languageRevision = useUiLanguageRevision() - const [devices, setDevices] = useState([]) - const [devicesKnown, setDevicesKnown] = useState(false) + // Rescan when dictation enables because permission and real device labels may arrive with that toggle. + const { devices, devicesKnown, refreshDevices } = useVoiceMicrophoneDevices(voiceSettings.enabled) const [accessPending, setAccessPending] = useState(false) const [accessError, setAccessError] = useState(null) const mountedRef = useMountedRef() - // Bluetooth emits repeated devicechange events; reject stale enumeration results. - const refreshGenerationRef = useRef(0) - - const refreshDevices = useCallback(async (): Promise => { - const generation = refreshGenerationRef.current + 1 - refreshGenerationRef.current = generation - if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) { - return - } - let next: VoiceMicrophoneDevice[] = [] - try { - next = listVoiceMicrophoneDevices(await navigator.mediaDevices.enumerateDevices()) - } catch { - next = [] - } - if (!mountedRef.current || refreshGenerationRef.current !== generation) { - return - } - setDevicesKnown(next.length > 0) - setDevices((current) => (sameDeviceList(current, next) ? current : next)) - }, [mountedRef]) - - // Rescan when dictation enables because permission and real device labels may arrive with that toggle. - useEffect(() => { - void refreshDevices() - if (typeof navigator === 'undefined' || !navigator.mediaDevices?.addEventListener) { - return - } - const handleDeviceChange = (): void => { - void refreshDevices() - } - navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange) - return () => { - navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange) - } - }, [refreshDevices, voiceSettings.enabled]) // A generic stream grants discovery even when the saved device is stale. const openStreamAndRefreshDevices = useCallback(async (): Promise => { diff --git a/src/renderer/src/components/settings/voice/VoicePane.test.tsx b/src/renderer/src/components/settings/voice/VoicePane.test.tsx index 19e1fc6f5f7..0694de92ee3 100644 --- a/src/renderer/src/components/settings/voice/VoicePane.test.tsx +++ b/src/renderer/src/components/settings/voice/VoicePane.test.tsx @@ -19,7 +19,8 @@ const { useAppStoreMock, useShortcutLabelMock } = vi.hoisted(() => ({ vi.mock('@/store/index', () => ({ useAppStore: useAppStoreMock })) vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: useShortcutLabelMock + useShortcutLabel: useShortcutLabelMock, + useShortcutKeyDetails: () => ({ keys: [], doubleTap: false }) })) vi.mock('sonner', () => ({ @@ -108,6 +109,12 @@ describe('VoicePane', () => { root.render() }) + const cloudTab = Array.from( + container.querySelectorAll('[role="radio"]') + ).find((button) => button.textContent === 'Cloud') + await act(async () => { + cloudTab?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) const disconnect = container.querySelector( 'button[aria-label="Disconnect OpenAI API key"]' ) diff --git a/src/renderer/src/components/settings/voice/VoicePane.tsx b/src/renderer/src/components/settings/voice/VoicePane.tsx index 9b008cdd441..b29ea9ff4a7 100644 --- a/src/renderer/src/components/settings/voice/VoicePane.tsx +++ b/src/renderer/src/components/settings/voice/VoicePane.tsx @@ -12,15 +12,18 @@ import { OpenAiCompatibleProviderDialog } from './OpenAiCompatibleProviderDialog import { OpenAiTranscriptionSettingsRow } from './OpenAiTranscriptionSettingsRow' import { handleVoiceDictationToggle } from './voice-dictation-toggle' import { VoiceDictationLanguageSetting } from './VoiceDictationLanguageSetting' -import { VoiceDictationSettingsSection } from './VoiceDictationSettingsSection' +import { VOICE_MICROPHONE_ROW_ID, VoiceInputSettings } from './VoiceInputSettings' +import { VoiceSetupCard } from './VoiceSetupCard' +import { cancelSpeechModelDownload, startSpeechModelDownload } from './speech-model-download' import { VoiceModelUnloadSetting } from './VoiceModelUnloadSetting' import { VoiceSpeechModelSection } from './VoiceSpeechModelSection' -import { SettingsSwitchRow } from '../form-controls/SettingsFormControls' +import { SettingsSubsectionHeader, SettingsSwitchRow } from '../form-controls/SettingsFormControls' import { VoiceSpeechGpuSetting } from './VoiceSpeechGpuSetting' -import { matchesSettingsSearch } from '../search/settings-search' -import { getOpenaiTranscriptionSearchEntry } from './voice-pane-search' import { useTranslate } from '@/i18n/use-translate' import { readIpcErrorMessage } from '@/lib/ipc-error' +import { useLanguageReadiness } from '@/i18n/language-readiness' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' +import { speechModelRatingLanguage } from '../../../../../shared/speech-model-quality' type VoicePaneProps = { settings: GlobalSettings @@ -35,8 +38,10 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J const modelStates = useAppStore((s) => s.modelStates) const refreshModelStates = useAppStore((s) => s.refreshModelStates) const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen) - const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery ?? '') - const [catalog, setCatalog] = useState([]) + const systemLanguageTag = useLanguageReadiness((s) => s.detection?.detectedTag) + // null until main answers, so the card and list never show a state the catalog would contradict. + const [loadedCatalog, setCatalog] = useState(null) + const catalog = loadedCatalog ?? [] const [permissionPending, setPermissionPending] = useState(false) const [openAiDialogOpen, setOpenAiDialogOpen] = useState(false) const [openAiApiKeyDraft, setOpenAiApiKeyDraft] = useState('') @@ -143,18 +148,11 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J } const selectedModel = catalog.find((m) => m.id === voiceSettings.sttModel) - const showOpenAiSettingsRow = - voiceSettings.openAiApiKeyConfigured || - selectedModel?.provider === 'openai' || - (settingsSearchQuery.trim() !== '' && - matchesSettingsSearch(settingsSearchQuery, getOpenaiTranscriptionSearchEntry())) const compatibleModel = catalog.find((m) => m.provider === 'openai-compatible') const compatibleConfigured = compatibleModel !== undefined && modelStates.some((s) => s.id === compatibleModel.id && s.status === 'ready') - const showCompatibleSettingsRow = - compatibleConfigured || selectedModel?.provider === 'openai-compatible' const openOpenAiDialog = (modelId: string | null = null): void => { setPendingCloudModelId(modelId) @@ -167,11 +165,11 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J setCompatibleDialogOpen(true) } - const openCloudSetup = (manifest: SpeechModelManifest): void => { - if (manifest.provider === 'openai-compatible') { - openCompatibleDialog(manifest.id) + const openCloudSetup = (modelId: string): void => { + if (catalog.find((m) => m.id === modelId)?.provider === 'openai-compatible') { + openCompatibleDialog(modelId) } else { - openOpenAiDialog(manifest.id) + openOpenAiDialog(modelId) } } @@ -252,95 +250,127 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J } } + const selectedState = modelStates.find((s) => s.id === voiceSettings.sttModel) + const ratingLanguage = + speechModelRatingLanguage(voiceSettings.dictationLanguage, systemLanguageTag ?? undefined) ?? + 'en' + return ( -
- void toggleVoiceDictation()} - onUpdateVoiceSettings={updateVoiceSettings} - /> - - - - - - - - - - - updateVoiceSettings({ removeFillerWords: !voiceSettings.removeFillerWords }) - } - /> - - - - - - {selectedModel?.provider === 'local' && ( +
+ {loadedCatalog ? ( <> - + void toggleVoiceDictation()} + onDownloadModel={(modelId) => startSpeechModelDownload(modelId, refreshModelStates)} + onCancelDownload={(modelId) => cancelSpeechModelDownload(modelId, refreshModelStates)} + onShowMicrophone={() => + document + .getElementById(VOICE_MICROPHONE_ROW_ID) + ?.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'center' }) + } + /> + + + {voiceSettings.openAiApiKeyConfigured ? ( + <> + + openOpenAiDialog(null)} + onClear={() => void clearOpenAiApiKey()} + /> + + ) : null} + {compatibleConfigured ? ( + openCompatibleDialog(null)} + onClear={() => void removeCompatibleProvider()} + /> + ) : null} + + } + /> + + ) : null} + + + +
+ + +
+ + + +
+ + + updateVoiceSettings({ removeFillerWords: !voiceSettings.removeFillerWords }) + } + /> +
+ + + + + + + +
+ +
- - )} - - {showOpenAiSettingsRow && ( - <> - - - openOpenAiDialog(null)} - onClear={() => void clearOpenAiApiKey()} - /> - - )} - - {showCompatibleSettingsRow && ( - <> - - openCompatibleDialog(null)} - onClear={() => void removeCompatibleProvider()} - /> - - )} +
+
{}} + onDownloadModel={() => {}} + onCancelDownload={() => {}} + onShowMicrophone={() => {}} + /> + ) +} + +export const newUser = defineFixture({ + width: 760, + render: () => render(false, undefined, undefined) +}) + +export const on = defineFixture({ + width: 760, + render: () => render(true, parakeet, { id: 'v3', status: 'ready' }) +}) + +export const off = defineFixture({ + width: 760, + render: () => render(false, parakeet, { id: 'v3', status: 'ready' }) +}) + +export const needsUpdate = defineFixture({ + width: 760, + render: () => render(true, parakeet, { id: 'v3', status: 'not-downloaded', needsUpdate: true }) +}) + +export const downloading = defineFixture({ + width: 760, + render: () => render(true, parakeet, { id: 'v3', status: 'downloading', progress: 0.42 }) +}) diff --git a/src/renderer/src/components/settings/voice/VoiceSetupCard.tsx b/src/renderer/src/components/settings/voice/VoiceSetupCard.tsx new file mode 100644 index 00000000000..32a11b6e2a1 --- /dev/null +++ b/src/renderer/src/components/settings/voice/VoiceSetupCard.tsx @@ -0,0 +1,177 @@ +import { CircleAlert, Check, Download, Mic } from 'lucide-react' +import type { + SpeechModelManifest, + SpeechModelState, + VoiceSettings +} from '../../../../../shared/speech-types' +import { resolveMicrophoneDevice } from '@/components/dictation/microphone-devices' +import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import { Button } from '../../ui/button' +import { Progress } from '../../ui/progress' +import { Switch } from '../../ui/switch' +import { useVoiceMicrophoneDevices } from './use-voice-microphone-devices' + +type VoiceSetupCardProps = { + voiceSettings: VoiceSettings + selectedModel: SpeechModelManifest | undefined + selectedState: SpeechModelState | undefined + permissionPending: boolean + onToggleVoiceDictation: () => void + onDownloadModel: (modelId: string) => void + onCancelDownload: (modelId: string) => void + onShowMicrophone: () => void +} + +/** The pane's first card: dictation's on/off switch and the one problem to fix, if any. */ +export function VoiceSetupCard({ + voiceSettings, + selectedModel, + selectedState, + permissionPending, + onToggleVoiceDictation, + onDownloadModel, + onCancelDownload, + onShowMicrophone +}: VoiceSetupCardProps): React.JSX.Element { + const t = useTranslate() + const shortcut = useShortcutLabel('voice.dictation') + const { devices, devicesKnown } = useVoiceMicrophoneDevices(voiceSettings.enabled) + const microphone = resolveMicrophoneDevice({ + devices: devicesKnown ? devices : null, + preferredDeviceId: voiceSettings.microphoneDeviceId, + preferredDeviceLabel: voiceSettings.microphoneDeviceLabel + }) + const downloading = + selectedState?.status === 'downloading' || selectedState?.status === 'extracting' + const ready = selectedState?.status === 'ready' + const totalMb = Math.round((selectedModel?.sizeBytes ?? 0) / 1_000_000) + const names = { model: selectedModel?.label ?? '', size: totalMb, shortcut } + + let tone: 'neutral' | 'ok' | 'warn' = 'neutral' + let icon = + let title: string + let description: string + let body: React.ReactNode = null + + if (selectedModel && downloading) { + icon = + const percent = Math.round((selectedState?.progress ?? 0) * 100) + title = t('speechModelUpdate.downloadingTitle', 'Downloading {{model}}', names) + description = t( + 'components.settings.voice.setup.downloadingDescription', + 'You can leave this page. Dictation works once the download finishes.' + ) + body = ( +
+ + + {t('speechModelUpdate.progress', '{{done}} of {{total}} MB', { + done: Math.round((selectedState?.progress ?? 0) * totalMb), + total: totalMb + })} + + +
+ ) + } else if (selectedModel && selectedState?.needsUpdate) { + icon = + title = t('speechModelUpdate.updateTitle', 'Update your speech model') + description = t( + 'speechModelUpdate.updateDescription', + 'Dictation now runs on a faster engine. {{model}} needs a one-time download ({{size}} MB) before you can dictate again.', + names + ) + body = ( +
+ +
+ ) + } else if (!selectedModel || !ready) { + title = t('components.settings.voice.setup.title', 'Set up dictation') + description = t( + 'components.settings.voice.setup.pickModel', + 'Talk instead of typing in any pane. Download a speech model below to start; models on this computer keep your audio here.' + ) + } else if (voiceSettings.enabled && microphone.kind === 'missing') { + tone = 'warn' + icon = + title = t( + 'components.settings.voice.setup.microphoneMissingTitle', + '{{microphone}} isn’t connected', + { + microphone: + voiceSettings.microphoneDeviceLabel || + t('components.settings.voice.setup.savedMicrophone', 'Your microphone') + } + ) + description = t( + 'components.settings.voice.setup.microphoneMissingDescription', + 'Dictation uses the system default microphone until it’s back.' + ) + body = ( +
+ +
+ ) + } else if (voiceSettings.enabled) { + tone = 'ok' + icon = + title = t('components.settings.voice.setup.onTitle', 'Dictation is on') + description = + selectedModel.provider === 'local' + ? t( + 'components.settings.voice.setup.onLocal', + 'Press {{shortcut}} in any pane. {{model}} runs on this computer.', + names + ) + : t( + 'components.settings.voice.setup.onCloud', + 'Press {{shortcut}} in any pane. {{model}} runs in the cloud.', + names + ) + } else { + title = t('components.settings.voice.setup.offTitle', 'Dictation is off') + description = t( + 'components.settings.voice.setup.offDescription', + 'Turn it on to dictate with {{shortcut}} in any pane.', + names + ) + } + + return ( +
+
+ {icon} +
+
+
+
{title}
+

{description}

+
+ {body} +
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelQuality.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelQuality.tsx new file mode 100644 index 00000000000..48ea0d150eb --- /dev/null +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelQuality.tsx @@ -0,0 +1,103 @@ +import type { SpeechModelQuality } from '../../../../../shared/speech-model-quality' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { uiDateTimeFormat, uiNumberFormat } from '@/i18n/ui-intl-formatters' +import { cn } from '@/lib/utils' +import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip' + +// Intl formatter caches key on option identity, so these must be module constants. +const PERCENT_FORMAT: Intl.NumberFormatOptions = { maximumFractionDigits: 1 } +const DATE_FORMAT: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeZone: 'UTC' } +const SEGMENTS = [1, 2, 3, 4, 5] as const + +export function VoiceSpeechModelQuality({ + quality +}: { + quality: SpeechModelQuality +}): React.JSX.Element { + const t = useTranslate() + const intlLocale = useIntlLocale() + if (quality.kind === 'unsupported') { + return ( + + {t('components.settings.voice.quality.unsupported', 'Doesn’t support your language')} + + ) + } + if (quality.kind === 'unmeasured') { + return ( + + {t('components.settings.voice.quality.unmeasured', 'Not measured')} + + ) + } + const { tier, errorRate } = quality + const word = [ + t('components.settings.voice.quality.poor', 'Poor'), + t('components.settings.voice.quality.fair', 'Fair'), + t('components.settings.voice.quality.good', 'Good'), + t('components.settings.voice.quality.veryGood', 'Very good'), + t('components.settings.voice.quality.excellent', 'Excellent') + ][tier - 1] + const percent = uiNumberFormat(PERCENT_FORMAT, intlLocale).format(errorRate.percent) + const values = { + percent, + clips: errorRate.clips, + date: uiDateTimeFormat(DATE_FORMAT, intlLocale).format(new Date(errorRate.measuredOn)) + } + const rate = + errorRate.metric === 'cer' + ? t( + 'components.settings.voice.quality.characterErrorRate', + '{{percent}}% character error rate', + values + ) + : t('components.settings.voice.quality.wordErrorRate', '{{percent}}% word error rate', values) + const test = + errorRate.test === 'librispeech' + ? t( + 'components.settings.voice.quality.librispeech', + 'Test: LibriSpeech read speech, {{clips}} clips, measured {{date}}.', + values + ) + : t( + 'components.settings.voice.quality.fleurs', + 'Test: FLEURS read speech, {{clips}} clips, measured {{date}}.', + values + ) + const bench = t( + 'components.settings.voice.quality.bench', + 'Orca’s benchmark on a Mac with Apple silicon.' + ) + + return ( + + + + + + +

{rate}

+

+ {test} {bench} +

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelRow.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelRow.tsx new file mode 100644 index 00000000000..f27575438d7 --- /dev/null +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelRow.tsx @@ -0,0 +1,191 @@ +import { Check, Download, Loader2, Trash2, X } from 'lucide-react' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import { Button } from '../../ui/button' +import { Progress } from '../../ui/progress' +import type { VoiceSpeechModelRowData } from './voice-speech-model-rows' +import { VoiceSpeechModelQuality } from './VoiceSpeechModelQuality' + +export type VoiceSpeechModelRowActions = { + onUse: (modelId: string) => void + onDownload: (modelId: string) => void + onCancelDownload: (modelId: string) => void + onDelete: (modelId: string) => void + onSetUpCloud: (modelId: string) => void +} + +type VoiceSpeechModelRowProps = VoiceSpeechModelRowActions & { + row: VoiceSpeechModelRowData + active: boolean + deletePending: boolean +} + +export function VoiceSpeechModelRow({ + row: { manifest, state, quality }, + active, + deletePending, + onUse, + onDownload, + onCancelDownload, + onDelete, + onSetUpCloud +}: VoiceSpeechModelRowProps): React.JSX.Element { + const t = useTranslate() + const isCloud = manifest.provider !== 'local' + const retired = quality === null + const ready = state?.status === 'ready' + const downloading = state?.status === 'downloading' || state?.status === 'extracting' + const needsUpdate = state?.needsUpdate === true && !downloading + const totalMb = Math.round((manifest.sizeBytes ?? 0) / 1_000_000) + const percent = Math.round((state?.progress ?? 0) * 100) + + const deleteButton = ( + + ) + + let subline: React.ReactNode = null + if (retired) { + subline = t( + 'components.settings.voice.models.retired', + 'No longer offered. Delete it to free up space.' + ) + } else if (state?.status === 'extracting') { + subline = t('auto.components.settings.VoicePane.61a16c8141', 'Extracting...') + } else if (downloading) { + subline = t('speechModelUpdate.progress', '{{done}} of {{total}} MB', { + done: Math.round((state?.progress ?? 0) * totalMb), + total: totalMb + }) + } else if (needsUpdate) { + subline = ( + + {t( + 'auto.components.settings.VoicePane.updateToFasterVersion', + 'Update to the faster version ({{value0}} MB)', + { value0: totalMb } + )} + + ) + } else if (manifest.provider === 'openai') { + subline = t('components.settings.voice.models.sendsToOpenAi', 'Sends your audio to OpenAI.') + } else if (manifest.provider === 'openai-compatible') { + subline = t( + 'components.settings.voice.models.sendsToCompatible', + 'Sends your audio to the service you set up.' + ) + } + + let action: React.ReactNode + if (retired) { + action = ( + + ) + } else if (downloading) { + action = ( + <> + + + {percent}% + + + + ) + } else if (needsUpdate) { + action = ( + <> + + {deleteButton} + + ) + } else if (ready) { + action = ( + <> + {active ? ( + + + {t('components.settings.voice.models.inUse', 'In use')} + + ) : ( + + )} + {isCloud ? null : deleteButton} + + ) + } else if (isCloud) { + action = ( + + ) + } else { + action = ( + + ) + } + + return ( +
+
+
{manifest.label}
+ {subline ?
{subline}
: null} +
+
+ {quality ? : null} +
+
+ {isCloud + ? t('components.settings.voice.models.cloud', 'Cloud') + : retired + ? null + : t('auto.components.settings.VoicePane.91980ce124', '{{value0}} MB', { + value0: totalMb + })} +
+
{action}
+
+ ) +} diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx index 6ebdd0c6c0b..42c20fd70fc 100644 --- a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.fixture.tsx @@ -1,60 +1,112 @@ import { defineFixture } from '../../../component-fixtures/component-fixture' import { getDefaultVoiceSettings } from '../../../../../shared/constants' -import type { SpeechModelManifest } from '../../../../../shared/speech-types' +import type { + SpeechModelErrorRate, + SpeechModelManifest, + SpeechModelState +} from '../../../../../shared/speech-types' import { VoiceSpeechModelSection } from './VoiceSpeechModelSection' -function model(id: string, label: string, sizeBytes: number, description: string) { +const rates = (entries: Record): SpeechModelErrorRate[] => + Object.entries(entries).map(([language, percent]) => ({ + language, + percent, + metric: ['ja', 'ko', 'zh'].includes(language) ? 'cer' : 'wer', + test: language === 'en' ? 'librispeech' : 'fleurs', + clips: language === 'en' ? 73 : 5, + measuredOn: '2026-11-09' + })) + +function local( + id: string, + label: string, + sizeBytes: number, + languages: string[], + errorRates: SpeechModelErrorRate[], + replacedBy?: string +): SpeechModelManifest { return { id, label, - description, type: 'transducer', provider: 'local', - language: 'en', - languages: ['en'], + language: 'multilingual', + languages, + errorRates, sizeBytes, sampleRate: 16000, - streaming: false - } satisfies SpeechModelManifest + streaming: false, + replacedBy + } } -const catalog = [ - model('v3', 'Parakeet TDT v3', 739_508_576, 'Highest accuracy for 25 European languages.'), - model( - 'v2', - 'Parakeet TDT v2', - 729_574_912, - 'English only. Faster than v3 with similar accuracy.' - ), - model( - 'nemotron', - 'Nemotron 3.5', - 751_094_240, - '28 languages including Korean, Chinese and Japanese.' - ) +const catalog: SpeechModelManifest[] = [ + local('v3', 'Parakeet TDT v3', 739_508_576, ['en', 'de'], rates({ en: 3.7, de: 2.2 })), + local('v2', 'Parakeet TDT v2', 729_574_912, ['en'], rates({ en: 2.8 })), + local('zko', 'Zipformer Streaming KO', 0, ['ko'], [], 'sense'), + local('turbo', 'Whisper Large v3 Turbo', 886_381_760, ['en', 'ko'], rates({ en: 4.5, ko: 2.6 })), + local('sense', 'SenseVoice', 252_684_608, ['en', 'ko'], rates({ en: 6.9, ko: 10 })), + local('nemotron', 'Nemotron 3.5', 751_094_240, ['en', 'ko'], rates({ en: 5.3, ko: 11.8 })), + local('tiny', 'Whisper Tiny', 45_981_088, ['en', 'ko'], rates({ en: 13, ko: 24.9 })), + { + id: 'gpt', + label: 'GPT Transcribe', + type: 'openai', + provider: 'openai', + language: 'multilingual', + languages: ['en', 'ko'], + sampleRate: 16000, + streaming: false + } ] -export const updateAvailable = defineFixture({ - width: 640, - render: () => ( +function render( + sttModel: string, + modelStates: SpeechModelState[], + ratingLanguage: string +): React.JSX.Element { + return ( {}} onOpenCloudSetup={() => {}} onRefreshModelStates={() => {}} + cloudSettings={null} /> - ), - interact: (frame) => { - frame - .querySelector('button') - ?.dispatchEvent( - new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' }) - ) - } + ) +} + +export const english = defineFixture({ + width: 760, + render: () => + render( + 'v3', + [ + { id: 'v3', status: 'ready' }, + { id: 'tiny', status: 'ready' }, + { id: 'sense', status: 'downloading', progress: 0.42 } + ], + 'en' + ) +}) + +export const korean = defineFixture({ + width: 760, + render: () => render('', [], 'ko') +}) + +export const updateAndRetired = defineFixture({ + width: 760, + render: () => + render( + 'v2', + [ + { id: 'v2', status: 'not-downloaded', needsUpdate: true }, + { id: 'zko', status: 'ready' } + ], + 'en' + ) }) diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.test.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.test.tsx index 6a5a0433801..b019dc293be 100644 --- a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.test.tsx +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.test.tsx @@ -2,14 +2,12 @@ // @vitest-environment happy-dom import { act } from 'react' -import type { ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SpeechModelManifest, SpeechModelState } from '../../../../../shared/speech-types' import { getDefaultVoiceSettings } from '../../../../../shared/constants' const toastErrorMock = vi.hoisted(() => vi.fn()) -const menuDismissMock = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { @@ -18,50 +16,16 @@ vi.mock('sonner', () => ({ })) vi.mock('@/i18n/i18n', () => ({ + getIntlLocale: () => 'en', translate: (_key: string, fallback: string, values?: Record) => values ? fallback.replace('{{value0}}', values.value0) : fallback })) -vi.mock('../../ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuItem: ({ - children, - disabled, - onSelect, - className - }: { - children: ReactNode - disabled?: boolean - onSelect?: (event: Event) => void - className?: string - }) => ( -
{ - if (!disabled) { - const selectEvent = new Event('select', { cancelable: true }) - onSelect?.(selectEvent) - if (!selectEvent.defaultPrevented) { - menuDismissMock() - } - } - }} - > - {children} -
- ) -})) - import { VoiceSpeechModelSection } from './VoiceSpeechModelSection' const localModel: SpeechModelManifest = { id: 'model-a', label: 'Local Model', - description: 'Runs offline', provider: 'local', language: 'en', languages: ['en'], @@ -107,8 +71,10 @@ function renderSection(args: { catalog={catalog} modelStates={modelStates} onUpdateVoiceSettings={vi.fn()} + ratingLanguage="en" onOpenCloudSetup={vi.fn()} onRefreshModelStates={args.refreshModelStates ?? vi.fn()} + cloudSettings={null} /> ) }) @@ -119,7 +85,6 @@ function renderSection(args: { describe('VoiceSpeechModelSection', () => { beforeEach(() => { toastErrorMock.mockReset() - menuDismissMock.mockReset() }) afterEach(() => { diff --git a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx index aabb070722e..2d6d9b461c8 100644 --- a/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx +++ b/src/renderer/src/components/settings/voice/VoiceSpeechModelSection.tsx @@ -1,247 +1,138 @@ import { useState } from 'react' import { toast } from 'sonner' import type { - VoiceSettings, SpeechModelManifest, - SpeechModelState + SpeechModelState, + VoiceSettings } from '../../../../../shared/speech-types' -import { Button } from '../../ui/button' -import { Label } from '../../ui/label' +import { useTranslate, useIntlLocale } from '@/i18n/use-translate' +import { languageDisplayName } from '@/i18n/ui-intl-formatters' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '../../ui/dropdown-menu' -import { Cloud, Download, Trash2, Loader2, ChevronDown, Check } from 'lucide-react' -import { useTranslate } from '@/i18n/use-translate' -import { speechModelDescription } from './speech-model-description' -import { isOfferedSpeechModel } from '../../../../../shared/speech-model-retirement' - -function describeSpeechModelDownloadError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error) - // Strip Electron’s invoke wrapper so download errors show the underlying cause. - return message.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, '') -} + SettingsSegmentedControl, + SettingsSubsectionHeader +} from '../form-controls/SettingsFormControls' +import { + speechModelTab, + voiceSpeechModelRows, + type VoiceSpeechModelTab +} from './voice-speech-model-rows' +import { VoiceSpeechModelRow } from './VoiceSpeechModelRow' +import { cancelSpeechModelDownload, startSpeechModelDownload } from './speech-model-download' type VoiceSpeechModelSectionProps = { voiceSettings: VoiceSettings catalog: SpeechModelManifest[] modelStates: SpeechModelState[] + /** The language quality is rated for. */ + ratingLanguage: string onUpdateVoiceSettings: (updates: Partial) => void - /** A cloud model that isn't set up yet: open its provider's setup. */ - onOpenCloudSetup: (manifest: SpeechModelManifest) => void + onOpenCloudSetup: (modelId: string) => void onRefreshModelStates: () => void + /** Provider rows (API keys, base URL) shown under the cloud models. */ + cloudSettings: React.ReactNode } export function VoiceSpeechModelSection({ voiceSettings, catalog, modelStates, + ratingLanguage, onUpdateVoiceSettings, onOpenCloudSetup, - onRefreshModelStates + onRefreshModelStates, + cloudSettings }: VoiceSpeechModelSectionProps): React.JSX.Element { const t = useTranslate() - const [pendingDeleteModelIds, setPendingDeleteModelIds] = useState>(() => new Set()) - const getModelState = (id: string): SpeechModelState | undefined => - modelStates.find((s) => s.id === id) - + const intlLocale = useIntlLocale() const selectedModel = catalog.find((m) => m.id === voiceSettings.sttModel) - const selectedModelState = voiceSettings.sttModel - ? getModelState(voiceSettings.sttModel) - : undefined - const selectedIsReady = selectedModelState?.status === 'ready' + const [chosenTab, setTab] = useState(null) + const tab = chosenTab ?? (selectedModel ? speechModelTab(selectedModel) : 'local') + const [pendingDeleteModelIds, setPendingDeleteModelIds] = useState>( + () => new Set() + ) + const rows = voiceSpeechModelRows(catalog, modelStates, tab, ratingLanguage) + + const setDeletePending = (modelId: string, pending: boolean): void => + setPendingDeleteModelIds((prev) => { + const next = new Set(prev) + if (pending) { + next.add(modelId) + } else { + next.delete(modelId) + } + return next + }) + + const deleteModel = (modelId: string): void => { + if (pendingDeleteModelIds.has(modelId)) { + return + } + setDeletePending(modelId, true) + void window.api.speech + .deleteModel(modelId) + .then(onRefreshModelStates) + .catch(() => + toast.error(t('auto.components.settings.VoicePane.68de13f72c', 'Failed to delete model.')) + ) + .finally(() => setDeletePending(modelId, false)) + } return ( -
-
- -

- {selectedModel && selectedIsReady - ? `${selectedModel.label} — ${speechModelDescription(selectedModel)}` - : t( - 'auto.components.settings.VoicePane.e24f7d43d2', - 'Select a speech model. Local models run offline; cloud models require an API key.' - )} -

-
- - - - - - {catalog.map((manifest) => { - const mState = getModelState(manifest.id) - const isReady = mState?.status === 'ready' - const isCloud = manifest.provider !== 'local' - const isRetired = !isOfferedSpeechModel(manifest) - // Retired local models stay listed only while downloaded, so their files can be deleted. - if (isRetired && (isCloud || !isReady)) { - return null - } - const isDownloading = - mState?.status === 'downloading' || mState?.status === 'extracting' - const isActive = voiceSettings.sttModel === manifest.id - // Only the old sherpa-onnx files are on disk; downloading replaces them. - const needsUpdate = mState?.needsUpdate === true && !isDownloading - const deletePending = pendingDeleteModelIds.has(manifest.id) - const sizeMb = manifest.sizeBytes ? Math.round(manifest.sizeBytes / 1_000_000) : null - - return ( - { - if (isRetired) { - event.preventDefault() - } else if (isReady) { - onUpdateVoiceSettings({ sttModel: manifest.id }) - } else if (isCloud) { - onOpenCloudSetup(manifest) - } else if (!isDownloading) { - // Keep the menu open so download progress remains visible. - event.preventDefault() - void window.api.speech.downloadModel(manifest.id).catch((error: unknown) => - toast.error( - t( - 'auto.components.settings.VoicePane.cfde55c7b0', - 'Failed to download model.' - ), - // Include the raw cause because it is the diagnosable failure users can report. - { description: describeSpeechModelDownloadError(error) } - ) - ) - } - }} - className={`group flex items-center gap-2.5 py-2.5 ${ - !isCloud && !isReady && !isDownloading ? 'opacity-50' : '' - }`} - > - - {isActive && isReady ? ( - - ) : isDownloading ? ( - - ) : isCloud ? ( - - ) : null} - -
-
- {manifest.label} - {!isCloud && ( - - {manifest.streaming - ? t('auto.components.settings.VoicePane.d504ab05f0', 'streaming') - : t('auto.components.settings.VoicePane.8f4d2a51d7', 'offline')} - - )} - {manifest.recommended && ( - - {t('auto.components.settings.VoicePane.1ba81c0ff0', 'recommended')} - - )} - - {isDownloading && mState?.progress !== undefined - ? mState.status === 'extracting' - ? t('auto.components.settings.VoicePane.61a16c8141', 'Extracting...') - : `${Math.round(mState.progress * 100)}%` - : isCloud - ? null - : t('auto.components.settings.VoicePane.91980ce124', '{{value0}} MB', { - value0: sizeMb - })} - -
-

- {speechModelDescription(manifest)} -

- {needsUpdate && ( -

- {t( - 'auto.components.settings.VoicePane.updateToFasterVersion', - 'Update to the faster version ({{value0}} MB)', - { value0: sizeMb } - )} -

- )} -
- {!isCloud && (isReady || needsUpdate) ? ( - - ) : !isCloud && !isReady && !isDownloading ? ( - - - - ) : null} -
- ) - })} -
-
+ value={tab} + onChange={setTab} + ariaLabel={t('components.settings.voice.models.where', 'Where models run')} + options={[ + { + value: 'local', + label: t('components.settings.voice.models.local', 'On this computer') + }, + { value: 'cloud', label: t('components.settings.voice.models.cloud', 'Cloud') } + ]} + /> + } + /> + {tab === 'cloud' ? ( +

+ {t( + 'components.settings.voice.models.cloudDescription', + 'Cloud models send your audio to the provider. Models on this computer keep it here.' + )} +

+ ) : null} +
+ {rows.map((row) => ( +
+ onUpdateVoiceSettings({ sttModel: modelId })} + onDownload={(modelId) => startSpeechModelDownload(modelId, onRefreshModelStates)} + onCancelDownload={(modelId) => + cancelSpeechModelDownload(modelId, onRefreshModelStates) + } + onDelete={deleteModel} + onSetUpCloud={onOpenCloudSetup} + /> +
+ ))} +
+ {tab === 'cloud' ? cloudSettings : null}
) } diff --git a/src/renderer/src/components/settings/voice/speech-model-description.ts b/src/renderer/src/components/settings/voice/speech-model-description.ts deleted file mode 100644 index 1f1f0abffb4..00000000000 --- a/src/renderer/src/components/settings/voice/speech-model-description.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SpeechModelManifest } from '../../../../../shared/speech-types' -import { translate } from '@/i18n/i18n' - -// Models transcribe-cpp can't run; their users moved to SenseVoice or Parakeet. -const retiredForEngine = (): string => - translate( - 'settings.voice.modelDescriptions.retiredForEngine', - 'No longer offered: the speech engine can no longer run it. Delete it to free up space.' - ) - -const DESCRIPTIONS: Record string> = { - 'parakeet-tdt-0.6b-v3-int8': () => - translate( - 'settings.voice.modelDescriptions.parakeetV3', - 'Highest accuracy for 25 European languages. Punctuation, capitalization, and word-level timestamps.' - ), - 'parakeet-tdt-0.6b-v2-int8': () => - translate( - 'settings.voice.modelDescriptions.parakeetV2', - 'English only. Faster than v3 with similar accuracy. Punctuation and capitalization.' - ), - 'zipformer-bilingual-zh-en': retiredForEngine, - 'paraformer-bilingual-zh-en': retiredForEngine, - 'zipformer-streaming-en-20m': () => - translate( - 'settings.voice.modelDescriptions.zipformerStreamingEnRetired', - 'No longer offered: it drops the start of sentences. Delete it to free up space.' - ), - 'zipformer-streaming-zh-14m': retiredForEngine, - 'zipformer-streaming-korean': retiredForEngine, - 'parakeet-tdt-ctc-0.6b-ja-int8': retiredForEngine, - 'whisper-tiny': () => - translate( - 'settings.voice.modelDescriptions.whisperTiny', - '90+ languages. Lower accuracy than Parakeet but broadest language coverage.' - ), - 'whisper-large-v3-turbo': () => - translate( - 'settings.voice.modelDescriptions.whisperLargeV3Turbo', - 'Best accuracy for Japanese, Korean, and Chinese, and 90+ languages. Slower than Parakeet.' - ), - 'sense-voice-zh-en-ja-ko-yue': () => - translate( - 'settings.voice.modelDescriptions.senseVoice', - 'Chinese, English, Japanese, Korean, and Cantonese with automatic language detection.' - ), - 'openai-compatible': () => - translate( - 'settings.voice.modelDescriptions.openAiCompatible', - 'Any service with an OpenAI-style transcription API, such as Groq, Mistral or a local server.' - ), - 'nemotron-3.5-asr-streaming-0.6b': () => - translate( - 'settings.voice.modelDescriptions.nemotron', - 'English, Spanish, German and many more languages, with automatic language detection.' - ), - 'openai-gpt-transcribe': () => - translate( - 'settings.voice.modelDescriptions.gptTranscribe', - 'Cloud transcription with high accuracy. Requires an OpenAI API key.' - ) -} - -/** Main's catalog arrives in English over IPC; unknown ids from a newer main keep it. */ -export function speechModelDescription(manifest: SpeechModelManifest): string { - return Object.hasOwn(DESCRIPTIONS, manifest.id) - ? DESCRIPTIONS[manifest.id]() - : manifest.description -} diff --git a/src/renderer/src/components/settings/voice/speech-model-download.ts b/src/renderer/src/components/settings/voice/speech-model-download.ts new file mode 100644 index 00000000000..db4b882650e --- /dev/null +++ b/src/renderer/src/components/settings/voice/speech-model-download.ts @@ -0,0 +1,26 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +function describeSpeechModelDownloadError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + // Strip Electron’s invoke wrapper so download errors show the underlying cause. + return message.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, '') +} + +/** Starts a model download; a failure becomes a toast, and model states refresh either way. */ +export function startSpeechModelDownload(modelId: string, refreshModelStates: () => void): void { + void window.api.speech + .downloadModel(modelId) + .catch((error: unknown) => + toast.error( + translate('auto.components.settings.VoicePane.cfde55c7b0', 'Failed to download model.'), + // Include the raw cause because it is the diagnosable failure users can report. + { description: describeSpeechModelDownloadError(error) } + ) + ) + .finally(refreshModelStates) +} + +export function cancelSpeechModelDownload(modelId: string, refreshModelStates: () => void): void { + void window.api.speech.cancelDownload(modelId).finally(refreshModelStates) +} diff --git a/src/renderer/src/components/settings/voice/use-voice-microphone-devices.ts b/src/renderer/src/components/settings/voice/use-voice-microphone-devices.ts new file mode 100644 index 00000000000..de2f0bf55af --- /dev/null +++ b/src/renderer/src/components/settings/voice/use-voice-microphone-devices.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + listVoiceMicrophoneDevices, + type VoiceMicrophoneDevice +} from '@/components/dictation/microphone-devices' +import { useMountedRef } from '@/hooks/useMountedRef' + +function sameDeviceList( + a: readonly VoiceMicrophoneDevice[], + b: readonly VoiceMicrophoneDevice[] +): boolean { + return ( + a.length === b.length && + a.every((device, index) => { + const other = b[index] + return device.deviceId === other?.deviceId && device.label === other.label + }) + ) +} + +/** The live microphone list, rescanned on devicechange and whenever `rescanKey` changes. */ +export function useVoiceMicrophoneDevices(rescanKey: unknown): { + devices: VoiceMicrophoneDevice[] + devicesKnown: boolean + refreshDevices: () => Promise +} { + const [devices, setDevices] = useState([]) + const [devicesKnown, setDevicesKnown] = useState(false) + const mountedRef = useMountedRef() + // Bluetooth emits repeated devicechange events; reject stale enumeration results. + const refreshGenerationRef = useRef(0) + + const refreshDevices = useCallback(async (): Promise => { + const generation = refreshGenerationRef.current + 1 + refreshGenerationRef.current = generation + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) { + return + } + let next: VoiceMicrophoneDevice[] = [] + try { + next = listVoiceMicrophoneDevices(await navigator.mediaDevices.enumerateDevices()) + } catch { + next = [] + } + if (!mountedRef.current || refreshGenerationRef.current !== generation) { + return + } + setDevicesKnown(next.length > 0) + setDevices((current) => (sameDeviceList(current, next) ? current : next)) + }, [mountedRef]) + + useEffect(() => { + void refreshDevices() + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.addEventListener) { + return + } + const handleDeviceChange = (): void => { + void refreshDevices() + } + navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange) + return () => { + navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange) + } + }, [refreshDevices, rescanKey]) + + return { devices, devicesKnown, refreshDevices } +} diff --git a/src/renderer/src/components/settings/voice/voice-speech-model-rows.test.ts b/src/renderer/src/components/settings/voice/voice-speech-model-rows.test.ts new file mode 100644 index 00000000000..0521ff111ed --- /dev/null +++ b/src/renderer/src/components/settings/voice/voice-speech-model-rows.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { SpeechModelManifest } from '../../../../../shared/speech-types' +import { voiceSpeechModelRows } from './voice-speech-model-rows' + +const model = ( + id: string, + languages: string[], + extra: Partial = {} +): SpeechModelManifest => ({ + id, + label: id, + type: 'transducer', + provider: 'local', + language: 'multilingual', + languages, + sampleRate: 16000, + streaming: false, + ...extra +}) + +describe('voiceSpeechModelRows', () => { + it('puts models without the language last and lists retired models only while on disk', () => { + const catalog = [ + model('english-only', ['en']), + model('retired-on-disk', ['ko'], { replacedBy: 'korean' }), + model('korean', ['en', 'ko']), + model('retired-gone', ['ko'], { replacedBy: 'korean' }), + model('cloud', ['ko'], { provider: 'openai' }) + ] + const rows = voiceSpeechModelRows( + catalog, + [{ id: 'retired-on-disk', status: 'ready' }], + 'local', + 'ko' + ) + expect(rows.map((row) => row.manifest.id)).toEqual([ + 'korean', + 'english-only', + 'retired-on-disk' + ]) + }) +}) diff --git a/src/renderer/src/components/settings/voice/voice-speech-model-rows.ts b/src/renderer/src/components/settings/voice/voice-speech-model-rows.ts new file mode 100644 index 00000000000..5e58965d70f --- /dev/null +++ b/src/renderer/src/components/settings/voice/voice-speech-model-rows.ts @@ -0,0 +1,47 @@ +import type { SpeechModelManifest, SpeechModelState } from '../../../../../shared/speech-types' +import { isOfferedSpeechModel } from '../../../../../shared/speech-model-retirement' +import { + speechModelQuality, + type SpeechModelQuality +} from '../../../../../shared/speech-model-quality' + +export type VoiceSpeechModelTab = 'local' | 'cloud' + +export type VoiceSpeechModelRowData = { + manifest: SpeechModelManifest + state: SpeechModelState | undefined + /** null for a retired model, which is only listed so its files can be deleted. */ + quality: SpeechModelQuality | null +} + +export function speechModelTab(manifest: SpeechModelManifest): VoiceSpeechModelTab { + return manifest.provider === 'local' ? 'local' : 'cloud' +} + +/** A tab's rows: models for the language first, then ones that can't handle it, then retired downloads. */ +export function voiceSpeechModelRows( + catalog: readonly SpeechModelManifest[], + modelStates: readonly SpeechModelState[], + tab: VoiceSpeechModelTab, + language: string +): VoiceSpeechModelRowData[] { + const supported: VoiceSpeechModelRowData[] = [] + const unsupported: VoiceSpeechModelRowData[] = [] + const retired: VoiceSpeechModelRowData[] = [] + for (const manifest of catalog) { + if (speechModelTab(manifest) !== tab) { + continue + } + const state = modelStates.find((s) => s.id === manifest.id) + if (!isOfferedSpeechModel(manifest)) { + // Retired models stay listed only while their files are on disk. + if (manifest.provider === 'local' && (state?.status === 'ready' || state?.needsUpdate)) { + retired.push({ manifest, state, quality: null }) + } + continue + } + const quality = speechModelQuality(manifest, language) + ;(quality.kind === 'unsupported' ? unsupported : supported).push({ manifest, state, quality }) + } + return [...supported, ...unsupported, ...retired] +} diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index b7d13c0a29d..454f252e06d 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -972,19 +972,6 @@ "missingHost": "Die Proxy-URL muss einen Host enthalten." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "Höchste Genauigkeit für 25 europäische Sprachen. Zeichensetzung, Groß- und Kleinschreibung und Zeitstempel auf Wortebene.", - "parakeetV2": "Nur Englisch. Schneller als v3 bei ähnlicher Genauigkeit. Zeichensetzung und Groß- und Kleinschreibung.", - "zipformerStreamingEnRetired": "Wird nicht mehr angeboten: Es verschluckt den Anfang von Sätzen. Löschen Sie es, um Speicherplatz freizugeben.", - "whisperTiny": "Über 90 Sprachen. Geringere Genauigkeit als Parakeet, aber die größte Sprachabdeckung.", - "senseVoice": "Chinesisch, Englisch, Japanisch, Koreanisch und Kantonesisch mit automatischer Erkennung der gesprochenen Sprache.", - "gptTranscribe": "Cloud-Transkription mit hoher Genauigkeit. Benötigt einen OpenAI-API-Schlüssel.", - "whisperLargeV3Turbo": "Beste Genauigkeit für Japanisch, Koreanisch und Chinesisch sowie über 90 Sprachen. Langsamer als Parakeet.", - "retiredForEngine": "Nicht mehr verfügbar: Die Spracherkennung kann dieses Modell nicht mehr ausführen. Löschen Sie es, um Speicherplatz freizugeben.", - "nemotron": "Englisch, Spanisch, Deutsch und viele weitere Sprachen, mit automatischer Spracherkennung." - } - }, "diagnostics": { "errors": { "creatingDisabled": "das Erstellen von Dateien zur Prüfung ist deaktiviert", @@ -8946,7 +8933,6 @@ "7807c11c4d": "Allgemein", "6855b0f77d": "Die grundlegenden Abläufe abschließen, die Orca für die parallele Arbeit mit Agenten nützlich machen.", "6d119427ef": "Checkliste für den Einstieg", - "eb1176a14e": "Lokales Sprachdiktat mit Modellen auf dem Gerät.", "5063bb47a5": "Spracheingabe", "7118953f14": "Agenten die Steuerung beliebiger Apps auf dem Computer ermöglichen.", "c9841721cb": "Computersteuerung", @@ -9461,7 +9447,6 @@ "VoicePane": { "6fa734ed95": "{{value0}} löschen", "68de13f72c": "Modell konnte nicht gelöscht werden.", - "1ba81c0ff0": "empfohlen", "cfde55c7b0": "Modell konnte nicht heruntergeladen werden.", "43fd4f454b": "Spracherkennungsmodell", "ba4a900d1d": "Diktatmodus", @@ -9472,10 +9457,6 @@ "506df81ba6": "OpenAI-API-Schlüssel gespeichert", "91980ce124": "{{value0}} MB", "61a16c8141": "Wird entpackt…", - "8f4d2a51d7": "offline", - "d504ab05f0": "Streaming", - "fbe5990716": "Modell auswählen", - "e24f7d43d2": "Ein Spracherkennungsmodell auswählen. Lokale Modelle funktionieren offline; Cloud-Modelle benötigen einen API-Schlüssel.", "174da92062": "Gedrückt halten", "118b3c2dee": "Umschalten", "ad5d036ecc": "Mikrofonberechtigung konnte nicht angefordert werden. Das Diktat wurde nicht aktiviert.", @@ -9483,7 +9464,6 @@ "1eac933202": "Die macOS-Einstellungen „Privacy & Security“ wurden geöffnet. Nach der Zugriffsfreigabe das Diktat erneut aktivieren.", "cd9fe37556": "Mikrofonberechtigung erteilt", "openAiKeyName": "OpenAI-Transkriptionsschlüssel", - "dictationShortcutDescription": "Mit {{shortcut}} Text in den jeweils fokussierten Bereich diktieren.", "dictationModeAuto": "Automatisch", "dictationModesWithAutoDescription": "Automatisch: Halten Sie {{shortcut}} gedrückt, um bis zum Loslassen zu diktieren, oder tippen Sie kurz zum Starten und Stoppen. Gedrückt halten: Diktieren, solange {{shortcut}} gedrückt bleibt. Umschalten: {{shortcut}} einmal zum Starten und erneut zum Stoppen drücken.", "speechGpuLabel": "GPU verwenden", @@ -20705,6 +20685,67 @@ "fillerWords": { "title": "Füllwörter entfernen", "description": "Entfernt Laute wie „äh“ und „ähm“ aus diktiertem Text, passend zur Diktiersprache." + }, + "sectionDescription": "Sprechen statt tippen, in jedem Bereich. Die Sprache wird auf diesem Computer erkannt, außer du wählst ein Cloud-Modell.", + "input": { + "title": "Eingabe", + "shortcut": "Tastenkürzel", + "shortcutDescription": "Startet das Diktieren im fokussierten Bereich.", + "changeShortcut": "Ändern" + }, + "language": { + "title": "Sprache" + }, + "cleanup": { + "title": "Bereinigung" + }, + "advanced": { + "title": "Erweitert" + }, + "setup": { + "downloadingDescription": "Du kannst diese Seite verlassen. Diktieren funktioniert, sobald der Download fertig ist.", + "title": "Diktieren einrichten", + "pickModel": "Sprechen statt tippen, in jedem Bereich. Lade unten ein Sprachmodell herunter; Modelle auf diesem Computer behalten deine Audiodaten hier.", + "microphoneMissingTitle": "{{microphone}} ist nicht verbunden", + "microphoneMissingDescription": "Diktieren nutzt das Standardmikrofon des Systems, bis es wieder da ist.", + "changeMicrophone": "Mikrofon ändern", + "onTitle": "Diktieren ist an", + "onLocal": "Drücke {{shortcut}} in einem beliebigen Bereich. {{model}} läuft auf diesem Computer.", + "onCloud": "Drücke {{shortcut}} in einem beliebigen Bereich. {{model}} läuft in der Cloud.", + "offTitle": "Diktieren ist aus", + "offDescription": "Schalte es ein, um mit {{shortcut}} in jedem Bereich zu diktieren.", + "savedMicrophone": "Dein Mikrofon" + }, + "quality": { + "unsupported": "Unterstützt deine Sprache nicht", + "unmeasured": "Nicht gemessen", + "poor": "Schwach", + "fair": "Mäßig", + "good": "Gut", + "veryGood": "Sehr gut", + "excellent": "Hervorragend", + "characterErrorRate": "{{percent}} % Zeichenfehlerrate", + "wordErrorRate": "{{percent}} % Wortfehlerrate", + "librispeech": "Test: LibriSpeech (vorgelesene Sprache), {{clips}} Clips, gemessen am {{date}}.", + "fleurs": "Test: FLEURS (vorgelesene Sprache), {{clips}} Clips, gemessen am {{date}}.", + "bench": "Orcas Benchmark auf einem Mac mit Apple Silicon.", + "label": "Qualität: {{word}}" + }, + "models": { + "retired": "Wird nicht mehr angeboten. Lösche es, um Speicherplatz freizugeben.", + "sendsToOpenAi": "Sendet deine Audiodaten an OpenAI.", + "delete": "Löschen", + "cancelDownload": "Download abbrechen", + "inUse": "In Verwendung", + "use": "Verwenden", + "setUpCloud": "Einrichten", + "download": "Herunterladen", + "cloud": "Cloud", + "description": "Die Qualität ist für {{language}} bewertet. Fahre über eine Bewertung, um den Test dahinter zu sehen.", + "where": "Wo Modelle laufen", + "local": "Auf diesem Computer", + "cloudDescription": "Cloud-Modelle senden deine Audiodaten an den Anbieter. Modelle auf diesem Computer behalten sie hier.", + "sendsToCompatible": "Sendet deine Audiodaten an den Dienst, den du eingerichtet hast." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cbda726753e..fd3e66b2e85 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1144,20 +1144,6 @@ "missingHost": "Proxy URL must include a host." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "Highest accuracy for 25 European languages. Punctuation, capitalization, and word-level timestamps.", - "parakeetV2": "English only. Faster than v3 with similar accuracy. Punctuation and capitalization.", - "zipformerStreamingEnRetired": "No longer offered: it drops the start of sentences. Delete it to free up space.", - "whisperTiny": "90+ languages. Lower accuracy than Parakeet but broadest language coverage.", - "senseVoice": "Chinese, English, Japanese, Korean, and Cantonese with automatic language detection.", - "gptTranscribe": "Cloud transcription with high accuracy. Requires an OpenAI API key.", - "openAiCompatible": "Any service with an OpenAI-style transcription API, such as Groq, Mistral or a local server.", - "whisperLargeV3Turbo": "Best accuracy for Japanese, Korean, and Chinese, and 90+ languages. Slower than Parakeet.", - "retiredForEngine": "No longer offered: the speech engine can no longer run it. Delete it to free up space.", - "nemotron": "English, Spanish, German and many more languages, with automatic language detection." - } - }, "diagnostics": { "errors": { "creatingDisabled": "creating review files is disabled", @@ -9264,7 +9250,6 @@ "7807c11c4d": "General", "6855b0f77d": "Finish the core workflows that make Orca useful for parallel agent work.", "6d119427ef": "Onboarding checklist", - "eb1176a14e": "Local speech-to-text dictation with on-device models.", "5063bb47a5": "Voice", "7118953f14": "Enable agents to control any app on your computer.", "c9841721cb": "Computer Use", @@ -9783,7 +9768,6 @@ "VoicePane": { "6fa734ed95": "Delete {{value0}}", "68de13f72c": "Failed to delete model.", - "1ba81c0ff0": "recommended", "cfde55c7b0": "Failed to download model.", "43fd4f454b": "Speech Model", "ba4a900d1d": "Dictation Mode", @@ -9794,10 +9778,6 @@ "506df81ba6": "OpenAI API key saved", "91980ce124": "{{value0}} MB", "61a16c8141": "Extracting...", - "8f4d2a51d7": "offline", - "d504ab05f0": "streaming", - "fbe5990716": "Select Model", - "e24f7d43d2": "Select a speech model. Local models run offline; cloud models require an API key.", "174da92062": "Hold", "118b3c2dee": "Toggle", "ad5d036ecc": "Could not request microphone permission. Voice dictation was not enabled.", @@ -9805,7 +9785,6 @@ "1eac933202": "Opened macOS Privacy & Security. Enable dictation again after granting access.", "cd9fe37556": "Microphone permission granted", "openAiKeyName": "Your OpenAI transcription key", - "dictationShortcutDescription": "Press {{shortcut}} to dictate text into any focused pane.", "dictationModeAuto": "Auto", "dictationModesWithAutoDescription": "Auto: hold {{shortcut}} to dictate until you let go, or tap it to start and stop. Hold: dictate while {{shortcut}} is held. Toggle: press {{shortcut}} once to start, again to stop.", "speechGpuLabel": "Use GPU", @@ -21405,6 +21384,67 @@ "edit": "Edit", "removeLabel": "Remove OpenAI-compatible provider", "setUp": "Set up" + }, + "sectionDescription": "Talk instead of typing in any pane. Speech is recognized on this computer unless you pick a cloud model.", + "input": { + "title": "Input", + "shortcut": "Shortcut", + "shortcutDescription": "Starts dictation in the focused pane.", + "changeShortcut": "Change" + }, + "language": { + "title": "Language" + }, + "cleanup": { + "title": "Cleanup" + }, + "advanced": { + "title": "Advanced" + }, + "setup": { + "downloadingDescription": "You can leave this page. Dictation works once the download finishes.", + "title": "Set up dictation", + "pickModel": "Talk instead of typing in any pane. Download a speech model below to start; models on this computer keep your audio here.", + "microphoneMissingTitle": "{{microphone}} isn’t connected", + "microphoneMissingDescription": "Dictation uses the system default microphone until it’s back.", + "changeMicrophone": "Change Microphone", + "onTitle": "Dictation is on", + "onLocal": "Press {{shortcut}} in any pane. {{model}} runs on this computer.", + "onCloud": "Press {{shortcut}} in any pane. {{model}} runs in the cloud.", + "offTitle": "Dictation is off", + "offDescription": "Turn it on to dictate with {{shortcut}} in any pane.", + "savedMicrophone": "Your microphone" + }, + "quality": { + "unsupported": "Doesn’t support your language", + "unmeasured": "Not measured", + "poor": "Poor", + "fair": "Fair", + "good": "Good", + "veryGood": "Very good", + "excellent": "Excellent", + "characterErrorRate": "{{percent}}% character error rate", + "wordErrorRate": "{{percent}}% word error rate", + "librispeech": "Test: LibriSpeech read speech, {{clips}} clips, measured {{date}}.", + "fleurs": "Test: FLEURS read speech, {{clips}} clips, measured {{date}}.", + "bench": "Orca’s benchmark on a Mac with Apple silicon.", + "label": "Quality: {{word}}" + }, + "models": { + "retired": "No longer offered. Delete it to free up space.", + "sendsToOpenAi": "Sends your audio to OpenAI.", + "sendsToCompatible": "Sends your audio to the service you set up.", + "delete": "Delete", + "cancelDownload": "Cancel download", + "inUse": "In use", + "use": "Use", + "setUpCloud": "Set up", + "download": "Download", + "cloud": "Cloud", + "description": "Quality is rated for {{language}}. Hover a rating to see the test behind it.", + "where": "Where models run", + "local": "On this computer", + "cloudDescription": "Cloud models send your audio to the provider. Models on this computer keep it here." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 91adf61cfd7..76d24032fdf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -503,19 +503,6 @@ "missingHost": "La URL de proxy debe incluir un host." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "Máxima precisión para 25 idiomas europeos. Puntuación, mayúsculas y minúsculas y marcas de tiempo por palabra.", - "parakeetV2": "Solo inglés. Más rápido que v3 con una precisión similar. Puntuación y mayúsculas y minúsculas.", - "zipformerStreamingEnRetired": "Ya no se ofrece: omite el comienzo de las frases. Elimínalo para liberar espacio.", - "whisperTiny": "Más de 90 idiomas. Menor precisión que Parakeet, pero con la mayor cobertura de idiomas.", - "senseVoice": "Chino, inglés, japonés, coreano y cantonés con detección automática de idioma.", - "gptTranscribe": "Transcripción en la nube con alta precisión. Requiere una clave API de OpenAI.", - "whisperLargeV3Turbo": "La mejor precisión en japonés, coreano y chino, y más de 90 idiomas. Más lento que Parakeet.", - "retiredForEngine": "Ya no se ofrece: el motor de voz ya no puede ejecutarlo. Elimínelo para liberar espacio.", - "nemotron": "Inglés, español, alemán y muchos idiomas más, con detección automática del idioma." - } - }, "diagnostics": { "errors": { "creatingDisabled": "la creación de archivos de revisión está desactivada", @@ -8529,7 +8516,6 @@ "7807c11c4d": "General", "6855b0f77d": "Termina los flujos de trabajo principales que hacen que Orca sea útil para el trabajo de agentes en paralelo.", "6d119427ef": "Lista de verificación de incorporación", - "eb1176a14e": "Dictado local de voz a texto con modelos en el dispositivo.", "5063bb47a5": "Voz", "7118953f14": "Permite que los agentes controlen cualquier app en tu computadora.", "c9841721cb": "Computer Use", @@ -9039,7 +9025,6 @@ }, "VoicePane": { "68de13f72c": "No se pudo eliminar el modelo.", - "1ba81c0ff0": "recomendado", "cfde55c7b0": "No se pudo descargar el modelo.", "43fd4f454b": "Modelo de voz", "ba4a900d1d": "Modo de dictado", @@ -9050,10 +9035,6 @@ "506df81ba6": "Clave API de OpenAI guardada", "91980ce124": "{{value0}} MB", "61a16c8141": "Extrayendo...", - "8f4d2a51d7": "sin conexión", - "d504ab05f0": "streaming", - "fbe5990716": "Seleccionar modelo", - "e24f7d43d2": "Selecciona un modelo de voz. Los modelos locales funcionan sin conexión; los modelos en la nube requieren una clave API.", "174da92062": "Mantener", "118b3c2dee": "Alternar", "ad5d036ecc": "No se pudo solicitar permiso para el micrófono. El dictado de voz no se habilitó.", @@ -9062,7 +9043,6 @@ "cd9fe37556": "Permiso de micrófono concedido", "6fa734ed95": "Eliminar {{value0}}", "openAiKeyName": "Su clave de transcripción OpenAI", - "dictationShortcutDescription": "Pulsa {{shortcut}} para dictar texto en cualquier panel con foco.", "dictationModeAuto": "Automático", "dictationModesWithAutoDescription": "Automático: mantén {{shortcut}} para dictar hasta que lo sueltes, o tócalo para empezar y parar. Mantener: dicta mientras {{shortcut}} se mantiene presionado. Alternar: pulsa {{shortcut}} una vez para empezar y otra para parar.", "speechGpuLabel": "Usar GPU", @@ -20493,6 +20473,67 @@ "fillerWords": { "title": "Quitar muletillas", "description": "Quita sonidos como «ehm» y «em» del texto dictado, según el idioma de dictado." + }, + "sectionDescription": "Habla en lugar de escribir en cualquier panel. La voz se reconoce en este equipo, salvo que elijas un modelo en la nube.", + "input": { + "title": "Entrada", + "shortcut": "Atajo", + "shortcutDescription": "Inicia el dictado en el panel activo.", + "changeShortcut": "Cambiar" + }, + "language": { + "title": "Idioma" + }, + "cleanup": { + "title": "Limpieza" + }, + "advanced": { + "title": "Avanzado" + }, + "setup": { + "downloadingDescription": "Puedes salir de esta página. El dictado funcionará cuando termine la descarga.", + "title": "Configurar el dictado", + "pickModel": "Habla en lugar de escribir en cualquier panel. Descarga abajo un modelo de voz para empezar; los modelos de este equipo mantienen tu audio aquí.", + "microphoneMissingTitle": "{{microphone}} no está conectado", + "microphoneMissingDescription": "El dictado usa el micrófono predeterminado del sistema hasta que vuelva.", + "changeMicrophone": "Cambiar micrófono", + "onTitle": "El dictado está activado", + "onLocal": "Pulsa {{shortcut}} en cualquier panel. {{model}} se ejecuta en este equipo.", + "onCloud": "Pulsa {{shortcut}} en cualquier panel. {{model}} se ejecuta en la nube.", + "offTitle": "El dictado está desactivado", + "offDescription": "Actívalo para dictar con {{shortcut}} en cualquier panel.", + "savedMicrophone": "Tu micrófono" + }, + "quality": { + "unsupported": "No admite tu idioma", + "unmeasured": "Sin medir", + "poor": "Mala", + "fair": "Regular", + "good": "Buena", + "veryGood": "Muy buena", + "excellent": "Excelente", + "characterErrorRate": "{{percent}} % de error por carácter", + "wordErrorRate": "{{percent}} % de error por palabra", + "librispeech": "Prueba: LibriSpeech (habla leída), {{clips}} clips, medida el {{date}}.", + "fleurs": "Prueba: FLEURS (habla leída), {{clips}} clips, medida el {{date}}.", + "bench": "Benchmark de Orca en un Mac con Apple silicon.", + "label": "Calidad: {{word}}" + }, + "models": { + "retired": "Ya no se ofrece. Bórralo para liberar espacio.", + "sendsToOpenAi": "Envía tu audio a OpenAI.", + "delete": "Borrar", + "cancelDownload": "Cancelar descarga", + "inUse": "En uso", + "use": "Usar", + "setUpCloud": "Configurar", + "download": "Descargar", + "cloud": "Nube", + "description": "La calidad se valora para {{language}}. Pasa el cursor sobre una valoración para ver la prueba.", + "where": "Dónde se ejecutan los modelos", + "local": "En este equipo", + "cloudDescription": "Los modelos en la nube envían tu audio al proveedor. Los de este equipo lo mantienen aquí.", + "sendsToCompatible": "Envía tu audio al servicio que configuraste." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 7a8bbf7ad7d..5e54997ebbb 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -742,19 +742,6 @@ "missingHost": "L’URL du proxy doit inclure un hôte." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "Précision maximale pour 25 langues européennes. Ponctuation, majuscules et minuscules, et horodatage par mot.", - "parakeetV2": "Anglais uniquement. Plus rapide que v3 avec une précision similaire. Ponctuation, majuscules et minuscules.", - "zipformerStreamingEnRetired": "N’est plus proposé : il omet le début des phrases. Supprimez-le pour libérer de l’espace.", - "whisperTiny": "Plus de 90 langues. Moins précis que Parakeet, mais offre la plus large couverture linguistique.", - "senseVoice": "Chinois, anglais, japonais, coréen et cantonais avec détection automatique de la langue.", - "gptTranscribe": "Transcription dans le cloud avec une grande précision. Nécessite une clé API OpenAI.", - "whisperLargeV3Turbo": "Meilleure précision pour le japonais, le coréen et le chinois, et plus de 90 langues. Plus lent que Parakeet.", - "retiredForEngine": "N’est plus proposé : le moteur vocal ne peut plus l’exécuter. Supprimez-le pour libérer de l’espace.", - "nemotron": "Anglais, espagnol, allemand et bien d’autres langues, avec détection automatique de la langue." - } - }, "diagnostics": { "errors": { "creatingDisabled": "la création de fichiers de révision est désactivée", @@ -8814,7 +8801,6 @@ "7807c11c4d": "Général", "6855b0f77d": "Terminez les workflows essentiels qui rendent Orca utile au travail parallèle avec des agents.", "6d119427ef": "Checklist d'intégration", - "eb1176a14e": "Dictée vocale locale avec modèles embarqués sur l'appareil.", "5063bb47a5": "Voix", "7118953f14": "Permettez aux agents de contrôler n'importe quelle app de votre ordinateur.", "c9841721cb": "Computer Use", @@ -9329,7 +9315,6 @@ "VoicePane": { "6fa734ed95": "Supprimer {{value0}}", "68de13f72c": "Échec de la suppression du modèle.", - "1ba81c0ff0": "recommandé", "cfde55c7b0": "Échec du téléchargement du modèle.", "43fd4f454b": "Modèle vocal", "ba4a900d1d": "Mode de dictée", @@ -9340,10 +9325,6 @@ "506df81ba6": "Clé API OpenAI enregistrée", "91980ce124": "{{value0}} Mo", "61a16c8141": "Extraction...", - "8f4d2a51d7": "hors ligne", - "d504ab05f0": "streaming", - "fbe5990716": "Sélectionner un modèle", - "e24f7d43d2": "Sélectionnez un modèle vocal. Les modèles locaux fonctionnent hors ligne ; les modèles cloud nécessitent une clé API.", "174da92062": "Maintien", "118b3c2dee": "Bascule", "ad5d036ecc": "Impossible de demander l'autorisation d'accès au micro. La dictée vocale n'a pas été activée.", @@ -9351,7 +9332,6 @@ "1eac933202": "Panneau Confidentialité et sécurité de macOS ouvert. Réactivez la dictée après avoir accordé l'accès.", "cd9fe37556": "Autorisation d'accès au micro accordée", "openAiKeyName": "Votre clé de transcription OpenAI", - "dictationShortcutDescription": "Appuyez sur {{shortcut}} pour dicter du texte dans n'importe quel volet actif.", "dictationModeAuto": "Auto", "dictationModesWithAutoDescription": "Auto : maintenez {{shortcut}} pour dicter jusqu’à ce que vous relâchiez, ou appuyez brièvement pour démarrer et arrêter. Maintien : dictez tant que {{shortcut}} est maintenu. Bascule : appuyez une fois sur {{shortcut}} pour démarrer, puis à nouveau pour arrêter.", "speechGpuLabel": "Utiliser le GPU", @@ -20810,6 +20790,67 @@ "fillerWords": { "title": "Supprimer les hésitations", "description": "Supprime les sons comme « euh » et « hum » du texte dicté, selon la langue de dictée." + }, + "sectionDescription": "Parlez au lieu de taper, dans n’importe quel volet. La parole est reconnue sur cet ordinateur, sauf si vous choisissez un modèle cloud.", + "input": { + "title": "Entrée", + "shortcut": "Raccourci", + "shortcutDescription": "Lance la dictée dans le volet actif.", + "changeShortcut": "Modifier" + }, + "language": { + "title": "Langue" + }, + "cleanup": { + "title": "Nettoyage" + }, + "advanced": { + "title": "Avancé" + }, + "setup": { + "downloadingDescription": "Vous pouvez quitter cette page. La dictée fonctionnera une fois le téléchargement terminé.", + "title": "Configurer la dictée", + "pickModel": "Parlez au lieu de taper, dans n’importe quel volet. Téléchargez un modèle vocal ci-dessous pour commencer ; les modèles de cet ordinateur gardent votre audio ici.", + "microphoneMissingTitle": "{{microphone}} n’est pas connecté", + "microphoneMissingDescription": "La dictée utilise le micro par défaut du système jusqu’à son retour.", + "changeMicrophone": "Changer de micro", + "onTitle": "La dictée est activée", + "onLocal": "Appuyez sur {{shortcut}} dans n’importe quel volet. {{model}} tourne sur cet ordinateur.", + "onCloud": "Appuyez sur {{shortcut}} dans n’importe quel volet. {{model}} tourne dans le cloud.", + "offTitle": "La dictée est désactivée", + "offDescription": "Activez-la pour dicter avec {{shortcut}} dans n’importe quel volet.", + "savedMicrophone": "Votre micro" + }, + "quality": { + "unsupported": "Ne prend pas en charge votre langue", + "unmeasured": "Non mesuré", + "poor": "Faible", + "fair": "Passable", + "good": "Bonne", + "veryGood": "Très bonne", + "excellent": "Excellente", + "characterErrorRate": "{{percent}} % d’erreurs par caractère", + "wordErrorRate": "{{percent}} % d’erreurs par mot", + "librispeech": "Test : LibriSpeech (parole lue), {{clips}} extraits, mesuré le {{date}}.", + "fleurs": "Test : FLEURS (parole lue), {{clips}} extraits, mesuré le {{date}}.", + "bench": "Benchmark d’Orca sur un Mac avec puce Apple.", + "label": "Qualité : {{word}}" + }, + "models": { + "retired": "N’est plus proposé. Supprimez-le pour libérer de l’espace.", + "sendsToOpenAi": "Envoie votre audio à OpenAI.", + "delete": "Supprimer", + "cancelDownload": "Annuler le téléchargement", + "inUse": "Utilisé", + "use": "Utiliser", + "setUpCloud": "Configurer", + "download": "Télécharger", + "cloud": "Cloud", + "description": "La qualité est évaluée pour {{language}}. Survolez une note pour voir le test.", + "where": "Où les modèles tournent", + "local": "Sur cet ordinateur", + "cloudDescription": "Les modèles cloud envoient votre audio au fournisseur. Ceux de cet ordinateur le gardent ici.", + "sendsToCompatible": "Envoie votre audio au service que vous avez configuré." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b3d7e56f49c..6f309bdfab3 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -497,19 +497,6 @@ "missingHost": "プロキシ URL にはホストが必要です。" } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "ヨーロッパの 25 言語で最高の精度。句読点、大文字・小文字、単語単位のタイムスタンプに対応。", - "parakeetV2": "英語のみ。v3 と同程度の精度で、より高速。句読点と大文字・小文字に対応。", - "zipformerStreamingEnRetired": "提供を終了しました。文の冒頭が抜け落ちます。削除して空き容量を確保してください。", - "whisperTiny": "90 以上の言語に対応。Parakeet より精度は低いものの、対応言語が最も豊富。", - "senseVoice": "中国語、英語、日本語、韓国語、広東語に対応。言語を自動検出。", - "gptTranscribe": "高精度なクラウド文字起こし。OpenAI API キーが必要です。", - "whisperLargeV3Turbo": "日本語・韓国語・中国語で最も高い精度。90以上の言語に対応。Parakeetより低速です。", - "retiredForEngine": "提供終了: 音声エンジンでこのモデルを実行できなくなりました。削除して空き容量を確保してください。", - "nemotron": "英語、スペイン語、ドイツ語など多数の言語に対応。言語を自動判別します。" - } - }, "diagnostics": { "errors": { "creatingDisabled": "確認用ファイルの作成は無効になっています", @@ -8273,7 +8260,6 @@ "7807c11c4d": "一般", "6855b0f77d": "Orca を並列 Agent 作業に活用するためのコアワークフローを完了します。", "6d119427ef": "オンボーディングチェックリスト", - "eb1176a14e": "オンデバイスモデルを使用したローカルの音声からテキストへのディクテーション。", "5063bb47a5": "音声", "7118953f14": "Agent がコンピュータ上の任意のアプリを制御できるようにします。", "c9841721cb": "コンピュータ操作", @@ -8783,7 +8769,6 @@ }, "VoicePane": { "68de13f72c": "モデルの削除に失敗しました。", - "1ba81c0ff0": "推奨", "cfde55c7b0": "モデルのダウンロードに失敗しました。", "43fd4f454b": "音声モデル", "ba4a900d1d": "ディクテーションモード", @@ -8794,10 +8779,6 @@ "506df81ba6": "OpenAI API キーが保存されました", "91980ce124": "{{value0}} MB", "61a16c8141": "抽出中…", - "8f4d2a51d7": "オフライン", - "d504ab05f0": "ストリーミング", - "fbe5990716": "モデルの選択", - "e24f7d43d2": "音声モデルを選択します。ローカルモデルはオフラインで実行されます。クラウドモデルには API キーが必要です。", "174da92062": "ホールド", "118b3c2dee": "トグル", "ad5d036ecc": "マイクの許可を要求できませんでした。音声ディクテーションが有効になっていませんでした。", @@ -8806,7 +8787,6 @@ "cd9fe37556": "マイクの許可が与えられました", "6fa734ed95": "{{value0}} を削除", "openAiKeyName": "OpenAI 転写キー", - "dictationShortcutDescription": "{{shortcut}} を押すと、フォーカスされているペインにテキストを音声入力できます。", "dictationModeAuto": "自動", "dictationModesWithAutoDescription": "自動:{{shortcut}} を押し続けると離すまで音声入力し、短く押すと開始と停止を切り替えます。ホールド:{{shortcut}} を押している間、音声入力します。トグル:{{shortcut}} を1回押すと開始し、もう一度押すと停止します。", "speechGpuLabel": "GPU を使用", @@ -19986,6 +19966,67 @@ "fillerWords": { "title": "フィラーを削除", "description": "音声入力の言語に合わせて、「えーと」「うーん」などのつなぎ言葉を入力テキストから取り除きます。" + }, + "sectionDescription": "どのペインでも、入力の代わりに話せます。クラウドモデルを選ばない限り、音声はこのコンピューター上で認識されます。", + "input": { + "title": "入力", + "shortcut": "ショートカット", + "shortcutDescription": "フォーカス中のペインで音声入力を開始します。", + "changeShortcut": "変更" + }, + "language": { + "title": "言語" + }, + "cleanup": { + "title": "クリーンアップ" + }, + "advanced": { + "title": "詳細設定" + }, + "setup": { + "downloadingDescription": "このページを離れても大丈夫です。ダウンロードが終わると音声入力が使えます。", + "title": "音声入力を設定", + "pickModel": "どのペインでも、入力の代わりに話せます。下の音声モデルをダウンロードして始めましょう。このコンピューター上のモデルなら音声は外に出ません。", + "microphoneMissingTitle": "{{microphone}} が接続されていません", + "microphoneMissingDescription": "接続されるまで、システムの既定のマイクを使います。", + "changeMicrophone": "マイクを変更", + "onTitle": "音声入力はオンです", + "onLocal": "どのペインでも {{shortcut}} を押します。{{model}} はこのコンピューター上で動作します。", + "onCloud": "どのペインでも {{shortcut}} を押します。{{model}} はクラウドで動作します。", + "offTitle": "音声入力はオフです", + "offDescription": "オンにすると、どのペインでも {{shortcut}} で音声入力できます。", + "savedMicrophone": "選択したマイク" + }, + "quality": { + "unsupported": "お使いの言語に非対応", + "unmeasured": "未測定", + "poor": "低い", + "fair": "まずまず", + "good": "良い", + "veryGood": "とても良い", + "excellent": "非常に優秀", + "characterErrorRate": "文字誤り率 {{percent}}%", + "wordErrorRate": "単語誤り率 {{percent}}%", + "librispeech": "テスト:LibriSpeech(朗読音声)、{{clips}} クリップ、{{date}} に測定。", + "fleurs": "テスト:FLEURS(朗読音声)、{{clips}} クリップ、{{date}} に測定。", + "bench": "Apple シリコン搭載 Mac での Orca のベンチマーク。", + "label": "品質:{{word}}" + }, + "models": { + "retired": "提供終了。削除すると空き容量が増えます。", + "sendsToOpenAi": "音声を OpenAI に送信します。", + "delete": "削除", + "cancelDownload": "ダウンロードをキャンセル", + "inUse": "使用中", + "use": "使用", + "setUpCloud": "設定", + "download": "ダウンロード", + "cloud": "クラウド", + "description": "品質は{{language}}での評価です。評価にカーソルを合わせるとテストの詳細が表示されます。", + "where": "モデルの実行場所", + "local": "このコンピューター", + "cloudDescription": "クラウドモデルは音声をプロバイダーに送信します。このコンピューター上のモデルなら音声は外に出ません。", + "sendsToCompatible": "音声を設定したサービスに送信します。" } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bd509836c0f..068db9b1fbd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -497,19 +497,6 @@ "missingHost": "프록시 URL에는 호스트가 포함되어야 합니다." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "유럽 25개 언어에서 가장 높은 정확도. 문장 부호, 대소문자, 단어별 타임스탬프 지원.", - "parakeetV2": "영어 전용. v3와 정확도는 비슷하며 속도는 더 빠릅니다. 문장 부호와 대소문자 지원.", - "zipformerStreamingEnRetired": "더 이상 제공되지 않습니다. 문장 앞부분이 누락됩니다. 삭제하여 공간을 확보하세요.", - "whisperTiny": "90개 이상 언어 지원. Parakeet보다 정확도는 낮지만 지원 언어가 가장 다양합니다.", - "senseVoice": "중국어, 영어, 일본어, 한국어, 광둥어를 지원하며 언어를 자동으로 감지합니다.", - "gptTranscribe": "정확도가 높은 클라우드 전사. OpenAI API 키가 필요합니다.", - "whisperLargeV3Turbo": "일본어, 한국어, 중국어에서 가장 정확하며 90개 이상의 언어를 지원합니다. Parakeet보다 느립니다.", - "retiredForEngine": "더 이상 제공되지 않음: 음성 엔진에서 더 이상 실행할 수 없습니다. 삭제하여 공간을 확보하세요.", - "nemotron": "영어, 스페인어, 독일어 등 다양한 언어를 지원하며 언어를 자동으로 감지합니다." - } - }, "diagnostics": { "errors": { "creatingDisabled": "검토 파일 생성이 비활성화되어 있습니다", @@ -8236,7 +8223,6 @@ "7807c11c4d": "일반", "6855b0f77d": "Orca를 병렬 에이전트 작업에 유용하게 만드는 핵심 워크플로를 완료합니다.", "6d119427ef": "온보딩 체크리스트", - "eb1176a14e": "온디바이스 모델을 사용한 로컬 음성-텍스트 받아쓰기.", "5063bb47a5": "음성", "7118953f14": "agents가 컴퓨터의 모든 앱을 제어할 수 있도록 하세요.", "c9841721cb": "컴퓨터 사용", @@ -8746,7 +8732,6 @@ }, "VoicePane": { "68de13f72c": "모델을 삭제하지 못했습니다.", - "1ba81c0ff0": "추천", "cfde55c7b0": "모델을 다운로드하지 못했습니다.", "43fd4f454b": "음성 모델", "ba4a900d1d": "받아쓰기 모드", @@ -8757,10 +8742,6 @@ "506df81ba6": "OpenAI API 키가 저장되었습니다.", "91980ce124": "{{value0}}MB", "61a16c8141": "추출 중...", - "8f4d2a51d7": "오프라인", - "d504ab05f0": "스트리밍", - "fbe5990716": "모델 선택", - "e24f7d43d2": "음성 모델을 선택하세요. 로컬 모델은 오프라인으로 실행됩니다. 클라우드 모델에는 API 키가 필요합니다.", "174da92062": "길게 누르기", "118b3c2dee": "토글", "ad5d036ecc": "마이크 권한을 요청할 수 없습니다. 음성 받아쓰기가 활성화되지 않았습니다.", @@ -8769,7 +8750,6 @@ "cd9fe37556": "마이크 권한이 부여되었습니다.", "6fa734ed95": "{{value0}} 삭제", "openAiKeyName": "OpenAI 트랜스크립션 키", - "dictationShortcutDescription": "{{shortcut}} 단축키를 눌러 포커스된 패널에 텍스트를 받아쓰세요.", "dictationModeAuto": "자동", "dictationModesWithAutoDescription": "자동: {{shortcut}} 단축키를 길게 누르면 손을 뗄 때까지 받아쓰기하고, 짧게 누르면 시작하고 중지합니다. 길게 누르기: {{shortcut}} 단축키를 누르고 있는 동안 받아쓰기합니다. 토글: {{shortcut}} 단축키를 한 번 누르면 받아쓰기를 시작하고, 다시 누르면 중지합니다.", "speechGpuLabel": "GPU 사용", @@ -19986,6 +19966,67 @@ "fillerWords": { "title": "군말 제거", "description": "받아쓰기 언어에 맞춰 받아쓴 텍스트에서 “음”, “흠” 같은 소리를 지웁니다." + }, + "sectionDescription": "어느 창에서든 입력 대신 말하세요. 클라우드 모델을 고르지 않으면 음성은 이 컴퓨터에서 인식됩니다.", + "input": { + "title": "입력", + "shortcut": "단축키", + "shortcutDescription": "포커스된 창에서 받아쓰기를 시작합니다.", + "changeShortcut": "변경" + }, + "language": { + "title": "언어" + }, + "cleanup": { + "title": "정리" + }, + "advanced": { + "title": "고급" + }, + "setup": { + "downloadingDescription": "이 페이지를 나가도 됩니다. 다운로드가 끝나면 받아쓰기를 쓸 수 있습니다.", + "title": "받아쓰기 설정", + "pickModel": "어느 창에서든 입력 대신 말하세요. 아래에서 음성 모델을 다운로드해 시작하세요. 이 컴퓨터의 모델은 오디오를 밖으로 보내지 않습니다.", + "microphoneMissingTitle": "{{microphone}}이(가) 연결되어 있지 않습니다", + "microphoneMissingDescription": "다시 연결될 때까지 시스템 기본 마이크를 사용합니다.", + "changeMicrophone": "마이크 변경", + "onTitle": "받아쓰기가 켜져 있습니다", + "onLocal": "어느 창에서든 {{shortcut}}을(를) 누르세요. {{model}}은(는) 이 컴퓨터에서 실행됩니다.", + "onCloud": "어느 창에서든 {{shortcut}}을(를) 누르세요. {{model}}은(는) 클라우드에서 실행됩니다.", + "offTitle": "받아쓰기가 꺼져 있습니다", + "offDescription": "켜면 어느 창에서든 {{shortcut}}으로 받아쓸 수 있습니다.", + "savedMicrophone": "선택한 마이크" + }, + "quality": { + "unsupported": "사용하는 언어를 지원하지 않음", + "unmeasured": "측정 안 됨", + "poor": "낮음", + "fair": "보통", + "good": "좋음", + "veryGood": "매우 좋음", + "excellent": "탁월함", + "characterErrorRate": "문자 오류율 {{percent}}%", + "wordErrorRate": "단어 오류율 {{percent}}%", + "librispeech": "테스트: LibriSpeech(낭독 음성), 클립 {{clips}}개, {{date}} 측정.", + "fleurs": "테스트: FLEURS(낭독 음성), 클립 {{clips}}개, {{date}} 측정.", + "bench": "Apple 실리콘 Mac에서 측정한 Orca 벤치마크.", + "label": "품질: {{word}}" + }, + "models": { + "retired": "더 이상 제공되지 않습니다. 삭제하면 공간을 확보할 수 있습니다.", + "sendsToOpenAi": "오디오를 OpenAI로 보냅니다.", + "delete": "삭제", + "cancelDownload": "다운로드 취소", + "inUse": "사용 중", + "use": "사용", + "setUpCloud": "설정", + "download": "다운로드", + "cloud": "클라우드", + "description": "품질은 {{language}} 기준입니다. 평가에 마우스를 올리면 테스트 내용을 볼 수 있습니다.", + "where": "모델 실행 위치", + "local": "이 컴퓨터", + "cloudDescription": "클라우드 모델은 오디오를 제공업체로 보냅니다. 이 컴퓨터의 모델은 오디오를 밖으로 보내지 않습니다.", + "sendsToCompatible": "오디오를 설정한 서비스로 보냅니다." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 35b47a935ef..12065876a1f 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -1717,19 +1717,6 @@ "missingHost": "A URL de proxy deve incluir um host." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "Máxima precisão para 25 idiomas europeus. Pontuação, maiúsculas e minúsculas e marcas de tempo por palavra.", - "parakeetV2": "Somente inglês. Mais rápido que v3 com precisão semelhante. Pontuação e maiúsculas e minúsculas.", - "zipformerStreamingEnRetired": "Não é mais oferecido: ele perde o início das frases. Exclua-o para liberar espaço.", - "whisperTiny": "Mais de 90 idiomas. Menor precisão que Parakeet, mas com a maior cobertura de idiomas.", - "senseVoice": "Chinês, inglês, japonês, coreano e cantonês com detecção automática de idioma.", - "gptTranscribe": "Transcrição na nuvem com alta precisão. Exige uma chave de API da OpenAI.", - "whisperLargeV3Turbo": "A melhor precisão em japonês, coreano e chinês, e mais de 90 idiomas. Mais lento que o Parakeet.", - "retiredForEngine": "Não é mais oferecido: o mecanismo de fala não consegue mais executá-lo. Exclua-o para liberar espaço.", - "nemotron": "Inglês, espanhol, alemão e muitos outros idiomas, com detecção automática de idioma." - } - }, "diagnostics": { "errors": { "creatingDisabled": "a criação de arquivos de revisão está desativada", @@ -5147,7 +5134,6 @@ "7807c11c4d": "Geral", "6855b0f77d": "Conclua os fluxos de trabalho essenciais que tornam o Orca útil para trabalho paralelo com agentes.", "6d119427ef": "Checklist de introdução", - "eb1176a14e": "Ditado de voz para texto local com modelos no dispositivo.", "5063bb47a5": "Voz", "7118953f14": "Permita que agentes controlem qualquer aplicativo no seu computador.", "c9841721cb": "Uso do Computador", @@ -6480,7 +6466,6 @@ "VoicePane": { "6fa734ed95": "Excluir {{value0}}", "68de13f72c": "Falha ao excluir o modelo.", - "1ba81c0ff0": "recomendado", "cfde55c7b0": "Falha ao baixar o modelo.", "43fd4f454b": "Modelo de Voz", "ba4a900d1d": "Modo de Ditado", @@ -6491,10 +6476,6 @@ "506df81ba6": "Chave de API da OpenAI salva", "91980ce124": "{{value0}} MB", "61a16c8141": "Extraindo...", - "8f4d2a51d7": "off-line", - "d504ab05f0": "streaming", - "fbe5990716": "Selecionar Modelo", - "e24f7d43d2": "Selecione um modelo de voz. Modelos locais funcionam off-line; modelos na nuvem exigem uma chave de API.", "174da92062": "Manter Pressionado", "118b3c2dee": "Alternar", "ad5d036ecc": "Não foi possível solicitar a permissão do microfone. O ditado por voz não foi ativado.", @@ -6502,7 +6483,6 @@ "1eac933202": "Privacidade e Segurança do macOS aberta. Ative o ditado novamente após conceder o acesso.", "cd9fe37556": "Permissão do microfone concedida", "openAiKeyName": "Sua chave de transcrição da OpenAI", - "dictationShortcutDescription": "Pressione {{shortcut}} para ditar texto em qualquer painel com foco.", "dictationModeAuto": "Automático", "dictationModesWithAutoDescription": "Automático: mantenha {{shortcut}} pressionado para ditar até soltar, ou toque para iniciar e parar. Manter pressionado: dite enquanto mantém {{shortcut}} pressionado. Alternar: pressione {{shortcut}} uma vez para iniciar e novamente para parar.", "speechGpuLabel": "Usar GPU", @@ -21675,6 +21655,67 @@ "fillerWords": { "title": "Remover hesitações", "description": "Remove sons como “hã” e “hum” do texto ditado, conforme o idioma do ditado." + }, + "sectionDescription": "Fale em vez de digitar em qualquer painel. A fala é reconhecida neste computador, a menos que você escolha um modelo na nuvem.", + "input": { + "title": "Entrada", + "shortcut": "Atalho", + "shortcutDescription": "Inicia o ditado no painel em foco.", + "changeShortcut": "Alterar" + }, + "language": { + "title": "Idioma" + }, + "cleanup": { + "title": "Limpeza" + }, + "advanced": { + "title": "Avançado" + }, + "setup": { + "downloadingDescription": "Você pode sair desta página. O ditado funciona quando o download terminar.", + "title": "Configurar o ditado", + "pickModel": "Fale em vez de digitar em qualquer painel. Baixe um modelo de voz abaixo para começar; modelos neste computador mantêm seu áudio aqui.", + "microphoneMissingTitle": "{{microphone}} não está conectado", + "microphoneMissingDescription": "O ditado usa o microfone padrão do sistema até ele voltar.", + "changeMicrophone": "Trocar microfone", + "onTitle": "O ditado está ativado", + "onLocal": "Pressione {{shortcut}} em qualquer painel. {{model}} roda neste computador.", + "onCloud": "Pressione {{shortcut}} em qualquer painel. {{model}} roda na nuvem.", + "offTitle": "O ditado está desativado", + "offDescription": "Ative para ditar com {{shortcut}} em qualquer painel.", + "savedMicrophone": "Seu microfone" + }, + "quality": { + "unsupported": "Não suporta seu idioma", + "unmeasured": "Não medido", + "poor": "Ruim", + "fair": "Razoável", + "good": "Boa", + "veryGood": "Muito boa", + "excellent": "Excelente", + "characterErrorRate": "{{percent}}% de erro por caractere", + "wordErrorRate": "{{percent}}% de erro por palavra", + "librispeech": "Teste: LibriSpeech (fala lida), {{clips}} clipes, medido em {{date}}.", + "fleurs": "Teste: FLEURS (fala lida), {{clips}} clipes, medido em {{date}}.", + "bench": "Benchmark do Orca em um Mac com Apple silicon.", + "label": "Qualidade: {{word}}" + }, + "models": { + "retired": "Não é mais oferecido. Exclua para liberar espaço.", + "sendsToOpenAi": "Envia seu áudio para a OpenAI.", + "delete": "Excluir", + "cancelDownload": "Cancelar download", + "inUse": "Em uso", + "use": "Usar", + "setUpCloud": "Configurar", + "download": "Baixar", + "cloud": "Nuvem", + "description": "A qualidade é avaliada para {{language}}. Passe o cursor sobre uma avaliação para ver o teste.", + "where": "Onde os modelos rodam", + "local": "Neste computador", + "cloudDescription": "Modelos na nuvem enviam seu áudio ao provedor. Os deste computador o mantêm aqui.", + "sendsToCompatible": "Envia seu áudio para o serviço que você configurou." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 8f07a41b469..6cc23c88138 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -1014,19 +1014,6 @@ "missingHost": "URL прокси должен содержать адрес сервера." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "Наивысшая точность для 25 европейских языков. Пунктуация, регистр букв и временные метки для каждого слова.", - "parakeetV2": "Только английский. Быстрее v3 при схожей точности. Пунктуация и регистр букв.", - "zipformerStreamingEnRetired": "Больше не предлагается: теряет начало фраз. Удалите модель, чтобы освободить место.", - "whisperTiny": "Более 90 языков. Точность ниже, чем у Parakeet, но самый широкий охват языков.", - "senseVoice": "Китайский, английский, японский, корейский и кантонский с автоматическим определением языка.", - "gptTranscribe": "Облачная расшифровка с высокой точностью. Требуется API-ключ OpenAI.", - "whisperLargeV3Turbo": "Лучшая точность для японского, корейского и китайского, а также более 90 языков. Медленнее, чем Parakeet.", - "retiredForEngine": "Больше не предлагается: движок распознавания речи больше не может её запускать. Удалите её, чтобы освободить место.", - "nemotron": "Английский, испанский, немецкий и многие другие языки, с автоматическим определением языка." - } - }, "diagnostics": { "errors": { "creatingDisabled": "создание файлов для проверки отключено", @@ -9298,7 +9285,6 @@ "7807c11c4d": "Общие", "6855b0f77d": "Завершите настройку основных процессов Orca для параллельной работы агентов.", "6d119427ef": "Знакомство с Orca", - "eb1176a14e": "Локальная голосовая диктовка с моделями на устройстве.", "5063bb47a5": "Голос", "7118953f14": "Разрешите агентам управлять любыми приложениями на компьютере.", "c9841721cb": "Управление компьютером", @@ -9813,7 +9799,6 @@ "VoicePane": { "6fa734ed95": "Удалить {{value0}}", "68de13f72c": "Не удалось удалить модель.", - "1ba81c0ff0": "рекомендуется", "cfde55c7b0": "Не удалось скачать модель.", "43fd4f454b": "Речевая модель", "ba4a900d1d": "Режим диктовки", @@ -9824,10 +9809,6 @@ "506df81ba6": "API-ключ OpenAI сохранён", "91980ce124": "{{value0}} МБ", "61a16c8141": "Распаковка…", - "8f4d2a51d7": "без сети", - "d504ab05f0": "потоковая обработка", - "fbe5990716": "Выберите модель", - "e24f7d43d2": "Выберите речевую модель. Локальные модели работают без сети; облачным нужен API-ключ.", "174da92062": "Удержание", "118b3c2dee": "Переключение", "ad5d036ecc": "Не удалось запросить доступ к микрофону. Голосовая диктовка не включена.", @@ -9835,7 +9816,6 @@ "1eac933202": "Открыт раздел macOS «Конфиденциальность и безопасность». После выдачи доступа включите диктовку снова.", "cd9fe37556": "Доступ к микрофону разрешён", "openAiKeyName": "Ваш ключ расшифровки OpenAI", - "dictationShortcutDescription": "Нажмите {{shortcut}}, чтобы диктовать текст в любую активную панель.", "dictationModeAuto": "Авто", "dictationModesWithAutoDescription": "Авто: удерживайте {{shortcut}}, чтобы диктовать до отпускания, или нажмите коротко, чтобы начать и остановить. Удержание: диктуйте, пока удерживаете {{shortcut}}. Переключение: нажмите {{shortcut}} для начала и ещё раз для остановки.", "speechGpuLabel": "Использовать GPU", @@ -21392,6 +21372,67 @@ "fillerWords": { "title": "Удалять слова-паразиты", "description": "Убирает звуки вроде «э» и «эм» из надиктованного текста с учётом языка диктовки." + }, + "sectionDescription": "Говорите вместо того, чтобы печатать, в любой панели. Речь распознаётся на этом компьютере, если вы не выберете облачную модель.", + "input": { + "title": "Ввод", + "shortcut": "Сочетание клавиш", + "shortcutDescription": "Запускает диктовку в активной панели.", + "changeShortcut": "Изменить" + }, + "language": { + "title": "Язык" + }, + "cleanup": { + "title": "Очистка" + }, + "advanced": { + "title": "Дополнительно" + }, + "setup": { + "downloadingDescription": "Можно уйти с этой страницы. Диктовка заработает, когда загрузка закончится.", + "title": "Настроить диктовку", + "pickModel": "Говорите вместо того, чтобы печатать, в любой панели. Скачайте модель речи ниже, чтобы начать; модели на этом компьютере не отправляют звук наружу.", + "microphoneMissingTitle": "{{microphone}} не подключён", + "microphoneMissingDescription": "Пока он не вернётся, диктовка использует системный микрофон по умолчанию.", + "changeMicrophone": "Сменить микрофон", + "onTitle": "Диктовка включена", + "onLocal": "Нажмите {{shortcut}} в любой панели. {{model}} работает на этом компьютере.", + "onCloud": "Нажмите {{shortcut}} в любой панели. {{model}} работает в облаке.", + "offTitle": "Диктовка выключена", + "offDescription": "Включите, чтобы диктовать с помощью {{shortcut}} в любой панели.", + "savedMicrophone": "Ваш микрофон" + }, + "quality": { + "unsupported": "Не поддерживает ваш язык", + "unmeasured": "Не измерено", + "poor": "Плохо", + "fair": "Средне", + "good": "Хорошо", + "veryGood": "Очень хорошо", + "excellent": "Отлично", + "characterErrorRate": "{{percent}}% ошибок в символах", + "wordErrorRate": "{{percent}}% ошибок в словах", + "librispeech": "Тест: LibriSpeech (чтение вслух), клипов: {{clips}}, измерено {{date}}.", + "fleurs": "Тест: FLEURS (чтение вслух), клипов: {{clips}}, измерено {{date}}.", + "bench": "Бенчмарк Orca на Mac с Apple silicon.", + "label": "Качество: {{word}}" + }, + "models": { + "retired": "Больше не предлагается. Удалите, чтобы освободить место.", + "sendsToOpenAi": "Отправляет звук в OpenAI.", + "delete": "Удалить", + "cancelDownload": "Отменить загрузку", + "inUse": "Используется", + "use": "Использовать", + "setUpCloud": "Настроить", + "download": "Скачать", + "cloud": "Облако", + "description": "Качество оценено для языка: {{language}}. Наведите на оценку, чтобы увидеть тест.", + "where": "Где работают модели", + "local": "На этом компьютере", + "cloudDescription": "Облачные модели отправляют звук провайдеру. Модели на этом компьютере оставляют его здесь.", + "sendsToCompatible": "Отправляет звук в настроенный вами сервис." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 95304867e99..002982551d7 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -948,19 +948,6 @@ "missingHost": "Proxy URL'si bir ana makine içermelidir." } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "25 Avrupa dili için en yüksek doğruluk. Noktalama, büyük/küçük harf kullanımı ve sözcük düzeyinde zaman damgaları.", - "parakeetV2": "Yalnızca İngilizce. Benzer doğrulukla v3'ten daha hızlı. Noktalama ve büyük/küçük harf kullanımı.", - "zipformerStreamingEnRetired": "Artık sunulmuyor: cümlelerin başını atlıyor. Yer açmak için silin.", - "whisperTiny": "90'dan fazla dil. Parakeet'ten daha düşük doğruluk, ancak en geniş dil kapsamı.", - "senseVoice": "Otomatik dil algılamayla Çince, İngilizce, Japonca, Korece ve Kantonca.", - "gptTranscribe": "Yüksek doğrulukla bulut transkripsiyonu. OpenAI API anahtarı gerektirir.", - "whisperLargeV3Turbo": "Japonca, Korece ve Çince için en iyi doğruluk, ayrıca 90'dan fazla dil. Parakeet'ten daha yavaş.", - "retiredForEngine": "Artık sunulmuyor: konuşma motoru bu modeli artık çalıştıramıyor. Yer açmak için silin.", - "nemotron": "İngilizce, İspanyolca, Almanca ve daha birçok dil; dil otomatik algılanır." - } - }, "diagnostics": { "errors": { "creatingDisabled": "inceleme dosyası oluşturma devre dışı", @@ -8930,7 +8917,6 @@ "7807c11c4d": "Genel", "6855b0f77d": "Orca'yı paralel ajan çalışması için kullanışlı kılan temel iş akışlarını tamamlayın.", "6d119427ef": "Başlangıç kontrol listesi", - "eb1176a14e": "Cihaz üzerindeki modellerle yerel sesten metne dikte.", "5063bb47a5": "Ses", "7118953f14": "Ajanların bilgisayarınızdaki herhangi bir uygulamayı kontrol etmesini sağlayın.", "c9841721cb": "Bilgisayar Kullanımı", @@ -9445,7 +9431,6 @@ "VoicePane": { "6fa734ed95": "{{value0}} silinsin mi", "68de13f72c": "Model silinemedi.", - "1ba81c0ff0": "önerilen", "cfde55c7b0": "Model indirilemedi.", "43fd4f454b": "Konuşma Modeli", "ba4a900d1d": "Dikte Modu", @@ -9456,10 +9441,6 @@ "506df81ba6": "OpenAI API anahtarı kaydedildi", "91980ce124": "{{value0}} MB", "61a16c8141": "Çıkarılıyor...", - "8f4d2a51d7": "çevrimdışı", - "d504ab05f0": "akış", - "fbe5990716": "Model Seç", - "e24f7d43d2": "Bir konuşma modeli seçin. Yerel modeller çevrimdışı çalışır; bulut modelleri bir API anahtarı gerektirir.", "174da92062": "Basılı Tut", "118b3c2dee": "Aç/kapat", "ad5d036ecc": "Mikrofon izni istenemedi. Sesli dikte etkinleştirilmedi.", @@ -9467,7 +9448,6 @@ "1eac933202": "macOS Gizlilik ve Güvenlik açıldı. Erişimi verdikten sonra dikteyi tekrar etkinleştirin.", "cd9fe37556": "Mikrofon izni verildi", "openAiKeyName": "OpenAI transkripsiyon anahtarınız", - "dictationShortcutDescription": "Odaktaki herhangi bir bölmeye metin dikte etmek için {{shortcut}} tuşlarına basın.", "dictationModeAuto": "Otomatik", "dictationModesWithAutoDescription": "Otomatik: bırakana kadar dikte etmek için {{shortcut}} tuşlarını basılı tutun veya başlatıp durdurmak için kısaca dokunun. Basılı tut: {{shortcut}} basılıyken dikte edin. Aç/kapat: başlatmak için {{shortcut}} tuşlarına bir kez, durdurmak için tekrar basın.", "speechGpuLabel": "GPU kullan", @@ -20687,6 +20667,67 @@ "fillerWords": { "title": "Dolgu seslerini kaldır", "description": "Dikte edilen metinden “hmm” gibi duraksama seslerini dikte diline göre çıkarır." + }, + "sectionDescription": "Her bölmede yazmak yerine konuşun. Bulut modeli seçmediğiniz sürece konuşma bu bilgisayarda tanınır.", + "input": { + "title": "Giriş", + "shortcut": "Kısayol", + "shortcutDescription": "Odaktaki bölmede dikteyi başlatır.", + "changeShortcut": "Değiştir" + }, + "language": { + "title": "Dil" + }, + "cleanup": { + "title": "Temizleme" + }, + "advanced": { + "title": "Gelişmiş" + }, + "setup": { + "downloadingDescription": "Bu sayfadan ayrılabilirsiniz. İndirme bitince dikte çalışır.", + "title": "Dikteyi kur", + "pickModel": "Her bölmede yazmak yerine konuşun. Başlamak için aşağıdan bir konuşma modeli indirin; bu bilgisayardaki modeller sesinizi burada tutar.", + "microphoneMissingTitle": "{{microphone}} bağlı değil", + "microphoneMissingDescription": "Geri gelene kadar dikte sistemin varsayılan mikrofonunu kullanır.", + "changeMicrophone": "Mikrofonu değiştir", + "onTitle": "Dikte açık", + "onLocal": "Herhangi bir bölmede {{shortcut}} tuşuna basın. {{model}} bu bilgisayarda çalışır.", + "onCloud": "Herhangi bir bölmede {{shortcut}} tuşuna basın. {{model}} bulutta çalışır.", + "offTitle": "Dikte kapalı", + "offDescription": "Her bölmede {{shortcut}} ile dikte etmek için açın.", + "savedMicrophone": "Mikrofonunuz" + }, + "quality": { + "unsupported": "Dilinizi desteklemiyor", + "unmeasured": "Ölçülmedi", + "poor": "Zayıf", + "fair": "Orta", + "good": "İyi", + "veryGood": "Çok iyi", + "excellent": "Mükemmel", + "characterErrorRate": "%{{percent}} karakter hata oranı", + "wordErrorRate": "%{{percent}} kelime hata oranı", + "librispeech": "Test: LibriSpeech (sesli okuma), {{clips}} klip, ölçüm tarihi {{date}}.", + "fleurs": "Test: FLEURS (sesli okuma), {{clips}} klip, ölçüm tarihi {{date}}.", + "bench": "Orca'nın Apple silikonlu bir Mac'teki kıyaslaması.", + "label": "Kalite: {{word}}" + }, + "models": { + "retired": "Artık sunulmuyor. Yer açmak için silin.", + "sendsToOpenAi": "Sesinizi OpenAI'ye gönderir.", + "delete": "Sil", + "cancelDownload": "İndirmeyi iptal et", + "inUse": "Kullanımda", + "use": "Kullan", + "setUpCloud": "Kur", + "download": "İndir", + "cloud": "Bulut", + "description": "Kalite {{language}} için değerlendirildi. Testi görmek için bir puanın üzerine gelin.", + "where": "Modellerin çalıştığı yer", + "local": "Bu bilgisayarda", + "cloudDescription": "Bulut modelleri sesinizi sağlayıcıya gönderir. Bu bilgisayardaki modeller sesi burada tutar.", + "sendsToCompatible": "Sesinizi kurduğunuz hizmete gönderir." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 6c6ffa4853d..11bd936a4f6 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -921,19 +921,6 @@ "missingHost": "代理 URL 必須包含主機。" } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "為 25 種歐洲語言提供最高準確度。支援標點符號、大小寫及詞級時間戳記。", - "parakeetV2": "僅限英文。準確度與 v3 相近,速度更快。支援標點符號及大小寫。", - "zipformerStreamingEnRetired": "已不再提供:它會遺漏句子開頭。刪除它以釋放空間。", - "whisperTiny": "支援 90 多種語言。準確度低於 Parakeet,但語言涵蓋範圍最廣。", - "senseVoice": "支援中文、英文、日文、韓文及粵語,並自動偵測語言。", - "gptTranscribe": "準確度高的雲端轉錄。需要 OpenAI API 金鑰。", - "whisperLargeV3Turbo": "日語、韓語和中文準確度最高,支援 90 多種語言。速度比 Parakeet 慢。", - "retiredForEngine": "已不再提供:語音引擎已無法執行此模型。刪除它以釋放空間。", - "nemotron": "支援英語、西班牙語、德語等多種語言,可自動辨識語言。" - } - }, "diagnostics": { "errors": { "creatingDisabled": "已停用建立檢視檔案", @@ -8767,7 +8754,6 @@ "7807c11c4d": "一般", "6855b0f77d": "完成核心工作流程,讓 Orca 有效協助代理程式並行工作。", "6d119427ef": "入門清單", - "eb1176a14e": "使用裝置上模型進行本機語音到文字聽寫。", "5063bb47a5": "語音", "7118953f14": "使代理程式能夠控制您電腦上的任何應用程式。", "c9841721cb": "電腦控制", @@ -9282,7 +9268,6 @@ "VoicePane": { "6fa734ed95": "刪除 {{value0}}", "68de13f72c": "刪除模型失敗。", - "1ba81c0ff0": "推薦", "cfde55c7b0": "下載模型失敗。", "43fd4f454b": "語音模型", "ba4a900d1d": "聽寫模式", @@ -9293,10 +9278,6 @@ "506df81ba6": "OpenAI API 金鑰已儲存", "91980ce124": "{{value0}} MB", "61a16c8141": "正在解壓縮...", - "8f4d2a51d7": "離線", - "d504ab05f0": "串流", - "fbe5990716": "選擇模型", - "e24f7d43d2": "選擇語音模型。本機模型離線執行;雲端模型需要 API 金鑰。", "174da92062": "按住", "118b3c2dee": "切換", "ad5d036ecc": "無法請求麥克風權限。未啟用語音聽寫。", @@ -9304,7 +9285,6 @@ "1eac933202": "已開啟 macOS 的「隱私權與安全性」。授予存取權限後再次啟用聽寫。", "cd9fe37556": "已授予麥克風權限", "openAiKeyName": "您的 OpenAI 轉錄金鑰", - "dictationShortcutDescription": "按 {{shortcut}} 可將文字聽寫到目前取得焦點的任意窗格中。", "dictationModeAuto": "自動", "dictationModesWithAutoDescription": "自動:按住 {{shortcut}} 聽寫,放開即停止;輕按則開始或停止。按住:按住 {{shortcut}} 期間進行聽寫。切換:按一次 {{shortcut}} 開始,再按一次停止。", "speechGpuLabel": "使用 GPU", @@ -20388,6 +20368,67 @@ "fillerWords": { "title": "移除語氣詞", "description": "依聽寫語言,從聽寫文字中移除「嗯」「呃」之類的語氣詞。" + }, + "sectionDescription": "在任何面板中用說話取代打字。除非你選擇雲端模型,否則語音都在這台電腦上辨識。", + "input": { + "title": "輸入", + "shortcut": "快捷鍵", + "shortcutDescription": "在目前聚焦的面板中開始聽寫。", + "changeShortcut": "變更" + }, + "language": { + "title": "語言" + }, + "cleanup": { + "title": "清理" + }, + "advanced": { + "title": "進階" + }, + "setup": { + "downloadingDescription": "你可以離開此頁面。下載完成後即可使用聽寫。", + "title": "設定聽寫", + "pickModel": "在任何面板中用說話取代打字。先在下方下載一個語音模型;本機模型會把你的音訊留在這台電腦上。", + "microphoneMissingTitle": "{{microphone}} 未連接", + "microphoneMissingDescription": "在它重新連接前,聽寫會使用系統預設麥克風。", + "changeMicrophone": "更換麥克風", + "onTitle": "聽寫已開啟", + "onLocal": "在任何面板中按 {{shortcut}}。{{model}} 在這台電腦上執行。", + "onCloud": "在任何面板中按 {{shortcut}}。{{model}} 在雲端執行。", + "offTitle": "聽寫已關閉", + "offDescription": "開啟後,可在任何面板中按 {{shortcut}} 聽寫。", + "savedMicrophone": "你的麥克風" + }, + "quality": { + "unsupported": "不支援你的語言", + "unmeasured": "未測量", + "poor": "差", + "fair": "普通", + "good": "良好", + "veryGood": "很好", + "excellent": "優秀", + "characterErrorRate": "字元錯誤率 {{percent}}%", + "wordErrorRate": "詞錯誤率 {{percent}}%", + "librispeech": "測試:LibriSpeech(朗讀語音),{{clips}} 段音訊,測於 {{date}}。", + "fleurs": "測試:FLEURS(朗讀語音),{{clips}} 段音訊,測於 {{date}}。", + "bench": "Orca 在 Apple 晶片 Mac 上的基準測試。", + "label": "品質:{{word}}" + }, + "models": { + "retired": "已不再提供。刪除即可釋放空間。", + "sendsToOpenAi": "會把你的音訊傳送給 OpenAI。", + "delete": "刪除", + "cancelDownload": "取消下載", + "inUse": "使用中", + "use": "使用", + "setUpCloud": "設定", + "download": "下載", + "cloud": "雲端", + "description": "品質依{{language}}評定。將指標停在評分上可查看所用測試。", + "where": "模型執行位置", + "local": "這台電腦", + "cloudDescription": "雲端模型會把你的音訊傳送給服務商。本機模型會把音訊留在這台電腦上。", + "sendsToCompatible": "會把你的音訊傳送到你設定的服務。" } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c410a72c92d..31c5adba076 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -497,19 +497,6 @@ "missingHost": "代理 URL 必须包含主机。" } }, - "voice": { - "modelDescriptions": { - "parakeetV3": "为 25 种欧洲语言提供最高准确度。支持标点、大小写和词级时间戳。", - "parakeetV2": "仅限英语。准确度与 v3 相近,速度更快。支持标点和大小写。", - "zipformerStreamingEnRetired": "已不再提供:它会丢失句子开头。删除它以释放空间。", - "whisperTiny": "支持 90 多种语言。准确度低于 Parakeet,但语言覆盖最广。", - "senseVoice": "支持中文、英语、日语、韩语和粤语,并自动检测语言。", - "gptTranscribe": "准确度高的云端转录。需要 OpenAI API 密钥。", - "whisperLargeV3Turbo": "日语、韩语和中文准确度最高,支持 90 多种语言。速度比 Parakeet 慢。", - "retiredForEngine": "已不再提供:语音引擎已无法运行此模型。删除它以释放空间。", - "nemotron": "支持英语、西班牙语、德语等多种语言,可自动识别语言。" - } - }, "diagnostics": { "errors": { "creatingDisabled": "已禁用创建查看文件", @@ -8236,7 +8223,6 @@ "7807c11c4d": "通用", "6855b0f77d": "完成使 Orca 对于并行智能体工作有用的核心工作流程。", "6d119427ef": "入门清单", - "eb1176a14e": "使用设备上模型进行本地语音到文本听写。", "5063bb47a5": "语音", "7118953f14": "使智能体能够控制您计算机上的任何应用程序。", "c9841721cb": "计算机控制", @@ -8746,7 +8732,6 @@ }, "VoicePane": { "68de13f72c": "删除模型失败。", - "1ba81c0ff0": "推荐", "cfde55c7b0": "下载模型失败。", "43fd4f454b": "语音模型", "ba4a900d1d": "听写模式", @@ -8757,10 +8742,6 @@ "506df81ba6": "OpenAI API 密钥已保存", "91980ce124": "{{value0}} MB", "61a16c8141": "正在提取...", - "8f4d2a51d7": "离线", - "d504ab05f0": "流媒体", - "fbe5990716": "选择型号", - "e24f7d43d2": "选择语音模型,本地模型离线运行;云模型需要 API 密钥。", "174da92062": "按住", "118b3c2dee": "切换", "ad5d036ecc": "无法请求麦克风权限。未启用语音听写。", @@ -8769,7 +8750,6 @@ "cd9fe37556": "已授予麦克风权限", "6fa734ed95": "删除 {{value0}}", "openAiKeyName": "您的 OpenAI 转录密钥", - "dictationShortcutDescription": "按 {{shortcut}} 可将文本听写到当前聚焦的任意窗格中。", "dictationModeAuto": "自动", "dictationModesWithAutoDescription": "自动:按住 {{shortcut}} 听写,松开即停止;轻按则开始或停止。按住:按住 {{shortcut}} 期间进行听写。切换:按一次 {{shortcut}} 开始,再按一次停止。", "speechGpuLabel": "使用 GPU", @@ -19985,6 +19965,67 @@ "fillerWords": { "title": "移除语气词", "description": "按听写语言,从听写文本中去掉“嗯”“呃”之类的语气词。" + }, + "sectionDescription": "在任意面板中用说话代替打字。除非你选择云端模型,否则语音都在这台电脑上识别。", + "input": { + "title": "输入", + "shortcut": "快捷键", + "shortcutDescription": "在当前聚焦的面板中开始听写。", + "changeShortcut": "更改" + }, + "language": { + "title": "语言" + }, + "cleanup": { + "title": "清理" + }, + "advanced": { + "title": "高级" + }, + "setup": { + "downloadingDescription": "你可以离开此页面。下载完成后即可使用听写。", + "title": "设置听写", + "pickModel": "在任意面板中用说话代替打字。先在下方下载一个语音模型;本机模型会把你的音频留在这台电脑上。", + "microphoneMissingTitle": "{{microphone}} 未连接", + "microphoneMissingDescription": "在它重新连接前,听写会使用系统默认麦克风。", + "changeMicrophone": "更换麦克风", + "onTitle": "听写已开启", + "onLocal": "在任意面板中按 {{shortcut}}。{{model}} 在这台电脑上运行。", + "onCloud": "在任意面板中按 {{shortcut}}。{{model}} 在云端运行。", + "offTitle": "听写已关闭", + "offDescription": "开启后,可在任意面板中按 {{shortcut}} 听写。", + "savedMicrophone": "你的麦克风" + }, + "quality": { + "unsupported": "不支持你的语言", + "unmeasured": "未测量", + "poor": "差", + "fair": "一般", + "good": "良好", + "veryGood": "很好", + "excellent": "优秀", + "characterErrorRate": "字符错误率 {{percent}}%", + "wordErrorRate": "词错误率 {{percent}}%", + "librispeech": "测试:LibriSpeech(朗读语音),{{clips}} 段音频,测于 {{date}}。", + "fleurs": "测试:FLEURS(朗读语音),{{clips}} 段音频,测于 {{date}}。", + "bench": "Orca 在 Apple 芯片 Mac 上的基准测试。", + "label": "质量:{{word}}" + }, + "models": { + "retired": "已不再提供。删除即可释放空间。", + "sendsToOpenAi": "会把你的音频发送给 OpenAI。", + "delete": "删除", + "cancelDownload": "取消下载", + "inUse": "使用中", + "use": "使用", + "setUpCloud": "设置", + "download": "下载", + "cloud": "云端", + "description": "质量按{{language}}评定。将指针悬停在评分上可查看所用测试。", + "where": "模型运行位置", + "local": "这台电脑", + "cloudDescription": "云端模型会把你的音频发送给服务商。本机模型会把音频留在这台电脑上。", + "sendsToCompatible": "会把你的音频发送到你设置的服务。" } }, "repositoryHooks": { diff --git a/src/shared/speech-types.ts b/src/shared/speech-types.ts index bd50b1dc7e4..bedd641f99f 100644 --- a/src/shared/speech-types.ts +++ b/src/shared/speech-types.ts @@ -29,7 +29,6 @@ export type SpeechModelErrorRate = { export type SpeechModelManifest = { id: string label: string - description: string type: SpeechModelType provider: SpeechModelProvider /** The text pipeline's hint: one language, a bilingual pair such as 'zh-en', or 'multilingual'. */ From 4e42cc2a3f8992c8aa9a09d5091dc00ceac3660a Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:49:37 +0000 Subject: [PATCH 062/138] fix(speech): keep a slow GPU model load on the GPU while it still makes progress (#40472) The GPU stall watch gave the whole model load one 25 s budget, so a cold disk or busy machine restarted a healthy load on the CPU and could miss the 60 s start limit. The speech process now reports each load step, and the watch restarts on it; a step that hangs for 25 s still moves to the CPU. --- src/main/speech/stt-recognizer.ts | 2 ++ .../speech/stt-transcribe-cpp-recognizer.ts | 5 ++++ .../speech/stt-worker-cpu-fallback.test.ts | 27 ++++++++++++++++++- src/main/speech/stt-worker-cpu-fallback.ts | 9 +++++-- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/main/speech/stt-recognizer.ts b/src/main/speech/stt-recognizer.ts index 2b91dd8d43f..1998151130c 100644 --- a/src/main/speech/stt-recognizer.ts +++ b/src/main/speech/stt-recognizer.ts @@ -15,6 +15,8 @@ export type SttRecognizerEvent = | { type: 'chunk'; streamId: string; text: string; samples: number } /** Pause-cut streams only: speech was heard that no chunk has committed yet. */ | { type: 'speech-pending'; streamId: string; pending: boolean } + /** A load step finished, so a slow load is told apart from a hung GPU. */ + | { type: 'loading' } export type SttRecognizerPost = (event: SttRecognizerEvent) => void diff --git a/src/main/speech/stt-transcribe-cpp-recognizer.ts b/src/main/speech/stt-transcribe-cpp-recognizer.ts index e8c62efda14..9d5b51f0852 100644 --- a/src/main/speech/stt-transcribe-cpp-recognizer.ts +++ b/src/main/speech/stt-transcribe-cpp-recognizer.ts @@ -91,7 +91,12 @@ export async function createTranscribeCppRecognizer( if (!gguf) { throw new Error(`No .gguf model file in: ${init.files.join(', ')}`) } + // Device setup (Metal shader library, Vulkan drivers) and reading the weights each report done, + // so a cold disk or a busy machine isn't taken for a GPU that hung. + await transcribe.initialize() + post({ type: 'loading' }) const model = await transcribe.TranscribeModel.load(join(init.modelDir, gguf)) + post({ type: 'loading' }) // The child runs one call at a time, so every stream can share one session. const session = model.createSession({ nThreads: performanceCoreThreads() }) const { languages } = model.capabilities diff --git a/src/main/speech/stt-worker-cpu-fallback.test.ts b/src/main/speech/stt-worker-cpu-fallback.test.ts index 582acc144d1..ffc7a6cb42c 100644 --- a/src/main/speech/stt-worker-cpu-fallback.test.ts +++ b/src/main/speech/stt-worker-cpu-fallback.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { spawnSttWorkerWithCpuFallback } from './stt-worker-cpu-fallback' class FakeChild extends EventEmitter { @@ -115,6 +115,31 @@ describe('GPU speech process falling back to the CPU', () => { expect(marks()).toBe(0) }) + it('keeps a slow GPU load that still finishes its steps, and moves a silent one to the CPU', () => { + vi.useFakeTimers() + try { + const { children, events } = setup() + const [gpu] = children + vi.advanceTimersByTime(20_000) + gpu.emit('message', { type: 'loading' }) + vi.advanceTimersByTime(20_000) + gpu.emit('message', { type: 'loading' }) + vi.advanceTimersByTime(20_000) + gpu.emit('message', { type: 'ready' }) + expect(children).toHaveLength(1) + expect(events).toEqual([{ type: 'ready' }]) + + const silent = setup() + vi.advanceTimersByTime(20_000) + silent.children[0].emit('message', { type: 'loading' }) + vi.advanceTimersByTime(25_000) + expect(silent.children).toHaveLength(2) + expect(silent.children[1].cpuOnly).toBe(true) + } finally { + vi.useRealTimers() + } + }) + it('fails the worker when the CPU replacement cannot load the model mid-dictation', () => { const { worker, children } = setup() const exits: number[] = [] diff --git a/src/main/speech/stt-worker-cpu-fallback.ts b/src/main/speech/stt-worker-cpu-fallback.ts index 63bdeba05a4..d86c3d1b15b 100644 --- a/src/main/speech/stt-worker-cpu-fallback.ts +++ b/src/main/speech/stt-worker-cpu-fallback.ts @@ -17,7 +17,7 @@ type ChildMessage = | { type: 'chunk'; streamId: string; text: string; samples: number } | { type: 'speech-pending'; streamId: string; pending: boolean } | { - type: 'ready' | 'stopped' | 'error' | 'partial' | 'final' + type: 'ready' | 'loading' | 'stopped' | 'error' | 'partial' | 'final' streamId?: string text?: string error?: string @@ -33,7 +33,8 @@ type StreamBacklog = { pending: 'stop' | 'discard' | null } -// Well under the session's 60 s start and stop timeouts, so a CPU replay still fits; covers first-run shader compiles. +// Per load step or decode. Well under the session's 60 s start and stop timeouts, so a CPU replay still fits; +// covers first-run shader compiles. const GPU_STALL_TIMEOUT_MS = 25_000 /** @@ -132,6 +133,10 @@ class SttCpuFallbackWorker extends EventEmitter implements SttWorkerProcess { this.emit('exit', 1) return } + if (message.type === 'loading') { + this.watchForStall(true) + return + } if (message.type === 'chunk') { const { streamId, text, samples } = message const stream = this.streams.get(streamId) From ab9f29c1da8e6e08ac145231d504d266bfa97442 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:57:55 -0800 Subject: [PATCH 063/138] Fork Codex replies after rewinding a chat (#40140) * Fork Codex replies from the current history after rewind * Strengthen Codex fork preparation failure coverage * Preserve Codex fork compatibility and original chat guidance * Explain when Codex cannot start for a fork --- ...le-structured-agent-session-launch.test.ts | 28 ++ .../mobile-structured-agent-session-launch.ts | 6 +- .../codex/codex-conversation-copy.test.ts | 344 +++++++++++-- src/main/codex/codex-conversation-copy.ts | 459 +++++++++--------- src/main/codex/codex-fork-history.ts | 100 ++++ .../agent-conversation-copy-error.ts | 2 + .../structured-agent-session-fork-source.ts | 24 +- .../structured-agent-session-fork.ts | 2 +- ...ed-agent-session-precommit-refusal.test.ts | 61 +++ 9 files changed, 754 insertions(+), 272 deletions(-) create mode 100644 src/main/codex/codex-fork-history.ts diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts index b5b62c474c0..457d3fb3c2e 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -57,3 +57,31 @@ it('settles a proven missing-original fork refusal but keeps a lost earlier atte (await createMobileStructuredAgentSession(lost, 'workspace-1', 'opencode', forkFrom)).kind ).toBe('unknown') }) + +it('settles a proven fork read refusal without settling a prior lost copy', async () => { + const refusal = { + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'transcriptUnreadable' }, + createAttemptRefusedBeforeCommit: true, + message: 'Codex could not read the saved chat.' + } + } + } + const client = clientReturning({ ok: true, result: { supported: true } }, refusal) + const forkFrom = { sessionId: 'source-chat', throughItemId: 'codex:source:selected:1' } + expect( + (await createMobileStructuredAgentSession(client, 'workspace-1', 'codex', forkFrom)).kind + ).toBe('failed') + const lost = clientReturning({ ok: true, result: { supported: true } }, refusal) + lost.sendRequest + .mockImplementationOnce(async () => ({ ok: true, result: { supported: true } })) + .mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('copy reply lost'))) + .mockResolvedValueOnce(refusal) + expect( + (await createMobileStructuredAgentSession(lost, 'workspace-1', 'codex', forkFrom)).kind + ).toBe('unknown') +}) diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts index 55dbc1ebef0..d7cb85e2a31 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -7,7 +7,8 @@ import type { } from '../../../src/shared/agent-session/agent-session-wire' import { isDefinitiveAgentSessionCreateRefusal, - isAgentSessionForkSourceAdmissionRefusal + isAgentSessionForkSourceAdmissionRefusal, + isAgentSessionCreateAdmissionRefusal } from '../../../src/shared/agent-session/failures/agent-session-definitive-refusal' import { createStructuredAgentSessionId, @@ -187,7 +188,8 @@ export async function createMobileStructuredAgentSession( forkFrom && !priorCreateOutcomeUnknown && result.refusal.createAttemptRefusedBeforeCommit === true && - isAgentSessionForkSourceAdmissionRefusal(result.refusal) + (isAgentSessionForkSourceAdmissionRefusal(result.refusal) || + isAgentSessionCreateAdmissionRefusal(result.refusal)) ) { return { kind: 'failed', diff --git a/src/main/codex/codex-conversation-copy.test.ts b/src/main/codex/codex-conversation-copy.test.ts index bafac8ca255..17938242104 100644 --- a/src/main/codex/codex-conversation-copy.test.ts +++ b/src/main/codex/codex-conversation-copy.test.ts @@ -10,15 +10,24 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' -import { copyCodexConversation, codexForkHistory } from './codex-conversation-copy' +import { copyCodexConversation } from './codex-conversation-copy' +import { codexForkHistory } from './codex-fork-history' +import { CodexAppServerTimeoutError } from './app-server/codex-app-server-session' import type { CodexAppServerRpc } from './app-server/codex-app-server-session' -const state = vi.hoisted(() => ({ - home: '', - paths: new Map(), - request: vi.fn(), - initialize: vi.fn() -})) +const state = vi.hoisted(() => { + const session: { + run?: (body: (rpc: CodexAppServerRpc) => Promise) => Promise + transport?: 'native' | 'unsupported-read' + } = {} + return { + home: '', + paths: new Map(), + request: vi.fn(), + initialize: vi.fn(), + session + } +}) vi.mock('../native-chat/codex-local-history', async (importOriginal) => { const original = await importOriginal() return { ...original, readCodexLocalHistory: vi.fn(original.readCodexLocalHistory) } @@ -30,19 +39,58 @@ vi.mock('../native-chat/codex-rollout-history-stream', async (importOriginal) => vi.mock('../native-chat/session-file-resolver', () => ({ resolveSessionFilePath: async (_agent: string, id: string) => state.paths.get(id) ?? null })) -vi.mock('./app-server/codex-app-server-session', async (importOriginal) => ({ - ...(await importOriginal()), - runCodexAppServerSession: async ( - launch: unknown, - body: (rpc: CodexAppServerRpc) => Promise - ) => { - state.initialize(launch) - return body({ request: state.request, notify: () => {} }) +vi.mock('./app-server/codex-app-server-session', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + runCodexAppServerSession: async ( + launch: AppServerSession.CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ) => { + state.initialize(launch) + if (state.session.transport) { + const server = String.raw` + let experimental = false + require('node:readline').createInterface({ input: process.stdin }).on('line', line => { + const message = JSON.parse(line) + if (typeof message.id !== 'number') return + if (message.method === 'initialize') { + experimental = message.params.capabilities?.experimentalApi === true + } + const error = message.method === 'thread/read' && process.argv[1] === 'unsupported-read' + ? { code: -32601, message: 'thread/read is not supported yet' } + : message.method === 'thread/turns/list' && !experimental + ? { code: -32600, message: 'thread/turns/list requires experimentalApi capability' } + : null + process.stdout.write(JSON.stringify({ id: message.id, ...(error ? { error } : { result: {} }) }) + '\n') + }) + ` + return original.runCodexAppServerSession( + { + ...launch, + command: process.execPath, + args: ['-e', server, state.session.transport], + cliPath: null + }, + (rpc) => + body({ + ...rpc, + request: async (method, params) => { + await rpc.request(method, params) + return state.request(method, params) + } + }) + ) + } + return state.session.run + ? state.session.run(body) + : body({ request: state.request, notify: () => {} }) + } } -})) +}) -const SOURCE = '00000000-0000-4000-8000-000000000001' -const CHILD = '00000000-0000-4000-8000-000000000002' +const SOURCE = '01a1bdd2-6d33-7a21-9a1d-c14f0117bc8c' +const CHILD = '01a1bde0-5ded-7f03-a1bf-3dae1613d8c7' let root: string beforeEach(async () => { @@ -50,6 +98,8 @@ beforeEach(async () => { state.home = root state.paths.clear() state.request.mockReset() + state.session.run = undefined + state.session.transport = undefined state.initialize.mockReset() vi.mocked(readCodexLocalHistory).mockClear() vi.mocked(readCodexRolloutBytes).mockClear() @@ -85,7 +135,12 @@ async function history(id: string, records: object[]) { state.paths.set(id, path) return path } -function copy(selected = `${SOURCE}:a1`, settled = true, turnId?: string) { +function copy( + selected = `${SOURCE}:a1`, + settled = true, + turnId?: string, + options: { signal?: AbortSignal; selectedItemId?: string } = {} +) { return copyCodexConversation( { record: agentSessionRecordFixture({ @@ -102,15 +157,18 @@ function copy(selected = `${SOURCE}:a1`, settled = true, turnId?: string) { providerHandle: null }, providerSessionId: SOURCE, + signal: options.signal, title: 'Source fork', accountHome: { variable: 'CODEX_HOME', path: root }, checkpoint: { - selectedItemId: agentJournalItemKey({ - provider: 'legacy', - agent: 'codex', - sessionId: SOURCE, - recordId: selected - }), + selectedItemId: + options.selectedItemId ?? + agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: SOURCE, + recordId: selected + }), settled, ...(turnId ? { turnId } : {}) } @@ -150,10 +208,12 @@ async function childPrefix(lines?: number) { return path } -it('uses a persisted terminal canonical boundary for an idle imported latest reply with no live owner', async () => { +it('keeps omitted-mode compatibility for a persisted terminal imported reply', async () => { await history(SOURCE, turn('canonical', 1)) await childPrefix() - state.request.mockResolvedValue({ thread: { id: CHILD } }) + state.request.mockImplementation(async (method) => ({ + thread: { id: method === 'thread/read' ? SOURCE : CHILD } + })) await expect(copy(undefined, false)).resolves.toEqual({ sessionId: CHILD, coverage: { unit: 'codex-turn', ids: ['canonical'] } @@ -168,9 +228,11 @@ it('uses beforeTurnId only for the immediate next effective root and opts into t await history(SOURCE, [...turn(undefined, 1), ...turn('canonical-next', 2)]) await childPrefix(3) state.request.mockImplementation(async (method) => - method === 'thread/fork' - ? { thread: { id: CHILD } } - : { data: [{ id: 'legacy-first' }, { id: 'canonical-next' }], nextCursor: null } + method === 'thread/read' + ? { thread: { id: SOURCE, historyMode: 'paginated' } } + : method === 'thread/fork' + ? { thread: { id: CHILD } } + : { data: [{ id: 'legacy-first' }, { id: 'canonical-next' }], nextCursor: null } ) await expect(copy()).resolves.toEqual({ sessionId: CHILD, @@ -196,6 +258,9 @@ it('forks and reverts only the child for legacy, legacy, canonical, counting aga state.paths.set(CHILD, path) let reverted = false state.request.mockImplementation(async (method) => { + if (method === 'thread/read') { + return { thread: { id: SOURCE, historyMode: 'paginated' } } + } if (method === 'thread/fork') { return { thread: { id: CHILD, historyMode: 'paginated' } } } @@ -249,7 +314,8 @@ it('uses exact physical identities across repeated prompts rather than a retaine expect(result.selected.id).toBe('second') }) -it('freezes all-unrecorded root IDs on the source, excluding child replacements and later source growth', async () => { +it('keeps method-not-found compatibility with frozen unrecorded IDs despite source growth and child replacements', async () => { + state.session.transport = 'unsupported-read' await history(SOURCE, [...turn(undefined, 1), ...turn(undefined, 2), ...turn(undefined, 3)]) let reverted = false state.request.mockImplementation(async (method, params) => { @@ -290,7 +356,7 @@ it('freezes all-unrecorded root IDs on the source, excluding child replacements }) }) -it('certifies recorded turns in a standalone copied rollout with no physical history base', async () => { +it('keeps legacy-mode compatibility for recorded turns in a standalone copied rollout', async () => { const rows = await recordedCodexRollout('c') const source = rows.find((row) => row.type === 'session_meta')! const started = rows.find( @@ -312,9 +378,221 @@ it('certifies recorded turns in a standalone copied rollout with no physical his .join('\n') await writeFile(path, `${serialized}\n`) } - state.request.mockResolvedValue({ thread: { id: CHILD } }) + state.request.mockImplementation(async (method) => ({ + thread: { id: method === 'thread/read' ? SOURCE : CHILD, historyMode: 'legacy' } + })) await expect(copy(undefined, true, String(started.payload.turn_id))).resolves.toEqual({ sessionId: CHILD, coverage: { unit: 'codex-turn', ids: ['01a1b0d7-ac1d-7e00-85b8-806a4d8d494b'] } }) }) + +it('forks the current paged native prefix after rewind without source or child rollout authority', async () => { + state.session.transport = 'native' + await history(SOURCE, [ + ...turn('01a1bdd2-6d5e-7960-8fbb-30c962a5eeaa', 1), + ...turn('01a1bdd3-1293-7bc2-b750-017ca47738f9', 2) + ]) + const selected = '01a1bdd4-2977-7f81-8495-eb160b46419a' + state.request.mockImplementation(async (method, params) => { + if (method === 'thread/read') { + return { thread: { id: SOURCE, historyMode: 'paginated', status: { type: 'notLoaded' } } } + } + if (method === 'thread/fork') { + return { thread: { id: CHILD, historyMode: 'paginated' } } + } + if (!params?.cursor) { + return { + data: [{ id: '01a1bdd2-6d5e-7960-8fbb-30c962a5eeaa', status: 'completed' }], + nextCursor: 'page-2' + } + } + return { + data: [ + { id: '01a1bdd2-b7a1-7c63-8b19-cc361c2baefb', status: 'completed' }, + { id: selected, status: 'completed' }, + ...(params.threadId === CHILD + ? [{ id: '01a1bde0-5e1e-7ad0-ab18-f9ed07fd00e7', status: 'completed' }] + : []) + ], + nextCursor: null + } + }) + await expect( + copy(undefined, false, selected, { selectedItemId: `codex:${SOURCE}:${selected}:1` }) + ).resolves.toEqual({ + sessionId: '01a1bde0-5ded-7f03-a1bf-3dae1613d8c7', + coverage: { + unit: 'codex-turn', + ids: [ + '01a1bdd2-6d5e-7960-8fbb-30c962a5eeaa', + '01a1bdd2-b7a1-7c63-8b19-cc361c2baefb', + '01a1bdd4-2977-7f81-8495-eb160b46419a' + ] + } + }) + expect(state.request).toHaveBeenCalledWith('thread/fork', { + threadId: SOURCE, + lastTurnId: '01a1bdd4-2977-7f81-8495-eb160b46419a', + excludeTurns: true, + cwd: root + }) +}) + +it('bridges a saved physical reply with an absent command candidate without restoring a removed native turn', async () => { + await history(SOURCE, [...turn('kept', 1), ...turn('removed', 2)]) + state.request.mockImplementation(async (method) => { + if (method === 'thread/read') { + return { thread: { id: SOURCE, historyMode: 'paginated' } } + } + if (method === 'thread/fork') { + return { thread: { id: CHILD } } + } + return { data: [{ id: 'kept', status: 'completed' }], nextCursor: null } + }) + await expect(copy(undefined, true, 'orca-command')).resolves.toEqual({ + sessionId: CHILD, + coverage: { unit: 'codex-turn', ids: ['kept'] } + }) + await expect(copy(`${SOURCE}:a2`, true, 'orca-command')).rejects.toMatchObject({ + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'savedConversationMissing' }, + createAttemptRefusedBeforeCommit: true + } + }) + await expect(copy(undefined, true)).resolves.toEqual({ + sessionId: CHILD, + coverage: { unit: 'codex-turn', ids: ['kept'] } + }) + await expect( + copy(undefined, true, 'removed', { selectedItemId: `codex:${SOURCE}:removed:1` }) + ).rejects.toMatchObject({ + refusal: { + details: { reason: 'savedConversationMissing' }, + createAttemptRefusedBeforeCommit: true + } + }) +}) + +it('keeps read faults in preparation and a contradictory copied prefix uncertain', async () => { + const selected = '01a1bdd4-2977-7f81-8495-eb160b46419a' + await history(SOURCE, turn(selected, 1)) + await childPrefix() + const prepare = () => copy(undefined, true, selected) + state.request.mockRejectedValue(new Error('read failed')) + await expect(prepare()).rejects.toMatchObject({ + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'transcriptUnreadable' }, + createAttemptRefusedBeforeCommit: true + } + }) + state.request.mockResolvedValue({ thread: { id: CHILD, historyMode: 'paginated' } }) + await expect(prepare()).rejects.toMatchObject({ + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'transcriptUnreadable' }, + createAttemptRefusedBeforeCommit: true + } + }) + state.request.mockImplementation(async (method, params) => { + if (method === 'thread/read') { + return { thread: { id: SOURCE, historyMode: 'paginated' } } + } + if (method === 'thread/fork') { + return { thread: { id: CHILD } } + } + return { + data: (params?.threadId === SOURCE + ? ['01a1bdd2-6d5e-7960-8fbb-30c962a5eeaa', selected] + : [selected, '01a1bdd2-6d5e-7960-8fbb-30c962a5eeaa'] + ).map((id) => ({ + id + })), + nextCursor: null + } + }) + await expect(prepare()).rejects.not.toHaveProperty('refusal') +}) + +it('refuses invalid and repeated source cursors before copying', async () => { + state.request.mockImplementation(async (method) => + method === 'thread/read' + ? { thread: { id: SOURCE, historyMode: 'paginated' } } + : { data: [{ id: 'first' }], nextCursor: 'same-page' } + ) + await expect(copy(undefined, true, 'missing')).rejects.toMatchObject({ + refusal: { details: { reason: 'transcriptUnreadable' }, createAttemptRefusedBeforeCommit: true } + }) + state.request.mockImplementation(async (method) => + method === 'thread/read' + ? { thread: { id: SOURCE, historyMode: 'paginated' } } + : { data: [], nextCursor: 17 } + ) + await expect(copy(undefined, true, 'missing')).rejects.toMatchObject({ + refusal: { details: { reason: 'transcriptUnreadable' }, createAttemptRefusedBeforeCommit: true } + }) +}) + +it('cannot dispatch an abandoned preparation after its lifetime ends, but keeps an invoked fork uncertain', async () => { + const listed = Promise.withResolvers() + const release = Promise.withResolvers() + const expired = Promise.withResolvers() + const lifetime = new AbortController() + let abandoned: Promise | undefined + let childCreated = false + state.request.mockImplementation(async (method) => { + if (method === 'thread/read') { + return { thread: { id: SOURCE, historyMode: 'paginated' } } + } + if (method === 'thread/fork') { + childCreated = true + return { thread: { id: CHILD } } + } + listed.resolve() + return release.promise + }) + state.session.run = async (body) => { + abandoned = body({ request: state.request, notify: () => {}, signal: lifetime.signal }) + return Promise.race([abandoned, expired.promise]) + } + const preparation = copy(undefined, true, 'selected') + await listed.promise + lifetime.abort(new CodexAppServerTimeoutError('session ended')) + expired.reject(lifetime.signal.reason) + await expect(preparation).rejects.toMatchObject({ + refusal: { createAttemptRefusedBeforeCommit: true } + }) + release.resolve({ data: [{ id: 'selected' }], nextCursor: null }) + await expect(abandoned).rejects.toBe(lifetime.signal.reason) + expect(childCreated).toBe(false) + const forked = Promise.withResolvers() + const lost = Promise.withResolvers() + const pending = Promise.withResolvers() + state.session.run = async (body) => + Promise.race([body({ request: state.request, notify: () => {} }), lost.promise]) + state.request.mockImplementation(async (method) => { + if (method === 'thread/read') { + return { thread: { id: SOURCE, historyMode: 'paginated' } } + } + if (method === 'thread/fork') { + forked.resolve() + return pending.promise + } + return { data: [{ id: 'selected' }], nextCursor: null } + }) + const invoked = copy(undefined, true, 'selected') + await forked.promise + const timeout = new CodexAppServerTimeoutError('reply lost') + lost.reject(timeout) + await expect(invoked).rejects.not.toHaveProperty('refusal') + pending.reject(timeout) + state.session.run = undefined + state.session.transport = undefined + const caller = new AbortController() + caller.abort(new Error('caller cancelled')) + await expect(copy(undefined, true, 'selected', { signal: caller.signal })).rejects.toBe( + caller.signal.reason + ) +}) diff --git a/src/main/codex/codex-conversation-copy.ts b/src/main/codex/codex-conversation-copy.ts index f0bc1e43c49..cb3b3a9adee 100644 --- a/src/main/codex/codex-conversation-copy.ts +++ b/src/main/codex/codex-conversation-copy.ts @@ -1,15 +1,19 @@ -import { AgentConversationCopyError } from '../native-chat/agent-conversation-copy-error' +import { TEXT_GENERATION_FAILURE_COPY } from '../../shared/text-generation-failure-copy' +import { codexForkHistory } from './codex-fork-history' +import { + AgentConversationCopyError, + AgentConversationCopyStartError, + agentConversationCopyRefusal +} from '../native-chat/agent-conversation-copy-error' +import { isAgentSessionRefusalError } from '../../shared/agent-session/failures/agent-session-wire-refusals' import { join } from 'node:path' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session/lifecycle/agent-session-account-home' import { parseAgentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' -import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' +import { asRecord } from '../ai-vault/session-scanner-values' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/providers/structured-agent-session-adapter' -import { createLegacyIdentityTracker } from '../native-chat/agent-session-journal/history-import/journal-legacy-identity' -import { readCodexLocalHistory } from '../native-chat/codex-local-history' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { decodeCodexTranscriptLine } from '../native-chat/transcript-line-decoders-codex' import { - CodexAppServerTimeoutError, + isCodexAppServerUnsupportedError, type CodexAppServerRpc } from './app-server/codex-app-server-session' import { @@ -18,117 +22,28 @@ import { } from './structured/session-lifecycle/codex-provider-session-rename' type CopyInput = Parameters>[0] -type Root = { id?: string; terminal: boolean; prompt: boolean } function unavailable(): never { - throw new AgentConversationCopyError( - 'Codex could not locate an exact saved boundary in this older chat. Fork a response in a newer chat.' - ) -} - -/** Use the importer's physical lineage and identity accounting, including discarded window rows. */ -export async function codexForkHistory(input: { - path: string - roots: string[] - selectedItemId: string - turnId?: string - sessionId: string - signal?: AbortSignal - inheritedOnly?: boolean -}) { - const identity = parseAgentJournalItemKey(input.selectedItemId) - const tracker = createLegacyIdentityTracker({ - transcriptAgent: 'codex', - agent: 'codex', - sessionId: input.sessionId - }) - const roots: Root[] = [] - let selected: Root | undefined - let lineIndex = 0 - let referenceBacked = false - await readCodexLocalHistory({ - filePath: input.path, - roots: input.roots, - visitRecord: ({ record, selected }) => { - if (selected && record?.type === 'session_meta') { - referenceBacked = asRecord(record.payload)?.history_base != null - } - }, - visit: (line, fallbackId, selectedSegment, recordId) => { - if (input.inheritedOnly && referenceBacked && selectedSegment) { - return - } - input.signal?.throwIfAborted() - const tracked = tracker.identify(line, lineIndex++) - const raw = parseJsonObject(line) - const payload = asRecord(raw?.payload) - if ( - raw?.type === 'event_msg' && - payload?.type === 'thread_rolled_back' && - typeof payload.num_turns === 'number' - ) { - roots.splice(Math.max(0, roots.length - payload.num_turns)) - return - } - if (raw?.type === 'event_msg' && payload?.type === 'task_started') { - roots.push({ - id: typeof payload.turn_id === 'string' ? payload.turn_id : undefined, - terminal: false, - prompt: false - }) - } - const message = decodeCodexTranscriptLine(line, fallbackId, { historyImport: true }) - if (message?.role === 'user') { - const previous = roots.at(-1) - if (!previous || (previous.prompt && (!previous.id || previous.terminal))) { - roots.push({ terminal: false, prompt: true }) - } else { - previous.prompt = true - } - } - const root = roots.at(-1) - if ( - root && - raw?.type === 'event_msg' && - (payload?.type === 'task_complete' || payload?.type === 'turn_aborted') && - (!payload.turn_id || payload.turn_id === root.id) - ) { - root.terminal = true - } - const importedMatch = - identity?.provider === 'legacy' && - identity.recordId === - (recordId ?? (tracked.provider === 'legacy' ? tracked.recordId : undefined)) - if ( - root && - ((importedMatch && message?.role === 'assistant') || - (input.turnId && root.id === input.turnId)) - ) { - if (selected && selected !== root) { - return unavailable() - } - selected = root - } - } - }) - const index = selected ? roots.indexOf(selected) : -1 - if (index < 0) { - return unavailable() - } - return { roots, index, selected: roots[index] } + throw new AgentConversationCopyError('Codex could not verify the copied chat.') } async function turns( rpc: CodexAppServerRpc, threadId: string, - count: number -): Promise<{ id: string }[]> { - const all: { id: string }[] = [] + through: number | string +): Promise<{ id: string; status?: unknown }[]> { + const all: { id: string; status?: unknown }[] = [] let cursor: string | null = null const seen = new Set() do { const page = asRecord( - await rpc.request('thread/turns/list', { threadId, cursor, sortDirection: 'asc', limit: 100 }) + await rpc.request('thread/turns/list', { + threadId, + cursor, + sortDirection: 'asc', + limit: 100, + itemsView: 'summary' + }) ) if (!Array.isArray(page?.data)) { return unavailable() @@ -138,7 +53,13 @@ async function turns( if (typeof turn?.id !== 'string') { return unavailable() } - all.push({ id: turn.id }) + all.push({ id: turn.id, status: turn.status }) + if (turn.id === through) { + return all + } + } + if (page.nextCursor != null && (typeof page.nextCursor !== 'string' || !page.nextCursor)) { + return unavailable() } cursor = typeof page.nextCursor === 'string' ? page.nextCursor : null if (cursor && seen.has(cursor)) { @@ -147,7 +68,7 @@ async function turns( if (cursor) { seen.add(cursor) } - } while (cursor && all.length < count) + } while (cursor && (typeof through === 'string' || all.length < through)) return all } @@ -155,140 +76,226 @@ export async function copyCodexConversation( input: CopyInput, invocation: CodexProviderInvocationDeps | undefined ) { - if (!input.record || !input.checkpoint || !invocation) { - return unavailable() - } - const { record, checkpoint } = input - const signal = AbortSignal.any([ - AbortSignal.timeout(60_000), - ...(input.signal ? [input.signal] : []) - ]) - const home = requireLegacyAgentSessionAccountHome(record.accountHome).path - const stores = [join(home, 'sessions'), join(home, 'archived_sessions')] - const find = async (id: string) => - (await resolveSessionFilePath( - 'codex', - id, - { codexSessionsDirs: stores, strictErrors: true }, - signal - )) ?? unavailable() - const history = await codexForkHistory({ - path: await find(input.providerSessionId), - roots: stores, - selectedItemId: checkpoint.selectedItemId, - turnId: checkpoint.turnId, - sessionId: input.providerSessionId, - signal - }) - const beforeTurnId = history.roots[history.index + 1]?.id - const lastTurnId = history.selected.id - if (!checkpoint.settled && !history.selected.terminal && !beforeTurnId) { - throw new AgentConversationCopyError( - "Codex hasn't finished this response yet. Wait for it to finish, then try again." - ) - } - return runCodexProviderAppServer( - record, - { ...invocation, resolveLaunchArgs: () => record.launchArgs ?? invocation.resolveLaunchArgs() }, - { timeoutMs: 60_000, signal, experimentalApi: !lastTurnId && Boolean(beforeTurnId) }, - async (rpc, cwd) => { - const prefix = history.roots.slice(0, history.index + 1) - const sourceTurns = prefix.every((root) => root.id) - ? prefix.map((root) => ({ id: root.id! })) - : await turns(rpc, input.providerSessionId, history.roots.length) - if ( - sourceTurns.length < prefix.length || - prefix.some((root, index) => root.id && root.id !== sourceTurns[index]?.id) - ) { - return unavailable() - } - const coverage = { - unit: 'codex-turn' as const, - ids: sourceTurns.slice(0, prefix.length).map((turn) => turn.id) - } - const fork = asRecord( - await rpc.request('thread/fork', { - threadId: input.providerSessionId, - ...(lastTurnId ? { lastTurnId } : beforeTurnId ? { beforeTurnId } : {}), - excludeTurns: true, - cwd - }) - ) - const thread = asRecord(fork?.thread) - const id = thread?.id - if (typeof id !== 'string' || !id.trim() || id === input.providerSessionId) { - return unavailable() - } - const captured = await codexForkHistory({ - path: await find(id), + let starting = false + let dispatched = false + try { + if (!input.record || !input.checkpoint || !invocation) { + return unavailable() + } + const { record, checkpoint } = input + const signal = AbortSignal.any([ + AbortSignal.timeout(60_000), + ...(input.signal ? [input.signal] : []) + ]) + const home = requireLegacyAgentSessionAccountHome(record.accountHome).path + const stores = [join(home, 'sessions'), join(home, 'archived_sessions')] + const legacy = parseAgentJournalItemKey(checkpoint.selectedItemId)?.provider === 'legacy' + const find = async (id: string) => + (await resolveSessionFilePath( + 'codex', + id, + { codexSessionsDirs: stores, strictErrors: true }, + signal + )) ?? unavailable() + const physicalHistory = async (turnId: string | undefined) => { + const path = await find(input.providerSessionId) + return codexForkHistory({ + path, roots: stores, selectedItemId: checkpoint.selectedItemId, - turnId: checkpoint.turnId, - sessionId: id, - inheritedOnly: true, + turnId, + sessionId: input.providerSessionId, signal }) - if ( - captured.index !== history.index || - captured.roots.length < prefix.length || - captured.roots - .slice(0, prefix.length) - .some((root, index) => root.id && root.id !== coverage.ids[index]) - ) { - return unavailable() - } - if (lastTurnId || beforeTurnId) { - if (captured.roots.length !== prefix.length) { - return unavailable() - } - } else { - // Trim the actual capture; coverage identities were frozen on the source before fork. - if (thread?.historyMode !== 'paginated') { - return unavailable() - } - const childTurns = await turns(rpc, id, captured.roots.length + 1) - if (childTurns.length !== captured.roots.length) { - return unavailable() - } - for (const [index, root] of captured.roots.entries()) { - if (root.id && root.id !== childTurns[index]?.id) { - return unavailable() + } + const missing = (): never => { + throw agentConversationCopyRefusal( + 'agent_session_operation_invalid', + { reason: 'savedConversationMissing' }, + 'Codex could not find this response in the saved chat. Open the chat again, then fork a response still in its history.' + ) + } + let history = legacy && !checkpoint.turnId ? await physicalHistory(undefined) : undefined + starting = true + return await runCodexProviderAppServer( + record, + { + ...invocation, + resolveLaunchArgs: () => record.launchArgs ?? invocation.resolveLaunchArgs() + }, + { timeoutMs: 60_000, signal, experimentalApi: true }, + async (rpc, cwd) => { + starting = false + let metadata + try { + metadata = asRecord( + asRecord( + await rpc.request('thread/read', { + threadId: input.providerSessionId, + includeTurns: false + }) + )?.thread + ) + if ( + metadata?.id !== input.providerSessionId || + (metadata.historyMode != null && + metadata.historyMode !== 'legacy' && + metadata.historyMode !== 'paginated') + ) { + throw new Error('Invalid source metadata') + } + } catch (error) { + if (!isCodexAppServerUnsupportedError(error)) { + throw error } } - const firstRemoved = childTurns[captured.index + 1] - if (firstRemoved) { - await rpc.request('thread/revert', { threadId: id, beforeTurnId: firstRemoved.id }) - const kept = await turns(rpc, id, captured.index + 2) + let native = metadata?.historyMode === 'paginated' + let sourceTurns = + native && checkpoint.turnId + ? await turns(rpc, input.providerSessionId, checkpoint.turnId) + : undefined + if (!native || !sourceTurns || sourceTurns.at(-1)?.id !== checkpoint.turnId) { + if (native && !legacy) { + return missing() + } + history ??= await physicalHistory(native && legacy ? undefined : checkpoint.turnId) + if (native && history.selected.id) { + sourceTurns = await turns(rpc, input.providerSessionId, history.selected.id) + if (sourceTurns.at(-1)?.id !== history.selected.id) { + return missing() + } + } else { + native = false + } + } + const lastTurnId = native ? sourceTurns!.at(-1)!.id : history!.selected.id + const beforeTurnId = native ? undefined : history!.roots[history!.index + 1]?.id + const terminal = native + ? ['completed', 'failed', 'interrupted'].includes(String(sourceTurns!.at(-1)?.status)) + : history!.selected.terminal + if (!checkpoint.settled && !terminal && !beforeTurnId) { + throw agentConversationCopyRefusal( + 'agent_session_operation_invalid', + { reason: 'turnActive' }, + "Codex hasn't finished this response yet. Wait for it to finish, then try again." + ) + } + const prefix = native ? sourceTurns! : history!.roots.slice(0, history!.index + 1) + sourceTurns = native + ? sourceTurns! + : prefix.every((root) => root.id) + ? prefix.map((root) => ({ id: root.id! })) + : await turns(rpc, input.providerSessionId, history!.roots.length) + if ( + sourceTurns.length < prefix.length || + prefix.some((root, index) => root.id && root.id !== sourceTurns![index]?.id) + ) { + return missing() + } + const coverage = { + unit: 'codex-turn' as const, + ids: sourceTurns.slice(0, prefix.length).map((turn) => turn.id) + } + rpc.signal?.throwIfAborted() + signal.throwIfAborted() + dispatched = true + const fork = asRecord( + await rpc.request('thread/fork', { + threadId: input.providerSessionId, + ...(lastTurnId ? { lastTurnId } : beforeTurnId ? { beforeTurnId } : {}), + excludeTurns: true, + cwd + }) + ) + const thread = asRecord(fork?.thread) + const id = thread?.id + if (typeof id !== 'string' || !id.trim() || id === input.providerSessionId) { + return unavailable() + } + if (native) { + const captured = await turns(rpc, id, lastTurnId!) if ( - kept.length !== captured.index + 1 || - kept.at(-1)?.id !== childTurns[captured.index]?.id + captured.length !== coverage.ids.length || + captured.some((turn, index) => turn.id !== coverage.ids[index]) ) { return unavailable() } + return { sessionId: id, coverage } } + const captured = await codexForkHistory({ + path: await find(id), + roots: stores, + selectedItemId: checkpoint.selectedItemId, + turnId: checkpoint.turnId, + sessionId: id, + inheritedOnly: true, + signal + }) + if ( + captured.index !== history!.index || + captured.roots.length < prefix.length || + captured.roots + .slice(0, prefix.length) + .some((root, index) => root.id && root.id !== coverage.ids[index]) + ) { + return unavailable() + } + if (lastTurnId || beforeTurnId) { + if (captured.roots.length !== prefix.length) { + return unavailable() + } + } else { + // Trim the actual capture; coverage identities were frozen on the source before fork. + if (thread?.historyMode !== 'paginated') { + return unavailable() + } + const childTurns = await turns(rpc, id, captured.roots.length + 1) + if (childTurns.length !== captured.roots.length) { + return unavailable() + } + for (const [index, root] of captured.roots.entries()) { + if (root.id && root.id !== childTurns[index]?.id) { + return unavailable() + } + } + const firstRemoved = childTurns[captured.index + 1] + if (firstRemoved) { + await rpc.request('thread/revert', { threadId: id, beforeTurnId: firstRemoved.id }) + const kept = await turns(rpc, id, captured.index + 2) + if ( + kept.length !== captured.index + 1 || + kept.at(-1)?.id !== childTurns[captured.index]?.id + ) { + return unavailable() + } + } + } + return { sessionId: id, coverage } } - return { sessionId: id, coverage } + ) + } catch (error) { + input.signal?.throwIfAborted() + if (!dispatched) { + const refusal = isAgentSessionRefusalError(error) + ? error + : agentConversationCopyRefusal( + 'agent_session_identity_required', + { reason: 'transcriptUnreadable' }, + starting + ? TEXT_GENERATION_FAILURE_COPY.agentCouldNotStart.replace('{{label}}', 'Codex') + : 'Codex could not read the saved chat. Open it again, then try forking the response.' + ) + if (starting) { + refusal.cause = new AgentConversationCopyStartError(refusal.refusal.message) + } + refusal.refusal.createAttemptRefusedBeforeCommit = true + throw refusal } - ).catch((error: unknown) => { if (error instanceof AgentConversationCopyError) { throw error } - if (error instanceof CodexAppServerTimeoutError || signal.aborted) { - throw new AgentConversationCopyError('Codex took too long to copy this chat. Try again.', { - cause: error - }) - } - if ( - error instanceof Error && - /(?:in[- ]progress|active turn|turn.*running)/i.test(error.message) - ) { - throw new AgentConversationCopyError( - 'Codex could not copy this chat while a response was running. Wait for it to finish, then try again.', - { cause: error } - ) - } - throw new AgentConversationCopyError('Codex could not copy this chat. Try again.', { + throw new AgentConversationCopyError('Codex could not verify the copied chat.', { cause: error }) - }) + } } diff --git a/src/main/codex/codex-fork-history.ts b/src/main/codex/codex-fork-history.ts new file mode 100644 index 00000000000..26d345e2693 --- /dev/null +++ b/src/main/codex/codex-fork-history.ts @@ -0,0 +1,100 @@ +import { AgentConversationCopyError } from '../native-chat/agent-conversation-copy-error' +import { parseAgentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' +import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' +import { createLegacyIdentityTracker } from '../native-chat/agent-session-journal/history-import/journal-legacy-identity' +import { readCodexLocalHistory } from '../native-chat/codex-local-history' +import { decodeCodexTranscriptLine } from '../native-chat/transcript-line-decoders-codex' + +type Root = { id?: string; terminal: boolean; prompt: boolean } + +/** Use the importer's physical lineage and identity accounting, including discarded window rows. */ +export async function codexForkHistory(input: { + path: string + roots: string[] + selectedItemId: string + turnId?: string + sessionId: string + signal?: AbortSignal + inheritedOnly?: boolean +}) { + const identity = parseAgentJournalItemKey(input.selectedItemId) + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'codex', + agent: 'codex', + sessionId: input.sessionId + }) + const roots: Root[] = [] + let selected: Root | undefined + let lineIndex = 0 + let referenceBacked = false + await readCodexLocalHistory({ + filePath: input.path, + roots: input.roots, + visitRecord: ({ record, selected }) => { + if (selected && record?.type === 'session_meta') { + referenceBacked = asRecord(record.payload)?.history_base != null + } + }, + visit: (line, fallbackId, selectedSegment, recordId) => { + if (input.inheritedOnly && referenceBacked && selectedSegment) { + return + } + input.signal?.throwIfAborted() + const tracked = tracker.identify(line, lineIndex++) + const raw = parseJsonObject(line) + const payload = asRecord(raw?.payload) + if ( + raw?.type === 'event_msg' && + payload?.type === 'thread_rolled_back' && + typeof payload.num_turns === 'number' + ) { + roots.splice(Math.max(0, roots.length - payload.num_turns)) + return + } + if (raw?.type === 'event_msg' && payload?.type === 'task_started') { + roots.push({ + id: typeof payload.turn_id === 'string' ? payload.turn_id : undefined, + terminal: false, + prompt: false + }) + } + const message = decodeCodexTranscriptLine(line, fallbackId, { historyImport: true }) + if (message?.role === 'user') { + const previous = roots.at(-1) + if (!previous || (previous.prompt && (!previous.id || previous.terminal))) { + roots.push({ terminal: false, prompt: true }) + } else { + previous.prompt = true + } + } + const root = roots.at(-1) + if ( + root && + raw?.type === 'event_msg' && + (payload?.type === 'task_complete' || payload?.type === 'turn_aborted') && + (!payload.turn_id || payload.turn_id === root.id) + ) { + root.terminal = true + } + const importedMatch = + identity?.provider === 'legacy' && + identity.recordId === + (recordId ?? (tracked.provider === 'legacy' ? tracked.recordId : undefined)) + if ( + root && + ((importedMatch && message?.role === 'assistant') || + (input.turnId && root.id === input.turnId)) + ) { + if (selected && selected !== root) { + throw new AgentConversationCopyError('Codex could not verify the copied chat.') + } + selected = root + } + } + }) + const index = selected ? roots.indexOf(selected) : -1 + if (index < 0) { + throw new AgentConversationCopyError('Codex could not verify the copied chat.') + } + return { roots, index, selected: roots[index] } +} diff --git a/src/main/native-chat/agent-conversation-copy-error.ts b/src/main/native-chat/agent-conversation-copy-error.ts index 8924223a2a2..64fb08a76f8 100644 --- a/src/main/native-chat/agent-conversation-copy-error.ts +++ b/src/main/native-chat/agent-conversation-copy-error.ts @@ -8,6 +8,8 @@ import type { AgentSessionRefusalDetailsByCode } from '../../shared/agent-sessio /** Only copy errors authored here may pass through the host's generic provider failure. */ export class AgentConversationCopyError extends Error {} +export class AgentConversationCopyStartError extends AgentConversationCopyError {} + export function agentConversationCopyRefusal( code: C, details: NoInfer, diff --git a/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork-source.ts b/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork-source.ts index 5c60bb1c6bc..dba1bd926a7 100644 --- a/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork-source.ts +++ b/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork-source.ts @@ -9,7 +9,10 @@ import { type AgentSessionExecutionLocation } from '../../../../shared/agent-session/lifecycle/agent-session-record' import type { StructuredAgentId } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle' -import { isAgentSessionRefusalError } from '../../../../shared/agent-session/failures/agent-session-wire-refusals' +import { + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../../../shared/agent-session/failures/agent-session-wire-refusals' import { isAgentSessionForkSourceAdmissionRefusal } from '../../../../shared/agent-session/failures/agent-session-definitive-refusal' import { AGENT_SESSION_FAILURE_COPY, @@ -17,6 +20,7 @@ import { } from '../../../../shared/agent-session/failures/agent-session-failure-copy' import { AgentConversationCopyError, + AgentConversationCopyStartError, agentConversationCopyRefusal } from '../../agent-conversation-copy-error' import { acpAgentName } from '../../../acp/session/acp-structured-agent-definitions' @@ -70,7 +74,7 @@ async function resolveForkSource(input: StructuredAgentSessionForkInput) { const capability = host.deps.agents.capabilities(input.agent)?.fork const { head, pendingClear } = activeProviderContext(record) const fail = ( - message: string, + message: string | AgentSessionRefusalError, reason: | 'conversationCleared' | 'rewindUnconfirmed' @@ -79,16 +83,16 @@ async function resolveForkSource(input: StructuredAgentSessionForkInput) { | 'providerRejected' | 'launchFolderMissing' = 'savedConversationMissing' ): never => { - const refusal = agentConversationCopyRefusal( - 'agent_session_operation_invalid', - { reason }, - message - ) - if (original) { + const refusal = + typeof message === 'string' + ? agentConversationCopyRefusal('agent_session_operation_invalid', { reason }, message) + : message + const failureReason = refusal.refusal.details?.reason + if (original && !(refusal.cause instanceof AgentConversationCopyStartError)) { const piece = - reason === 'savedConversationMissing' + failureReason === 'savedConversationMissing' ? 'forkOriginalMissing' - : reason === 'turnActive' + : failureReason === 'turnActive' ? 'forkOriginalLatestOnly' : 'forkOriginalUnavailable' const title = record.conversationName?.trim() diff --git a/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork.ts b/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork.ts index ae112b21465..184a31f819a 100644 --- a/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork.ts +++ b/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-fork.ts @@ -51,7 +51,7 @@ export async function prepareStructuredAgentSessionFork( accountHome: record.accountHome }).catch((error: unknown) => { if (isAgentSessionRefusalError(error)) { - throw error + return fail(error) } host.deps.logger.warn('native conversation copy failed', { scope: 'chat-fork', diff --git a/src/main/runtime/rpc/methods/structured-sessions/creation/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-sessions/creation/structured-agent-session-precommit-refusal.test.ts index fdab266aaf1..441bff0acff 100644 --- a/src/main/runtime/rpc/methods/structured-sessions/creation/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-sessions/creation/structured-agent-session-precommit-refusal.test.ts @@ -1,3 +1,6 @@ +import { copyCodexConversation } from '../../../../../codex/codex-conversation-copy' +import type * as AppServerSession from '../../../../../codex/app-server/codex-app-server-session' +import { tmpdir } from 'node:os' import { createRestTestRig } from '../../../../../native-chat/agent-session-wire/host/testing/structured-agent-session-rest-test-rig' import { forkTestSource } from '../../../../../native-chat/agent-session-wire/acquisition/testing/structured-agent-session-fork.test-fixture' import { agentSessionRecordFixture } from '../../../../../native-chat/agent-session-record-test-fixture' @@ -21,6 +24,15 @@ import { RpcDispatcher } from '../../../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from '../structured-agent-session' import './structured-agent-session-create' +const codex = vi.hoisted(() => ({ request: vi.fn() })) +vi.mock('../../../../../codex/app-server/codex-app-server-session', async (importOriginal) => ({ + ...(await importOriginal()), + runCodexAppServerSession: async ( + _launch: unknown, + body: (rpc: AppServerSession.CodexAppServerRpc) => Promise + ) => body({ request: codex.request, notify: () => {} }) +})) + const SESSION = 'session-alpha' const OPERATION = '1800000000000-00000000000000000000000000000001' const WORKTREE = 'id:workspace-1' @@ -310,3 +322,52 @@ it('does not prove no copy when source revalidation fails after the native copy' await rig.dispose() } }) + +it('settles a redirected Codex preparation refusal but not a dispatched child-read failure', async () => { + const { rig, test, createFork } = await forkHost() + test.record.conversationName = ' Release notes ' + try { + test.copy.mockImplementation((input) => + copyCodexConversation(input, { + resolveLaunchArgs: () => [], + resolveWorkspacePath: async () => tmpdir(), + resolveCommand: () => ({ command: 'codex', prefixArgs: [] }) + }) + ) + codex.request.mockRejectedValue(new Error('source read failed')) + expect(refusalOf(await createFork('codex_source_read_failed'))).toMatchObject({ + code: 'agent_session_identity_required', + details: { reason: 'transcriptUnreadable' }, + createAttemptRefusedBeforeCommit: true + }) + expect(rig.store.getRecord('codex_source_read_failed')).toBeNull() + codex.request.mockImplementation(async (method: string) => { + if (method === 'thread/read') { + return { thread: { id: '019e6b26-18e2-7103-b7a0-7ba6bb071123', historyMode: 'paginated' } } + } + if (method === 'thread/fork') { + return { thread: { id: '01a1bde0-5ded-7f03-a1bf-3dae1613d8c7' } } + } + throw new Error('child read failed') + }) + // Source listing succeeds before the child read fails. + codex.request + .mockImplementationOnce(async () => ({ + thread: { id: '019e6b26-18e2-7103-b7a0-7ba6bb071123', historyMode: 'paginated' } + })) + .mockImplementationOnce(async () => ({ + data: [{ id: 'completed-turn', status: 'completed' }], + nextCursor: null + })) + const refusal = refusalOf(await createFork('codex_child_read_failed')) + expect(refusal).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'providerRejected' } + }) + expect(refusal).not.toHaveProperty('createAttemptRefusedBeforeCommit') + expect(rig.store.getRecord('codex_child_read_failed')).toBeNull() + } finally { + setStructuredAgentSessionHost(null) + await rig.dispose() + } +}) From c55bee048576a43d529b413924678721dc4d30b8 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:06:44 +0000 Subject: [PATCH 064/138] refactor(claude): move the kill-delivered receipt check out of the session adapter (#40481) The adapter crossed the 300-line cap after #40069 and main's lint fails. The same receipt check ran twice in it; one shared predicate brings the file back under the cap with no behaviour change. --- .../claude-kill-delivered-exit-receipt.ts | 10 ++++++++++ .../claude-structured-session-adapter.ts | 16 +++------------- 2 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 src/main/claude/structured/claude-kill-delivered-exit-receipt.ts diff --git a/src/main/claude/structured/claude-kill-delivered-exit-receipt.ts b/src/main/claude/structured/claude-kill-delivered-exit-receipt.ts new file mode 100644 index 00000000000..4fa1bbb75ef --- /dev/null +++ b/src/main/claude/structured/claude-kill-delivered-exit-receipt.ts @@ -0,0 +1,10 @@ +import { AgentSessionAcquisitionRootExitObservedError } from '../../native-chat/agent-session-wire/acquisition/structured-agent-session-acquisition-errors' + +export function isKillDeliveredExitReceipt( + receipt: Error | undefined +): receipt is AgentSessionAcquisitionRootExitObservedError { + return ( + receipt instanceof AgentSessionAcquisitionRootExitObservedError && + receipt.deathEvidenceKind === 'kill-delivered' + ) +} diff --git a/src/main/claude/structured/claude-structured-session-adapter.ts b/src/main/claude/structured/claude-structured-session-adapter.ts index 45ea207c93a..f2b69b423d1 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.ts @@ -1,5 +1,5 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch' -import { AgentSessionAcquisitionRootExitObservedError } from '../../native-chat/agent-session-wire/acquisition/structured-agent-session-acquisition-errors' +import { isKillDeliveredExitReceipt } from './claude-kill-delivered-exit-receipt' import { claudeAcquireFailure } from '../claude-startup-failure' import { claudeStartPublication, openClaudeStart } from './claude-structured-start-ending' import { AgentSessionStartEndings } from '../../native-chat/agent-session-wire/startup/structured-agent-session-start-failure' @@ -259,11 +259,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda try { const closed = await close() const receipt = this.settledExitErrors.get(sessionId) - if ( - closed && - receipt instanceof AgentSessionAcquisitionRootExitObservedError && - receipt.deathEvidenceKind === 'kill-delivered' - ) { + if (closed && isKillDeliveredExitReceipt(receipt)) { throw receipt } return closed @@ -290,13 +286,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda options ) => this.releaseAcquisition({ sessionId, closeOptions: options }).finally(() => { - const receipt = this.settledExitErrors.get(sessionId) - if ( - !( - receipt instanceof AgentSessionAcquisitionRootExitObservedError && - receipt.deathEvidenceKind === 'kill-delivered' - ) - ) { + if (!isKillDeliveredExitReceipt(this.settledExitErrors.get(sessionId))) { this.settledExitErrors.delete(sessionId) } }) From 313fb8733e37d4c6e10f725e720177ea7c022a78 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:18:06 +0000 Subject: [PATCH 065/138] fix(speech): reload the warm recognizer once the VAD download lands (#40477) A recognizer that loaded before the Silero VAD file existed kept cutting text only at stop until its next restart. When the VAD lands, the speech service now unloads that recognizer once no dictation uses it and warms it again, so pause cuts start on first use. A running dictation is never interrupted. --- src/main/speech/speech-vad-model.ts | 25 ++++++++++--- src/main/speech/stt-service.test.ts | 54 ++++++++++++++++++++++++---- src/main/speech/stt-service.ts | 22 ++++++++++++ src/main/speech/stt-session-start.ts | 3 +- src/main/speech/stt-session-state.ts | 2 ++ 5 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/main/speech/speech-vad-model.ts b/src/main/speech/speech-vad-model.ts index 5aa954a01b0..12403954e06 100644 --- a/src/main/speech/speech-vad-model.ts +++ b/src/main/speech/speech-vad-model.ts @@ -19,10 +19,7 @@ export function downloadSileroVadInBackground(models: Pick -): string | undefined { +function placedSileroVadPath(models: Pick): string | undefined { const [file] = SILERO_VAD_MODEL.downloadFiles const path = join(models.getModelDir(SILERO_VAD_MODEL.id), file.name) if (path === verifiedVadPath) { @@ -36,6 +33,24 @@ export function sileroVadModelPath( } catch { // Not downloaded yet, e.g. the model came before VAD support. } - downloadSileroVadInBackground(models) return undefined } + +/** Whether a model-state change for `modelId` means the Silero VAD file is now in place. */ +export function sileroVadLanded( + models: Pick, + modelId: string +): boolean { + return modelId === SILERO_VAD_MODEL.id && placedSileroVadPath(models) !== undefined +} + +/** The downloaded Silero VAD file; when it's missing, starts fetching it and returns undefined. */ +export function sileroVadModelPath( + models: Pick +): string | undefined { + const path = placedSileroVadPath(models) + if (!path) { + downloadSileroVadInBackground(models) + } + return path +} diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index cdd27198850..f397c55b819 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -1,6 +1,9 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { ModelManager } from './model-manager' import type { SpeechModelState } from '../../shared/speech-types' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' const { MockWorker, getCreatedWorkerCount, getLastWorker, resetWorkers } = vi.hoisted(() => { class HoistedMockWorker extends EventTarget { @@ -104,9 +107,16 @@ const stoppedReply = (worker: InstanceType) => ({ streamId: worker.messages.findLast((message) => message.type === 'stop')?.streamId }) -const testModels = (models: Pick): ModelManager => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SttService reads getModelState and getModelDir, and fetches the VAD with downloadModel. - ({ ...models, downloadModel: vi.fn().mockResolvedValue(undefined) }) as never +const testModels = ( + models: Pick, + onModelStateChange: ModelManager['setProgressCallback'] = () => () => {} +): ModelManager => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SttService reads getModelState and getModelDir, fetches the VAD with downloadModel and watches it land. + ({ + ...models, + downloadModel: vi.fn().mockResolvedValue(undefined), + setProgressCallback: onModelStateChange + }) as never const readyModels = (): ModelManager => testModels({ @@ -122,6 +132,7 @@ async function flushMicrotasks(): Promise { } vi.mock('./model-catalog', () => ({ + isLocalSpeechModel: (manifest: { provider: string }) => manifest.provider === 'local', getCatalogModel: (id: string) => id === 'openai-model' ? { @@ -174,6 +185,31 @@ describe('SttService', () => { resetWorkers() }) + it('reloads a recognizer loaded before the VAD landed once the dictation using it ends', async () => { + const modelsDir = mkdtempSync(join(tmpdir(), 'stt-vad-')) + onTestFinished(() => rmSync(modelsDir, { recursive: true, force: true })) + const watchModels = vi.fn(() => () => {}) + const models = testModels( + { getModelState: readyModels().getModelState, getModelDir: (id) => join(modelsDir, id) }, + watchModels + ) + const service = new SttService(models, spawnMockWorker) + await service.startDictation('model-a', vi.fn(), undefined, 'desktop') + const initOf = (worker = getLastWorker()) => worker?.messages.find((m) => m.type === 'init') + expect(initOf()).toMatchObject({ vadModelPath: undefined }) + + const vadPath = join(modelsDir, 'silero-vad-v6.2', 'silero-vad-v6.2-F32.gguf') + mkdirSync(join(modelsDir, 'silero-vad-v6.2')) + writeFileSync(vadPath, Buffer.alloc(1_241_056)) + watchModels.mock.calls[0]?.[0]('silero-vad-v6.2', -1) + await flushMicrotasks() + expect(getCreatedWorkerCount()).toBe(1) + + await service.stopDictation('desktop') + await vi.waitFor(() => expect(getCreatedWorkerCount()).toBe(2)) + expect(initOf()).toMatchObject({ vadModelPath: vadPath }) + }) + it('gives each device dictating at once only its own text from one recognizer', async () => { const service = new SttService(readyModels(), spawnMockWorker) const desktop = vi.fn() @@ -739,15 +775,19 @@ describe('SttService', () => { async (event) => { vi.useFakeTimers() try { - const models: Pick = { + const models: Pick< + ModelManager, + 'getModelState' | 'getModelDir' | 'downloadModel' | 'setProgressCallback' + > = { getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), getModelDir: vi.fn().mockReturnValue('/tmp/model-a'), - downloadModel: vi.fn().mockResolvedValue(undefined) + downloadModel: vi.fn().mockResolvedValue(undefined), + setProgressCallback: () => () => {} } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This lifecycle reads getModelState/getModelDir and downloadModel (VAD) on the model manager. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This lifecycle reads getModelState/getModelDir, downloadModel (VAD) and setProgressCallback on the model manager. const service = new SttService(models as ModelManager, spawnMockWorker) const sink = vi.fn() await service.startDictation('model-a', sink, undefined, 'desktop') diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index 048c4872135..2333dfae0ed 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -6,6 +6,7 @@ import { } from '../../shared/dictation-text-pipeline' import { getCatalogModel } from './model-catalog' import type { ModelManager } from './model-manager' +import { sileroVadLanded } from './speech-vad-model' import type { SpawnSttWorkerProcess } from './stt-worker-process' import { prewarmSttModel } from './stt-session-prewarm' import { startSttDictation } from './stt-session-start' @@ -45,6 +46,27 @@ export class SttService { constructor(modelManager: ModelManager, spawnWorker: SpawnSttWorkerProcess) { this.state = createSttSessionState(modelManager, spawnWorker) + modelManager.setProgressCallback((modelId) => { + if (sileroVadLanded(modelManager, modelId)) { + void this.reloadForVad() + } + }) + } + + /** A recognizer loaded before the VAD landed cuts text only at stop; reload it once no dictation uses it. */ + private async reloadForVad(): Promise { + const engine = this.state.engine + if (!engine || engine.retired || engine.vadModelPath) { + return + } + const { modelId, language } = engine + const unloaded = this.unloadModelWhenIdle() + const generation = this.state.prewarmGeneration + await unloaded + // A newer pre-warm or a dictation took over; loading is theirs now. + if (this.state.prewarmGeneration === generation && !this.state.engine) { + await this.prewarm(modelId, language) + } } /** `terms`: the dictionary for where this dictation goes, repo terms first (mergeDictationTerms). */ diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index cd815fbc920..ee7bd5b1833 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -187,7 +187,7 @@ export function spawnSttEngine( if (!speechModulesDir) { throw new Error(LOCAL_SPEECH_UNAVAILABLE_ERROR) } - // A VAD fetched now serves the next engine start. + // A VAD fetched now reloads this engine once it is idle (SttService). const vadModelPath = sileroVadModelPath(state.modelManager) clearSttIdleTeardownTimer(state) const worker = state.spawnWorker() @@ -210,6 +210,7 @@ export function spawnSttEngine( worker, detach, retired: false, + vadModelPath, onUnloaded: [], ready: readyPromise.catch((error: unknown) => { failSttEngine(state, engine, STT_WORKER_EXITED_ERROR) diff --git a/src/main/speech/stt-session-state.ts b/src/main/speech/stt-session-state.ts index 21c081dacf9..c54d4dd5eed 100644 --- a/src/main/speech/stt-session-state.ts +++ b/src/main/speech/stt-session-state.ts @@ -19,6 +19,8 @@ export type SttEngine = SttRecognizerConfig & { detach: () => void /** Torn down on purpose, so a load it cut short is not a failure of the model. */ retired: boolean + /** Absent when the VAD wasn't downloaded yet at load, so this process cuts text only at stop. */ + vadModelPath: string | undefined /** Called once this process is gone; set when it must not outlive its last dictation. */ onUnloaded: (() => void)[] } From 18996530407d9744890f196813f3d5cc8061cd4b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:18:14 +0000 Subject: [PATCH 066/138] feat(settings): manage dictation vocabulary in Voice settings, per user and per repo (#40471) * feat(settings): add a Vocabulary group to Voice settings for personal and repo terms * feat(speech): use the repo name as a dictation hint without recasing one-word names * fix(settings): use Input and Button styling as the design system defines it * fix(settings): chain quick repo-term edits, report a host that drops them, keep the hint off phone replies * fix(settings): key a pending repo-term write by host too * refactor(settings): catch a dropped repo-term write in updateRepo and keep the name hint out of stored terms * fix(settings): one vocabulary chip per term with its aliases, and no repo suffix on the scope tab * fix(settings): skip a bare vocabulary term that already has an entry --- src/main/ipc/speech.ts | 7 +- .../runtime/speech/runtime-dictation-terms.ts | 6 +- .../speech/runtime-mobile-speech-catalog.ts | 4 +- src/main/speech/stt-service.ts | 2 +- .../components/dictation/dictation-context.ts | 6 +- .../components/settings/voice/VoicePane.tsx | 8 + .../settings/voice/VoiceVocabularySetting.tsx | 269 ++++++++++++++++++ .../settings/voice/voice-pane-search.ts | 18 ++ src/renderer/src/i18n/locales/de.json | 23 +- src/renderer/src/i18n/locales/en.json | 23 +- src/renderer/src/i18n/locales/es.json | 23 +- src/renderer/src/i18n/locales/fr.json | 23 +- src/renderer/src/i18n/locales/ja.json | 23 +- src/renderer/src/i18n/locales/ko.json | 23 +- src/renderer/src/i18n/locales/pt.json | 23 +- src/renderer/src/i18n/locales/ru.json | 23 +- src/renderer/src/i18n/locales/tr.json | 23 +- src/renderer/src/i18n/locales/zh-Hant.json | 23 +- src/renderer/src/i18n/locales/zh.json | 23 +- src/renderer/src/store/repos/repo-update.ts | 8 + src/shared/dictation-dictionary.test.ts | 21 +- src/shared/dictation-dictionary.ts | 47 ++- src/shared/speech-types.ts | 3 + 23 files changed, 619 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/components/settings/voice/VoiceVocabularySetting.tsx diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index 2451c5acff7..98f7f52f9b0 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -6,7 +6,7 @@ import { type MessagePortMain } from 'electron' import { abortWhenRendererGone } from './renderer-lifetime-abort' -import { mergeDictationTerms } from '../../shared/dictation-dictionary' +import { dictationTermsFor } from '../../shared/dictation-dictionary' import { SPEECH_MODEL_CATALOG } from '../speech/model-catalog' import { deleteLocalSpeechModel } from '../speech/speech-model-deletion' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' @@ -220,7 +220,10 @@ export function registerSpeechHandlers(store: Store): void { break } }, - mergeDictationTerms(context?.repoTerms, store.getSettings().voice?.dictationTerms), + dictationTermsFor(store.getSettings().voice, { + dictationTerms: context?.repoTerms, + displayName: context?.repoName + }), owner, store.getSettings().voice ) diff --git a/src/main/runtime/speech/runtime-dictation-terms.ts b/src/main/runtime/speech/runtime-dictation-terms.ts index 13785a2cc2d..63838db9179 100644 --- a/src/main/runtime/speech/runtime-dictation-terms.ts +++ b/src/main/runtime/speech/runtime-dictation-terms.ts @@ -1,12 +1,12 @@ -import { mergeDictationTerms, type DictationTerm } from '../../../shared/dictation-dictionary' +import { dictationTermsFor, type DictationTerm } from '../../../shared/dictation-dictionary' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import type { RuntimeStore } from '../construction/runtime-store-contract' -/** This host's dictionary for a worktree: its repo's terms, then the personal ones. A folder workspace has no repo row, so it gets the personal ones. */ +/** This host's dictionary for a worktree; a folder workspace has no repo row, so it gets the personal terms. */ export function hostDictationTerms( store: RuntimeStore, worktreeId: string | undefined ): DictationTerm[] { const repo = worktreeId ? store.getRepo(getRepoIdFromWorktreeId(worktreeId)) : undefined - return mergeDictationTerms(repo?.dictationTerms, store.getSettings().voice?.dictationTerms) + return dictationTermsFor(store.getSettings().voice, repo) } diff --git a/src/main/runtime/speech/runtime-mobile-speech-catalog.ts b/src/main/runtime/speech/runtime-mobile-speech-catalog.ts index fa042e5be68..6619e4f7e0b 100644 --- a/src/main/runtime/speech/runtime-mobile-speech-catalog.ts +++ b/src/main/runtime/speech/runtime-mobile-speech-catalog.ts @@ -69,7 +69,9 @@ export class RuntimeMobileSpeechCatalog { dictationMode: voice.dictationMode === 'hold' ? 'hold' : 'toggle', dictationHoldToRecord: voice.dictationHoldToRecord !== false, removeFillerWords: voice.removeFillerWords !== false, - dictationTerms: hostDictationTerms(store, worktreeId), + // The phone has no Whisper prompt to bias, and its text pass would only join the name; keeping the + // hint out also spares a phone page from before hints, which would treat the name as a full term. + dictationTerms: hostDictationTerms(store, worktreeId).filter((entry) => !entry.hint), models } } diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index 2333dfae0ed..bacc4b753ed 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -69,7 +69,7 @@ export class SttService { } } - /** `terms`: the dictionary for where this dictation goes, repo terms first (mergeDictationTerms). */ + /** `terms`: the dictionary for where this dictation goes, repo terms first (dictationTermsFor). */ startDictation( modelId: string, sink: SttEventSink, diff --git a/src/renderer/src/components/dictation/dictation-context.ts b/src/renderer/src/components/dictation/dictation-context.ts index f2cbe4088fe..08f9db5290a 100644 --- a/src/renderer/src/components/dictation/dictation-context.ts +++ b/src/renderer/src/components/dictation/dictation-context.ts @@ -11,5 +11,9 @@ export function captureDictationContext(): DictationContext { return {} } const repo = selectRepoByIdForActiveWorkspace(state, getRepoIdFromWorktreeId(worktreeId)) - return { worktreeId, ...(repo?.dictationTerms ? { repoTerms: repo.dictationTerms } : {}) } + return { + worktreeId, + ...(repo?.dictationTerms ? { repoTerms: repo.dictationTerms } : {}), + ...(repo ? { repoName: repo.displayName } : {}) + } } diff --git a/src/renderer/src/components/settings/voice/VoicePane.tsx b/src/renderer/src/components/settings/voice/VoicePane.tsx index b29ea9ff4a7..e95683cda94 100644 --- a/src/renderer/src/components/settings/voice/VoicePane.tsx +++ b/src/renderer/src/components/settings/voice/VoicePane.tsx @@ -12,6 +12,7 @@ import { OpenAiCompatibleProviderDialog } from './OpenAiCompatibleProviderDialog import { OpenAiTranscriptionSettingsRow } from './OpenAiTranscriptionSettingsRow' import { handleVoiceDictationToggle } from './voice-dictation-toggle' import { VoiceDictationLanguageSetting } from './VoiceDictationLanguageSetting' +import { VoiceVocabularySetting } from './VoiceVocabularySetting' import { VOICE_MICROPHONE_ROW_ID, VoiceInputSettings } from './VoiceInputSettings' import { VoiceSetupCard } from './VoiceSetupCard' import { cancelSpeechModelDownload, startSpeechModelDownload } from './speech-model-download' @@ -331,6 +332,13 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J + + + +
) => void +} + +const NO_TERMS: readonly DictationTerm[] = [] + +export function VoiceVocabularySetting({ + voiceSettings, + onUpdateVoiceSettings +}: VoiceVocabularySettingProps): React.JSX.Element { + const t = useTranslate() + // The repo you're working in; a folder workspace has none, so only personal terms show. + const repo = useAppStore((s) => selectRepoByIdForActiveWorkspace(s, s.activeRepoId)) + const updateRepo = useAppStore((s) => s.updateRepo) + const [chosenScope, setScope] = useState('personal') + // A repo write is a round trip (RPC for a remote host); the next edit builds on it, not on the stored list. + const [pending, setPending] = useState<{ + repoId: string + hostId: ExecutionHostId + terms: DictationTerm[] + } | null>(null) + const scope = repo ? chosenScope : 'personal' + const personalTerms = voiceSettings.dictationTerms ?? NO_TERMS + const repoTerms = + pending && repo && pending.repoId === repo.id && pending.hostId === getRepoExecutionHostId(repo) + ? pending.terms + : (repo?.dictationTerms ?? NO_TERMS) + const terms = scope === 'repo' ? repoTerms : personalTerms + + const saveRepoTerms = async ( + repoId: string, + hostId: ExecutionHostId, + next: DictationTerm[] + ): Promise => { + const write = { repoId, hostId, terms: next } + setPending(write) + if (!(await updateRepo(repoId, { dictationTerms: next }, { hostId }))) { + toast.error( + t( + 'components.settings.voice.vocabulary.repoSaveFailed', + "Couldn't save the repo's terms. The host running this repo may need an Orca update." + ) + ) + } + setPending((current) => (current === write ? null : current)) + } + + const saveTerms = (next: DictationTerm[]): void => { + if (scope === 'repo' && repo) { + void saveRepoTerms(repo.id, getRepoExecutionHostId(repo), next) + } else { + onUpdateVoiceSettings({ dictationTerms: next }) + } + } + + return ( +
+ +
+

+ {scope === 'repo' && repo + ? t( + 'components.settings.voice.vocabulary.repoScope', + 'Only used while you dictate in {{repo}}.', + { repo: repo.displayName } + ) + : t('components.settings.voice.vocabulary.personalScope', 'Used in every repo.')} +

+ {repo ? ( + + ) : null} +
+ {terms.length > 0 ? ( +
    + {chipsBySpelling(terms).map((chip) => ( + saveTerms(terms.filter((entry) => entry.term !== chip.term))} + /> + ))} +
+ ) : null} + = MAX_DICTATION_TERMS} + onAdd={(entry) => { + // A term already keys its own spelling, so a bare one needs no second entry. + const exists = terms.some( + (other) => + other.term === entry.term && (!entry.heardAs || other.heardAs === entry.heardAs) + ) + if (!exists) { + saveTerms([...terms, entry]) + } + }} + /> + + onUpdateVoiceSettings({ + dictationRepoNameHint: voiceSettings.dictationRepoNameHint === false + }) + } + /> +
+ ) +} + +type VocabularyChipModel = { term: string; aliases: string[] } + +// One chip per spelling: the stored list keeps one entry per "heard as". +function chipsBySpelling(terms: readonly DictationTerm[]): VocabularyChipModel[] { + const chips = new Map() + for (const { term, heardAs } of terms) { + const chip = chips.get(term) ?? { term, aliases: [] } + if (heardAs) { + chip.aliases.push(heardAs) + } + chips.set(term, chip) + } + return [...chips.values()] +} + +function VocabularyChip({ + chip, + onRemove +}: { + chip: VocabularyChipModel + onRemove: () => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
  • + {chip.term} + {chip.aliases.length > 0 ? ( + + {t('components.settings.voice.vocabulary.heardAsChip', '← {{heardAs}}', { + heardAs: chip.aliases.join(', ') + })} + + ) : null} + +
  • + ) +} + +function VocabularyAddRow({ + full, + onAdd +}: { + full: boolean + onAdd: (entry: DictationTerm) => void +}): React.JSX.Element { + const t = useTranslate() + const [term, setTerm] = useState('') + const [heardAs, setHeardAs] = useState('') + const canAdd = !full && term.trim() !== '' + + const add = (): void => { + if (!canAdd) { + return + } + onAdd({ term: term.trim(), ...(heardAs.trim() ? { heardAs: heardAs.trim() } : {}) }) + setTerm('') + setHeardAs('') + } + const addOnEnter = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter') { + event.preventDefault() + add() + } + } + + return ( +
    + setTerm(event.target.value)} + onKeyDown={addOnEnter} + /> + setHeardAs(event.target.value)} + onKeyDown={addOnEnter} + /> + +
    + ) +} diff --git a/src/renderer/src/components/settings/voice/voice-pane-search.ts b/src/renderer/src/components/settings/voice/voice-pane-search.ts index ff52e8b254c..55abb31d426 100644 --- a/src/renderer/src/components/settings/voice/voice-pane-search.ts +++ b/src/renderer/src/components/settings/voice/voice-pane-search.ts @@ -120,6 +120,24 @@ export const getVoicePaneSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.voice.pane.search.10d45a9fce', 'stt') ] }, + { + title: translate('auto.components.settings.voice.pane.search.vocabularyTitle', 'Vocabulary'), + description: translate( + 'auto.components.settings.voice.pane.search.vocabularyDescription', + 'Names and code terms dictation spells your way, for you or for one repo.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.voice.pane.search.7640ed9848', 'voice'), + ...translateSearchKeyword( + 'auto.components.settings.voice.pane.search.089d31a45b', + 'dictation' + ), + ...translateSearchKeyword( + 'auto.components.settings.voice.pane.search.vocabularyKeyword', + 'dictionary' + ) + ] + }, { title: translate( 'auto.components.settings.voice.pane.search.fillerWordsTitle', diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 454f252e06d..47e9692fe35 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -11299,7 +11299,10 @@ "modelMemoryKeyword": "speicher", "fillerWordsTitle": "Füllwörter entfernen", "fillerWordsDescription": "Entfernt „äh“, „ähm“ und ähnliche Laute aus diktiertem Text.", - "fillerWordsKeyword": "füllwörter" + "fillerWordsKeyword": "füllwörter", + "vocabularyTitle": "Wortschatz", + "vocabularyDescription": "Namen und Code-Begriffe, die das Diktat auf deine Weise schreibt, für dich oder ein Repo.", + "vocabularyKeyword": "wörterbuch" } } }, @@ -20746,6 +20749,24 @@ "local": "Auf diesem Computer", "cloudDescription": "Cloud-Modelle senden deine Audiodaten an den Anbieter. Modelle auf diesem Computer behalten sie hier.", "sendsToCompatible": "Sendet deine Audiodaten an den Dienst, den du eingerichtet hast." + }, + "vocabulary": { + "title": "Wortschatz", + "description": "Namen und Code-Begriffe, die du auf deine Weise geschrieben haben willst. Gib an, wie das Modell sie falsch hört, wenn es sie immer wieder verwechselt.", + "repoScope": "Nur beim Diktieren in {{repo}}.", + "personalScope": "In jedem Repo.", + "scope": "Begriffe für", + "personal": "Persönlich · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "{{term}} entfernen", + "term": "Wort oder Wendung", + "heardAs": "Gehört als (optional)", + "heardAsLabel": "Gehört als", + "add": "Hinzufügen", + "repoNameHint": "Repo-Namen als Hinweis verwenden", + "repoNameHintDescription": "Hilft, den Namen des Repos zu erkennen.", + "repoSaveFailed": "Die Begriffe des Repos konnten nicht gespeichert werden. Auf dem Host dieses Repos ist eventuell ein Orca-Update nötig." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fd3e66b2e85..c0bd1dd1335 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11635,7 +11635,10 @@ "modelMemoryKeyword": "memory", "fillerWordsTitle": "Remove Filler Words", "fillerWordsDescription": "Drop “um”, “uh” and similar sounds from dictated text.", - "fillerWordsKeyword": "filler words" + "fillerWordsKeyword": "filler words", + "vocabularyTitle": "Vocabulary", + "vocabularyDescription": "Names and code terms dictation spells your way, for you or for one repo.", + "vocabularyKeyword": "dictionary" } } }, @@ -21445,6 +21448,24 @@ "where": "Where models run", "local": "On this computer", "cloudDescription": "Cloud models send your audio to the provider. Models on this computer keep it here." + }, + "vocabulary": { + "title": "Vocabulary", + "description": "Names and code terms to spell your way. Add what the model mishears it as, if it keeps getting it wrong.", + "repoScope": "Only used while you dictate in {{repo}}.", + "personalScope": "Used in every repo.", + "scope": "Terms for", + "personal": "Personal · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "Remove {{term}}", + "term": "Word or phrase", + "heardAs": "Heard as (optional)", + "heardAsLabel": "Heard as", + "add": "Add", + "repoNameHint": "Use the repo name as a hint", + "repoNameHintDescription": "Helps recognize the repo's name.", + "repoSaveFailed": "Couldn't save the repo's terms. The host running this repo may need an Orca update." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 76d24032fdf..fdbe57e1fcb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10882,7 +10882,10 @@ "modelMemoryKeyword": "memoria", "fillerWordsTitle": "Quitar muletillas", "fillerWordsDescription": "Quita «ehm», «em» y sonidos parecidos del texto dictado.", - "fillerWordsKeyword": "muletillas" + "fillerWordsKeyword": "muletillas", + "vocabularyTitle": "Vocabulario", + "vocabularyDescription": "Nombres y términos de código que el dictado escribe a tu manera, para ti o para un repo.", + "vocabularyKeyword": "diccionario" } } }, @@ -20534,6 +20537,24 @@ "local": "En este equipo", "cloudDescription": "Los modelos en la nube envían tu audio al proveedor. Los de este equipo lo mantienen aquí.", "sendsToCompatible": "Envía tu audio al servicio que configuraste." + }, + "vocabulary": { + "title": "Vocabulario", + "description": "Nombres y términos de código que se escriben a tu manera. Añade cómo lo oye mal el modelo si se equivoca una y otra vez.", + "repoScope": "Solo se usa al dictar en {{repo}}.", + "personalScope": "Se usa en todos los repos.", + "scope": "Términos para", + "personal": "Personal · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "Quitar {{term}}", + "term": "Palabra o frase", + "heardAs": "Se oye como (opcional)", + "heardAsLabel": "Se oye como", + "add": "Añadir", + "repoNameHint": "Usar el nombre del repo como pista", + "repoNameHintDescription": "Ayuda a reconocer el nombre del repo.", + "repoSaveFailed": "No se pudieron guardar los términos del repo. Puede que el host de este repo necesite actualizar Orca." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5e54997ebbb..28f2e380f9a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -11167,7 +11167,10 @@ "modelMemoryKeyword": "mémoire", "fillerWordsTitle": "Supprimer les hésitations", "fillerWordsDescription": "Supprime « euh », « hum » et les sons similaires du texte dicté.", - "fillerWordsKeyword": "hésitations" + "fillerWordsKeyword": "hésitations", + "vocabularyTitle": "Vocabulaire", + "vocabularyDescription": "Noms et termes de code que la dictée écrit à votre façon, pour vous ou pour un dépôt.", + "vocabularyKeyword": "dictionnaire" } } }, @@ -20851,6 +20854,24 @@ "local": "Sur cet ordinateur", "cloudDescription": "Les modèles cloud envoient votre audio au fournisseur. Ceux de cet ordinateur le gardent ici.", "sendsToCompatible": "Envoie votre audio au service que vous avez configuré." + }, + "vocabulary": { + "title": "Vocabulaire", + "description": "Noms et termes de code à écrire à votre façon. Ajoutez ce que le modèle entend à la place s’il se trompe toujours.", + "repoScope": "Utilisé seulement quand vous dictez dans {{repo}}.", + "personalScope": "Utilisé dans tous les dépôts.", + "scope": "Termes pour", + "personal": "Personnel · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "Retirer {{term}}", + "term": "Mot ou expression", + "heardAs": "Entendu comme (facultatif)", + "heardAsLabel": "Entendu comme", + "add": "Ajouter", + "repoNameHint": "Utiliser le nom du dépôt comme indice", + "repoNameHintDescription": "Aide à reconnaître le nom du dépôt.", + "repoSaveFailed": "Impossible d’enregistrer les termes du dépôt. L’hôte de ce dépôt doit peut-être mettre Orca à jour." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6f309bdfab3..4d90f59f72d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10626,7 +10626,10 @@ "modelMemoryKeyword": "メモリ", "fillerWordsTitle": "フィラーを削除", "fillerWordsDescription": "「えーと」「うーん」などのつなぎ言葉を音声入力のテキストから取り除きます。", - "fillerWordsKeyword": "フィラー" + "fillerWordsKeyword": "フィラー", + "vocabularyTitle": "語彙", + "vocabularyDescription": "音声入力が自分の書き方で表記する名前やコード用語。個人用またはリポジトリ用。", + "vocabularyKeyword": "辞書" } } }, @@ -20027,6 +20030,24 @@ "local": "このコンピューター", "cloudDescription": "クラウドモデルは音声をプロバイダーに送信します。このコンピューター上のモデルなら音声は外に出ません。", "sendsToCompatible": "音声を設定したサービスに送信します。" + }, + "vocabulary": { + "title": "語彙", + "description": "名前やコード用語を自分の書き方で入力します。モデルが繰り返し聞き間違える場合は、聞き取られる言い方も追加してください。", + "repoScope": "{{repo}} で音声入力するときだけ使います。", + "personalScope": "すべてのリポジトリで使います。", + "scope": "用語の対象", + "personal": "個人 · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "{{term}} を削除", + "term": "単語またはフレーズ", + "heardAs": "聞き取られ方(任意)", + "heardAsLabel": "聞き取られ方", + "add": "追加", + "repoNameHint": "リポジトリ名をヒントとして使う", + "repoNameHintDescription": "リポジトリ名を認識しやすくします。", + "repoSaveFailed": "リポジトリの用語を保存できませんでした。このリポジトリのホストで Orca の更新が必要かもしれません。" } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 068db9b1fbd..db599a834a2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10589,7 +10589,10 @@ "modelMemoryKeyword": "메모리", "fillerWordsTitle": "군말 제거", "fillerWordsDescription": "받아쓴 텍스트에서 “음”, “흠” 같은 소리를 지웁니다.", - "fillerWordsKeyword": "군말" + "fillerWordsKeyword": "군말", + "vocabularyTitle": "어휘", + "vocabularyDescription": "받아쓰기가 원하는 대로 표기하는 이름과 코드 용어, 개인용 또는 저장소용.", + "vocabularyKeyword": "사전" } } }, @@ -20027,6 +20030,24 @@ "local": "이 컴퓨터", "cloudDescription": "클라우드 모델은 오디오를 제공업체로 보냅니다. 이 컴퓨터의 모델은 오디오를 밖으로 보내지 않습니다.", "sendsToCompatible": "오디오를 설정한 서비스로 보냅니다." + }, + "vocabulary": { + "title": "어휘", + "description": "원하는 대로 표기할 이름과 코드 용어입니다. 모델이 계속 잘못 들으면 잘못 들리는 말도 추가하세요.", + "repoScope": "{{repo}}에서 받아쓸 때만 사용합니다.", + "personalScope": "모든 저장소에서 사용합니다.", + "scope": "용어 대상", + "personal": "개인 · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "{{term}} 삭제", + "term": "단어 또는 구문", + "heardAs": "들리는 말(선택)", + "heardAsLabel": "들리는 말", + "add": "추가", + "repoNameHint": "저장소 이름을 힌트로 사용", + "repoNameHintDescription": "저장소 이름을 더 잘 인식하도록 돕습니다.", + "repoSaveFailed": "저장소 용어를 저장하지 못했습니다. 이 저장소의 호스트에서 Orca 업데이트가 필요할 수 있습니다." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 12065876a1f..1aad807a1d8 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -6597,7 +6597,10 @@ "modelMemoryKeyword": "memória", "fillerWordsTitle": "Remover hesitações", "fillerWordsDescription": "Remove “hã”, “hum” e sons parecidos do texto ditado.", - "fillerWordsKeyword": "hesitações" + "fillerWordsKeyword": "hesitações", + "vocabularyTitle": "Vocabulário", + "vocabularyDescription": "Nomes e termos de código que o ditado escreve do seu jeito, para você ou para um repositório.", + "vocabularyKeyword": "dicionário" } } }, @@ -21716,6 +21719,24 @@ "local": "Neste computador", "cloudDescription": "Modelos na nuvem enviam seu áudio ao provedor. Os deste computador o mantêm aqui.", "sendsToCompatible": "Envia seu áudio para o serviço que você configurou." + }, + "vocabulary": { + "title": "Vocabulário", + "description": "Nomes e termos de código escritos do seu jeito. Adicione como o modelo os ouve errado, se ele continuar errando.", + "repoScope": "Usado só ao ditar em {{repo}}.", + "personalScope": "Usado em todos os repositórios.", + "scope": "Termos para", + "personal": "Pessoal · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "Remover {{term}}", + "term": "Palavra ou frase", + "heardAs": "Ouvido como (opcional)", + "heardAsLabel": "Ouvido como", + "add": "Adicionar", + "repoNameHint": "Usar o nome do repositório como dica", + "repoNameHintDescription": "Ajuda a reconhecer o nome do repositório.", + "repoSaveFailed": "Não foi possível salvar os termos do repositório. O host deste repositório pode precisar de uma atualização do Orca." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 6cc23c88138..58e12c44554 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -11651,7 +11651,10 @@ "modelMemoryKeyword": "память", "fillerWordsTitle": "Удалять слова-паразиты", "fillerWordsDescription": "Убирает «э», «эм» и похожие звуки из надиктованного текста.", - "fillerWordsKeyword": "паразиты" + "fillerWordsKeyword": "паразиты", + "vocabularyTitle": "Словарь", + "vocabularyDescription": "Имена и термины из кода, которые диктовка пишет по-вашему, для вас или для репозитория.", + "vocabularyKeyword": "словарь" } } }, @@ -21433,6 +21436,24 @@ "local": "На этом компьютере", "cloudDescription": "Облачные модели отправляют звук провайдеру. Модели на этом компьютере оставляют его здесь.", "sendsToCompatible": "Отправляет звук в настроенный вами сервис." + }, + "vocabulary": { + "title": "Словарь", + "description": "Имена и термины из кода, которые нужно писать по-вашему. Если модель всё время ошибается, добавьте, как она их слышит.", + "repoScope": "Используется только при диктовке в {{repo}}.", + "personalScope": "Используется во всех репозиториях.", + "scope": "Термины для", + "personal": "Личные · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "Удалить {{term}}", + "term": "Слово или фраза", + "heardAs": "Слышится как (необязательно)", + "heardAsLabel": "Слышится как", + "add": "Добавить", + "repoNameHint": "Использовать имя репозитория как подсказку", + "repoNameHintDescription": "Помогает распознавать имя репозитория.", + "repoSaveFailed": "Не удалось сохранить термины репозитория. Возможно, на хосте этого репозитория нужно обновить Orca." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 002982551d7..0893ffd23e7 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -11283,7 +11283,10 @@ "modelMemoryKeyword": "bellek", "fillerWordsTitle": "Dolgu seslerini kaldır", "fillerWordsDescription": "Dikte edilen metinden “hmm” gibi duraksama seslerini çıkarır.", - "fillerWordsKeyword": "dolgu" + "fillerWordsKeyword": "dolgu", + "vocabularyTitle": "Sözcük dağarcığı", + "vocabularyDescription": "Diktenin senin yazımınla yazdığı adlar ve kod terimleri, senin için veya bir depo için.", + "vocabularyKeyword": "sözlük" } } }, @@ -20728,6 +20731,24 @@ "local": "Bu bilgisayarda", "cloudDescription": "Bulut modelleri sesinizi sağlayıcıya gönderir. Bu bilgisayardaki modeller sesi burada tutar.", "sendsToCompatible": "Sesinizi kurduğunuz hizmete gönderir." + }, + "vocabulary": { + "title": "Sözcük dağarcığı", + "description": "Kendi yazımınla yazılacak adlar ve kod terimleri. Model sürekli yanlış duyuyorsa nasıl duyduğunu da ekle.", + "repoScope": "Yalnızca {{repo}} içinde dikte ederken kullanılır.", + "personalScope": "Her depoda kullanılır.", + "scope": "Terimler", + "personal": "Kişisel · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "{{term}} kaldır", + "term": "Sözcük veya ifade", + "heardAs": "Duyulan biçim (isteğe bağlı)", + "heardAsLabel": "Duyulan biçim", + "add": "Ekle", + "repoNameHint": "Depo adını ipucu olarak kullan", + "repoNameHintDescription": "Depo adının tanınmasına yardımcı olur.", + "repoSaveFailed": "Deponun terimleri kaydedilemedi. Bu deponun ana makinesinde Orca güncellemesi gerekebilir." } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 11bd936a4f6..b04ad95e54d 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -11120,7 +11120,10 @@ "modelMemoryKeyword": "記憶體", "fillerWordsTitle": "移除語氣詞", "fillerWordsDescription": "從聽寫文字中移除「嗯」「呃」之類的語氣詞。", - "fillerWordsKeyword": "語氣詞" + "fillerWordsKeyword": "語氣詞", + "vocabularyTitle": "詞彙", + "vocabularyDescription": "聽寫依你的寫法拼寫的名稱和程式碼術語,可用於個人或某個儲存庫。", + "vocabularyKeyword": "詞典" } } }, @@ -20429,6 +20432,24 @@ "local": "這台電腦", "cloudDescription": "雲端模型會把你的音訊傳送給服務商。本機模型會把音訊留在這台電腦上。", "sendsToCompatible": "會把你的音訊傳送到你設定的服務。" + }, + "vocabulary": { + "title": "詞彙", + "description": "依你的寫法拼寫的名稱和程式碼術語。如果模型總是聽錯,可以加上它聽成的說法。", + "repoScope": "僅在 {{repo}} 中聽寫時使用。", + "personalScope": "在所有儲存庫中使用。", + "scope": "術語範圍", + "personal": "個人 · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "移除 {{term}}", + "term": "詞語或片語", + "heardAs": "聽成(選填)", + "heardAsLabel": "聽成", + "add": "新增", + "repoNameHint": "將儲存庫名稱用作提示", + "repoNameHintDescription": "幫助辨識儲存庫名稱。", + "repoSaveFailed": "無法儲存儲存庫的術語。執行此儲存庫的主機可能需要更新 Orca。" } }, "repositoryHooks": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 31c5adba076..8985678ae5f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10589,7 +10589,10 @@ "modelMemoryKeyword": "内存", "fillerWordsTitle": "移除语气词", "fillerWordsDescription": "从听写文本中去掉“嗯”“呃”之类的语气词。", - "fillerWordsKeyword": "语气词" + "fillerWordsKeyword": "语气词", + "vocabularyTitle": "词汇", + "vocabularyDescription": "听写按你的写法拼写的名称和代码术语,可用于个人或某个仓库。", + "vocabularyKeyword": "词典" } } }, @@ -20026,6 +20029,24 @@ "local": "这台电脑", "cloudDescription": "云端模型会把你的音频发送给服务商。本机模型会把音频留在这台电脑上。", "sendsToCompatible": "会把你的音频发送到你设置的服务。" + }, + "vocabulary": { + "title": "词汇", + "description": "按你的写法拼写的名称和代码术语。如果模型总是听错,可以加上它听成的说法。", + "repoScope": "仅在 {{repo}} 中听写时使用。", + "personalScope": "在所有仓库中使用。", + "scope": "术语范围", + "personal": "个人 · {{total}}", + "repo": "{{repo}} · {{total}}", + "heardAsChip": "← {{heardAs}}", + "remove": "移除 {{term}}", + "term": "词语或短语", + "heardAs": "听成(可选)", + "heardAsLabel": "听成", + "add": "添加", + "repoNameHint": "将仓库名用作提示", + "repoNameHintDescription": "帮助识别仓库名称。", + "repoSaveFailed": "无法保存仓库的术语。运行此仓库的主机可能需要更新 Orca。" } }, "repositoryHooks": { diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 3a7604b3831..995331a18a7 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -158,6 +158,14 @@ export function createRepoUpdateActions( ) { return false } + // A host from before dictionaries drops the field and still answers with its row. + if ( + sanitizedUpdates.dictationTerms !== undefined && + JSON.stringify(updatedRepo?.dictationTerms ?? []) !== + JSON.stringify(sanitizedUpdates.dictationTerms) + ) { + return false + } set((s) => { const nextRepos = s.repos.map((r) => { const matchesOwner = ownerHasExplicitHost diff --git a/src/shared/dictation-dictionary.test.ts b/src/shared/dictation-dictionary.test.ts index 1e81f897337..f7b159cd011 100644 --- a/src/shared/dictation-dictionary.test.ts +++ b/src/shared/dictation-dictionary.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { createDictationDictionary, mergeDictationTerms } from './dictation-dictionary' +import { + createDictationDictionary, + dictationTermsFor, + dictationVocabulary +} from './dictation-dictionary' import { createDictationTextPipeline } from './dictation-text-pipeline' const spell = (terms: Parameters[0], text: string): string => @@ -55,8 +59,21 @@ describe('createDictationDictionary', () => { ) }) + it('lets the repo name join its spoken words but never recase a single word', () => { + const oneWord = dictationTermsFor({}, { displayName: 'orca' }) + const words = dictationTermsFor({}, { displayName: 'dd-scratch-repo' }) + + expect(createDictationDictionary(oneWord)).toBeNull() + expect(dictationVocabulary(oneWord)).toEqual(['orca']) + expect(spell(words, 'Open dd scratch repo now.')).toBe('Open dd-scratch-repo now.') + expect(dictationTermsFor({ dictationRepoNameHint: false }, { displayName: 'orca' })).toEqual([]) + }) + it("prefers a repo's spelling over a personal one", () => { - const terms = mergeDictationTerms([{ term: 'RPC' }], [{ term: 'rpc' }]) + const terms = dictationTermsFor( + { dictationTerms: [{ term: 'rpc' }] }, + { dictationTerms: [{ term: 'RPC' }] } + ) expect(spell(terms, 'Call the rpc.')).toBe('Call the RPC.') }) }) diff --git a/src/shared/dictation-dictionary.ts b/src/shared/dictation-dictionary.ts index a66d3f5785b..ad448055809 100644 --- a/src/shared/dictation-dictionary.ts +++ b/src/shared/dictation-dictionary.ts @@ -1,5 +1,8 @@ -/** A word or phrase dictation should spell `term`'s way, optionally also replacing what the model hears instead. */ -export type DictationTerm = { term: string; heardAs?: string } +/** + * A word or phrase dictation should spell `term`'s way, optionally also replacing what the model hears instead. + * `hint` marks the repo name: it biases recognition and joins its words, but never recases a single word. + */ +export type DictationTerm = { term: string; heardAs?: string; hint?: true } export const MAX_DICTATION_TERMS = 500 export const MAX_DICTATION_TERM_LENGTH = 100 @@ -20,32 +23,49 @@ function wordKey(text: string): string { return (text.normalize('NFKC').toLowerCase().match(WORD_RE) ?? []).join('') } -/** Well-formed entries only, trimmed and capped; undefined when the value isn't a list. */ +/** Well-formed, distinct entries only, trimmed and capped; undefined when the value isn't a list. */ export function normalizeDictationTerms(value: unknown): DictationTerm[] | undefined { if (!Array.isArray(value)) { return undefined } const terms: DictationTerm[] = [] + const seen = new Set() for (const entry of value) { const term: unknown = entry?.term const heardAs: unknown = entry?.heardAs if (typeof term !== 'string' || !term.trim()) { continue } - const alias = typeof heardAs === 'string' ? heardAs.trim() : '' + const spelling = term.trim().slice(0, MAX_DICTATION_TERM_LENGTH) + const alias = + typeof heardAs === 'string' ? heardAs.trim().slice(0, MAX_DICTATION_TERM_LENGTH) : '' + // The same spelling and alias twice is one stored entry. + const id = `${spelling}\u0000${alias}` + if (seen.has(id)) { + continue + } + seen.add(id) terms.push({ - term: term.trim().slice(0, MAX_DICTATION_TERM_LENGTH), - ...(alias ? { heardAs: alias.slice(0, MAX_DICTATION_TERM_LENGTH) } : {}) + term: spelling, + ...(alias ? { heardAs: alias } : {}) }) } return terms.slice(0, MAX_DICTATION_TERMS) } -/** Repo terms first, so a repo's spelling wins over a personal one for the same words. */ -export function mergeDictationTerms(repoTerms: unknown, personalTerms: unknown): DictationTerm[] { +/** + * The dictionary for a dictation into `repo` (none for a folder workspace): its terms first, so a repo's + * spelling wins over a personal one, then the personal terms, then its name as a hint unless that's off. + */ +export function dictationTermsFor( + voice: { dictationTerms?: unknown; dictationRepoNameHint?: boolean } | undefined, + repo: { dictationTerms?: unknown; displayName?: string } | undefined +): DictationTerm[] { + const name = repo?.displayName?.trim().slice(0, MAX_DICTATION_TERM_LENGTH) return [ - ...(normalizeDictationTerms(repoTerms) ?? []), - ...(normalizeDictationTerms(personalTerms) ?? []) + ...(normalizeDictationTerms(repo?.dictationTerms) ?? []), + ...(normalizeDictationTerms(voice?.dictationTerms) ?? []), + ...(name && voice?.dictationRepoNameHint !== false ? [{ term: name, hint: true as const }] : []) ] } @@ -75,8 +95,11 @@ export function createDictationDictionary( add(wordKey(heardAs), term) } } - for (const { term } of terms ?? []) { - add(wordKey(term), term) + for (const { term, hint } of terms ?? []) { + // A one-word repo name like "orca" would recase every "Orca"; only a name of several words joins. + if (!hint || (term.match(WORD_RE)?.length ?? 0) > 1) { + add(wordKey(term), term) + } } if (byKey.size === 0 && unspacedAliases.length === 0) { return null diff --git a/src/shared/speech-types.ts b/src/shared/speech-types.ts index bedd641f99f..dafcdfeba76 100644 --- a/src/shared/speech-types.ts +++ b/src/shared/speech-types.ts @@ -132,10 +132,13 @@ export type VoiceSettings = { speechGpu?: SpeechGpuMode /** Personal dictionary, used in every repo; each repo adds its own Repo.dictationTerms. */ dictationTerms?: DictationTerm[] + /** Adds the current repo's name as a hint (DictationTerm.hint). Absent = on. */ + dictationRepoNameHint?: boolean } /** Where a dictation goes: an engine in the desktop's own process takes repoTerms, a host resolves worktreeId itself. */ export type DictationContext = { worktreeId?: string repoTerms?: DictationTerm[] + repoName?: string } From 854928969e2d5a7251cfba06bea708d401350a75 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:19:11 +0000 Subject: [PATCH 067/138] feat(mobile): add Shift, Ctrl and Alt latches and opt-in agent keys to the terminal key row (#38776) * feat(mobile): smart rail keys for the terminal agent's state, with Shift/Ctrl/Alt latches * fix(mobile): cancel key presses only when the key row really moves * fix(mobile): keep Smart rail on through Reset Defaults and finish two-write keys * fix(mobile): only a finger scrolling the key row cancels its presses * fix(mobile): hold the smart group until the tap's press lands, and drop clicks on keys that moved * refactor(mobile): simplify the smart rail touch hook and layout read * chore(mobile): allowlist the Shift, Ctrl and Alt key names * feat(mobile): Codex async question keys on the smart rail * fix(mobile): drop the Codex Always key, whose key differs by approval Codex binds "don't ask again" to p for a command-prefix rule and a for session and file approvals (0.136 through 0.166). The permission hook doesn't say which one is on screen. --- ...obile-localization-coverage-allowlist.json | 21 ++ .../src/components/CustomKeyModal.styles.ts | 3 + mobile/src/components/CustomKeyModal.tsx | 10 +- mobile/src/components/SmartRailRow.tsx | 24 +++ .../components/TerminalShortcutSettings.tsx | 22 +- mobile/src/i18n/locales/en.json | 23 ++ .../src/session/MobileSessionCommandDock.tsx | 10 +- mobile/src/session/MobileSessionSheets.tsx | 3 + .../session/use-mobile-session-lifecycle.ts | 21 +- .../use-mobile-session-screen-state.ts | 3 + .../session/MobileSessionAccessoryKeys.tsx | 163 +++++++++++--- .../session/MobileSessionLatchKeys.tsx | 58 +++++ .../session/MobileSessionSmartKeys.tsx | 112 ++++++++++ .../session/mobile-terminal-tab-agent.ts | 21 ++ .../terminal/session/terminal-dock-styles.ts | 54 +++++ .../session/use-terminal-dock-keys.ts | 6 +- .../session/use-terminal-key-row-touch.ts | 141 ++++++++++++ .../src/terminal/terminal-accessory-layout.ts | 69 ++++-- .../terminal/input/agent-smart-keys.test.ts | 71 +++++++ src/shared/terminal/input/agent-smart-keys.ts | 200 ++++++++++++++++++ 20 files changed, 967 insertions(+), 68 deletions(-) create mode 100644 mobile/src/components/SmartRailRow.tsx create mode 100644 mobile/src/terminal/session/MobileSessionLatchKeys.tsx create mode 100644 mobile/src/terminal/session/MobileSessionSmartKeys.tsx create mode 100644 mobile/src/terminal/session/use-terminal-key-row-touch.ts create mode 100644 src/shared/terminal/input/agent-smart-keys.test.ts create mode 100644 src/shared/terminal/input/agent-smart-keys.ts diff --git a/config/mobile-localization-coverage-allowlist.json b/config/mobile-localization-coverage-allowlist.json index 50d8243f0f7..265dd13cf28 100644 --- a/config/mobile-localization-coverage-allowlist.json +++ b/config/mobile-localization-coverage-allowlist.json @@ -307,6 +307,27 @@ "dynamic": false, "count": 1 }, + { + "filePath": "mobile/src/terminal/session/MobileSessionLatchKeys.tsx", + "kind": "object-property:label", + "text": "Shift", + "dynamic": false, + "count": 1 + }, + { + "filePath": "mobile/src/terminal/session/MobileSessionLatchKeys.tsx", + "kind": "object-property:label", + "text": "Ctrl", + "dynamic": false, + "count": 1 + }, + { + "filePath": "mobile/src/terminal/session/MobileSessionLatchKeys.tsx", + "kind": "object-property:label", + "text": "Alt", + "dynamic": false, + "count": 1 + }, { "filePath": "mobile/src/tasks/mobile-smart-source-modes.ts", "kind": "object-property:label", diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts index a95b5abe3be..2921025e632 100644 --- a/mobile/src/components/CustomKeyModal.styles.ts +++ b/mobile/src/components/CustomKeyModal.styles.ts @@ -41,6 +41,9 @@ export const customKeyModalStyles = themedStyleSheet(() => ({ borderRadius: radii.surface, overflow: 'hidden' }, + smartRailGroup: { + marginTop: spacing.md + }, shortcutForm: { paddingTop: spacing.sm }, diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index b6dfac3cdc6..42afc17fd3c 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -19,6 +19,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { MobileSwitch } from './MobileSwitch' import { ListGroup, ListRow } from './list/ListGroup' import { FieldTextInput } from './composer-input/FieldTextInput' +import { SmartRailRow } from './SmartRailRow' type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro' @@ -62,9 +63,11 @@ type Props = { onClose: () => void onKeysChanged: (keys: CustomKey[]) => void onManageShortcuts?: () => void + smartRail?: { enabled: boolean; onChange: (enabled: boolean) => void } } -export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) { +export function CustomKeyModal(props: Props) { + const { visible, onClose, onKeysChanged, onManageShortcuts, smartRail } = props useMobileColorScheme() const t = useMobileTranslation() const [step, setStep] = useState('choose-type') @@ -235,6 +238,11 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc ) : null} )} + {step === 'choose-type' && smartRail ? ( + + + + ) : null} {step === 'shortcut-combo' && ( diff --git a/mobile/src/components/SmartRailRow.tsx b/mobile/src/components/SmartRailRow.tsx new file mode 100644 index 00000000000..8789fcd0f42 --- /dev/null +++ b/mobile/src/components/SmartRailRow.tsx @@ -0,0 +1,24 @@ +import { ListRow } from './list/ListGroup' +import { MobileSwitch } from './MobileSwitch' +import { useMobileTranslation } from '../i18n/use-mobile-translation' + +/** The Smart rail setting: keys for what the terminal's agent is doing, at the front of the key row. */ +export function SmartRailRow({ + enabled, + onChange +}: { + enabled: boolean + onChange: (enabled: boolean) => void +}): React.JSX.Element { + const t = useMobileTranslation() + const title = t('components.smartRailRow.title', 'Smart rail') + return ( + + } + /> + ) +} diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx index db5bfe5b64e..37e14d0620f 100644 --- a/mobile/src/components/TerminalShortcutSettings.tsx +++ b/mobile/src/components/TerminalShortcutSettings.tsx @@ -27,6 +27,7 @@ import { setTerminalAccessoryBuiltInVisible } from '../terminal/terminal-accessory-layout' import { MobileSwitch } from './MobileSwitch' +import { SmartRailRow } from './SmartRailRow' // Absolutely positioned DragReorderList rows require one fixed height per section. const REORDER_ROW_HEIGHT = 56 @@ -148,10 +149,13 @@ export function TerminalShortcutSettings({ [persistLayout] ) + // Resetting the keys leaves the Smart rail setting alone. const resetBuiltInKeys = useCallback(() => { - const next = defaultLayout() - setShortcutLayout(next) - persistLayout(next) + setShortcutLayout((current) => { + const next = { ...defaultLayout(), smartRail: current.smartRail } + persistLayout(next) + return next + }) }, [defaultLayout, persistLayout]) const reorderCustomKeys = useCallback( @@ -182,6 +186,18 @@ export function TerminalShortcutSettings({ return ( <> + + + setShortcutLayout((current) => { + const next = { ...current, smartRail: enabled } + persistLayout(next) + return next + }) + } + /> + {hasPad ? ( { - // A ctrl/alt/shift held on the Duo rail turns the next typed letter into a combo, while the rail shows it. - if ( - !keysInRail || - !dockKeys.interceptTypedCharacter(text, event.nativeEvent.text, liveInputRef) - ) { + // A held Shift, Ctrl or Alt on the key row or the Duo rail turns the next typed letter into a combo. + if (!dockKeys.interceptTypedCharacter(text, event.nativeEvent.text, liveInputRef)) { handleLiveInputChange(event) } }} diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx index 027af33cf82..fe6f590a3d2 100644 --- a/mobile/src/session/MobileSessionSheets.tsx +++ b/mobile/src/session/MobileSessionSheets.tsx @@ -77,6 +77,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC confirmDiscardMarkdown, handleDeleteCustomKey, handleManageShortcuts, + smartRailEnabled, + handleSmartRailChange, handleClearTerminal, handleCreateTerminal, launchQuickCommand, @@ -363,6 +365,7 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC onClose={() => setShowCustomKeyModal(false)} onKeysChanged={setCustomKeys} onManageShortcuts={handleManageShortcuts} + smartRail={{ enabled: smartRailEnabled, onChange: handleSmartRailChange }} /> { + // A read that started before the toggle must not flip it back. + layoutReadRevisionRef.current += 1 + setSmartRailEnabled(enabled) + saveTerminalSmartRail(enabled, padAvailable).catch((error: unknown) => { + console.warn('[terminal-keys] the store would not take the Smart rail setting', error) + setSmartRailEnabled(!enabled) + }) + }, + [padAvailable] + ) + useFocusEffect( useCallback(() => { const releaseKeys = refreshCustomKeys() @@ -195,6 +213,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM }, [connState, scheduleDelayedAction, subscribeToTerminal, terminalResume, unsubscribeTerminal]) return { accessoryPreferencesReady: customKeysLoaded && accessoryLayoutLoaded, + handleSmartRailChange, pendingForegroundRecoveryRef } } diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts index eb0f7d28b74..bde653c4045 100644 --- a/mobile/src/session/use-mobile-session-screen-state.ts +++ b/mobile/src/session/use-mobile-session-screen-state.ts @@ -82,6 +82,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) const [renameTarget, setRenameTarget] = useState(null) const [customKeys, setCustomKeys] = useState([]) const [directionPadEnabled, setDirectionPadEnabled] = useState(true) + const [smartRailEnabled, setSmartRailEnabled] = useState(false) const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( getDefaultTerminalAccessoryBuiltInIds ) @@ -182,6 +183,8 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) setCustomKeys, directionPadEnabled, setDirectionPadEnabled, + smartRailEnabled, + setSmartRailEnabled, visibleBuiltInIds, setVisibleBuiltInIds, showCustomKeyModal, diff --git a/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx b/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx index f88d4d336c7..737327fab48 100644 --- a/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx +++ b/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx @@ -21,6 +21,17 @@ import { styles as sessionStyles } from '../../session/mobile-session-styles' import { useKeyboardPersistingTaps } from '../../platform/keyboard-persisting-taps' import type { MobileSessionController } from '../../session/use-mobile-session-controller' import { useMobileSessionKeyboardLift } from './use-mobile-session-keyboard-lift' +import { + AGENT_SMART_KEY_STEP_MS, + type AgentSmartKey +} from '../../../../src/shared/terminal/input/agent-smart-keys' +import type { TerminalAccessoryKey } from '../terminal-accessory-keys' +import { resolveMobileTerminalTabSmartKeys } from './mobile-terminal-tab-agent' +import { MobileSessionSmartKeys } from './MobileSessionSmartKeys' +import { MobileSessionLatchKeys } from './MobileSessionLatchKeys' +import { isAccessibilityClick, useTerminalKeyRowTouch } from './use-terminal-key-row-touch' + +type PreparedPress = ReturnType // The terminal's key row above the live input; every key goes straight to the terminal. export function MobileSessionAccessoryKeys({ @@ -46,10 +57,26 @@ export function MobileSessionAccessoryKeys({ stopAccessoryRepeat, cancelAccessoryRepeat, prepareAccessoryPress, - handlePaste + handlePaste, + dockKeys, + activeSessionTab, + terminals, + smartRailEnabled } = controller const keyboardLift = useMobileSessionKeyboardLift(controller.dockBottomInset) - const accessoryPresses = useRef(new Map>()) + const accessoryPresses = useRef(new Map()) + const scrollRef = useRef(null) + const latestSmartKeys = useMemo( + () => + smartRailEnabled ? resolveMobileTerminalTabSmartKeys(activeSessionTab, terminals) : null, + [smartRailEnabled, activeSessionTab, terminals] + ) + const touch = useTerminalKeyRowTouch({ + scrollRef, + latest: latestSmartKeys, + cancelAccessoryRepeat + }) + const smartKeys = touch.shown const keepsKeyboard = useKeyboardPersistingTaps('always') // An unmounting Pressable never fires onPressOut, so a held key would outlive its row. useEffect(() => cancelAccessoryRepeat, [cancelAccessoryRepeat]) @@ -70,8 +97,57 @@ export function MobileSessionAccessoryKeys({ ] const sendKeyLabel = (label: string) => t('session.accessoryKeys.sendKey', 'Send {{label}}', { label }) + // Held Shift, Ctrl or Alt turn a key into its combo and are used up when it sends; keys that take none send as they are. + const prepareBuiltInPress = (key: TerminalAccessoryKey): PreparedPress => { + const chord = dockKeys.chordInput(key.id) + const press = prepareAccessoryPress(chord ?? createTerminalLiveAccessoryInput(key)) + const takeChord = () => { + if (chord) { + dockKeys.takeModifiers() + } + } + return { + tap: () => { + takeChord() + return press.tap() + }, + hold: (firstRepeatDelayMs) => { + takeChord() + press.hold(firstRepeatDelayMs) + } + } + } + // Later writes belong to the same press, so a scroll after release mustn't drop them; a tab switch does. + const sendSmartKey = (key: AgentSmartKey) => { + const handle = controller.activeHandleRef.current + void (async () => { + for (const [index, bytes] of key.writes.entries()) { + if (index > 0) { + await new Promise((resolve) => setTimeout(resolve, AGENT_SMART_KEY_STEP_MS)) + } + if (controller.activeHandleRef.current !== handle) { + return + } + if (!(await prepareAccessoryPress({ bytes }).tap())) { + return + } + } + })() + } + const latchIndex = builtInKeys.findIndex((key) => key.id === 'tab') + 1 + const latchKeys = ( + touch.arm('latch')} + onPress={(modifier, event) => + touch.fire('latch', event, () => dockKeys.tapModifier(modifier)) + } + /> + ) return ( - + {/* Why: fixed keyboard escape hatch; outside the ScrollView so it can't scroll away (#5106). */} {keyboardLift > 0 && ( + {smartKeys ? ( + touch.arm(`smart:${key.id}`)} + onPress={(key, event) => touch.fire(`smart:${key.id}`, event, () => sendSmartKey(key))} + /> + ) : null} { - if (activeHandle) { - void toggleDisplayMode(activeHandle) - } - }} + onPressIn={() => touch.arm('displayMode')} + onPress={(event) => + touch.fire('displayMode', event, () => { + if (activeHandle) { + void toggleDisplayMode(activeHandle) + } + }) + } accessibilityRole="button" accessibilityLabel={ phoneDisplay @@ -133,7 +222,8 @@ export function MobileSessionAccessoryKeys({ style={keyStyle} disabled={!canSend} hitSlop={TERMINAL_KEY_HIT_SLOP} - onPress={() => void handlePaste()} + onPressIn={() => touch.arm('paste')} + onPress={(event) => touch.fire('paste', event, () => void handlePaste())} accessibilityRole="button" accessibilityLabel={t( 'session.accessoryKeys.pasteFromClipboard', @@ -143,20 +233,22 @@ export function MobileSessionAccessoryKeys({ {t('session.accessoryKeys.paste', 'Paste')} )} - {builtInKeys.map((key) => ( + {latchIndex === 0 ? latchKeys : null} + {builtInKeys.flatMap((key, index) => [ { + touch.arm(key.id) if (!key.repeatable) { return } - const press = prepareAccessoryPress(createTerminalLiveAccessoryInput(key)) - accessoryPresses.current.set(key.id, press) + const prepared = prepareBuiltInPress(key) + accessoryPresses.current.set(key.id, prepared) if (event.type === 'keydown') { - press.hold(TERMINAL_ACCESSORY_REPEAT_DELAY_MS) + prepared.hold(TERMINAL_ACCESSORY_REPEAT_DELAY_MS) } }} onPressOut={() => { @@ -165,23 +257,23 @@ export function MobileSessionAccessoryKeys({ } }} onPress={(event) => { - if (key.repeatable) { - // Accessibility clicks have no touch-down to prepare a sender. - const accessibilityClick = - event.type === 'click' && - 'detail' in event.nativeEvent && - event.nativeEvent.detail === 0 - const press = - (!accessibilityClick && accessoryPresses.current.get(key.id)) || - prepareAccessoryPress(createTerminalLiveAccessoryInput(key)) - accessoryPresses.current.delete(key.id) - void press.tap() - return - } - void handleAccessoryKey(createTerminalLiveAccessoryInput(key)) + // Accessibility clicks have no touch-down to prepare a sender. + const prepared = + (key.repeatable && + !isAccessibilityClick(event) && + accessoryPresses.current.get(key.id)) || + prepareBuiltInPress(key) + accessoryPresses.current.delete(key.id) + touch.fire(key.id, event, () => void prepared.tap()) }} onLongPress={ - key.repeatable ? () => accessoryPresses.current.get(key.id)?.hold() : undefined + key.repeatable + ? () => { + if (touch.stillArmed(key.id)) { + accessoryPresses.current.get(key.id)?.hold() + } + } + : undefined } delayLongPress={TERMINAL_ACCESSORY_REPEAT_DELAY_MS} // A web