mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/CI-speed-audit
This commit is contained in:
@@ -14406,7 +14406,7 @@
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, SSH and paired-runtime host-platform routing including legacy runtime PTY IDs, active-PTY ownership after worktree host changes, Windows-to-WSL routing, unknown-metadata fallback, production handler composition, KKP authorization on every host, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. A live linux-arm64 Docker target additionally proved real relay deployment, SSH PTY host detection, and exact inactive/active KKP bytes. The Electron byte test runs only on Windows; live Windows evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added. Protocol-version constant inventories are excluded from handshake and foreground-confirmation evidence. The test-value audit narrows store-active-worktree-terminal-creation.test.ts to selected same-ID local/SSH folder creation and owner-context propagation plus split-survivor relay-drop/orphan protection. Immediate detach PTY/relay/tab-map synchronization, plain root-group inventory, quick-command/startup-cwd field normalization, and Windows default/project-runtime/UNC/SSH/Git-Bash shell-metadata examples are intentionally removed. Those direct state and metadata alarms are lost; remaining cases are not equivalent replacements. Historical executed commands, counts and results remain unchanged; no new shell-spawn, input-byte, live platform, remote or mobile evidence is claimed.",
|
||||
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, SSH and paired-runtime host-platform routing including legacy runtime PTY IDs, active-PTY ownership after worktree host changes, Windows-to-WSL routing, unknown-metadata fallback, production handler composition, KKP authorization on every host, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. A live linux-arm64 Docker target additionally proved real relay deployment, SSH PTY host detection, and exact inactive/active KKP bytes. The Electron byte test runs only on Windows; live Windows evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added. Protocol-version constant inventories are excluded from handshake and foreground-confirmation evidence. The test-value audit narrows store-active-worktree-terminal-creation.test.ts to selected same-ID local/SSH folder creation and owner-context propagation plus split-survivor relay-drop/orphan protection. Immediate detach PTY/relay/tab-map synchronization, plain root-group inventory, quick-command/startup-cwd field normalization, and Windows default/project-runtime/UNC/SSH/Git-Bash shell-metadata examples are intentionally removed. Those direct state and metadata alarms are lost; remaining cases are not equivalent replacements. Historical executed commands, counts and results remain unchanged; no new shell-spawn, input-byte, live platform, remote or mobile evidence is claimed. The test-value audit removes the direct Windows installed-path case for Droid npm cmd basename recognition. That exact unit alarm is lost; retained command-line recognition cases are not equivalent replacements. The direct Droid recognition assertion remains a deferred obligation. Historical evidence and broader process-confirmation/input-routing obligations remain unchanged; no new executed coverage, owner-gap closure or credit is claimed.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7620",
|
||||
"https://github.com/stablyai/orca/pull/7668"
|
||||
@@ -14617,7 +14617,7 @@
|
||||
{
|
||||
"file": "src/shared/agent-process-recognition.test.ts",
|
||||
"assertions": [
|
||||
"Droid's process name resolves to the allowlisted Droid agent identity without substring matching"
|
||||
"Deferred unit obligation after removal of the direct Windows basename case: Droid's process name resolves to the allowlisted Droid agent identity without substring matching. Retained cases are not equivalent coverage."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -14988,7 +14988,7 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. A deterministic worker-start RPC/SQLite contract proves delayed Codex composer acceptance, exactly one submitted turn, durable Dispatch success, and explicit stalled failure when Enter is swallowed without a rescue send. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. A deterministic worker-start RPC/SQLite contract proves delayed Codex composer acceptance, exactly one submitted turn, durable Dispatch success, and explicit stalled failure when Enter is swallowed without a rescue send. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps. The test-value audit intentionally removes these direct unit alarms: Only OMP joins submit to paste frame. Exact bracketed multiline prompt frame. Claude/team/unknown lead-line positives and Codex/OpenCode negatives. DSB generic lead/submit/delay fallback on three platform strings. 2KB/8KB Windows and 8KB Darwin delay below700ms. 320KB ingest estimate greater than1.4x3342ms. 145KB delay greater than1500ms. Darwin8KB <=2ms, zero/NaN Linux and negative Windows return zero. 400KB greater than40KB across platforms and Windows slower than Darwin. Antigravity100-line settle addition and Aider one-line delta. Retained unit cases still assert safe typed-lead normalization, pinned measured delay floors and ceiling/budget bounds, ESC sanitization, exact framed UTF-8 chunk reassembly, and held-draft discrimination. They are not equivalent replacements for the removed unit alarms. The OMP-versus-Claude/Codex/unidentified-agent submit-join predicate obligation remains deferred. Runtime PTY-writer, worker-start and live evidence above are unchanged; no runtime-writer coverage loss, new execution, owner-gap closure or credit is claimed.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-3800",
|
||||
"https://linear.app/stably/issue/STA-3904",
|
||||
@@ -15017,7 +15017,7 @@
|
||||
"file": "src/shared/agent-prompt-injection.test.ts",
|
||||
"assertions": [
|
||||
"agent prompts are always framed as bracketed paste",
|
||||
"submit stays separate from the paste frame",
|
||||
"Deferred unit obligation: OMP joins submit to the paste frame while Claude, Codex and unidentified agents keep submit separate; the direct predicate table was removed. Runtime-writer coverage is unchanged.",
|
||||
"embedded ESC bytes are made inert before framing",
|
||||
"chunk reconstruction preserves the paste frame"
|
||||
]
|
||||
|
||||
@@ -11607,13 +11607,9 @@
|
||||
"src/shared/agent-launch-operation.test.ts": 176,
|
||||
"src/shared/agent-launch-picker-legacy-permission.test.ts": 299,
|
||||
"src/shared/agent-launch-picker.test.ts": 303,
|
||||
"src/shared/agent-launch-preferences.test.ts": 193,
|
||||
"src/shared/agent-launch-remote.test.ts": 41,
|
||||
"src/shared/agent-launch-settings-auto-projection.test.ts": 219,
|
||||
"src/shared/agent-launch-settings-permissions.test.ts": 199,
|
||||
"src/shared/agent-lead-status-fold.test.ts": 59,
|
||||
"src/shared/agent-main-agent-verdict.test.ts": 55,
|
||||
"src/shared/agent-notification-id.test.ts": 46,
|
||||
"src/shared/agent-pending-prompt.test.ts": 99,
|
||||
"src/shared/agent-process-presence-platform.test.ts": 64,
|
||||
"src/shared/agent-process-presence-probe.test.ts": 88,
|
||||
@@ -11626,9 +11622,7 @@
|
||||
"src/shared/agent-session-handoff-context.test.ts": 216,
|
||||
"src/shared/agent-session/agent-message-display-text.test.ts": 147,
|
||||
"src/shared/agent-session/agent-session-approval-blocked-path.test.ts": 49,
|
||||
"src/shared/agent-session/agent-session-approval-subject.test.ts": 112,
|
||||
"src/shared/agent-session/agent-session-attachments.test.ts": 46,
|
||||
"src/shared/agent-session/agent-session-background-task-state-admission.test.ts": 49,
|
||||
"src/shared/agent-session/agent-session-context-clear.test.ts": 222,
|
||||
"src/shared/agent-session/agent-session-conversation-name.test.ts": 71,
|
||||
"src/shared/agent-session/agent-session-message-source.test.ts": 166,
|
||||
@@ -11643,17 +11637,11 @@
|
||||
"src/shared/agent-session/failures/agent-session-wire-refusals.test.ts": 144,
|
||||
"src/shared/agent-session/failures/agent-session-write-failure.test.ts": 133,
|
||||
"src/shared/agent-session/failures/structured-agent-session-rejection-words.test.ts": 167,
|
||||
"src/shared/agent-session/journal/agent-session-journal-body-admission.test.ts": 109,
|
||||
"src/shared/agent-session/journal/agent-session-journal-producer.test.ts": 42,
|
||||
"src/shared/agent-session/lifecycle/agent-session-account-home.test.ts": 163,
|
||||
"src/shared/agent-session/lifecycle/agent-session-lease-adjudication.test.ts": 66,
|
||||
"src/shared/agent-session/lifecycle/agent-session-legacy-handoff-lease.test.ts": 157,
|
||||
"src/shared/agent-session/lifecycle/agent-session-mutation-envelope.test.ts": 170,
|
||||
"src/shared/agent-session/lifecycle/agent-session-operation-ledger.test.ts": 169,
|
||||
"src/shared/agent-session/lifecycle/agent-session-provider-handle.test.ts": 90,
|
||||
"src/shared/agent-session/lifecycle/agent-session-record-stored-form.test.ts": 192,
|
||||
"src/shared/agent-session/lifecycle/agent-session-resume-cursor.test.ts": 219,
|
||||
"src/shared/agent-session/lifecycle/agent-session-resume.test.ts": 76,
|
||||
"src/shared/agent-session/lifecycle/agent-session-sign-in.test.ts": 144,
|
||||
"src/shared/agent-session/lifecycle/agent-session-stopped-history.test.ts": 164,
|
||||
"src/shared/agent-session/lifecycle/agent-session-stored-agent.test.ts": 160,
|
||||
@@ -11665,7 +11653,6 @@
|
||||
"src/shared/agent-session/options/agent-session-option-catalog-removers.test.ts": 199,
|
||||
"src/shared/agent-session/options/agent-session-option-catalog.test.ts": 210,
|
||||
"src/shared/agent-session/options/structured-agent-session-model-catalog-application.test.ts": 235,
|
||||
"src/shared/agent-session/options/structured-agent-session-option-pick-book.test.ts": 37,
|
||||
"src/shared/agent-session/options/structured-agent-session-option-picks.test.ts": 211,
|
||||
"src/shared/agent-session/options/structured-agent-session-option-view.test.ts": 275,
|
||||
"src/shared/agent-session/options/structured-agent-session-options-read-owner.test.ts": 234,
|
||||
@@ -11675,7 +11662,6 @@
|
||||
"src/shared/agent-session/options/structured-agent-session-selection-operations.test.ts": 202,
|
||||
"src/shared/agent-session/requests/structured-agent-session-dispatch-rejection.test.ts": 169,
|
||||
"src/shared/agent-session/requests/structured-agent-session-latest-request.test.ts": 218,
|
||||
"src/shared/agent-session/requests/structured-agent-session-opening-send.test.ts": 111,
|
||||
"src/shared/agent-session/requests/structured-agent-session-send-evidence.test.ts": 148,
|
||||
"src/shared/agent-session/structured-agent-session-child-work-legacy.test.ts": 47,
|
||||
"src/shared/agent-session/structured-agent-session-coalescer.test.ts": 387,
|
||||
|
||||
@@ -56,12 +56,11 @@ function renderHeader(handOff?: NativeChatHeaderHandOff, focused = true) {
|
||||
return source
|
||||
}
|
||||
|
||||
it('offers the terminal header action order and targets Resume and Close at the displayed chat', () => {
|
||||
it('offers chat actions without a duplicate close button and targets Resume at the displayed chat', () => {
|
||||
const source = renderHeader()
|
||||
expect(screen.getAllByRole('button').map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'Continue with Terminal Agent',
|
||||
'Split Chat Right',
|
||||
'Close tab'
|
||||
'Split Chat Right'
|
||||
])
|
||||
const folder = useAppStore.getState().folderWorkspaces[0]
|
||||
act(() => useAppStore.setState({ folderWorkspaces: [{ ...folder, executionHostId: 'ssh:dev' }] }))
|
||||
@@ -80,11 +79,6 @@ it('offers the terminal header action order and targets Resume and Close at the
|
||||
agent: 'codex'
|
||||
})
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close tab' }))
|
||||
expect(mocks.close).toHaveBeenCalledExactlyOnceWith({
|
||||
type: 'close',
|
||||
target: { kind: 'tab', worktreeId: source.worktreeId, tabId: source.id }
|
||||
})
|
||||
})
|
||||
|
||||
async function splitTooltipText(): Promise<string | null> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from 'react'
|
||||
import { MessageSquarePlus, SquareSplitVertical, X } from 'lucide-react'
|
||||
import { MessageSquarePlus, SquareSplitVertical } from 'lucide-react'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -119,27 +119,6 @@ export function NativeChatPaneHeader({
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{actions.canClosePane ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="pane-title-close"
|
||||
size="icon-xs"
|
||||
aria-label={actions.closeLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
actions.onClosePane()
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{actions.closeLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -145,7 +145,7 @@ it.each([10, 100])(
|
||||
const view = render(overlay(true))
|
||||
const retained = chat(view.container, 'chat-0').querySelector('output')
|
||||
const unfocused = chat(view.container, `chat-${size - 1}`)
|
||||
expect(within(unfocused).getByRole('button', { name: 'Close Pane' })).toBeTruthy()
|
||||
expect(within(unfocused).queryByRole('button', { name: 'Close Pane' })).toBeNull()
|
||||
for (const operation of [
|
||||
() => useAppStore.getState().setTabLabel('chat-0', 'Changed title'),
|
||||
() => useAppStore.getState().activateTab('chat-1', { worktreeId: workspace })
|
||||
@@ -174,11 +174,7 @@ it.each([10, 100])(
|
||||
const shown = chat(view.container, 'chat-1')
|
||||
expect(within(shown).queryByRole('button', { name: 'Close tab' })).toBeNull()
|
||||
act(() => useAppStore.getState().unpinTab('chat-1', { recordInteraction: false }))
|
||||
fireEvent.click(within(shown).getByRole('button', { name: 'Close tab' }))
|
||||
expect(mocks.close).toHaveBeenCalledExactlyOnceWith({
|
||||
type: 'close',
|
||||
target: { kind: 'tab', worktreeId: workspace, tabId: 'chat-1' }
|
||||
})
|
||||
expect(within(shown).queryByRole('button', { name: 'Close tab' })).toBeNull()
|
||||
expect(chat(view.container, 'chat-0').querySelector('output')).toBe(retained)
|
||||
expect(fullRequest).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ it('retains recorded Jcode session-start identity without a status row', () => {
|
||||
const store = createTestStore()
|
||||
seedStore(store, { tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } })
|
||||
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
// Jcode 0.87.1 captured input; agent-hook-listener-jcode.test.ts pins provenance.
|
||||
// Jcode 0.87.1 captured input.
|
||||
const payload = {
|
||||
hook_event_name: 'session_start',
|
||||
event: 'session_start',
|
||||
|
||||
@@ -1,91 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { normalizeHookPayload } from './agent-hook-listener'
|
||||
import type { AgentHookSource } from './agent-hook-relay'
|
||||
|
||||
import {
|
||||
clearAllListenerCaches,
|
||||
clearPaneCacheState,
|
||||
createHookListenerState,
|
||||
movePaneCacheState,
|
||||
seedLegacyAgentStatusForTests
|
||||
movePaneCacheState
|
||||
} from './agent-hook-listener/listener-state'
|
||||
import { warnOnHookEnvOrVersionMismatch } from './agent-hook-listener/listener-limits'
|
||||
import { resolveHookSource } from './agent-hook-listener/source-routing'
|
||||
import './agent-hook-listener/source-routing'
|
||||
import { bindOpenCodeSession } from './agent-hook-listener/opencode-session-registry'
|
||||
import { makePaneKey } from './stable-pane-id'
|
||||
|
||||
const PANE = makePaneKey('tab-hooks', '11111111-1111-4111-8112-111111111111')
|
||||
const MOVED_PANE = makePaneKey('tab-hooks', '22222222-2222-4222-8222-222222222222')
|
||||
function normalizeProviderState(
|
||||
source: AgentHookSource,
|
||||
eventName: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
) {
|
||||
const routedSource = resolveHookSource(`/hook/${source}`)
|
||||
if (routedSource === null) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
normalizeHookPayload(
|
||||
createHookListenerState(),
|
||||
routedSource,
|
||||
{ paneKey: PANE, hook_event_name: eventName, payload },
|
||||
'production'
|
||||
)?.payload ?? null
|
||||
)
|
||||
}
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
describe('agent hook extraction boundaries', () => {
|
||||
it.each([
|
||||
['qoder', 'UserPromptSubmit'],
|
||||
['qoder-cn', 'UserPromptSubmit'],
|
||||
['qwen-code', 'UserPromptSubmit'],
|
||||
['codebuddy', 'UserPromptSubmit'],
|
||||
['opencode2', 'SessionBusy'],
|
||||
['muse', 'UserPromptSubmit'],
|
||||
['zcode', 'UserPromptSubmit'],
|
||||
['dsh', 'UserPromptSubmit'],
|
||||
['kiro', 'userPromptSubmit']
|
||||
] as const)('normalizes %s hook traffic through its routed source', (source, eventName) => {
|
||||
expect(normalizeProviderState(source, eventName, { prompt: 'routed prompt' })).toMatchObject({
|
||||
state: 'working',
|
||||
agentType: source,
|
||||
prompt: 'routed prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unknown and malformed hook paths', () => {
|
||||
expect(resolveHookSource('/hook/unknown')).toBeNull()
|
||||
expect(resolveHookSource('/hook/devin/')).toBeNull()
|
||||
expect(resolveHookSource('/HOOK/DEVIN')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves thin provider lifecycle branches and source attribution', () => {
|
||||
const cases = [
|
||||
['devin', 'UserPromptSubmit', 'working'],
|
||||
['devin', 'PermissionRequest', 'waiting'],
|
||||
['devin', 'Stop', 'done'],
|
||||
['mimo-code', 'MessagePart', 'working'],
|
||||
['mimo-code', 'AskUserQuestion', 'waiting'],
|
||||
['prime-agent', 'before_agent_start', 'working'],
|
||||
['prime-agent', 'agent_end', 'done'],
|
||||
['kimi', 'PermissionRequest', 'waiting'],
|
||||
['kimi', 'StopFailure', 'done'],
|
||||
['hermes', 'on_session_finalize', 'done'],
|
||||
['hermes', 'on_session_reset', 'done']
|
||||
] as const
|
||||
|
||||
for (const [source, eventName, expectedState] of cases) {
|
||||
expect(normalizeProviderState(source, eventName)).toMatchObject({
|
||||
state: expectedState,
|
||||
agentType: source
|
||||
})
|
||||
}
|
||||
for (const source of ['devin', 'mimo-code', 'prime-agent', 'kimi', 'hermes'] as const) {
|
||||
expect(normalizeProviderState(source, 'UnknownEvent')).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('warns before tab rejection and bounds distinct version and environment warnings', () => {
|
||||
const orderState = createHookListenerState()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
@@ -136,78 +66,6 @@ describe('agent hook extraction boundaries', () => {
|
||||
expect(warn).toHaveBeenCalledTimes(64)
|
||||
})
|
||||
|
||||
it('moves selected pane-owned collections and preserves NUL suffixes', () => {
|
||||
const state = createHookListenerState()
|
||||
const scoped = `${PANE}\0child`
|
||||
const movedScoped = `${MOVED_PANE}\0child`
|
||||
const sibling = `${PANE}-sibling`
|
||||
const paneMaps = [
|
||||
state.lastPromptByPaneKey,
|
||||
state.lastToolByPaneKey,
|
||||
state.antigravityCompletedTranscriptByPaneKey,
|
||||
state.claudeSubagentRosterByPaneKey,
|
||||
state.claudeLeadStateByPaneKey,
|
||||
state.codexSubagentRosterByPaneKey,
|
||||
state.codexSubagentTranscriptByPaneKey,
|
||||
state.codexLeadStateByPaneKey,
|
||||
state.grokActiveTurnByPaneKey
|
||||
]
|
||||
for (const map of paneMaps) {
|
||||
const cache = map as Map<string, unknown>
|
||||
cache.set(PANE, 'exact')
|
||||
cache.set(scoped, 'scoped')
|
||||
cache.set(sibling, 'sibling')
|
||||
}
|
||||
for (const [paneKey, prompt] of [
|
||||
[PANE, 'exact'],
|
||||
[scoped, 'scoped'],
|
||||
[sibling, 'sibling']
|
||||
]) {
|
||||
seedLegacyAgentStatusForTests(state, {
|
||||
paneKey,
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt }
|
||||
})
|
||||
}
|
||||
const paneSets = [
|
||||
state.ampCompletedCacheKeys,
|
||||
state.claudeUnconfirmedRestoredStatusPaneKeys,
|
||||
state.claudeRunningNonAgentTaskPaneKeys,
|
||||
state.claudeActiveSessionCronPaneKeys
|
||||
]
|
||||
for (const set of paneSets) {
|
||||
set.add(PANE)
|
||||
set.add(scoped)
|
||||
set.add(sibling)
|
||||
}
|
||||
|
||||
movePaneCacheState(state, PANE, MOVED_PANE)
|
||||
|
||||
for (const map of paneMaps) {
|
||||
const cache = map as Map<string, unknown>
|
||||
expect(cache.get(MOVED_PANE)).toBe('exact')
|
||||
expect(cache.get(movedScoped)).toBe('scoped')
|
||||
expect(cache.get(sibling)).toBe('sibling')
|
||||
expect(cache.has(PANE)).toBe(false)
|
||||
expect(cache.has(scoped)).toBe(false)
|
||||
}
|
||||
expect(state.lastStatusByPaneKey.get(MOVED_PANE)?.payload.prompt).toBe('exact')
|
||||
expect(state.lastStatusByPaneKey.get(movedScoped)?.payload.prompt).toBe('scoped')
|
||||
expect(state.lastStatusByPaneKey.get(sibling)?.payload.prompt).toBe('sibling')
|
||||
expect(state.lastStatusByPaneKey.has(PANE)).toBe(false)
|
||||
expect(state.lastStatusByPaneKey.has(scoped)).toBe(false)
|
||||
for (const set of paneSets) {
|
||||
expect(set.has(MOVED_PANE)).toBe(true)
|
||||
expect(set.has(movedScoped)).toBe(true)
|
||||
expect(set.has(sibling)).toBe(true)
|
||||
expect(set.has(PANE)).toBe(false)
|
||||
expect(set.has(scoped)).toBe(false)
|
||||
}
|
||||
|
||||
movePaneCacheState(state, MOVED_PANE, MOVED_PANE)
|
||||
expect((state.lastPromptByPaneKey as Map<string, unknown>).get(MOVED_PANE)).toBe('exact')
|
||||
})
|
||||
|
||||
it('preserves bound OpenCode session ownership and launch identity after a pane move', () => {
|
||||
const state = createHookListenerState()
|
||||
normalizeHookPayload(
|
||||
@@ -242,81 +100,6 @@ describe('agent hook extraction boundaries', () => {
|
||||
expect(state.lastLaunchTokenByPaneKey.has(PANE)).toBe(false)
|
||||
})
|
||||
|
||||
it('clears exact and NUL-scoped cache keys but not sibling prefixes', () => {
|
||||
const state = createHookListenerState()
|
||||
const scoped = `${PANE}\0thread`
|
||||
const sibling = `${PANE}-sibling`
|
||||
const paneMaps = [
|
||||
state.lastPromptByPaneKey,
|
||||
state.lastToolByPaneKey,
|
||||
state.antigravityCompletedTranscriptByPaneKey
|
||||
]
|
||||
for (const map of paneMaps) {
|
||||
const cache = map as Map<string, unknown>
|
||||
cache.set(PANE, 'exact')
|
||||
cache.set(scoped, 'scoped')
|
||||
cache.set(sibling, 'sibling')
|
||||
}
|
||||
for (const [paneKey, prompt] of [
|
||||
[PANE, 'exact'],
|
||||
[scoped, 'scoped'],
|
||||
[sibling, 'sibling']
|
||||
]) {
|
||||
seedLegacyAgentStatusForTests(state, {
|
||||
paneKey,
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt }
|
||||
})
|
||||
}
|
||||
state.ampCompletedCacheKeys.add(PANE)
|
||||
state.ampCompletedCacheKeys.add(scoped)
|
||||
state.ampCompletedCacheKeys.add(sibling)
|
||||
state.claudeLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 })
|
||||
state.codexLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 })
|
||||
state.grokActiveTurnByPaneKey.set(PANE, { promptId: 'prompt-1' })
|
||||
|
||||
clearPaneCacheState(state, PANE)
|
||||
|
||||
for (const map of paneMaps) {
|
||||
const cache = map as Map<string, unknown>
|
||||
expect(cache.has(PANE)).toBe(false)
|
||||
expect(cache.has(scoped)).toBe(false)
|
||||
expect(cache.get(sibling)).toBe('sibling')
|
||||
}
|
||||
expect(state.lastStatusByPaneKey.has(PANE)).toBe(false)
|
||||
expect(state.lastStatusByPaneKey.has(scoped)).toBe(false)
|
||||
expect(state.lastStatusByPaneKey.get(sibling)?.payload.prompt).toBe('sibling')
|
||||
expect(state.ampCompletedCacheKeys.has(scoped)).toBe(false)
|
||||
expect(state.ampCompletedCacheKeys.has(PANE)).toBe(false)
|
||||
expect(state.ampCompletedCacheKeys.has(sibling)).toBe(true)
|
||||
expect(state.claudeLeadStateByPaneKey.has(PANE)).toBe(false)
|
||||
expect(state.codexLeadStateByPaneKey.has(PANE)).toBe(false)
|
||||
expect(state.grokActiveTurnByPaneKey.has(PANE)).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves cache mutation from a provider reset that emits no row', () => {
|
||||
const state = createHookListenerState()
|
||||
state.lastPromptByPaneKey.set(PANE, 'old prompt')
|
||||
state.lastToolByPaneKey.set(PANE, { toolName: 'old tool' })
|
||||
seedLegacyAgentStatusForTests(state, {
|
||||
paneKey: PANE,
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: 'old status' }
|
||||
})
|
||||
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'droid',
|
||||
{ paneKey: PANE, payload: { hook_event_name: 'SessionStart' } },
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(event).toBeNull()
|
||||
expect(state.lastPromptByPaneKey.has(PANE)).toBe(false)
|
||||
expect(state.lastToolByPaneKey.has(PANE)).toBe(false)
|
||||
expect(state.lastStatusByPaneKey.has(PANE)).toBe(true)
|
||||
})
|
||||
|
||||
it('clears warning, provider, and lifecycle caches together', () => {
|
||||
const state = createHookListenerState()
|
||||
state.warnedVersions.add('old-version')
|
||||
|
||||
@@ -69,113 +69,6 @@ describe('Grok completion observations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the captured shutdown SessionEnd and trailing Stop as session boundaries', () => {
|
||||
const sessionEnd = normalize(
|
||||
capturedHook((hook) => hook.hookEventName === 'session_end' && hook.reason === 'shutdown')
|
||||
)
|
||||
const shutdownStop = normalize(
|
||||
capturedHook(
|
||||
(hook) =>
|
||||
hook.hookEventName === 'stop' &&
|
||||
hook.reason === 'shutdown' &&
|
||||
!Object.hasOwn(hook, 'backgroundTasks')
|
||||
)
|
||||
)
|
||||
|
||||
expect(sessionEnd).toMatchObject({
|
||||
state: 'done',
|
||||
sessionBoundary: true
|
||||
})
|
||||
expect(shutdownStop).toMatchObject({
|
||||
state: 'done',
|
||||
sessionBoundary: true
|
||||
})
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'Stop',
|
||||
reason: 'shutdown',
|
||||
stopHookActive: true,
|
||||
backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'running' }]
|
||||
})
|
||||
).toMatchObject({ state: 'done', sessionBoundary: true })
|
||||
})
|
||||
|
||||
it('settles a lead StopFailure without requiring background inventory', () => {
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'StopFailure',
|
||||
error: 'server_error',
|
||||
errorDetails: 'upstream failed',
|
||||
lastAssistantMessage: 'The request failed.'
|
||||
})
|
||||
).toMatchObject({
|
||||
state: 'done'
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a lead StopCancelled as interrupted', () => {
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'StopCancelled',
|
||||
reason: 'user_interrupt',
|
||||
cancelledBy: 'user',
|
||||
cancelTrigger: 'stop_gesture',
|
||||
reasonDetails: 'user interrupted the turn',
|
||||
lastAssistantMessage: 'Stopped by user.'
|
||||
})
|
||||
).toMatchObject({
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
lastAssistantMessage: 'Stopped by user.'
|
||||
})
|
||||
})
|
||||
|
||||
// Pins grok-events.ts: finite task predicate and sessionCrons omission; treating monitors/crons as finite must redden.
|
||||
it.each([
|
||||
{
|
||||
label: 'monitor-only',
|
||||
backgroundTasks: [
|
||||
{ id: 'monitor-1', type: 'monitor', status: 'running', description: 'watch the build' }
|
||||
],
|
||||
sessionCrons: []
|
||||
},
|
||||
{
|
||||
label: 'cron-only',
|
||||
backgroundTasks: [],
|
||||
sessionCrons: [
|
||||
{ id: 'cron-1', schedule: 'every minute', recurring: true, prompt: 'check the build' }
|
||||
]
|
||||
}
|
||||
])('settles when outstanding work is $label', ({ backgroundTasks, sessionCrons }) => {
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'Stop',
|
||||
reason: 'end_turn',
|
||||
stopHookActive: false,
|
||||
backgroundTasks,
|
||||
sessionCrons
|
||||
})?.state
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
// Legacy Grok versions omit both fields, so only positive finite-work evidence may hold the row.
|
||||
it.each([
|
||||
{},
|
||||
{ backgroundTasks: { unexpected: true } },
|
||||
{ backgroundTasks: [{ type: 'future-task', status: 'running' }] },
|
||||
{ backgroundTasks: [], stopHookActive: 'true' },
|
||||
{ background_tasks: [], stop_hook_active: false }
|
||||
])('fails open for unknown or legacy optional fields %#', (payload) => {
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'Stop',
|
||||
reason: 'end_turn',
|
||||
stopHookActive: false,
|
||||
...payload
|
||||
})?.state
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps ambiguous continuation Stops working and settles on the idle backstop', () => {
|
||||
normalize({
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
@@ -208,15 +101,6 @@ describe('Grok completion observations', () => {
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps a starting finite task working', () => {
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'Stop',
|
||||
backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'starting' }]
|
||||
})
|
||||
).toMatchObject({ state: 'working', workingMode: 'monitoring' })
|
||||
})
|
||||
|
||||
// Real Grok 1.0.41 Stop entry for a background `spawn_subagent`, identifiers dropped.
|
||||
const backgroundSubagent = {
|
||||
id: 'task-1',
|
||||
@@ -253,17 +137,6 @@ describe('Grok completion observations', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('reads the snake_case inventory the same way', () => {
|
||||
const row = normalize({
|
||||
hookEventName: 'Stop',
|
||||
reason: 'end_turn',
|
||||
stop_hook_active: false,
|
||||
background_tasks: [{ id: 'task-1', type: 'subagent', status: 'running' }]
|
||||
})
|
||||
expect(row).toMatchObject({ state: 'working', mainAgent: { state: 'done' } })
|
||||
expect(row?.workingMode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a background subagent working past a cancel, and settles only at a session boundary', () => {
|
||||
const cancelled = normalize({
|
||||
hookEventName: 'StopCancelled',
|
||||
@@ -436,30 +309,4 @@ describe('Grok completion observations', () => {
|
||||
).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('settles an unseen prompt id and a session-scoped idle event', () => {
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'StopCancelled',
|
||||
sessionId: 'session-1',
|
||||
promptId: 'bash-mode-prompt',
|
||||
reason: 'user_interrupt'
|
||||
})?.state
|
||||
).toBe('done')
|
||||
expect(
|
||||
normalize({ hookEventName: 'Notification', notificationType: 'idle_prompt' })?.state
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
// Pins grok-events.ts: permission/question branch ordering; gating waiting notifications on background work must redden.
|
||||
it('leaves the real ask-user-question notification path ungated', () => {
|
||||
expect(
|
||||
normalize({
|
||||
hookEventName: 'Notification',
|
||||
notificationType: 'elicitation_dialog',
|
||||
message: 'User question requested',
|
||||
level: 'info'
|
||||
})
|
||||
).toMatchObject({ state: 'waiting', agentType: 'grok' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,47 +72,6 @@ describe('shared agent-hook-listener', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('previews Grok-native tool names (run_terminal_command / search_replace)', () => {
|
||||
const shell = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hookEventName: 'PreToolUse',
|
||||
tool_name: 'run_terminal_command',
|
||||
tool_input: { command: 'git status' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
const edit = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hookEventName: 'PreToolUse',
|
||||
toolName: 'search_replace',
|
||||
toolInput: { path: 'src/app.ts', old_string: 'a', new_string: 'b' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(shell?.payload).toMatchObject({
|
||||
agentType: 'grok',
|
||||
state: 'working',
|
||||
toolName: 'run_terminal_command',
|
||||
toolInput: 'git status'
|
||||
})
|
||||
expect(edit?.payload).toMatchObject({
|
||||
agentType: 'grok',
|
||||
state: 'working',
|
||||
toolName: 'search_replace',
|
||||
toolInput: 'src/app.ts'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps Grok ask_user_question PreToolUse to waiting with interactivePrompt', () => {
|
||||
const questions = [
|
||||
{
|
||||
@@ -161,26 +120,6 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(answered?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not recreate a Grok question card on post_tool_use_failure', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hookEventName: 'post_tool_use_failure',
|
||||
toolName: 'ask_user_question',
|
||||
toolInput: { questions: [{ question: 'Retry?', options: ['yes', 'no'] }] },
|
||||
error: 'cancelled'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(event?.payload.state).toBe('working')
|
||||
expect(event?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces the Grok tool-failure error and clears stale tool fields', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
@@ -218,76 +157,6 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(failed?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps Grok StopFailure to done', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hookEventName: 'UserPromptSubmit', prompt: 'do work' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
const done = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hookEventName: 'StopFailure', error: 'api timeout' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(done?.payload).toMatchObject({
|
||||
agentType: 'grok',
|
||||
state: 'done',
|
||||
prompt: 'do work'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips Grok internal user_query wrapper before caching the prompt', () => {
|
||||
const prompt = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hookEventName: 'user_prompt_submit',
|
||||
prompt: '<user_query>\nFind recent PR\n</user_query>'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(prompt?.payload.prompt).toBe('Find recent PR')
|
||||
|
||||
const tool = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hookEventName: 'pre_tool_use',
|
||||
toolName: 'web_search',
|
||||
toolInput: { query: 'recent PR' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(tool?.payload.prompt).toBe('Find recent PR')
|
||||
})
|
||||
|
||||
it('strips Grok opening user_query wrapper even when the closing tag is absent', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hookEventName: 'user_prompt_submit', prompt: '<user_query>Find recent PR' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload.prompt).toBe('Find recent PR')
|
||||
})
|
||||
|
||||
it('preserves both ends of newline-heavy Grok user_query prompts', () => {
|
||||
const promptText = `Find recent PR${'\n'.repeat(300)}Keep the final requirement`
|
||||
const event = normalizeHookPayload(
|
||||
@@ -317,32 +186,6 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(tool?.payload.prompt).toBe('Find recent PR Keep the final requirement')
|
||||
})
|
||||
|
||||
it('maps Grok feedback notifications to waiting without overwriting the prompt', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{ paneKey: PANE_KEY, payload: { hookEventName: 'UserPromptSubmit', prompt: 'ship it' } },
|
||||
'production'
|
||||
)
|
||||
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'grok',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hookEventName: 'Notification', message: 'Grok needs your feedback to proceed' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.payload).toMatchObject({
|
||||
state: 'waiting',
|
||||
prompt: 'ship it',
|
||||
agentType: 'grok'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores Grok routine permission prompt notifications during tool use', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
@@ -517,18 +360,6 @@ describe('shared agent-hook-listener', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('treats Grok SessionEnd chat history as pending result text', () => {
|
||||
expect(
|
||||
hasPendingAgentResultText('grok', {
|
||||
payload: {
|
||||
hookEventName: 'SessionEnd',
|
||||
sessionId: '019e37f4-5135-7b63-a4ab-6d13aa6bf528',
|
||||
cwd: '/tmp/workspace'
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('enriches a long-cwd Grok result after async discovery completes', async () => {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-pending-home-'))
|
||||
const hookGrokHome = join(tmpDir, 'hook-grok')
|
||||
@@ -560,16 +391,4 @@ describe('shared agent-hook-listener', () => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not schedule Grok discovery for an invalid session id', () => {
|
||||
expect(
|
||||
hasPendingAgentResultText('grok', {
|
||||
payload: {
|
||||
hookEventName: 'SessionEnd',
|
||||
sessionId: '../escape',
|
||||
cwd: '/tmp/workspace'
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,112 +19,6 @@ describe('shared agent-hook-listener', () => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('normalizes Hermes pre_llm_call to a working turn with prompt text', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt',
|
||||
env: 'production',
|
||||
version: '1',
|
||||
payload: {
|
||||
hook_event_name: 'pre_llm_call',
|
||||
user_message: 'ship the Hermes support'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.payload.state).toBe('working')
|
||||
expect(event!.payload.prompt).toBe('ship the Hermes support')
|
||||
expect(event!.payload.agentType).toBe('hermes')
|
||||
})
|
||||
|
||||
it('normalizes Hermes tool calls and approval hooks', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'pre_llm_call',
|
||||
user_message: 'run tests'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
const tool = normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'pre_tool_call',
|
||||
tool_name: 'terminal',
|
||||
args: { command: 'pnpm test' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(tool?.payload.state).toBe('working')
|
||||
expect(tool?.payload.toolName).toBe('terminal')
|
||||
expect(tool?.payload.toolInput).toBe('pnpm test')
|
||||
expect(tool?.payload.prompt).toBe('run tests')
|
||||
|
||||
const approval = normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'pre_approval_request',
|
||||
command: 'rm -rf build',
|
||||
description: 'Remove stale build output'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(approval?.payload.state).toBe('waiting')
|
||||
expect(approval?.payload.toolName).toBe('approval')
|
||||
expect(approval?.payload.toolInput).toBe('rm -rf build')
|
||||
})
|
||||
|
||||
it('normalizes Hermes first-party tool argument previews', () => {
|
||||
const execute = normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'pre_tool_call',
|
||||
tool_name: 'execute_code',
|
||||
args: { code: 'print("ok")' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(execute?.payload.toolName).toBe('execute_code')
|
||||
expect(execute?.payload.toolInput).toBe('print("ok")')
|
||||
|
||||
const pluginTool = normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'pre_tool_call',
|
||||
tool_name: 'custom_plugin_tool',
|
||||
args: { query: 'agent hooks' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(pluginTool?.payload.toolName).toBe('custom_plugin_tool')
|
||||
expect(pluginTool?.payload.toolInput).toBe('agent hooks')
|
||||
})
|
||||
|
||||
it('clears stale Codex tool input when a same-tool update has explicit unpreviewable input', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
@@ -216,24 +110,6 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(stop?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps ordinary Codex PreToolUse mapped to working', () => {
|
||||
const working = normalizeHookPayload(
|
||||
state,
|
||||
'codex',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'shell',
|
||||
tool_input: { command: 'ls' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(working?.payload.state).toBe('working')
|
||||
expect(working?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records the new folder when Codex forks a thread elsewhere (/cd, /worktree)', () => {
|
||||
normalizeAndAccept(state, 'codex', {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
@@ -284,36 +160,6 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(next?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes Hermes post_llm_call to done with assistant text', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'pre_llm_call',
|
||||
user_message: 'summarize'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
const done = normalizeHookPayload(
|
||||
state,
|
||||
'hermes',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'post_llm_call',
|
||||
assistant_response: 'Hermes is wired up.'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(done?.payload.state).toBe('done')
|
||||
expect(done?.payload.prompt).toBe('summarize')
|
||||
expect(done?.payload.lastAssistantMessage).toBe('Hermes is wired up.')
|
||||
})
|
||||
|
||||
// Order captured from a real Hermes `delegate_task` run (session ids shortened).
|
||||
it("keeps the Hermes pane's provider session on the parent, never a delegate child", () => {
|
||||
const parent = { session_id: 'local-parent' }
|
||||
|
||||
@@ -19,28 +19,6 @@ describe('shared agent-hook-listener', () => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('normalizes a Claude UserPromptSubmit body to a working state', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt',
|
||||
env: 'production',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hello' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.paneKey).toBe(PANE_KEY)
|
||||
expect(event!.connectionId).toBeNull()
|
||||
expect(event!.payload.state).toBe('working')
|
||||
expect(event!.payload.prompt).toBe('hello')
|
||||
expect(event!.payload.agentType).toBe('claude')
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3])(
|
||||
'normalizes %i leading Cursor BOMs to a working state (#21421)',
|
||||
(count) => {
|
||||
@@ -82,27 +60,6 @@ describe('shared agent-hook-listener', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes Gemini BeforeTool to working with tool fields', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'gemini',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'BeforeTool',
|
||||
tool_name: 'read_file',
|
||||
args: { file_path: 'src/index.ts' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(event?.payload.state).toBe('working')
|
||||
expect(event?.payload.agentType).toBe('gemini')
|
||||
expect(event?.payload.toolName).toBe('read_file')
|
||||
expect(event?.payload.toolInput).toBe('src/index.ts')
|
||||
})
|
||||
|
||||
it('captures the full AskUserQuestion tool input as interactivePrompt (untruncated)', () => {
|
||||
const questions = {
|
||||
questions: Array.from({ length: 4 }, (_, i) => ({
|
||||
@@ -134,120 +91,6 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(event?.payload.interactivePrompt!.length).toBe(expected.length)
|
||||
})
|
||||
|
||||
it('maps Claude AskUserQuestion PreToolUse to waiting, then back to working on answer', () => {
|
||||
const question = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
const answered = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_response: { selected: ['a'] }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(question?.payload).toMatchObject({
|
||||
agentType: 'claude',
|
||||
state: 'waiting',
|
||||
toolName: 'AskUserQuestion'
|
||||
})
|
||||
expect(answered?.payload).toMatchObject({
|
||||
agentType: 'claude',
|
||||
state: 'working',
|
||||
toolName: 'AskUserQuestion'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a normal Claude PreToolUse tool call as working', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload.state).toBe('working')
|
||||
expect(event?.payload.toolName).toBe('Bash')
|
||||
})
|
||||
|
||||
it('leaves interactivePrompt undefined for a normal tool call', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/tmp/x.ts' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload.toolName).toBe('Edit')
|
||||
expect(event?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures an approval envelope as interactivePrompt on a PermissionRequest', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'rm -rf build' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload.interactivePrompt).toBe(
|
||||
JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('captures an approval envelope for a Codex PermissionRequest', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'codex',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: 'shell',
|
||||
input: { command: 'git push --force' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload.interactivePrompt).toBe(
|
||||
JSON.stringify({ approval: { tool: 'shell', summary: 'git push --force' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears interactivePrompt on the next tool event after AskUserQuestion', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
@@ -363,38 +206,4 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(answered?.payload.state).toBe('working')
|
||||
expect(answered?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-assert the AskUserQuestion prompt on PostToolUse', () => {
|
||||
// Answered questions must clear the card instead of rebuilding it from carried tool input.
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_input: { questions: [{ question: 'Pick', options: ['a'] }] }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload.toolName).toBe('AskUserQuestion')
|
||||
expect(event?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures interactivePrompt for the OpenCode AskUserQuestion route', () => {
|
||||
const properties = { questions: [{ question: 'Choose', options: ['x', 'y'] }] }
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'opencode',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hook_event_name: 'AskUserQuestion', ...properties }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload.state).toBe('waiting')
|
||||
expect(event?.payload.interactivePrompt).toBe(JSON.stringify(properties))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,46 +33,6 @@ describe('shared agent-hook-listener: jcode', () => {
|
||||
rmSync(jcodeHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps turn_start to working before any tool has run', () => {
|
||||
const event = ingest(state, {
|
||||
event: 'turn_start',
|
||||
session_id: 'session_jc_1',
|
||||
model: 'claude-haiku-4-5',
|
||||
source: 'chat'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({
|
||||
agentType: 'jcode',
|
||||
state: 'working',
|
||||
model: 'claude-haiku-4-5'
|
||||
})
|
||||
expect(event?.payload?.toolName).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports the live tool from pre_tool, before the tool has finished', () => {
|
||||
const event = ingest(state, {
|
||||
event: 'pre_tool',
|
||||
session_id: 'session_jc_1',
|
||||
tool_name: 'read',
|
||||
tool_input: '{"file_path":"sample.txt","intent":"Read sample.txt to get its contents"}'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({
|
||||
agentType: 'jcode',
|
||||
state: 'working',
|
||||
toolName: 'read',
|
||||
toolInput: 'sample.txt'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the tool intent when no tool-specific key matches', () => {
|
||||
const event = ingest(state, {
|
||||
event: 'pre_tool',
|
||||
session_id: 'session_jc_1',
|
||||
tool_name: 'swarm',
|
||||
tool_input: '{"action":"status","intent":"Check on the workers"}'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({ toolName: 'swarm', toolInput: 'Check on the workers' })
|
||||
})
|
||||
|
||||
it('keeps the pre_tool input visible when post_tool reports completion', () => {
|
||||
ingest(state, {
|
||||
event: 'pre_tool',
|
||||
@@ -95,24 +55,6 @@ describe('shared agent-hook-listener: jcode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a pending request_permission to waiting with the full question', () => {
|
||||
const event = ingest(state, {
|
||||
event: 'pre_tool',
|
||||
session_id: 'session_jc_2',
|
||||
tool_name: 'request_permission',
|
||||
tool_input: '{"action":"delete the staging bucket","reason":"Why this needs approval"}'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({
|
||||
agentType: 'jcode',
|
||||
state: 'waiting',
|
||||
toolName: 'request_permission'
|
||||
})
|
||||
expect(JSON.parse(event?.payload?.interactivePrompt ?? '{}')).toMatchObject({
|
||||
action: 'delete the staging bucket',
|
||||
reason: 'Why this needs approval'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not re-open a question on post_tool, which fires after the answer', () => {
|
||||
const pending = ingest(state, {
|
||||
event: 'pre_tool',
|
||||
@@ -132,32 +74,6 @@ describe('shared agent-hook-listener: jcode', () => {
|
||||
expect(event?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves unrelated tool names working even when they read like a question', () => {
|
||||
const event = ingest(state, {
|
||||
event: 'pre_tool',
|
||||
session_id: 'session_jc_2',
|
||||
tool_name: 'conversation_search',
|
||||
tool_input: '{"query":"what did we confirm about the ask flow"}'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({ state: 'working', toolName: 'conversation_search' })
|
||||
})
|
||||
|
||||
it('maps turn_end to done with the last assistant text', () => {
|
||||
const event = ingest(state, {
|
||||
event: 'turn_end',
|
||||
session_id: 'session_jc_3',
|
||||
status: 'ok',
|
||||
duration_ms: '6868',
|
||||
model: 'claude-haiku-4-5',
|
||||
last_assistant_text: 'Done.'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({
|
||||
agentType: 'jcode',
|
||||
state: 'done',
|
||||
lastAssistantMessage: 'Done.'
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the turn error instead of a stale reply when a turn fails', () => {
|
||||
const previous = ingest(state, {
|
||||
event: 'turn_end',
|
||||
@@ -179,29 +95,6 @@ describe('shared agent-hook-listener: jcode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the finished turn detail a completion notification needs', () => {
|
||||
// Done banners require reply/tool detail; retain the last tool when the turn ends without prose.
|
||||
ingest(state, {
|
||||
event: 'turn_start',
|
||||
session_id: 'session_jc_6',
|
||||
model: 'claude-haiku-4-5',
|
||||
source: 'chat'
|
||||
})
|
||||
ingest(state, {
|
||||
event: 'pre_tool',
|
||||
session_id: 'session_jc_6',
|
||||
tool_name: 'write',
|
||||
tool_input: '{"file_path":"SUMMARY.md","content":"# Summary"}'
|
||||
})
|
||||
const event = ingest(state, {
|
||||
event: 'turn_end',
|
||||
session_id: 'session_jc_6',
|
||||
status: 'ok',
|
||||
duration_ms: '18000'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({ state: 'done', toolName: 'write' })
|
||||
})
|
||||
|
||||
it('clears the previous turn tool when a new turn starts', () => {
|
||||
const previous = ingest(state, {
|
||||
event: 'pre_tool',
|
||||
@@ -219,41 +112,4 @@ describe('shared agent-hook-listener: jcode', () => {
|
||||
expect(event?.payload?.toolName).toBeUndefined()
|
||||
expect(event?.payload?.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats session_start as identity-only (no status row)', () => {
|
||||
const event = ingest(state, {
|
||||
event: 'session_start',
|
||||
session_id: 'session_jc_5',
|
||||
model: 'claude-haiku-4-5',
|
||||
source: 'create'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({ agentType: 'jcode', state: 'done' })
|
||||
expect(event?.providerSession).toEqual({ key: 'session_id', id: 'session_jc_5' })
|
||||
expect(event?.providerSessionOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('reads the lifecycle point from jcode\u2019s own `event` key', () => {
|
||||
// Managed transport posts JCODE_HOOK_PAYLOAD verbatim; accept its event field without form restatement.
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'jcode',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { event: 'turn_start', session_id: 'session_jc_7', model: 'claude-haiku-4-5' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event?.payload).toMatchObject({ agentType: 'jcode', state: 'working' })
|
||||
})
|
||||
|
||||
it('does not count a direct jcode prompt without journal evidence as explicit', () => {
|
||||
// Hook prompt fields lack journal backing and must not imply explicit submission.
|
||||
const event = ingest(state, {
|
||||
event: 'post_tool',
|
||||
tool_name: 'read',
|
||||
prompt: 'fix the bug'
|
||||
})
|
||||
expect(event?.payload).toMatchObject({ agentType: 'jcode', state: 'working' })
|
||||
expect(event?.hasExplicitPrompt).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,34 +32,6 @@ describe('OMP approval ownership', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// omp 17.0.5's `tools.approvalMode` enum, captured off the wire from a real run of each mode.
|
||||
it.each(['always-ask', 'write', 'yolo'])(
|
||||
'keeps an approval request blocked under approval_mode %s',
|
||||
(approvalMode) => {
|
||||
expect(approvalRequest({ approval_mode: approvalMode })?.payload).toMatchObject({
|
||||
state: 'blocked',
|
||||
agentType: 'omp',
|
||||
toolName: 'bash'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['missing', {}],
|
||||
['unrecognised', { approval_mode: 'some-future-mode' }],
|
||||
['non-string', { approval_mode: 3 }],
|
||||
['null', { approval_mode: null }]
|
||||
])('treats a %s approval_mode exactly like today', (_name, extra) => {
|
||||
expect(approvalRequest(extra)?.payload.state).toBe('blocked')
|
||||
})
|
||||
|
||||
it('reports the reason omp sends as the tool input preview', () => {
|
||||
// omp's bash tool returns this reason for its critical-pattern override.
|
||||
expect(
|
||||
approvalRequest({ reason: 'Critical pattern detected', approval_mode: 'write' })?.payload
|
||||
).toMatchObject({ state: 'blocked', toolInput: 'Critical pattern detected' })
|
||||
})
|
||||
|
||||
it('clears the previous tool input when omp sends no reason', () => {
|
||||
expect(
|
||||
approvalRequest({ reason: 'Critical pattern detected', approval_mode: 'always-ask' })?.payload
|
||||
@@ -72,17 +44,6 @@ describe('OMP approval ownership', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the wait on resolution regardless of the mode that requested it', () => {
|
||||
expect(approvalRequest({ approval_mode: 'yolo' })?.payload.state).toBe('blocked')
|
||||
expect(
|
||||
post('omp', {
|
||||
hook_event_name: 'tool_approval_resolved',
|
||||
tool_name: 'bash',
|
||||
approved: true
|
||||
})?.payload
|
||||
).toMatchObject({ state: 'working', toolName: 'bash' })
|
||||
})
|
||||
|
||||
/** Normalizes the post and admits its row, as the hook server does, so the next post sees it. */
|
||||
function deliver(payload: Record<string, unknown>): string {
|
||||
const event = post('omp', payload)
|
||||
@@ -119,28 +80,4 @@ describe('OMP approval ownership', () => {
|
||||
expect(deliver({ hook_event_name: 'tool_execution_start', tool_name: 'ask' })).toBe('blocked')
|
||||
expect(deliver({ hook_event_name: 'tool_execution_end', tool_name: 'ask' })).toBe('working')
|
||||
})
|
||||
|
||||
// Pi and prime-agent share this normalizer but have no approval lifecycle, so the field must not
|
||||
// give their panes a status row.
|
||||
it.each(['always-ask', 'write', 'yolo'])(
|
||||
'still ignores a Pi approval request carrying approval_mode %s',
|
||||
(approvalMode) => {
|
||||
expect(
|
||||
post('pi', {
|
||||
hook_event_name: 'tool_approval_requested',
|
||||
tool_name: 'bash',
|
||||
approval_mode: approvalMode
|
||||
})
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a Pi input modal waiting, not blocked, when approval_mode is present', () => {
|
||||
expect(
|
||||
post('pi', {
|
||||
hook_event_name: 'ui_prompt_start',
|
||||
approval_mode: 'yolo'
|
||||
})?.payload.state
|
||||
).toBe('waiting')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { normalizeHookPayload } from './agent-hook-listener'
|
||||
import { PANE_KEY } from './agent-hook-listener-test-harness'
|
||||
import { AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH } from './agent-status-types'
|
||||
import { buildOmpPlanReviewAnswer, parseOmpPlanReviewPrompt } from './omp-plan-review-prompt'
|
||||
import { parseOmpPlanReviewPrompt } from './omp-plan-review-prompt'
|
||||
|
||||
const REVIEW_ID = '86949d10-20aa-46d4-9359-4e1e71467d8f'
|
||||
|
||||
@@ -35,21 +35,6 @@ describe('OMP plan review status', () => {
|
||||
state = createHookListenerState()
|
||||
})
|
||||
|
||||
it('blocks on the review with a parsed plan card and answer choices', () => {
|
||||
const blocked = requestReview(state, '# Add a greeting')
|
||||
|
||||
expect(blocked?.state).toBe('blocked')
|
||||
expect(parseOmpPlanReviewPrompt(blocked?.interactivePrompt)).toEqual({
|
||||
reviewId: REVIEW_ID,
|
||||
plan: '# Add a greeting',
|
||||
options: [
|
||||
{ label: 'Approve and execute', send: buildOmpPlanReviewAnswer(REVIEW_ID, 'execute') },
|
||||
{ label: 'Refine plan', send: buildOmpPlanReviewAnswer(REVIEW_ID, 'dismiss') },
|
||||
{ label: 'Save and quit', send: buildOmpPlanReviewAnswer(REVIEW_ID, 'save') }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('clips a long plan so the card still parses', () => {
|
||||
const blocked = requestReview(state, `"quoted"\n${'x'.repeat(40_000)}`)
|
||||
|
||||
@@ -61,17 +46,6 @@ describe('OMP plan review status', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'done'],
|
||||
[false, 'working']
|
||||
])('ends the review wait when OMP closes it (idle %s)', (isIdle, expected) => {
|
||||
requestReview(state, '# Plan')
|
||||
const resolved = post(state, { hook_event_name: 'plan_review_resolved', is_idle: isIdle })
|
||||
|
||||
expect(resolved?.state).toBe(expected)
|
||||
expect(resolved?.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps the card when an earlier turn's last async child ends under the review", () => {
|
||||
const deliver = (payload: Record<string, unknown>) => {
|
||||
const event = normalizeHookPayload(state, 'omp', { paneKey: PANE_KEY, payload }, 'production')
|
||||
|
||||
@@ -39,12 +39,6 @@ describe('OpenCode 1 shared-server reattribution (#21359)', () => {
|
||||
expect(result?.payload.state).toBe('working')
|
||||
})
|
||||
|
||||
it('keeps the stamped pane for unbound sessions', () => {
|
||||
const state = createHookListenerState()
|
||||
const result = opencodeBusy(state, PANE_A, 'ses_unknown')
|
||||
expect(result?.paneKey).toBe(PANE_A)
|
||||
})
|
||||
|
||||
it('uses the bound pane live launch token', () => {
|
||||
const state = createHookListenerState()
|
||||
// A tokened post teaches the listener pane B's live token.
|
||||
@@ -86,22 +80,6 @@ describe('OpenCode 1 shared-server reattribution (#21359)', () => {
|
||||
expect(result?.launchToken).toBe('token-a')
|
||||
})
|
||||
|
||||
it('leaves other sources untouched', () => {
|
||||
const state = createHookListenerState()
|
||||
bindOpenCodeSession(state, 'ses_1', {
|
||||
paneKey: PANE_B,
|
||||
boundAt: 1,
|
||||
basis: 'argv'
|
||||
})
|
||||
const result = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{ paneKey: PANE_A, payload: { hook_event_name: 'Stop', session_id: 'ses_1' } },
|
||||
'production'
|
||||
)
|
||||
expect(result?.paneKey).toBe(PANE_A)
|
||||
})
|
||||
|
||||
it('never lets a stale same-pane stamp overwrite the live token', () => {
|
||||
const state = createHookListenerState()
|
||||
// A tokened post teaches the listener pane B's live token.
|
||||
|
||||
@@ -15,45 +15,15 @@ import { tokenizeStartupCommand } from './tui-agent-startup-shell'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as choiceArgs from './agent-choice-args'
|
||||
import { reconcileAgentChoiceArgs } from './agent-choice-reconciliation'
|
||||
import { resolveAgentChoiceLayers } from './agent-choice-layers'
|
||||
import './agent-choice-layers'
|
||||
import { projectAgentLaunchProfile } from './agent-launch-profile'
|
||||
import { agentLaunchNeedsPreparedCommand } from './agent-launch-preferences'
|
||||
import './agent-launch-preferences'
|
||||
|
||||
const keys = new Set(['model', 'effort', 'permissionMode'])
|
||||
const load = (agent: 'claude' | 'codex', userArgs: string) =>
|
||||
reconcileAgentChoiceArgs({ agent, userArgs, choices: {}, mode: 'load', keys })
|
||||
|
||||
describe('dedicated agent choices', () => {
|
||||
it.each([
|
||||
['-c modelx', undefined, false],
|
||||
['--config=modelx', undefined, false],
|
||||
['-c model_reasoning_effortx', undefined, false],
|
||||
['-c model=value', { model: 'value' }, true],
|
||||
['-c model_reasoning_effort=low', { options: { effort: 'low' } }, true],
|
||||
['-c model=42', undefined, true]
|
||||
] as const)('requires a config assignment for %s ownership', (userArgs, choices, owned) => {
|
||||
const migrated = load('codex', userArgs)
|
||||
const patch = { model: 'gpt-5.5', options: { effort: 'high' } }
|
||||
const edited = reconcileAgentChoiceArgs({
|
||||
agent: 'codex',
|
||||
userArgs,
|
||||
choices: {},
|
||||
mode: 'choice-edit',
|
||||
keys,
|
||||
patch
|
||||
})
|
||||
const projected = projectAgentLaunchProfile(
|
||||
'codex',
|
||||
{ agentPermissionDefault: 'own' },
|
||||
{ target: 'terminal', userArgs, choices: patch }
|
||||
)
|
||||
expect.soft(migrated).toEqual(choices ? { userArgs: '', choices } : { userArgs, choices: {} })
|
||||
expect.soft(edited).toEqual({ userArgs: owned ? '' : userArgs, choices: patch })
|
||||
expect(projected.args).toBe(
|
||||
['-m gpt-5.5 -c model_reasoning_effort=high', owned ? '' : userArgs].filter(Boolean).join(' ')
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds range comparisons and copied bytes on repeated-flag choice edits', () => {
|
||||
const analyze = choiceArgs.analyzeAgentChoiceArgs
|
||||
const slice = String.prototype.slice
|
||||
@@ -143,26 +113,6 @@ describe('dedicated agent choices', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('lifts a complete tuple atomically and keeps surviving raw bytes', () => {
|
||||
expect(
|
||||
load('claude', '--model saved-unlisted-id --effort high --permission-mode auto')
|
||||
).toEqual({
|
||||
userArgs: '',
|
||||
choices: { model: 'saved-unlisted-id', options: { effort: 'high' }, permissionMode: 'auto' }
|
||||
})
|
||||
expect(load('codex', '-c model=opus --search -c model_reasoning_effort=high')).toEqual({
|
||||
userArgs: '--search',
|
||||
choices: { model: 'opus', options: { effort: 'high' } }
|
||||
})
|
||||
expect(load('claude', '--effort high')).toEqual({
|
||||
userArgs: '',
|
||||
choices: { options: { effort: 'high' } }
|
||||
})
|
||||
expect(load('claude', '--model opus --append-system-prompt "keep spacing"').userArgs).toBe(
|
||||
'--append-system-prompt "keep spacing"'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps duplicates, operands, uncertain regions and unproved TOML literal', () => {
|
||||
for (const text of [
|
||||
'-m old --model new',
|
||||
@@ -276,95 +226,6 @@ describe('dedicated agent choices', () => {
|
||||
).args
|
||||
).toBe('--model claude-opus-4-8-thinking --verbose')
|
||||
})
|
||||
|
||||
it('does not revive remembered model/options when raw text defeats a yielding layer', () => {
|
||||
const input = {
|
||||
agent: 'claude' as const,
|
||||
remembered: { model: 'sonnet', options: { effort: 'high' } },
|
||||
action: { permissionMode: 'ask' as const },
|
||||
argumentOwner: 'launch' as const,
|
||||
launchPreferencesYieldToAgentArgs: true
|
||||
}
|
||||
expect(resolveAgentChoiceLayers({ ...input, userArgs: '--model haiku' })).toEqual({
|
||||
permissionMode: 'ask'
|
||||
})
|
||||
expect(resolveAgentChoiceLayers({ ...input, userArgs: '--effort medium' })).toEqual({
|
||||
model: 'sonnet',
|
||||
permissionMode: 'ask'
|
||||
})
|
||||
expect(
|
||||
resolveAgentChoiceLayers({ ...input, userArgs: '--model haiku', explicit: { model: 'opus' } })
|
||||
).toEqual({ model: 'opus', permissionMode: 'ask' })
|
||||
})
|
||||
|
||||
it('resolves raw inheritance before projecting and never imports another model’s options', () => {
|
||||
expect(
|
||||
resolveAgentChoiceLayers({
|
||||
agent: 'claude',
|
||||
defaults: { model: 'sonnet' },
|
||||
action: { model: 'opus' },
|
||||
userArgs: '--append-system-prompt hello',
|
||||
argumentOwner: 'launch',
|
||||
launchPreferencesYieldToAgentArgs: true
|
||||
})
|
||||
).toEqual({ model: 'opus' })
|
||||
expect(
|
||||
resolveAgentChoiceLayers({
|
||||
agent: 'claude',
|
||||
remembered: { model: 'sonnet', options: { effort: 'high' } },
|
||||
explicit: { model: 'opus' },
|
||||
argumentOwner: 'defaults'
|
||||
})
|
||||
).toEqual({ model: 'opus' })
|
||||
expect(
|
||||
resolveAgentChoiceLayers({
|
||||
agent: 'claude',
|
||||
remembered: { model: 'sonnet' },
|
||||
defaults: { options: { effort: 'high' } },
|
||||
argumentOwner: 'defaults'
|
||||
})
|
||||
).toEqual({ model: 'sonnet', options: { effort: 'high' } })
|
||||
expect(
|
||||
projectAgentLaunchProfile(
|
||||
'claude',
|
||||
{ agentPermissionDefault: 'ask' },
|
||||
{ target: 'terminal', choices: { options: { effort: 'high' } }, userArgs: '' }
|
||||
).args
|
||||
).toContain('--effort')
|
||||
expect(
|
||||
projectAgentLaunchProfile(
|
||||
'claude',
|
||||
{ agentPermissionDefault: 'ask' },
|
||||
{ target: 'structured', choices: { model: 'opus' }, userArgs: '' }
|
||||
)
|
||||
).toEqual({
|
||||
args: '',
|
||||
choices: { model: 'opus', permissionMode: 'ask' },
|
||||
launch: { args: ['--model', 'opus'], appliedValues: { model: 'opus' } }
|
||||
})
|
||||
expect(agentLaunchNeedsPreparedCommand('codex', { model: 'unlisted', effort: 'high' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('adds one Claude settings carrier only for deliberate Fast, including explicit off', () => {
|
||||
for (const value of ['true', 'false']) {
|
||||
const launch = projectAgentLaunchProfile('claude', undefined, {
|
||||
target: 'terminal',
|
||||
userArgs: '--add-dir docs',
|
||||
choices: { options: { fastMode: value } }
|
||||
})
|
||||
expect(launch.args).toContain(`"fastMode":${value}`)
|
||||
expect(launch.args.match(/--settings/g)).toHaveLength(1)
|
||||
}
|
||||
expect(
|
||||
projectAgentLaunchProfile('claude', undefined, {
|
||||
target: 'terminal',
|
||||
userArgs: '--add-dir docs',
|
||||
choices: { model: 'opus' }
|
||||
}).args
|
||||
).not.toContain('--settings')
|
||||
})
|
||||
|
||||
it('edits only the last inline Claude settings object and never unshadows earlier settings on removal', () => {
|
||||
@@ -415,27 +276,6 @@ it('leaves effective file and unparseable Claude settings literal and never repo
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips arbitrary host-owned Codex tier ids through the common config carrier without a release list', () => {
|
||||
const userArgs = '-c service_tier=account_fast_v9 --add-dir docs'
|
||||
const read = reconcileAgentChoiceArgs({
|
||||
agent: 'codex',
|
||||
userArgs,
|
||||
choices: {},
|
||||
mode: 'arguments-edit',
|
||||
keys: new Set(['serviceTier'])
|
||||
})
|
||||
expect(read).toEqual({
|
||||
userArgs: '--add-dir docs',
|
||||
choices: { options: { serviceTier: 'account_fast_v9' } }
|
||||
})
|
||||
const launch = projectAgentLaunchProfile('codex', undefined, {
|
||||
target: 'terminal',
|
||||
userArgs: read.userArgs,
|
||||
choices: read.choices
|
||||
})
|
||||
expect(launch.args).toBe('-c service_tier=account_fast_v9 --add-dir docs')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude', '--add-dir /tmp/😀 --model "opus" --effort high -- tail'],
|
||||
['claude', '--add-dir /tmp --model=opus --effort=high -- tail'],
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type AgentLaunchSettingsSource
|
||||
} from './agent-launch-settings'
|
||||
import { migrateAgentPermissionChoice } from './tui-agent-permission-lift'
|
||||
import { structuredLaunchSeedOptions } from './native-chat/input/native-chat-session-option-defaults'
|
||||
import './native-chat/input/native-chat-session-option-defaults'
|
||||
|
||||
const baseline: AgentLaunchSettingsSource = {
|
||||
agentPermissionDefault: 'ask',
|
||||
@@ -163,48 +163,3 @@ it('uses original raw ownership before compiling authored terminal defaults', ()
|
||||
expect(input(raw).sessionOptions).toEqual({ model: 'opus', effort: 'high' })
|
||||
expect(launch(input(raw))).toContain('{"language":"french","fastMode":false}')
|
||||
})
|
||||
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'preserves encoded boolean seeds through authored/remembered layering for %s',
|
||||
(agent) => {
|
||||
const remembered = {
|
||||
model: 'remembered-model',
|
||||
valuesByModel: { 'remembered-model': { effort: 'low', fastMode: true } }
|
||||
}
|
||||
const settings = {
|
||||
agentPermissionDefault: 'ask' as const,
|
||||
nativeChatSessionOptions: { [agent]: remembered },
|
||||
agentLaunchChoices: {}
|
||||
}
|
||||
expect(structuredLaunchSeedOptions(settings, agent)).toMatchObject({
|
||||
model: 'remembered-model',
|
||||
effort: 'low',
|
||||
fastMode: 'true'
|
||||
})
|
||||
const authored = {
|
||||
...settings,
|
||||
agentLaunchChoices: {
|
||||
[agent]: { model: 'authored-model', options: { effort: 'high', fastMode: 'false' } }
|
||||
}
|
||||
}
|
||||
expect(structuredLaunchSeedOptions(authored, agent)).toMatchObject({
|
||||
model: 'authored-model',
|
||||
effort: 'high',
|
||||
fastMode: 'false'
|
||||
})
|
||||
expect(
|
||||
structuredLaunchSeedOptions({ ...authored, agentLaunchChoices: {} }, agent)
|
||||
).toMatchObject({ model: 'remembered-model', effort: 'low', fastMode: 'true' })
|
||||
expect(settings.nativeChatSessionOptions[agent]).toEqual(remembered)
|
||||
expect(
|
||||
structuredLaunchSeedOptions(
|
||||
{
|
||||
agentLaunchChoices: {
|
||||
[agent]: { options: { effort: 'high', fastMode: 'false' } }
|
||||
}
|
||||
},
|
||||
agent
|
||||
)
|
||||
).toMatchObject({ effort: 'high', fastMode: 'false' })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -119,29 +119,6 @@ it('handles a whole-map legacy no-op, removed sibling and permission carrier in
|
||||
expect(changed.agentDefaultArgs?.claude).toBe('--verbose --debug')
|
||||
})
|
||||
|
||||
it('does not conserve generated ownership through duplicate, operand, terminator or changed model carriers', () => {
|
||||
const settings: AgentLaunchSettingsSource = {
|
||||
agentPermissionDefault: 'own',
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
agentLaunchChoices: { claude: { model: String.raw`org\model`, options: { effort: 'high' } } },
|
||||
agentDefaultArgs: { claude: '--verbose' }
|
||||
}
|
||||
const text = projectAgentLaunchSettings(settings, 'win32').agentDefaultArgs.claude!
|
||||
for (const value of [
|
||||
`${text} --model other`,
|
||||
`-- ${text}`,
|
||||
`--append-system-prompt ${text}`,
|
||||
'--model different --verbose'
|
||||
]) {
|
||||
const update = agentLaunchSettingsMutationUpdates(
|
||||
settings,
|
||||
{ type: 'arguments', agent: 'claude', value },
|
||||
'win32'
|
||||
)
|
||||
expect(update.agentLaunchChoices?.claude?.model).not.toBe(String.raw`org\model`)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native-only unrepresented choices and cuts Windows settings with their actual grammar', () => {
|
||||
const native: AgentLaunchSettingsSource = {
|
||||
agentPermissionDefault: 'own',
|
||||
|
||||
@@ -1,127 +1,9 @@
|
||||
/** Fingerprint behavioral fields, excluding telemetry: omissions can replay a different launch; extra attribution fields can reject an honest retry. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
computeAgentLaunchFingerprint,
|
||||
type AgentLaunchFingerprintInput
|
||||
} from './agent-launch-operation'
|
||||
import { computeAgentLaunchFingerprint } from './agent-launch-operation'
|
||||
import { canonicalAgentSessionDigest } from './agent-session/lifecycle/agent-session-mutation-envelope'
|
||||
|
||||
const BASE = {
|
||||
agent: 'claude',
|
||||
target: { kind: 'existing' as const, worktree: 'wt-1' }
|
||||
}
|
||||
const PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301'
|
||||
const OTHER_PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:6fa459ea-ee8a-4ca4-894e-db77e160355e'
|
||||
|
||||
/** The handler passes whole wire objects; test runtime exclusion of launchSource rather than its absence from the input type. */
|
||||
function fingerprintOfWirePayload(
|
||||
params: AgentLaunchFingerprintInput & { launchSource?: string }
|
||||
): string {
|
||||
return computeAgentLaunchFingerprint(params)
|
||||
}
|
||||
|
||||
describe('fields the launch fingerprint covers', () => {
|
||||
it('covers encoded chat choices while omission keeps the previous digest', () => {
|
||||
expect(
|
||||
computeAgentLaunchFingerprint({ ...BASE, chatOptions: { model: 'opus', fastMode: 'false' } })
|
||||
).not.toBe(computeAgentLaunchFingerprint(BASE))
|
||||
expect(
|
||||
computeAgentLaunchFingerprint({ ...BASE, chatOptions: { model: 'opus', fastMode: 'false' } })
|
||||
).not.toBe(
|
||||
computeAgentLaunchFingerprint({ ...BASE, chatOptions: { model: 'opus', fastMode: 'true' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('separates two launches that differ only in agentArgs', () => {
|
||||
expect(computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus' })).not.toBe(
|
||||
computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model sonnet' })
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an explicit "no arguments" as different from falling back to the settings default', () => {
|
||||
// Preserve explicit-clear versus default-arguments identity across retries.
|
||||
expect(computeAgentLaunchFingerprint({ ...BASE, agentArgs: null })).not.toBe(
|
||||
computeAgentLaunchFingerprint(BASE)
|
||||
)
|
||||
})
|
||||
|
||||
it('separates two launches that differ only in cwd', () => {
|
||||
expect(computeAgentLaunchFingerprint({ ...BASE, cwd: '/repo/packages/a' })).not.toBe(
|
||||
computeAgentLaunchFingerprint({ ...BASE, cwd: '/repo/packages/b' })
|
||||
)
|
||||
})
|
||||
|
||||
it('separates two launches that reserved different panes or sessions', () => {
|
||||
// The key is baked into the pane's env; replaying another pane's key strands the new reservation.
|
||||
expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe(
|
||||
computeAgentLaunchFingerprint({ ...BASE, paneKey: OTHER_PANE_KEY })
|
||||
)
|
||||
expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe(
|
||||
computeAgentLaunchFingerprint(BASE)
|
||||
)
|
||||
const sessionId = '11111111-1111-4111-8111-111111111111'
|
||||
expect(computeAgentLaunchFingerprint({ ...BASE, sessionId })).not.toBe(
|
||||
computeAgentLaunchFingerprint({ ...BASE, sessionId: '22222222-2222-4222-8222-222222222222' })
|
||||
)
|
||||
expect(computeAgentLaunchFingerprint({ ...BASE, sessionId })).not.toBe(
|
||||
computeAgentLaunchFingerprint(BASE)
|
||||
)
|
||||
})
|
||||
|
||||
it('separates a retry that picked another surface, or none, from the first launch', () => {
|
||||
const terminal = computeAgentLaunchFingerprint({ ...BASE, surface: 'terminal' })
|
||||
expect(terminal).not.toBe(computeAgentLaunchFingerprint({ ...BASE, surface: 'chat' }))
|
||||
expect(terminal).not.toBe(computeAgentLaunchFingerprint(BASE))
|
||||
})
|
||||
})
|
||||
|
||||
describe('fields the launch fingerprint deliberately ignores', () => {
|
||||
it('does not separate two launches that differ only in launchSource', () => {
|
||||
// Attribution changes must not turn an identical launch retry into a conflict.
|
||||
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'shortcut' })).toBe(
|
||||
fingerprintOfWirePayload({ ...BASE, launchSource: 'tab_bar_quick_launch' })
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores launchSource entirely, so sending one matches sending none', () => {
|
||||
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'sidebar' })).toBe(
|
||||
computeAgentLaunchFingerprint(BASE)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatibility with rows written before these fields existed', () => {
|
||||
/** Preserve previous-build digests when new fields are absent so host upgrades do not refuse in-flight retries. */
|
||||
it('matches the pre-existing digest when no new field is sent', () => {
|
||||
const previousBuild = canonicalAgentSessionDigest({
|
||||
method: 'agent.launch',
|
||||
agent: BASE.agent,
|
||||
target: BASE.target,
|
||||
prompt: undefined,
|
||||
sessionOptions: undefined,
|
||||
reuseTerminal: undefined
|
||||
})
|
||||
expect(computeAgentLaunchFingerprint(BASE)).toBe(previousBuild)
|
||||
})
|
||||
|
||||
it('matches the digest from before the pane key existed when no pane is reserved', () => {
|
||||
const beforePaneKey = canonicalAgentSessionDigest({
|
||||
method: 'agent.launch',
|
||||
agent: BASE.agent,
|
||||
target: BASE.target,
|
||||
prompt: undefined,
|
||||
sessionOptions: undefined,
|
||||
reuseTerminal: undefined,
|
||||
agentArgs: '--model opus',
|
||||
cwd: '/repo/packages/a'
|
||||
})
|
||||
expect(
|
||||
computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus', cwd: '/repo/packages/a' })
|
||||
).toBe(beforePaneKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transient cwd observation is not launch intent', () => {
|
||||
const create = {
|
||||
repo: 'id:00000000-0000-4000-8000-000000000099',
|
||||
@@ -183,35 +65,6 @@ describe('transient cwd observation is not launch intent', () => {
|
||||
})
|
||||
).not.toBe(computeAgentLaunchFingerprint(input))
|
||||
})
|
||||
it.each<Partial<AgentLaunchFingerprintInput>>([
|
||||
{ agent: 'claude' },
|
||||
{ prompt: { text: 'hello', delivery: 'submit' } },
|
||||
{ sessionOptions: { model: 'model' } },
|
||||
{ chatOptions: { model: 'model' } },
|
||||
{ reuseTerminal: { handle: 'term_other' } },
|
||||
{ agentArgs: null },
|
||||
{ agentArgs: '--verbose' },
|
||||
{ cwd: '/another' },
|
||||
{ paneKey: PANE_KEY },
|
||||
{ sessionId: 'session_other' },
|
||||
{ surface: 'terminal' }
|
||||
])('retains outer launch intent and explicit clear with the observation: %j', (change) => {
|
||||
const input: AgentLaunchFingerprintInput = {
|
||||
agent: 'codex',
|
||||
target: { kind: 'create-worktree', create }
|
||||
}
|
||||
const changed = { ...input, ...change }
|
||||
expect(computeAgentLaunchFingerprint(changed)).not.toBe(computeAgentLaunchFingerprint(input))
|
||||
expect(computeAgentLaunchFingerprint(changed)).toBe(
|
||||
canonicalAgentSessionDigest({ method: 'agent.launch', ...changed })
|
||||
)
|
||||
expect(
|
||||
computeAgentLaunchFingerprint({
|
||||
...changed,
|
||||
target: { kind: 'create-worktree', create: { ...create, cwdParentAmbiguous: true } }
|
||||
})
|
||||
).toBe(computeAgentLaunchFingerprint(changed))
|
||||
})
|
||||
it.each(['existing', 'create-folder-workspace'] as const)(
|
||||
'does not project %s targets',
|
||||
(kind) => {
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from './agent-launch-picker'
|
||||
import { agentPickerArgumentPermission } from './agent-launch-picker-permission'
|
||||
import { projectAgentLaunchProfile } from './agent-launch-profile'
|
||||
import { structuredAgentConfiguredArgs } from '../main/native-chat/structured-agent-configured-args'
|
||||
import { claudeStructuredLaunchArgs } from '../main/claude/structured/launch/claude-structured-launch-args'
|
||||
import { codexStructuredLaunchArgs } from '../main/codex/codex-structured-launch-args'
|
||||
import '../main/native-chat/structured-agent-configured-args'
|
||||
import '../main/claude/structured/launch/claude-structured-launch-args'
|
||||
import '../main/codex/codex-structured-launch-args'
|
||||
|
||||
const settings = Object.freeze({
|
||||
agentPermissionDefault: 'ask' as const,
|
||||
@@ -57,25 +57,6 @@ it('retains inherited raw permission precedence despite a typed choice; removal
|
||||
)
|
||||
})
|
||||
|
||||
it('shows unliftable launch-owned permission bytes without guessing a native choice and preserves unrelated Arguments', () => {
|
||||
const draft = editAgentPickerArguments(
|
||||
agentPickerDraft(entry),
|
||||
'--permission-mode plan --add-dir docs'
|
||||
)
|
||||
expect(draft.choices.permissionMode).toBeUndefined()
|
||||
const permission = agentPickerArgumentPermission(entry, draft)
|
||||
expect(permission).toEqual({
|
||||
flag: '--permission-mode plan',
|
||||
remainingArguments: '--add-dir docs',
|
||||
value: 'plan',
|
||||
choice: undefined,
|
||||
opaque: false
|
||||
})
|
||||
const removed = editAgentPickerArguments(draft, permission?.remainingArguments ?? '')
|
||||
expect(removed.arguments).toBe('--add-dir docs')
|
||||
expect(agentPickerArgumentPermission(entry, removed)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps shell-ambiguous Arguments together rather than inventing a removable permission boundary', () => {
|
||||
const draft = {
|
||||
...agentPickerDraft(entry),
|
||||
@@ -89,24 +70,3 @@ it('keeps shell-ambiguous Arguments together rather than inventing a removable p
|
||||
opaque: true
|
||||
})
|
||||
})
|
||||
|
||||
it('retains raw CLI permission bytes while native configured-Arguments filtering stays unchanged', () => {
|
||||
const chat = { ...agentPickerDraft(entry, 'chat'), arguments: '--permission-mode plan' }
|
||||
expect(agentPickerArgumentPermission(entry, chat)).toMatchObject({
|
||||
value: 'plan',
|
||||
choice: undefined,
|
||||
opaque: false
|
||||
})
|
||||
expect(
|
||||
claudeStructuredLaunchArgs(structuredAgentConfiguredArgs('claude', settings)).extraArgs
|
||||
).toEqual({})
|
||||
expect(
|
||||
codexStructuredLaunchArgs(
|
||||
structuredAgentConfiguredArgs('codex', {
|
||||
agentDefaultArgs: {
|
||||
codex: '-a never -s danger-full-access -c approval_policy=never --search'
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual(['--search'])
|
||||
})
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { buildNewTabAgentMenuRows } from './agent-launch-picker-rows'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { z } from 'zod'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import 'node:fs'
|
||||
import 'zod'
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
agentPickerLaunchInputs,
|
||||
agentPickerEntriesFromRows,
|
||||
agentPickerChoiceSeeds,
|
||||
agentPickerDraft,
|
||||
agentPickerHydrationQueue,
|
||||
agentPickerEntryOptions,
|
||||
agentPickerEffectiveChoices,
|
||||
searchAgentPickerSetups,
|
||||
editAgentPickerDraft,
|
||||
editAgentPickerArguments,
|
||||
type AgentPickerEntry
|
||||
@@ -43,109 +40,6 @@ const entry: AgentPickerEntry = {
|
||||
}
|
||||
}
|
||||
|
||||
describe('shared agent launch picker', () => {
|
||||
it('derives setup matches from every query word, folds extra high and leaves plain agent searches plain', () => {
|
||||
expect(searchAgentPickerSetups([entry], 'codex')).toEqual([])
|
||||
expect(searchAgentPickerSetups([entry], 'astra low')[0]?.choices).toEqual({
|
||||
model: 'astra',
|
||||
options: { effort: 'low' }
|
||||
})
|
||||
expect(
|
||||
searchAgentPickerSetups([entry], 'codex astra extra high')[0]?.choices.options?.effort
|
||||
).toBe('xhigh')
|
||||
expect(searchAgentPickerSetups([entry], 'astra unsupported')).toEqual([])
|
||||
expect(searchAgentPickerSetups([entry], 'astra')).toHaveLength(1)
|
||||
})
|
||||
it('keeps unknown saved models launchable while current search excludes a classified legacy model', () => {
|
||||
const legacy = {
|
||||
...entry,
|
||||
catalog: {
|
||||
origin: 'probe' as const,
|
||||
fetchedAt: 1,
|
||||
models: [
|
||||
{
|
||||
id: 'retired',
|
||||
label: 'Retired',
|
||||
isDefault: false,
|
||||
efforts: [],
|
||||
isLegacy: true as const
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
expect(searchAgentPickerSetups([legacy], 'retired')).toEqual([])
|
||||
const draft = editAgentPickerDraft(agentPickerDraft(entry), { model: 'my-private-alias' })
|
||||
expect(editAgentPickerDraft(draft, { permissionMode: 'own' }).choices).toEqual({
|
||||
model: 'my-private-alias',
|
||||
permissionMode: 'own'
|
||||
})
|
||||
})
|
||||
it('reconciles deliberate Arguments edits once and preserves explicit empty versus inheritance', () => {
|
||||
const draft = editAgentPickerArguments(
|
||||
agentPickerDraft(entry, 'terminal'),
|
||||
'--model astra --add-dir docs'
|
||||
)
|
||||
expect(draft.choices.model).toBeUndefined()
|
||||
expect(agentPickerLaunchInputs(draft, entry).sessionOptions?.model).toBe('astra')
|
||||
expect(draft.arguments).toBe('--model astra --add-dir docs')
|
||||
expect(editAgentPickerArguments(draft, '').arguments).toBeNull()
|
||||
expect(agentPickerDraft(entry).arguments).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('prioritizes the selected pending catalog and reads each offered agent only once', () => {
|
||||
let selected: AgentPickerEntry['agent'] | undefined
|
||||
const entries = [
|
||||
entry,
|
||||
{ ...entry, agent: 'claude' as const },
|
||||
{ ...entry, agent: 'gemini' as const },
|
||||
{ ...entry, agent: 'grok' as const, surfaces: [] }
|
||||
]
|
||||
const next = agentPickerHydrationQueue(entries, () => selected)
|
||||
expect(next()).toBe(0)
|
||||
selected = 'gemini'
|
||||
expect(next()).toBe(2)
|
||||
expect(next()).toBe(1)
|
||||
expect(next()).toBeUndefined()
|
||||
})
|
||||
it('retains a model named by a captured Claude init and honors its disabled fast mode', () => {
|
||||
const initSchema = z.object({
|
||||
frame: z.object({
|
||||
type: z.literal('system'),
|
||||
subtype: z.literal('init'),
|
||||
model: z.string(),
|
||||
fast_mode_disabled_reason: z.literal('sdk_opt_in_required')
|
||||
})
|
||||
})
|
||||
const capture = readFileSync(
|
||||
new URL('../main/claude/__fixtures__/claude-adapter-capture-fold.jsonl', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const init = capture
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => initSchema.safeParse(JSON.parse(line)))
|
||||
.find((result) => result.success)
|
||||
if (!init?.success) {
|
||||
throw new Error('Captured init is missing')
|
||||
}
|
||||
const model = init.data.frame.model
|
||||
const captured: AgentPickerEntry = {
|
||||
...entry,
|
||||
agent: 'claude',
|
||||
catalog: {
|
||||
origin: 'live-session',
|
||||
fetchedAt: 1,
|
||||
fastModeSupport: { supported: false, reason: init.data.frame.fast_mode_disabled_reason },
|
||||
models: [{ id: model, label: model, isDefault: false, efforts: [], effortsKnown: false }]
|
||||
}
|
||||
}
|
||||
expect(
|
||||
agentPickerEntryOptions(captured, model, 'chat').some((option) => option.id === 'fastMode')
|
||||
).toBe(false)
|
||||
expect(editAgentPickerDraft(agentPickerDraft(captured), { model }).choices.model).toBe(model)
|
||||
})
|
||||
|
||||
it.each(['chat', 'terminal'] as const)(
|
||||
'keeps CLI edits separate from shared intent with %s as the default',
|
||||
(surface) => {
|
||||
@@ -218,47 +112,6 @@ it('shows inherited terminal Arguments choices without promoting them into launc
|
||||
).toBe('opus')
|
||||
})
|
||||
|
||||
it('only suggests host-advertised model words and preserves the mixed omnibox short-prefix boundary', () => {
|
||||
expect(
|
||||
searchAgentPickerSetups([{ ...entry, catalog: { origin: 'unknown' } }], 'gpt low')
|
||||
).toEqual([])
|
||||
for (const query of ['a', 'low', 'high', 'codex low']) {
|
||||
expect(searchAgentPickerSetups([entry], query)).toEqual([])
|
||||
}
|
||||
expect(searchAgentPickerSetups([entry], 'as low')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bounds real setup search work at N and 10N for no, broad and effort matches', () => {
|
||||
const measure = (count: number, query: string) => {
|
||||
let visits = 0
|
||||
const models = Array.from({ length: count }, (_, index) => ({
|
||||
...(entry.catalog.origin !== 'unknown'
|
||||
? entry.catalog.models[0]
|
||||
: { isDefault: false, efforts: [] }),
|
||||
get id() {
|
||||
visits++
|
||||
return `astra-${index}`
|
||||
},
|
||||
get label() {
|
||||
visits++
|
||||
return `Astra ${index}`
|
||||
}
|
||||
}))
|
||||
const result = searchAgentPickerSetups(
|
||||
[{ ...entry, catalog: { origin: 'probe', fetchedAt: 1, models } }],
|
||||
query
|
||||
)
|
||||
return { visits, length: result.length }
|
||||
}
|
||||
for (const query of ['absent', 'astra', 'astra low', 'low']) {
|
||||
const small = measure(30, query),
|
||||
large = measure(300, query)
|
||||
expect(large.visits).toBeLessThanOrEqual(10 * small.visits)
|
||||
expect(large.visits).toBeGreaterThan(0)
|
||||
expect(large.length).toBeLessThanOrEqual(8)
|
||||
}
|
||||
})
|
||||
|
||||
it('clears an Arguments-owned model/effort through deliberate controls without copying other CLI choices into Chat', () => {
|
||||
const own = { ...entry, agent: 'claude' as const }
|
||||
const original = editAgentPickerDraft(agentPickerDraft(own), {
|
||||
@@ -337,20 +190,6 @@ it('reports opaque permission literally when the actual compiler ignores a diffe
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a raw permission spelling even when its literal value happens to name a typed choice', async () => {
|
||||
const { agentPickerPanelArguments } = await import('./agent-launch-picker-arguments')
|
||||
const own: AgentPickerEntry = {
|
||||
...entry,
|
||||
agent: 'claude',
|
||||
defaultSurface: 'terminal',
|
||||
arguments: '--permission-mode ask --add-dir docs'
|
||||
}
|
||||
const view = agentPickerPanelArguments(own, agentPickerDraft(own))
|
||||
expect(view.permission).toBe('ask')
|
||||
expect(view.selectedPermission).toBeUndefined()
|
||||
expect(view.chips[0].flag).toBe('--permission-mode ask')
|
||||
})
|
||||
|
||||
it('keeps offered host Chat independent of preference through the actual row builder and adapter', () => {
|
||||
for (const scenario of [
|
||||
{
|
||||
@@ -513,23 +352,3 @@ it('keeps offered host Chat independent of preference through the actual row bui
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('orders setups by agent, not by whether each opens as a chat or its CLI', () => {
|
||||
const rows = buildNewTabAgentMenuRows({
|
||||
settings: { nativeChatProviders: { codex: true, grok: true, claude: false } },
|
||||
detectedOnHost: ['claude', 'codex', 'grok', 'gemini'],
|
||||
chatAgents: ['claude', 'codex', 'grok'],
|
||||
hostChatAnswer: () => 'yes',
|
||||
chatProgram: () => 'available'
|
||||
})
|
||||
const order = (defaultAgent: 'codex' | 'blank') =>
|
||||
agentPickerEntriesFromRows(
|
||||
rows,
|
||||
['claude', 'codex', 'grok', 'gemini'],
|
||||
() => ({ terminal: {}, structured: {} }),
|
||||
(agent) => agent,
|
||||
defaultAgent
|
||||
).map((entry) => entry.agent)
|
||||
expect(order('blank')).toEqual(['claude', 'codex', 'grok', 'gemini'])
|
||||
expect(order('codex')).toEqual(['codex', 'claude', 'grok', 'gemini'])
|
||||
})
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toAgentLaunchPreferences } from './agent-launch-preferences'
|
||||
import { getAgentSessionOptionCatalog } from './agent-session/options/agent-session-option-catalog'
|
||||
import { resolveAgentSessionOptionLaunch } from './agent-session/options/agent-session-option-launch'
|
||||
import {
|
||||
decodeCatalogOptionValue,
|
||||
encodeCatalogOptionValue
|
||||
} from './agent-session/options/structured-agent-session-option-codec'
|
||||
|
||||
describe('toAgentLaunchPreferences', () => {
|
||||
it('keeps the strict preferences, with a mode only where the agent has a terminal flag', () => {
|
||||
const options = { model: ' gpt-5 ', effort: 'high', permissionMode: 'ask', fastMode: true }
|
||||
expect(toAgentLaunchPreferences(options, 'codex')).toEqual({
|
||||
model: 'gpt-5',
|
||||
effort: 'high',
|
||||
mode: 'ask'
|
||||
})
|
||||
// OpenCode's terminal has no permission flag; a released host refuses any option it gets.
|
||||
expect(toAgentLaunchPreferences({ permissionMode: 'ask' }, 'opencode')).toBeUndefined()
|
||||
expect(toAgentLaunchPreferences({ permissionMode: 'own' }, 'opencode')).toBeUndefined()
|
||||
expect(toAgentLaunchPreferences({ permissionMode: 'own' }, 'codex')).toEqual({ mode: 'own' })
|
||||
expect(toAgentLaunchPreferences({ permissionMode: 'own' }, 'goose')).toEqual({ mode: 'own' })
|
||||
expect(toAgentLaunchPreferences({ permissionMode: 'ask' }, 'antigravity')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers nothing when no preference survives', () => {
|
||||
expect(toAgentLaunchPreferences({ model: ' ', fastMode: true }, 'codex')).toBeUndefined()
|
||||
expect(toAgentLaunchPreferences(undefined, 'codex')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a saved boolean option across the wire', () => {
|
||||
it('round-trips Cursor Thinking into the composed terminal model', () => {
|
||||
const thinking = getAgentSessionOptionCatalog('cursor')!
|
||||
.models.find((model) => model.id === 'claude-opus-4-8')!
|
||||
.options.find((option) => option.id === 'thinking')!
|
||||
const saved = encodeCatalogOptionValue(thinking.kind, true)
|
||||
expect(saved).toBe('true')
|
||||
expect(decodeCatalogOptionValue(thinking.kind, saved!)).toBe(true)
|
||||
const options = { model: 'claude-opus-4-8', thinking: saved!, effort: 'high' }
|
||||
expect(toAgentLaunchPreferences(options, 'cursor')?.model).toBe('claude-opus-4-8-thinking-high')
|
||||
expect(resolveAgentSessionOptionLaunch('cursor', options).args).toEqual([
|
||||
'--model',
|
||||
'claude-opus-4-8-thinking-high'
|
||||
])
|
||||
})
|
||||
|
||||
it('carries a saved permission mode on the existing mode key', () => {
|
||||
expect(toAgentLaunchPreferences({ permissionMode: 'full-access' }, 'claude')).toEqual({
|
||||
mode: 'full-access'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { repoIsRemote } from './agent-launch-remote'
|
||||
|
||||
describe('repoIsRemote', () => {
|
||||
it('reads both spellings of SSH ownership on two different hosts', () => {
|
||||
// Distinct SSH hosts catch lookups accidentally reading another row's owner (ssh:m4air to openclaw).
|
||||
expect(repoIsRemote({ connectionId: 'm4air', executionHostId: null })).toBe(true)
|
||||
expect(repoIsRemote({ connectionId: null, executionHostId: 'ssh:openclaw' })).toBe(true)
|
||||
expect(repoIsRemote({ connectionId: 'm4air', executionHostId: 'ssh:m4air' })).toBe(true)
|
||||
})
|
||||
|
||||
it('answers local for a row that declares itself local with a stale connection', () => {
|
||||
expect(repoIsRemote({ connectionId: 'm4air', executionHostId: 'local' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a runtime host with a nested SSH target remote', () => {
|
||||
expect(repoIsRemote({ connectionId: 'nested-target', executionHostId: 'runtime:vm-1' })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a headless runtime with no nested SSH target as remote', () => {
|
||||
expect(repoIsRemote({ connectionId: null, executionHostId: 'runtime:vm-1' })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a WSL workspace on a Windows server on the WSL CLI name', () => {
|
||||
expect(
|
||||
repoIsRemote({
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:win-1',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\u\\repo'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps plain local rows local', () => {
|
||||
expect(repoIsRemote({ connectionId: null, executionHostId: null })).toBe(false)
|
||||
expect(repoIsRemote({ connectionId: null, executionHostId: 'local' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentLaunchSettingsMutationUpdates,
|
||||
projectAgentLaunchSettings
|
||||
} from './agent-launch-settings'
|
||||
import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings'
|
||||
import { applyAgentPermissionMode, AUTO_TUI_AGENT_ARGS } from './tui-agent-permissions'
|
||||
|
||||
describe('agent launch permission projection compatibility', () => {
|
||||
it('publishes Auto through an optional marker alongside the existing Mixed value', () => {
|
||||
const settings = projectAgentLaunchSettings(applyAgentPermissionMode({ mode: 'auto' }))
|
||||
expect(settings.permissionMode).toBe('mixed')
|
||||
expect(settings.autoPermissionMode).toBe(true)
|
||||
expect(settings.agentDefaultArgs.codex).toBe('--approve-for-me')
|
||||
expect(settings.agentDefaultArgs.claude).toBe('--permission-mode auto')
|
||||
})
|
||||
|
||||
it.each(['yolo', 'manual'] as const)('preserves the existing %s projection', (mode) => {
|
||||
const settings = projectAgentLaunchSettings(applyAgentPermissionMode({ mode }))
|
||||
expect(settings.permissionMode).toBe(mode)
|
||||
expect(settings).not.toHaveProperty('autoPermissionMode')
|
||||
})
|
||||
|
||||
it('does not label a partly configured automatic profile as Auto', () => {
|
||||
const settings = projectAgentLaunchSettings({ agentDefaultArgs: { codex: '--approve-for-me' } })
|
||||
expect(settings.permissionMode).toBe('mixed')
|
||||
expect(settings).not.toHaveProperty('autoPermissionMode')
|
||||
})
|
||||
|
||||
it('publishes the mode of agents Auto does not cover, never Auto itself', () => {
|
||||
const yolo = applyAgentPermissionMode({ mode: 'yolo' })
|
||||
const settings = projectAgentLaunchSettings(applyAgentPermissionMode({ mode: 'auto', ...yolo }))
|
||||
expect(settings.nonAutoPermissionMode).toBe('yolo')
|
||||
const manual = projectAgentLaunchSettings(applyAgentPermissionMode({ mode: 'auto' }))
|
||||
expect(manual.nonAutoPermissionMode).toBe('manual')
|
||||
})
|
||||
|
||||
it('keeps one custom agent from making the profile mixed', () => {
|
||||
const yolo = applyAgentPermissionMode({ mode: 'yolo' })
|
||||
const settings = projectAgentLaunchSettings({
|
||||
...yolo,
|
||||
agentDefaultArgs: { ...yolo.agentDefaultArgs, codex: '--model gpt-5' }
|
||||
})
|
||||
expect(settings.permissionMode).toBe('yolo')
|
||||
})
|
||||
|
||||
it('changes only agents Auto does not cover', () => {
|
||||
const automatic = applyAgentPermissionMode({
|
||||
mode: 'auto',
|
||||
|
||||
@@ -1,128 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
continueMainAgentStatus,
|
||||
mainAgentTurnInterrupted,
|
||||
foldAgentLeadStatus,
|
||||
isAgentTimeAccruing,
|
||||
isAgentStatusHeldOpenByChildWork
|
||||
} from './agent-lead-status-fold'
|
||||
|
||||
describe('foldAgentLeadStatus', () => {
|
||||
it('keeps a blocked lead while child work is working', () => {
|
||||
expect(foldAgentLeadStatus({ leadState: 'blocked', childWorkLiveness: 'working' })).toEqual({
|
||||
stateName: 'blocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a settled lead with live agent work as working', () => {
|
||||
expect(foldAgentLeadStatus({ leadState: 'done', childWorkLiveness: 'working' })).toEqual({
|
||||
stateName: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a settled lead with only watch loops as monitoring', () => {
|
||||
expect(foldAgentLeadStatus({ leadState: 'done', childWorkLiveness: 'monitoring' })).toEqual({
|
||||
stateName: 'working',
|
||||
workingMode: 'monitoring'
|
||||
})
|
||||
})
|
||||
|
||||
it('settles when nothing is running', () => {
|
||||
expect(foldAgentLeadStatus({ leadState: 'done', childWorkLiveness: null })).toEqual({
|
||||
stateName: 'done'
|
||||
})
|
||||
})
|
||||
|
||||
describe('a child waiting on a human', () => {
|
||||
it('makes a working or settled main agent wait', () => {
|
||||
for (const leadState of ['working', 'done'] as const) {
|
||||
expect(foldAgentLeadStatus({ leadState, childWorkLiveness: 'waiting' })).toEqual({
|
||||
stateName: 'waiting'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("yields to the main agent's own request for a human, in the main agent's own vocabulary", () => {
|
||||
for (const leadState of ['waiting', 'blocked'] as const) {
|
||||
expect(foldAgentLeadStatus({ leadState, childWorkLiveness: 'waiting' })).toEqual({
|
||||
stateName: leadState
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mainAgentTurnInterrupted', () => {
|
||||
it('reads only a cancellation verdict as an interrupt', () => {
|
||||
expect(mainAgentTurnInterrupted({ outcome: 'cancellation' })).toBe(true)
|
||||
expect(mainAgentTurnInterrupted({ outcome: 'failure' })).toBe(false)
|
||||
// The legacy flag means the user stopped it; a newer request's replacement names no one.
|
||||
expect(mainAgentTurnInterrupted({ outcome: 'superseded' })).toBe(false)
|
||||
expect(mainAgentTurnInterrupted({})).toBe(false)
|
||||
expect(mainAgentTurnInterrupted(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// Older readers take the flag as "the user stopped it": a crash or an unproven end never sets it.
|
||||
it('stays down for a turn cut off by a crash and for an end nobody can prove', () => {
|
||||
expect(mainAgentTurnInterrupted({ outcome: 'interruption' })).toBe(false)
|
||||
expect(mainAgentTurnInterrupted({ outcome: 'unconfirmed' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentStatusHeldOpenByChildWork', () => {
|
||||
it('is true only when a settled main agent sits under a row that is not settled', () => {
|
||||
expect(
|
||||
isAgentStatusHeldOpenByChildWork({ state: 'working', mainAgent: { state: 'done' } })
|
||||
).toBe(true)
|
||||
expect(isAgentStatusHeldOpenByChildWork({ state: 'done', mainAgent: { state: 'done' } })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isAgentStatusHeldOpenByChildWork({ state: 'working', mainAgent: { state: 'working' } })
|
||||
).toBe(false)
|
||||
// No main agent fact means no claim: an old host's row is never read as child-held.
|
||||
expect(isAgentStatusHeldOpenByChildWork({ state: 'working' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentTimeAccruing', () => {
|
||||
it('accrues while the row works for the main agent or its live agent child work', () => {
|
||||
expect(isAgentTimeAccruing({ state: 'working' })).toBe(true)
|
||||
})
|
||||
|
||||
it('pauses while the row waits on the user, whoever raised the prompt', () => {
|
||||
expect(isAgentTimeAccruing({ state: 'waiting' })).toBe(false)
|
||||
expect(isAgentTimeAccruing({ state: 'blocked' })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not accrue for a watch loop or a settled row', () => {
|
||||
expect(isAgentTimeAccruing({ state: 'working', workingMode: 'monitoring' })).toBe(false)
|
||||
expect(isAgentTimeAccruing({ state: 'done' })).toBe(false)
|
||||
})
|
||||
|
||||
it('relies on the fold emitting monitoring only for a settled main agent', () => {
|
||||
// Monitoring requires a settled lead; live lead turns must still accrue time.
|
||||
const leadStates = ['working', 'waiting', 'blocked', 'done'] as const
|
||||
const liveness = ['waiting', 'working', 'monitoring', null] as const
|
||||
for (const leadState of leadStates) {
|
||||
for (const childWorkLiveness of liveness) {
|
||||
const folded = foldAgentLeadStatus({ leadState, childWorkLiveness })
|
||||
if (folded.workingMode === 'monitoring') {
|
||||
expect(leadState).toBe('done')
|
||||
}
|
||||
const accrues = isAgentTimeAccruing({
|
||||
state: folded.stateName,
|
||||
workingMode: folded.workingMode
|
||||
})
|
||||
// A child waiting on a human pauses the row, whatever the main agent is doing.
|
||||
expect(accrues).toBe(
|
||||
childWorkLiveness !== 'waiting' &&
|
||||
(leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working'))
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
import { continueMainAgentStatus } from './agent-lead-status-fold'
|
||||
|
||||
describe('continueMainAgentStatus', () => {
|
||||
it('keeps the clock across an unchanged state and restarts it on a change', () => {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentMainAgentVerdict,
|
||||
agentTurnEndedUncleanly,
|
||||
agentTurnEndedOnPurpose,
|
||||
agentVerdictDisplayMark,
|
||||
agentVerdictFields
|
||||
} from './agent-main-agent-verdict'
|
||||
import type { AgentTurnOutcome } from './agent-turn-outcome'
|
||||
|
||||
describe('agentMainAgentVerdict', () => {
|
||||
it.each(['failure', 'interruption'] as const)(
|
||||
'reads a main agent whose turn ended in %s while its subagent still works as failed',
|
||||
(outcome) => {
|
||||
const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } }
|
||||
expect(agentMainAgentVerdict(row)).toBe(outcome)
|
||||
expect(agentVerdictDisplayMark(row)).toBe('failed')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a success or a stop with live subagent work reading working', () => {
|
||||
for (const outcome of ['success', 'cancellation'] as const) {
|
||||
const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } }
|
||||
expect(agentVerdictDisplayMark(row)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('has no verdict while the main agent itself is not done, whatever the combined row says', () => {
|
||||
for (const state of ['working', 'blocked', 'waiting'] as const) {
|
||||
expect(
|
||||
agentMainAgentVerdict({ state: 'done', interrupted: true, mainAgent: { state } })
|
||||
).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the legacy flag only on a done row, and under a done main agent with no outcome', () => {
|
||||
for (const state of ['working', 'blocked', 'waiting'] as const) {
|
||||
expect(agentMainAgentVerdict({ state, interrupted: true })).toBeNull()
|
||||
expect(
|
||||
agentMainAgentVerdict({ state, interrupted: true, mainAgent: { state: 'done' } })
|
||||
).toBeNull()
|
||||
}
|
||||
expect(agentMainAgentVerdict({ state: 'done' })).toBeNull()
|
||||
expect(agentMainAgentVerdict({ state: 'done', interrupted: false })).toBeNull()
|
||||
expect(agentMainAgentVerdict({ state: 'done', mainAgent: { state: 'done' } })).toBeNull()
|
||||
expect(
|
||||
agentMainAgentVerdict({ state: 'done', interrupted: true, mainAgent: { state: 'done' } })
|
||||
).toBe('cancellation')
|
||||
expect(agentMainAgentVerdict({ state: 'done', interrupted: true })).toBe('cancellation')
|
||||
})
|
||||
|
||||
it("marks a done row by its verdict: a user's Stop reads interrupted, any other cut failed", () => {
|
||||
for (const [outcome, mark, unclean, onPurpose] of [
|
||||
['success', null, false, false],
|
||||
['failure', 'failed', true, false],
|
||||
['cancellation', 'interrupted', true, true],
|
||||
// A newer request replaced it: not news, and no one to name.
|
||||
['superseded', 'interrupted', true, true],
|
||||
['interruption', 'failed', true, false],
|
||||
['unconfirmed', 'unconfirmed', true, false]
|
||||
] as const) {
|
||||
const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } }
|
||||
expect(agentVerdictDisplayMark(row), outcome).toBe(mark)
|
||||
expect(agentTurnEndedUncleanly(row), outcome).toBe(unclean)
|
||||
expect(agentTurnEndedOnPurpose(row), outcome).toBe(onPurpose)
|
||||
}
|
||||
// An old host's legacy flag is a user's Stop too.
|
||||
expect(agentVerdictDisplayMark({ state: 'done', interrupted: true })).toBe('interrupted')
|
||||
})
|
||||
|
||||
it('reads a crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => {
|
||||
for (const [outcome, mark] of [
|
||||
['interruption', 'failed'],
|
||||
['unconfirmed', 'unconfirmed']
|
||||
] as const) {
|
||||
const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } }
|
||||
expect(agentVerdictDisplayMark(row)).toBe(mark)
|
||||
expect(agentTurnEndedUncleanly(row)).toBe(true)
|
||||
// Nobody asked for it, so attention ranks it as news, like a completion or a failure.
|
||||
expect(agentTurnEndedOnPurpose(row)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('reads an arm from a newer host as no verdict, so the row reads done as it did', () => {
|
||||
const row = {
|
||||
state: 'done' as const,
|
||||
mainAgent: {
|
||||
state: 'done' as const,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a mirrored row reaches the reader unparsed, carrying an arm this build cannot name.
|
||||
outcome: 'from-a-newer-host' as AgentTurnOutcome
|
||||
}
|
||||
}
|
||||
expect(agentMainAgentVerdict(row)).toBeNull()
|
||||
expect(agentVerdictDisplayMark(row)).toBeNull()
|
||||
expect(agentTurnEndedUncleanly(row)).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers the recorded verdict over the legacy flag', () => {
|
||||
expect(
|
||||
agentMainAgentVerdict({
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
mainAgent: { state: 'done', outcome: 'failure' }
|
||||
})
|
||||
).toBe('failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentVerdictFields', () => {
|
||||
const mainAgent = { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 7 }
|
||||
|
||||
it('copies the whole main agent status and a true legacy flag together', () => {
|
||||
expect(agentVerdictFields({ interrupted: true, mainAgent })).toEqual({
|
||||
interrupted: true,
|
||||
mainAgent
|
||||
})
|
||||
})
|
||||
|
||||
it('copies nothing a row does not carry, and never a false flag', () => {
|
||||
expect(agentVerdictFields({ interrupted: false })).toEqual({})
|
||||
expect(agentVerdictFields({})).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentModelProbeSpec } from './agent-model-probe-spec'
|
||||
import { GROK_SESSION_OPTION_CATALOG } from './agent-session/options/agent-session-option-catalog-grok'
|
||||
import { getCommitMessageAgentSpec, listCommitMessageAgentIds } from './commit-message-agent-spec'
|
||||
|
||||
describe('getAgentModelProbeSpec', () => {
|
||||
it('resolves grok as a discovery-only probe with no static model list', () => {
|
||||
const spec = getAgentModelProbeSpec('grok')
|
||||
expect(spec).toMatchObject({
|
||||
id: 'grok',
|
||||
binary: 'grok',
|
||||
modelSource: 'dynamic',
|
||||
models: [],
|
||||
defaultModelId: 'grok-4.7'
|
||||
})
|
||||
expect(spec?.modelDiscovery).toMatchObject({ binary: 'grok', args: ['models'] })
|
||||
})
|
||||
|
||||
it('wires that probe to the parser that reads a real listing', () => {
|
||||
// Binary/argv assertions alone miss a wrong parser publishing no models.
|
||||
const parsed = getAgentModelProbeSpec('grok')!.modelDiscovery!.parse(
|
||||
'You are logged in with grok.com.\n\nDefault model: grok-4.5\n\nAvailable models:\n * grok-4.5 (default)\n'
|
||||
)
|
||||
expect(parsed).toEqual([{ id: 'grok-4.5', label: 'Grok 4.5', isDefault: true }])
|
||||
})
|
||||
|
||||
it('keeps the grok probe default in step with the catalog seed', () => {
|
||||
expect(getAgentModelProbeSpec('grok')!.defaultModelId).toBe(
|
||||
GROK_SESSION_OPTION_CATALOG.models[0].id
|
||||
)
|
||||
})
|
||||
|
||||
it('aliases agents without a generation-only default by identity', () => {
|
||||
// Adapters must not lose discovery fields such as Claude's stdinPayload.
|
||||
for (const id of listCommitMessageAgentIds().filter((agentId) => agentId !== 'omp')) {
|
||||
expect(getAgentModelProbeSpec(id)).toBe(getCommitMessageAgentSpec(id))
|
||||
}
|
||||
})
|
||||
|
||||
it('removes only the OMP generation sentinel while preserving discovery', () => {
|
||||
const generation = getCommitMessageAgentSpec('omp')!
|
||||
const probe = getAgentModelProbeSpec('omp')!
|
||||
expect(generation.defaultModelId).toBe('default')
|
||||
expect(probe.defaultModelId).toBe('')
|
||||
expect(probe.models).toEqual([])
|
||||
expect(probe.modelDiscovery).toBe(generation.modelDiscovery)
|
||||
})
|
||||
|
||||
it('keeps grok out of the commit-message registry', () => {
|
||||
expect(getCommitMessageAgentSpec('grok')).toBeUndefined()
|
||||
expect(listCommitMessageAgentIds()).not.toContain('grok')
|
||||
})
|
||||
|
||||
it('is undefined for an agent in neither registry', () => {
|
||||
expect(getAgentModelProbeSpec('aider')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAgentNotificationId } from './agent-notification-id'
|
||||
|
||||
describe('buildAgentNotificationId', () => {
|
||||
it('builds a stable escaped id for the same agent event metadata', () => {
|
||||
const args = {
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature',
|
||||
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
|
||||
stateStartedAt: 1780000000123
|
||||
}
|
||||
|
||||
const id = buildAgentNotificationId(args)
|
||||
expect(id).toBe(
|
||||
'agent:repo%3A%3A%2FUsers%2Fme%2Forca%2Fworkspaces%2Ffeature:tab-1%3A11111111-1111-4111-8111-111111111111:1780000000123'
|
||||
)
|
||||
expect(buildAgentNotificationId(args)).toBe(id)
|
||||
})
|
||||
|
||||
it('changes when the agent state start time changes', () => {
|
||||
const base = {
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature',
|
||||
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
|
||||
expect(buildAgentNotificationId({ ...base, stateStartedAt: 1780000000123 })).not.toBe(
|
||||
buildAgentNotificationId({ ...base, stateStartedAt: 1780000000456 })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when required fields are missing', () => {
|
||||
expect(
|
||||
buildAgentNotificationId({
|
||||
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
|
||||
stateStartedAt: 1780000000123
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
buildAgentNotificationId({
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature',
|
||||
stateStartedAt: 1780000000123
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
buildAgentNotificationId({
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature',
|
||||
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -19,43 +19,6 @@ function waiting(interactivePrompt: string, toolName?: string) {
|
||||
}
|
||||
|
||||
describe('readAgentPromptAnswers', () => {
|
||||
it('reads an approval with the cards’ own allow and deny keys', () => {
|
||||
for (const agent of ['claude', 'codex']) {
|
||||
const read = readAgentPromptAnswers(waiting(approval), agent, 'p1', true)
|
||||
expect(read?.prompt).toEqual({
|
||||
kind: 'approval',
|
||||
promptId: 'p1',
|
||||
tool: 'Bash',
|
||||
summary: 'rm -rf build',
|
||||
options: [
|
||||
{ id: 'allow_once', label: 'Allow once' },
|
||||
{ id: 'reject_once', label: 'Reject' }
|
||||
]
|
||||
})
|
||||
expect(Object.fromEntries(read!.keysByOption)).toEqual({
|
||||
allow_once: '1',
|
||||
reject_once: '\x1b'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a one-question single choice with each option’s selector key', () => {
|
||||
for (const agent of ['claude', 'codex']) {
|
||||
const read = readAgentPromptAnswers(waiting(question(), 'AskUserQuestion'), agent, 'p2', true)
|
||||
expect(read?.prompt).toEqual({
|
||||
kind: 'question',
|
||||
promptId: 'p2',
|
||||
question: 'Which color?',
|
||||
header: 'Color',
|
||||
options: [
|
||||
{ id: '1', label: 'Red', description: 'warm' },
|
||||
{ id: '2', label: 'Blue' }
|
||||
]
|
||||
})
|
||||
expect(Object.fromEntries(read!.keysByOption)).toEqual({ '1': '1', '2': '2' })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves to Orca what one keystroke cannot answer', () => {
|
||||
const unsupported = {
|
||||
prompt: { kind: 'unsupported', promptId: 'p' },
|
||||
@@ -92,11 +55,4 @@ describe('readAgentPromptAnswers', () => {
|
||||
readAgentPromptAnswers(waiting(question(), 'AskUserQuestion'), 'grok', 'p', true)
|
||||
).toEqual(unsupported)
|
||||
})
|
||||
|
||||
it('reads nothing while the agent is not waiting on the user', () => {
|
||||
expect(
|
||||
readAgentPromptAnswers({ state: 'working', interactivePrompt: approval }, 'claude', 'p', true)
|
||||
).toBeNull()
|
||||
expect(readAgentPromptAnswers({ state: 'done' }, 'claude', 'p', true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,19 +15,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('platform process evidence', () => {
|
||||
it('leaves Windows unanswered until a Windows hook captures an identity', async () => {
|
||||
platform('win32')
|
||||
expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' })
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a missing macOS utility as unanswered', async () => {
|
||||
platform('darwin')
|
||||
run.mockRejectedValue(Object.assign(new Error('missing ps'), { code: 'ENOENT' }))
|
||||
expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' })
|
||||
})
|
||||
|
||||
it('requires Linux process-file absence rather than an unreadable boot identity', async () => {
|
||||
platform('linux')
|
||||
read.mockRejectedValueOnce(Object.assign(new Error('gone'), { code: 'ENOENT' }))
|
||||
|
||||
@@ -31,31 +31,4 @@ describe('single-process presence evidence', () => {
|
||||
expect(await probeAgentProcessPresence(identity, read, 'linux', observe)).toBe('exited')
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[AgentProcessObservation, string]>([
|
||||
[{ verdict: 'live', startTime: 'boot:100', zombie: false }, 'live'],
|
||||
[{ verdict: 'live', startTime: 'boot:101', zombie: false }, 'exited'],
|
||||
[{ verdict: 'live', startTime: 'boot:100', zombie: true }, 'exited'],
|
||||
[{ verdict: 'live', startTime: 'boot:100', zombie: false, stopped: true }, 'unverifiable'],
|
||||
[{ verdict: 'exited' }, 'exited'],
|
||||
[{ verdict: 'unverifiable' }, 'unverifiable']
|
||||
])('judges %j as %s', async (observation, verdict) => {
|
||||
const read = vi.fn(async () => observation)
|
||||
expect(await probeAgentProcessPresence(identity, read, 'linux')).toBe(verdict)
|
||||
expect(read).toHaveBeenCalledExactlyOnceWith(4242)
|
||||
})
|
||||
|
||||
it('keeps a timeout or permission error unverifiable', async () => {
|
||||
const read = vi.fn(async () => {
|
||||
throw new Error('EPERM')
|
||||
})
|
||||
expect(await probeAgentProcessPresence(identity, read, 'linux')).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('does not inspect a missing identity or a different execution platform', async () => {
|
||||
const read = vi.fn()
|
||||
expect(await probeAgentProcessPresence(undefined, read, 'linux')).toBe('unverifiable')
|
||||
expect(await probeAgentProcessPresence(identity, read, 'win32')).toBe('unverifiable')
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,47 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import { withFreshOmpLaunch } from './omp-fresh-launch'
|
||||
import { buildAgentDraftLaunchPlan } from './tui-agent-startup'
|
||||
import { detectExplicitPiAgentKindFromCommand } from './pi-agent-kind'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
isExpectedAgentProcess,
|
||||
isRecognizedAgentType,
|
||||
recognizeAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
} from './agent-process-recognition'
|
||||
import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition'
|
||||
|
||||
describe('agent process recognition', () => {
|
||||
it.each([
|
||||
'/usr/local/bin/dsb',
|
||||
'/usr/bin/deepseek-build',
|
||||
'C:\\Users\\dev\\bin\\dsb.cmd',
|
||||
'C:\\Users\\dev\\bin\\deepseek-build-agent.exe'
|
||||
])('recognizes manually started DeepSeek Build at %s', (processName) => {
|
||||
expect(recognizeAgentProcess(processName)?.agent).toBe('dsb')
|
||||
})
|
||||
|
||||
it('recognizes DeepSeek Build by binary name and its npm shim', () => {
|
||||
expect(recognizeAgentProcess('dsb')).toEqual({ agent: 'dsb', processName: 'dsb' })
|
||||
expect(recognizeAgentProcess('deepseek-build')).toEqual({
|
||||
agent: 'dsb',
|
||||
processName: 'deepseek-build'
|
||||
})
|
||||
expect(recognizeAgentProcess('deepseek-build-agent')).toEqual({
|
||||
agent: 'dsb',
|
||||
processName: 'deepseek-build-agent'
|
||||
})
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine(
|
||||
'node /usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js'
|
||||
)
|
||||
).toEqual({ agent: 'dsb', processName: 'dsb' })
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine(
|
||||
'node /usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js run "explain this"'
|
||||
)
|
||||
).toBeNull()
|
||||
expect(recognizeAgentProcessFromCommandLine('dsb run "explain this"')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'-r ./preload.js',
|
||||
'--require "./preload path.js"',
|
||||
@@ -61,14 +23,6 @@ describe('agent process recognition', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine(`${shim} agent`)?.agent).toBe('dsb')
|
||||
})
|
||||
|
||||
it('recognizes packaged Codex foreground process names', () => {
|
||||
expect(recognizeAgentProcess('codex-aarch64-ap')).toEqual({
|
||||
agent: 'codex',
|
||||
processName: 'codex-aarch64-ap'
|
||||
})
|
||||
expect(isRecognizedAgentType('codex-aarch64-ap')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['posix', 'powershell', 'cmd'] as const)(
|
||||
'recognizes fresh OMP commands and drafts in %s',
|
||||
(shell) => {
|
||||
@@ -95,12 +49,6 @@ describe('agent process recognition', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('recognizes cmd fresh OMP lines built before the by-name guard', () => {
|
||||
const legacy =
|
||||
'if exist "%ORCA_OMP_FRESH_CONFIG%" (if not exist "%ORCA_OMP_FRESH_CONFIG%\\*" (omp --config "%ORCA_OMP_FRESH_CONFIG%") else (x)) else (x)'
|
||||
expect(recognizeAgentProcessFromCommandLine(legacy)?.agent).toBe('omp')
|
||||
})
|
||||
|
||||
it('does not classify embedded fresh-launch text as an agent command', () => {
|
||||
for (const command of [
|
||||
`echo 'omp --config "$ORCA_OMP_FRESH_CONFIG"'`,
|
||||
@@ -111,41 +59,6 @@ describe('agent process recognition', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('recognizes both published DeepSeek Build npm entrypoints and excludes run with cwd', () => {
|
||||
const shim = '/usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/deepseek-build.js'
|
||||
expect(recognizeAgentProcessFromCommandLine(`node ${shim}`)?.agent).toBe('dsb')
|
||||
expect(recognizeAgentProcessFromCommandLine(`node ${shim} --cwd folder run task`)).toBeNull()
|
||||
expect(recognizeAgentProcessFromCommandLine('dsb --cwd folder run task')).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes the OpenClaude foreground process', () => {
|
||||
expect(recognizeAgentProcess('/usr/local/bin/openclaude')).toEqual({
|
||||
agent: 'openclaude',
|
||||
processName: 'openclaude'
|
||||
})
|
||||
expect(isRecognizedAgentType('openclaude')).toBe(true)
|
||||
expect(isExpectedAgentProcess('/usr/local/bin/openclaude', 'claude')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes the Droid foreground process on Windows', () => {
|
||||
expect(recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\droid.cmd`)).toEqual({
|
||||
agent: 'droid',
|
||||
processName: 'droid'
|
||||
})
|
||||
})
|
||||
|
||||
it('matches expected agents from platform-specific foreground process paths', () => {
|
||||
expect(recognizeAgentProcess('claude')).toEqual({
|
||||
agent: 'claude',
|
||||
processName: 'claude'
|
||||
})
|
||||
expect(
|
||||
isExpectedAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\claude.exe`, 'claude')
|
||||
).toBe(true)
|
||||
expect(isExpectedAgentProcess('/usr/local/bin/claude', 'claude')).toBe(true)
|
||||
expect(isExpectedAgentProcess('powershell.exe', 'claude')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not recognize Claude print-mode hook subprocesses as interactive agents', () => {
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine(
|
||||
@@ -168,39 +81,6 @@ describe('agent process recognition', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes Command Code without classifying Windows cmd.exe as an agent', () => {
|
||||
expect(recognizeAgentProcess('command-code')).toEqual({
|
||||
agent: 'command-code',
|
||||
processName: 'command-code'
|
||||
})
|
||||
expect(
|
||||
recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\command-code.cmd`)
|
||||
).toEqual({
|
||||
agent: 'command-code',
|
||||
processName: 'command-code'
|
||||
})
|
||||
expect(isRecognizedAgentType('command-code')).toBe(true)
|
||||
expect(isRecognizedAgentType('cmd.exe')).toBe(false)
|
||||
expect(recognizeAgentProcess('cmd.exe')).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes Ante without classifying ante-prefixed path fragments as the agent', () => {
|
||||
expect(recognizeAgentProcess('ante')).toEqual({
|
||||
agent: 'ante',
|
||||
processName: 'ante'
|
||||
})
|
||||
expect(recognizeAgentProcess('/Users/dev/.ante/bin/ante')).toEqual({
|
||||
agent: 'ante',
|
||||
processName: 'ante'
|
||||
})
|
||||
expect(isExpectedAgentProcess('/Users/dev/.ante/bin/ante', 'ante')).toBe(true)
|
||||
expect(isRecognizedAgentType('ante')).toBe(true)
|
||||
// Ante occurs in ordinary names; require its exact normalized basename.
|
||||
expect(recognizeAgentProcess('ante-obsidian')).toBeNull()
|
||||
expect(recognizeAgentProcess('antechamber')).toBeNull()
|
||||
expect(isExpectedAgentProcess('ante-obsidian', 'ante')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not recognize Ante headless one-shot commands as interactive agents', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine('ante -p "summarize this diff"')).toBeNull()
|
||||
expect(recognizeAgentProcessFromCommandLine('ante -psummarize')).toBeNull()
|
||||
@@ -231,22 +111,6 @@ describe('agent process recognition', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes Trae by its traecli binary, not the ambiguous trae-cli name', () => {
|
||||
expect(recognizeAgentProcess('traecli')).toEqual({
|
||||
agent: 'trae',
|
||||
processName: 'traecli'
|
||||
})
|
||||
expect(recognizeAgentProcess('/Users/dev/.local/bin/traecli')).toEqual({
|
||||
agent: 'trae',
|
||||
processName: 'traecli'
|
||||
})
|
||||
expect(isExpectedAgentProcess('/Users/dev/.local/bin/traecli', 'traecli')).toBe(true)
|
||||
expect(isRecognizedAgentType('traecli')).toBe(true)
|
||||
// Why: `trae-cli` and `trae-agent` both name the unrelated open-source bytedance/trae-agent.
|
||||
expect(recognizeAgentProcess('trae-cli')).toBeNull()
|
||||
expect(recognizeAgentProcess('trae-agent')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not recognize Trae headless one-shot commands as interactive agents', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine('traecli -p "summarize this diff"')).toBeNull()
|
||||
expect(recognizeAgentProcessFromCommandLine('traecli --print "review this"')).toBeNull()
|
||||
@@ -267,44 +131,6 @@ describe('agent process recognition', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes Muse by its muse binary', () => {
|
||||
expect(recognizeAgentProcess('muse')).toEqual({
|
||||
agent: 'muse',
|
||||
processName: 'muse'
|
||||
})
|
||||
expect(recognizeAgentProcess('/Users/dev/.local/bin/muse')).toEqual({
|
||||
agent: 'muse',
|
||||
processName: 'muse'
|
||||
})
|
||||
expect(isExpectedAgentProcess('/Users/dev/.local/bin/muse', 'muse')).toBe(true)
|
||||
expect(isRecognizedAgentType('muse')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes Mistral Vibe by its installed executable and legacy alias', () => {
|
||||
expect(recognizeAgentProcess('/home/dev/.local/bin/vibe')).toEqual({
|
||||
agent: 'mistral-vibe',
|
||||
processName: 'vibe'
|
||||
})
|
||||
expect(recognizeAgentProcess('mistral-vibe')).toEqual({
|
||||
agent: 'mistral-vibe',
|
||||
processName: 'mistral-vibe'
|
||||
})
|
||||
expect(isRecognizedAgentType('vibe')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes Kimi Code by the kimi-code process its launcher becomes', () => {
|
||||
expect(recognizeAgentProcess('/home/dev/.kimi-code/bin/kimi')).toEqual({
|
||||
agent: 'kimi',
|
||||
processName: 'kimi'
|
||||
})
|
||||
expect(recognizeAgentProcess('kimi-code')).toEqual({
|
||||
agent: 'kimi',
|
||||
processName: 'kimi-code'
|
||||
})
|
||||
expect(isExpectedAgentProcess('/home/dev/.kimi-code/bin/kimi', 'kimi')).toBe(true)
|
||||
expect(isRecognizedAgentType('kimi-code')).toBe(true)
|
||||
})
|
||||
|
||||
// #31311: the npm install on Windows runs as node.exe, observed with this exact .bin shim path.
|
||||
it('recognizes Kimi Code launched by its npm shim on Windows', () => {
|
||||
expect(
|
||||
@@ -315,19 +141,6 @@ describe('agent process recognition', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine('node C:\\tools\\app\\dist\\main.mjs')).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes Qwen Code by its installed qwen executable', () => {
|
||||
expect(recognizeAgentProcess('/home/dev/.local/bin/qwen')).toEqual({
|
||||
agent: 'qwen-code',
|
||||
processName: 'qwen'
|
||||
})
|
||||
expect(recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\qwen.cmd`)).toEqual({
|
||||
agent: 'qwen-code',
|
||||
processName: 'qwen'
|
||||
})
|
||||
expect(isExpectedAgentProcess('/usr/local/bin/qwen', 'qwen')).toBe(true)
|
||||
expect(isRecognizedAgentType('qwen')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes agent CLIs launched through interpreter wrappers', () => {
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine('node /Users/dev/.nvm/versions/node/bin/codex')
|
||||
@@ -471,27 +284,6 @@ describe('agent process recognition', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine(command)).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes Prime Agent by its binary and npm entrypoint', () => {
|
||||
expect(recognizeAgentProcess('prime-agent')).toEqual({
|
||||
agent: 'prime-agent',
|
||||
processName: 'prime-agent'
|
||||
})
|
||||
expect(recognizeAgentProcess('/opt/homebrew/bin/prime-agent')).toEqual({
|
||||
agent: 'prime-agent',
|
||||
processName: 'prime-agent'
|
||||
})
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine(
|
||||
'node /opt/homebrew/lib/node_modules/prime-agent/dist/bundle/cli.js'
|
||||
)
|
||||
).toEqual({ agent: 'prime-agent', processName: 'prime-agent' })
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine(
|
||||
String.raw`node.exe C:\Users\dev\AppData\Roaming\npm\node_modules\prime-agent\dist\bundle\cli.js`
|
||||
)
|
||||
).toEqual({ agent: 'prime-agent', processName: 'prime-agent' })
|
||||
})
|
||||
|
||||
it('does not recognize Prime Agent headless one-shot commands as interactive agents', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine('prime-agent -p "summarize this diff"')).toBeNull()
|
||||
expect(recognizeAgentProcessFromCommandLine('prime-agent --print "review this"')).toBeNull()
|
||||
@@ -580,64 +372,6 @@ describe('agent process recognition', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine('python -m not_aider')).toBeNull()
|
||||
})
|
||||
|
||||
it('identifies only foreground processes that can wrap agent entrypoints', () => {
|
||||
expect(isAgentForegroundWrapperProcess('node.exe')).toBe(true)
|
||||
expect(isAgentForegroundWrapperProcess('/usr/bin/python3')).toBe(true)
|
||||
expect(isAgentForegroundWrapperProcess('python3.12.exe')).toBe(true)
|
||||
expect(isAgentForegroundWrapperProcess('bash')).toBe(false)
|
||||
expect(isAgentForegroundWrapperProcess('vim.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes the Antigravity CLI from bare, POSIX and Windows command lines', () => {
|
||||
const agy = { agent: 'antigravity', processName: 'agy' }
|
||||
|
||||
expect(recognizeAgentProcess('agy')).toEqual(agy)
|
||||
expect(recognizeAgentProcess('/Users/dev/.local/bin/agy')).toEqual(agy)
|
||||
expect(recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Local\agy\bin\agy.exe`)).toEqual(
|
||||
agy
|
||||
)
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine(
|
||||
String.raw`"C:\Users\dev\AppData\Local\agy\bin\agy.exe" --dangerously-skip-permissions`
|
||||
)
|
||||
).toEqual(agy)
|
||||
expect(recognizeAgentProcessFromCommandLine('agy --dangerously-skip-permissions')).toEqual(agy)
|
||||
})
|
||||
|
||||
it('recognizes versioned Grok process names observed from the installed CLI', () => {
|
||||
expect(recognizeAgentProcess('grok-0.2.51')).toEqual({
|
||||
agent: 'grok',
|
||||
processName: 'grok-0.2.51'
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes the versioned Muse binary execed by the launcher', () => {
|
||||
expect(recognizeAgentProcess('muse-bin-1.3.0-r3401.1')).toEqual({
|
||||
agent: 'muse',
|
||||
processName: 'muse-bin-1.3.0-r3401.1'
|
||||
})
|
||||
expect(
|
||||
recognizeAgentProcessFromCommandLine('/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1')
|
||||
).toEqual({
|
||||
agent: 'muse',
|
||||
processName: 'muse-bin-1.3.0-r3401.1'
|
||||
})
|
||||
// Why: Linux truncates comm to 15 chars, so `muse-bin-1.0.3-R…` rows still match.
|
||||
expect(recognizeAgentProcess('muse-bin-1.0.3-R')).toEqual({
|
||||
agent: 'muse',
|
||||
processName: 'muse-bin-1.0.3-r'
|
||||
})
|
||||
expect(recognizeAgentProcess('muse-workbench')).toBeNull()
|
||||
expect(isRecognizedAgentType('muse-bin-1.3.0-R3401.1')).toBe(true)
|
||||
expect(isExpectedAgentProcess('muse', 'muse')).toBe(true)
|
||||
expect(isExpectedAgentProcess('muse-bin-1.3.0-R3401.1', 'muse')).toBe(true)
|
||||
expect(isExpectedAgentProcess('/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1', 'muse')).toBe(
|
||||
true
|
||||
)
|
||||
expect(isExpectedAgentProcess('not-muse', 'muse')).toBe(false)
|
||||
expect(isExpectedAgentProcess('muse-workbench', 'muse')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not recognize Muse headless exec runs as interactive agents', () => {
|
||||
expect(recognizeAgentProcessFromCommandLine("muse 'exec'")).toBeNull()
|
||||
for (const command of ['muse resume', 'muse -- resume', 'muse -- "exec the release notes"']) {
|
||||
|
||||
@@ -3,41 +3,17 @@ import {
|
||||
AGENT_PROMPT_BRACKETED_PASTE_END,
|
||||
AGENT_PROMPT_BRACKETED_PASTE_START,
|
||||
buildAgentPromptPasteBytes,
|
||||
agentPromptSubmitJoinsPasteFrame,
|
||||
agentPromptTakesLeadLine,
|
||||
getAgentPromptSubmitDelayMs,
|
||||
getMaxTerminalPasteBytesForIngestMs,
|
||||
getTerminalPasteIngestMs,
|
||||
isHeldAgentPromptDraft,
|
||||
iterateAgentPromptPasteChunks,
|
||||
resolveAgentPromptSubmitDelayForAgent
|
||||
iterateAgentPromptPasteChunks
|
||||
} from './agent-prompt-injection'
|
||||
|
||||
const BEGIN = AGENT_PROMPT_BRACKETED_PASTE_START
|
||||
const END = AGENT_PROMPT_BRACKETED_PASTE_END
|
||||
|
||||
describe('agent prompt injection bytes', () => {
|
||||
it('joins submit only for OMP', () => {
|
||||
expect(agentPromptSubmitJoinsPasteFrame('omp')).toBe(true)
|
||||
expect(agentPromptSubmitJoinsPasteFrame('claude')).toBe(false)
|
||||
expect(agentPromptSubmitJoinsPasteFrame('codex')).toBe(false)
|
||||
expect(agentPromptSubmitJoinsPasteFrame(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('always bracket-pastes prompts so agent TUIs treat newlines as content', () => {
|
||||
expect(buildAgentPromptPasteBytes('line one\nline two')).toBe(
|
||||
'\x1b[200~line one\nline two\x1b[201~'
|
||||
)
|
||||
})
|
||||
|
||||
it('types the lead line only for Claude agents and unidentified ones', () => {
|
||||
expect(agentPromptTakesLeadLine('claude')).toBe(true)
|
||||
expect(agentPromptTakesLeadLine('claude-agent-teams')).toBe(true)
|
||||
expect(agentPromptTakesLeadLine(null)).toBe(true)
|
||||
expect(agentPromptTakesLeadLine('codex')).toBe(false)
|
||||
expect(agentPromptTakesLeadLine('opencode')).toBe(false)
|
||||
})
|
||||
|
||||
it('types the lead line ahead of the paste frame on one line', () => {
|
||||
expect(buildAgentPromptPasteBytes('brief', 'Please\r\nfollow\x03\x1b[201~\x7f')).toBe(
|
||||
`Please follow [201~ ${BEGIN}brief${END}`
|
||||
@@ -45,23 +21,6 @@ describe('agent prompt injection bytes', () => {
|
||||
expect(buildAgentPromptPasteBytes('brief', '')).toBe(`${BEGIN}brief${END}`)
|
||||
})
|
||||
|
||||
it('keeps generic prompt behavior for recognition-only agents', () => {
|
||||
expect(agentPromptTakesLeadLine('dsb')).toBe(agentPromptTakesLeadLine(undefined))
|
||||
expect(agentPromptSubmitJoinsPasteFrame('dsb')).toBe(false)
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
expect(resolveAgentPromptSubmitDelayForAgent(platform, 'first\nsecond', 'dsb')).toBe(
|
||||
resolveAgentPromptSubmitDelayForAgent(platform, 'first\nsecond', undefined)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('costs a common-sized prompt far less than the old flat Windows delay', () => {
|
||||
// Measured ingest: 2 KB 14-25 ms, 8 KB 60-89 ms. The old constant charged 1_500 ms for both.
|
||||
expect(getAgentPromptSubmitDelayMs('win32', 2_000)).toBeLessThan(700)
|
||||
expect(getAgentPromptSubmitDelayMs('win32', 8_000)).toBeLessThan(700)
|
||||
expect(getAgentPromptSubmitDelayMs('darwin', 8_000)).toBeLessThan(700)
|
||||
})
|
||||
|
||||
it.each([
|
||||
// Pin the slower measured Win11 ingest samples so Enter cannot race incomplete paste.
|
||||
[2_000, 25],
|
||||
@@ -75,16 +34,6 @@ describe('agent prompt injection bytes', () => {
|
||||
expect(getAgentPromptSubmitDelayMs('win32', bytes)).toBeGreaterThan(measuredMs)
|
||||
})
|
||||
|
||||
it('keeps a real margin over the slowest measured slope', () => {
|
||||
// 1.5x of 0.0104 ms/byte, so a host ~50% slower than either measured one is still covered.
|
||||
expect(getTerminalPasteIngestMs('win32', 320_000)).toBeGreaterThan(3_342 * 1.4)
|
||||
})
|
||||
|
||||
it('outgrows the old 1_500 ms constant before ConPTY ingest does', () => {
|
||||
// Ingest crossed 1_500 ms at ~145 KB; the delay must already exceed it there.
|
||||
expect(getAgentPromptSubmitDelayMs('win32', 145_000)).toBeGreaterThan(1_500)
|
||||
})
|
||||
|
||||
it('scales without a cap all the way to the terminal input ceiling', () => {
|
||||
const ceilingBytes = 16 * 1024 * 1024
|
||||
expect(getAgentPromptSubmitDelayMs('win32', ceilingBytes)).toBeGreaterThan(250_000)
|
||||
@@ -92,35 +41,6 @@ describe('agent prompt injection bytes', () => {
|
||||
expect(getAgentPromptSubmitDelayMs('linux', ceilingBytes)).toBeGreaterThan(4_000)
|
||||
})
|
||||
|
||||
it('charges non-Windows hosts nothing measurable for a real prompt', () => {
|
||||
expect(getTerminalPasteIngestMs('darwin', 8_000)).toBeLessThanOrEqual(2)
|
||||
expect(getTerminalPasteIngestMs('linux', 0)).toBe(0)
|
||||
expect(getTerminalPasteIngestMs('linux', Number.NaN)).toBe(0)
|
||||
expect(getTerminalPasteIngestMs('win32', -5)).toBe(0)
|
||||
})
|
||||
|
||||
it('grows monotonically with payload size on every platform', () => {
|
||||
for (const platform of ['win32', 'darwin', 'linux'] as const) {
|
||||
expect(getAgentPromptSubmitDelayMs(platform, 400_000)).toBeGreaterThan(
|
||||
getAgentPromptSubmitDelayMs(platform, 40_000)
|
||||
)
|
||||
}
|
||||
expect(getTerminalPasteIngestMs('win32', 320_000)).toBeGreaterThan(
|
||||
getTerminalPasteIngestMs('darwin', 320_000)
|
||||
)
|
||||
})
|
||||
|
||||
it('adds per-line settle time for antigravity multiline prompts', () => {
|
||||
const short = resolveAgentPromptSubmitDelayForAgent('darwin', 'one line', 'antigravity')
|
||||
const long = resolveAgentPromptSubmitDelayForAgent(
|
||||
'darwin',
|
||||
`${'Filler line\n'.repeat(100)}AGY_LONG_OK`,
|
||||
'antigravity'
|
||||
)
|
||||
expect(long - short).toBeGreaterThanOrEqual(100 * 45)
|
||||
expect(resolveAgentPromptSubmitDelayForAgent('darwin', 'one line', 'aider')).toBe(short - 45)
|
||||
})
|
||||
|
||||
it('inverts the host ingest budget without crossing it', () => {
|
||||
const bytes = getMaxTerminalPasteBytesForIngestMs('win32', 20_000)
|
||||
expect(getTerminalPasteIngestMs('win32', bytes)).toBe(20_000)
|
||||
|
||||
@@ -70,16 +70,6 @@ describe('dropAgentResumeArgvFromCommand', () => {
|
||||
).toEqual({ status: 'dropped', command: "codex '-c' 'disable_paste_burst=true' '--search'" })
|
||||
})
|
||||
|
||||
it('drops an unquoted resume argv from a legacy persisted command', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `codex resume ${CODEX_SESSION.id}`,
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'dropped', command: 'codex' })
|
||||
})
|
||||
|
||||
it('drops the joined Copilot resume token and keeps the user args', () => {
|
||||
const providerSession = {
|
||||
key: 'session_id' as const,
|
||||
@@ -101,16 +91,6 @@ describe('dropAgentResumeArgvFromCommand', () => {
|
||||
).toEqual({ status: 'dropped', command: "copilot '--yolo'" })
|
||||
})
|
||||
|
||||
it('reports absent when the command never carried the resume locator', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: 'codex --search',
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'absent' })
|
||||
})
|
||||
|
||||
it('refuses a command whose locator is not the trailing argv', () => {
|
||||
// Why: the caller must fail the spawn rather than launch a resume it could not cancel.
|
||||
expect(
|
||||
@@ -142,18 +122,6 @@ describe('dropAgentResumeArgvFromCommand', () => {
|
||||
).toEqual({ status: 'unrecognized' })
|
||||
})
|
||||
|
||||
it('drops a resume argv left with trailing whitespace by a persisted command', () => {
|
||||
// Why: the suffix match is anchored to the end, so an unnormalized command would
|
||||
// fall through to `unrecognized` and refuse an otherwise strippable launch.
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `codex 'resume' '${CODEX_SESSION.id}' \n`,
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'dropped', command: 'codex' })
|
||||
})
|
||||
|
||||
it('refuses when the locator also appears outside the trailing resume argv', () => {
|
||||
// Why: stripping the suffix would leave the id behind in the cwd, so the launch is
|
||||
// not actually resume-free and must not be reported as dropped.
|
||||
@@ -165,14 +133,4 @@ describe('dropAgentResumeArgvFromCommand', () => {
|
||||
})
|
||||
).toEqual({ status: 'unrecognized' })
|
||||
})
|
||||
|
||||
it('drops the resume argv for the other resumable agents too', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `claude '--resume' '${CODEX_SESSION.id}'`,
|
||||
agent: 'claude',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'dropped', command: 'claude' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,18 +36,6 @@ function expectSingleAuthoritativeResume(command: string, shell: AgentStartupShe
|
||||
}
|
||||
|
||||
describe('buildClaudeResumeLaunchCommand', () => {
|
||||
it.each(SHELLS)('appends the authoritative selector to a plain base ($shell)', ({ shell }) => {
|
||||
const command = buildClaudeResumeLaunchCommand('claude', RESUME, shell)
|
||||
expectSingleAuthoritativeResume(command, shell)
|
||||
expect(command.startsWith('claude ')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(SHELLS)('strips a bare persisted --resume picker default ($shell)', ({ shell }) => {
|
||||
const base = shell === 'cmd' ? 'claude "--resume"' : "claude '--resume'"
|
||||
const command = buildClaudeResumeLaunchCommand(base, RESUME, shell)
|
||||
expectSingleAuthoritativeResume(command, shell)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude --resume stale-session',
|
||||
'claude --resume=stale-session',
|
||||
@@ -64,12 +52,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
expectSingleAuthoritativeResume(command, 'posix')
|
||||
})
|
||||
|
||||
it('keeps surviving options when stripping selectors', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand('claude --resume stale --model sonnet', RESUME, 'posix')
|
||||
).toBe(`claude --model sonnet '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it('never mistakes a dash-leading option value for a selector', () => {
|
||||
expect(buildClaudeResumeLaunchCommand('claude --model -recent', RESUME, 'posix')).toBe(
|
||||
`claude --model -recent '--resume' '${SESSION_ID}'`
|
||||
@@ -85,37 +67,12 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the ambiguous joined -r<id> form alone (degrades to pre-guard behavior)', () => {
|
||||
expect(buildClaudeResumeLaunchCommand('claude -rstale-session', RESUME, 'posix')).toBe(
|
||||
`claude -rstale-session '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves wrapper commands untouched and appends at the end', () => {
|
||||
expect(buildClaudeResumeLaunchCommand('bash -c claude', RESUME, 'posix')).toBe(
|
||||
`bash -c claude '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
expect(buildClaudeResumeLaunchCommand('mise exec -- claude', RESUME, 'posix')).toBe(
|
||||
`mise exec -- claude '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
expect(buildClaudeResumeLaunchCommand('sudo -u dev -- claude', RESUME, 'posix')).toBe(
|
||||
`sudo -u dev -- claude '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('strips selectors that follow claude after a wrapper terminator', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand("mise exec -- claude '--resume' stale", RESUME, 'posix')
|
||||
).toBe(`mise exec -- claude '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it('fails open for claude outside command position (wrapper without --)', () => {
|
||||
// Without a wrapper -- boundary, claude may be an argument; retain append-only behavior.
|
||||
expect(buildClaudeResumeLaunchCommand("npx claude '--resume'", RESUME, 'posix')).toBe(
|
||||
`npx claude '--resume' '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('never mistakes a claude-suffixed argument for the executable', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand(
|
||||
@@ -141,21 +98,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
).toBe(`nix develop /Users/me/src/claude -c claude --resume OLD '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it.each(['claude "\\--resume" old', 'claude "\\-r" old', 'claude --model "\\--resume"'])(
|
||||
'fails open on a double-quoted backslash the shell keeps literal: %s',
|
||||
(base) => {
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('still strips when a double-quoted backslash is shell-consumed', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand('claude --model "a\\"b" --resume old', RESUME, 'posix')
|
||||
).toBe(`claude --model "a\\"b" '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude "--resu\\\nme" --resume stale',
|
||||
"claude $'-c' --resume stale",
|
||||
@@ -166,35 +108,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude --resume old --append-system-prompt "spend under 5$"',
|
||||
'claude --resume old --note x$',
|
||||
'claude --resume old --note "a$"'
|
||||
])('still strips when a $ is not an expansion opener: %s', (base) => {
|
||||
const command = buildClaudeResumeLaunchCommand(base, RESUME, 'posix')
|
||||
expect(command).not.toContain('--resume old')
|
||||
expectSingleAuthoritativeResume(command, 'posix')
|
||||
})
|
||||
|
||||
it.each(['claude a^" --resume ^"b', 'claude ^"x --resume^" --resume old'])(
|
||||
'fails open when a cmd caret escapes a quote: %s',
|
||||
(base) => {
|
||||
// cmd strips the caret before child quote parsing; tokenizer words no longer match argv.
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'cmd')).toBe(
|
||||
`${base} "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['claude --resume (Get-Content id.txt)', 'claude --hook { npm -c run } --resume old'])(
|
||||
'fails open on bare powershell evaluation syntax: %s',
|
||||
(base) => {
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'claude "-`r" foo --model x',
|
||||
'claude "-`u{72}" oldid --resume x',
|
||||
@@ -207,14 +120,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on a token-leading powershell backtick before whitespace', () => {
|
||||
// PowerShell drops backtick+whitespace without a token; the extra tokenizer token would shift the locator.
|
||||
const base = 'claude --resume ` \t"q"'
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on the powershell stop-parsing token', () => {
|
||||
// After --%, appended quotes reach the child literally.
|
||||
const base = 'claude --% --resume stale --model sonnet'
|
||||
@@ -223,52 +128,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('still strips when --% is quoted or on cmd, where it is an ordinary token', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand('claude "--%" --resume stale', RESUME, 'powershell')
|
||||
).toBe(`claude "--%" '--resume' '${SESSION_ID}'`)
|
||||
expect(buildClaudeResumeLaunchCommand('claude --% --resume stale', RESUME, 'cmd')).toBe(
|
||||
`claude --% "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('still strips with parens safely inside quotes on powershell', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand(
|
||||
'claude --allowedTools "Bash(git:*)" --resume old',
|
||||
RESUME,
|
||||
'powershell'
|
||||
)
|
||||
).toBe(`claude --allowedTools "Bash(git:*)" '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude "--add-dir" "C:\\a\\" "--" "--out" "D:\\x\\"',
|
||||
'claude --add-dir "C:\\repo\\" --resume old'
|
||||
])('fails open when a cmd backslash run makes a quote literal: %s', (base) => {
|
||||
// Odd backslash runs make quotes literal to CommandLineToArgvW, invalidating tokenizer boundaries.
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'cmd')).toBe(
|
||||
`${base} "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude --add-dir "C:\\Users\\me\\repo" --resume old',
|
||||
'claude --add-dir "C:\\Program Files (x86)\\x" --resume old'
|
||||
])('still strips for ordinary quoted windows paths: %s', (base) => {
|
||||
const command = buildClaudeResumeLaunchCommand(base, RESUME, 'cmd')
|
||||
expect(command).not.toContain('--resume old')
|
||||
expectSingleAuthoritativeResume(command, 'cmd')
|
||||
})
|
||||
|
||||
it('fails open when a cmd caret escapes a real argument separator', () => {
|
||||
// cmd strips ^ before the child splits the bare space; splicing a merged token would lose the second argument.
|
||||
const base = 'claude --resume a^ b --model x'
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'cmd')).toBe(
|
||||
`${base} "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude --resume old \\', 'posix' as const],
|
||||
['claude --resume old `', 'powershell' as const],
|
||||
@@ -279,88 +138,18 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, shell)).toBe(`${base} ${quoted}`)
|
||||
})
|
||||
|
||||
it('fails open on a windows escaped line continuation', () => {
|
||||
const powershellBase = 'claude --resume stale `\n--resume hidden'
|
||||
expect(buildClaudeResumeLaunchCommand(powershellBase, RESUME, 'powershell')).toBe(
|
||||
`${powershellBase} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
const cmdBase = 'claude --resume stale ^\n--resume hidden'
|
||||
expect(buildClaudeResumeLaunchCommand(cmdBase, RESUME, 'cmd')).toBe(
|
||||
`${cmdBase} "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on a posix line continuation hiding a selector', () => {
|
||||
const base = 'claude --model x \\\n--resume old'
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on escape characters the windows shells keep literal', () => {
|
||||
// cmd retains ^ in double quotes and PowerShell retains backticks in single quotes; neither forms a selector.
|
||||
expect(buildClaudeResumeLaunchCommand('claude "-^-resume" old', RESUME, 'cmd')).toBe(
|
||||
`claude "-^-resume" old "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
expect(buildClaudeResumeLaunchCommand("claude '-`-resume' old", RESUME, 'powershell')).toBe(
|
||||
`claude '-\`-resume' old '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on an unclosed expansion opened before claude', () => {
|
||||
const base = '$(x; npx -- claude --resume stale)'
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open when the executable token itself is unmodelable', () => {
|
||||
// cmd has no single-quote syntax, so 'claude' is not the claude executable.
|
||||
expect(buildClaudeResumeLaunchCommand("'claude' --resume old", RESUME, 'cmd')).toBe(
|
||||
`'claude' --resume old "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['cmd', 'powershell'] as const)(
|
||||
'fails open on a posix-style assignment prefix under %s',
|
||||
(shell) => {
|
||||
// Assignment prefixes are POSIX-only; Windows shells treat them as bogus executable names.
|
||||
const base = "FOO='bar' claude --resume old"
|
||||
const quoted = shell === 'cmd' ? `"--resume" "${SESSION_ID}"` : `'--resume' '${SESSION_ID}'`
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, shell)).toBe(`${base} ${quoted}`)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves an env-assignment or path prefix byte for byte', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand('FOO="$HOME/x" ~/bin/claude \'--resume\'', RESUME, 'posix')
|
||||
).toBe(`FOO="$HOME/x" ~/bin/claude '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it('recognizes Windows claude executable spellings', () => {
|
||||
expect(buildClaudeResumeLaunchCommand('C:\\tools\\claude.CMD "--resume"', RESUME, 'cmd')).toBe(
|
||||
`C:\\tools\\claude.CMD "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it("inserts the selector before claude's own -- terminator", () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand('claude --resume stale -- positional', RESUME, 'posix')
|
||||
).toBe(`claude '--resume' '${SESSION_ID}' -- positional`)
|
||||
})
|
||||
|
||||
it('fails open when the base cannot be tokenized', () => {
|
||||
expect(buildClaudeResumeLaunchCommand('claude "unterminated', RESUME, 'posix')).toBe(
|
||||
`claude "unterminated '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open when no claude executable token exists', () => {
|
||||
expect(buildClaudeResumeLaunchCommand('my-agent-wrapper --resume', RESUME, 'posix')).toBe(
|
||||
`my-agent-wrapper --resume '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude -c && echo done',
|
||||
'claude --resume stale; echo hi',
|
||||
@@ -375,29 +164,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude --resume old "y"; echo hi',
|
||||
"claude --resume old 'y'; echo hi",
|
||||
'claude --resume old --model "sonnet"&&echo hi',
|
||||
'claude --resume old "y"| tee /tmp/x',
|
||||
'claude --resume old \\"; echo hi'
|
||||
])('fails open when shell syntax hides behind partial quoting: %s', (base) => {
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on escape-adjacent operators in windows shells', () => {
|
||||
const powershellBase = 'claude --resume old `x; echo hi'
|
||||
expect(buildClaudeResumeLaunchCommand(powershellBase, RESUME, 'powershell')).toBe(
|
||||
`${powershellBase} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
const cmdBase = 'claude --resume old ^x& echo hi'
|
||||
expect(buildClaudeResumeLaunchCommand(cmdBase, RESUME, 'cmd')).toBe(
|
||||
`${cmdBase} "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude --resume $(cat sid.txt)',
|
||||
'claude --resume=$(cat sid.txt)',
|
||||
@@ -409,31 +175,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'claude --resume "`cat "a b"`"',
|
||||
'claude --model "$(pick "x -c y")"',
|
||||
'claude --model "$(f "x --resume y")"',
|
||||
'claude --resume "$(cat "$HOME/My Sessions/id")"'
|
||||
])('fails open on expansions nested inside double quotes: %s', (base) => {
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'posix')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on a powershell double-quoted subexpression', () => {
|
||||
const base = 'claude --model "$(pick "x -c y")"'
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on a powershell subexpression locator', () => {
|
||||
const base = 'claude --resume $(Get-Content sid.txt)'
|
||||
expect(buildClaudeResumeLaunchCommand(base, RESUME, 'powershell')).toBe(
|
||||
`${base} '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('never walks the separator backoff into an escaped-space token', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand(
|
||||
@@ -444,13 +185,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
).toBe(`claude --append-system-prompt Be\\ nice\\ '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it('fails open when cmd operators hide in single quotes', () => {
|
||||
// cmd.exe has no single-quote syntax, so '&' is a live command separator.
|
||||
expect(buildClaudeResumeLaunchCommand("claude '&' --resume=old-session", RESUME, 'cmd')).toBe(
|
||||
`claude '&' --resume=old-session "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on cmd single-quoted literals that merely look like selectors', () => {
|
||||
// cmd passes single quotes through as junk positional bytes, not selectors/terminators.
|
||||
expect(buildClaudeResumeLaunchCommand("claude '--resume' old", RESUME, 'cmd')).toBe(
|
||||
@@ -460,36 +194,6 @@ describe('buildClaudeResumeLaunchCommand', () => {
|
||||
`claude '--' --resume old "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('still strips next to a caret-escaped literal ampersand on cmd', () => {
|
||||
// ^& is an inactive, literal & in cmd, so the guard may keep working.
|
||||
expect(buildClaudeResumeLaunchCommand('claude --resume old ^&', RESUME, 'cmd')).toBe(
|
||||
`claude ^& "--resume" "${SESSION_ID}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('still strips when operators are safely quoted', () => {
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand(
|
||||
"claude --append-system-prompt 'use && wisely' --resume stale",
|
||||
RESUME,
|
||||
'posix'
|
||||
)
|
||||
).toBe(`claude --append-system-prompt 'use && wisely' '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it('recognizes claude behind the PowerShell call operator', () => {
|
||||
expect(buildClaudeResumeLaunchCommand("& claude '--resume'", RESUME, 'powershell')).toBe(
|
||||
`& claude '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
expect(
|
||||
buildClaudeResumeLaunchCommand(
|
||||
"& 'C:\\Program Files\\claude\\claude.exe' --resume old",
|
||||
RESUME,
|
||||
'powershell'
|
||||
)
|
||||
).toBe(`& 'C:\\Program Files\\claude\\claude.exe' '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAgentResumeStartupPlan claude selector guard', () => {
|
||||
@@ -520,40 +224,6 @@ describe('buildAgentResumeStartupPlan claude selector guard', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('emits one identity-bearing resume when only default args carry a stale id', () => {
|
||||
const restored = buildAgentResumeStartupPlan({
|
||||
agent: 'claude',
|
||||
providerSession,
|
||||
cmdOverrides: {},
|
||||
agentArgs: '--resume stale-session --model sonnet',
|
||||
platform: 'linux'
|
||||
})
|
||||
expect(restored?.launchCommand).toBe(`claude '--model' 'sonnet' '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it('builds a resume command for a custom command the tokenizer rejects', () => {
|
||||
const restored = buildAgentResumeStartupPlan({
|
||||
agent: 'claude',
|
||||
providerSession,
|
||||
cmdOverrides: {},
|
||||
agentCommand: 'claude --model $(cat ~/.claude-model) "unterminated',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(restored).not.toBeNull()
|
||||
expect(restored?.launchCommand.endsWith(`'--resume' '${SESSION_ID}'`)).toBe(true)
|
||||
})
|
||||
|
||||
it('retains Gemini args while appending its resume identity', () => {
|
||||
const restored = buildAgentResumeStartupPlan({
|
||||
agent: 'gemini',
|
||||
providerSession,
|
||||
cmdOverrides: {},
|
||||
agentArgs: '--resume',
|
||||
platform: 'linux'
|
||||
})
|
||||
expect(restored?.launchCommand).toBe(`gemini '--resume' '--resume' '${SESSION_ID}'`)
|
||||
})
|
||||
|
||||
it('persists the original base command unchanged', () => {
|
||||
const restored = buildAgentResumeStartupPlan({
|
||||
agent: 'claude',
|
||||
|
||||
@@ -9,31 +9,6 @@ function makeTab(overrides: Partial<ConversationNameTab> = {}): ConversationName
|
||||
}
|
||||
|
||||
describe('getAgentRowConversationName', () => {
|
||||
it('prefers the manual tab rename over every other source', () => {
|
||||
const tab = makeTab({
|
||||
customTitle: 'Patient sync spike',
|
||||
quickCommandLabel: 'Run tests',
|
||||
generatedTitle: 'Fix intake flow',
|
||||
title: '✳ Investigate replay bug'
|
||||
})
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Patient sync spike')
|
||||
})
|
||||
|
||||
it('falls back to the quick-command label before titles', () => {
|
||||
const tab = makeTab({ quickCommandLabel: 'Run tests', title: '✳ Investigate replay bug' })
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Run tests')
|
||||
})
|
||||
|
||||
it('keeps OpenCode semantic session titles whole', () => {
|
||||
const tab = makeTab({ title: 'OC | build the release pipeline', generatedTitle: 'Older title' })
|
||||
expect(getAgentRowConversationName(tab, 'opencode', false)).toBe(
|
||||
'OC | build the release pipeline'
|
||||
)
|
||||
expect(getAgentRowConversationName(tab, 'opencode', true)).toBe(
|
||||
'OC | build the release pipeline'
|
||||
)
|
||||
})
|
||||
|
||||
it("shows the title owned by the row's provider session", () => {
|
||||
const tab = makeTab({
|
||||
aiVaultTitle: { agent: 'claude', sessionId: 's1', title: 'Fix the lease probe' },
|
||||
@@ -50,28 +25,6 @@ describe('getAgentRowConversationName', () => {
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
|
||||
})
|
||||
|
||||
it('lets a manual rename and a quick command still outrank the provider title', () => {
|
||||
const vault = { agent: 'claude' as const, sessionId: 's1', title: 'Fix the lease probe' }
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ aiVaultTitle: vault, customTitle: 'Mine' }),
|
||||
'claude',
|
||||
true,
|
||||
undefined,
|
||||
's1'
|
||||
)
|
||||
).toBe('Mine')
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ aiVaultTitle: vault, quickCommandLabel: 'Run tests' }),
|
||||
'claude',
|
||||
true,
|
||||
undefined,
|
||||
's1'
|
||||
)
|
||||
).toBe('Run tests')
|
||||
})
|
||||
|
||||
it('keeps a cwd-shaped provider title that the live-title sanitizer would discard', () => {
|
||||
// `auth/login` is a real session name; as a scraped live title it would be read as a cwd.
|
||||
const tab = makeTab({
|
||||
@@ -82,12 +35,6 @@ describe('getAgentRowConversationName', () => {
|
||||
expect(getAgentRowConversationName(makeTab({ title: 'auth/login' }), 'claude', true)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the generated title only when generated titles are enabled', () => {
|
||||
const tab = makeTab({ generatedTitle: 'Fix intake flow', title: '✳ Investigate replay bug' })
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
|
||||
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug')
|
||||
})
|
||||
|
||||
it('names a split pane from its own live title, not the tab title', () => {
|
||||
// Tab live title belongs to the focused pane; siblings need their own.
|
||||
const tab = makeTab({ title: '\u2733 Linear work log' })
|
||||
@@ -100,134 +47,4 @@ describe('getAgentRowConversationName', () => {
|
||||
expect(getAgentRowConversationName(tab, 'claude', false, null)).toBeNull()
|
||||
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Linear work log')
|
||||
})
|
||||
|
||||
it('keeps tab-owned names above the pane title', () => {
|
||||
// User-given names belong to the tab and remain stable across pane focus.
|
||||
const custom = makeTab({ customTitle: 'Patient sync spike' })
|
||||
expect(getAgentRowConversationName(custom, 'claude', false, 'Redis cache strategy')).toBe(
|
||||
'Patient sync spike'
|
||||
)
|
||||
const quick = makeTab({ quickCommandLabel: 'Run tests' })
|
||||
expect(getAgentRowConversationName(quick, 'claude', false, null)).toBe('Run tests')
|
||||
const generated = makeTab({ generatedTitle: 'Fix intake flow' })
|
||||
expect(getAgentRowConversationName(generated, 'claude', true, null)).toBe('Fix intake flow')
|
||||
})
|
||||
|
||||
it('strips leading status decoration from agent-set titles', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false)
|
||||
).toBe('Fix patient intake flow')
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '⠋ Refactor replay guard' }), 'codex', false)
|
||||
).toBe('Refactor replay guard')
|
||||
})
|
||||
|
||||
it('rejects spinner+cwd titles instead of surfacing paths as names', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '⠋ ~/orca/workspaces' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '/Users/dev/repo' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'C:\\repos\\orca' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'orca/workspaces' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }),
|
||||
'codex',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'repos\\orca' }), 'codex', false)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts multi-word titles that merely contain a slash', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Fix a/b toggle in settings' }), 'codex', false)
|
||||
).toBe('Fix a/b toggle in settings')
|
||||
})
|
||||
|
||||
it('rejects synthetic status titles', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Codex ready' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Codex - action required' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Cursor Agent' }), 'cursor', false)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects identity-echo, management, and placeholder titles', () => {
|
||||
expect(getAgentRowConversationName(makeTab({ title: 'Claude' }), 'claude', false)).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '✳ Claude Code' }), 'claude', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: 'Claude Code - action required' }),
|
||||
'claude',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '✦ Gemini CLI' }), 'gemini', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '◇ Ready (orca)' }), 'gemini', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'claude agents' }), 'claude', false)
|
||||
).toBeNull()
|
||||
expect(getAgentRowConversationName(makeTab({ title: 'Agent' }), 'claude', false)).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects jcode's live identity title so the row shows the prompt instead", () => {
|
||||
// jcode's per-second codename/diff/duration title would hide the actual prompt/reply preview.
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: '🌐 jcode Puppy · +3 -0 · last ~23s' }),
|
||||
'jcode',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '🐍 jcode Snake · work ~6s' }), 'jcode', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '🐍 jcode/creek Snake' }), 'jcode', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Fix the greet helper' }), 'jcode', false)
|
||||
).toBe('Fix the greet helper')
|
||||
})
|
||||
|
||||
it('rejects empty, glyph-only, and default terminal titles', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: 'Workbench', defaultTitle: 'Workbench' }),
|
||||
'claude',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
expect(getAgentRowConversationName(makeTab(), 'claude', false)).toBeNull()
|
||||
expect(getAgentRowConversationName(makeTab({ title: '✳' }), 'claude', false)).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Terminal 1' }), 'claude', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: 'Terminal 2', defaultTitle: 'Terminal 2' }),
|
||||
'claude',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeAgentStatusPayload } from './agent-status-types'
|
||||
import {
|
||||
getAgentRowGeneratedTitleText,
|
||||
getAgentRowPrimaryText,
|
||||
getOrcaDispatchTaskId,
|
||||
isOrcaDispatchPrompt
|
||||
} from './agent-row-primary-text'
|
||||
import { getAgentRowPrimaryText } from './agent-row-primary-text'
|
||||
|
||||
describe('getAgentRowPrimaryText', () => {
|
||||
it('prefers orchestration display name over the raw hook prompt', () => {
|
||||
expect(
|
||||
getAgentRowPrimaryText({
|
||||
prompt: 'You are working inside Orca, a multi-agent IDE.',
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskTitle: 'Checkout race',
|
||||
displayName: 'Fix checkout race'
|
||||
}
|
||||
})
|
||||
).toBe('Fix checkout race')
|
||||
})
|
||||
|
||||
it('falls back to task title when display name is absent', () => {
|
||||
expect(
|
||||
getAgentRowPrimaryText({
|
||||
prompt: 'You are working inside Orca, a multi-agent IDE.',
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskTitle: 'Checkout race'
|
||||
}
|
||||
})
|
||||
).toBe('Checkout race')
|
||||
})
|
||||
|
||||
it('ignores sticky orchestration labels that belong to a different task id', () => {
|
||||
expect(
|
||||
getAgentRowPrimaryText({
|
||||
@@ -53,57 +21,6 @@ Review dispatch prompts and make worker labels distinct`,
|
||||
).toBe('Review dispatch prompts and make worker labels distinct')
|
||||
})
|
||||
|
||||
it('uses the task block when orchestration metadata has not arrived yet', () => {
|
||||
expect(
|
||||
getAgentRowPrimaryText({
|
||||
prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker.
|
||||
Your coordinator's terminal handle is: term_parent
|
||||
Your task ID is: task_1
|
||||
|
||||
=== CLI COMMANDS ===
|
||||
orca orchestration send --to term_parent
|
||||
|
||||
=== TASK ===
|
||||
Review dispatch prompts and make worker labels distinct
|
||||
|
||||
Keep the raw preamble out of the sidebar.`
|
||||
})
|
||||
).toBe('Review dispatch prompts and make worker labels distinct')
|
||||
})
|
||||
|
||||
it('falls back to the raw prompt outside orchestration workers', () => {
|
||||
expect(getAgentRowPrimaryText({ prompt: ' Fix checkout race\n ' })).toBe('Fix checkout race')
|
||||
})
|
||||
|
||||
it.each([
|
||||
// Pi 1.5.0 `pi @report.txt "summarize"`, folded and cut by status normalization before the closer.
|
||||
['<file name="/home/me/report.txt"> long contents the preview cut off', 'report.txt'],
|
||||
['<file name="C:\\logs\\main.log"></file> ', 'main.log'],
|
||||
['<file name="/home/me/a.txt"> hi </file> summarize this', 'summarize this'],
|
||||
['<file name="/a.png"></file> <file name="/b.png"></file> compare these', 'compare these'],
|
||||
['<file name="/a.txt"></file> <file name="/b.txt"> contents cut off', 'b.txt'],
|
||||
['<file name="/a.txt"></file> { "tool": "x"', 'a.txt'],
|
||||
// Real paste from the report, folded by status normalization.
|
||||
['{ "tool": "orca diagnostics", "takenAt": "2026-10-16T21:04:57.936Z"', ''],
|
||||
['[{"code": "ETIMEDOUT", "count": 12}]', ''],
|
||||
['[ "a", "b" ]', ''],
|
||||
['<?xml version="1.0"?><project>', ''],
|
||||
['<!DOCTYPE html> <html>', '']
|
||||
])('labels a prompt that opens with pasted data: %s', (prompt, label) => {
|
||||
expect(getAgentRowPrimaryText({ prompt })).toBe(label)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'{foo} is undefined in the template',
|
||||
'{ fix this block',
|
||||
'[1, 2, 3] should come back sorted',
|
||||
'[WIP] finish the login flow',
|
||||
'<my-custom-element> render this component',
|
||||
'<file> tags are not closing'
|
||||
])('keeps a typed prompt that merely starts with punctuation: %s', (prompt) => {
|
||||
expect(getAgentRowPrimaryText({ prompt })).toBe(prompt)
|
||||
})
|
||||
|
||||
// The normalizer must preserve a meaningful task preview within 200 chars despite multi-KB dispatch preambles.
|
||||
it('derives a task preview from a normalized 200-char dispatch prompt without labels', () => {
|
||||
const longCliNoise = Array.from(
|
||||
@@ -138,45 +55,6 @@ ${taskBody}`
|
||||
expect(preview).not.toContain('CLI COMMANDS')
|
||||
})
|
||||
|
||||
it('prefers later orchestration labels over the extracted task preview', () => {
|
||||
const normalized = normalizeAgentStatusPayload({
|
||||
state: 'working',
|
||||
prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker.
|
||||
Your task ID is: task_label_later
|
||||
|
||||
=== CLI COMMANDS ===
|
||||
${'orca orchestration check\n'.repeat(30)}
|
||||
=== TASK ===
|
||||
Implement the detailed worker instructions that should not stay as the final label`
|
||||
})
|
||||
expect(normalized).not.toBeNull()
|
||||
|
||||
expect(getAgentRowPrimaryText({ prompt: normalized!.prompt })).toContain(
|
||||
'Implement the detailed worker instructions'
|
||||
)
|
||||
|
||||
expect(
|
||||
getAgentRowPrimaryText({
|
||||
prompt: normalized!.prompt,
|
||||
orchestration: {
|
||||
taskId: 'task_label_later',
|
||||
dispatchId: 'ctx-later',
|
||||
taskTitle: 'Worker instructions',
|
||||
displayName: 'Better worker label'
|
||||
}
|
||||
})
|
||||
).toBe('Better worker label')
|
||||
})
|
||||
|
||||
it('does not surface preamble boilerplate when the TASK marker is absent', () => {
|
||||
expect(
|
||||
getAgentRowPrimaryText({
|
||||
prompt:
|
||||
'You are working inside Orca, a multi-agent IDE. You are a dispatched worker. Your task ID is: task_no_body CLI noise only'
|
||||
})
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
// Raw preambles need standalone markers so base-drift subjects mentioning === TASK === cannot win.
|
||||
it('ignores adversarial === TASK === text in raw multi-line base-drift subjects', () => {
|
||||
expect(
|
||||
@@ -195,87 +73,4 @@ Implement the detailed worker instructions that should not stay as the final lab
|
||||
})
|
||||
).toBe('Fix the actual dispatch fallback preview')
|
||||
})
|
||||
|
||||
it('still reads the task body from a normalized single-line compact prompt', () => {
|
||||
expect(
|
||||
getAgentRowPrimaryText({
|
||||
prompt:
|
||||
'You are working inside Orca, a multi-agent IDE. Your task ID is: task_inline === TASK === Compact body preview'
|
||||
})
|
||||
).toBe('Compact body preview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrcaDispatchTaskId', () => {
|
||||
it('extracts the id when the trailing newline was folded to a space', () => {
|
||||
const normalized = normalizeAgentStatusPayload({
|
||||
state: 'working',
|
||||
prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker.
|
||||
Your task ID is: task_9f3ab2
|
||||
|
||||
=== TASK ===
|
||||
body`
|
||||
})
|
||||
expect(getOrcaDispatchTaskId(normalized!.prompt)).toBe('task_9f3ab2')
|
||||
})
|
||||
|
||||
it('extracts the id from a raw multi-line prompt', () => {
|
||||
expect(
|
||||
getOrcaDispatchTaskId(
|
||||
`You are working inside Orca, a multi-agent IDE.\nYour task ID is: task_1\n\n=== TASK ===\nbody`
|
||||
)
|
||||
).toBe('task_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOrcaDispatchPrompt / getAgentRowGeneratedTitleText', () => {
|
||||
it('treats leading whitespace as still a dispatch preamble', () => {
|
||||
expect(
|
||||
isOrcaDispatchPrompt(' You are working inside Orca, a multi-agent IDE. Worker task')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses orchestration labels for generated titles only on matching dispatch prompts', () => {
|
||||
expect(
|
||||
getAgentRowGeneratedTitleText({
|
||||
prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker.
|
||||
Your task ID is: task-1
|
||||
|
||||
=== TASK ===
|
||||
Checkout race body`,
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskTitle: 'Checkout race',
|
||||
displayName: 'Fix checkout race'
|
||||
}
|
||||
})
|
||||
).toBe('Fix checkout race')
|
||||
expect(
|
||||
getAgentRowGeneratedTitleText({
|
||||
prompt:
|
||||
'You are working inside Orca, a multi-agent IDE. Your task ID is: task-2 === TASK === New worker body',
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskTitle: 'Stale task',
|
||||
displayName: 'Stale worker label'
|
||||
}
|
||||
})
|
||||
).toBe('New worker body')
|
||||
})
|
||||
|
||||
it('ignores sticky orchestration for non-dispatch generated titles', () => {
|
||||
expect(
|
||||
getAgentRowGeneratedTitleText({
|
||||
prompt: ' Refactor the auth middleware\n ',
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskTitle: 'Stale task',
|
||||
displayName: 'Stale worker label'
|
||||
}
|
||||
})
|
||||
).toBe(' Refactor the auth middleware\n ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createAgentScratchWorktreePathMatcher,
|
||||
isAgentScratchRepoRootPath,
|
||||
isAgentScratchWorktreePath
|
||||
} from './agent-scratch-worktrees'
|
||||
|
||||
describe('isAgentScratchWorktreePath', () => {
|
||||
const repoPath = '/Users/dev/app'
|
||||
|
||||
it('matches Claude Code sub-agent worktrees', () => {
|
||||
expect(
|
||||
isAgentScratchWorktreePath(
|
||||
repoPath,
|
||||
'/Users/dev/app/.claude/worktrees/agent-a04ccaaa55ddadb91',
|
||||
[]
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches gsd parallel-agent workspaces', () => {
|
||||
expect(
|
||||
isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.gsd-workspaces/phase-1-subagent-2', [])
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches scratch worktrees created from a linked checkout', () => {
|
||||
const matchesAgentScratch = createAgentScratchWorktreePathMatcher(
|
||||
[repoPath, '/Users/dev/orca/workspaces/app/feature-x'],
|
||||
@@ -40,26 +23,6 @@ describe('isAgentScratchWorktreePath', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('matches Windows path separators and casing', () => {
|
||||
expect(
|
||||
isAgentScratchWorktreePath(
|
||||
'C:\\Users\\dev\\app',
|
||||
'c:\\USERS\\dev\\app\\.Claude\\Worktrees\\agent-a04ccaaa',
|
||||
[]
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches WSL UNC paths', () => {
|
||||
expect(
|
||||
isAgentScratchWorktreePath(
|
||||
'//wsl$/Ubuntu/home/dev/app',
|
||||
'//wsl.localhost/Ubuntu/home/dev/app/.claude/worktrees/agent-a04ccaaa',
|
||||
[]
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves case-sensitive POSIX and WSL tool segments', () => {
|
||||
expect(
|
||||
isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.Claude/Worktrees/agent-a04ccaaa', [])
|
||||
@@ -87,12 +50,6 @@ describe('isAgentScratchWorktreePath', () => {
|
||||
expect(isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.gsd-workspaces', [])).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match undotted claude directories', () => {
|
||||
expect(
|
||||
isAgentScratchWorktreePath(repoPath, '/Users/dev/app/claude/worktrees/agent-1', [])
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not inherit a scratch classification from the repo parent path', () => {
|
||||
expect(
|
||||
isAgentScratchWorktreePath(
|
||||
@@ -102,61 +59,4 @@ describe('isAgentScratchWorktreePath', () => {
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match user worktree conventions', () => {
|
||||
expect(isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.worktrees/feature-x', [])).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isAgentScratchWorktreePath(
|
||||
'/Users/dev/app',
|
||||
'/Users/dev/.superset/worktrees/app/fix-notes',
|
||||
[]
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isAgentScratchWorktreePath('/Users/dev/app', '/orca/workspaces/app/feature', [])).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentScratchRepoRootPath', () => {
|
||||
it('matches codex scratch capsule repos', () => {
|
||||
expect(
|
||||
isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6')
|
||||
).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/rc-fwd-qEXuEq')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches codex vendor imports and claude skills containers', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports/skills')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.claude/skills/obsidian-second-brain')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a repo registered at the scratch container itself', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches scratch worktree containers used as repo roots', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/app/.claude/worktrees/agent-a04ccaaa')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/app/.gsd-workspaces/phase-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches Windows separators and casing', () => {
|
||||
expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.codex-tmp\\Capsule-X')).toBe(true)
|
||||
expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.Claude\\Skills\\foo')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match ordinary user repos', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/projects/app')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/codex-tmp/app')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.codex/checkouts/app')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/skills/.claude-app')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match partial multi-segment markers', () => {
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/.claude/config')).toBe(false)
|
||||
expect(isAgentScratchRepoRootPath('/Users/dev/vendor_imports/app')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,39 +107,6 @@ it('preserves Unicode at both excerpt boundaries and across multiple blocks', ()
|
||||
expect(context.indexOf('FAILED')).toBeLessThan(context.indexOf('Last detail'))
|
||||
})
|
||||
|
||||
it('retains saved decisions, delivery states and image references while excluding reasoning and creation notices', () => {
|
||||
const context = buildAgentSessionHandoffContext([
|
||||
{ ...text('system', 'Private worker assignment'), agentStart: true },
|
||||
text('reasoning', 'Private reasoning'),
|
||||
text('system', 'Prompt state: resolved\nSelected choice: Keep planning'),
|
||||
{ ...text('user', 'Do not deploy'), queued: true },
|
||||
{ ...text('user', 'Unsent request'), unsent: true },
|
||||
message('user', [{ type: 'image-ref', path: '/work/reference.png' }])
|
||||
])
|
||||
expect(context).toContain('Selected choice: Keep planning')
|
||||
expect(context).toContain('[user queued] Do not deploy')
|
||||
expect(context).toContain('[user unsent] Unsent request')
|
||||
expect(context).toContain('[user] [image] /work/reference.png')
|
||||
expect(context).not.toContain('Private')
|
||||
})
|
||||
|
||||
it('pairs out-of-order tool results by call ID and labels standalone results without guessing a tool', () => {
|
||||
const context = buildAgentSessionHandoffContext([
|
||||
message('assistant', [
|
||||
{ type: 'tool-call', name: 'Read', input: 'a.ts', callId: 'a' },
|
||||
{ type: 'tool-call', name: 'Search', input: 'pattern', callId: 'b' },
|
||||
{ type: 'tool-result', output: 'search result', callId: 'b' },
|
||||
{ type: 'tool-result', output: 'file contents', callId: 'a' },
|
||||
{ type: 'tool-result', output: 'standalone result', callId: 'unknown' }
|
||||
])
|
||||
])
|
||||
expect(context).toContain('[tool Read input preview] call=a a.ts')
|
||||
expect(context).toContain('[tool result] call=a file contents')
|
||||
expect(context).toContain('[tool Search input preview] call=b pattern')
|
||||
expect(context).toContain('[tool result] call=b search result')
|
||||
expect(context).toContain('[tool result] call=unknown standalone result')
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'prioritizes the latest out-of-order result under tool budget pressure (separate messages: %s)',
|
||||
(separateMessages) => {
|
||||
@@ -198,37 +165,6 @@ it('respects the total bound with oversized metadata and a nearly exhausted tool
|
||||
expect(buildAgentSessionHandoffContext([])).toBe('')
|
||||
})
|
||||
|
||||
it('retains readable provider failures and warnings while excluding wordless frames', () => {
|
||||
const frame = {
|
||||
provider: 'codex',
|
||||
kind: 'notification:hook/completed',
|
||||
payload: { head: '{}', byteLength: 2, digest: 'abc', truncated: false }
|
||||
}
|
||||
const context = buildAgentSessionHandoffContext([
|
||||
text('user', 'Run the tests'),
|
||||
message('system', [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Codex PreToolUse hook failed: permission denied',
|
||||
tone: 'error',
|
||||
providerFrame: frame
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The configured sandbox is unavailable',
|
||||
tone: 'warning',
|
||||
providerFrame: frame
|
||||
},
|
||||
{ type: 'text', text: 'Readable provider notice', providerFrame: frame },
|
||||
{ type: 'text', text: 'codex · notification:hook/completed', providerFrame: frame }
|
||||
])
|
||||
])
|
||||
expect(context).toContain('permission denied')
|
||||
expect(context).toContain('The configured sandbox is unavailable')
|
||||
expect(context).toContain('Readable provider notice')
|
||||
expect(context).not.toContain('codex · notification:hook/completed')
|
||||
})
|
||||
|
||||
it.each(['queued', 'unsent'] as const)(
|
||||
'reserves delivered conversation ahead of oversized %s messages',
|
||||
(delivery) => {
|
||||
|
||||
@@ -5,19 +5,6 @@ import {
|
||||
} from './agent-session-approval-blocked-path'
|
||||
|
||||
describe('approvalBlockedPathToShow', () => {
|
||||
it('hides a path the tool input already shows, including JSON-escaped Windows paths', () => {
|
||||
const blockedPath = 'C:\\qa\\demo\\notes.md'
|
||||
expect(
|
||||
approvalBlockedPathToShow({
|
||||
blockedPath,
|
||||
detail: JSON.stringify({ file_path: blockedPath }, null, 2)
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
approvalBlockedPathToShow({ blockedPath: '/tmp/out.txt', detail: 'echo hi > /tmp/out.txt' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('checks only the text the card draws: a plan replaces the detail', () => {
|
||||
expect(
|
||||
approvalBlockedPathToShow({
|
||||
@@ -66,19 +53,4 @@ describe('approvalBlockedPathToShow', () => {
|
||||
})
|
||||
).toBe('C:\\qa\\demo')
|
||||
})
|
||||
|
||||
it('hides a path the text names before a separator, quote or space', () => {
|
||||
expect(
|
||||
approvalBlockedPathToShow({ blockedPath: '/srv/data', detail: 'ls /srv/data/x' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
approvalBlockedPathToShow({ blockedPath: '/srv/data', detail: "cd '/srv/data' && ls" })
|
||||
).toBeNull()
|
||||
expect(
|
||||
approvalBlockedPathToShow({
|
||||
blockedPath: 'C:\\qa\\demo',
|
||||
detail: JSON.stringify({ file_path: 'C:\\qa\\demo\\notes.md' }, null, 2)
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { pendingPromptsAllUnanswerableHere } from './agent-session-approval-subject'
|
||||
import type { AgentJournalItemBody } from './journal/agent-session-journal-types'
|
||||
import { isAdmissibleAgentJournalItemBody } from './journal/agent-session-journal-schemas'
|
||||
|
||||
function pending(body: Record<string, unknown>): { body: AgentJournalItemBody } {
|
||||
const promptBody = {
|
||||
...body,
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
}
|
||||
if (!isAdmissibleAgentJournalItemBody(promptBody)) {
|
||||
throw new Error('Invalid pending prompt fixture')
|
||||
}
|
||||
return { body: promptBody }
|
||||
}
|
||||
const NEWER = pending({
|
||||
kind: 'approval',
|
||||
title: 'Review',
|
||||
detail: null,
|
||||
subject: { kind: 'diff' },
|
||||
options: [{ id: 'allow', label: 'Approve' }]
|
||||
})
|
||||
const PLAN = pending({
|
||||
kind: 'approval',
|
||||
title: 'Plan',
|
||||
detail: null,
|
||||
subject: { kind: 'plan', text: 'do it' },
|
||||
options: [{ id: 'allow', label: 'Approve' }]
|
||||
})
|
||||
const QUESTION = pending({ kind: 'question', question: 'Which?', options: [] })
|
||||
|
||||
it('holds only while every pending prompt is an approval this build cannot answer', () => {
|
||||
expect(pendingPromptsAllUnanswerableHere([])).toBe(false)
|
||||
expect(pendingPromptsAllUnanswerableHere([NEWER])).toBe(true)
|
||||
expect(pendingPromptsAllUnanswerableHere([NEWER, NEWER])).toBe(true)
|
||||
expect(pendingPromptsAllUnanswerableHere([NEWER, PLAN])).toBe(false)
|
||||
expect(pendingPromptsAllUnanswerableHere([QUESTION, NEWER])).toBe(false)
|
||||
const cancelled = JSON.parse(
|
||||
JSON.stringify(NEWER).replace('"state":"pending"', '"state":"cancelled"')
|
||||
)
|
||||
expect(pendingPromptsAllUnanswerableHere([cancelled, NEWER])).toBe(true)
|
||||
expect(pendingPromptsAllUnanswerableHere([NEWER, cancelled])).toBe(true)
|
||||
expect(pendingPromptsAllUnanswerableHere([cancelled, PLAN])).toBe(false)
|
||||
expect(pendingPromptsAllUnanswerableHere([cancelled])).toBe(false)
|
||||
})
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
const bytes = (text: string): number => new TextEncoder().encode(text).byteLength
|
||||
|
||||
describe('sanitizeAgentSessionAttachmentName', () => {
|
||||
it('keeps only the last path segment', () => {
|
||||
expect(sanitizeAgentSessionAttachmentName('../../etc/passwd')).toBe('passwd')
|
||||
expect(sanitizeAgentSessionAttachmentName('C:\\Users\\me\\shot.png')).toBe('shot.png')
|
||||
})
|
||||
|
||||
it('caps a long name in UTF-8 bytes on whole characters, keeping its extension', () => {
|
||||
const name = sanitizeAgentSessionAttachmentName(`${'图'.repeat(120)}.pdf`)
|
||||
expect(bytes(name)).toBeLessThanOrEqual(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES)
|
||||
@@ -27,10 +22,4 @@ describe('sanitizeAgentSessionAttachmentName', () => {
|
||||
expect(name).toBe('a'.repeat(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - 1))
|
||||
expect(sanitizeAgentSessionAttachmentName('notes. .')).toBe('notes')
|
||||
})
|
||||
|
||||
it('renames what Windows reserves for devices', () => {
|
||||
expect(sanitizeAgentSessionAttachmentName('CON')).toBe('_CON')
|
||||
expect(sanitizeAgentSessionAttachmentName('nul.tar.gz')).toBe('_nul.tar.gz')
|
||||
expect(sanitizeAgentSessionAttachmentName('console.log')).toBe('console.log')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { admitAgentSessionBackgroundTaskState } from './agent-session-background-task-state-admission'
|
||||
|
||||
describe('a background-task frame a client admits', () => {
|
||||
it('takes a frame whose only change is incomplete tracking, both ways', () => {
|
||||
const listed = admitAgentSessionBackgroundTaskState({ state: 'monitoring', children: [] })
|
||||
const incomplete = admitAgentSessionBackgroundTaskState(
|
||||
{ state: 'monitoring', children: [], incomplete: true },
|
||||
listed
|
||||
)
|
||||
expect(incomplete).not.toBe(listed)
|
||||
expect(listed).toEqual({ state: 'monitoring', children: [] })
|
||||
expect(incomplete).toEqual({ state: 'monitoring', children: [], incomplete: true })
|
||||
const complete = admitAgentSessionBackgroundTaskState(
|
||||
{ state: 'monitoring', children: [] },
|
||||
incomplete
|
||||
)
|
||||
expect(complete).not.toBe(incomplete)
|
||||
expect(complete).toEqual({ state: 'monitoring', children: [] })
|
||||
expect(
|
||||
admitAgentSessionBackgroundTaskState({ state: 'monitoring', children: [] }, complete)
|
||||
).toBe(complete)
|
||||
})
|
||||
})
|
||||
@@ -179,13 +179,4 @@ describe('completed context clear projections', () => {
|
||||
expect(latestStructuredAgentContextFacts([newUsage, clear, usageRow])).toEqual(old)
|
||||
expect(selectStructuredAgentContextUsage([usageRow], undefined, clear.sequence)).toBeNull()
|
||||
})
|
||||
|
||||
it('chooses the newest clear independent of map order and scopes off-page rows', () => {
|
||||
const again = { ...clear, itemId: 'clear-again', sequence: 8 }
|
||||
expect(latestAgentSessionContextClearSequence([again, user, clear])).toBe(8)
|
||||
expect(agentSessionCurrentContextRows([user], [oldSend], 5)).toEqual({
|
||||
items: [],
|
||||
submissions: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,55 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH,
|
||||
isAgentSessionConversationName,
|
||||
normalizeAgentSessionConversationName
|
||||
} from './agent-session-conversation-name'
|
||||
|
||||
describe('normalizeAgentSessionConversationName', () => {
|
||||
it('keeps a plain single-line name unchanged', () => {
|
||||
expect(normalizeAgentSessionConversationName('Fix the flaky lease probe')).toBe(
|
||||
'Fix the flaky lease probe'
|
||||
)
|
||||
})
|
||||
|
||||
it('flattens whitespace so a multi-line name cannot break the tab strip', () => {
|
||||
expect(normalizeAgentSessionConversationName('Fix the\nlease\tprobe ')).toBe(
|
||||
'Fix the lease probe'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an empty or whitespace-only name rather than blanking the label', () => {
|
||||
expect(normalizeAgentSessionConversationName('')).toBeNull()
|
||||
expect(normalizeAgentSessionConversationName(' \n ')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects anything that is not a string', () => {
|
||||
expect(normalizeAgentSessionConversationName(undefined)).toBeNull()
|
||||
expect(normalizeAgentSessionConversationName(null)).toBeNull()
|
||||
expect(normalizeAgentSessionConversationName(42)).toBeNull()
|
||||
expect(normalizeAgentSessionConversationName({ title: 'x' })).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds a pasted essay to the stored maximum', () => {
|
||||
const normalized = normalizeAgentSessionConversationName(`First ${'x'.repeat(994)}`)
|
||||
expect(normalized).toBe(`First ${'x'.repeat(194)}`)
|
||||
expect(normalized).toHaveLength(AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH)
|
||||
expect(isAgentSessionConversationName(normalized)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentSessionConversationName', () => {
|
||||
it('accepts only bounded canonical names', () => {
|
||||
expect(isAgentSessionConversationName('Fix the probe')).toBe(true)
|
||||
expect(isAgentSessionConversationName('')).toBe(false)
|
||||
expect(isAgentSessionConversationName('a'.repeat(201))).toBe(false)
|
||||
expect(isAgentSessionConversationName(' Fix the probe ')).toBe(false)
|
||||
expect(isAgentSessionConversationName('Fix\nthe probe')).toBe(false)
|
||||
expect(isAgentSessionConversationName('Fix\u202Egnp.exe probe')).toBe(false)
|
||||
expect(isAgentSessionConversationName(7)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeAgentSessionConversationName hostile text', () => {
|
||||
it('strips controls and lone surrogates', () => {
|
||||
expect(normalizeAgentSessionConversationName('Fix\u0007the probe')).toBe('Fix the probe')
|
||||
@@ -66,12 +20,6 @@ describe('normalizeAgentSessionConversationName hostile text', () => {
|
||||
expect(normalized).toBe('a'.repeat(AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH - 1))
|
||||
expect(normalized).not.toContain('\uFFFD')
|
||||
})
|
||||
|
||||
it('keeps a legitimate non-ASCII name intact', () => {
|
||||
expect(normalizeAgentSessionConversationName('R\u00E9sum\u00E9 du fil \u2615')).toBe(
|
||||
'R\u00E9sum\u00E9 du fil \u2615'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeAgentSessionConversationName joiners', () => {
|
||||
@@ -89,18 +37,6 @@ describe('normalizeAgentSessionConversationName joiners', () => {
|
||||
expect(normalizeAgentSessionConversationName(name)).toBe(name)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bidi override', 'Fix\u202Egnp.exe probe', 'Fix gnp.exe probe'],
|
||||
['isolate pair', 'Fix\u2066the\u2069 probe', 'Fix the probe'],
|
||||
['Arabic letter mark', 'Fix\u061Cthe probe', 'Fix the probe'],
|
||||
['soft hyphen', 'Fix\u00ADthe probe', 'Fix the probe'],
|
||||
['word joiner', 'Fix\u2060the probe', 'Fix the probe'],
|
||||
['zero-width space', 'Fix\u200Bthe probe', 'Fix the probe'],
|
||||
['byte order mark', 'Fix\uFEFFthe probe', 'Fix the probe']
|
||||
])('still strips a %s', (_label, name, expected) => {
|
||||
expect(normalizeAgentSessionConversationName(name)).toBe(expected)
|
||||
})
|
||||
|
||||
// These Cf runs escaped the old enumeration and produced nonempty labels that rendered invisibly.
|
||||
it.each([
|
||||
['invisible maths operators', '\u2061\u2062\u2063\u2064'],
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentMessageSendersShown,
|
||||
readAgentMessageSource,
|
||||
type AgentMessageSource
|
||||
} from './agent-session-message-source'
|
||||
import { readAgentMessageSource, type AgentMessageSource } from './agent-session-message-source'
|
||||
import { testOrcaSessionId } from '../orca-session-address-test-fixture'
|
||||
|
||||
const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37'
|
||||
@@ -34,11 +30,6 @@ const FROM: AgentMessageSource = {
|
||||
}
|
||||
|
||||
describe("a message's sender, read through its one reader", () => {
|
||||
it('reads absence as the person, and a valid sender back whole', () => {
|
||||
expect(readAgentMessageSource(undefined)).toBeUndefined()
|
||||
expect(readAgentMessageSource(structuredClone(FROM))).toEqual(FROM)
|
||||
})
|
||||
|
||||
it("reads a Dispatch's task back whole", () => {
|
||||
const task: AgentMessageSource = {
|
||||
...FROM,
|
||||
@@ -87,23 +78,4 @@ describe("a message's sender, read through its one reader", () => {
|
||||
])
|
||||
expect(read?.senders[1]?.name?.startsWith('a b ')).toBe(true)
|
||||
})
|
||||
|
||||
it("reads a value that is not a sender at all as the person's message", () => {
|
||||
for (const value of [null, 'nobody', 42, [], { senders: [] }]) {
|
||||
expect(readAgentMessageSource(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('names at most a few senders and counts the rest', () => {
|
||||
const many: AgentMessageSource = {
|
||||
...FROM,
|
||||
senders: Array.from({ length: 5 }, (_, index) => ({
|
||||
party: { address: `term_${index}`, terminalHandle: `term_${index}`, orcaSessionId: null },
|
||||
name: `Agent ${index}`
|
||||
}))
|
||||
}
|
||||
const { shown, more } = agentMessageSendersShown(many)
|
||||
expect(shown.map((sender) => sender.name)).toEqual(['Agent 0', 'Agent 1', 'Agent 2'])
|
||||
expect(more).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,16 +38,4 @@ describe('provider context after clear', () => {
|
||||
expect(activeProviderContext(record).handleChain).toBe(record.providerHandleChain)
|
||||
expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(record))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a boundary beyond the record fence', () => {
|
||||
const record = agentSessionRecordFixture()
|
||||
expect(
|
||||
isPersistedAgentSessionRecord(
|
||||
encodeAgentSessionRecord({
|
||||
...record,
|
||||
providerContextBoundary: { ...boundary, afterFence: record.lease.runtimeFence + 1 }
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,21 +58,6 @@ describe('agent-session grouped question answers', () => {
|
||||
).toEqual(unicodeAnswers)
|
||||
})
|
||||
|
||||
it("keeps Claude's maximum choice group compact without losing selections", () => {
|
||||
const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({
|
||||
questionId: `q${questionIndex + 1}`,
|
||||
optionIds: Array.from(
|
||||
{ length: 4 },
|
||||
(_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}`
|
||||
)
|
||||
}))
|
||||
|
||||
const encoded = encodeAgentSessionQuestionAnswers(maximumSelections)
|
||||
expect(encoded.length).toBeLessThanOrEqual(512)
|
||||
expect(decodeAgentSessionQuestionAnswers(encoded)).toEqual(maximumSelections)
|
||||
expect(decodeWithOriginalPercentDecoder(encoded)).toEqual(maximumSelections)
|
||||
})
|
||||
|
||||
it('validates each grouped answer against its question shape', () => {
|
||||
const questions = [
|
||||
{
|
||||
|
||||
@@ -15,10 +15,6 @@ const CODEX = {
|
||||
}
|
||||
|
||||
describe('decodeAgentSessionAgentsResult', () => {
|
||||
it('reads every well-formed agent', () => {
|
||||
expect(decodeAgentSessionAgentsResult({ agents: [CODEX] })).toEqual([CODEX])
|
||||
})
|
||||
|
||||
it('drops a row it cannot read and keeps the rest', () => {
|
||||
expect(
|
||||
decodeAgentSessionAgentsResult({
|
||||
@@ -61,15 +57,4 @@ describe('decodeAgentSessionAgentsResult', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the first row of a repeated agent', () => {
|
||||
const later = { ...CODEX, capabilities: { ...CODEX.capabilities, rewind: false } }
|
||||
expect(decodeAgentSessionAgentsResult({ agents: [CODEX, later] })).toEqual([CODEX])
|
||||
})
|
||||
|
||||
it('answers null for a reply that is not an agent list', () => {
|
||||
expect(decodeAgentSessionAgentsResult(null)).toBeNull()
|
||||
expect(decodeAgentSessionAgentsResult({})).toBeNull()
|
||||
expect(decodeAgentSessionAgentsResult({ agents: 'claude' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
AgentJournalThreadGoal
|
||||
} from './journal/agent-session-journal-types'
|
||||
import {
|
||||
agentSessionThreadGoalElapsedSeconds,
|
||||
currentAgentSessionThreadGoal,
|
||||
currentAgentSessionThreadGoalSourceBySequence
|
||||
} from './agent-session-thread-goal'
|
||||
@@ -47,26 +46,6 @@ function goalRow(sequence: number, state: 'set' | 'cleared', value = goal()) {
|
||||
}
|
||||
|
||||
describe('current thread goal', () => {
|
||||
it('reports no answer when no row records a goal transition', () => {
|
||||
expect(
|
||||
currentAgentSessionThreadGoal([
|
||||
row(1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }),
|
||||
row(2, { kind: 'status', text: 'Context compacted' })
|
||||
])
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads the last goal row of a rendered snapshot, past later rows of other kinds', () => {
|
||||
const paused = goal({ status: 'paused' })
|
||||
expect(
|
||||
currentAgentSessionThreadGoal([
|
||||
goalRow(1, 'set'),
|
||||
goalRow(2, 'set', paused),
|
||||
row(3, { kind: 'status', text: 'Context compacted' })
|
||||
])
|
||||
).toEqual(paused)
|
||||
})
|
||||
|
||||
it('takes the latest transition by sequence for items held unordered', () => {
|
||||
const paused = goal({ status: 'paused' })
|
||||
const latest = goalRow(5, 'set', paused)
|
||||
@@ -81,10 +60,6 @@ describe('current thread goal', () => {
|
||||
expect(currentAgentSessionThreadGoalSourceBySequence([])?.goal).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers null once the latest transition cleared the goal', () => {
|
||||
expect(currentAgentSessionThreadGoal([goalRow(1, 'set'), goalRow(2, 'cleared')])).toBeNull()
|
||||
})
|
||||
|
||||
it('answers null for a row written before the typed snapshot existed', () => {
|
||||
const legacy = row(3, {
|
||||
kind: 'status',
|
||||
@@ -108,19 +83,3 @@ describe('current thread goal', () => {
|
||||
expect(currentAgentSessionThreadGoal([goalRow(1, 'set'), future])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('thread goal presentation facts', () => {
|
||||
it('adds time only while an active goal has a turn running', () => {
|
||||
const now = 1_000_000 + 7_500
|
||||
const running = { startedAt: null }
|
||||
expect(agentSessionThreadGoalElapsedSeconds(goal(), now, null)).toBe(30)
|
||||
expect(agentSessionThreadGoalElapsedSeconds(goal(), now, running)).toBe(37)
|
||||
expect(agentSessionThreadGoalElapsedSeconds(goal({ status: 'paused' }), now, running)).toBe(30)
|
||||
// A report older than the turn counts from the turn's start, not across the idle gap.
|
||||
expect(agentSessionThreadGoalElapsedSeconds(goal(), now, { startedAt: now - 2_000 })).toBe(32)
|
||||
// A provider clock ahead of this one never subtracts time.
|
||||
expect(
|
||||
agentSessionThreadGoalElapsedSeconds(goal({ updatedAt: now + 5_000 }), now, running)
|
||||
).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readAgentSessionFailureFact,
|
||||
type AgentSessionFailureFact,
|
||||
type SubmissionRejectionKind
|
||||
} from './agent-session-failure'
|
||||
import './agent-session-failure'
|
||||
import type { AgentSessionFailureFact, SubmissionRejectionKind } from './agent-session-failure'
|
||||
import {
|
||||
agentSessionFailureSentence,
|
||||
agentSessionFailureWords
|
||||
@@ -65,27 +62,3 @@ describe('the words written beside a failure fact', () => {
|
||||
expect(retrying({})).toBe('Codex hit a temporary problem and is retrying.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('an attachment problem as a reader meets it', () => {
|
||||
it('keeps what it can place and drops what it cannot', () => {
|
||||
expect(
|
||||
readAgentSessionFailureFact({
|
||||
kind: 'attachmentInvalid',
|
||||
attachment: { reason: 'tooMany', limit: 20 }
|
||||
})
|
||||
).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } })
|
||||
expect(
|
||||
readAgentSessionFailureFact({
|
||||
kind: 'attachmentInvalid',
|
||||
attachment: { reason: 'tooMany', limit: 'twenty' }
|
||||
})
|
||||
).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany' } })
|
||||
// A reason a newer host added reads as the generic attachment failure.
|
||||
expect(
|
||||
readAgentSessionFailureFact({
|
||||
kind: 'attachmentInvalid',
|
||||
attachment: { reason: 'futureReason' }
|
||||
})
|
||||
).toEqual({ kind: 'attachmentInvalid' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,40 +75,6 @@ describe('reading a failure fact', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps what a provider said it is retrying, and only that', () => {
|
||||
expect(
|
||||
readAgentSessionFailureFact({
|
||||
kind: 'providerRetrying',
|
||||
retry: { error: 'rate_limit', status: 429, retryDelayMs: 600 }
|
||||
})
|
||||
).toEqual({ kind: 'providerRetrying', retry: { error: 'rate_limit', status: 429 } })
|
||||
expect(
|
||||
readAgentSessionFailureFact({ kind: 'providerRetrying', retry: { error: '', status: 'x' } })
|
||||
).toEqual({ kind: 'providerRetrying' })
|
||||
// The provider's own account of what failed survives a read, bounded like any detail.
|
||||
expect(
|
||||
readAgentSessionFailureFact({
|
||||
kind: 'providerRetrying',
|
||||
retry: { cause: ` stream disconnected${' x'.repeat(400)}` }
|
||||
})?.retry?.cause
|
||||
).toBe(`stream disconnected${' x'.repeat(400)}`.slice(0, 512).trim())
|
||||
expect(
|
||||
readAgentSessionFailureFact({ kind: 'providerRetrying', retry: { cause: ' ' } })
|
||||
).toEqual({ kind: 'providerRetrying' })
|
||||
})
|
||||
|
||||
it('drops what a newer host wrote that this build cannot place', () => {
|
||||
expect(readAgentSessionFailureFact({ kind: 'futureKind' })).toBeUndefined()
|
||||
expect(readAgentSessionFailureFact(undefined)).toBeUndefined()
|
||||
expect(
|
||||
readAgentSessionFailureFact({
|
||||
kind: 'restartFailed',
|
||||
refusal: { code: 'agent_session_conflict', details: { reason: 'futureReason' } },
|
||||
detail: { text: 'x', audience: 'future' }
|
||||
})
|
||||
).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading all of a failure fact', () => {
|
||||
|
||||
@@ -4,32 +4,14 @@ import {
|
||||
agentSessionWriteNoticeEnglish,
|
||||
agentSessionWriteNoticeParts
|
||||
} from './agent-session-refusal-notice'
|
||||
import { agentSessionFailureSentence } from './agent-session-failure-words'
|
||||
import { readAgentSessionRefusalDetails } from './agent-session-refusal-details'
|
||||
import './agent-session-failure-words'
|
||||
import './agent-session-refusal-details'
|
||||
import {
|
||||
agentSessionRefusalFailure,
|
||||
parseAgentSessionWriteFailure
|
||||
} from './agent-session-write-failure'
|
||||
|
||||
describe('agentSessionRefusalCauseParts', () => {
|
||||
it.each(['managed', 'system'] as const)(
|
||||
'retains the %s account when saving a refused message',
|
||||
(account) => {
|
||||
const refusal = {
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: { reason: 'notSignedIn', account }
|
||||
} as const
|
||||
const saved = agentSessionRefusalFailure(refusal)
|
||||
expect(saved.details).toEqual(refusal.details)
|
||||
const parts = agentSessionRefusalCauseParts(saved, { agentName: 'Claude' })
|
||||
expect(agentSessionWriteNoticeEnglish(parts)).toBe(
|
||||
agentSessionFailureSentence({ kind: 'notSignedIn', account }, 'rejection', {
|
||||
agentName: 'Claude'
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// A failed start the host refused for a running terminal says so; only a named chat is another chat.
|
||||
it('names the terminal that holds the conversation unless the refusal names a chat', () => {
|
||||
const held = agentSessionRefusalFailure({
|
||||
@@ -102,53 +84,3 @@ describe('saved Arguments refusals', () => {
|
||||
expect(agentSessionRefusalCauseParts(refused)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('an expired attachment refusal', () => {
|
||||
const notice = (details: unknown): string =>
|
||||
agentSessionWriteNoticeEnglish(
|
||||
agentSessionWriteNoticeParts(
|
||||
agentSessionRefusalFailure({
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: readAgentSessionRefusalDetails('agent_session_operation_invalid', details)
|
||||
}),
|
||||
'composer-send'
|
||||
)
|
||||
)
|
||||
const expired = (...names: string[]) => ({
|
||||
reason: 'attachmentExpired',
|
||||
expiredAttachments: names.map((name, index) => ({ uploadId: `upload-${index}`, name }))
|
||||
})
|
||||
|
||||
it('names the file', () => {
|
||||
expect(notice(expired('notes.pdf'))).toBe(
|
||||
'notes.pdf expired. Your message was not sent. Remove it and attach it again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('names several, then stops short of a long list', () => {
|
||||
expect(notice(expired('a.pdf', 'b.pdf'))).toBe(
|
||||
'a.pdf, b.pdf expired. Your message was not sent. Remove them and attach them again.'
|
||||
)
|
||||
expect(notice(expired('a.pdf', 'b.pdf', 'c.pdf', 'd.pdf'))).toBe(
|
||||
'a.pdf, b.pdf, c.pdf… expired. Your message was not sent. Remove them and attach them again.'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['orca-paste-1-abc.png', 'orca-paste-1-abc.txt'])(
|
||||
"keeps a paste's generated name out of the words: %s",
|
||||
(name) => {
|
||||
expect(notice(expired(name))).toBe(
|
||||
'This attachment expired. Your message was not sent. Remove it and attach it again.'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['an older host, which names none', { reason: 'attachmentExpired' }],
|
||||
['a list it cannot read', { reason: 'attachmentExpired', expiredAttachments: [{ name: 1 }] }]
|
||||
])('stays generic for %s', (_case, details) => {
|
||||
expect(notice(details)).toBe(
|
||||
'This attachment expired. Your message was not sent. Remove it and attach it again.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,118 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readAgentSessionRefusalDetails } from './agent-session-refusal-details'
|
||||
import {
|
||||
agentSessionRefusalReference,
|
||||
readAgentSessionRefusalReference,
|
||||
refuse,
|
||||
refuseUnclassified,
|
||||
withAgentSessionRefusalFacts,
|
||||
type AgentSessionWireRefusal
|
||||
} from './agent-session-wire-refusals'
|
||||
import type { AgentJournalResolution } from '../journal/agent-session-journal-types'
|
||||
|
||||
const RESOLUTION: AgentJournalResolution = {
|
||||
state: 'resolved',
|
||||
selectedOptionId: 'allow',
|
||||
resolvedBy: 'phone-1',
|
||||
resolvedAt: 1_789_000_000_000
|
||||
}
|
||||
|
||||
/** The refusal with its new field removed: exactly what a released client reads. */
|
||||
function asReleasedClientSeesIt(refusal: AgentSessionWireRefusal): unknown {
|
||||
const { details: _details, ...rest } = JSON.parse(JSON.stringify(refusal))
|
||||
return rest
|
||||
}
|
||||
|
||||
describe('the loose fields released clients read', () => {
|
||||
it('gain a verdict learned after the refusal, in details and at the top level', () => {
|
||||
const stamped = withAgentSessionRefusalFacts(
|
||||
refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'm'),
|
||||
{ ownerVerdict: 'exited' }
|
||||
)
|
||||
expect(stamped).toEqual({
|
||||
code: 'agent_session_operation_invalid',
|
||||
message: 'm',
|
||||
details: { reason: 'providerStartFailed', ownerVerdict: 'exited' },
|
||||
ownerVerdict: 'exited'
|
||||
})
|
||||
// A refusal an older row replayed, with no reason, still reaches released clients with it.
|
||||
expect(
|
||||
withAgentSessionRefusalFacts(refuseUnclassified('agent_session_conflict', 'm'), {
|
||||
ownerVerdict: 'unverifiable'
|
||||
})
|
||||
).toEqual({
|
||||
code: 'agent_session_conflict',
|
||||
message: 'm',
|
||||
details: { ownerVerdict: 'unverifiable' },
|
||||
ownerVerdict: 'unverifiable'
|
||||
})
|
||||
})
|
||||
|
||||
// Each expected value is the literal the host built for the same situation before details.
|
||||
it.each([
|
||||
[
|
||||
'a moved prompt',
|
||||
refuse(
|
||||
'agent_session_item_revision_stale',
|
||||
{ reason: 'promptMoved', currentRevision: 2, resolution: RESOLUTION },
|
||||
'Item i has moved on.'
|
||||
),
|
||||
{
|
||||
code: 'agent_session_item_revision_stale',
|
||||
message: 'Item i has moved on.',
|
||||
currentRevision: 2,
|
||||
resolution: RESOLUTION
|
||||
}
|
||||
],
|
||||
[
|
||||
'a resolved prompt',
|
||||
refuse(
|
||||
'agent_session_already_resolved',
|
||||
{ reason: 'promptAlreadyResolved', currentRevision: 2, resolution: RESOLUTION },
|
||||
'Item i was already resolved.'
|
||||
),
|
||||
{
|
||||
code: 'agent_session_already_resolved',
|
||||
message: 'Item i was already resolved.',
|
||||
currentRevision: 2,
|
||||
resolution: RESOLUTION
|
||||
}
|
||||
],
|
||||
[
|
||||
'a stale fence',
|
||||
refuse('agent_session_checkpoint_stale', { reason: 'fenceStale', currentFence: 4 }, 'm'),
|
||||
{ code: 'agent_session_checkpoint_stale', message: 'm', currentFence: 4 }
|
||||
],
|
||||
[
|
||||
'a refused rewind',
|
||||
refuse(
|
||||
'agent_session_operation_invalid',
|
||||
{ reason: 'rewindRefused', rewindReason: 'busy' },
|
||||
'agent_session_rewind:busy'
|
||||
),
|
||||
{
|
||||
code: 'agent_session_operation_invalid',
|
||||
message: 'agent_session_rewind:busy',
|
||||
rewindReason: 'busy'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a failed create',
|
||||
withAgentSessionRefusalFacts(
|
||||
refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'exited 1'),
|
||||
{ ownerVerdict: 'exited' }
|
||||
),
|
||||
{ code: 'agent_session_operation_invalid', message: 'exited 1', ownerVerdict: 'exited' }
|
||||
],
|
||||
[
|
||||
'a refusal with no facts',
|
||||
refuse('agent_session_conflict', { reason: 'claimConflicted' }, 'm'),
|
||||
{ code: 'agent_session_conflict', message: 'm' }
|
||||
]
|
||||
] as const)('read by a released client exactly as before for %s', (_label, refusal, before) => {
|
||||
expect(asReleasedClientSeesIt(refusal)).toEqual(before)
|
||||
})
|
||||
})
|
||||
import './agent-session-wire-refusals'
|
||||
|
||||
describe('a stored refusal read back', () => {
|
||||
it('keeps what the code lists and drops the rest', () => {
|
||||
@@ -135,24 +23,4 @@ describe('a stored refusal read back', () => {
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips through its reference, and reads an older shape as naming nothing', () => {
|
||||
const refusal = refuse(
|
||||
'agent_session_ownership_unknown',
|
||||
{ reason: 'noLiveOwner', ownerVerdict: 'live' },
|
||||
'm'
|
||||
)
|
||||
const reference = agentSessionRefusalReference(refusal)
|
||||
expect(reference).toEqual({
|
||||
code: 'agent_session_ownership_unknown',
|
||||
details: { reason: 'noLiveOwner', ownerVerdict: 'live' }
|
||||
})
|
||||
expect(readAgentSessionRefusalReference(JSON.parse(JSON.stringify(reference)))).toEqual(
|
||||
reference
|
||||
)
|
||||
expect(
|
||||
readAgentSessionRefusalReference({ code: 'agent_session_conflict', cause: 'claimConflicted' })
|
||||
).toEqual({ code: 'agent_session_conflict' })
|
||||
expect(readAgentSessionRefusalReference({ code: 'agent_session_future' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,54 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentSessionRefusalFailure,
|
||||
agentSessionThrownFailure,
|
||||
parseAgentSessionWriteFailure,
|
||||
readAgentSessionErrorRefusal
|
||||
agentSessionThrownFailure
|
||||
} from './agent-session-write-failure'
|
||||
|
||||
const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.'
|
||||
|
||||
function saved(value: unknown): unknown {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
describe('parseAgentSessionWriteFailure', () => {
|
||||
// The shape saved before refusals carried a reason; it loads as it was and reads as its code.
|
||||
it('reads back an entry saved with the code alone, unchanged', () => {
|
||||
expect(
|
||||
parseAgentSessionWriteFailure(saved({ kind: 'refused', code: 'agent_session_conflict' }))
|
||||
).toEqual({ kind: 'refused', code: 'agent_session_conflict' })
|
||||
expect(parseAgentSessionWriteFailure({ kind: 'failed' })).toEqual({ kind: 'failed' })
|
||||
expect(
|
||||
parseAgentSessionWriteFailure({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_conflict',
|
||||
message: HOST_TEXT
|
||||
})
|
||||
).toEqual({ kind: 'refused', code: 'agent_session_conflict' })
|
||||
})
|
||||
|
||||
// A newer build's reason or verdict is dropped, never guessed; the code's words stand.
|
||||
it.each([
|
||||
[{ reason: 'fromTheFuture' }, undefined],
|
||||
[{ reason: 'fromTheFuture', ownerVerdict: 'exited' }, { ownerVerdict: 'exited' }],
|
||||
[{ reason: 'ownerUnproven', ownerVerdict: 'gone' }, { reason: 'ownerUnproven' }],
|
||||
[{ reason: 'chatStarting' }, undefined]
|
||||
])('drops what this build cannot place: %j', (details, kept) => {
|
||||
expect(
|
||||
parseAgentSessionWriteFailure({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_ownership_unknown',
|
||||
details
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_ownership_unknown',
|
||||
...(kept ? { details: kept } : {})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentSessionRefusalFailure', () => {
|
||||
// What moves with the owner, the question's answer and the host's words are never kept.
|
||||
it('keeps only the facts that stay true after a reload', () => {
|
||||
@@ -80,14 +35,6 @@ describe('agentSessionRefusalFailure', () => {
|
||||
details: { reason: 'promptAlreadyResolved' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a code from a newer host without reading its details', () => {
|
||||
const refusal = JSON.parse('{"code":"agent_session_from_the_future","details":{"reason":"x"}}')
|
||||
expect(agentSessionRefusalFailure(refusal)).toEqual({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_from_the_future'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('a refusal a failed request carried in its error', () => {
|
||||
@@ -97,17 +44,6 @@ describe('a refusal a failed request carried in its error', () => {
|
||||
}
|
||||
const payload = { code: 'runtime_error', message: 'agent_session_journal_unreadable' }
|
||||
|
||||
it('is read from a thrown RPC error and from a stream payload alike', () => {
|
||||
const expected = {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalCorrupt' }
|
||||
}
|
||||
expect(
|
||||
readAgentSessionErrorRefusal({ response: { error: { ...payload, data: { refusal } } } })
|
||||
).toEqual(expected)
|
||||
expect(readAgentSessionErrorRefusal(saved({ ...payload, data: { refusal } }))).toEqual(expected)
|
||||
})
|
||||
|
||||
it("words the refusal when there is one, else what the request's error code proves", () => {
|
||||
expect(agentSessionThrownFailure({ ...payload, data: { refusal } }, 'runtime_error')).toEqual({
|
||||
kind: 'refused',
|
||||
@@ -120,17 +56,4 @@ describe('a refusal a failed request carried in its error', () => {
|
||||
code: 'structured_agent_session_unsupported'
|
||||
})
|
||||
})
|
||||
|
||||
it("degrades a reason another build added to the code's own words", () => {
|
||||
const newer = {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalFromTheFuture' }
|
||||
}
|
||||
expect(
|
||||
agentSessionThrownFailure({ ...payload, data: { refusal: newer } }, 'runtime_error')
|
||||
).toEqual({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_journal_unreadable'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,17 +56,4 @@ describe('the line under a recorded rejection', () => {
|
||||
it('says only that it was not sent when a loaded row already states the reason', () => {
|
||||
expect(line('Written by the host.', signedOut)).toBe('Your message was not sent.')
|
||||
})
|
||||
|
||||
it('words a fact no row states from the fact itself', () => {
|
||||
expect(line('Written by the host.', { kind: 'writeFailed' })).toBe(
|
||||
"Orca couldn't reach the agent. Your message was not sent."
|
||||
)
|
||||
})
|
||||
|
||||
it("shows an older host's reason sentence, and never a legacy marker", () => {
|
||||
expect(line('Claude messages support at most 20 images')).toBe(
|
||||
'Claude messages support at most 20 images'
|
||||
)
|
||||
expect(line('not_delivered')).toBe('Your message was not sent.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readAgentJournalMessageBody } from './agent-session-journal-body-admission'
|
||||
|
||||
describe("a submission's body", () => {
|
||||
it('reads a message, a newer kind as unreadable, and another known kind as damage', () => {
|
||||
expect(readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [] })).toBe(
|
||||
'readable'
|
||||
)
|
||||
expect(readAgentJournalMessageBody({ kind: 'voice-note', clip: 'x' })).toBe('unreadable')
|
||||
expect(readAgentJournalMessageBody({ kind: ' ' })).toBe('malformed')
|
||||
expect(readAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe('malformed')
|
||||
expect(
|
||||
readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [{ type: 'text' }] })
|
||||
).toBe('malformed')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalLinkageFields, isRootAgentJournalItem } from './agent-session-journal-producer'
|
||||
|
||||
describe('isRootAgentJournalItem', () => {
|
||||
it('reads an id that failed to resolve as a subagent’s, not as root', () => {
|
||||
// Presence, not truthiness: an unresolved child id must never attribute its content to the parent.
|
||||
expect(isRootAgentJournalItem({ agentId: '' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentJournalLinkageFields', () => {
|
||||
it('omits absent members rather than writing them as undefined', () => {
|
||||
// An own key set to undefined is still present; absence alone claims root.
|
||||
expect(agentJournalLinkageFields({ agentId: 'task-1' })).toEqual({ agentId: 'task-1' })
|
||||
expect(Object.keys(agentJournalLinkageFields({ agentId: 'task-1' }))).toEqual(['agentId'])
|
||||
expect(agentJournalLinkageFields(undefined)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,47 +1,10 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
|
||||
import {
|
||||
AgentJournalSubmissionSchema,
|
||||
isAdmissibleAgentJournalSubmission
|
||||
} from './agent-session-journal-submission-schema'
|
||||
|
||||
// Persisted and published: a reader that keeps the parsed value must not lose either fact.
|
||||
it('keeps a kept send’s card id and its source through a parse', () => {
|
||||
const kept: AgentJournalSubmission = {
|
||||
clientMessageId: 'client-kept',
|
||||
fence: 2,
|
||||
payloadFingerprint: 'fingerprint-kept',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: 'Orca restarted before this message was sent.',
|
||||
rejection: { kind: 'hostRestarted' },
|
||||
submittedAt: 1,
|
||||
resolvedAt: 2,
|
||||
recovered: true,
|
||||
source: { kind: 'user' },
|
||||
keptAsQueuedMessageId: 'client-kept'
|
||||
}
|
||||
expect(isAdmissibleAgentJournalSubmission(kept)).toBe(true)
|
||||
expect(AgentJournalSubmissionSchema.parse(kept)).toEqual(kept)
|
||||
expect(AgentJournalSubmissionSchema.parse(JSON.parse(JSON.stringify(kept)))).toEqual(kept)
|
||||
})
|
||||
|
||||
it('refuses an empty card id rather than reading it as kept', () => {
|
||||
expect(
|
||||
isAdmissibleAgentJournalSubmission({
|
||||
clientMessageId: 'client-kept',
|
||||
fence: 2,
|
||||
payloadFingerprint: 'fingerprint-kept',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 2,
|
||||
keptAsQueuedMessageId: ''
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('admits newer fields without replacing or mutating the submission', () => {
|
||||
const value = {
|
||||
clientMessageId: 'client-1',
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentSessionAccountHomesEqual } from './agent-session-account-home'
|
||||
import { isPersistedAgentSessionRecord } from './agent-session-record'
|
||||
import { agentSessionRecordFixture } from './agent-session-record.test-fixture'
|
||||
import { encodeAgentSessionRecord } from './agent-session-record-stored-form'
|
||||
|
||||
describe('agent session account home', () => {
|
||||
// Validate agent-specific env ownership at launch, when this binding becomes the child environment.
|
||||
it('reads any well-formed variable, and sets aside one that is not a variable name', () => {
|
||||
const record = encodeAgentSessionRecord(agentSessionRecordFixture())
|
||||
const withVariable = (variable: string) =>
|
||||
isPersistedAgentSessionRecord({ ...record, accountHome: { variable, path: '/tmp/x' } })
|
||||
expect(withVariable('CODEX_HOME')).toBe(true)
|
||||
expect(withVariable('GROK_HOME')).toBe(true)
|
||||
expect(withVariable('X'.repeat(128))).toBe(true)
|
||||
for (const malformed of ['', 'A B', '1HOME', 'HOME=x', 'X'.repeat(129)]) {
|
||||
expect(withVariable(malformed)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates tagged OpenCode locators, keeping ones a newer build added fields to', () => {
|
||||
const record = encodeAgentSessionRecord(agentSessionRecordFixture())
|
||||
const managed = {
|
||||
kind: 'opencode',
|
||||
locator: { kind: 'managed', managedProfileId: '123e4567-e89b-42d3-a456-426614174000' }
|
||||
} as const
|
||||
const unmanaged = { kind: 'opencode', locator: { kind: 'unmanaged' } } as const
|
||||
expect(isPersistedAgentSessionRecord({ ...record, accountHome: managed })).toBe(true)
|
||||
expect(isPersistedAgentSessionRecord({ ...record, accountHome: unmanaged })).toBe(true)
|
||||
expect(agentSessionAccountHomesEqual(managed, unmanaged)).toBe(false)
|
||||
expect(agentSessionAccountHomesEqual(managed, { ...managed })).toBe(true)
|
||||
expect(
|
||||
agentSessionAccountHomesEqual(managed, {
|
||||
...managed,
|
||||
locator: { ...managed.locator, managedProfileId: '223e4567-e89b-42d3-a456-426614174000' }
|
||||
})
|
||||
).toBe(false)
|
||||
for (const managedProfileId of ['', 'not-a-profile-id']) {
|
||||
expect(
|
||||
isPersistedAgentSessionRecord({
|
||||
...record,
|
||||
accountHome: { ...managed, locator: { ...managed.locator, managedProfileId } }
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
expect(agentSessionAccountHomesEqual(unmanaged, { ...unmanaged })).toBe(true)
|
||||
expect(
|
||||
isPersistedAgentSessionRecord({
|
||||
...record,
|
||||
accountHome: { ...managed, path: '/fake/home' }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isPersistedAgentSessionRecord({
|
||||
...record,
|
||||
accountHome: { ...unmanaged, locator: { ...unmanaged.locator, channel: 'beta' } }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isPersistedAgentSessionRecord({
|
||||
...record,
|
||||
accountHome: { ...unmanaged, locator: { kind: 'detached' } }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
adjudicateAgentSessionRestart,
|
||||
agentSessionLeaseAdmitsWriter,
|
||||
evaluateAgentSessionAcquisition,
|
||||
isProvenAliveProbe,
|
||||
isProvenDeadProbe,
|
||||
type AgentSessionOwnerProbe
|
||||
} from './agent-session-lease-adjudication'
|
||||
import { normalizeLegacyHandoffLease } from './agent-session-legacy-handoff-lease'
|
||||
@@ -53,70 +50,7 @@ function acquire(
|
||||
})
|
||||
}
|
||||
|
||||
describe('proof classification', () => {
|
||||
it('treats a pid match with nothing PID-reuse-safe as no proof at all', () => {
|
||||
// A bare pid match is exactly the case that mints a second writer after pid reuse.
|
||||
expect(isProvenAliveProbe({ outcome: 'identity-matched', matchedOn: [] })).toBe(false)
|
||||
expect(isProvenAliveProbe(MATCHED)).toBe(true)
|
||||
expect(isProvenDeadProbe(INDETERMINATE)).toBe(false)
|
||||
expect(isProvenAliveProbe(INDETERMINATE)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['exit-observed', { outcome: 'exit-observed' } as AgentSessionOwnerProbe],
|
||||
['pid-absent', { outcome: 'pid-absent' } as AgentSessionOwnerProbe],
|
||||
[
|
||||
'identity-mismatch',
|
||||
{ outcome: 'identity-mismatch', field: 'spawn-token' } as AgentSessionOwnerProbe
|
||||
]
|
||||
])('accepts %s as proof of death', (_name, probe) => {
|
||||
expect(isProvenDeadProbe(probe)).toBe(true)
|
||||
})
|
||||
|
||||
it('never counts a reservation probe or an indeterminate answer as death', () => {
|
||||
expect(isProvenDeadProbe({ outcome: 'reservation-unused' })).toBe(false)
|
||||
expect(isProvenDeadProbe(INDETERMINATE)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('acquisition compare-and-swap', () => {
|
||||
it('refuses a stale fence and grants at exactly fence + 1', () => {
|
||||
const held = lease({ ownerProcess: null, claimStatus: 'released', reservedSpawnToken: null })
|
||||
expect(
|
||||
evaluateAgentSessionAcquisition({
|
||||
lease: held,
|
||||
expectedFence: held.runtimeFence - 1,
|
||||
handoffOperationId: null,
|
||||
probe: MATCHED
|
||||
})
|
||||
).toEqual({
|
||||
decision: 'refused',
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
details: { reason: 'fenceStale' }
|
||||
})
|
||||
expect(acquire(held, MATCHED)).toEqual({ decision: 'granted', nextFence: 8 })
|
||||
})
|
||||
|
||||
it('refuses a cached fence after the lease advances to a reservation', () => {
|
||||
const before = lease({ ownerProcess: null, claimStatus: 'released', reservedSpawnToken: null })
|
||||
const winner = acquire(before, MATCHED)
|
||||
expect(winner).toEqual({ decision: 'granted', nextFence: 8 })
|
||||
// The loser still holds the pre-swap fence, which is no longer current.
|
||||
const after = lease({ ...before, runtimeFence: 8, claimStatus: 'reserved' })
|
||||
expect(
|
||||
evaluateAgentSessionAcquisition({
|
||||
lease: after,
|
||||
expectedFence: 7,
|
||||
handoffOperationId: null,
|
||||
probe: MATCHED
|
||||
})
|
||||
).toEqual({
|
||||
decision: 'refused',
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
details: { reason: 'fenceStale' }
|
||||
})
|
||||
})
|
||||
|
||||
it('never grants a second owner on expiry alone', () => {
|
||||
// The recorded owner is long past its deadline; nothing here may consult that deadline.
|
||||
const expired = lease({ leaseDeadlineAt: 1, lastRenewedAt: 1 })
|
||||
@@ -144,23 +78,6 @@ describe('acquisition compare-and-swap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a conflicted claim conflicted regardless of proof', () => {
|
||||
// Restart adjudication and recovery resolution are what retire it, once its owner is gone.
|
||||
expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({
|
||||
decision: 'refused',
|
||||
code: 'agent_session_conflict',
|
||||
details: { reason: 'claimConflicted' }
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses acquisition in the recovering stage', () => {
|
||||
expect(acquire(lease({ handoffStage: 'recovering' }), { outcome: 'pid-absent' })).toEqual({
|
||||
decision: 'refused',
|
||||
code: 'agent_session_ownership_unknown',
|
||||
details: { reason: 'ownerUnproven' }
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a different acquisition operation and replays the matching one', () => {
|
||||
const mid = lease({
|
||||
handoffStage: 'new-owner-proving',
|
||||
@@ -206,13 +123,6 @@ describe('restart reconciliation', () => {
|
||||
).toMatchObject({ disposition: 'recovering', stage: 'recovering' })
|
||||
})
|
||||
|
||||
it('routes a surviving native owner to recovery instead of readopting a dead transport', () => {
|
||||
// The runtime crash lost native stdio; stop the orphan and respawn at fence + 1 instead of readopting an undrivable child.
|
||||
expect(
|
||||
adjudicateAgentSessionRestart({ lease: lease(), probe: MATCHED, observedAt: 9_000 })
|
||||
).toMatchObject({ disposition: 'recovering', stage: 'recovering' })
|
||||
})
|
||||
|
||||
it('bumps the fence exactly once for a proven-dead owner and records the evidence', () => {
|
||||
const result = adjudicateAgentSessionRestart({
|
||||
lease: lease(),
|
||||
@@ -244,16 +154,6 @@ describe('restart reconciliation', () => {
|
||||
).toMatchObject({ evidence: { observedAt: 9_000, lastProvenAliveAt: 9_000 } })
|
||||
})
|
||||
|
||||
it('hands an unverifiable owner to recovery resolution instead of evicting it', () => {
|
||||
expect(
|
||||
adjudicateAgentSessionRestart({
|
||||
lease: lease({ leaseDeadlineAt: 1 }),
|
||||
probe: INDETERMINATE,
|
||||
observedAt: 9_000
|
||||
})
|
||||
).toEqual({ disposition: 'recovering', stage: 'recovering', reason: 'no answer' })
|
||||
})
|
||||
|
||||
it('re-adjudicates a claim an older record marked conflicted by the owner it names', () => {
|
||||
expect(
|
||||
adjudicateAgentSessionRestart({
|
||||
@@ -271,27 +171,6 @@ describe('restart reconciliation', () => {
|
||||
).toEqual({ disposition: 'evicted', nextFence: 8, evidence: null })
|
||||
})
|
||||
|
||||
it('frees a conflict whose named owner is proven gone', () => {
|
||||
// Once this specific process is proven gone, retain no conflict that would permanently prevent reopening.
|
||||
expect(
|
||||
adjudicateAgentSessionRestart({
|
||||
lease: lease({ claimStatus: 'conflicted' }),
|
||||
probe: { outcome: 'pid-absent' },
|
||||
observedAt: 9_000
|
||||
})
|
||||
).toEqual({
|
||||
disposition: 'evicted',
|
||||
nextFence: 8,
|
||||
evidence: {
|
||||
kind: 'pid-absent',
|
||||
detail: 'recorded pid absent on host',
|
||||
observedAt: 9_000,
|
||||
ownerFence: 7,
|
||||
lastProvenAliveAt: 500
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('frees a lease that names neither an owner nor a reservation, without moving the fence', () => {
|
||||
// Evicted leases have neither owner nor token to probe; treating them as unproven reservations re-latches every released record.
|
||||
expect(
|
||||
@@ -336,13 +215,3 @@ describe('restart reconciliation', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('writer admission', () => {
|
||||
it('admits a writer only when reconciled, settled, live, and holding a process', () => {
|
||||
expect(agentSessionLeaseAdmitsWriter(lease())).toBe(true)
|
||||
expect(agentSessionLeaseAdmitsWriter(lease({ unreconciled: true }))).toBe(false)
|
||||
expect(agentSessionLeaseAdmitsWriter(lease({ handoffStage: 'new-owner-proving' }))).toBe(false)
|
||||
expect(agentSessionLeaseAdmitsWriter(lease({ claimStatus: 'reserved' }))).toBe(false)
|
||||
expect(agentSessionLeaseAdmitsWriter(lease({ ownerProcess: null }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
leaseCarriesLegacyHandoffValues,
|
||||
normalizeLegacyHandoffLease,
|
||||
type PersistedAgentSessionLease
|
||||
} from './agent-session-legacy-handoff-lease'
|
||||
import type { AgentSessionClaimStatus } from './agent-session-record'
|
||||
import { agentSessionLeaseFixture } from './agent-session-record.test-fixture'
|
||||
|
||||
const CLAIMS: AgentSessionClaimStatus[] = ['reserved', 'live', 'conflicted', 'released']
|
||||
|
||||
function persisted(overrides: Partial<PersistedAgentSessionLease>): PersistedAgentSessionLease {
|
||||
return { ...agentSessionLeaseFixture(), ...overrides }
|
||||
}
|
||||
|
||||
describe('normalizing a lease the removed terminal handoff wrote', () => {
|
||||
it.each(['preparing', 'old-owner-stopped', 'manual-recovery'] as const)(
|
||||
'maps the %s stage to recovering and keeps its operation id',
|
||||
(handoffStage) => {
|
||||
const lease = persisted({ handoffStage, handoffOperationId: 'op-handoff' })
|
||||
expect(normalizeLegacyHandoffLease(lease)).toEqual({
|
||||
...lease,
|
||||
handoffStage: 'recovering'
|
||||
})
|
||||
expect(leaseCarriesLegacyHandoffValues(lease)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(CLAIMS)(
|
||||
'turns a recorded terminal owner (%s) into a conflicted native claim',
|
||||
(claim) => {
|
||||
const lease = persisted({ runtimeKind: 'tui', claimStatus: claim })
|
||||
expect(normalizeLegacyHandoffLease(lease)).toEqual({
|
||||
...lease,
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'conflicted'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(CLAIMS)('changes only the kind of a terminal lease naming no process (%s)', (claim) => {
|
||||
const lease = persisted({ runtimeKind: 'tui', claimStatus: claim, ownerProcess: null })
|
||||
expect(normalizeLegacyHandoffLease(lease)).toEqual({ ...lease, runtimeKind: 'native' })
|
||||
expect(leaseCarriesLegacyHandoffValues(lease)).toBe(true)
|
||||
})
|
||||
|
||||
it('maps a terminal owner mid return trip on both fields', () => {
|
||||
const lease = persisted({ runtimeKind: 'tui', handoffStage: 'old-owner-stopped' })
|
||||
expect(normalizeLegacyHandoffLease(lease)).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
handoffStage: 'recovering',
|
||||
claimStatus: 'conflicted'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -55,52 +55,11 @@ describe('computeAgentSessionPayloadFingerprint', () => {
|
||||
expect(a).toBe(b)
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('separates one payload from another and one method from another', () => {
|
||||
const send = computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: 'session-1',
|
||||
fields: { text: 'hi' }
|
||||
})
|
||||
expect(
|
||||
computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: 'session-1',
|
||||
fields: { text: 'hi there' }
|
||||
})
|
||||
).not.toBe(send)
|
||||
expect(
|
||||
computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.cancel',
|
||||
sessionId: 'session-1',
|
||||
fields: { text: 'hi' }
|
||||
})
|
||||
).not.toBe(send)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admitAgentSessionMutation', () => {
|
||||
const base = { envelope: envelope(), hostFingerprint: 'f'.repeat(64), lease: LEASE }
|
||||
|
||||
it('admits a matching first-time operation and refuses a mismatched payload', () => {
|
||||
expect(admitAgentSessionMutation({ ...base, ledger: ADMIT('f'.repeat(64)) }).decision).toBe(
|
||||
'admit'
|
||||
)
|
||||
expect(
|
||||
admitAgentSessionMutation({
|
||||
...base,
|
||||
hostFingerprint: 'a'.repeat(64),
|
||||
ledger: ADMIT('a'.repeat(64))
|
||||
})
|
||||
).toMatchObject({
|
||||
decision: 'refused',
|
||||
refusal: {
|
||||
code: 'agent_session_operation_conflict',
|
||||
details: { reason: 'fingerprintMismatch' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('replays a recorded outcome even while the lease is unreconciled', () => {
|
||||
const recorded = {
|
||||
...row('f'.repeat(64)),
|
||||
@@ -125,62 +84,4 @@ describe('admitAgentSessionMutation', () => {
|
||||
expect(admission, String(expectedRuntimeFence)).toMatchObject({ decision: 'admit' })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a writer while the lease is unreconciled', () => {
|
||||
const admission = admitAgentSessionMutation({
|
||||
...base,
|
||||
lease: { ...LEASE, unreconciled: true },
|
||||
ledger: ADMIT('f'.repeat(64))
|
||||
})
|
||||
expect(admission).toMatchObject({
|
||||
decision: 'refused',
|
||||
refusal: { code: 'execution_owner_reconciling' }
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a writer while the chat is still starting', () => {
|
||||
const admission = admitAgentSessionMutation({
|
||||
...base,
|
||||
lease: { ...LEASE, handoffStage: 'new-owner-proving' },
|
||||
ledger: ADMIT('f'.repeat(64))
|
||||
})
|
||||
expect(admission).toMatchObject({
|
||||
decision: 'refused',
|
||||
refusal: { code: 'agent_session_conflict', message: 'The chat is still starting.' }
|
||||
})
|
||||
})
|
||||
|
||||
it('names recovery rather than a handoff when a restart left the owner unproven', () => {
|
||||
const admission = admitAgentSessionMutation({
|
||||
...base,
|
||||
lease: { ...LEASE, handoffStage: 'recovering' },
|
||||
ledger: ADMIT('f'.repeat(64))
|
||||
})
|
||||
expect(admission).toMatchObject({
|
||||
decision: 'refused',
|
||||
refusal: {
|
||||
code: 'agent_session_conflict',
|
||||
message:
|
||||
"Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again."
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a ledger refusal verbatim', () => {
|
||||
const admission = admitAgentSessionMutation({
|
||||
...base,
|
||||
ledger: {
|
||||
decision: 'refused',
|
||||
code: 'agent_session_operation_expired',
|
||||
details: { reason: 'operationExpired' }
|
||||
}
|
||||
})
|
||||
expect(admission).toMatchObject({
|
||||
decision: 'refused',
|
||||
refusal: {
|
||||
code: 'agent_session_operation_expired',
|
||||
details: { reason: 'operationExpired' }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,13 +49,6 @@ function admit(
|
||||
}
|
||||
|
||||
describe('operation admission', () => {
|
||||
it('admits a fresh id once and replays the identical retry', () => {
|
||||
const rows = new Map<string, AgentSessionOperationRow>()
|
||||
const row = admit(rows)
|
||||
const replay = evaluate(rows)
|
||||
expect(replay).toEqual({ decision: 'replay', row })
|
||||
})
|
||||
|
||||
it('refuses the same id carrying different parameters', () => {
|
||||
const rows = new Map<string, AgentSessionOperationRow>()
|
||||
admit(rows)
|
||||
@@ -150,11 +143,4 @@ describe('retention', () => {
|
||||
expect(pruneAgentSessionOperationRows(rows, lastAdmissibleAt).size).toBe(1)
|
||||
expect(evaluate(pruneAgentSessionOperationRows(rows, lastAdmissibleAt)).decision).toBe('replay')
|
||||
})
|
||||
|
||||
it('drops only rows past their own expiry', () => {
|
||||
const rows = new Map<string, AgentSessionOperationRow>()
|
||||
const row = admit(rows)
|
||||
expect(pruneAgentSessionOperationRows(rows, row.expiresAt).size).toBe(0)
|
||||
expect(pruneAgentSessionOperationRows(rows, row.expiresAt - 1).size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from './agent-session-provider-handle'
|
||||
import {
|
||||
agentSessionProviderHandleKey,
|
||||
agentSessionProviderHandleRoot,
|
||||
isAgentSessionProviderHandle,
|
||||
claudeProviderHandle,
|
||||
codexProviderHandle
|
||||
} from './agent-session-provider-handle-encoding'
|
||||
@@ -30,28 +28,6 @@ function link(overrides: Partial<AgentSessionProviderHandleLink> = {}) {
|
||||
}
|
||||
|
||||
describe('handle identity', () => {
|
||||
it('keys a Claude handle by session id AND leaf, so two branches are two handles', () => {
|
||||
// Concurrent resumes branch one transcript silently; the session id alone cannot name a writer.
|
||||
const branchA = agentSessionProviderHandleKey(CLAUDE)
|
||||
const branchB = agentSessionProviderHandleKey(claudeProviderHandle('sess-1', 'leaf-2'))
|
||||
expect(branchA).not.toEqual(branchB)
|
||||
expect(agentSessionProviderHandleRoot(CLAUDE)).toEqual(
|
||||
agentSessionProviderHandleRoot(claudeProviderHandle('sess-1', 'leaf-2'))
|
||||
)
|
||||
})
|
||||
|
||||
it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => {
|
||||
expect(isAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toBe(true)
|
||||
expect(isAgentSessionProviderHandle({ ...CLAUDE, resumeCursor: '' })).toBe(false)
|
||||
expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: '' })).toBe(false)
|
||||
expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: ' sess-1 ' })).toBe(false)
|
||||
// A stored typed shape is not an in-memory handle: it must be decoded first.
|
||||
expect(
|
||||
isAgentSessionProviderHandle({ provider: 'claude', sessionId: 'x', leafUuid: null })
|
||||
).toBe(false)
|
||||
expect(isAgentSessionProviderHandle({ provider: 'gemini', sessionId: 'x' })).toBe(false)
|
||||
})
|
||||
|
||||
it('uses collision-free keys when Claude ids contain delimiters', () => {
|
||||
const left = claudeProviderHandle('a#b', 'c')
|
||||
const right = claudeProviderHandle('a', 'b#c')
|
||||
@@ -61,30 +37,6 @@ describe('handle identity', () => {
|
||||
})
|
||||
|
||||
describe('chain append', () => {
|
||||
it('starts only from a created or adopted link', () => {
|
||||
expect(appendAgentSessionProviderHandleLink([], link())).toEqual([link()])
|
||||
expect(appendAgentSessionProviderHandleLink([], link({ origin: 'adopted' }))).toHaveLength(1)
|
||||
expect(() => appendAgentSessionProviderHandleLink([], link({ origin: 'resumed' }))).toThrow(
|
||||
'agent_session_provider_handle_invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to record a fork as a resume', () => {
|
||||
// --fork-session copies item IDs without proving continuity; never record it as a resume.
|
||||
const chain = [link()]
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
chain,
|
||||
link({
|
||||
linkId: 'link-2',
|
||||
origin: 'resumed',
|
||||
handle: claudeProviderHandle('sess-2', 'leaf-9'),
|
||||
mintedAtFence: 2
|
||||
})
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_forked')
|
||||
})
|
||||
|
||||
it('records a fork only with a new root and the seed it came from', () => {
|
||||
const chain = [link()]
|
||||
const forked = link({
|
||||
@@ -107,35 +59,6 @@ describe('chain append', () => {
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
})
|
||||
|
||||
it('rejects a link minted under an older fence', () => {
|
||||
const chain = [link({ mintedAtFence: 5 })]
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
chain,
|
||||
link({
|
||||
linkId: 'link-2',
|
||||
origin: 'resumed',
|
||||
handle: claudeProviderHandle('sess-1', 'leaf-2'),
|
||||
mintedAtFence: 4
|
||||
})
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_stale_fence')
|
||||
})
|
||||
|
||||
it('rejects a provider change mid-chain', () => {
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[link()],
|
||||
link({
|
||||
linkId: 'link-2',
|
||||
origin: 'resumed',
|
||||
handle: codexProviderHandle('thread-1'),
|
||||
mintedAtFence: 2
|
||||
})
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_provider_mismatch')
|
||||
})
|
||||
|
||||
it('treats re-proving the same handle at the same fence as a retry, not a new link', () => {
|
||||
const chain = [link({ mintedAtFence: 3 })]
|
||||
const retried = appendAgentSessionProviderHandleLink(
|
||||
@@ -153,19 +76,6 @@ describe('chain append', () => {
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects reuse of a stable link id for a different proof', () => {
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[link()],
|
||||
link({
|
||||
origin: 'resumed',
|
||||
handle: claudeProviderHandle('sess-1', 'leaf-2'),
|
||||
mintedAtFence: 2
|
||||
})
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
})
|
||||
|
||||
it('refuses to grow past the cap rather than dropping fork provenance', () => {
|
||||
const chain: AgentSessionProviderHandleLink[] = [link()]
|
||||
for (let index = 1; index < MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS; index += 1) {
|
||||
@@ -195,38 +105,9 @@ describe('chain append', () => {
|
||||
`link-${MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS}`
|
||||
)
|
||||
})
|
||||
|
||||
it('never mutates the chain it was given', () => {
|
||||
const chain = [link()]
|
||||
appendAgentSessionProviderHandleLink(
|
||||
chain,
|
||||
link({
|
||||
linkId: 'link-2',
|
||||
origin: 'resumed',
|
||||
handle: claudeProviderHandle('sess-1', 'leaf-2'),
|
||||
mintedAtFence: 2
|
||||
})
|
||||
)
|
||||
expect(chain).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chain lookup and validation', () => {
|
||||
it('rejects a persisted chain that is over the cap or holds a malformed link', () => {
|
||||
expect(isAgentSessionProviderHandleChain([{ ...link(), mintedAtFence: -1 }])).toBe(false)
|
||||
expect(isAgentSessionProviderHandleChain([{ ...link(), linkId: 'not a link id!' }])).toBe(false)
|
||||
expect(isAgentSessionProviderHandleChain([{ ...link(), forkedFromKey: 'claude:seed' }])).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isAgentSessionProviderHandleChain(
|
||||
Array.from({ length: MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS + 1 }, (_value, index) =>
|
||||
link({ linkId: `link-${index}` })
|
||||
)
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects persisted chains that bypass append invariants', () => {
|
||||
expect(
|
||||
isAgentSessionProviderHandleChain([
|
||||
@@ -285,67 +166,6 @@ describe('adopted chain heads', () => {
|
||||
expect(chain.map((entry) => entry.origin)).toEqual(['adopted', 'resumed'])
|
||||
expect(agentSessionProviderHandleChainHead(chain)).toBe(resumed)
|
||||
})
|
||||
|
||||
it('elides a Claude re-proof of the identical adopted handle at the same fence', () => {
|
||||
const chain = [adopted()]
|
||||
const elided = appendAgentSessionProviderHandleLink(
|
||||
chain,
|
||||
link({
|
||||
linkId: 'claude-1-sess-1-empty-retry',
|
||||
origin: 'resumed',
|
||||
handle: claudeProviderHandle('sess-1', null),
|
||||
mintedAtFence: 1
|
||||
})
|
||||
)
|
||||
|
||||
expect(elided).toEqual(chain)
|
||||
})
|
||||
|
||||
it('appends a Codex resume only once the fence has moved', () => {
|
||||
const codexAdopted = link({
|
||||
linkId: 'codex-1-thread-1',
|
||||
origin: 'adopted',
|
||||
handle: codexProviderHandle('thread-1')
|
||||
})
|
||||
const reproved = link({
|
||||
linkId: 'codex-1-thread-1-retry',
|
||||
origin: 'resumed',
|
||||
handle: codexProviderHandle('thread-1'),
|
||||
mintedAtFence: 1
|
||||
})
|
||||
|
||||
// Codex's thread id is the whole key, so a same-fence re-proof can only ever be a retry.
|
||||
expect(appendAgentSessionProviderHandleLink([codexAdopted], reproved)).toEqual([codexAdopted])
|
||||
expect(
|
||||
appendAgentSessionProviderHandleLink([codexAdopted], { ...reproved, mintedAtFence: 2 })
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses a second origin link on top of an adopted head', () => {
|
||||
// Nothing re-origins a chain: a create landing here would erase where the conversation came from.
|
||||
for (const origin of ['created', 'adopted'] as const) {
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[adopted()],
|
||||
link({ linkId: 'claude-2-sess-1-leaf-1', origin, handle: CLAUDE, mintedAtFence: 2 })
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a resume that landed on another conversation entirely', () => {
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[adopted()],
|
||||
link({
|
||||
linkId: 'claude-1-sess-9-leaf-9',
|
||||
origin: 'resumed',
|
||||
handle: claudeProviderHandle('sess-9', 'leaf-9'),
|
||||
mintedAtFence: 1
|
||||
})
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_forked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('superseding a creation the provider never saved', () => {
|
||||
@@ -409,19 +229,6 @@ describe('superseding a creation the provider never saved', () => {
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_stale_fence')
|
||||
})
|
||||
|
||||
it('carries supersession only on a creation, and never as a persisted second link', () => {
|
||||
expect(
|
||||
appendAgentSessionProviderHandleLink([], replacement({ origin: 'created' }))
|
||||
).toHaveLength(1)
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[created],
|
||||
replacement({ origin: 'resumed', handle: unsaved })
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
expect(isAgentSessionProviderHandleChain([created, replacement()])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a transport shared code has never heard of', () => {
|
||||
@@ -544,39 +351,6 @@ describe('replacing a conversation the provider could not restore', () => {
|
||||
).toThrow('agent_session_provider_handle_stale_fence')
|
||||
})
|
||||
|
||||
it('is only ever a creation, and never opens a chain', () => {
|
||||
expect(() => appendAgentSessionProviderHandleLink([], fresh())).toThrow(
|
||||
'agent_session_provider_handle_invalid'
|
||||
)
|
||||
for (const origin of ['resumed', 'adopted', 'forked'] as const) {
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[created, resumed],
|
||||
fresh({ origin, forkedFromKey: lost.key })
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a reason a later build records, but no malformed one', () => {
|
||||
const later = fresh({ replaces: { ...lost, reason: 'transport-retired' } })
|
||||
expect(appendAgentSessionProviderHandleLink([created, resumed], later)).toHaveLength(3)
|
||||
for (const replaces of [
|
||||
{ ...lost, reason: '' },
|
||||
{ ...lost, reason: 'x'.repeat(65) },
|
||||
{ ...lost, replacedAt: -1 },
|
||||
{ key: lost.key, reason: lost.reason }
|
||||
]) {
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[created, resumed],
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately malformed stored data the guard must refuse.
|
||||
fresh({ replaces: replaces as AgentSessionProviderHandleLink['replaces'] })
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('stays lost when a fresh conversation the agent never saved is superseded in turn', () => {
|
||||
const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh())
|
||||
const superseding = link({
|
||||
@@ -610,15 +384,6 @@ describe('replacing a conversation the provider could not restore', () => {
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
})
|
||||
|
||||
it('refuses a supersession key that names no creation in the chain', () => {
|
||||
expect(() =>
|
||||
appendAgentSessionProviderHandleLink(
|
||||
[created, resumed],
|
||||
fresh({ supersedesKey: agentSessionProviderHandleKey(acp('s-9')) })
|
||||
)
|
||||
).toThrow('agent_session_provider_handle_invalid')
|
||||
})
|
||||
|
||||
it('is refused in a Claude or Codex chain, whose rows older builds read', () => {
|
||||
for (const [first, lost, next] of [
|
||||
[CLAUDE, CLAUDE, claudeProviderHandle('sess-2', null)],
|
||||
|
||||
@@ -2,15 +2,12 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import {
|
||||
decodePersistedAgentSessionProviderHandleChain,
|
||||
encodePersistedAgentSessionProviderHandleChain,
|
||||
isAgentSessionHandleProvider,
|
||||
type AgentSessionProviderHandle,
|
||||
type AgentSessionProviderHandleLink
|
||||
} from './agent-session-provider-handle'
|
||||
import {
|
||||
agentSessionJournalProviderHandle,
|
||||
agentSessionProviderHandleKey,
|
||||
agentSessionProviderHandleRoot,
|
||||
agentSessionWireProviderHandle,
|
||||
claudeProviderHandle,
|
||||
codexProviderHandle,
|
||||
decodePersistedAgentSessionProviderHandle,
|
||||
@@ -31,24 +28,6 @@ import {
|
||||
agentSessionRecordFixture
|
||||
} from './agent-session-record.test-fixture'
|
||||
|
||||
/** The pre-neutral-handle guard applied to every stored handle. */
|
||||
function olderBuildReadsHandle(value: unknown): boolean {
|
||||
const isHandleField = (field: unknown): boolean =>
|
||||
typeof field === 'string' && field.length > 0 && field.length <= 512 && field === field.trim()
|
||||
if (typeof value !== 'object' || value === null || !('provider' in value)) {
|
||||
return false
|
||||
}
|
||||
if (value.provider === 'claude') {
|
||||
return (
|
||||
'sessionId' in value &&
|
||||
isHandleField(value.sessionId) &&
|
||||
'leafUuid' in value &&
|
||||
(value.leafUuid === null || isHandleField(value.leafUuid))
|
||||
)
|
||||
}
|
||||
return value.provider === 'codex' && 'threadId' in value && isHandleField(value.threadId)
|
||||
}
|
||||
|
||||
/** A Claude record row exactly as builds before this change wrote it: a fork with its seed key. */
|
||||
const STORED_CLAUDE_ROW = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
@@ -102,18 +81,6 @@ const STORED_CODEX_ROW = JSON.stringify({
|
||||
})
|
||||
|
||||
describe('a record row older builds wrote', () => {
|
||||
it.each([undefined, 0, 6, 7, 9, Number.MAX_SAFE_INTEGER])(
|
||||
'keeps a valid legacy next-fence floor %s readable',
|
||||
(minimumNextFence) => {
|
||||
const record = agentSessionRecordFixture(agentSessionLeaseFixture({ minimumNextFence }))
|
||||
const stored = JSON.parse(JSON.stringify(encodeAgentSessionRecord(record)))
|
||||
expect(isPersistedAgentSessionRecord(stored)).toBe(true)
|
||||
expect(decodePersistedAgentSessionRecord(stored).record.lease.minimumNextFence).toBe(
|
||||
minimumNextFence
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['Claude', STORED_CLAUDE_ROW],
|
||||
['Codex', STORED_CODEX_ROW]
|
||||
@@ -129,30 +96,6 @@ describe('a record row older builds wrote', () => {
|
||||
expect(JSON.stringify(encodeAgentSessionRecord(record))).toBe(row)
|
||||
})
|
||||
|
||||
it.each([undefined, 25, 0, -1, '25', null, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
'normalizes a saved sleep value %s without hiding the conversation',
|
||||
(sleptAt) => {
|
||||
const stored = { ...encodeAgentSessionRecord(agentSessionRecordFixture()), sleptAt }
|
||||
expect(isPersistedAgentSessionRecord(stored)).toBe(true)
|
||||
const decoded = decodePersistedAgentSessionRecord(stored)
|
||||
expectTypeOf(decoded.record).toMatchTypeOf<AgentSessionRecord>()
|
||||
expect(decoded.record.sleptAt).toBe(sleptAt === 25 ? 25 : undefined)
|
||||
expect(decoded.normalized).toBe(sleptAt !== undefined && sleptAt !== 25)
|
||||
}
|
||||
)
|
||||
|
||||
it('hands shared code only the neutral handle', () => {
|
||||
const stored: unknown = JSON.parse(STORED_CLAUDE_ROW)
|
||||
if (!isPersistedAgentSessionRecord(stored)) {
|
||||
throw new Error('fixture row must be readable')
|
||||
}
|
||||
const { record } = decodePersistedAgentSessionRecord(stored)
|
||||
expect(record.providerHandleChain.map((link) => link.handle)).toEqual([
|
||||
{ transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1', resumeCursor: 'leaf-1' },
|
||||
{ transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-2' }
|
||||
])
|
||||
})
|
||||
|
||||
it('still reports a lease only the removed terminal handoff wrote', () => {
|
||||
const record = agentSessionRecordFixture()
|
||||
const stored = encodeAgentSessionRecord(record)
|
||||
@@ -188,28 +131,6 @@ describe('a record row older builds wrote', () => {
|
||||
})
|
||||
|
||||
describe('what this build writes', () => {
|
||||
it('stores Claude and Codex handles in the typed form older builds read', () => {
|
||||
const claude = claudeProviderHandle('sess-1', 'leaf-1')
|
||||
const codex = codexProviderHandle('thread-1')
|
||||
expect(encodePersistedAgentSessionProviderHandle(claude)).toEqual({
|
||||
provider: 'claude',
|
||||
sessionId: 'sess-1',
|
||||
leafUuid: 'leaf-1'
|
||||
})
|
||||
expect(encodePersistedAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toEqual(
|
||||
{ provider: 'claude', sessionId: 'sess-1', leafUuid: null }
|
||||
)
|
||||
expect(encodePersistedAgentSessionProviderHandle(codex)).toEqual({
|
||||
provider: 'codex',
|
||||
threadId: 'thread-1'
|
||||
})
|
||||
for (const handle of [claude, codex]) {
|
||||
const stored = encodePersistedAgentSessionProviderHandle(handle)
|
||||
expect(olderBuildReadsHandle(stored)).toBe(true)
|
||||
expect(decodePersistedAgentSessionProviderHandle(stored)).toEqual(handle)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the persisted key and root strings older builds derived', () => {
|
||||
// Fork seeds, superseded creations and resume offers store these; a new spelling strands them.
|
||||
const claude = claudeProviderHandle('sess-1', 'leaf-1')
|
||||
@@ -222,27 +143,6 @@ describe('what this build writes', () => {
|
||||
expect(agentSessionProviderHandleRoot(codexProviderHandle('t'))).toBe('codex:"t"')
|
||||
})
|
||||
|
||||
it('round-trips another transport in the neutral form, which an older build refuses', () => {
|
||||
const grok = {
|
||||
transport: 'acp',
|
||||
agent: 'grok',
|
||||
nativeId: 'acp-session-1',
|
||||
resumeCursor: '{"cwd":"/repo"}'
|
||||
}
|
||||
const stored = encodePersistedAgentSessionProviderHandle(grok)
|
||||
expect(stored).toEqual(grok)
|
||||
expect(decodePersistedAgentSessionProviderHandle(JSON.parse(JSON.stringify(stored)))).toEqual(
|
||||
grok
|
||||
)
|
||||
// Unknown providers must never impersonate Codex on an older build.
|
||||
expect(olderBuildReadsHandle(stored)).toBe(false)
|
||||
expect(isAgentSessionHandleProvider(grok.agent)).toBe(false)
|
||||
expect(agentSessionWireProviderHandle(grok)).toBeNull()
|
||||
// Root, not the resume cursor: the cursor is resume state, not identity.
|
||||
expect(agentSessionProviderHandleRoot(grok)).toBe('acp/grok:"acp-session-1"')
|
||||
expect(agentSessionProviderHandleKey(grok)).toBe(agentSessionProviderHandleRoot(grok))
|
||||
})
|
||||
|
||||
it('refuses a stored handle that is not the one form its transport writes', () => {
|
||||
for (const value of [
|
||||
// Claude or Codex in the neutral form: no build writes it.
|
||||
@@ -318,34 +218,6 @@ describe('what this build writes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the wire and journal forms', () => {
|
||||
it('records the journal rows exactly as before, and another transport as opaque', () => {
|
||||
expect(
|
||||
agentSessionJournalProviderHandle({
|
||||
agent: 'claude',
|
||||
providerHandle: claudeProviderHandle('sess-1', 'leaf-1')
|
||||
})
|
||||
).toEqual({ kind: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' })
|
||||
expect(
|
||||
agentSessionJournalProviderHandle({
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('t')
|
||||
})
|
||||
).toEqual({ kind: 'codex', threadId: 't' })
|
||||
expect(agentSessionJournalProviderHandle({ agent: 'codex', providerHandle: null })).toEqual({
|
||||
kind: 'opaque',
|
||||
agent: 'codex',
|
||||
value: 'pending'
|
||||
})
|
||||
expect(
|
||||
agentSessionJournalProviderHandle({
|
||||
agent: 'grok',
|
||||
providerHandle: { transport: 'acp', agent: 'grok', nativeId: 's' }
|
||||
})
|
||||
).toEqual({ kind: 'opaque', agent: 'grok', value: 's' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a chat whose saved conversation could not be restored', () => {
|
||||
const acp = (nativeId: string): AgentSessionProviderHandle => ({
|
||||
transport: 'acp',
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractAgentProviderSession, isResumableTuiAgent } from './agent-session-resume'
|
||||
import { getAgentResumeArgv } from '../../agent-resume-argv'
|
||||
import { buildAgentResumeStartupPlan } from '../../tui-agent-resume-startup'
|
||||
import { resolveStartupShell, tokenizeStartupCommand } from '../../tui-agent-startup-shell'
|
||||
|
||||
describe('Cursor conversation continuity', () => {
|
||||
it('captures only the hook conversation identity', () => {
|
||||
expect(isResumableTuiAgent('cursor')).toBe(true)
|
||||
expect(
|
||||
extractAgentProviderSession('cursor', {
|
||||
conversation_id: 'conversation-742',
|
||||
session_id: 'tool-session'
|
||||
})
|
||||
).toEqual({ key: 'conversation_id', id: 'conversation-742' })
|
||||
expect(extractAgentProviderSession('cursor', { session_id: 'tool-session' })).toBeNull()
|
||||
expect(extractAgentProviderSession('cursor', { conversation_id: '--continue' })).toBeNull()
|
||||
expect(
|
||||
extractAgentProviderSession('cursor', { conversation_id: 'session\ncommand' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resumes the exact conversation instead of the latest one', () => {
|
||||
expect(
|
||||
getAgentResumeArgv('cursor', { key: 'conversation_id', id: 'conversation-742' })
|
||||
).toEqual(['cursor-agent', '--resume', 'conversation-742'])
|
||||
expect(getAgentResumeArgv('cursor', { key: 'session_id', id: 'tool-session' })).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['linux', 'darwin', 'win32'] as const)(
|
||||
'preserves the configured executable and launch recipe on %s',
|
||||
(platform) => {
|
||||
const plan = buildAgentResumeStartupPlan({
|
||||
agent: 'cursor',
|
||||
providerSession: { key: 'conversation_id', id: 'conversation-742' },
|
||||
cmdOverrides: { cursor: 'custom-cursor' },
|
||||
platform,
|
||||
agentArgs: '--mode ask'
|
||||
})
|
||||
expect(plan).not.toBeNull()
|
||||
if (!plan) {
|
||||
throw new Error('Missing Cursor resume plan')
|
||||
}
|
||||
expect(
|
||||
tokenizeStartupCommand(plan.launchCommand, resolveStartupShell(platform))
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
tokens: ['custom-cursor', '--mode', 'ask', '--resume', 'conversation-742']
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,203 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentProviderSessionsEqual,
|
||||
extractAgentProviderSession,
|
||||
normalizeAgentProviderSession
|
||||
} from './agent-session-resume'
|
||||
import { getAgentResumeArgv } from '../../agent-resume-argv'
|
||||
|
||||
describe('agent session resume metadata', () => {
|
||||
it.each([
|
||||
['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }],
|
||||
['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }],
|
||||
['gemini', { session_id: 'gemini-session' }, { key: 'session_id', id: 'gemini-session' }],
|
||||
[
|
||||
'antigravity',
|
||||
{ conversationId: 'agy-conversation' },
|
||||
{ key: 'conversation_id', id: 'agy-conversation' }
|
||||
],
|
||||
['opencode', { sessionID: 'opencode-session' }, { key: 'session_id', id: 'opencode-session' }],
|
||||
[
|
||||
'pi',
|
||||
{ session_id: 'pi-session', session_file: '/tmp/pi-session.jsonl' },
|
||||
{ key: 'session_id', id: 'pi-session', transcriptPath: '/tmp/pi-session.jsonl' }
|
||||
],
|
||||
['mimo-code', { sessionID: 'mimo-session' }, { key: 'session_id', id: 'mimo-session' }],
|
||||
['droid', { session_id: 'droid-session' }, { key: 'session_id', id: 'droid-session' }],
|
||||
['grok', { sessionId: 'grok-session' }, { key: 'session_id', id: 'grok-session' }],
|
||||
['devin', { session_id: 'devin-session' }, { key: 'session_id', id: 'devin-session' }],
|
||||
['omp', { session_id: 'omp-session' }, { key: 'session_id', id: 'omp-session' }],
|
||||
[
|
||||
'prime-agent',
|
||||
{ session_id: 'prime-session', session_file: '/tmp/prime-session.jsonl' },
|
||||
{ key: 'session_id', id: 'prime-session', transcriptPath: '/tmp/prime-session.jsonl' }
|
||||
],
|
||||
[
|
||||
'copilot',
|
||||
{ session_id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' },
|
||||
{ key: 'session_id', id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' }
|
||||
],
|
||||
['copilot', { sessionId: 'copilot-camel' }, { key: 'session_id', id: 'copilot-camel' }],
|
||||
[
|
||||
'kimi',
|
||||
{ session_id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201' },
|
||||
{ key: 'session_id', id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201' }
|
||||
],
|
||||
['jcode', { session_id: 'session_jc_1' }, { key: 'session_id', id: 'session_jc_1' }],
|
||||
['jcode', { sessionId: 'session_jc_2' }, { key: 'session_id', id: 'session_jc_2' }],
|
||||
[
|
||||
'hermes',
|
||||
{ session_id: '20261020_221305_5e3a1c' },
|
||||
{ key: 'session_id', id: '20261020_221305_5e3a1c' }
|
||||
]
|
||||
] as const)('extracts %s provider session ids', (source, payload, expected) => {
|
||||
expect(extractAgentProviderSession(source, payload)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude', { key: 'session_id', id: 's1' }, ['claude', '--resume', 's1']],
|
||||
[
|
||||
'claude',
|
||||
{ key: 'session_id', id: 's1', effort: 'max' },
|
||||
['claude', '--effort', 'max', '--resume', 's1']
|
||||
],
|
||||
['codebuddy', { key: 'session_id', id: 's1' }, ['codebuddy', '--resume', 's1']],
|
||||
['codex', { key: 'session_id', id: 's1' }, ['codex', 'resume', 's1']],
|
||||
['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']],
|
||||
['antigravity', { key: 'conversation_id', id: 's1' }, ['agy', '--conversation', 's1']],
|
||||
['opencode', { key: 'session_id', id: 's1' }, ['opencode', '--session', 's1']],
|
||||
[
|
||||
'pi',
|
||||
{ key: 'session_id', id: 's1', transcriptPath: '/tmp/pi-session.jsonl' },
|
||||
['pi', '--session', '/tmp/pi-session.jsonl']
|
||||
],
|
||||
['mimo-code', { key: 'session_id', id: 's1' }, ['mimo', '--session', 's1']],
|
||||
['droid', { key: 'session_id', id: 's1' }, ['droid', '--resume', 's1']],
|
||||
['grok', { key: 'session_id', id: 's1' }, ['grok', '--resume', 's1']],
|
||||
['devin', { key: 'session_id', id: 'abc12345' }, ['devin', '--resume', 'abc12345']],
|
||||
['omp', { key: 'session_id', id: 's1' }, ['omp', '--resume', 's1']],
|
||||
[
|
||||
'prime-agent',
|
||||
{ key: 'session_id', id: 's1', transcriptPath: '/tmp/prime-session.jsonl' },
|
||||
['prime-agent', '--resume', '/tmp/prime-session.jsonl']
|
||||
],
|
||||
['copilot', { key: 'session_id', id: 's1' }, ['copilot', '--resume=s1']],
|
||||
[
|
||||
'kimi',
|
||||
{ key: 'session_id', id: 'session_431324d7' },
|
||||
['kimi', '--session', 'session_431324d7']
|
||||
],
|
||||
['jcode', { key: 'session_id', id: 'session_jc_1' }, ['jcode', '--resume', 'session_jc_1']],
|
||||
['hermes', { key: 'session_id', id: 's1' }, ['hermes', '--resume', 's1']],
|
||||
['dsh', { key: 'session_id', id: 's1' }, ['dsh-tui', '--backend=dsh', '--resume', 's1']]
|
||||
] as const)('builds %s resume argv', (agent, providerSession, expected) => {
|
||||
expect(getAgentResumeArgv(agent, providerSession)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('rejects unsupported sources and unsafe ids', () => {
|
||||
expect(extractAgentProviderSession('cursor', { session_id: 'cursor-session' })).toBeNull()
|
||||
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'bad\nid' })).toBeNull()
|
||||
expect(normalizeAgentProviderSession({ key: 'session_id', id: '--last' })).toBeNull()
|
||||
expect(extractAgentProviderSession('codex', { session_id: '--last' })).toBeNull()
|
||||
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok' })).toEqual({
|
||||
key: 'session_id',
|
||||
id: 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not capture ephemeral Pi sessions without a session file', () => {
|
||||
expect(extractAgentProviderSession('pi', { session_id: 'pi-session' })).toBeNull()
|
||||
expect(
|
||||
extractAgentProviderSession('pi', { session_id: 'pi-session', session_file: '' })
|
||||
).toBeNull()
|
||||
expect(extractAgentProviderSession('pi', { session_file: '/tmp/pi-session.jsonl' })).toBeNull()
|
||||
expect(getAgentResumeArgv('pi', { key: 'session_id', id: 'pi-session' })).toBeNull()
|
||||
})
|
||||
|
||||
it('compares the actual provider resume locator for each agent', () => {
|
||||
const first = { key: 'session_id' as const, id: 'session-1', transcriptPath: '/tmp/first' }
|
||||
const second = { key: 'session_id' as const, id: 'session-1', transcriptPath: '/tmp/second' }
|
||||
|
||||
expect(agentProviderSessionsEqual('pi', first, second)).toBe(false)
|
||||
expect(agentProviderSessionsEqual('prime-agent', first, second)).toBe(false)
|
||||
expect(agentProviderSessionsEqual('claude', first, second)).toBe(true)
|
||||
})
|
||||
|
||||
it('captures the hook transcript_path for native-chat agents (claude/codex)', () => {
|
||||
expect(
|
||||
extractAgentProviderSession('claude', {
|
||||
session_id: 'cs',
|
||||
transcript_path: '/home/u/.claude/projects/slug/real.jsonl'
|
||||
})
|
||||
).toEqual({
|
||||
key: 'session_id',
|
||||
id: 'cs',
|
||||
transcriptPath: '/home/u/.claude/projects/slug/real.jsonl'
|
||||
})
|
||||
expect(
|
||||
extractAgentProviderSession('codex', { session_id: 'xs', transcriptPath: '/x/r.jsonl' })
|
||||
).toEqual({ key: 'session_id', id: 'xs', transcriptPath: '/x/r.jsonl' })
|
||||
})
|
||||
|
||||
it('does not attach transcript_path for non-native-chat agents', () => {
|
||||
expect(
|
||||
extractAgentProviderSession('gemini', { session_id: 'gs', transcript_path: '/x/r.jsonl' })
|
||||
).toEqual({ key: 'session_id', id: 'gs' })
|
||||
})
|
||||
|
||||
it('round-trips transcriptPath through normalizeAgentProviderSession', () => {
|
||||
expect(
|
||||
normalizeAgentProviderSession({ key: 'session_id', id: 'ok', transcriptPath: '/x/r.jsonl' })
|
||||
).toEqual({ key: 'session_id', id: 'ok', transcriptPath: '/x/r.jsonl' })
|
||||
expect(
|
||||
normalizeAgentProviderSession({
|
||||
key: 'session_id',
|
||||
id: 'ok',
|
||||
transcriptPath: '/tmp/bad\npath.jsonl'
|
||||
})
|
||||
).toEqual({ key: 'session_id', id: 'ok' })
|
||||
})
|
||||
|
||||
it('round-trips a Claude effort level and drops values unsafe for argv', () => {
|
||||
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok', effort: 'xhigh' })).toEqual(
|
||||
{ key: 'session_id', id: 'ok', effort: 'xhigh' }
|
||||
)
|
||||
for (const effort of ['--model', 'high; rm -rf /', 'HIGH', '', 3]) {
|
||||
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok', effort })).toEqual({
|
||||
key: 'session_id',
|
||||
id: 'ok'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('OMP recorded resume locators', () => {
|
||||
it.each([
|
||||
{
|
||||
explicit: '/explicit/session.jsonl',
|
||||
recorded: '/hook/session.jsonl',
|
||||
target: '/explicit/session.jsonl'
|
||||
},
|
||||
{ explicit: undefined, recorded: ' /hook/session.jsonl ', target: '/hook/session.jsonl' },
|
||||
{ explicit: ' ', recorded: '/hook/session.jsonl', target: '/hook/session.jsonl' },
|
||||
{ explicit: undefined, recorded: ' ', target: 'session-id' },
|
||||
{ explicit: undefined, recorded: undefined, target: 'session-id' }
|
||||
])('selects explicit then recorded path then UUID %j', ({ explicit, recorded, target }) => {
|
||||
expect(
|
||||
getAgentResumeArgv(
|
||||
'omp',
|
||||
{ key: 'session_id', id: 'session-id', transcriptPath: recorded },
|
||||
explicit
|
||||
)
|
||||
).toEqual(['omp', '--resume', target])
|
||||
})
|
||||
it('retains UUID equality when hook path metadata arrives later', () => {
|
||||
expect(
|
||||
agentProviderSessionsEqual(
|
||||
'omp',
|
||||
{ key: 'session_id', id: 'session-id' },
|
||||
{ key: 'session_id', id: 'session-id', transcriptPath: '/hook/session.jsonl' }
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPersistedAgentSessionRecord, type AgentSessionRecord } from './agent-session-record'
|
||||
import { agentSessionRecordFixture } from './agent-session-record.test-fixture'
|
||||
import {
|
||||
decodePersistedAgentSessionRecord,
|
||||
encodeAgentSessionRecord
|
||||
} from './agent-session-record-stored-form'
|
||||
import { encodeAgentSessionRecord } from './agent-session-record-stored-form'
|
||||
|
||||
function grokRecord(transport = 'acp'): AgentSessionRecord {
|
||||
const record = agentSessionRecordFixture()
|
||||
@@ -20,35 +17,6 @@ function grokRecord(transport = 'acp'): AgentSessionRecord {
|
||||
}
|
||||
|
||||
describe('provider-independent stored records', () => {
|
||||
it('reads and decodes a well-formed record', () => {
|
||||
const stored = encodeAgentSessionRecord(grokRecord())
|
||||
expect(isPersistedAgentSessionRecord(stored)).toBe(true)
|
||||
if (!isPersistedAgentSessionRecord(stored)) {
|
||||
return
|
||||
}
|
||||
expect(decodePersistedAgentSessionRecord(stored).record).toEqual(grokRecord())
|
||||
})
|
||||
|
||||
it.each(['', '1grok', 'gro k', 'grok/', 'g'.repeat(65)])(
|
||||
'quarantines a malformed provider id %j even without a handle',
|
||||
(provider) => {
|
||||
const record = grokRecord()
|
||||
expect(
|
||||
isPersistedAgentSessionRecord({
|
||||
...encodeAgentSessionRecord(record),
|
||||
provider,
|
||||
providerHandleChain: [],
|
||||
lease: { ...record.lease, claimStatus: 'released' }
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
// Check transport drivability at start (structured-agent-session-drivability.test.ts), so definition changes never hide stored chats.
|
||||
it("reads a record whose handles are in a transport other than the agent's current one", () => {
|
||||
expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(grokRecord('other')))).toBe(true)
|
||||
})
|
||||
|
||||
it('sets aside a record whose handles name another agent, or mix transports', () => {
|
||||
const record = grokRecord()
|
||||
const [link] = record.providerHandleChain
|
||||
@@ -75,38 +43,4 @@ describe('provider-independent stored records', () => {
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps reading Claude and Codex records exactly as before', () => {
|
||||
const claude = agentSessionRecordFixture()
|
||||
const codex = {
|
||||
...claude,
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' },
|
||||
providerHandleChain: claude.providerHandleChain.map((link) => ({
|
||||
...link,
|
||||
handle: { transport: 'codex-app-server', agent: 'codex', nativeId: 'codex-thread-1' }
|
||||
}))
|
||||
}
|
||||
const storedClaude = encodeAgentSessionRecord(claude)
|
||||
const storedCodex = encodeAgentSessionRecord(codex)
|
||||
expect(isPersistedAgentSessionRecord(storedClaude)).toBe(true)
|
||||
expect(isPersistedAgentSessionRecord(storedCodex)).toBe(true)
|
||||
expect(JSON.stringify(storedClaude.providerHandleChain[0]?.handle)).toBe(
|
||||
'{"provider":"claude","sessionId":"provider-session-alpha-1","leafUuid":null}'
|
||||
)
|
||||
expect(JSON.stringify(storedCodex.providerHandleChain[0]?.handle)).toBe(
|
||||
'{"provider":"codex","threadId":"codex-thread-1"}'
|
||||
)
|
||||
expect(decodePersistedAgentSessionRecord(storedClaude).record).toEqual(claude)
|
||||
expect(decodePersistedAgentSessionRecord(storedCodex).record).toEqual(codex)
|
||||
})
|
||||
|
||||
it('refuses a Claude record whose handles another agent owns', () => {
|
||||
const record = agentSessionRecordFixture()
|
||||
expect(
|
||||
isPersistedAgentSessionRecord(
|
||||
encodeAgentSessionRecord({ ...record, provider: 'codex', accountHome: record.accountHome })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
journalRowSchemaVersion,
|
||||
type AgentJournalTurnLifecycle
|
||||
} from '../journal/agent-session-journal-types'
|
||||
import '../journal/agent-session-journal-types'
|
||||
import type { AgentJournalTurnLifecycle } from '../journal/agent-session-journal-types'
|
||||
import {
|
||||
agentJournalTurnBody,
|
||||
isRunningAgentJournalTurn,
|
||||
legacyAgentJournalTurnStatusBody,
|
||||
readAgentJournalTurn,
|
||||
readAgentJournalTurnOutcome
|
||||
} from './agent-session-turn-record'
|
||||
@@ -21,51 +16,7 @@ const turn: AgentJournalTurnLifecycle = {
|
||||
durationMs: 7_172
|
||||
}
|
||||
|
||||
describe('readAgentJournalTurn', () => {
|
||||
it('reads the typed item and the legacy status form alike', () => {
|
||||
expect(readAgentJournalTurn(agentJournalTurnBody(turn))).toEqual(turn)
|
||||
expect(readAgentJournalTurn({ kind: 'status', text: 'x', turnLifecycle: turn })).toEqual(turn)
|
||||
})
|
||||
|
||||
it('reads nothing off other bodies', () => {
|
||||
expect(readAgentJournalTurn({ kind: 'status', text: 'Conversation compacted.' })).toBeNull()
|
||||
expect(
|
||||
readAgentJournalTurn({
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'hi' }]
|
||||
})
|
||||
).toBeNull()
|
||||
expect(readAgentJournalTurn(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers the running check for either form', () => {
|
||||
expect(isRunningAgentJournalTurn(agentJournalTurnBody({ ...turn, state: 'running' }))).toBe(
|
||||
true
|
||||
)
|
||||
expect(isRunningAgentJournalTurn({ kind: 'status', text: 'x', turnLifecycle: turn })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentJournalTurnBody', () => {
|
||||
it('preserves every lifecycle field', () => {
|
||||
const complete: Required<AgentJournalTurnLifecycle> = {
|
||||
turnId: 't1',
|
||||
state: 'completed',
|
||||
outcome: 'success',
|
||||
userItemId: 'codex:thread:t1:0',
|
||||
startedAt: 1_000,
|
||||
requestedAt: 900,
|
||||
completedAt: 8_200,
|
||||
durationMs: 7_172,
|
||||
contextUsage: { window: { tokens: 100, capturedAt: 8_200 } },
|
||||
providerTurnId: 'provider-t1'
|
||||
}
|
||||
expect(agentJournalTurnBody(complete)).toEqual({ kind: 'turn', ...complete })
|
||||
})
|
||||
|
||||
it('normalizes known fields and preserves extensions without changing the saved lifecycle', () => {
|
||||
const saved = {
|
||||
...turn,
|
||||
@@ -87,44 +38,9 @@ describe('agentJournalTurnBody', () => {
|
||||
})
|
||||
expect(saved).toEqual(before)
|
||||
})
|
||||
|
||||
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
'omits an invalid optional time %s without inventing an end',
|
||||
(completedAt) => {
|
||||
expect(agentJournalTurnBody({ turnId: 't', state: 'interrupted', completedAt })).toEqual({
|
||||
kind: 'turn',
|
||||
turnId: 't',
|
||||
state: 'interrupted'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('omits invalid fields newly typed on a legacy lifecycle', () => {
|
||||
const saved = {
|
||||
...turn,
|
||||
providerTurnId: '',
|
||||
contextUsage: { window: { tokens: -1, capturedAt: 1 } }
|
||||
}
|
||||
expect(agentJournalTurnBody(saved)).toEqual({ kind: 'turn', ...turn })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAgentJournalTurnOutcome', () => {
|
||||
it.each(['success', 'failure', 'cancellation'] as const)(
|
||||
'reads a %s verdict off either journal shape',
|
||||
(outcome) => {
|
||||
const withOutcome = { ...turn, outcome }
|
||||
expect(
|
||||
readAgentJournalTurnOutcome(readAgentJournalTurn(agentJournalTurnBody(withOutcome)))
|
||||
).toBe(outcome)
|
||||
expect(
|
||||
readAgentJournalTurnOutcome(
|
||||
readAgentJournalTurn({ kind: 'status', text: 'x', turnLifecycle: withOutcome })
|
||||
)
|
||||
).toBe(outcome)
|
||||
}
|
||||
)
|
||||
|
||||
it('never reads an old host completed row as success', () => {
|
||||
// Pre-outcome hosts marked API-error turns completed; absent verdict must remain unknown or historical failures become successes.
|
||||
expect(readAgentJournalTurnOutcome(turn)).toBeNull()
|
||||
@@ -135,71 +51,4 @@ describe('readAgentJournalTurnOutcome', () => {
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a verdict from a later vocabulary as unknown rather than an arm', () => {
|
||||
// Persisted verdicts are open strings; the reader, not schema typing, checks known arms.
|
||||
expect(readAgentJournalTurnOutcome({ ...turn, outcome: 'partially-refused' })).toBeNull()
|
||||
})
|
||||
|
||||
it('answers unknown for a body that carries no turn at all', () => {
|
||||
expect(readAgentJournalTurnOutcome(null)).toBeNull()
|
||||
expect(readAgentJournalTurnOutcome(undefined)).toBeNull()
|
||||
expect(
|
||||
readAgentJournalTurnOutcome(readAgentJournalTurn({ kind: 'status', text: 'compacted' }))
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacyAgentJournalTurnStatusBody', () => {
|
||||
it('names the agent from the lifecycle identity and never calls an unobserved end completed', () => {
|
||||
expect(legacyAgentJournalTurnStatusBody(turn, 'legacy:claude:s:turn-lifecycle%3At1')).toEqual({
|
||||
kind: 'status',
|
||||
text: 'Claude turn completed',
|
||||
turnLifecycle: turn
|
||||
})
|
||||
expect(
|
||||
legacyAgentJournalTurnStatusBody(
|
||||
{ turnId: 't2', state: 'unverifiable', startedAt: 1 },
|
||||
'legacy:codex:s:turn-lifecycle%3At2'
|
||||
).text
|
||||
).toBe('Codex turn outcome unverifiable')
|
||||
})
|
||||
|
||||
it('names any other agent by its own name, never as Codex', () => {
|
||||
expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'gemini').text).toBe(
|
||||
'Gemini turn completed'
|
||||
)
|
||||
expect(legacyAgentJournalTurnStatusBody(turn, 'legacy:gemini:s:turn-lifecycle%3At1').text).toBe(
|
||||
'Gemini turn completed'
|
||||
)
|
||||
// Nothing names the agent: say so rather than guess one.
|
||||
expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1').text).toBe(
|
||||
'Agent turn completed'
|
||||
)
|
||||
expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'codex').text).toBe(
|
||||
'Codex turn completed'
|
||||
)
|
||||
})
|
||||
|
||||
it('carries the verdict to a client that predates the turn item', () => {
|
||||
// Keep legacy completed copy while carrying the verdict under turnLifecycle, so clients can learn outcomes without a host change.
|
||||
const failed = { ...turn, outcome: 'failure' as const }
|
||||
expect(legacyAgentJournalTurnStatusBody(failed, 'legacy:claude:s:turn-lifecycle%3At1')).toEqual(
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'Claude turn completed',
|
||||
turnLifecycle: failed
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journalRowSchemaVersion', () => {
|
||||
it('stamps only rows that carry a turn item with the current version', () => {
|
||||
// The newest this build reads is the queue row's.
|
||||
expect(AGENT_SESSION_JOURNAL_SCHEMA_VERSION).toBe(4)
|
||||
expect(journalRowSchemaVersion([agentJournalTurnBody(turn)])).toBe(3)
|
||||
expect(journalRowSchemaVersion([{ kind: 'message' }, { kind: 'status' }])).toBe(2)
|
||||
expect(journalRowSchemaVersion([])).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
+2
-214
@@ -9,14 +9,8 @@ import type {
|
||||
AgentJournalSubmission
|
||||
} from '../journal/agent-session-journal-types'
|
||||
import type { AgentJournalTurnScope } from '../journal/agent-session-journal-turn-scope'
|
||||
import {
|
||||
NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION,
|
||||
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT
|
||||
} from '../../native-chat/native-chat-stopped-before-start'
|
||||
import {
|
||||
DISPATCH_REJECTED_CANCELLED,
|
||||
DISPATCH_REJECTED_WRITE_FAILED
|
||||
} from '../requests/structured-agent-session-dispatch-rejection'
|
||||
import '../../native-chat/native-chat-stopped-before-start'
|
||||
import { DISPATCH_REJECTED_CANCELLED } from '../requests/structured-agent-session-dispatch-rejection'
|
||||
import { projectStructuredAgentSessionMessages } from '../view/structured-agent-session-message-projection'
|
||||
import {
|
||||
projectNativeChatTranscript,
|
||||
@@ -116,37 +110,6 @@ function takenBackOn(
|
||||
}
|
||||
|
||||
describe('a send a Stop took back before the agent started it', () => {
|
||||
it('stays where it was sent, with one row after it', () => {
|
||||
const items = [sent('warm-up', 'warm up'), sent('never-ran', 'look around')]
|
||||
|
||||
expect(rows(items, [submission('warm-up'), stopped('never-ran')])).toEqual([
|
||||
user('warm-up'),
|
||||
user('never-ran'),
|
||||
stopRow('never-ran')
|
||||
])
|
||||
const [, , row] = projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')])
|
||||
expect(row?.blocks).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT,
|
||||
presentation: NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION
|
||||
}
|
||||
])
|
||||
expect(row?.journalPosition).toEqual(
|
||||
projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')])[1]?.journalPosition
|
||||
)
|
||||
})
|
||||
|
||||
it('reads the same from the typed fact as from the older marker', () => {
|
||||
const items = [sent('never-ran', 'look around')]
|
||||
const typed = stopped('never-ran', {
|
||||
reason: 'This message was withdrawn before the agent started it.',
|
||||
rejection: { kind: 'cancelled' }
|
||||
})
|
||||
|
||||
expect(rows(items, [typed])).toEqual(rows(items, [stopped('never-ran')]))
|
||||
})
|
||||
|
||||
it('shares one row with the sends taken back right before it', () => {
|
||||
const items = [sent('first', 'one'), sent('second', 'two')]
|
||||
|
||||
@@ -157,21 +120,6 @@ describe('a send a Stop took back before the agent started it', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stays in the turn that opened for it, whose end already says it was stopped', () => {
|
||||
const items = [
|
||||
sent('opened', 'look around'),
|
||||
entry('turn-1', {
|
||||
kind: 'turn',
|
||||
turnId: 'turn-1',
|
||||
state: 'interrupted',
|
||||
outcome: 'cancellation',
|
||||
userItemId: agentJournalSubmissionKey('opened')
|
||||
})
|
||||
]
|
||||
|
||||
expect(rows(items, [stopped('opened')])).toEqual([user('opened')])
|
||||
})
|
||||
|
||||
// Codex reports the turn open before it echoes the send, so the record names the provider's key.
|
||||
it('stays in the turn that opened for it before the provider echoed it', () => {
|
||||
const items = [
|
||||
@@ -320,38 +268,6 @@ describe('a send a Stop took back before the agent started it', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('is drawn after the turn it waited behind when it was never handed over', () => {
|
||||
const items = [
|
||||
sent('first', 'first prompt'),
|
||||
entry('t1', {
|
||||
kind: 'turn',
|
||||
turnId: 't1',
|
||||
state: 'interrupted',
|
||||
outcome: 'cancellation',
|
||||
userItemId: agentJournalSubmissionKey('first')
|
||||
}),
|
||||
said('before', 'working on it', 't1'),
|
||||
sent('queued', 'queued while t1 ran'),
|
||||
said('after', 'more t1 output', 't1'),
|
||||
entry(
|
||||
'note',
|
||||
{ kind: 'status', text: 'Cancellation requested.' },
|
||||
{ kind: 'turn', turnItemId: 't1' }
|
||||
)
|
||||
]
|
||||
|
||||
expect(
|
||||
rows(items, [submission('first'), stopped('queued', { handoverRecorded: true })])
|
||||
).toEqual([
|
||||
user('first'),
|
||||
{ id: 'before', role: 'assistant' },
|
||||
{ id: 'after', role: 'assistant' },
|
||||
{ id: 'note', role: 'system' },
|
||||
user('queued'),
|
||||
stopRow('queued')
|
||||
])
|
||||
})
|
||||
|
||||
// The first send opens the turn; stopped follow-ups stay below that turn’s rows.
|
||||
it("is drawn after a turn that opened while it waited, with that turn's rows above it", () => {
|
||||
const items = [
|
||||
@@ -391,37 +307,6 @@ describe('a send a Stop took back before the agent started it', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// One Stop takes back two /compact-queued sends: only the first had a handover.
|
||||
it('keeps two sends it took back in the order they were sent', () => {
|
||||
const compactTurn = 'compact-turn'
|
||||
const items = [
|
||||
sent('compact', '/compact'),
|
||||
entry(compactTurn, {
|
||||
kind: 'turn',
|
||||
turnId: compactTurn,
|
||||
state: 'completed',
|
||||
userItemId: agentJournalSubmissionKey('compact')
|
||||
}),
|
||||
sent('queued-second', 'queued second'),
|
||||
said('compacted', 'Context compacted', compactTurn),
|
||||
sent('queued-first', 'queued first')
|
||||
]
|
||||
|
||||
expect(
|
||||
rows(items, [
|
||||
submission('compact', { submittedAt: 1 }),
|
||||
stopped('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }),
|
||||
stopped('queued-second', { submittedAt: 3, handoverRecorded: true })
|
||||
])
|
||||
).toEqual([
|
||||
user('compact'),
|
||||
{ id: 'compacted', role: 'assistant' },
|
||||
user('queued-first'),
|
||||
user('queued-second'),
|
||||
stopRow('queued-second')
|
||||
])
|
||||
})
|
||||
|
||||
// The host's clock stepped back between the two accepts; the journal's order still holds.
|
||||
it('keeps two sends in journal order where the host publishes it, whatever the clock says', () => {
|
||||
const compactTurn = 'compact-turn'
|
||||
@@ -589,54 +474,6 @@ describe('a send a Stop took back before the agent started it', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Without an uncertain send, the earlier exchange’s opener is the old host’s latest ordering evidence.
|
||||
it.each(HOSTS)(
|
||||
'stays below a finished exchange whose opener was the last thing sent before it, on %s',
|
||||
(_host, published) => {
|
||||
const compactTurn = 'compact-turn'
|
||||
const items = [
|
||||
sent('compact', '/compact'),
|
||||
entry(compactTurn, {
|
||||
kind: 'turn',
|
||||
turnId: compactTurn,
|
||||
state: 'completed',
|
||||
userItemId: agentJournalSubmissionKey('compact')
|
||||
}),
|
||||
sent('queued-b', 'queued B'),
|
||||
said('compacted', 'Context compacted', compactTurn),
|
||||
sent('queued-zero', 'queued zero'),
|
||||
entry('turn-2', {
|
||||
kind: 'turn',
|
||||
turnId: 'turn-2',
|
||||
state: 'completed',
|
||||
userItemId: agentJournalSubmissionKey('queued-zero')
|
||||
}),
|
||||
said('answer', 'answer to zero', 'turn-2')
|
||||
]
|
||||
|
||||
const host = takenBackOn(published, items, 'queued-b', sequence + 1)
|
||||
|
||||
expect(
|
||||
rows(host.items, [
|
||||
submission('compact', { submittedAt: 1 }),
|
||||
submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }),
|
||||
stopped('queued-b', {
|
||||
submittedAt: 4,
|
||||
handoverRecorded: true,
|
||||
...host.sent
|
||||
})
|
||||
])
|
||||
).toEqual([
|
||||
user('compact'),
|
||||
{ id: 'compacted', role: 'assistant' },
|
||||
user('queued-zero'),
|
||||
{ id: 'answer', role: 'assistant' },
|
||||
user('queued-b'),
|
||||
stopRow('queued-b')
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
// Tied accepts cannot order the exchanges; only the moved take-back row proves which finished before Stop.
|
||||
it('stays below a finished exchange when accept times tie, by the row the host moved', () => {
|
||||
const compactTurn = 'compact-turn'
|
||||
@@ -676,53 +513,4 @@ describe('a send a Stop took back before the agent started it', () => {
|
||||
stopRow('queued-b')
|
||||
])
|
||||
})
|
||||
|
||||
// The first taken back by one Stop, a later send by another: each stays where it was sent.
|
||||
it('keeps a send a later Stop took back below one an earlier Stop took', () => {
|
||||
const compactTurn = 'compact-turn'
|
||||
const items = [
|
||||
sent('compact', '/compact'),
|
||||
entry(compactTurn, {
|
||||
kind: 'turn',
|
||||
turnId: compactTurn,
|
||||
state: 'completed',
|
||||
userItemId: agentJournalSubmissionKey('compact')
|
||||
}),
|
||||
sent('queued', 'queued during compact'),
|
||||
said('compacted', 'Context compacted', compactTurn),
|
||||
sent('later', 'sent after the first Stop')
|
||||
]
|
||||
|
||||
expect(
|
||||
rows(items, [
|
||||
submission('compact', { submittedAt: 1 }),
|
||||
stopped('queued', { submittedAt: 2, handoverRecorded: true }),
|
||||
stopped('later', { submittedAt: 9, handoverRecorded: true, handedOverAt: 10 })
|
||||
])
|
||||
).toEqual([
|
||||
user('compact'),
|
||||
{ id: 'compacted', role: 'assistant' },
|
||||
user('queued'),
|
||||
user('later'),
|
||||
stopRow('later')
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a queued card to hold its own text', () => {
|
||||
const items = [sent('card-send', 'from a card')]
|
||||
|
||||
expect(rows(items, [stopped('card-send', { queuedMessageId: 'card-1' })])).toEqual([])
|
||||
})
|
||||
|
||||
it('draws any other refused send as not sent, with no stop row', () => {
|
||||
const shown = projectStructuredAgentSessionMessages(
|
||||
[sent('refused', 'hello')],
|
||||
[],
|
||||
[submission('refused', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED })]
|
||||
)
|
||||
|
||||
expect(shown.map(({ id, unsent }) => ({ id, unsent }))).toEqual([
|
||||
{ id: agentJournalSubmissionKey('refused'), unsent: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,22 +3,6 @@ import { structuredAgentSessionPayloadFingerprint } from './structured-agent-ses
|
||||
import { computeAgentSessionPayloadFingerprint } from './agent-session-mutation-envelope'
|
||||
|
||||
describe('structured agent session client mutations', () => {
|
||||
it('canonicalizes payload fields before hashing', () => {
|
||||
const first = structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: 'session-1',
|
||||
fields: { body: { role: 'user', kind: 'message' }, omitted: undefined }
|
||||
})
|
||||
const second = structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: 'session-1',
|
||||
fields: { body: { kind: 'message', role: 'user' } }
|
||||
})
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect(first).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it('matches host code-unit ordering for mixed-case and non-ASCII keys', () => {
|
||||
const input = {
|
||||
method: 'agentSession.send',
|
||||
|
||||
@@ -1,112 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mergeDiscoveredAuthoritativeModels } from './agent-session-option-catalog'
|
||||
import type { CatalogModel, CatalogOption } from './agent-session-option-catalog-types'
|
||||
import type { CatalogModel } from './agent-session-option-catalog-types'
|
||||
import { GROK_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-grok'
|
||||
import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch'
|
||||
import { parseBuiltSessionOptionCommand } from '../../native-chat/input/native-chat-session-option-commands'
|
||||
|
||||
function grokEffortOption(modelId = 'grok-4.7'): CatalogOption {
|
||||
const model = GROK_SESSION_OPTION_CATALOG.models.find((candidate) => candidate.id === modelId)!
|
||||
return model.options.find((option) => option.id === 'effort')!
|
||||
}
|
||||
|
||||
describe('grok launch args', () => {
|
||||
it('emits the model flag first, then effort', () => {
|
||||
expect(resolveAgentSessionOptionLaunch('grok', { model: 'grok-4.5', effort: 'low' })).toEqual({
|
||||
args: ['-m', 'grok-4.5', '--reasoning-effort', 'low'],
|
||||
appliedValues: { model: 'grok-4.5', effort: 'low' }
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the seeded effort default when none is stored', () => {
|
||||
// `high`, not the menu's ceiling: xhigh is opt-in, never a silent escalation.
|
||||
expect(resolveAgentSessionOptionLaunch('grok', { model: 'grok-4.6' }).args).toEqual([
|
||||
'-m',
|
||||
'grok-4.6',
|
||||
'--reasoning-effort',
|
||||
'high'
|
||||
])
|
||||
expect(resolveAgentSessionOptionLaunch('grok', { model: 'grok-4.5' }).args).toEqual([
|
||||
'-m',
|
||||
'grok-4.5',
|
||||
'--reasoning-effort',
|
||||
'high'
|
||||
])
|
||||
})
|
||||
|
||||
it('emits only -m for a persisted model the seed does not carry', () => {
|
||||
// A remembered model remains the launch selection even when discovery no longer lists it.
|
||||
expect(resolveAgentSessionOptionLaunch('grok', { model: 'grok-build' })).toEqual({
|
||||
args: ['-m', 'grok-build'],
|
||||
appliedValues: { model: 'grok-build' }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a picked effort onto a discovered model the seed never listed', () => {
|
||||
// Static-seed resolution once dropped --reasoning-effort for discovered IDs absent from the seed.
|
||||
expect(resolveAgentSessionOptionLaunch('grok', { model: 'grok-build', effort: 'low' })).toEqual(
|
||||
{
|
||||
args: ['-m', 'grok-build', '--reasoning-effort', 'low'],
|
||||
appliedValues: { model: 'grok-build', effort: 'low' }
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('drops an effort value the menu does not offer on an unseeded model', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('grok', { model: 'grok-build', effort: 'none' })
|
||||
).toEqual({ args: ['-m', 'grok-build'], appliedValues: { model: 'grok-build' } })
|
||||
})
|
||||
|
||||
it('honors a user effort flag over the picker on an unseeded model too', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('grok', { model: 'grok-build', effort: 'low' }, [
|
||||
'--reasoning-effort=high'
|
||||
]).appliedValues
|
||||
).toEqual({ model: 'grok-build' })
|
||||
})
|
||||
|
||||
it('resolves no picker args when no model was ever picked', () => {
|
||||
expect(resolveAgentSessionOptionLaunch('grok', undefined)).toEqual({
|
||||
args: [],
|
||||
appliedValues: {}
|
||||
})
|
||||
})
|
||||
})
|
||||
import '../../native-chat/input/native-chat-session-option-commands'
|
||||
|
||||
describe('grok agent-arg overrides', () => {
|
||||
const removeModel = GROK_SESSION_OPTION_CATALOG.modelApply.removeAgentArgs!
|
||||
const removeEffort = grokEffortOption().apply.removeAgentArgs!
|
||||
|
||||
it('strips a user-supplied model flag in every spelling', () => {
|
||||
for (const tokens of [
|
||||
['-m', 'grok-build'],
|
||||
['-mgrok-build'],
|
||||
['--model', 'grok-build'],
|
||||
['--model=grok-build']
|
||||
]) {
|
||||
expect(removeModel(tokens)).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fire on a different flag or a positional that contains -m', () => {
|
||||
for (const tokens of [
|
||||
['--model-context', '8000'],
|
||||
['summarize-my-diff'],
|
||||
['--reasoning-effort', 'low'],
|
||||
[]
|
||||
]) {
|
||||
expect(removeModel(tokens)).toEqual(tokens)
|
||||
}
|
||||
})
|
||||
|
||||
it('strips both effort spellings', () => {
|
||||
expect(removeEffort(['--effort', 'low', '--reasoning-effort=high', '--keep'])).toEqual([
|
||||
'--keep'
|
||||
])
|
||||
expect(removeEffort(['--effortless'])).toEqual(['--effortless'])
|
||||
})
|
||||
|
||||
it('drops only the overridden key from the launch record and argv', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('grok', { model: 'grok-4.5', effort: 'high' }, [
|
||||
@@ -127,37 +26,6 @@ describe('grok agent-arg overrides', () => {
|
||||
]).appliedValues
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps the record intact when a lookalike flag is present', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('grok', { model: 'grok-4.5', effort: 'low' }, [
|
||||
'--model-context',
|
||||
'8000'
|
||||
]).appliedValues
|
||||
).toEqual({ model: 'grok-4.5', effort: 'low' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('grok mid-session commands', () => {
|
||||
it('round-trips the model command through the built-command parser', () => {
|
||||
const midSession = GROK_SESSION_OPTION_CATALOG.modelApply.midSession!
|
||||
if (midSession.kind !== 'command') {
|
||||
throw new Error('grok model changes must be a typed command, not an agent picker')
|
||||
}
|
||||
expect(midSession.build('grok-4.5')).toBe('/model grok-4.5')
|
||||
expect(parseBuiltSessionOptionCommand(midSession.build, '/model grok-4.5')).toBe('grok-4.5')
|
||||
expect(parseBuiltSessionOptionCommand(midSession.build, '/effort low')).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips the effort command', () => {
|
||||
const midSession = grokEffortOption().apply.midSession!
|
||||
if (midSession.kind !== 'command') {
|
||||
throw new Error('grok effort changes must be a typed command')
|
||||
}
|
||||
expect(midSession.build('low')).toBe('/effort low')
|
||||
expect(parseBuiltSessionOptionCommand(midSession.build, '/effort low')).toBe('low')
|
||||
expect(parseBuiltSessionOptionCommand(midSession.build, '/effort ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeDiscoveredAuthoritativeModels', () => {
|
||||
@@ -169,25 +37,6 @@ describe('mergeDiscoveredAuthoritativeModels', () => {
|
||||
return effort?.kind.type === 'select' ? effort.kind.choices.map((choice) => choice.value) : []
|
||||
}
|
||||
|
||||
it('takes the default flag from the probe and drops the seed’s stale one', () => {
|
||||
// Follow the account's default so the picker matches an unflagged launch.
|
||||
const merged = mergeDiscoveredAuthoritativeModels(seed, [
|
||||
{ id: 'grok-4.5', label: 'Grok 4.5', options: [] },
|
||||
{ id: 'grok-5', label: 'Grok 5', isDefault: true, options: [] }
|
||||
])
|
||||
expect(merged.map(({ id, isDefault }) => [id, isDefault])).toEqual([
|
||||
['grok-4.5', undefined],
|
||||
['grok-5', true]
|
||||
])
|
||||
})
|
||||
|
||||
it('names no default when the probe marks none, rather than reviving the seed’s', () => {
|
||||
const merged = mergeDiscoveredAuthoritativeModels(seed, discovered('grok-4.5'))
|
||||
expect(merged[0].isDefault).toBeUndefined()
|
||||
// The seed's menu still survives — only the default claim is discovery's to make.
|
||||
expect(merged[0].options.map(({ id }) => id)).toEqual(['effort'])
|
||||
})
|
||||
|
||||
it('gives a matched seed row its own menu, not the default row’s', () => {
|
||||
const multiSeed: CatalogModel[] = [
|
||||
{ id: 'grok-4.6', label: 'Grok 4.6', isDefault: true, options: seed[0].options },
|
||||
@@ -205,29 +54,4 @@ describe('mergeDiscoveredAuthoritativeModels', () => {
|
||||
expect(mergedEffortValues(merged[0])).toEqual(['low', 'medium', 'high'])
|
||||
expect(mergedEffortValues(merged[1])).toEqual(['low', 'medium', 'high', 'xhigh'])
|
||||
})
|
||||
|
||||
it('takes the inherited options from the default seed row, not the first one', () => {
|
||||
const multiSeed: CatalogModel[] = [
|
||||
{ id: 'legacy', label: 'Legacy', options: [] },
|
||||
{ id: 'grok-4.6', label: 'Grok 4.6', isDefault: true, options: seed[0].options }
|
||||
]
|
||||
const merged = mergeDiscoveredAuthoritativeModels(multiSeed, discovered('grok-build'))
|
||||
expect(merged[0].options.map(({ id }) => id)).toEqual(['effort'])
|
||||
})
|
||||
|
||||
it('lends no options when the seed is empty', () => {
|
||||
expect(mergeDiscoveredAuthoritativeModels([], discovered('grok-build'))).toEqual([
|
||||
{ id: 'grok-build', label: 'grok-build', options: [] }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves discovery order rather than seed order', () => {
|
||||
const merged = mergeDiscoveredAuthoritativeModels(seed, discovered('grok-build', 'grok-4.5'))
|
||||
expect(merged.map(({ id }) => id)).toEqual(['grok-build', 'grok-4.5'])
|
||||
})
|
||||
|
||||
it('publishes an empty list for an empty discovery, so callers must gate on it', () => {
|
||||
// Only enrichment's non-empty guard keeps a failed probe from blanking the picker.
|
||||
expect(mergeDiscoveredAuthoritativeModels(seed, [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp'
|
||||
import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch'
|
||||
import { parseBuiltSessionOptionCommand } from '../../native-chat/input/native-chat-session-option-commands'
|
||||
import './agent-session-option-catalog-omp'
|
||||
import './agent-session-option-launch'
|
||||
import '../../native-chat/input/native-chat-session-option-commands'
|
||||
import { parseOmpModelList } from '../../omp-model-list-probe'
|
||||
|
||||
const LISTING = JSON.stringify({
|
||||
@@ -38,33 +38,6 @@ describe('omp model list probe', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the context window each row states', () => {
|
||||
// Row shape as `omp models --json` prints it (OMP 17.0.5).
|
||||
const listing = JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
provider: 'openai-codex',
|
||||
id: 'gpt-5.5',
|
||||
selector: 'openai-codex/gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000
|
||||
},
|
||||
{ provider: 'openai-codex', id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 1000000 },
|
||||
{ provider: 'zai', id: 'glm', name: 'GLM', contextWindow: 0 },
|
||||
{ provider: 'zai', id: 'glm-air', name: 'GLM Air', contextWindow: '128000' }
|
||||
]
|
||||
})
|
||||
expect(
|
||||
parseOmpModelList(listing).map(({ id, contextWindowTokens }) => ({ id, contextWindowTokens }))
|
||||
).toEqual([
|
||||
{ id: 'openai-codex/gpt-5.5', contextWindowTokens: 272000 },
|
||||
{ id: 'openai-codex/gpt-5.4', contextWindowTokens: 1000000 },
|
||||
{ id: 'zai/glm', contextWindowTokens: undefined },
|
||||
{ id: 'zai/glm-air', contextWindowTokens: undefined }
|
||||
])
|
||||
})
|
||||
|
||||
it('tolerates an update notice printed ahead of the JSON', () => {
|
||||
const noisy = `Package updates are available. Run omp update\n${LISTING}\n`
|
||||
expect(parseOmpModelList(noisy).map(({ id }) => id)).toEqual([
|
||||
@@ -94,46 +67,4 @@ describe('omp model list probe', () => {
|
||||
expect(parseOmpModelList(value)).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('returns nothing for output that is not a listing', () => {
|
||||
expect(parseOmpModelList('')).toEqual([])
|
||||
expect(parseOmpModelList('omp: command not found')).toEqual([])
|
||||
expect(parseOmpModelList('{"models": "nope"}')).toEqual([])
|
||||
expect(parseOmpModelList('[1, 2]')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('omp session option catalog', () => {
|
||||
it('launches a picked model with --model <selector>', () => {
|
||||
expect(resolveAgentSessionOptionLaunch('omp', { model: 'deepseek/deepseek-v4-pro' })).toEqual({
|
||||
args: ['--model', 'deepseek/deepseek-v4-pro'],
|
||||
appliedValues: { model: 'deepseek/deepseek-v4-pro' }
|
||||
})
|
||||
expect(resolveAgentSessionOptionLaunch('omp', {})).toEqual({ args: [], appliedValues: {} })
|
||||
})
|
||||
|
||||
it('yields to a user --model in the launch args, in either spelling', () => {
|
||||
const remove = OMP_SESSION_OPTION_CATALOG.modelApply.removeAgentArgs!
|
||||
expect(remove(['--model', 'opus', '--model=openai/gpt-5.5', '--no-extensions'])).toEqual([
|
||||
'--no-extensions'
|
||||
])
|
||||
// `--models` scopes Ctrl+P cycling; it does not pick a model. omp has no `-m`.
|
||||
expect(remove(['--models=anthropic/*', '-m', 'opus'])).toEqual([
|
||||
'--models=anthropic/*',
|
||||
'-m',
|
||||
'opus'
|
||||
])
|
||||
})
|
||||
|
||||
it('builds and parses /orca-model with the exact selector', () => {
|
||||
const midSession = OMP_SESSION_OPTION_CATALOG.modelApply.midSession
|
||||
expect(midSession).toMatchObject({ kind: 'command' })
|
||||
if (midSession?.kind !== 'command') {
|
||||
throw new Error('expected a command apply')
|
||||
}
|
||||
const command = midSession.build('minimax-cn/MiniMax-M3')
|
||||
expect(command).toBe('/orca-model minimax-cn/MiniMax-M3')
|
||||
expect(parseBuiltSessionOptionCommand(midSession.build, command)).toBe('minimax-cn/MiniMax-M3')
|
||||
expect(parseBuiltSessionOptionCommand(midSession.build, '/orca-model ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,28 +5,6 @@ import {
|
||||
} from './agent-session-option-launch'
|
||||
|
||||
describe('OpenCode launch options', () => {
|
||||
it('maps an opaque model id to the OpenCode --model flag', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('opencode', {
|
||||
model: 'zai-coding-plan/glm-5.3-flash'
|
||||
})
|
||||
).toEqual({
|
||||
args: ['--model', 'zai-coding-plan/glm-5.3-flash'],
|
||||
appliedValues: { model: 'zai-coding-plan/glm-5.3-flash' }
|
||||
})
|
||||
})
|
||||
|
||||
it('removes a configured model flag before applying a per-launch model', () => {
|
||||
expect(
|
||||
removeOverriddenAgentSessionArgs('opencode', { model: 'zai-coding-plan/glm-5.3-flash' }, [
|
||||
'--model',
|
||||
'opencode/global-default',
|
||||
'--log-level',
|
||||
'DEBUG'
|
||||
])
|
||||
).toEqual(['--log-level', 'DEBUG'])
|
||||
})
|
||||
|
||||
it('keeps arguments after the terminator while removing every earlier model choice', () => {
|
||||
expect(
|
||||
removeOverriddenAgentSessionArgs('opencode', { model: 'private-proof/model-b' }, [
|
||||
@@ -41,15 +19,6 @@ describe('OpenCode launch options', () => {
|
||||
).toEqual(['--log-level', 'DEBUG', '--', '--model', 'trailing'])
|
||||
})
|
||||
|
||||
it('does not send or record the selected model when free-form arguments override it', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('opencode', { model: 'private-proof/model-b' }, [
|
||||
'-m',
|
||||
'private-proof/model-a'
|
||||
])
|
||||
).toEqual({ args: [], appliedValues: {} })
|
||||
})
|
||||
|
||||
it('records the selected model when model-like arguments follow the terminator', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('opencode', { model: 'private-proof/model-b' }, [
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentSessionOptionCatalog } from './agent-session-option-catalog'
|
||||
import { agentArgTerminatorIndex, removeAgentArgOption } from './agent-session-option-agent-args'
|
||||
import {
|
||||
removeOverriddenAgentSessionArgs,
|
||||
resolveAgentSessionOptionLaunch
|
||||
} from './agent-session-option-launch'
|
||||
import { agentArgTerminatorIndex } from './agent-session-option-agent-args'
|
||||
import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch'
|
||||
|
||||
const removeCodexModel = getAgentSessionOptionCatalog('codex')!.modelApply.removeAgentArgs!
|
||||
|
||||
@@ -31,36 +28,9 @@ describe('catalog removers', () => {
|
||||
]
|
||||
expect(removeCodexModel(tokens)).toEqual(tokens)
|
||||
})
|
||||
|
||||
it('leaves everything after a terminator', () => {
|
||||
const tokens = ['--', '-c', 'model=o3']
|
||||
expect(removeCodexModel(tokens)).toEqual(tokens)
|
||||
})
|
||||
|
||||
it('lets a chat pick replace a configured gemini model on server launches', () => {
|
||||
expect(
|
||||
removeOverriddenAgentSessionArgs('gemini', { model: 'gemini-2.5-pro' }, [
|
||||
'-m',
|
||||
'a',
|
||||
'--model=b',
|
||||
'-mc',
|
||||
'--yolo'
|
||||
])
|
||||
).toEqual(['--yolo'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAgentArgOption', () => {
|
||||
it('consumes an empty value', () => {
|
||||
expect(removeAgentArgOption('claude', ['--model', '', '--yolo'], ['--model'])).toEqual([
|
||||
'--yolo'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a following flag', () => {
|
||||
expect(removeAgentArgOption('claude', ['--model', '--yolo'], ['--model'])).toEqual(['--yolo'])
|
||||
})
|
||||
|
||||
it('requires an exact terminator outside option values', () => {
|
||||
expect(
|
||||
agentArgTerminatorIndex('claude', [
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentSessionOptionCatalog, mergeCatalogModels } from './agent-session-option-catalog'
|
||||
import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch'
|
||||
import { getAgentSessionOptionCatalog } from './agent-session-option-catalog'
|
||||
import './agent-session-option-launch'
|
||||
import {
|
||||
resolveNativeChatSessionOptionDefaults,
|
||||
updateNativeChatSessionOptionDefaults
|
||||
} from '../../native-chat/input/native-chat-session-option-defaults'
|
||||
|
||||
describe('agent session option catalog', () => {
|
||||
it('merges discovered labels while preserving cataloged option shapes', () => {
|
||||
const seed = getAgentSessionOptionCatalog('cursor')!.models
|
||||
const merged = mergeCatalogModels(seed, [
|
||||
{ id: 'gpt-5.3-codex', label: 'GPT 5.3 (live)', options: [] },
|
||||
{ id: 'new-account-model', label: 'new-account-model', options: [] }
|
||||
])
|
||||
expect(merged.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({
|
||||
label: 'GPT 5.3 (live)',
|
||||
options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })])
|
||||
})
|
||||
expect(merged.at(-1)).toEqual({
|
||||
id: 'new-account-model',
|
||||
label: 'new-account-model',
|
||||
options: []
|
||||
})
|
||||
})
|
||||
|
||||
it('parses Claude list_models discovery into catalog models with options', () => {
|
||||
const stdout = JSON.stringify({
|
||||
type: 'control_response',
|
||||
@@ -77,64 +60,6 @@ describe('agent session option catalog', () => {
|
||||
expect(parsed[2].options).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no discovered models for unsupported or malformed Claude output', () => {
|
||||
const parse = getAgentSessionOptionCatalog('claude')!.listModels!.parse
|
||||
const unsupported =
|
||||
'{"type":"control_response","response":{"subtype":"error","request_id":"x","error":"Unsupported control request subtype: list_models"}}'
|
||||
expect(parse(unsupported)).toEqual([])
|
||||
expect(parse('')).toEqual([])
|
||||
expect(parse('garbage')).toEqual([])
|
||||
})
|
||||
|
||||
it('merges discovered Claude variants after the seed and overlays matched labels', () => {
|
||||
const catalog = getAgentSessionOptionCatalog('claude')!
|
||||
const merged = mergeCatalogModels(catalog.models, [
|
||||
{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
|
||||
{ id: 'sonnet', label: 'Sonnet', description: 'Sonnet 5 · Efficient', options: [] }
|
||||
])
|
||||
expect(merged.map(({ id }) => id)).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]'])
|
||||
const sonnet = merged.find((model) => model.id === 'sonnet')!
|
||||
expect(sonnet.description).toBe('Sonnet 5 · Efficient')
|
||||
expect(sonnet.isDefault).toBe(true)
|
||||
expect(sonnet.options.map(({ id }) => id)).toEqual(['effort'])
|
||||
})
|
||||
|
||||
it('parses Cursor model discovery without treating headings as models', () => {
|
||||
const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse(
|
||||
'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n'
|
||||
)
|
||||
expect(parsed.map(({ id }) => id)).toEqual(['auto', 'gpt-5.3-codex'])
|
||||
})
|
||||
|
||||
it('composes Cursor effort and fast mode into the supported slug form', () => {
|
||||
const resolved = resolveAgentSessionOptionLaunch('cursor', {
|
||||
model: 'gpt-5.3-codex',
|
||||
effort: 'high',
|
||||
fastMode: true
|
||||
})
|
||||
expect(resolved.args).toEqual(['--model', 'gpt-5.3-codex-high-fast'])
|
||||
expect(resolved.appliedValues).toEqual({
|
||||
model: 'gpt-5.3-codex',
|
||||
effort: 'high',
|
||||
fastMode: true
|
||||
})
|
||||
})
|
||||
|
||||
it('passes unknown models through and applies effort only to cataloged models', () => {
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('claude', {
|
||||
model: 'claude-future',
|
||||
effort: 'future-effort'
|
||||
})
|
||||
).toEqual({ args: ['--model', 'claude-future'], appliedValues: { model: 'claude-future' } })
|
||||
expect(
|
||||
resolveAgentSessionOptionLaunch('claude', { model: 'opus', effort: 'future-effort' })
|
||||
).toMatchObject({
|
||||
args: ['--model', 'opus', '--effort', 'future-effort'],
|
||||
appliedValues: { model: 'opus', effort: 'future-effort' }
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves only stored values without leaking values across models', () => {
|
||||
let persisted = updateNativeChatSessionOptionDefaults({
|
||||
persisted: undefined,
|
||||
@@ -163,51 +88,4 @@ describe('agent session option catalog', () => {
|
||||
})
|
||||
expect(persisted.claude?.valuesByModel?.opus).toEqual({ effort: 'xhigh' })
|
||||
})
|
||||
|
||||
it('spawns vanilla when the user has not explicitly selected a model', () => {
|
||||
// Fresh launches must preserve CLI defaults instead of forcing catalog model/effort (#9085).
|
||||
expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toBeUndefined()
|
||||
expect(resolveNativeChatSessionOptionDefaults({}, 'claude')).toBeUndefined()
|
||||
expect(resolveNativeChatSessionOptionDefaults({}, 'future-agent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves an explicitly selected model and only its stored options', () => {
|
||||
let persisted = updateNativeChatSessionOptionDefaults({
|
||||
persisted: undefined,
|
||||
agent: 'claude',
|
||||
modelId: 'opus',
|
||||
optionId: 'model',
|
||||
value: 'opus'
|
||||
})
|
||||
expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({
|
||||
model: 'opus'
|
||||
})
|
||||
persisted = updateNativeChatSessionOptionDefaults({
|
||||
persisted,
|
||||
agent: 'claude',
|
||||
modelId: 'opus',
|
||||
optionId: 'effort',
|
||||
value: 'xhigh'
|
||||
})
|
||||
expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps catalog option defaults after the user explicitly selects a model', () => {
|
||||
const persisted = updateNativeChatSessionOptionDefaults({
|
||||
persisted: undefined,
|
||||
agent: 'claude',
|
||||
modelId: 'sonnet',
|
||||
optionId: 'model',
|
||||
value: 'sonnet'
|
||||
})
|
||||
const defaults = resolveNativeChatSessionOptionDefaults(persisted, 'claude')
|
||||
|
||||
expect(resolveAgentSessionOptionLaunch('claude', defaults)).toEqual({
|
||||
args: ['--model', 'sonnet', '--effort', 'high'],
|
||||
appliedValues: { model: 'sonnet', effort: 'high' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
-45
@@ -63,21 +63,6 @@ describe('structured option state from the host model catalog', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('upgrades the seed with host models as a provisional, uncommitted default', () => {
|
||||
const state = applyStructuredAgentSessionModelCatalog(
|
||||
createStructuredAgentSessionOptionState('codex', SEED),
|
||||
SEED,
|
||||
HOST_CATALOG,
|
||||
LAUNCH
|
||||
)
|
||||
expect(state.catalogSource).toBe('host')
|
||||
const snapshot = structuredAgentSessionOptionSnapshot(state)
|
||||
const model = snapshot.find((descriptor) => descriptor.id === 'model')!
|
||||
expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-hosted')
|
||||
// Provisional provenance: a listing default, never a reported value.
|
||||
expect(model.valueSource).toBe('default')
|
||||
})
|
||||
|
||||
it('lists host models but names no value for a session it did not launch', () => {
|
||||
// A reopened session may run a model picked in it, not the listing's default.
|
||||
const state = applyStructuredAgentSessionModelCatalog(
|
||||
@@ -112,36 +97,6 @@ describe('structured option state from the host model catalog', () => {
|
||||
expect(currentModel(false)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('names the default effort the listing states, and none it does not', () => {
|
||||
const effortOf = (catalog: AgentSessionModelCatalogResult) => {
|
||||
const state = applyStructuredAgentSessionModelCatalog(
|
||||
createStructuredAgentSessionOptionState('codex', SEED),
|
||||
SEED,
|
||||
catalog,
|
||||
LAUNCH
|
||||
)
|
||||
return structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'effort')!
|
||||
}
|
||||
const stated = effortOf(HOST_CATALOG)
|
||||
expect(stated.kind.type === 'select' ? stated.kind.currentValue : null).toBe('high')
|
||||
expect(stated.valueSource).toBe('default')
|
||||
const { defaultEffort: _stated, ...unstated } = HOST_CATALOG.models[0]!
|
||||
const silent = effortOf({ ...HOST_CATALOG, models: [unstated] })
|
||||
expect(silent.kind.type === 'select' ? silent.kind.currentValue : null).toBeUndefined()
|
||||
expect(silent.valueSource).toBe('unknown')
|
||||
})
|
||||
|
||||
it('settles on the built-in list on an unknown host answer, and keeps a host list', () => {
|
||||
const seeded = createStructuredAgentSessionOptionState('codex', SEED)
|
||||
for (const answer of [{ origin: 'unknown' as const }]) {
|
||||
const settled = applyStructuredAgentSessionModelCatalog(seeded, SEED, answer, LAUNCH)
|
||||
expect(settled.catalogSource).toBe('builtin')
|
||||
expect(settled.catalog).toBe(seeded.catalog)
|
||||
const hosted = applyStructuredAgentSessionModelCatalog(seeded, SEED, HOST_CATALOG, LAUNCH)
|
||||
expect(applyStructuredAgentSessionModelCatalog(hosted, SEED, answer, LAUNCH)).toBe(hosted)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps known empty inventory distinct from an unavailable observation', () => {
|
||||
const seeded = createStructuredAgentSessionOptionState('codex', SEED)
|
||||
const hosted = applyStructuredAgentSessionModelCatalog(seeded, SEED, HOST_CATALOG, LAUNCH)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decodeStructuredAgentSessionOptionValue,
|
||||
encodeStructuredAgentSessionOptionValue
|
||||
} from './structured-agent-session-option-codec'
|
||||
|
||||
describe('structured agent session option codec', () => {
|
||||
it('encodes and decodes explicit Fast mode booleans', () => {
|
||||
expect(encodeStructuredAgentSessionOptionValue('fastMode', true)).toBe('true')
|
||||
expect(encodeStructuredAgentSessionOptionValue('fastMode', false)).toBe('false')
|
||||
expect(decodeStructuredAgentSessionOptionValue('fastMode', 'true')).toBe(true)
|
||||
expect(decodeStructuredAgentSessionOptionValue('fastMode', 'false')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects string booleans on the UI side and non-canonical strings on the wire side', () => {
|
||||
for (const value of ['true', 'false']) {
|
||||
expect(encodeStructuredAgentSessionOptionValue('fastMode', value)).toBeNull()
|
||||
}
|
||||
for (const value of ['TRUE', '1', 'False', ' true', 'false ', '', '0']) {
|
||||
expect(decodeStructuredAgentSessionOptionValue('fastMode', value)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves string options in both directions and rejects boolean values', () => {
|
||||
for (const [id, value] of [
|
||||
['model', 'gpt-example'],
|
||||
['effort', 'high'],
|
||||
['serviceTier', 'flex']
|
||||
] as const) {
|
||||
expect(encodeStructuredAgentSessionOptionValue(id, value)).toBe(value)
|
||||
expect(decodeStructuredAgentSessionOptionValue(id, value)).toBe(value)
|
||||
expect(encodeStructuredAgentSessionOptionValue(id, true)).toBeNull()
|
||||
expect(encodeStructuredAgentSessionOptionValue(id, false)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
beginStructuredOptionPick,
|
||||
createStructuredOptionPickBook,
|
||||
settleStructuredOptionPick,
|
||||
structuredOptionPickCommit
|
||||
} from './structured-agent-session-option-pick-book'
|
||||
|
||||
describe('structured option pick commits', () => {
|
||||
it('commits only its own option when another option was picked since', () => {
|
||||
const book = createStructuredOptionPickBook()
|
||||
const model = beginStructuredOptionPick(book, 'model', 'opus', false)
|
||||
beginStructuredOptionPick(book, 'effort', 'low', false)
|
||||
expect(structuredOptionPickCommit(book, model, { model: 'opus', effort: 'high' })).toEqual({
|
||||
model: 'opus'
|
||||
})
|
||||
})
|
||||
|
||||
it('commits nothing once the same option was picked again', () => {
|
||||
const book = createStructuredOptionPickBook()
|
||||
const first = beginStructuredOptionPick(book, 'model', 'opus', false)
|
||||
beginStructuredOptionPick(book, 'model', 'sonnet', false)
|
||||
expect(structuredOptionPickCommit(book, first, undefined)).toBeNull()
|
||||
settleStructuredOptionPick(book, first)
|
||||
expect(book.pending).toEqual({ model: 'sonnet' })
|
||||
})
|
||||
})
|
||||
@@ -108,14 +108,6 @@ describe('structuredAgentSessionOptionPicks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('writes nothing before the provider catalog lands', () => {
|
||||
expect(
|
||||
structuredAgentSessionOptionPicks(createStructuredAgentSessionOptionState('codex'), {
|
||||
effort: 'high'
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('remembers explicit Fast on and off as booleans and re-encodes the launch seed', () => {
|
||||
const state = liveState({ model: 'account-model', fastMode: true })
|
||||
const picks = structuredAgentSessionOptionPicks(state, { fastMode: 'false' })
|
||||
@@ -126,21 +118,3 @@ describe('structuredAgentSessionOptionPicks', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyNativeChatSessionOptionPicks', () => {
|
||||
it('leaves every other agent untouched', () => {
|
||||
const persisted = applyNativeChatSessionOptionPicks({
|
||||
persisted: { claude: { model: 'opus', valuesByModel: { opus: { effort: 'high' } } } },
|
||||
agent: 'codex',
|
||||
picks: [{ modelId: 'gpt-fast', optionId: 'effort', value: 'low' }]
|
||||
})
|
||||
expect(resolveStructuredLaunchSeedOptions(persisted, 'claude')).toEqual({
|
||||
model: 'opus',
|
||||
effort: 'high'
|
||||
})
|
||||
expect(resolveStructuredLaunchSeedOptions(persisted, 'codex')).toEqual({
|
||||
model: 'gpt-fast',
|
||||
effort: 'low'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,16 +90,6 @@ describe('a Claude model its CLI no longer lists', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the literal seed when a built-in id has no host-listed label', () => {
|
||||
const seed = { model: 'sonnet' }
|
||||
const view = structuredAgentSessionOptionView(withHostList(), seed, {})
|
||||
const model = structuredAgentSessionOptionSnapshot(view).find((entry) => entry.id === 'model')
|
||||
expect(model?.kind).toMatchObject({
|
||||
currentValue: 'sonnet',
|
||||
choices: expect.arrayContaining([{ value: 'sonnet', label: 'Sonnet', description: 'sonnet' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an unlisted seed literally and carries a current effort without an offered menu', () => {
|
||||
const view = structuredAgentSessionOptionView(withHostList(), remembered, {})
|
||||
expect(carried(withHostList(), remembered)).toEqual(remembered)
|
||||
@@ -275,12 +265,6 @@ describe('what a send carries', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('carries the default it shows when nothing names a model', () => {
|
||||
const named = { ...SONNET, isDefault: true }
|
||||
const listing = { ...LISTED, models: [OPUS, named], listingNamesConfiguredModel: true }
|
||||
expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' })
|
||||
})
|
||||
|
||||
it('carries held picks without committing them to the chat', () => {
|
||||
const listed = withHostList(fresh(), { ...LISTED, models: [OPUS, SONNET] })
|
||||
const originalRecord = structuredClone(listed.record)
|
||||
@@ -299,16 +283,6 @@ describe('what a send carries', () => {
|
||||
model: 'claude-retired'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries nothing before a model shows, or for another agent', () => {
|
||||
expect(carried(fresh())).toBeUndefined()
|
||||
const codex = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex', CLAUDE_SESSION_OPTION_CATALOG),
|
||||
CLAUDE_SESSION_OPTION_CATALOG,
|
||||
{ models: [OPUS], current: { model: 'opus[1m]' } }
|
||||
)
|
||||
expect(carried(codex)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("a chat's model and effort, apart from the defaults filling them in", () => {
|
||||
|
||||
@@ -291,30 +291,6 @@ it('clears account-switch evidence only on a current owner read failure and reta
|
||||
close()
|
||||
})
|
||||
|
||||
it('retries a failed options read for a restarted process without an unrelated event', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { reader, context, reads } = fixture()
|
||||
let close = reader.attach(context)
|
||||
try {
|
||||
await tick()
|
||||
reads[0].resolve(answer)
|
||||
await tick()
|
||||
close()
|
||||
close = reader.attach({ ...context, reset: [2], fence: 2 })
|
||||
await tick()
|
||||
reads[1].reject(new Error('host timeout'))
|
||||
await tick()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(reads).toHaveLength(3)
|
||||
reads[2].resolve(answer)
|
||||
await tick()
|
||||
expect(reader.getSnapshot().support?.fence).toBe(2)
|
||||
} finally {
|
||||
close()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds failed reads, wakes on invalidation, and cancels a detached lease retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { reader, context, reads, controller } = fixture()
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
CLAUDE_SESSION_OPTION_CATALOG
|
||||
} from './agent-session-option-catalog-claude-codex'
|
||||
import { buildNativeChatSessionOptionSnapshot } from '../../native-chat/input/native-chat-session-option-snapshot'
|
||||
import { createNativeChatSessionOptionRecord } from '../../native-chat/input/native-chat-session-option-state'
|
||||
import '../../native-chat/input/native-chat-session-option-snapshot'
|
||||
import '../../native-chat/input/native-chat-session-option-state'
|
||||
import {
|
||||
applyStructuredAgentSessionModelCatalog,
|
||||
applyStructuredAgentSessionOptions,
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
settleStructuredAgentSessionBuiltinCatalog,
|
||||
type StructuredAgentSessionOptionState
|
||||
} from './structured-agent-session-options'
|
||||
import {
|
||||
canSetStructuredAgentSessionOption,
|
||||
structuredAgentSessionOptionSnapshot
|
||||
} from './structured-agent-session-option-snapshot'
|
||||
import { structuredAgentSessionOptionSnapshot } from './structured-agent-session-option-snapshot'
|
||||
import {
|
||||
structuredAgentSessionOptionView,
|
||||
structuredAgentSessionMessageSelection
|
||||
@@ -124,81 +121,6 @@ describe('structured agent session options', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects native Codex selects while bridge Codex keeps its agent picker', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: 'account-model',
|
||||
label: 'Account Model',
|
||||
isDefault: true,
|
||||
defaultEffort: 'medium',
|
||||
efforts: [
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' }
|
||||
]
|
||||
}
|
||||
],
|
||||
current: { model: 'account-model', effort: 'medium' }
|
||||
}
|
||||
)
|
||||
|
||||
const structured = structuredAgentSessionOptionSnapshot(state)
|
||||
expect(structured.map((descriptor) => descriptor.id)).toEqual(['model', 'effort'])
|
||||
expect(structured[0]).toMatchObject({
|
||||
settable: true,
|
||||
kind: { type: 'select', currentValue: 'account-model' }
|
||||
})
|
||||
expect(structured[0]).not.toHaveProperty('action')
|
||||
expect(structured[1]).toMatchObject({
|
||||
settable: true,
|
||||
kind: { type: 'select', currentValue: 'medium' }
|
||||
})
|
||||
|
||||
const bridgeRecord = createNativeChatSessionOptionRecord('codex')
|
||||
bridgeRecord.model = { value: 'gpt-5.6-sol', source: 'reported' }
|
||||
const bridge = buildNativeChatSessionOptionSnapshot({
|
||||
catalog: CODEX_SESSION_OPTION_CATALOG,
|
||||
models: CODEX_SESSION_OPTION_CATALOG.models,
|
||||
record: bridgeRecord,
|
||||
mode: 'live',
|
||||
modelLabel: 'Model',
|
||||
liveTransport: 'catalog'
|
||||
})
|
||||
// Same catalog, same `dispatched` vocabulary — only the transport separates them.
|
||||
expect(structured.every((descriptor) => descriptor.transport === 'agent-session')).toBe(true)
|
||||
expect(bridge.every((descriptor) => descriptor.transport === 'catalog')).toBe(true)
|
||||
expect(bridge[0]).toMatchObject({ action: { type: 'agent-picker' } })
|
||||
expect(bridge.find((descriptor) => descriptor.id === 'effort')).toMatchObject({
|
||||
action: { type: 'agent-picker' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses provider-scoped models and retains the current unknown id', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: 'account-model',
|
||||
label: 'Account Model',
|
||||
isDefault: false,
|
||||
efforts: []
|
||||
}
|
||||
],
|
||||
current: { model: 'persisted-unknown' }
|
||||
}
|
||||
)
|
||||
const model = structuredAgentSessionOptionSnapshot(state)[0]
|
||||
expect(
|
||||
model.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : []
|
||||
).toEqual(['account-model', 'persisted-unknown'])
|
||||
expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown')
|
||||
})
|
||||
|
||||
it('projects Fast mode only from positive session and model capability', () => {
|
||||
const supported = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
@@ -242,77 +164,6 @@ describe('structured agent session options', () => {
|
||||
expect(absent.record.values.fastMode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('offers one speed choice in place of the Fast toggle where the model lists speeds', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-6.1-sol',
|
||||
label: 'GPT-6.1 Sol',
|
||||
isDefault: true,
|
||||
efforts: [],
|
||||
supportsFastMode: true,
|
||||
serviceTiers: [
|
||||
{ value: 'priority', label: 'Fast' },
|
||||
{ value: 'ultrafast', label: 'Ultrafast' }
|
||||
]
|
||||
}
|
||||
],
|
||||
fastModeSupport: { supported: true },
|
||||
current: { model: 'gpt-6.1-sol', serviceTier: 'ultrafast', confirmed: ['serviceTier'] }
|
||||
}
|
||||
)
|
||||
const snapshot = structuredAgentSessionOptionSnapshot(state)
|
||||
expect(snapshot.map(({ id }) => id)).toEqual(['model', 'serviceTier'])
|
||||
expect(snapshot).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'serviceTier',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'ultrafast',
|
||||
choices: [
|
||||
{ value: 'default', label: 'Standard' },
|
||||
{ value: 'priority', label: 'Fast' },
|
||||
{ value: 'ultrafast', label: 'Ultrafast' }
|
||||
]
|
||||
},
|
||||
valueSource: 'reported',
|
||||
settable: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('renders Fast off but marked unreported when support is known and no value is', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: 'account-model',
|
||||
label: 'Account Model',
|
||||
isDefault: true,
|
||||
efforts: [],
|
||||
supportsFastMode: true
|
||||
}
|
||||
],
|
||||
fastModeSupport: { supported: true },
|
||||
current: { model: 'account-model' }
|
||||
}
|
||||
)
|
||||
|
||||
// A binary switch resolves false, but unknown provenance prevents claiming it is the running default in this live-only lane.
|
||||
expect(structuredAgentSessionOptionSnapshot(state)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'fastMode',
|
||||
kind: { type: 'boolean', currentValue: false },
|
||||
valueSource: 'unknown'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the launch seed until the record names a model, and held picks over both', () => {
|
||||
const cold = createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG)
|
||||
const seeded = applyStructuredAgentSessionModelCatalog(
|
||||
@@ -364,50 +215,6 @@ describe('structured agent session options', () => {
|
||||
expect(viewModel(live, seed, { model: 'gpt-5.5' })).toBe('gpt-5.5')
|
||||
expect(live.record.values.model?.value).toBe('gpt-5.6-luna')
|
||||
})
|
||||
|
||||
it('keeps a saved Fast with no model known, Standard turning it off', () => {
|
||||
const sol = {
|
||||
id: 'gpt-6.1-sol',
|
||||
label: 'GPT-6.1 Sol',
|
||||
isDefault: false,
|
||||
efforts: [],
|
||||
serviceTiers: [{ value: 'priority', label: 'Fast' }]
|
||||
}
|
||||
// A resumed chat's at-rest answer, whose listing names no default.
|
||||
const fast = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
{
|
||||
models: [sol],
|
||||
fastModeSupport: { supported: true },
|
||||
current: { serviceTier: 'priority', defaulted: [] }
|
||||
}
|
||||
)
|
||||
expect(
|
||||
structuredAgentSessionOptionSnapshot(fast).find(({ id }) => id === 'serviceTier')
|
||||
).toMatchObject({ settable: true, kind: { currentValue: 'priority' } })
|
||||
const standard = commitStructuredAgentSessionOption(fast, 'serviceTier', 'default')
|
||||
// Inventory does not authorize a literal pick.
|
||||
expect(canSetStructuredAgentSessionOption(standard, 'serviceTier', 'priority')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('separates typed literal readiness from an empty or refreshing catalog', () => {
|
||||
const state = createStructuredAgentSessionOptionState('codex')
|
||||
const snapshot = structuredAgentSessionOptionSnapshot(state)
|
||||
expect(canSetStructuredAgentSessionOption(state, 'model', 'unlisted', snapshot, false)).toBe(
|
||||
false
|
||||
)
|
||||
expect(canSetStructuredAgentSessionOption(state, 'model', 'unlisted', snapshot, true)).toBe(true)
|
||||
expect(
|
||||
canSetStructuredAgentSessionOption(
|
||||
{ ...state, listingInProgress: true },
|
||||
'effort',
|
||||
'high',
|
||||
snapshot,
|
||||
true
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
|
||||
+3
-55
@@ -1,16 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionOptionsResult } from './agent-session-options-result'
|
||||
import { sortNativeChatSessionOptions } from '../../native-chat/input/native-chat-session-option-snapshot'
|
||||
import '../../native-chat/input/native-chat-session-option-snapshot'
|
||||
import {
|
||||
canSetStructuredAgentSessionOption,
|
||||
structuredAgentSessionOptionSnapshot
|
||||
} from './structured-agent-session-option-snapshot'
|
||||
import {
|
||||
applyStructuredAgentSessionOptions,
|
||||
createStructuredAgentSessionOptionState,
|
||||
structuredAgentSessionOptionPicks
|
||||
createStructuredAgentSessionOptionState
|
||||
} from './structured-agent-session-options'
|
||||
import { structuredAgentSessionOptionView } from './structured-agent-session-option-view'
|
||||
import './structured-agent-session-option-view'
|
||||
import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog'
|
||||
|
||||
const permission = (state: Parameters<typeof structuredAgentSessionOptionSnapshot>[0]) =>
|
||||
@@ -68,20 +67,6 @@ describe('the permission mode picker', () => {
|
||||
).toMatchObject({ settable: true, kind: { currentValue: 'full-access' } })
|
||||
})
|
||||
|
||||
it.each(['claude', 'codex', 'grok', 'opencode', 'opencode2', 'omp', 'antigravity'])(
|
||||
'keeps a saved Ask choice editable for %s despite stale or missing reports',
|
||||
(agent) => {
|
||||
const state = live(agent, { permissionMode: 'ask' })
|
||||
for (const reportedPermissionMode of [undefined, null, 'full-access', 'retired-mode']) {
|
||||
expect(permission({ ...state, reportedPermissionMode })).toMatchObject({
|
||||
settable: true,
|
||||
valueSource: 'applied',
|
||||
kind: { currentValue: 'ask' }
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('admits supported Own delegation through the same descriptor/readiness predicate', () => {
|
||||
const state = { ...live('codex', { permissionMode: 'ask' }), permissionOwnSupported: true }
|
||||
expect(canSetStructuredAgentSessionOption(state, 'permissionMode', 'own')).toBe(true)
|
||||
@@ -114,41 +99,4 @@ describe('the permission mode picker', () => {
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the seeded mode before the agent starts, beside the quiet model pill', () => {
|
||||
const state = createStructuredAgentSessionOptionState(
|
||||
'codex',
|
||||
structuredAgentSessionSeedCatalog('codex')
|
||||
)
|
||||
const view = structuredAgentSessionOptionView(state, { permissionMode: 'full-access' }, {})
|
||||
expect(structuredAgentSessionOptionSnapshot(view).map((descriptor) => descriptor.id)).toEqual([
|
||||
'model',
|
||||
'permissionMode'
|
||||
])
|
||||
expect(permission(view)).toMatchObject({
|
||||
settable: true,
|
||||
kind: { currentValue: 'full-access' }
|
||||
})
|
||||
})
|
||||
|
||||
it("shows a held pick over the host's value, and the host's over the seed", () => {
|
||||
const state = live('codex', { permissionMode: 'auto' })
|
||||
const seed = { permissionMode: 'ask' }
|
||||
expect(permission(structuredAgentSessionOptionView(state, seed, {}))).toMatchObject({
|
||||
kind: { currentValue: 'auto' }
|
||||
})
|
||||
expect(
|
||||
permission(structuredAgentSessionOptionView(state, seed, { permissionMode: 'full-access' }))
|
||||
).toMatchObject({ kind: { currentValue: 'full-access' } })
|
||||
})
|
||||
|
||||
it('stays out of the per-model option pill and out of the saved selection', () => {
|
||||
const state = live('codex', { permissionMode: 'ask' })
|
||||
expect(
|
||||
sortNativeChatSessionOptions(structuredAgentSessionOptionSnapshot(state)).map(
|
||||
(descriptor) => descriptor.id
|
||||
)
|
||||
).not.toContain('permissionMode')
|
||||
expect(structuredAgentSessionOptionPicks(state, { permissionMode: 'full-access' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,12 +88,6 @@ describe('classifyDispatchRejection', () => {
|
||||
})
|
||||
|
||||
describe('isWriteFailureSubmission', () => {
|
||||
it('holds for a legacy row in any state that carries the marker', () => {
|
||||
expect(
|
||||
isWriteFailureSubmission({ reason: 'provider_write_failed: closed before enqueue' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('holds for the bare marker a new host writes, as released clients already read it', () => {
|
||||
const written = agentSessionFailureWords(agentSessionFailureFact('writeFailed'), {
|
||||
surface: 'rejection'
|
||||
@@ -101,11 +95,4 @@ describe('isWriteFailureSubmission', () => {
|
||||
expect(written.reason).toBe('provider_write_failed')
|
||||
expect(isWriteFailureSubmission({ reason: written.reason })).toBe(true)
|
||||
})
|
||||
|
||||
it('holds for a typed write failure and for nothing else', () => {
|
||||
expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'writeFailed' } })).toBe(true)
|
||||
expect(isWriteFailureSubmission({ reason: DISPATCH_REJECTED_QUEUE_FULL })).toBe(false)
|
||||
expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'queueFull' } })).toBe(false)
|
||||
expect(isWriteFailureSubmission({ reason: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,27 +47,6 @@ describe('first structured chat prompt', () => {
|
||||
).toBe('first user prompt')
|
||||
})
|
||||
|
||||
it('bounds a multi-block prompt before joining it', () => {
|
||||
const prompt = message('', {
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'x'.repeat(STRUCTURED_CHAT_NAME_PROMPT_LIMIT - 2) },
|
||||
{ type: 'text', text: 'yz'.repeat(100_000) }
|
||||
]
|
||||
}
|
||||
})
|
||||
const result = firstStructuredAgentSessionPrompt([prompt])
|
||||
expect(result).toHaveLength(STRUCTURED_CHAT_NAME_PROMPT_LIMIT)
|
||||
expect(result.endsWith('\ny')).toBe(true)
|
||||
})
|
||||
|
||||
it('clips a single prompt block without splitting a Unicode character', () => {
|
||||
const prefix = 'A'.repeat(STRUCTURED_CHAT_NAME_PROMPT_LIMIT - 1)
|
||||
expect(firstStructuredAgentSessionPrompt([message(`${prefix}𠮷`)])).toBe(prefix)
|
||||
})
|
||||
|
||||
it('clips a later prompt block without splitting a Unicode character', () => {
|
||||
const prefix = 'A'.repeat(STRUCTURED_CHAT_NAME_PROMPT_LIMIT - 2)
|
||||
const prompt = message('', {
|
||||
@@ -117,9 +96,4 @@ describe('first structured chat prompt', () => {
|
||||
).toBe('修复登录')
|
||||
expect(firstStructuredAgentSessionPrompt([message('?!')])).toBe('')
|
||||
})
|
||||
|
||||
it('retains a URL-only first request', () => {
|
||||
const url = 'https://example.test/issues/123'
|
||||
expect(firstStructuredAgentSessionPrompt([message(url), message('Later')])).toBe(url)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalSubmissionKey } from '../journal/agent-session-journal-item-key'
|
||||
import {
|
||||
structuredAgentSessionSendOpeningTurn,
|
||||
type StructuredAgentSessionOpeningSendItem
|
||||
} from './structured-agent-session-opening-send'
|
||||
|
||||
const send = {
|
||||
clientMessageId: 'send-1',
|
||||
dispatchState: 'pending',
|
||||
handedOverAt: 10,
|
||||
fence: 1
|
||||
} as const
|
||||
const handover: StructuredAgentSessionOpeningSendItem = {
|
||||
itemId: agentJournalSubmissionKey('send-1'),
|
||||
sequence: 5,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] },
|
||||
turnScope: { kind: 'thread' }
|
||||
}
|
||||
const turnRecord = (sequence: number): StructuredAgentSessionOpeningSendItem => ({
|
||||
itemId: `turn-${sequence}`,
|
||||
sequence,
|
||||
body: { kind: 'turn', turnId: 'turn-1', state: 'running' }
|
||||
})
|
||||
|
||||
function opening(items: StructuredAgentSessionOpeningSendItem[]): boolean {
|
||||
return structuredAgentSessionSendOpeningTurn([send], (visit) => items.forEach(visit), 1)
|
||||
}
|
||||
|
||||
describe('a send still opening its turn', () => {
|
||||
it('holds the next message until a turn record follows its handover', () => {
|
||||
expect(opening([handover])).toBe(true)
|
||||
expect(opening([handover, turnRecord(6)])).toBe(false)
|
||||
})
|
||||
|
||||
// A Stop the provider took before the turn opened withdraws the send: nothing is opening.
|
||||
it('ends once the send is settled, with no turn record', () => {
|
||||
const withdrawn = { ...send, dispatchState: 'rejected' } as const
|
||||
expect(structuredAgentSessionSendOpeningTurn([withdrawn], (visit) => visit(handover), 1)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -34,10 +34,6 @@ function message(role: 'user' | 'assistant', from?: unknown): AgentJournalRender
|
||||
}
|
||||
|
||||
describe("a message's sender, projected for every client", () => {
|
||||
it('carries the sender of a user-role message another agent sent', () => {
|
||||
expect(projectStructuredItemToNativeChat(message('user', FROM))?.from).toEqual(FROM)
|
||||
})
|
||||
|
||||
it("leaves the person's message, and a value no reader can place, without one", () => {
|
||||
expect(projectStructuredItemToNativeChat(message('user'))).not.toHaveProperty('from')
|
||||
expect(projectStructuredItemToNativeChat(message('user', 'nobody'))).not.toHaveProperty('from')
|
||||
|
||||
@@ -47,21 +47,6 @@ function thrown(rpcCode: string | undefined): StructuredAgentSessionSendAnswer {
|
||||
}
|
||||
|
||||
describe('structuredAgentSessionSendEvidence', () => {
|
||||
it('reads a row the host returns, in any state, as its own from then on', () => {
|
||||
for (const dispatchState of ['pending', 'accepted', 'rejected', 'unknown'] as const) {
|
||||
expect(structuredAgentSessionSendEvidence(withRow({ dispatchState })).kind).toBe('recorded')
|
||||
}
|
||||
// A restart lost its outcome, or a queued card's hand-off: a record, never a probe.
|
||||
const rows: Partial<AgentJournalSubmission>[] = [
|
||||
{ dispatchState: 'unknown', recovered: true, reason: 'host_restarted' },
|
||||
{ dispatchState: 'rejected', clientMessageId: 'other', queuedMessageId: 'm' },
|
||||
{ dispatchState: 'rejected', keptAsQueuedMessageId: 'm' }
|
||||
]
|
||||
for (const row of rows) {
|
||||
expect(structuredAgentSessionSendEvidence(withRow(row)).kind).toBe('recorded')
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a made-up row for an id the host journal lost as unconfirmed, not recorded', () => {
|
||||
expect(
|
||||
structuredAgentSessionSendEvidence(
|
||||
@@ -104,18 +89,6 @@ describe('structuredAgentSessionSendEvidence', () => {
|
||||
).toBe('uncertain')
|
||||
})
|
||||
|
||||
// Pins the full turned-away list (round-2 S5): every other code, client-minted ones included, is uncertain.
|
||||
it('treats a call the host turned away before running it as never written', () => {
|
||||
for (const rpcCode of [
|
||||
'method_not_found',
|
||||
'method_not_supported',
|
||||
'invalid_argument',
|
||||
'unauthorized'
|
||||
]) {
|
||||
expect(structuredAgentSessionSendEvidence(thrown(rpcCode)).kind).toBe('not-recorded')
|
||||
}
|
||||
})
|
||||
|
||||
it('reads an unknown outcome or a lost result as unconfirmed, and an unconfirmed rewind as not sent', () => {
|
||||
for (const reason of ['outcomeUnknown', 'resultLost']) {
|
||||
expect(
|
||||
@@ -128,17 +101,4 @@ describe('structuredAgentSessionSendEvidence', () => {
|
||||
).kind
|
||||
).toBe('not-recorded')
|
||||
})
|
||||
|
||||
// The one request is the only one that carried the id, so a refusal of it proves nothing ran.
|
||||
it('reads any other refusal as never written', () => {
|
||||
for (const answer of [
|
||||
refused('agent_session_ownership_unknown', 'sessionNotAttached'),
|
||||
refused('agent_session_conflict'),
|
||||
refused('agent_session_operation_capacity'),
|
||||
refused('agent_session_journal_unreadable'),
|
||||
refused('agent_session_operation_expired', 'operationExpired')
|
||||
]) {
|
||||
expect(structuredAgentSessionSendEvidence(answer).kind).toBe('not-recorded')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalMessageItem } from '../journal/agent-session-journal-types'
|
||||
import type { AgentMessageSource } from '../agent-session-message-source'
|
||||
import { AgentJournalSubmissionSchema } from '../journal/agent-session-journal-submission-schema'
|
||||
import '../journal/agent-session-journal-submission-schema'
|
||||
import { SendParams } from '../../rpc-contract/structured-agent-session-params'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../lifecycle/structured-agent-session-mutation'
|
||||
import {
|
||||
@@ -27,24 +27,6 @@ const FROM: AgentMessageSource = {
|
||||
}
|
||||
|
||||
describe('send fingerprints never cover the sender', () => {
|
||||
it('reads a saved submission’s choices, ignoring keys an older build stored', () => {
|
||||
const submission = {
|
||||
clientMessageId: 'message-1',
|
||||
options: { model: 'model-a', unknownChoice: 'kept-by-an-older-build' },
|
||||
fence: 1,
|
||||
payloadFingerprint: 'a'.repeat(64),
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 2
|
||||
}
|
||||
expect(AgentJournalSubmissionSchema.parse(submission).options).toEqual({ model: 'model-a' })
|
||||
expect(
|
||||
AgentJournalSubmissionSchema.omit({ options: true }).parse(submission)
|
||||
).not.toHaveProperty('options')
|
||||
})
|
||||
|
||||
it('keeps the digest every stored submission and draft already holds', () => {
|
||||
// Pinned bytes: a stored fingerprint that changed would stop matching its provider echo.
|
||||
expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe(
|
||||
|
||||
@@ -4,4 +4,3 @@ import './commit-message-omp.spec'
|
||||
import './antigravity-source-control.spec'
|
||||
import './conversation-name-action.spec'
|
||||
import './source-control-ai-action-variables.spec'
|
||||
import './agent-model-probe-spec.spec'
|
||||
|
||||
@@ -6,5 +6,4 @@ import './agent-session/requests/structured-agent-session-first-prompt.spec'
|
||||
import './agent-session/journal/agent-session-journal-submission-schema.spec'
|
||||
import './agent-session/requests/structured-agent-session-projection-sender.spec'
|
||||
import './agent-session/lifecycle/structured-agent-session-mutation.spec'
|
||||
import './agent-session/options/structured-agent-session-option-codec.spec'
|
||||
import './agent-session/structured-agent-session-first-frame-picker.spec'
|
||||
|
||||
Reference in New Issue
Block a user