mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,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,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,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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user