refactor: split the four files the merge pushed over max-lines

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`.
This commit is contained in:
Brennan Benson
2026-08-18 19:29:36 -07:00
parent 9ead462e5f
commit 09378ef5e4
50 changed files with 539 additions and 531 deletions
+1 -140
View File
@@ -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 (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={`${title} diff line ${index + 1}`}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={`Add note on line ${commentLine}`}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder="Add review note"
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
function FileReader({
doc,
title,
@@ -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 {
@@ -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
+150
View File
@@ -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 (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={`${title} diff line ${index + 1}`}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={`Add note on line ${commentLine}`}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder="Add review note"
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
@@ -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 {
+1 -1
View File
@@ -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
@@ -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,
@@ -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'
@@ -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'
@@ -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,
@@ -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,
@@ -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'
@@ -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
@@ -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'
+1 -1
View File
@@ -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 {
@@ -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 {
@@ -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'
@@ -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<string, unknown> }
+1 -1
View File
@@ -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,
@@ -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')
)
}
@@ -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.
@@ -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
@@ -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'
@@ -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
@@ -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 =
@@ -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
@@ -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'
@@ -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,
@@ -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'
@@ -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'
@@ -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[]
@@ -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
@@ -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 {
+2 -11
View File
@@ -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 {
@@ -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'
@@ -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'
+2 -7
View File
@@ -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 {
+2 -1
View File
@@ -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'
+2 -16
View File
@@ -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()
+2 -48
View File
@@ -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<string> = new Set<string>(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
+52
View File
@@ -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
}
@@ -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',
@@ -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'
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+2 -5
View File
@@ -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,
+2 -7
View File
@@ -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
+230
View File
@@ -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()
})
})
@@ -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')
}
+4 -236
View File
@@ -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',