From 09378ef5e409be8e580a36f2314dd35d4b490878 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:29:36 -0700 Subject: [PATCH] refactor: split the four files the merge pushed over max-lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both sides added to each of these, so the merged file cleared its cap. Split rather than raise the cap. - `agent-status-types.ts` -> `agent-subagent-snapshot.ts` takes the subagent roster: its two types, the per-entry cap, and the structural equality stores use to skip fanout. - `stable-logical-rpc-client.ts` -> `logical-client-cutover-error.ts` takes the cutover error. Several modules that retry on it are imported BY the client, so the error belonging to the client was already the wrong way round. - `tui-agent-startup.test.ts` -> `tui-agent-startup-hermes.test.ts`. Hermes is the one agent whose launch Orca rewrites token by token; the two shell-script decoders both files need move to a `.test-fixture`. - the mobile session screen -> `DiffLineRow.tsx`. The row owns a whole comment-draft interaction and reads `styles` from a module already split out. Also two things the root typecheck could not see, because it does not cover mobile and because the lane's import predated a main-side split: - `registerActiveFailure` still assigned the raw `consecutiveFailures` field main replaced with `RelayRecoveryFailureCount`. A structured reconnect signal recomputes the streak rather than adding to it, so the counter gains `recomputeTo` and the assignment routes through it — the recovery-attempt reporter sees the change, which a bare field write did not do. - the typing indicator imported `isCommandMarkerId` from `native-chat-pending` after main moved it to `native-chat-command-marker`. --- .../app/h/[hostId]/session/[worktreeId].tsx | 141 +--------- .../dictation/mobile-dictation-setup.test.ts | 2 +- .../src/dictation/mobile-dictation-setup.ts | 2 +- mobile/src/session/DiffLineRow.tsx | 150 +++++++++++ .../session/mobile-clipboard-image.test.ts | 2 +- mobile/src/session/mobile-clipboard-image.ts | 2 +- .../session/mobile-native-chat-send.test.ts | 2 +- mobile/src/session/mobile-native-chat-send.ts | 2 +- .../mobile-session-tab-activation.test.ts | 2 +- .../session/mobile-session-tab-activation.ts | 2 +- .../session/mobile-structured-outbox-entry.ts | 2 +- .../session/use-mobile-native-chat-stop.ts | 2 +- ...use-mobile-structured-session-mutations.ts | 2 +- mobile/src/session/use-quick-commands.test.ts | 2 +- mobile/src/session/use-quick-commands.ts | 2 +- .../tasks/worktree-create-capability.test.ts | 2 +- .../src/tasks/worktree-create-capability.ts | 2 +- .../src/tasks/worktree-create-retry.test.ts | 2 +- mobile/src/tasks/worktree-create-retry.ts | 2 +- .../transport/logical-client-cutover-error.ts | 17 ++ .../mobile-relay-reconnect-controller.ts | 4 +- .../transport/relay-recovery-failure-count.ts | 7 + .../runtime-capability-probe.test.ts | 2 +- .../src/transport/runtime-capability-probe.ts | 2 +- .../stable-logical-rpc-client.test.ts | 7 +- .../transport/stable-logical-rpc-client.ts | 15 +- .../worktree/home-host-worktree-fetch.test.ts | 6 +- .../src/worktree/home-host-worktree-fetch.ts | 2 +- .../restored-subagent-liveness-sweep.test.ts | 2 +- ...-claude-child-permission-lifecycle.test.ts | 2 +- .../agent-hook-envelope-publication.test.ts | 2 +- ...ay-oversized-notification-survival.test.ts | 8 +- .../native-chat-typing-indicator.ts | 2 +- src/renderer/src/store/slices/agent-status.ts | 13 +- ...ent-hook-listener-claude-subagents.test.ts | 2 +- ...listener-startless-child-lifecycle.test.ts | 2 +- src/shared/agent-hook-listener.ts | 9 +- src/shared/agent-hook-relay.ts | 3 +- src/shared/agent-status-types.test.ts | 18 +- src/shared/agent-status-types.ts | 50 +--- src/shared/agent-subagent-snapshot.ts | 52 ++++ .../claude-background-task-inventory.ts | 2 +- .../claude-background-task-status.test.ts | 2 +- src/shared/claude-subagent-roster.test.ts | 2 +- src/shared/claude-subagent-roster.ts | 2 +- src/shared/codex-subagent-roster.test.ts | 7 +- src/shared/codex-subagent-roster.ts | 9 +- src/shared/tui-agent-startup-hermes.test.ts | 230 +++++++++++++++++ .../tui-agent-startup-script.test-fixture.ts | 24 ++ src/shared/tui-agent-startup.test.ts | 240 +----------------- 50 files changed, 539 insertions(+), 531 deletions(-) create mode 100644 mobile/src/session/DiffLineRow.tsx create mode 100644 mobile/src/transport/logical-client-cutover-error.ts create mode 100644 src/shared/agent-subagent-snapshot.ts create mode 100644 src/shared/tui-agent-startup-hermes.test.ts create mode 100644 src/shared/tui-agent-startup-script.test-fixture.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 2e162112604..06ce4a24185 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -287,6 +287,7 @@ import { import { colors } from '../../../../src/theme/mobile-theme' import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' import { styles } from '../../../../src/session/mobile-session-styles' +import { DiffLineRow } from '../../../../src/session/DiffLineRow' import type { DiffComment } from '../../../../../src/shared/diff-comment-types' import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types' import type { @@ -312,146 +313,6 @@ import type { const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 -function DiffLineRow({ - line, - title, - index, - comments, - activeCommentLine, - commentDraft, - commentsBusy, - onStartComment, - onCancelComment, - onDraftChange, - onSubmitComment, - onDeleteComment -}: { - line: RenderableDiffLine - title: string - index: number - comments: DiffComment[] - activeCommentLine: number | null - commentDraft: string - commentsBusy: boolean - onStartComment: (lineNumber: number) => void - onCancelComment: () => void - onDraftChange: (value: string) => void - onSubmitComment: (lineNumber: number) => void - onDeleteComment: (commentId: string) => void -}) { - const commentLine = line.newLineNumber - const isCommenting = commentLine !== undefined && activeCommentLine === commentLine - const canComment = commentLine !== undefined - // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter. - const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? '' - return ( - - - {gutterLineNumber} - - - {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} - - - - {canComment ? ( - [ - styles.diffCommentAddButton, - pressed && styles.diffCommentAddButtonPressed, - commentsBusy && styles.diffCommentButtonDisabled - ]} - disabled={commentsBusy} - onPress={() => { - if (commentLine !== undefined) { - onStartComment(commentLine) - } - }} - accessibilityLabel={`Add note on line ${commentLine}`} - > - - - ) : null} - - {comments.length > 0 ? ( - - {comments.map((comment) => ( - - - - Line {comment.lineNumber} - onDeleteComment(comment.id)} - accessibilityLabel={`Delete note on line ${comment.lineNumber}`} - > - - - - {comment.body} - - ))} - - ) : null} - {isCommenting ? ( - - - - - Cancel - - { - if (commentLine !== undefined) { - onSubmitComment(commentLine) - } - }} - > - Save note - - - - ) : null} - - ) -} - function FileReader({ doc, title, diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts index f84e545af49..84ab66a1b0b 100644 --- a/mobile/src/dictation/mobile-dictation-setup.test.ts +++ b/mobile/src/dictation/mobile-dictation-setup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts index ca510ee4a31..e66d5d4eeb6 100644 --- a/mobile/src/dictation/mobile-dictation-setup.ts +++ b/mobile/src/dictation/mobile-dictation-setup.ts @@ -1,6 +1,6 @@ import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types' import type { RpcClient } from '../transport/rpc-client' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcSuccess } from '../transport/types' export type MobileSpeechSetup = RuntimeSpeechSetupState diff --git a/mobile/src/session/DiffLineRow.tsx b/mobile/src/session/DiffLineRow.tsx new file mode 100644 index 00000000000..7419bd20f39 --- /dev/null +++ b/mobile/src/session/DiffLineRow.tsx @@ -0,0 +1,150 @@ +import { Pressable, Text, TextInput, View } from 'react-native' +import { MessageSquare, Plus, X } from 'lucide-react-native' +import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-session-styles' +import type { RenderableDiffLine } from './mobile-session-route-types' +import type { DiffComment } from '../../../src/shared/diff-comment-types' + +// One row of a rendered diff, with its inline note thread. Lifted out of the session screen +// because the row owns a whole comment-draft interaction of its own. + +export function DiffLineRow({ + line, + title, + index, + comments, + activeCommentLine, + commentDraft, + commentsBusy, + onStartComment, + onCancelComment, + onDraftChange, + onSubmitComment, + onDeleteComment +}: { + line: RenderableDiffLine + title: string + index: number + comments: DiffComment[] + activeCommentLine: number | null + commentDraft: string + commentsBusy: boolean + onStartComment: (lineNumber: number) => void + onCancelComment: () => void + onDraftChange: (value: string) => void + onSubmitComment: (lineNumber: number) => void + onDeleteComment: (commentId: string) => void +}) { + const commentLine = line.newLineNumber + const isCommenting = commentLine !== undefined && activeCommentLine === commentLine + const canComment = commentLine !== undefined + // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter. + const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? '' + return ( + + + {gutterLineNumber} + + + {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} + + + + {canComment ? ( + [ + styles.diffCommentAddButton, + pressed && styles.diffCommentAddButtonPressed, + commentsBusy && styles.diffCommentButtonDisabled + ]} + disabled={commentsBusy} + onPress={() => { + if (commentLine !== undefined) { + onStartComment(commentLine) + } + }} + accessibilityLabel={`Add note on line ${commentLine}`} + > + + + ) : null} + + {comments.length > 0 ? ( + + {comments.map((comment) => ( + + + + Line {comment.lineNumber} + onDeleteComment(comment.id)} + accessibilityLabel={`Delete note on line ${comment.lineNumber}`} + > + + + + {comment.body} + + ))} + + ) : null} + {isCommenting ? ( + + + + + Cancel + + { + if (commentLine !== undefined) { + onSubmitComment(commentLine) + } + }} + > + Save note + + + + ) : null} + + ) +} diff --git a/mobile/src/session/mobile-clipboard-image.test.ts b/mobile/src/session/mobile-clipboard-image.test.ts index cc563127631..c5b0af656fc 100644 --- a/mobile/src/session/mobile-clipboard-image.test.ts +++ b/mobile/src/session/mobile-clipboard-image.test.ts @@ -8,7 +8,7 @@ import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { RpcClient } from '../transport/rpc-client' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' function ok(id: string, result: unknown): RpcSuccess { diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts index 578dabefcb9..91355c998c6 100644 --- a/mobile/src/session/mobile-clipboard-image.ts +++ b/mobile/src/session/mobile-clipboard-image.ts @@ -1,5 +1,5 @@ import type { RpcClient } from '../transport/rpc-client' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcFailure, RpcSuccess } from '../transport/types' export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024 diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index cc70e2e3b0f..127f33879db 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, openMobileNativeChatSendBudget, diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 28f91759168..bb7a5dab0df 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -1,6 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts index 8c490dffca5..188d0630a9b 100644 --- a/mobile/src/session/mobile-session-tab-activation.test.ts +++ b/mobile/src/session/mobile-session-tab-activation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation' diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts index ff8353459b4..c78464b8045 100644 --- a/mobile/src/session/mobile-session-tab-activation.ts +++ b/mobile/src/session/mobile-session-tab-activation.ts @@ -1,6 +1,6 @@ import type { TabActivationIntent } from '../../../src/shared/tab-activation-intent' import type { RpcClient } from '../transport/rpc-client' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcResponse } from '../transport/types' import { getMobileTerminalDiagnosticErrorName, diff --git a/mobile/src/session/mobile-structured-outbox-entry.ts b/mobile/src/session/mobile-structured-outbox-entry.ts index 91871f679cd..d60371196eb 100644 --- a/mobile/src/session/mobile-structured-outbox-entry.ts +++ b/mobile/src/session/mobile-structured-outbox-entry.ts @@ -9,7 +9,7 @@ import { } from '../../../src/shared/structured-agent-session-outbox' import { createStructuredAgentSessionOperationId } from '../../../src/shared/structured-agent-session-mutation' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' export { createStructuredAgentSessionOutboxEntry as createMobileStructuredOutboxEntry, diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts index 871d221abb2..4460cbd3720 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' import { openMobileNativeChatSendBudget } from './mobile-native-chat-send' diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts index 0a0c4a470c0..d5d8a00753f 100644 --- a/mobile/src/session/use-mobile-structured-session-mutations.ts +++ b/mobile/src/session/use-mobile-structured-session-mutations.ts @@ -17,7 +17,7 @@ import { } from '../../../src/shared/structured-agent-session-mutation' import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' export type MobileStructuredPromptItem = AgentJournalRenderItem & { body: AgentJournalApprovalItem | AgentJournalQuestionItem diff --git a/mobile/src/session/use-quick-commands.test.ts b/mobile/src/session/use-quick-commands.test.ts index b375a20bd97..e3402dc535e 100644 --- a/mobile/src/session/use-quick-commands.test.ts +++ b/mobile/src/session/use-quick-commands.test.ts @@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import type { RpcClient } from '../transport/rpc-client' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcResponse } from '../transport/types' import { useQuickCommands } from './use-quick-commands' diff --git a/mobile/src/session/use-quick-commands.ts b/mobile/src/session/use-quick-commands.ts index 6537551be7a..4c70a62126d 100644 --- a/mobile/src/session/use-quick-commands.ts +++ b/mobile/src/session/use-quick-commands.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import { diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index 8b1e55efcae..5fc21606f2b 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability' function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient { diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 6083120c7a4..515708438c6 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { RpcSuccess } from '../transport/types' import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability' diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index 35fa050b620..74e7806a64f 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import { createWorktreeWithNameRetry } from './worktree-create-retry' type Attempt = { method: string; params: Record } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 6cee659b89f..646c14f69ff 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -1,6 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' import { CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, getClientWorktreeCreateCandidate, diff --git a/mobile/src/transport/logical-client-cutover-error.ts b/mobile/src/transport/logical-client-cutover-error.ts new file mode 100644 index 00000000000..3548eea1589 --- /dev/null +++ b/mobile/src/transport/logical-client-cutover-error.ts @@ -0,0 +1,17 @@ +// The error a pending RPC is rejected with when the logical client swaps physical sessions +// mid-flight. Its own module so every caller that retries on a cutover can import it without +// pulling in the client itself — several of them are imported BY the client. + +export class LogicalClientCutoverError extends Error { + constructor() { + super('RPC interrupted by connection migration') + } +} + +// Why: instanceof can miss across bundle copies, so also match by message. +export function isLogicalClientCutoverError(error: unknown): boolean { + return ( + error instanceof LogicalClientCutoverError || + (error instanceof Error && error.message === 'RPC interrupted by connection migration') + ) +} diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 7ef3e2f8e71..ef6098fc142 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -207,7 +207,9 @@ export class RelayReconnectController { return } const failure = this.activeSession?.getFailure() - this.consecutiveFailures = relayStructuredFailures(this.consecutiveFailures, this.activeSession) + this.failureCount.recomputeTo( + relayStructuredFailures(this.failureCount.current(), this.activeSession) + ) this.activeSession = null if (failure) { // Why: active relay closes need the same cooldown as failed replacement dials. diff --git a/mobile/src/transport/relay-recovery-failure-count.ts b/mobile/src/transport/relay-recovery-failure-count.ts index 1bbadbcd400..a2cafa1fb4d 100644 --- a/mobile/src/transport/relay-recovery-failure-count.ts +++ b/mobile/src/transport/relay-recovery-failure-count.ts @@ -26,6 +26,13 @@ export class RelayRecoveryFailureCount { this.update(0) } + /** A structured reconnect signal recomputes the streak rather than adding to it: a stream that + * proved long-lived retires it, a background restart floors it at one. Routed through `update` + * so the recovery-attempt reporter sees the change. */ + recomputeTo(count: number): void { + this.update(Math.max(0, count)) + } + private update(count: number): void { if (this.count === count) { return diff --git a/mobile/src/transport/runtime-capability-probe.test.ts b/mobile/src/transport/runtime-capability-probe.test.ts index 2272c25610f..039efc0e2ce 100644 --- a/mobile/src/transport/runtime-capability-probe.test.ts +++ b/mobile/src/transport/runtime-capability-probe.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { startRuntimeCapabilityProbe } from './runtime-capability-probe' -import { LogicalClientCutoverError } from './stable-logical-rpc-client' +import { LogicalClientCutoverError } from './logical-client-cutover-error' import type { RpcClient } from './rpc-client' import type { RpcResponse } from './types' diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts index ef636552863..8ecc992a1bb 100644 --- a/mobile/src/transport/runtime-capability-probe.ts +++ b/mobile/src/transport/runtime-capability-probe.ts @@ -1,6 +1,6 @@ import type { RpcClient } from './rpc-client' import type { RpcSuccess } from './types' -import { isLogicalClientCutoverError } from './stable-logical-rpc-client' +import { isLogicalClientCutoverError } from './logical-client-cutover-error' // Why: a relay→direct cutover or request timeout can reject an in-flight // status.get without ever changing connState, so a one-shot probe would latch diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index f7526683d03..1701e5377b5 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -2,11 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import type { ConnectionState, RpcResponse } from './types' import type { RpcClient } from './rpc-client' import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' -import { - createStableLogicalRpcClient, - LogicalClientCutoverError, - type MobileConnectionPath -} from './stable-logical-rpc-client' +import { createStableLogicalRpcClient, type MobileConnectionPath } from './stable-logical-rpc-client' +import { LogicalClientCutoverError } from './logical-client-cutover-error' class FakeSession implements RpcClient { readonly sendRequest = diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index edcc6cc8de9..48eb4d43d58 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -7,23 +7,10 @@ import { import { waitForAuthenticated } from './replacement-session-authentication' import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection' import { LogicalClientConnectionPath } from './logical-client-connection-path' +import { LogicalClientCutoverError } from './logical-client-cutover-error' export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' -export class LogicalClientCutoverError extends Error { - constructor() { - super('RPC interrupted by connection migration') - } -} - -// Why: instanceof can miss across bundle copies, so also match by message. -export function isLogicalClientCutoverError(error: unknown): boolean { - return ( - error instanceof LogicalClientCutoverError || - (error instanceof Error && error.message === 'RPC interrupted by connection migration') - ) -} - type SubscriptionRecord = { method: string params: unknown diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts index 5799de96366..4e6c6d23583 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.test.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest' import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' import type { RpcClient } from '../transport/rpc-client' -import { - createStableLogicalRpcClient, - LogicalClientCutoverError -} from '../transport/stable-logical-rpc-client' +import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' +import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error' import type { ConnectionState, RpcResponse } from '../transport/types' import { fetchHomeHostWorktreeInfo } from './home-host-worktree-fetch' import type { HostWorktreeInfo } from './home-worktree-info' diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts index 72b9e572ba1..88354dd5015 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -1,7 +1,7 @@ import { setCachedWorktrees } from '../cache/worktree-cache' import { sendSingleFlightRequest } from '../transport/request-single-flight' import type { RpcClient } from '../transport/rpc-client' -import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error' import { markHomeWorktreeCatalogUnavailable, type HomeWorktreeSummary, diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts index 9811773eb07..c7be075074c 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -2,7 +2,7 @@ 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 type { AgentSubagentSnapshot } from '../../shared/agent-status-types' +import type { AgentSubagentSnapshot } from '../../shared/agent-subagent-snapshot' import { makePaneKey } from '../../shared/stable-pane-id' import { toAppSshPtyId } from '../../shared/ssh-pty-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts index 4b1c9196878..3caca081b55 100644 --- a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts +++ b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-subagent-snapshot' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' diff --git a/src/relay/agent-hook-envelope-publication.test.ts b/src/relay/agent-hook-envelope-publication.test.ts index 164c36f9c7a..7f94e93eeba 100644 --- a/src/relay/agent-hook-envelope-publication.test.ts +++ b/src/relay/agent-hook-envelope-publication.test.ts @@ -6,7 +6,7 @@ import { createShedSubagentsField } from '../shared/agent-hook-relay' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' -import type { AgentSubagentSnapshot } from '../shared/agent-status-types' +import type { AgentSubagentSnapshot } from '../shared/agent-subagent-snapshot' type BoundedClient = { frames: Buffer[] diff --git a/src/relay/relay-oversized-notification-survival.test.ts b/src/relay/relay-oversized-notification-survival.test.ts index 41d3dd833e5..19af0be47b4 100644 --- a/src/relay/relay-oversized-notification-survival.test.ts +++ b/src/relay/relay-oversized-notification-survival.test.ts @@ -10,12 +10,8 @@ import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' -import { - AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, - AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, - normalizeAgentStatusPayload -} from '../shared/agent-status-types' +import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, normalizeAgentStatusPayload } from '../shared/agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from '../shared/agent-subagent-snapshot' // The relay runs on the REMOTE host, so the stream default is that host's Node major. const NODE22_HWM = 64 * 1024 // Node >= 22 default diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts index 095e01983c3..a3574e1842c 100644 --- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts +++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts @@ -13,7 +13,7 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' -import { isCommandMarkerId } from './native-chat-pending' +import { isCommandMarkerId } from './native-chat-command-marker' /** A row carrying only tool activity — no prose. It is progress, not an answer. */ function isToolActivityOnlyRow(message: NativeChatMessage): boolean { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index e55ddcf57ce..282dc492713 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1,17 +1,8 @@ /* eslint-disable max-lines -- Why: the agent-status slice co-locates live map, retained snapshots, retention-suppression, and tab-prefix sweep so the teardown contract stays readable end-to-end. Splitting across files would scatter the drop/remove/retain interactions that must stay in lockstep. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { - AGENT_STATUS_STALE_AFTER_MS, - AGENT_STATE_HISTORY_MAX, - agentSubagentsEqual, - type AgentStateHistoryEntry, - type AgentStatusEntry, - type AgentStatusOrchestrationContext, - type AgentType, - type MigrationUnsupportedPtyEntry, - type ParsedAgentStatusPayload -} from '../../../../shared/agent-status-types' +import { AGENT_STATUS_STALE_AFTER_MS, AGENT_STATE_HISTORY_MAX, type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusOrchestrationContext, type AgentType, type MigrationUnsupportedPtyEntry, type ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { agentSubagentsEqual } from '../../../../shared/agent-subagent-snapshot' import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations' import { diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 5278247cf8d..d8b204fa5fa 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -9,7 +9,7 @@ import { type HookListenerState } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot' import { makePaneKey } from './stable-pane-id' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts index aebade35d6c..8fdbbde2a8b 100644 --- a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts +++ b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts @@ -5,7 +5,7 @@ import { seedClaudeSubagentRosterFromSnapshots, type HookListenerState } from './agent-hook-listener' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '22222222-2222-4222-8222-222222222222' diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index da80e171841..2110080223f 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -17,13 +17,8 @@ import { } from 'node:fs' import { isAbsolute, join } from 'node:path' -import { - AGENT_MODEL_MAX_LENGTH, - normalizeAgentStatusPayload, - type AgentStatusState, - type AgentSubagentSnapshot, - type ParsedAgentStatusPayload -} from './agent-status-types' +import { AGENT_MODEL_MAX_LENGTH, normalizeAgentStatusPayload, type AgentStatusState, type ParsedAgentStatusPayload } from './agent-status-types' +import type { AgentSubagentSnapshot } from './agent-subagent-snapshot' import { normalizeOptionalField } from './agent-status-field-normalization' import { isAskUserQuestionTool } from './agent-question-answered-intent' import { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 0ea80999766..420b1e59f78 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -24,7 +24,8 @@ import { createHash } from 'node:crypto' -import type { AgentSubagentSnapshot, ParsedAgentStatusPayload } from './agent-status-types' +import type { ParsedAgentStatusPayload } from './agent-status-types' +import type { AgentSubagentSnapshot } from './agent-subagent-snapshot' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { AgentHookTarget } from './agent-hook-types' diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f82932f3aeb..06370b52cc8 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -1,20 +1,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest' -import { - agentSubagentsEqual, - isFreshNonDoneAgentStatus, - parseAgentStatusPayload, - normalizeAgentStatusPayload, - pickParsedAgentStatusPayload, - AGENT_STATUS_JSON_STRUCTURE_LIMITS, - AGENT_STATUS_MAX_FIELD_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, - AGENT_STATUS_TOOL_NAME_MAX_LENGTH, - AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, - AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, - AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, - AGENT_STATUS_STATES, - AGENT_TYPE_MAX_LENGTH -} from './agent-status-types' +import { isFreshNonDoneAgentStatus, parseAgentStatusPayload, normalizeAgentStatusPayload, pickParsedAgentStatusPayload, AGENT_STATUS_JSON_STRUCTURE_LIMITS, AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, AGENT_STATUS_STATES, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' +import { agentSubagentsEqual, AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot' afterEach(() => { vi.restoreAllMocks() diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 4b56cb38bf2..e82e8a87727 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -15,6 +15,8 @@ import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' +import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-subagent-snapshot' + export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is @@ -75,22 +77,6 @@ export type AgentStatusOrchestrationContext = { orchestrationRunId?: string } -export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' - -/** A live in-process child of the pane's provider session. Rendered as an - * indented child row with no PTY of its own. */ -export type AgentSubagentSnapshot = { - /** Provider-assigned lifecycle id. */ - id: string - agentType?: string - /** Provider model used by this child, when exposed by its lifecycle event. */ - model?: string - description?: string - state: AgentSubagentState - /** Timestamp (ms) when this subagent was first observed. */ - startedAt: number -} - export type AgentStatusEntry = { state: AgentStatusState /** The user's most recent prompt. Cached across the turn — later tool-use events @@ -302,9 +288,6 @@ const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) export const AGENT_TYPE_MAX_LENGTH = 40 export const AGENT_MODEL_MAX_LENGTH = 120 -/** Maximum subagent child rows carried per status entry. Bounds per-pane cache - * and IPC fanout against a runaway spawner. */ -export const AGENT_STATUS_MAX_SUBAGENTS = 32 export const AGENT_STATUS_JSON_STRUCTURE_LIMITS = { structuralTokens: 4096, nestingDepth: 16 @@ -359,35 +342,6 @@ function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | unde return normalized.length > 0 ? normalized : undefined } -/** Structural equality for subagent lists so stores can reuse the previous - * array reference (and skip fanout) when nothing actually changed. */ -export function agentSubagentsEqual( - a: AgentSubagentSnapshot[] | undefined, - b: AgentSubagentSnapshot[] | undefined -): boolean { - if (a === b) { - return true - } - if (!a || !b || a.length !== b.length) { - return !a && !b - } - for (let i = 0; i < a.length; i++) { - const x = a[i] - const y = b[i] - if ( - x.id !== y.id || - x.state !== y.state || - x.startedAt !== y.startedAt || - x.agentType !== y.agentType || - x.model !== y.model || - x.description !== y.description - ) { - return false - } - } - return true -} - /** * Normalize and validate an already-parsed agent status object. Shared by the * JSON string entry point (`parseAgentStatusPayload`) and the object entry diff --git a/src/shared/agent-subagent-snapshot.ts b/src/shared/agent-subagent-snapshot.ts new file mode 100644 index 00000000000..67564a57831 --- /dev/null +++ b/src/shared/agent-subagent-snapshot.ts @@ -0,0 +1,52 @@ +// The subagent roster carried on a status entry: a provider session's in-process children, which +// have no PTY of their own. Separate from `agent-status-types` so the entry's own shape and this +// roster can each grow without the two colliding in one file. + +export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' + +/** A live in-process child of the pane's provider session. Rendered as an + * indented child row with no PTY of its own. */ +export type AgentSubagentSnapshot = { + /** Provider-assigned lifecycle id. */ + id: string + agentType?: string + /** Provider model used by this child, when exposed by its lifecycle event. */ + model?: string + description?: string + state: AgentSubagentState + /** Timestamp (ms) when this subagent was first observed. */ + startedAt: number +} + +/** Maximum subagent child rows carried per status entry. Bounds per-pane cache + * and IPC fanout against a runaway spawner. */ +export const AGENT_STATUS_MAX_SUBAGENTS = 32 + +/** Structural equality for subagent lists so stores can reuse the previous + * array reference (and skip fanout) when nothing actually changed. */ +export function agentSubagentsEqual( + a: AgentSubagentSnapshot[] | undefined, + b: AgentSubagentSnapshot[] | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b || a.length !== b.length) { + return !a && !b + } + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if ( + x.id !== y.id || + x.state !== y.state || + x.startedAt !== y.startedAt || + x.agentType !== y.agentType || + x.model !== y.model || + x.description !== y.description + ) { + return false + } + } + return true +} diff --git a/src/shared/claude-background-task-inventory.ts b/src/shared/claude-background-task-inventory.ts index 33e38f64dfd..a26292b47af 100644 --- a/src/shared/claude-background-task-inventory.ts +++ b/src/shared/claude-background-task-inventory.ts @@ -1,4 +1,4 @@ -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot' const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([ 'idle', diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index ecf685706fe..bca10a077c2 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -9,7 +9,7 @@ import { seedClaudeSubagentRosterFromSnapshots, type HookListenerState } from './agent-hook-listener' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot' import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index df327cde829..30ba8bd6800 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot' import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' import { claudeRosterHasRuntimeWorkingSubagent, diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index 7891d19e767..5d767700cac 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -1,4 +1,4 @@ -import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-subagent-snapshot' import type { ClaudeBackgroundAgentTask } from './claude-background-task-inventory' /** Mirrors the wire-normalization id cap in agent-status-types. Enforced at diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts index 0cbb44830c9..a19f636aec4 100644 --- a/src/shared/codex-subagent-roster.test.ts +++ b/src/shared/codex-subagent-roster.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - AGENT_MODEL_MAX_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, - AGENT_TYPE_MAX_LENGTH -} from './agent-status-types' +import { AGENT_MODEL_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot' import { codexRosterToSnapshots, finishCodexSubagent, diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts index 5592ed8711f..1929f5676f4 100644 --- a/src/shared/codex-subagent-roster.ts +++ b/src/shared/codex-subagent-roster.ts @@ -1,10 +1,5 @@ -import { - AGENT_MODEL_MAX_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, - AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, - AGENT_TYPE_MAX_LENGTH, - type AgentSubagentSnapshot -} from './agent-status-types' +import { AGENT_MODEL_MAX_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' +import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-subagent-snapshot' import { normalizeOptionalField } from './agent-status-field-normalization' const CODEX_SUBAGENT_ID_MAX_LENGTH = 64 diff --git a/src/shared/tui-agent-startup-hermes.test.ts b/src/shared/tui-agent-startup-hermes.test.ts new file mode 100644 index 00000000000..bcf5f43750c --- /dev/null +++ b/src/shared/tui-agent-startup-hermes.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from './tui-agent-startup' +import { + unwrapPosixShellScript, + unwrapPowerShellScript +} from './tui-agent-startup-script.test-fixture' + +// Hermes is the only agent whose launch Orca rewrites token by token — it owns the startup query, +// the TUI mode, and where an override's flags may sit relative to the chat subcommand. Its cases +// outgrew the general startup-plan file. + +describe('hermes startup plans', () => { + it('moves Hermes command override flags after the chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run privately', + cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, + agentArgs: '--yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') + }) + + it.each([ + { + shell: 'powershell' as const, + override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', + expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" + }, + { + shell: 'cmd' as const, + override: 'C:\\Tools\\hermes.exe --tui', + expected: "& 'C:\\Tools\\hermes.exe' 'chat'" + } + ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: testCase.override }, + platform: 'win32', + shell: testCase.shell + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) + }) + + it('removes a configured duplicate chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, + agentArgs: '--provider copilot chat --yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--provider' 'copilot' '--yolo'") + }) + + it('preserves an option value named chat', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --profile chat --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") + }) + + it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'automation prompt', + cmdOverrides: { hermes: 'hermes --query override --cli' }, + agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/--query=/g)).toHaveLength(1) + expect(script).not.toContain('override') + expect(script).not.toContain("'--cli'") + expect(script.match(/'--tui'/g)).toHaveLength(1) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') + }) + + it('preserves wrapper tokens before the Hermes executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") + }) + + it('selects the final Hermes executable token in a wrapper', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'sudo' '-u' 'hermes' 'hermes' 'chat'" + ) + }) + + it('selects the wrapped executable when the wrapper and command both name Hermes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") + expect(script.match(/'chat'/g)).toHaveLength(1) + }) + + it('does not mistake a Hermes option value for a wrapped executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--resume' 'hermes'") + }) + + it('preserves POSIX environment-assignment command prefixes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" + ) + }) + + it('rejects a Hermes command override with no identifiable executable', () => { + expect( + buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'custom-agent --tui' }, + platform: 'linux' + }) + ).toBeNull() + }) + + it('rejects Hermes queries that exceed the safe Windows environment limit', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'x'.repeat(24_000), + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan).toBeNull() + }) + + it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( + 'keeps a complex cmd Hermes query out of command text: %s', + (prompt) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).not.toContain(prompt) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) + } + ) + + it('uses the Windows remote default shell for SSH Hermes queries', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run remotely', + cmdOverrides: {}, + platform: 'win32', + isRemote: true + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( + "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" + ) + }) + + it('measures POSIX Hermes query limits in UTF-8 bytes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '界'.repeat(50_000), + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan).toBeNull() + }) + + it('keeps empty Hermes launches on the interactive TUI command', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + expect(plan?.launchCommand).toBe('hermes --tui') + expect(plan?.followupPrompt).toBeNull() + }) +}) diff --git a/src/shared/tui-agent-startup-script.test-fixture.ts b/src/shared/tui-agent-startup-script.test-fixture.ts new file mode 100644 index 00000000000..b76197edcaa --- /dev/null +++ b/src/shared/tui-agent-startup-script.test-fixture.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +// A startup plan's launch command carries the real script encoded for the target shell — octal +// escapes fed to `printf %b` on POSIX, UTF-16 base64 for PowerShell. Assertions want the script, +// so these decode it back. + +export function unwrapPosixShellScript(command: string | undefined): string { + const tokenized = tokenizeStartupCommand(command ?? '', 'posix') + expect(tokenized.ok).toBe(true) + const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' + const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] + if (!encoded) { + return wrapper + } + const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function unwrapPowerShellScript(command: string | undefined): string { + const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeDefined() + return Buffer.from(encoded!, 'base64').toString('utf16le') +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 44cadb7d914..e5e26f46802 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -9,24 +9,10 @@ import { import { TUI_AGENT_CONFIG } from './tui-agent-config' import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' import { tokenizeStartupCommand } from './tui-agent-startup-shell' - -function unwrapPosixShellScript(command: string | undefined): string { - const tokenized = tokenizeStartupCommand(command ?? '', 'posix') - expect(tokenized.ok).toBe(true) - const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' - const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] - if (!encoded) { - return wrapper - } - const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) - return new TextDecoder().decode(new Uint8Array(bytes)) -} - -function unwrapPowerShellScript(command: string | undefined): string { - const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] - expect(encoded).toBeDefined() - return Buffer.from(encoded!, 'base64').toString('utf16le') -} +import { + unwrapPosixShellScript, + unwrapPowerShellScript +} from './tui-agent-startup-script.test-fixture' describe('draft prefill teardown ordering (#14975)', () => { // Why pinned: the teardown mutates the calling shell, so it must reference @@ -212,224 +198,6 @@ describe('tui agent startup plans', () => { expect(tokens.ok && tokens.tokens.at(-1)).toMatch(/\\0[0-7]{3}/) }) - it('moves Hermes command override flags after the chat subcommand', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run privately', - cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, - agentArgs: '--yolo', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') - }) - - it.each([ - { - shell: 'powershell' as const, - override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', - expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" - }, - { - shell: 'cmd' as const, - override: 'C:\\Tools\\hermes.exe --tui', - expected: "& 'C:\\Tools\\hermes.exe' 'chat'" - } - ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: testCase.override }, - platform: 'win32', - shell: testCase.shell - }) - - expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) - }) - - it('removes a configured duplicate chat subcommand', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, - agentArgs: '--provider copilot chat --yolo', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/'chat'/g)).toHaveLength(1) - expect(script).toContain("'--provider' 'copilot' '--yolo'") - }) - - it('preserves an option value named chat', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes --profile chat --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") - }) - - it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'automation prompt', - cmdOverrides: { hermes: 'hermes --query override --cli' }, - agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/--query=/g)).toHaveLength(1) - expect(script).not.toContain('override') - expect(script).not.toContain("'--cli'") - expect(script.match(/'--tui'/g)).toHaveLength(1) - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') - }) - - it('preserves wrapper tokens before the Hermes executable', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'uv run hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") - }) - - it('selects the final Hermes executable token in a wrapper', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( - "'sudo' '-u' 'hermes' 'hermes' 'chat'" - ) - }) - - it('selects the wrapped executable when the wrapper and command both name Hermes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") - expect(script.match(/'chat'/g)).toHaveLength(1) - }) - - it('does not mistake a Hermes option value for a wrapped executable', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/'chat'/g)).toHaveLength(1) - expect(script).toContain("'--resume' 'hermes'") - }) - - it('preserves POSIX environment-assignment command prefixes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( - "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" - ) - }) - - it('rejects a Hermes command override with no identifiable executable', () => { - expect( - buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'custom-agent --tui' }, - platform: 'linux' - }) - ).toBeNull() - }) - - it('rejects Hermes queries that exceed the safe Windows environment limit', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'x'.repeat(24_000), - cmdOverrides: {}, - platform: 'win32' - }) - - expect(plan).toBeNull() - }) - - it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( - 'keeps a complex cmd Hermes query out of command text: %s', - (prompt) => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt, - cmdOverrides: {}, - platform: 'win32', - shell: 'cmd' - }) - - expect(plan?.launchCommand).not.toContain(prompt) - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) - } - ) - - it('uses the Windows remote default shell for SSH Hermes queries', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run remotely', - cmdOverrides: {}, - platform: 'win32', - isRemote: true - }) - - expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( - "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" - ) - }) - - it('measures POSIX Hermes query limits in UTF-8 bytes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: '界'.repeat(50_000), - cmdOverrides: {}, - platform: 'linux' - }) - - expect(plan).toBeNull() - }) - - it('keeps empty Hermes launches on the interactive TUI command', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: '', - cmdOverrides: {}, - platform: 'linux', - allowEmptyPromptLaunch: true - }) - - expect(plan?.launchCommand).toBe('hermes --tui') - expect(plan?.followupPrompt).toBeNull() - }) - it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { const plan = buildAgentStartupPlan({ agent: 'codex',