[comment audit] Consolidate completed code comment cleanup

Combine the completed comment cleanup from 319 stacked PRs into one change against `main`, so the full CI suite validates the combined result once. This replaces the previous audit stack; no additional audit work is included.

The change shortens redundant comments across 2,753 source and shell-snapshot files (25,235 net lines removed). Conflicts preserve current-main executable code. Runtime behavior, test assertions, directives, licenses, generated notices, SAFETY rationales, and compatibility markers are preserved. The audit tracker remains local, outside the repository.

Validation:
- Equivalent source/emitted AST and executable tokens across all 2,750 source files, with separate checks for embedded shell, SQL, JavaScript, CSS, and generated fixtures.
- Identical executable lines in the three shell snapshots; protected comments unchanged.
- Changed-code quality gate passed with no new findings.
- 358 tests passed, 2 skipped across 23 focused test files.
- `git diff --check` passed.
This commit is contained in:
OrcaWin
2026-10-23 13:15:45 -07:00
committed by GitHub
parent b090d5a04d
commit 2847bbe9e8
2753 changed files with 9277 additions and 34512 deletions
@@ -3,8 +3,7 @@ _orca_shell_features=",${ORCA_SHELL_FEATURES:-},"
builtin unset ORCA_SHELL_FEATURES
__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
# Why a plain variable: the channel is consumed and destroyed in these first
# lines, so nothing this shell later spawns can see or inherit the selection.
# Consume the feature channel before profiles so child processes cannot inherit the selection.
__orca_ready_marker=""
__orca_has_feature ready && __orca_ready_marker=1
unset _orca_shell_features
@@ -17,10 +16,7 @@ elif [[ -f "$HOME/.bash_login" ]]; then
elif [[ -f "$HOME/.profile" ]]; then
source "$HOME/.profile"
fi
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
# treat each embedded newline as Enter and mangle the prompt into PS2
# continuation. Modern readline defaults this on; force it for the rest.
# Force bracketed paste on older readline so multiline startup prompts remain one literal paste.
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
__orca_restore_agent_teams_path() {
[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
@@ -36,8 +32,7 @@ if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
# Restore the managed OpenCode config after user profiles may have replaced it.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then
export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}"
@@ -110,7 +105,7 @@ if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
function omp { __orca_omp "$@"; }
fi
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
# Preserve the managed CODEX_HOME after user profiles.
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
# Why: a typed alias expands inside the shell, after pane launch prep.
# Why unalias inside the substitution: an alias named codex makes command -v
@@ -174,10 +169,7 @@ if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" &&
fi
unset __orca_claude_binary
# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent
# status when the foreground command exits — mirrors the zsh daemon wrapper.
# Without this, bash users (default on most Linux distros) keep a stuck
# 'working' spinner after the CLI exits without a Stop/SessionEnd hook.
# OSC 133 C/D clears stale agent status when a CLI exits without a stop hook.
__orca_initializing_wrapper=1
__orca_osc133_precmd() {
local exit_code=$?
@@ -224,22 +216,17 @@ __orca_osc133_preexec() {
__bp_*) [[ -n "${__orca_in_prompt_command:-}" ]] && return 0 ;;
esac
__orca_run_user_debug_trap
# Why: a framework (bash-preexec/starship) may replace our DEBUG trap at the
# first prompt; __orca_osc133_epilogue re-takes it each prompt and stores the
# framework's trap here, so the framework's own preexec still runs while our
# command-start C survives its re-arm.
# Chain the framework trap captured before each prompt's lifecycle re-arm.
if [[ -n "${__orca_chained_debug_trap:-}" ]]; then
eval "$__orca_chained_debug_trap" || true
fi
[[ -z "${__orca_in_prompt_command:-}" ]] || return 0
# Why: a chained trap can invoke us more than once for a single command, so
# emit C only on the first fire (the __orca_in_command gate), and never for a
# prompt-time hook — ours or bash-preexec's __bp_* helpers.
# Emit C once per foreground command, excluding prompt hooks and repeated chained-trap calls.
[[ -z "${__orca_in_command:-}" ]] || return 0
printf "\033]133;C\007"
__orca_in_command=1
}
# Why: adopt the latest user trap before Orca retakes lifecycle ownership.
# Adopt the latest user trap before reclaiming lifecycle ownership.
__orca_osc133_epilogue() {
unset __orca_in_prompt_command
__orca_adopt_outer_debug_trap
@@ -445,8 +432,7 @@ fi
unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace
unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
unset __orca_prompt_command_normalized
# Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile
# commands as a foreground command and emits a fake C/D before the first prompt.
# Arm DEBUG after setup so rcfile commands cannot emit a fake lifecycle before the first prompt.
__orca_initial_prompt=1
trap '__orca_osc133_preexec' DEBUG
unset __orca_initializing_wrapper
@@ -1,12 +1,9 @@
# Orca daemon zsh shell-ready wrapper
__orca_usable_zdotdir() {
[[ -n "${1:-}" ]] || return 1
# Orca's own dir, by marker file or by the shape older builds wrote.
[[ "$1" != */shell-ready/zsh ]] || return 1
[[ ! -f "$1/.orca-shell-wrapper" ]] || return 1
# A directory holding no zsh startup file at all is not a config root,
# whoever wrote it — and a stale value pointing at one would stop zsh from
# ever reading the user's real .zshenv.
# Reject empty config roots so stale wrapper state cannot hide the user's real .zshenv.
local _orca_startup_file
for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do
[[ -r "$1/$_orca_startup_file" ]] && return 0
@@ -23,10 +20,7 @@ builtin unfunction __orca_usable_zdotdir
builtin typeset -ga _orca_shell_features
_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}})
builtin unset ORCA_SHELL_FEATURES
# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config
# that replaces precmd_functions wholesale drops the hook, and an exported value
# nothing will ever consume is then inherited by every child of this pane,
# including a nested Orca (#11146). Captured non-exported, it cannot escape.
# Consume now: user config can drop the deferred hook, leaving exported history state to leak into children.
builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
builtin unset ORCA_HISTFILE
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
@@ -41,8 +35,7 @@ __orca_osc133_precmd() {
}
__orca_osc133_preexec() {
builtin printf "\033]133;C\007"
# Why typeset -g: a plain assignment here creates a global inside a function,
# which prints a warning above every command under warn_create_global.
# Declare explicitly to avoid warn_create_global output above every command.
builtin typeset -g __orca_in_command=1
}
__orca_deferred_line_init() {
@@ -58,7 +51,7 @@ __orca_deferred_line_init() {
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
# Run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
@@ -80,10 +73,7 @@ __orca_arm_deferred_line_init() {
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
# precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup
# drop whichever feature is listed first.
# Reset user options: NO_UNSET can abort hooks and KSH_ARRAYS breaks the feature lookup.
builtin emulate -L zsh
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
@@ -191,7 +181,7 @@ __orca_deferred_init() {
function omp { __orca_omp "$@"; }
fi
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
# Restore the runtime CODEX_HOME after user rc files.
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
fi
# Why: a typed alias expands inside the shell, after pane launch prep.
@@ -259,12 +249,7 @@ __orca_deferred_init() {
HISTFILE="$_orca_histfile"
fi
if __orca_has_feature ready; then
# Why: capture the prior zle-line-init so the marker chains to it. On a
# re-source we are already the bound widget, so keep the function captured
# the first time instead of clobbering it to empty (which would silently
# drop the user's widget on every prompt after the second source). Only
# user-defined widgets are chainable as plain functions; builtin/completion
# forms (rare for zle-line-init) are left unchained.
# Preserve the original user widget across re-sourcing; builtin/completion widgets cannot chain as plain functions.
if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then
:
elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then
@@ -274,16 +259,14 @@ __orca_deferred_init() {
fi
__orca_prompt_mark() {
printf "\033]777;orca-shell-ready\007"
# Why: call the prior hook as a plain function, not an aliased widget, so
# $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers.
# Call as a function so $WIDGET remains zle-line-init for add-zle-hook-widget dispatchers.
if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then
"${__orca_prev_line_init_fn}" "$@"
fi
}
zle -N zle-line-init __orca_prompt_mark
fi
# Why called here: we were appended during this prompt's own precmd sweep, so
# the permanent hook has not run yet and the first prompt would lose its mark.
# The permanent hook was added during this sweep; run it now so the first prompt gets its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
@@ -1,12 +1,9 @@
# Orca zsh shell-ready wrapper
__orca_usable_zdotdir() {
[[ -n "${1:-}" ]] || return 1
# Orca's own dir, by marker file or by the shape older builds wrote.
[[ "$1" != */shell-ready/zsh ]] || return 1
[[ ! -f "$1/.orca-shell-wrapper" ]] || return 1
# A directory holding no zsh startup file at all is not a config root,
# whoever wrote it — and a stale value pointing at one would stop zsh from
# ever reading the user's real .zshenv.
# Reject empty config roots so stale wrapper state cannot hide the user's real .zshenv.
local _orca_startup_file
for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do
[[ -r "$1/$_orca_startup_file" ]] && return 0
@@ -23,10 +20,7 @@ builtin unfunction __orca_usable_zdotdir
builtin typeset -ga _orca_shell_features
_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}})
builtin unset ORCA_SHELL_FEATURES
# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config
# that replaces precmd_functions wholesale drops the hook, and an exported value
# nothing will ever consume is then inherited by every child of this pane,
# including a nested Orca (#11146). Captured non-exported, it cannot escape.
# Consume now: user config can drop the deferred hook, leaving exported history state to leak into children.
builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
builtin unset ORCA_HISTFILE
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
@@ -41,8 +35,7 @@ __orca_osc133_precmd() {
}
__orca_osc133_preexec() {
builtin printf "\033]133;C\007"
# Why typeset -g: a plain assignment here creates a global inside a function,
# which prints a warning above every command under warn_create_global.
# Declare explicitly to avoid warn_create_global output above every command.
builtin typeset -g __orca_in_command=1
}
__orca_deferred_line_init() {
@@ -58,7 +51,7 @@ __orca_deferred_line_init() {
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
# Run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
@@ -80,10 +73,7 @@ __orca_arm_deferred_line_init() {
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
# precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup
# drop whichever feature is listed first.
# Reset user options: NO_UNSET can abort hooks and KSH_ARRAYS breaks the feature lookup.
builtin emulate -L zsh
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
@@ -198,7 +188,7 @@ __orca_deferred_init() {
function omp { __orca_omp "$@"; }
fi
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
# Restore the runtime CODEX_HOME after user rc files.
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
fi
# Why: a typed alias expands inside the shell, after pane launch prep.
@@ -268,12 +258,7 @@ __orca_deferred_init() {
if __orca_has_feature ready || __orca_has_feature startup; then
__orca_emit_ready_marker=""
__orca_has_feature ready && __orca_emit_ready_marker=1
# Why: capture the prior zle-line-init so the marker chains to it. On a
# re-source we are already the bound widget, so keep the function captured
# the first time instead of clobbering it to empty (which would silently
# drop the user's widget on every prompt after the second source). Only
# user-defined widgets are chainable as plain functions; builtin/completion
# forms (rare for zle-line-init) are left unchained.
# Preserve the original user widget across re-sourcing; builtin/completion widgets cannot chain as plain functions.
if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then
:
elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then
@@ -285,8 +270,7 @@ __orca_deferred_init() {
if [[ "${__orca_emit_ready_marker-1}" == 1 ]]; then
printf "\033]777;orca-shell-ready\007"
fi
# Why: call the prior hook as a plain function, not an aliased widget, so
# $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers.
# Call as a function so $WIDGET remains zle-line-init for add-zle-hook-widget dispatchers.
if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then
"${__orca_prev_line_init_fn}" "$@"
fi
@@ -299,8 +283,7 @@ __orca_deferred_init() {
}
zle -N zle-line-init __orca_prompt_mark
fi
# Why called here: we were appended during this prompt's own precmd sweep, so
# the permanent hook has not run yet and the first prompt would lose its mark.
# The permanent hook was added during this sweep; run it now so the first prompt gets its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
+4 -12
View File
@@ -1,6 +1,3 @@
// How an ACP agent's chats pin their account: what a new chat records, and how a launch points the
// agent at the account its record pinned. Both run on the machine that runs the agent.
import { chmod, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import {
@@ -12,21 +9,18 @@ import { resolveStructuredEnvAccountHomePath } from '../runtime/structured-agent
type AccountPin = { accountHomeVariable: string } | { accountLocatorKind: 'opencode' }
export type AcpAccountBinding = {
/** What the agent's definition says its records pin. */
pin: AccountPin
/** The account a new chat pins: the current selection, read without side effects. */
/** Resolves the current selection without side effects. */
resolve(input: {
launchEnv: NodeJS.ProcessEnv
stateDirectory: string
}): Promise<AgentSessionAccountHome>
/** `env` pointed at the pinned account; throws for an account of another kind. */
/** Throws if the pinned account belongs to another kind. */
environment(home: AgentSessionAccountHome, env: Record<string, string>): Record<string, string>
/** Readies the account a start's `env` points at, before every spawn; reads never call it. */
/** Runs before every spawn, never on reads. */
prepare?(env: Readonly<Record<string, string>>): Promise<void>
}
/** An agent whose account is one config directory named by `variable`, with a default under the
* user's home. */
export function directoryAccountBinding(
variable: string,
defaultHome: (homePath: string) => string
@@ -46,9 +40,7 @@ export function directoryAccountBinding(
}
}
/** An agent whose account is a folder Orca keeps for it on each host, which the agent signs in to
* itself. It keeps that sign-in there in plain text, so every start makes the folder and
* `privateSubfolders` owner-only, whatever made them first. */
/** Restricts plaintext sign-in folders to their owner before every spawn. */
export function hostFolderAccountBinding(
variable: string,
folderName: string,
+8 -12
View File
@@ -10,30 +10,26 @@ import { AcpConnectionClosedError, AcpSignInPromptError } from './acp-errors'
import { resolveAcpPeerOptions, type AcpPeerOptions } from './acp-peer-limits'
import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime'
/** How an agent that signs in through a browser says so on its output. */
export type AcpSignInWatch = {
/** A sign-in line's valid link (the connection ends signed out), null for a malformed one,
* undefined for any other line. */
/** Returns a valid link, null for a malformed sign-in line, or undefined for other output. */
read(line: string, source: 'stdout' | 'stderr'): string | null | undefined
/** The agent's answer on its sign-in: a session started, or it asked to sign in. */
answered?(signedIn: boolean): void
/** Receives each valid link instead, and the connection stays open for the sign-in to finish. */
/** Keeps the connection open for sign-in instead of closing it signed out. */
link?(link: string): void
}
// A full sign-in link with its framing; the stderr tail kept for diagnostics stays smaller.
// Allow full sign-in links while keeping the diagnostic tail smaller.
const SIGN_IN_LINE_MAX_CHARS = 32_768
export type AcpAgentConnectionOptions = Omit<AcpSessionRuntimeOptions, 'peer'> & {
peer?: Omit<AcpPeerOptions, 'closeOnInputEnd'>
/** Process exit evidence, including expected closes and processless spawn failures. */
onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void
/** Any stdout or stderr chunk from the child. */
onOutput?: () => void
signIn?: AcpSignInWatch
}
/** The execution host owns the child and protocol lifetime; the adapter owns turns and Stop. */
/** The host owns process lifetime; the adapter owns turns and Stop. */
export function createAcpAgentConnection(
launch: ProviderProcessLaunch,
options: AcpAgentConnectionOptions = {},
@@ -53,12 +49,12 @@ export class AcpAgentConnection extends AcpSessionRuntime {
options: AcpAgentConnectionOptions = {},
spawnImpl: PipedProcessSpawner = spawnProcess
) {
// Validate before spawning so invalid limits cannot leave an unowned child.
// Invalid limits must fail before spawning an unowned child.
const peer = resolveAcpPeerOptions({ ...options.peer, closeOnInputEnd: false })
const { signIn } = options
let signedOut = (): void => {}
let unreadable = (): void => {}
// A sign-in line is never kept or logged; only a whole, valid one is an answer.
// Never retain sign-in links in diagnostics.
const signInLine = (line: string, source: 'stdout' | 'stderr', cut = false): boolean => {
const link = signIn?.read(line, source)
if (link === undefined) {
@@ -137,7 +133,7 @@ export class AcpAgentConnection extends AcpSessionRuntime {
signIn?.answered?.(false)
super.close(new AcpSignInPromptError('The agent asked to sign in in a browser'))
}
// A broken sign-in line on the protocol stream is a broken agent, not a sign-in answer.
// A malformed protocol-stream link closes the connection without reporting signed-out status.
unreadable = () =>
super.close(new AcpConnectionClosedError('The agent printed a sign-in link Orca cannot read'))
child.stderr.on('error', (error) => super.close(error))
@@ -166,7 +162,7 @@ export class AcpAgentConnection extends AcpSessionRuntime {
return this.managed.rootVerdict
}
/** Retained evidence from the last close attempt; a null tree means no observation. */
/** Null tree means no observation. */
get lastCloseResult(): Readonly<ManagedProviderProcess['lastCloseResult']> {
return this.managed.lastCloseResult
}
@@ -7,14 +7,13 @@ import { isBackgroundTaskBlock } from '../../shared/native-chat-types'
import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards'
import type { AcpTimelineEvent } from './acp-timeline-event'
/** Keeps a task's handle apart from a subagent's, so a Stop knows which route it takes. */
// Keep task Stop handles distinct from subagent handles.
const TASK_ID_PREFIX = 'acp-task:'
function acpBackgroundTaskHandle(taskId: string): AgentChildWorkEvidenceHandle {
return { idKind: 'task_id', id: `${TASK_ID_PREFIX}${taskId}` }
}
/** The agent's own task id behind a child handle, when the handle names a background task. */
export function acpBackgroundTaskStopId(childId: string): string | undefined {
return childId.startsWith(TASK_ID_PREFIX) ? childId.slice(TASK_ID_PREFIX.length) : undefined
}
@@ -29,7 +28,6 @@ export function isAcpBackgroundTaskChildWorkEvent(event: AcpTimelineEvent): bool
return backgroundTaskBlocks(event).length > 0
}
/** A running task is a live record until it ends, and then its record goes, like a command's. */
export function acpBackgroundTaskChildWork(
event: AcpTimelineEvent,
observedAt: number,
@@ -37,7 +35,6 @@ export function acpBackgroundTaskChildWork(
): AgentChildWorkEvidence[] {
return backgroundTaskBlocks(event).flatMap((block): AgentChildWorkEvidence[] => {
const handle = acpBackgroundTaskHandle(block.taskId)
// A handle too long to record could not address the agent's own task either.
if (handle.id.length > AGENT_CHILD_WORK_ID_MAX_LENGTH) {
return []
}
+1 -3
View File
@@ -10,9 +10,7 @@ import type {
} from '../native-chat/agent-session-timeline/provider-timeline-event'
import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect'
/** Partial provider snapshots; the assembler owns settlement and durable placement. A running
* task's snapshot lives until it settles. Settled ones are history, bounded, so a later ending
* (after a Stop found it gone) keeps its label; one evicted keeps only its row's turn. */
/** Retain running snapshots and bounded settled labels; the assembler owns durable placement. */
export class AcpBackgroundTaskTimeline {
private readonly running = new Map<string, NativeChatBackgroundTaskBlock>()
private readonly settled = new BoundedMap<string, NativeChatBackgroundTaskBlock>({
+6 -16
View File
@@ -1,6 +1,4 @@
// A `/compact` Orca sends as a prompt, run as the command turn the host opened. What the agent
// writes meanwhile is the command's output, read for how it went and never drawn; its answer ends
// the turn with one result row, as Claude's and Codex's compactions end.
// Consume /compact output without rendering it; end the command turn with one result row.
import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
@@ -10,19 +8,16 @@ import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/s
import type { AcpDialect } from './acp-dialects/acp-dialect'
import type { ContentBlock, SessionUpdate } from './generated/acp-protocol.generated'
/** The prompt every compacting ACP agent takes as its own compaction command. */
export const ACP_COMPACT_PROMPT: readonly ContentBlock[] = [{ type: 'text', text: '/compact' }]
/** What a running compaction has said so far. */
export type AcpCompaction = { reply: string; failureDetail?: string }
/** The parts of a dialect's frame a running compaction reads. */
export type AcpCompactionFrame = {
failureDetail?: string
end?: { durationMs?: number; failureDetail?: string }
}
/** Reads the running compaction's reply text; true for an update that is its output. */
/** Returns true when the update belongs to the compaction. */
export function readAcpCompactionUpdate(compaction: AcpCompaction, update: SessionUpdate): boolean {
if (update.sessionUpdate === 'agent_message_chunk') {
if (update.content.type === 'text') {
@@ -33,8 +28,7 @@ export function readAcpCompactionUpdate(compaction: AcpCompaction, update: Sessi
return update.sessionUpdate === 'agent_thought_chunk'
}
/** Folds a frame of the running compaction into it: its words and end are read for the result its
* answer writes. True for a frame that is the compaction's own, which draws nothing. */
/** Absorbs compaction output; true suppresses rendering the frame. */
export function absorbAcpCompactionFrame(
prompt: { compaction?: AcpCompaction; durationMs?: number },
extension: AcpCompactionFrame | undefined,
@@ -58,7 +52,6 @@ export function absorbAcpCompactionFrame(
return update !== undefined && readAcpCompactionUpdate(compaction, update)
}
/** The compaction's result row and its turn's end, from the agent's answer. */
export function acpCompactionEnd(input: {
compaction: AcpCompaction
turn: string
@@ -66,15 +59,13 @@ export function acpCompactionEnd(input: {
stopReason: string
at: number
durationMs?: number
/** Words the answer itself carried, for one that failed. */
failureDetail?: string
/** The answer said the agent is not signed in. */
notSignedIn?: boolean
dialect: AcpDialect
agentName?: string
}): ProviderTimelineEvent[] {
const { compaction, turn, stopReason } = input
// The host's command turn is the assembler's open turn, which no provider key names.
// The host's command turn has no provider key.
const join = { thread: input.thread }
const end = (outcome: 'success' | 'failure' | 'cancellation'): ProviderTimelineEvent => ({
type: 'turn.end',
@@ -86,7 +77,6 @@ export function acpCompactionEnd(input: {
const reply =
stopReason === 'end_turn' ? input.dialect.compactionReply?.(compaction.reply) : undefined
if (reply?.outcome === 'skipped') {
// Nothing to compact is no failure: the turn succeeds and says why nothing changed.
const text = providerDiagnostic(reply.detail, 'person')?.text ?? 'Nothing to compact'
return [
{
@@ -106,7 +96,7 @@ export function acpCompactionEnd(input: {
const words = reply?.detail ?? input.failureDetail ?? compaction.failureDetail
const detail = words === undefined ? undefined : providerDiagnostic(words, 'person')
const verdict = structuredCompactionOutcome({
// An agent that ends `/compact` normally compacted, unless its reply says otherwise.
// Normal completion implies compaction unless the reply says otherwise.
compacted: stopReason === 'end_turn' && reply === undefined,
interruptRequested: stopReason === 'cancelled',
failed: detail ? { detail } : {}
@@ -114,7 +104,7 @@ export function acpCompactionEnd(input: {
if (verdict.outcome === 'cancellation') {
return [end('cancellation')]
}
// The same fact a signed-out send records, so its guidance shows and the child is replaced.
// Use signed-out guidance and replace the child, as for a signed-out send.
const failure =
verdict.failure && input.notSignedIn
? agentSessionFailureFact('notSignedIn', detail ? { detail } : {})
+1 -1
View File
@@ -31,7 +31,7 @@ export class AcpContextTimeline {
]
}
/** Replayed history is dropped except what it says about the context window. */
/** Retain only context-window information from replayed history. */
history(
update: SessionUpdate | undefined,
usage: AgentSessionContextUsage | undefined,
+1 -2
View File
@@ -5,7 +5,7 @@ import { copyAcpConversation } from './acp-conversation-copy'
import type { AcpStructuredConnection } from './acp-structured-connection'
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
/** Copy children have their own cleanup lifecycle; they never gate the original chat's start. */
/** Copy children never gate the original chat's start. */
export class AcpConversationCopies {
private readonly children = new ProviderAcquisitionStarts<AcpStructuredConnection>()
private readonly shutdown = new AbortController()
@@ -14,7 +14,6 @@ export class AcpConversationCopies {
constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {}
copy: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = async (input) => {
// A copy runs the agent too, so it is fenced like a chat start.
const gate = this.deps.spec.launchGate?.enter(this.shutdown.signal)
const copy = copyAcpConversation(this.deps, input, {
signal: gate?.signal ?? this.shutdown.signal,
@@ -154,7 +154,6 @@ describe('copying a chat conversation for the CLI', () => {
}
})
// A copy lands in this host's OpenCode store; a CLI sent to a server would not find it.
it.each(['opencode', 'opencode2'] as const)(
'refuses to copy a %s chat whose arguments route to a server, before starting OpenCode',
async (agent) => {
+1 -2
View File
@@ -1,6 +1,5 @@
import { normalizeAgentProviderSession } from '../../shared/agent-session-resume'
import { OpenCodeCopyServerRouteError } from '../../shared/opencode-launch-command'
// The chat protocol saves an independent conversation; its ordinary CLI opens that copy.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import {
agentSessionAccountHomesEqual,
@@ -132,7 +131,7 @@ async function forkAdvertised(
return connection.forkSession(sessionId, cwd)
}
/** Rederive selection at the fork boundaries; ordinary terminals still own their normal routing. */
/** Recheck the selected account at fork boundaries. */
async function assertCopyAccountSelected(
{ spec, dataDirectory }: Pick<AcpStructuredSessionAdapterDeps, 'spec' | 'dataDirectory'>,
home: AgentSessionAccountHome,
+12 -29
View File
@@ -15,31 +15,24 @@ export type AcpRequestPresentation = {
reply(response: AgentSessionPromptResponse | null): unknown
}
/** A request the client answers at once, asking no one: the reply, and a plan it carried for the
* chat's existing plan row. */
export type AcpRequestSettlement = { reply: unknown; plan?: string }
export type AcpBackgroundTaskUpdate = Pick<NativeChatBackgroundTaskBlock, 'taskId' | 'state'> &
Partial<Omit<NativeChatBackgroundTaskBlock, 'type' | 'taskId' | 'state'>> & {
/** Used only until a provider description is known. */
fallbackLabel?: string
/** Used only until a frame names the task's kind. */
fallbackKind?: NativeChatBackgroundTaskBlock['kind']
}
/** What the provider said about one subagent; absent fields are unchanged. */
/** Omitted fields leave existing subagent state unchanged. */
export type AcpSubagentUpdate = {
/** The provider's subagent id: the roster key, and where its own rows are filed. */
id: string
state?: NativeChatSubagentState
label?: string
/** Latest total tokens the provider reported for this subagent. */
tokens?: number
/** The provider turn that spawned it. */
/** Spawning provider turn. */
turn?: string
/** The subagent's final reply, once it completed. */
result?: string
/** For a report that cannot tell a subagent from a background task: applies only to a known one. */
/** Ambiguous task reports apply only to known subagents. */
knownOnly?: true
}
@@ -52,17 +45,15 @@ export type AcpDialectNotification =
at?: number
started?: boolean
end?: Omit<AcpTurnEnding, 'at' | 'notSignedIn'>
/** The provider's own words for why `turn` failed, sent apart from its end. */
failureDetail?: string
usage?: AgentSessionContextUsage
backgroundTasks?: AcpBackgroundTaskUpdate[]
subagents?: AcpSubagentUpdate[]
}
/** A prompt hook refused the message: the turn ended, but the user did not stop it. */
/** Hook denial completes the turn rather than recording a user Stop. */
export type AcpHookBlock = { reason?: string }
/** How a turn ended, as the agent reported it. */
export type AcpTurnEnding = {
stopReason: string
at: number
@@ -72,29 +63,29 @@ export type AcpTurnEnding = {
hookBlock?: AcpHookBlock
}
/** How a `/compact` the agent ended normally went, read from its reply; absent compacted. */
/** Undefined means a normally ended /compact succeeded. */
export type AcpCompactionReply = { outcome: 'skipped' | 'failed'; detail: string }
/** Hooks interpret extensions; lifecycle and row identity stay shared. */
export type AcpDialect = {
subagentStop?: {
/** Missing required target: proves the route exists without addressing any child. */
/** Probe without a target to establish support without cancelling a child. */
probe: { method: string; params: unknown }
recognizesProbeError(error: AcpAgentError): boolean
request(sessionId: string, id: string): { method: string; params: unknown }
response(value: unknown, id: string): { cancelled: boolean; state?: NativeChatSubagentState }
}
backgroundTaskStop?: {
/** Missing required target: proves the route exists without addressing any task. */
/** Probe without a target to establish support without killing a task. */
probe: { method: string; params: unknown }
/** The agent refusing the route by name: the only answer that hides Stop. */
/** Only explicit route rejection hides Stop. */
lacksRoute(error: AcpAgentError): boolean
request(sessionId: string, taskId: string): { method: string; params: unknown }
/** `gone`: the agent no longer runs a task by that id; its own ending may still follow. */
/** A missing task may still report its own ending later. */
response(value: unknown, taskId: string): 'killed' | 'gone' | 'refused'
}
injectedPromptIdentity?: true
/** A tool update in the shared shape (`rawOutput.stdout`, `rawOutput.exitCode`), read first. */
/** Normalize before reading tools: rawOutput.stdout and rawOutput.exitCode. */
normalizeToolUpdate?(update: ToolCallUpdate): ToolCallUpdate
toolName?(update: ToolCallUpdate): string | undefined
toolBackgroundTasks?(
@@ -106,27 +97,19 @@ export type AcpDialect = {
subagentSessionEnd?(method: string, params: unknown): 'completed' | 'stopped' | undefined
notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined
contextWindow?(models: unknown): number | undefined
/** A model's own effort menu and default, as the agent advertises them per model. */
modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string }
/** A request shown to the person; a permission request it claims is never auto-answered. */
/** Claimed permission requests are never auto-approved. */
request?(method: string, params: unknown): AcpRequestPresentation | undefined
/** Requests answered at once instead of shown to the person. */
settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined
/** The provider's words in a `session/prompt` error answer, when its message is generic. */
promptErrorDetail?(error: AcpAgentError): string | undefined
authenticationRequired?(error: AcpAgentError): boolean
/** A hook's refusal, read from the `_meta` of the prompt's answer or of the turn's end. */
hookBlock?(meta: unknown): AcpHookBlock | undefined
/** The row for a failed turn the provider gave no words for. */
failedTurnText?(stopReason: string): string
/** An agent that ends a `/compact` it did not do as a normal turn says so in its reply. */
compactionReply?(text: string): AcpCompactionReply | undefined
/** The agent's own `/` commands a chat does not offer: hidden from its list, and a message
* that is only one of them is refused before it reaches the agent. */
/** Hide these commands and reject command-only prompts before dispatch. */
withheldCommands?: readonly string[]
}
/** Which of the dialect's stop routes this agent process proved it answers. */
export type AcpChildStops = { subagents: boolean; backgroundTasks: boolean }
export const GENERIC_ACP_DIALECT: AcpDialect = {}
@@ -6,8 +6,7 @@ import { acpPermissionPresentation, pendingAcpResolution } from '../acp-timeline
import type { ToolCallUpdate } from '../generated/acp-protocol.generated'
import type { AcpDialect, AcpRequestPresentation } from './acp-dialect'
// Antigravity asks the person a question as a permission request on an `interaction_` tool call,
// one choice per option, sent even in its yolo mode: a question, never an approval to auto-allow.
// interaction_ permissions are questions, even in yolo mode; never auto-approve them.
function questionRequest(method: string, params: unknown): AcpRequestPresentation | undefined {
const request =
method === 'session/request_permission' ? readAcpPermissionRequest(params, () => {}) : null
@@ -32,7 +31,7 @@ function questionRequest(method: string, params: unknown): AcpRequestPresentatio
: legacyAgentSessionQuestionAnswers(body, response.optionId)
const [answer] = answers ?? []
const optionId = answer?.optionIds[0]
// The server takes back one of its own choices; it has no free-text answer.
// The server accepts one offered choice and no free text.
if (
answers?.length !== 1 ||
answer?.optionIds.length !== 1 ||
@@ -54,7 +53,7 @@ const securityWarningSchema = z.looseObject({
})
})
// The server marks a risky "Allow Always" with a prompt-injection warning in that option's `_meta`.
// Risky Allow Always options carry warnings in option metadata.
function warnedApproval(method: string, params: unknown): AcpRequestPresentation | undefined {
const request =
method === 'session/request_permission' ? readAcpPermissionRequest(params, () => {}) : null
@@ -67,9 +66,7 @@ function warnedApproval(method: string, params: unknown): AcpRequestPresentation
: undefined
}
// A command arrives as `rawInput {command_line, working_dir}`, and once done as `rawOutput
// {commandLine, workingDir, exitCode, combinedOutput, formatted_output}` with no content blocks;
// the shared shape is `rawInput.command` and `rawOutput {stdout, exitCode}`.
// Normalize the server's command-field variants to command/cwd and stdout/exitCode.
const optionalText = z.string().optional()
const commandFieldsSchema = z.looseObject({
CommandLine: optionalText,
@@ -113,8 +110,8 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate {
export const ANTIGRAVITY_ACP_DIALECT: AcpDialect = {
request: (method, params) => questionRequest(method, params) ?? warnedApproval(method, params),
normalizeToolUpdate,
// Its command's title is the command line itself; the kind is what names a command row.
// Avoid using the full command line as the tool name.
toolName: (update) => (update.kind === 'execute' ? 'execute' : undefined),
// The server's own sign-out clears the sign-in every Antigravity chat on the host shares.
// Logout would clear the account shared by every Antigravity chat on this host.
withheldCommands: ['logout']
}
@@ -9,14 +9,13 @@ const response = z.union([
outcome: z.enum(['killed', 'already_exited', 'not_found'])
})
}),
// Grok's envelope for a kill it could not attempt, such as an unknown session.
z.object({ result: z.null(), error: z.string() })
])
export const grokBackgroundTaskStop: NonNullable<AcpDialect['backgroundTaskStop']> = {
probe: { method: '_x.ai/task/kill', params: {} },
lacksRoute: (error) => error.code === -32601,
// `source` is left to Grok's default: its own per-task UI kill.
// Omit source to use Grok's per-task UI kill behavior.
request: (sessionId, taskId) => ({ method: '_x.ai/task/kill', params: { sessionId, taskId } }),
response: (value, taskId) => {
const parsed = response.safeParse(value)
@@ -44,16 +44,13 @@ const resultOutputSchema = z.object({
})
type GrokTask = z.infer<typeof taskSchema>
export type GrokTaskResult = z.infer<typeof taskResultSchema>
/** Grok's output reads name a monitor only by this command prefix. */
const MONITOR_COMMAND = /^\[monitor[:\]]/
/** ...and a subagent by this one; subagents are roster rows, not background tasks. */
const SUBAGENT_COMMAND = /^\[subagent[:\]]/
export function isGrokSubagentTask(task: Pick<GrokTask, 'command'>): boolean {
return SUBAGENT_COMMAND.test(task.command ?? '')
}
/** The per-task results of a finished output read or kill call. */
export function grokTaskResults(
rawOutput: unknown
): { type: 'TaskOutput' | 'KillTask'; tasks: GrokTaskResult[] } | undefined {
@@ -122,7 +119,7 @@ function snapshot(
: {}),
...(label ? { label } : fallbackLabel ? { fallbackLabel } : {}),
...(task.output_file === undefined ? {} : { outputFile: task.output_file }),
// A running task's summary is Grok's "Background task <id> started", not the task's result.
// Running summaries announce startup; only settled summaries contain task results.
...(settled ? { summary: task.summary ?? '' } : {}),
...(task.error === undefined && !settled ? {} : { error: task.error ?? '' })
}
@@ -156,7 +153,7 @@ export function grokToolBackgroundTasks(
}
const input = taskInputSchema.safeParse(tool.input)
if (tool.name === 'kill_command_or_subagent' && update.status === 'completed' && input.success) {
// Inference: a successful kill call settles its named tasks without a completion notice.
// Infer settlement from successful kill calls that emit no separate completion notice.
return [
...new Set([
...(input.data.task_ids ?? []),
+3 -4
View File
@@ -37,7 +37,6 @@ const hookDeniedSchema = z.looseObject({
cancellationContext: z.looseObject({ reason: z.string().optional() }).nullish()
})
const promptErrorDataSchema = z.looseObject({ message: z.string() })
/** Grok writes its result text as the failure's reason only for these ends. */
const FAILED_STOP_REASONS = ['error', 'rate_limit']
const SESSION_NOTIFICATIONS = ['x.ai/session_notification', 'x.ai/session/update']
@@ -78,7 +77,7 @@ function contextWindow(models: unknown): number | undefined {
)
}
/** Grok ends a hook-denied prompt as `cancelled`, naming the hook's verdict in `_meta`. */
// Grok reports hook denial as cancellation with a verdict in _meta.
function hookBlock(meta: unknown): AcpHookBlock | undefined {
const parsed = hookDeniedSchema.safeParse(meta)
const reason = parsed.data?.cancellationContext?.reason?.trim()
@@ -133,7 +132,7 @@ function notification(
: { disposition: 'map', usage: { window: { tokens, capturedAt: at } } }
}
if (canonical === 'x.ai/session/prompt_complete') {
// A copy of the turn's end; only its failure reason is read, in case the end carried none.
// Duplicate turn endings may supply a missing failure reason.
const parsed = promptCompleteSchema.safeParse(params)
const detail = parsed.success
? failureDetail(parsed.data.stopReason, parsed.data.agentResult)
@@ -167,7 +166,7 @@ function notification(
const meta = turnMetaSchema.safeParse(envelope._meta)
const completion = completionSchema.safeParse(envelope.update)
const response = responseSchema.safeParse(envelope.update)
// Only a retry Grok gave up on is read; it names no turn, so it joins the one running.
// Exhausted retries have no turn ID; attach their failure to the running turn.
const retry = retrySchema.safeParse(envelope.update)
const retryFailure =
retry.success && retry.data.type === 'failed' && retry.data.message?.trim()
@@ -1,6 +1,3 @@
// Grok's own model facts: the per-model effort menu it writes in each model's `_meta`, and the
// models it lists during `initialize` (or serves from `x.ai/models/list`) with no session.
import { z } from 'zod'
import type {
AgentSessionModelOption,
@@ -22,7 +19,7 @@ const EFFORT_LABELS: Readonly<Record<string, string>> = {
xhigh: 'X-High',
max: 'Max'
}
// The menu Grok offers a model that supports effort but names none of its own.
// Fallback only for effort-capable models without an advertised menu.
const BUILT_IN_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const
const effortEntrySchema = z.union([
@@ -48,8 +45,7 @@ function capitalized(id: string): string {
return id.charAt(0).toUpperCase() + id.slice(1)
}
/** One model's effort menu as Grok advertises it. Choice values are Grok's option ids — what its
* effort config option takes — not the canonical sampling value behind them. */
/** Effort choices use Grok's config option IDs, not underlying sampling values. */
export function grokModelEfforts(model: Pick<ModelInfo, '_meta'>): {
efforts: AgentSessionOptionChoice[]
defaultEffort?: string
@@ -90,8 +86,7 @@ export function grokModelEfforts(model: Pick<ModelInfo, '_meta'>): {
return { efforts, ...(defaultEffort ? { defaultEffort } : {}) }
}
/** Names no default: a session's own model can differ from the `currentModelId` computed without
* one (Grok resolves its default from remote config too), so a no-pick chat reports it instead. */
/** Session-free currentModelId may differ from the model a chat runs; leave defaults to sessions. */
export function grokModelCatalogFromState(state: SessionModelState): AgentSessionModelOption[] {
return state.availableModels.map((model) => ({
id: model.modelId,
@@ -102,7 +97,6 @@ export function grokModelCatalogFromState(state: SessionModelState): AgentSessio
}))
}
/** The models `initialize` computed, else `x.ai/models/list`; neither creates a session. */
export async function readGrokModelCatalog(
initialized: InitializeResponse,
connection: { requestSessionFreeExtension(method: string, params: unknown): Promise<unknown> }
+1 -3
View File
@@ -98,12 +98,10 @@ function questionRequest(params: unknown): AcpRequestPresentation {
}
}
/** What Grok hears once Orca has shown its plan: no approval is given for the person. */
const PLAN_SHOWN_FEEDBACK =
'Orca showed your plan to the user. Stop here and wait for their feedback or a request to implement it in a later turn.'
/** Grok's plan-mode exit: its plan goes to the chat's plan row and the gate is answered at once
* (`abandoned`), so the turn ends instead of waiting on an approval card. */
// Show the plan and abandon its approval gate so Grok waits for later user instructions.
function settlePlanRequest(params: unknown): AcpRequestSettlement {
const parsed = planRequestSchema.safeParse(params)
if (!parsed.success) {
+5 -11
View File
@@ -1,6 +1,4 @@
// Grok's subagents, read as roster updates. Recorded from Grok 1.0.46 (s7 fixtures): a spawn is
// `spawn_subagent`, and Grok reports the child on the parent session as `subagent_spawned`,
// `subagent_progress` and `subagent_finished`. The subagent id is also the child's own session id.
// Grok's subagent IDs also name their child sessions (1.0.46 recordings).
import { z } from 'zod'
import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types'
@@ -44,13 +42,11 @@ const killInputSchema = z.object({
task_id: idSchema.optional(),
task_ids: z.array(idSchema).optional()
})
/** The id line of the reply a background spawn returns at once. */
const SPAWN_ACK_ID = /^subagent_id:\s*(\S+)\s*$/m
/** Machine blocks Grok appends to a subagent's reply for the parent model. */
const MACHINE_BLOCKS = /<subagent_(meta|result)>[\s\S]*?<\/subagent_\1>/g
const MAX_PROMPT_LABEL_CHARS = 80
/** Grok's statuses in the roster's words; a Map so `__proto__` resolves to nothing. */
// Map lookups avoid treating __proto__ as a recognized state.
const SUBAGENT_STATES: ReadonlyMap<string, NativeChatSubagentState> = new Map([
['completed', 'completed'],
['success', 'completed'],
@@ -78,7 +74,6 @@ function promptLabel(prompt: string | undefined): string | undefined {
: line || undefined
}
/** A `subagent_*` session update; undefined for any other update. */
export function grokSubagentNotification(update: unknown): AcpSubagentUpdate | undefined {
const spawned = spawnedSchema.safeParse(update)
if (spawned.success) {
@@ -102,7 +97,7 @@ export function grokSubagentNotification(update: unknown): AcpSubagentUpdate | u
}
const state = subagentState(finished.data.status)
const result = state === 'completed' ? replyText(finished.data.output) : undefined
// A cancelled subagent reports 0 tokens; the count it reported running stands.
// Preserve running token totals when cancelled subagents report zero.
const tokens = finished.data.tokens_used
return {
id: finished.data.subagent_id,
@@ -133,8 +128,7 @@ function spawnUpdates(update: ToolCallUpdate, tool: AgentJournalToolCallItem): A
return id ? [{ id, state: 'working', ...named }] : []
}
/** What a finished tool call says about subagents: its spawn's end or start, an output read, or a
* kill. Reads and kills name tasks of any kind, so they settle only subagents already known. */
/** Output reads and kill calls can name other task types; apply only to known subagents. */
export function grokToolSubagents(
update: ToolCallUpdate,
tool: AgentJournalToolCallItem
@@ -167,7 +161,7 @@ export function grokToolSubagents(
if (tool.name !== 'kill_command_or_subagent' || !kill.success) {
return []
}
// Inference, as for background tasks: a successful kill ends the tasks it named.
// Infer stopped state from a successful kill call.
return [
...new Set([...(kill.data.task_ids ?? []), ...(kill.data.task_id ? [kill.data.task_id] : [])])
].map((id) => ({ id, state: 'stopped', knownOnly: true }))
+5 -10
View File
@@ -2,14 +2,10 @@ import { z } from 'zod'
import type { ToolCallContent, ToolCallUpdate } from '../generated/acp-protocol.generated'
import type { AcpCompactionReply, AcpDialect } from './acp-dialect'
// OMP sends a tool's result as `rawOutput: {content: [{type: 'text', text}], details}`, with a
// command's non-zero exit at `details.exitCode`, and repeats the text as content behind a
// `$ <command>` echo. The shared reader takes content text first, so that echo became the output.
// A zero exit is left out; completed foreground results carry wall time, while service and
// background launch results do not establish a process exit.
// Read raw result text instead of the repeated command echo in content.
// Only timed foreground completion implies exit zero; service/background launches prove no exit.
const textBlockSchema = z.looseObject({ type: z.literal('text'), text: z.string() })
const promptErrorDataSchema = z.looseObject({ details: z.string() })
// OMP's prompt check: no usable model (nothing signed in), or the selected model's provider has no key.
const SIGNED_OUT_DETAIL_PREFIXES = ['No model selected.\n\nUse /login', 'No API key found for ']
const toolResultSchema = z.looseObject({
content: z.array(z.unknown()),
@@ -30,7 +26,7 @@ function contentText(block: ToolCallContent | undefined): string | undefined {
return block?.type === 'content' && block.content.type === 'text' ? block.content.text : undefined
}
/** OMP appends these notice lines to a command's output; the row shows exit and timing itself. */
// Exit and timing notices already have dedicated row presentation.
function commandOutput(text: string, exitCode: number | undefined, timed: boolean): string {
let output = text
const exitNotice = `\n\nCommand exited with code ${exitCode}`
@@ -62,7 +58,7 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate {
if (texts.includes(echo)) {
return update
}
// The result's own text moves to `stdout`; content keeps anything else it carried.
// Retain non-output content while moving the raw result text to stdout.
const content = (update.content ?? []).slice(1).filter((block) => {
const text = contentText(block)
return text === undefined || !texts.includes(text)
@@ -94,8 +90,7 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate {
}
}
// OMP ends a `/compact` it could not do as a normal turn whose reply says `Compaction failed`;
// "Nothing to compact" and "Already compacted" are its no-ops (its RPC errors say the same).
// OMP reports compaction failure and no-ops in a normally completed turn's reply.
const COMPACTION_FAILED = /\bcompaction failed\b/i
const COMPACTION_NOOP = /\b(?:nothing to compact|already compacted)\b/i
@@ -2,8 +2,7 @@ import { z } from 'zod'
import type { ToolCallUpdate } from '../generated/acp-protocol.generated'
import type { AcpDialect } from './acp-dialect'
// OpenCode reports a command's output and exit code under `rawOutput.metadata`; the shared shape
// is `stdout` and `exitCode`.
// Normalize rawOutput.metadata to the shared stdout/exitCode fields.
const commandRawOutputSchema = z.looseObject({
output: z.unknown().optional(),
metadata: z
+4 -7
View File
@@ -12,8 +12,7 @@ export class AcpRpcError extends Error {
}
}
/** The agent's own error answer to a request: it read the request and refused it. Errors Orca
* raises about a request (a timeout, an unreadable answer, a closed connection) are not this. */
/** A rejection received from the agent, distinct from local transport failures. */
export class AcpAgentError extends AcpRpcError {
constructor(code: number, message: string, data?: unknown) {
super(code, message, data)
@@ -32,11 +31,10 @@ export class AcpAuthRequiredError extends AcpAgentError {
}
}
/** The agent printed its sign-in prompt instead of answering, so the message is Orca's, not its. */
/** Orca's sign-in prompt message, distinct from an agent-authored error. */
export class AcpSignInPromptError extends AcpAuthRequiredError {}
/** Types an agent's sign-in answer. A sign-in prompt close stays itself: a copy would pass Orca's
* log words off as the agent's. */
/** Preserve prompt errors so Orca's diagnostic text is never attributed to the agent. */
export function acpAuthRequiredError(
error: AcpAgentError,
authMethods?: AuthMethod[]
@@ -46,7 +44,7 @@ export function acpAuthRequiredError(
: new AcpAuthRequiredError(error.message, error.data, authMethods)
}
/** Orca could not read the agent's answer; `data` keeps the raw answer, `issues` why it failed. */
/** Retains the raw answer in `data` and validation failures in `issues`. */
export class AcpInvalidResponseError extends AcpRpcError {
constructor(
message: string,
@@ -58,7 +56,6 @@ export class AcpInvalidResponseError extends AcpRpcError {
}
}
/** A line from the agent exceeded the framing limit, so the message it carried was never read. */
export class AcpFrameTooLargeError extends Error {
constructor(
readonly method: string | null,
+2 -3
View File
@@ -28,8 +28,7 @@ export class AcpIncomingRequests {
this.open.clear()
}
// Each handler answers its own request (a permission answers `cancelled`); -32800 only if it
// throws. The runtime never answers for a live handler: an answer still being saved must win.
// Handlers own cancellation replies; do not preempt an answer still being saved.
cancel(): void {
for (const request of this.open.values()) {
if (!request.cancelled) {
@@ -63,7 +62,7 @@ export class AcpIncomingRequests {
void Promise.race([
abandoned,
Promise.resolve().then(() => {
// A cancelled request still reaches its handler, so a permission can answer `cancelled`.
// Cancellation still reaches the handler so permissions can answer `cancelled`.
if (request.closed) {
throw controller.signal.reason
}
+3 -4
View File
@@ -22,13 +22,12 @@ import {
export type { AcpJsonRpcMessage } from './acp-rpc-envelope'
export type AcpRequestContext = { id: string | number | null; signal: AbortSignal }
export type AcpPeerHandlers = {
// Void means handled; unsupported methods must throw AcpRpcError(-32601). The handler owns its
// request: once the signal aborts it still answers, or throws (-32800); unanswered ends at close().
// Void is handled; unsupported methods throw -32601. Aborted handlers still reply or throw -32800.
onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown
onNotification?: (method: string, params: unknown) => void
onDiagnostic?: (message: string) => void
onClose?: (error: Error) => void
/** A line on the input that is not a protocol message, including an unterminated last one. */
/** Includes non-protocol output and unterminated final lines. */
onOtherLine?: (line: string, cut?: boolean) => void
}
export class AcpJsonRpcPeer {
@@ -163,7 +162,7 @@ export class AcpJsonRpcPeer {
return this.send({ jsonrpc: '2.0', method, params })
}
/** Aborts every open agent request's signal; each handler still sends its own answer. */
/** Aborts signals; handlers still own replies. */
cancelIncomingRequests(): void {
this.incoming.cancel()
}
+4 -8
View File
@@ -1,6 +1,4 @@
// Fences an agent's chat starts on this host off while its shared sign-in or installed runtime is
// replaced or removed: a chat running meanwhile could save the old account back or run deleted files.
// In memory only; a restart ends it.
// Fence chat starts during host account/runtime changes to prevent stale writes or deleted-file use.
import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ProviderAcquisitionConnection } from '../provider-process/provider-acquisition-starts'
@@ -15,7 +13,7 @@ export class AcpLaunchGate {
this.refusal = `${label}'s setup is changing on this host. Try again after it finishes.`
}
/** A start's way in: refused while an operation runs; an operation that begins aborts it. */
/** Refuses starts during an operation and aborts existing starts when one begins. */
enter(signal: AbortSignal | undefined): { signal: AbortSignal; leave: () => void } {
if (this.operating) {
throw new AgentSessionPreSpawnError(new Error(this.refusal), {
@@ -34,15 +32,13 @@ export class AcpLaunchGate {
}
}
/** Held from spawn until the child exits: a start that left the gate still runs until its chat is
* loaded, so an operation proves every such child gone, not only the loaded chats. */
/** Retain spawned children even after acquisition finishes, before their chats are loaded. */
retain(connection: ProviderAcquisitionConnection): void {
this.retained.add(connection)
connection.onExit(() => this.retained.delete(connection))
}
/** `work`, once the starts under way have ended, `stopLive` has stopped the running chats and
* every retained child is proven gone; otherwise it rejects without running `work`. */
/** Stop starts, live chats and retained root processes before running setup changes. */
async run<T>(stopLive: () => Promise<void>, work: () => Promise<T>): Promise<T> {
if (this.operating) {
throw new Error(this.refusal)
+31 -77
View File
@@ -1,6 +1,3 @@
// How Orca starts each ACP agent, as data: adding an agent is one more row (plus a dialect when it
// speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name.
import { join } from 'node:path'
import type { AcpDialect } from './acp-dialects/acp-dialect'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
@@ -58,9 +55,7 @@ import { readAntigravitySignInLine } from '../antigravity/antigravity-sign-in-ur
import type { AcpSignInWatch } from './acp-agent-connection'
import type { AcpLaunchGate } from './acp-launch-gate'
/** How an agent lists its models before a chat: from its `initialize` answer (plus extension
* requests), from a listing command, from a session it opens and deletes, or not at all. Never
* `authenticate`. */
/** Model discovery never authenticates interactively. */
export type AcpModelDiscovery =
| {
kind: 'initialize'
@@ -70,7 +65,6 @@ export type AcpModelDiscovery =
requestSessionFreeExtension(method: string, params: unknown): Promise<unknown>
}
): Promise<AgentSessionModelOption[]>
/** The listing marks the model the account is configured to run as its default. */
listingNamesConfiguredModel: boolean
}
| {
@@ -78,28 +72,20 @@ export type AcpModelDiscovery =
args: readonly string[]
parse(stdout: string): AgentSessionModelOption[]
listingNamesConfiguredModel: boolean
/** The releases whose listing matches what their chats offer; any other lists in a session
* when `otherReleasesListInSession`, else not at all and its chats' own lists fill the
* catalog. Absent lists on every release. */
/** Unsupported releases use session discovery when enabled, otherwise live-chat catalogs. */
supportsVersion?(version: string): boolean
otherReleasesListInSession?: true
}
| AcpSessionModelDiscovery
| { kind: 'unavailable'; reason: string }
/** What a new chat offers, read from a session opened in the asking chat's folder and then
* deleted. An agent that cannot delete one is not asked unless it can keep that session in a
* folder Orca removes, and with no chat folder (a prewarm) none is opened, so no conversation or
* new project is left in its history. */
/** Requires a chat folder and session deletion or disposable storage to avoid persistent history. */
export type AcpSessionModelDiscovery = {
kind: 'session'
listingNamesConfiguredModel: boolean
/** Arguments that keep the listing's session in `folder` instead of the agent's own store. */
sessionFolderArgs?(folder: string): string[]
}
/** Whether some release lists in a session in the asking chat's folder, so the listing needs one
* and reads that folder's project config. */
export function acpListingNeedsChatFolder(discovery: AcpModelDiscovery): boolean {
return (
discovery.kind === 'session' ||
@@ -108,72 +94,55 @@ export function acpListingNeedsChatFolder(discovery: AcpModelDiscovery): boolean
}
export type AcpLaunchSpec = {
/** The Orca agent id, which names the agent's records, its catalog label and its settings. */
agent: TuiAgent
command: string
/** The terminal agent's Command setting may name a different program than this chat server. */
ignoreAgentCommandSetting?: true
/** Built per launch: `fullAccess` is the chat's own Full access permission mode;
* `pluginDir` is a plugin folder `visualsSkill` asked the agent to load. */
args(input: { fullAccess: boolean; pluginDir: string | null }): string[]
/** Laid over the child's environment last, after the account and the user's own variables. */
/** Applied after the account and user environment. */
env: Readonly<Record<string, string>>
/** Rewrites what the child would inherit from Orca's own plumbing; returns the keys it must not
* inherit at all. `inherited` is the environment the child process starts from. */
/** Scrubs inherited Orca plumbing and returns keys to omit entirely. */
scrubEnvironment?(env: Record<string, string>, inherited: NodeJS.ProcessEnv): string[]
dialect: AcpDialect
/** The agent's own sign-in command, for a person to run when it reports auth required. */
loginCommand: readonly string[]
/** Which advertised sign-in method to use when the agent reports auth required, read from the
* environment it was launched with (on its own machine); none leaves it not signed in. */
/** Selects an advertised method using the execution host's environment; undefined leaves it signed out. */
authMethod?(input: {
advertised: readonly string[]
env: Readonly<Record<string, string>>
}): string | undefined
/** The agent's browser sign-in on its output: a valid sign-in line ends the chat signed out. */
/** A valid sign-in output line closes the chat signed out. */
signIn?: Omit<AcpSignInWatch, 'link'>
/** The account each chat pins, and how a launch points the agent at it. */
account: AcpAccountBinding
/** The ascending `--version` minimums a structured chat needs (`agentCliMinimumVersion`), asked
* before a create and again at every launch; absent runs whatever is installed. */
/** Ascending release-line minimums, checked before creation and at every launch. */
minimumVersions?: readonly string[]
/** Where the agent installs its own binary, searched after PATH. */
/** Searched after PATH. */
installDirectories(input: { env: Readonly<Record<string, string>>; homePath: string }): string[]
/** A pinned copy Orca installs under its data folder when the person asks; a start runs an
* installed release before PATH and never installs one. A spec with one names no
* `minimumVersions`. */
/** Installed only on request; takes precedence over PATH and excludes `minimumVersions`. */
managedRuntime?: ManagedRuntime
/** The agent's `mode` config option value per chat permission mode, through which it enforces
* the mode itself; Orca then answers none of its permission requests for the person. */
/** Agent-enforced modes; Orca does not answer its permission requests. */
permissionModeConfig?: {
values: { ask: string } & Partial<Record<AgentChatPermissionMode, string>>
}
/** The agent compacts its conversation when sent `/compact` as a prompt; off hides `/compact`. */
/** Enables /compact as a prompt command. */
compaction?: true
/** Throws when the chat's own arguments would send a copied conversation's CLI elsewhere than
* the store the copy is written to. */
/** Rejects copy arguments that route the CLI away from the copy's store. */
assertCopyLaunchArgs?: (args: readonly string[]) => void
/** The agent's own store of a session's user messages, read for restart recovery only. */
/** Used only for restart recovery. */
readStoredUserMessages?: AcpStoredUserMessagesReader
modelDiscovery: AcpModelDiscovery
/** How a launch loads the inline-visuals skill; absent, the agent's chats have no visuals. */
visualsSkill?: AcpVisualsSkillLoader
/** How a chat adopts a conversation the agent's CLI recorded; null when it cannot. */
transcriptImport: StructuredAgentTranscriptImport | null
/** How the agent's chats own the Session History rows their conversations list as. */
sessionHistory: StructuredAgentSessionHistory
/** Each process gets its own temp folder: the agent's temp files collide across processes. */
/** Isolate processes whose agent temp files collide. */
privateTempDirectory?: true
/** Reopens a chat's session with `session/resume` even when the agent can also load it. */
/** Prefer session/resume even when loading is supported. */
resumePreference?: 'resume'
/** Holds this host's chat starts off while its sign-in or runtime is replaced or removed. */
launchGate?: AcpLaunchGate
}
const GROK_LAUNCH_SPEC: AcpLaunchSpec = {
agent: 'grok',
command: 'grok',
// `--always-approve` only for full access, as the chat's permission mode chooses.
args: ({ fullAccess, pluginDir }) => [
'agent',
...(fullAccess ? ['--always-approve'] : []),
@@ -183,7 +152,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = {
env: {},
dialect: GROK_ACP_DIALECT,
loginCommand: agentSessionSignInFor('grok')?.loginCommand ?? [],
// An API key in Grok's own environment, else the sign-in Grok already cached; never interactive.
// Prefer the launch environment's API key, then cached sign-in; never prompt interactively.
authMethod: ({ advertised, env }) =>
env.XAI_API_KEY?.trim() && advertised.includes('xai.api_key')
? 'xai.api_key'
@@ -192,8 +161,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = {
: undefined,
account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')),
installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []),
// Grok lists its models in `initialize`, before and without any session. Its `currentModelId`
// there can differ from what a session runs, so a chat started with no pick names the default.
// initialize's currentModelId can differ from a session's; leave the default to the chat.
modelDiscovery: {
kind: 'initialize',
read: readGrokModelCatalog,
@@ -205,21 +173,18 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = {
sessionHistory: GROK_SESSION_HISTORY
}
// 1.18.31 is the release the recorded sessions capture; 2.0.14 the 2.x release verified to start the
// private child below.
// Verified release lines: 1.18.31 recordings and 2.0.14 private-child startup.
const OPENCODE_MINIMUM_VERSIONS = ['1.18.31', '2.0.14'] as const
// `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child
// with this environment and ends it with stdin, so both lines run the chat's own account.
// 1.x serves in-process; 2.x passes this environment to a private serve --stdio child.
function openCodeLaunchSpec(
row: Pick<AcpLaunchSpec, 'agent' | 'command' | 'minimumVersions' | 'modelDiscovery'>
): AcpLaunchSpec {
return {
...row,
// OpenCode has no bypass flag: full access answers each permission request yes.
// No bypass flag; full access approves permission requests individually.
args: () => ['acp'],
// Applied last: the client name ACP sessions report, and no question tool, since Orca shows no
// question form yet (1.x would wait on one forever; 2.x drops it).
// Disable unsupported question forms: 1.x waits forever; 2.x drops them.
env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' },
scrubEnvironment: scrubOpenCodeAcpEnvironment,
dialect: OPENCODE_ACP_DIALECT,
@@ -230,7 +195,6 @@ function openCodeLaunchSpec(
visualsSkill: loadOpenCodeVisualsSkill,
compaction: true,
assertCopyLaunchArgs: assertOpenCodeCopyUsesLocalStore,
// Finds a conversation of either line in the one database both lines write.
transcriptImport: OPENCODE_TRANSCRIPT_IMPORT,
sessionHistory: OPENCODE_SESSION_HISTORY
}
@@ -240,12 +204,11 @@ const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({
agent: 'opencode',
command: 'opencode',
minimumVersions: OPENCODE_MINIMUM_VERSIONS,
// Its `initialize` names no models; 1.x lists them with a command, 2.x in a session it deletes.
// 1.x lists via CLI; 2.x requires a disposable session.
modelDiscovery: {
kind: 'command',
args: OPENCODE_MODEL_LISTING_ARGS,
parse: parseOpenCodeModelListing,
// The listing marks no default; a chat started with no pick names it.
listingNamesConfiguredModel: false,
// 2.x and later reject `--verbose`, and their listings omit providers that report after startup.
supportsVersion: (version) => compareAppVersions(version, '2.0.0') < 0,
@@ -253,8 +216,7 @@ const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({
}
})
// Since 2.0.0 `opencode2` is an alias OpenCode's installer writes beside `opencode`, and the only
// name for 2.x where `opencode` is still 1.x. Its chats share OpenCode's account and history rows.
// opencode2 is the installer's 2.x alias; both names share accounts and history.
const OPENCODE2_LAUNCH_SPEC = openCodeLaunchSpec({
agent: 'opencode2',
command: 'opencode2',
@@ -262,26 +224,21 @@ const OPENCODE2_LAUNCH_SPEC = openCodeLaunchSpec({
modelDiscovery: { kind: 'session', listingNamesConfiguredModel: false }
})
// OMP serves ACP through `omp acp`; its environment reaches it as the user set it.
const OMP_LAUNCH_SPEC: AcpLaunchSpec = {
agent: 'omp',
command: 'omp',
// Full access answers each permission request yes.
// Full access approves permission requests individually.
args: ({ pluginDir }) => ['acp', ...(pluginDir ? ['--plugin-dir', pluginDir] : [])],
env: {},
dialect: OMP_ACP_DIALECT,
loginCommand: agentSessionSignInFor('omp')?.loginCommand ?? [],
// The directory OMP's terminal chats read too; its default is OMP's own.
account: directoryAccountBinding('PI_CODING_AGENT_DIR', (homePath) =>
join(homePath, '.omp', 'agent')
),
// OMP's installers use directories the shared resolver already searches after PATH.
installDirectories: () => [],
// 17.0.5 is the release verified to serve `omp acp`.
// First release verified to serve ACP.
minimumVersions: ['17.0.5'],
// `omp models --json` lists every model without the `enabledModels` filter a chat applies, so it
// could offer models a chat refuses. A session lists exactly what a chat in that folder offers;
// `omp acp` cannot delete one, so it is kept out of OMP's store (17.0.5 and 18.12.2 verified).
// CLI listings include disabled models; use a disposable session (OMP cannot delete sessions).
modelDiscovery: {
kind: 'session',
listingNamesConfiguredModel: false,
@@ -293,37 +250,34 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = {
sessionHistory: OMP_SESSION_HISTORY
}
// `agy` keeps its own conversations apart from the ACP server's, so its arguments resume none.
// agy conversations are separate from the ACP server's and cannot resume these sessions.
const ANTIGRAVITY_SESSION_HISTORY: StructuredAgentSessionHistory = {
rowAgents: ['antigravity'],
rowSessionId: (link) => link.handle.nativeId,
parseResumeArgs: () => null
}
// Google's Antigravity ACP server, which Orca installs (the `agy` CLI has no ACP mode). Its chats
// sign in to a folder of their own on each host; an `agy` login is not theirs.
// The managed ACP server has its own sign-in; agy provides neither ACP nor its account.
export const ANTIGRAVITY_LAUNCH_SPEC = {
agent: 'antigravity',
command: ANTIGRAVITY_ACP_COMMAND,
ignoreAgentCommandSetting: true,
// The registry's Linux launch: without `--uid=` the server, started as root, drops to nobody.
// Without --uid=, the Linux server drops root launches to nobody.
args: () => (process.platform === 'linux' ? ['--uid='] : []),
env: ANTIGRAVITY_ACP_ENV,
scrubEnvironment: scrubAntigravityAcpEnvironment,
dialect: ANTIGRAVITY_ACP_DIALECT,
loginCommand: agentSessionSignInFor('antigravity')?.loginCommand ?? [],
// Signs in with the Google account the folder saved; without one, the server prints a sign-in link.
authMethod: ({ advertised }) =>
advertised.includes('oauth-personal') ? 'oauth-personal' : undefined,
signIn: {
read: readAntigravitySignInLine,
// The host's sign-in status is the server's latest answer, as chats meet it.
answered: answerAntigravityChatAuth
},
account: antigravityChatAccount,
installDirectories: () => [],
managedRuntime: ANTIGRAVITY_ACP_RUNTIME,
// Its yolo mode still asks the person questions and administrative reviews.
// Yolo still requests questions and administrative reviews.
permissionModeConfig: {
values: { ask: 'default', 'accept-edits': 'auto_edit', 'full-access': 'yolo' }
},
+3 -8
View File
@@ -14,7 +14,7 @@ import type { ProviderProcessLaunch } from '../provider-process/provider-process
import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home'
const GROK = acpLaunchSpecFor('grok')!
// The user's own OpenCode environment, read without the app's managed-account service.
// Bypass the app's managed-account service for this fixture.
const OPENCODE: AcpLaunchSpec = {
...acpLaunchSpecFor('opencode')!,
account: openCodeAcpAccountBinding(() => ({
@@ -33,7 +33,7 @@ const MODEL_STATE = {
name: 'Grok Build',
_meta: {
supportsReasoningEffort: true,
// The session's own pick, which must not become a catalog default.
// A session pick must not become a catalog default.
reasoningEffort: 'low',
reasoningEfforts: [
{ value: 'low', id: 'fast', label: 'Fast' },
@@ -182,9 +182,7 @@ describe('ACP model catalog probes', () => {
{
id: 'grok-build',
label: 'Grok Build',
// `initialize`'s currentModelId is not what a session runs, so it names no default.
isDefault: false,
// Grok's option ids, which its effort config option takes, and its own default only.
efforts: [
{ value: 'fast', label: 'Fast' },
{ value: 'deep', label: 'Deep' }
@@ -244,7 +242,6 @@ describe('ACP model catalog probes', () => {
expect.objectContaining({
command: '/opt/opencode/bin/opencode',
args: ['models', '--verbose'],
// Where the project's opencode.json is read, so it lists what a chat there offers.
cwd: '/work/project',
env: expect.objectContaining({ OPENCODE_CLIENT: 'acp' })
}),
@@ -255,7 +252,6 @@ describe('ACP model catalog probes', () => {
])
})
// Every 2.x release rejects `--verbose`, and its own listings omit providers that report late.
it('lists OpenCode 2.x from a session it opens and deletes, with no listing command', async () => {
const { agent, deps, launches } = scriptedOpenCode2(true)
const success = await createAcpModelCatalogProbe(OPENCODE, deps)(
@@ -289,7 +285,6 @@ describe('ACP model catalog probes', () => {
expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize'])
})
// `omp acp` advertises no delete, so its listing session goes to a folder the probe removes.
it('lists OMP from a session kept in a folder it removes, in the chat folder', async () => {
const { agent, deps, launches } = scriptedOpenCode2(false)
const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-omp-listing-'))
@@ -352,7 +347,7 @@ describe('ACP model catalog probes', () => {
warn.mockRestore()
})
// OpenCode may already have saved the session it never named; nothing is left to delete it by.
// An interrupted create can save a session without returning its ID.
it('stops a listing cut off while OpenCode 2.x creates its session, without deleting', async () => {
const { agent, deps, closed } = scriptedOpenCode2(true)
agent.on('session/new', () => {})
+6 -16
View File
@@ -29,14 +29,13 @@ import {
import { AcpStructuredOptions } from './acp-structured-options'
import type { InitializeResponse } from './generated/acp-protocol.generated'
/** The part of an ACP connection a session-free listing may use: no session method is reachable. */
/** Session-free capability boundary: no session methods are exposed. */
export type AcpModelCatalogConnection = {
initialize(): Promise<InitializeResponse>
requestSessionFreeExtension(method: string, params: unknown): Promise<unknown>
close(): Promise<unknown>
}
/** A connection that may open one session, to read what a new chat offers, and delete it. */
export type AcpModelCatalogSessionConnection = Omit<
AcpModelCatalogConnection,
'requestSessionFreeExtension'
@@ -46,34 +45,28 @@ export type AcpModelCatalogSessionConnection = Omit<
}
export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & {
/** Test seams; production spawns through the shared supervised runner and ACP connection. */
probeVersion?: AgentCliVersionReader
connect?: (launch: ProviderProcessLaunch) => AcpModelCatalogConnection
connectSession?: (launch: ProviderProcessLaunch) => AcpModelCatalogSessionConnection
runListing?: typeof runAgentModelCatalogListing
}
/**
* Lists an ACP agent's models before a chat, under the binary, account and scrubbed environment its
* chats launch with. A release that runs no structured chat lists nothing, nor does one its
* discovery names no listing for.
*/
/** Uses the same binary, account and scrubbed environment as chats. */
export function createAcpModelCatalogProbe(
spec: AcpLaunchSpec,
deps: AcpModelCatalogProbeDeps
): AgentModelCatalogProbe {
return async (accountHome, options): Promise<AgentModelCatalogSuccess> => {
const discovery = spec.modelDiscovery
// Registered as unavailable instead (`acpModelCatalogDiscovery`); never built for one.
if (discovery.kind === 'unavailable') {
throw new Error(discovery.reason)
}
const workspace = options?.workspacePath
// Known without spawning anything: a listing that may open a session needs a chat's folder.
// Reject folderless session discovery before spawning even a version probe.
if (!workspace && acpListingNeedsChatFolder(discovery)) {
throw folderlessListing(spec)
}
// Spawned from home; a listing reads a project's config only in the chat folder it is asked for.
// Start in home; only explicit chat-folder discovery reads project config.
const cwd = deps.homePath ?? homedir()
const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, {
...deps,
@@ -111,7 +104,7 @@ export function createAcpModelCatalogProbe(
if (!workspace) {
throw folderlessListing(spec)
}
// Kept out of the agent's own store, and removed once `close` has ended the process.
// Remove disposable session storage after the process closes.
const folderArgs = listing.sessionFolderArgs
const folder = folderArgs
? await acpRunFolder(join(deps.dataDirectory, 'acp-listing-sessions', spec.agent))
@@ -146,7 +139,6 @@ const folderlessListing = (spec: AcpLaunchSpec) =>
type AcpModelListing = Exclude<AcpModelDiscovery, { kind: 'unavailable' }>
/** How `version` lists: its discovery, unless that is a command this release has no listing for. */
function listingFor(discovery: AcpModelListing, version: string | null): AcpModelListing | null {
if (discovery.kind !== 'command' || !discovery.supportsVersion) {
return discovery
@@ -159,8 +151,6 @@ function listingFor(discovery: AcpModelListing, version: string | null): AcpMode
: null
}
/** What a new chat in `workspace` offers, read from a session opened only to be deleted, or kept
* in a folder the probe removes. */
async function readSessionModels(
spec: AcpLaunchSpec,
listing: AcpSessionModelDiscovery,
@@ -173,7 +163,7 @@ async function readSessionModels(
throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`)
}
const started = await connection.start({ cwd: workspace, mcpServers: [] })
// Best effort: a failed delete, or a listing stopped mid-create, leaves one empty conversation.
// A failed delete or interrupted create may leave an empty conversation.
if (deletes) {
await connection.deleteSession(started.sessionId).catch((error: unknown) => {
console.warn(`[acp-model-catalog] ${spec.agent} kept its listing session:`, error)
+3 -3
View File
@@ -9,7 +9,7 @@ export type AcpOversizedLineTarget = {
diagnose: (message: string) => void
}
/** The Codex reader's prefix classification: settle whoever was owed the message that was lost. */
/** Settle requests whose replies were lost to the framing limit. */
export function settleOversizedAcpLine(
rejected: NdjsonRejectedRecord & { kind: 'line-too-long' },
target: AcpOversizedLineTarget
@@ -31,8 +31,8 @@ export function settleOversizedAcpLine(
(method) => new AcpFrameTooLargeError(method, observedBytes, maxLineBytes)
)
} else if (record.kind === 'response-unknown') {
// A numeric id with no readable result: any pending call could be the one that never settles.
// An unreadable response ID leaves every pending call suspect.
target.close(new AcpFrameTooLargeError(null, observedBytes, maxLineBytes))
}
// A notification or non-JSON-RPC output (a stray log line) settles nothing; closing would end the session.
// Ignore oversized notifications and stray output without ending the session.
}
+2 -2
View File
@@ -6,7 +6,7 @@ export type AcpPeerOptions = {
maxPendingRequests?: number
maxIncomingRequests?: number
requestTimeoutMs?: number | null
/** A managed connection waits for process exit rather than treating stdout EOF as exit. */
/** Managed connections use process exit, not stdout EOF. */
closeOnInputEnd?: boolean
}
@@ -41,7 +41,7 @@ export function requestTimeout(value: number | null | undefined): number | null
return value
}
/** Each start step's own bound; turns are unbounded and option writes carry their caller's. */
// Bound startup steps separately; prompts remain unbounded.
const ACP_START_STEP_TIMEOUT_MS: Readonly<Partial<Record<string, number>>> = {
initialize: 60_000,
'session/new': 300_000,
@@ -79,7 +79,6 @@ describe('ACP permission requests', () => {
},
'handler failed: Error: renderer gone'
],
// JSON.parse yields an untyped value, so a deliberately malformed reply needs no assertion.
[
'the handler answers nonsense',
() => JSON.parse('{"outcome":"yes"}'),
+3 -3
View File
@@ -22,7 +22,7 @@ const routingSchema = z.looseObject({
})
const optionRoutingSchema = z.looseObject({ optionId: z.string() })
// Keeps every field the schema does not know or can read; drops (and names) the unreadable ones.
// Preserve unknown fields for forward compatibility; report malformed known fields.
function readableFields(
knownFields: Record<string, z.ZodType>,
value: Record<string, unknown>,
@@ -40,7 +40,7 @@ function readableFields(
return kept
}
/** Validates only what answering needs (session, tool call id, options with ids); null if unusable. */
/** Require routing IDs and usable options; tolerate malformed optional fields. */
export function readAcpPermissionRequest(
params: unknown,
diagnose: (message: string) => void
@@ -92,7 +92,7 @@ export function readAcpPermissionRequest(
return request.data
}
/** Asks the caller; any answer Orca cannot send (a throw, a bad reply, an unoffered option) is `cancelled`. */
/** Cancel on handler failure, malformed replies or unoffered options. */
export function answerAcpPermission(
request: RequestPermissionRequest,
context: AcpRequestContext,
+2 -6
View File
@@ -1,6 +1,4 @@
// A temp folder of its own for each process of an agent whose temp files collide across processes,
// under Orca's locked data root. ACP children talk over stdio, so one left by an earlier host run
// can never be reattached: the host's first start of the agent clears the whole root.
// Stdio children cannot reattach after a host restart, so stale temp folders can be cleared.
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
@@ -11,8 +9,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-
const wipes = new Map<string, Promise<void>>()
/** A new folder under `root` for one agent process. The host's first use of `root` clears it:
* a crash, or a handle Windows holds past the kill, can leave an earlier run's folder behind. */
/** Clear crash leftovers on first use of a root, then isolate each process in a new folder. */
export async function acpRunFolder(
root: string,
logger?: StructuredAgentSessionLogger
@@ -32,7 +29,6 @@ export async function acpRunFolder(
return { path, remove: () => remove(path) }
}
/** `deps.connect`, giving the child a new folder as its temp and removing it once the child exits. */
export async function acpPrivateTempConnect<
Connection extends Pick<AcpStructuredConnection, 'onExit'> = AcpStructuredConnection
>(
+1 -1
View File
@@ -135,7 +135,7 @@ describe('ACP prompt content', () => {
})
it('keeps a message with images within one protocol line', async () => {
// The images fit their raw limit, but with this much text the prompt would pass the line.
// Raw images fit, but their encoded payload plus text exceeds the protocol line limit.
const text = 'x'.repeat(8 * 1024 * 1024)
const image = {
type: 'image-ref' as const,
+4 -14
View File
@@ -1,6 +1,3 @@
// Native images require advertised support and bounded materialization; host paths retain
// their ordinary file-reference representation whenever native delivery cannot fit.
import { extname, isAbsolute } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
@@ -28,14 +25,14 @@ import { acpSessionAcceptsImages } from './acp-structured-options'
import type { ContentBlock } from './generated/acp-protocol.generated'
const ACP_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
/** A prompt is one JSON-RPC line; this leaves room for the request around its blocks. */
// Reserve space for the JSON-RPC envelope around the prompt blocks.
const ACP_PROMPT_LINE_BUDGET = resolveAcpPeerOptions().maxLineBytes - 64 * 1024
/** One message's images together, raw: base64 makes each 4 bytes of them 3 more. */
// Budget raw images for base64's 4:3 expansion and leave room for text.
export const ACP_PROMPT_IMAGE_MAX_BYTES = Math.floor((ACP_PROMPT_LINE_BUDGET * 3) / 4) - 1024 * 1024
const MAX_IMAGE_COUNT = 20
const READ_TIMEOUT_MS = 15_000
/** Why Orca did not send a message; never the agent's refusal. */
/** A local dispatch refusal, distinct from an agent rejection. */
export class AcpPromptContentError extends Error {
constructor(readonly failure: SubmissionRejectionFact) {
super(`ACP prompt refused: ${failure.kind}`)
@@ -47,7 +44,6 @@ function attachmentProblem(attachment: AgentSessionAttachmentProblem): AcpPrompt
return new AcpPromptContentError(agentSessionFailureFact('attachmentInvalid', { attachment }))
}
/** The image's absolute path on this host, from its path or a `file:` URL. */
function hostImagePath(block: NativeChatImageRefBlock): string | null {
let path = block.path
if (!path && block.url?.startsWith('file:')) {
@@ -121,12 +117,11 @@ async function imageBlock(
return { block: { type: 'image', mimeType, data: data.toString('base64') }, bytes: data.length }
}
/** The prompt for `body`; throws `AcpPromptContentError` for what Orca will not send. */
export async function acpPromptBlocks(
body: AgentJournalMessageItem,
acceptsImages: boolean
): Promise<ContentBlock[]> {
// Reserve text and path slots, including those after each optional native image.
// Reserve later text and file references before allocating image capacity.
const blocks: ContentBlock[] = body.blocks.map((block) => {
if (block.type === 'text') {
return { type: 'text', text: block.text }
@@ -148,7 +143,6 @@ export async function acpPromptBlocks(
throw attachmentProblem({ reason: 'unsupportedType' })
}
if (!acceptsImages) {
// As the composer would have written it, had it known before the agent's start answered.
const path = hostImagePath(block)
if (!path) {
throw attachmentProblem({ reason: 'unsupportedType' })
@@ -180,22 +174,18 @@ export async function acpPromptBlocks(
blocks[index] = image.block
encodedBytes += blockBytes(image.block) - previousBytes
}
// Only images can carry a message past one line here; their words are the refusal's.
if (imageCount > 0 && Buffer.byteLength(JSON.stringify(blocks)) > ACP_PROMPT_LINE_BUDGET) {
throw attachmentProblem({ reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES })
}
return blocks
}
/** A message that is only one of the dialect's withheld commands, with no attachment. */
function isWithheldCommand(body: AgentJournalMessageItem, withheld: readonly string[]): boolean {
const [block, ...rest] = body.blocks
const text = block?.type === 'text' && rest.length === 0 ? block.text.trim() : ''
return text.startsWith('/') && withheld.includes(text.slice(1))
}
/** The prompt for `body` to `session`'s agent, or Orca's refusal of it in words that name the
* agent. Images go only where `acpSessionAcceptsImages` allows them. */
export async function acpDispatchPrompt(
body: AgentJournalMessageItem,
session: {
+5 -11
View File
@@ -15,14 +15,12 @@ export type AcpPromptTurn = {
requestedAt: number
opened: boolean
durationMs?: number
/** A `/compact` running as the turn the host opened for it. */
compaction?: AcpCompaction
}
/** An injected identity is known before any provider output arrives. */
export class AcpPromptTurns {
current?: AcpPromptTurn
/** The last prompt that ended, so its answer can still add the failure reason the end lacked. */
/** Retain the ended prompt so its reply can supply a late failure reason. */
last?: AcpPromptTurn
constructor(
@@ -31,8 +29,7 @@ export class AcpPromptTurns {
private readonly agentName?: string
) {}
/** `compactionTurn` runs a `/compact` as the command turn the host already opened, which its
* frames join and its answer ends. */
/** Reuse the host's already-open command turn for compaction. */
open(
clientMessageId: string,
at: number,
@@ -57,14 +54,13 @@ export class AcpPromptTurns {
return { promptId: turn, events: injected ? [] : this.start(turn, at) }
}
/** The running `/compact`, when `turn` is its turn. */
compacting(turn: string | undefined): AcpPromptTurn | undefined {
return turn !== undefined && this.current?.turn === turn && this.current.compaction
? this.current
: undefined
}
/** True for a frame the running compaction `turn` read as its own. */
/** True suppresses rendering the compaction's frame. */
absorbCompaction(
turn: string | undefined,
extension: AcpCompactionFrame | undefined,
@@ -74,7 +70,6 @@ export class AcpPromptTurns {
return prompt !== undefined && absorbAcpCompactionFrame(prompt, extension, update)
}
/** The running compaction's result row and end, when `turn` is its turn. */
endCompaction(turn: string, ending: AcpTurnEnding): ProviderTimelineEvent[] | undefined {
const prompt = this.compacting(turn)
if (!prompt?.compaction) {
@@ -95,7 +90,7 @@ export class AcpPromptTurns {
this.current = undefined
}
/** Forgets a prompt the agent refused before its turn opened; false for any other. */
/** Only discard refusals received before the turn opens. */
refuse(clientMessageId: string): boolean {
if (this.current?.clientMessageId !== clientMessageId || this.current.opened) {
return false
@@ -122,8 +117,7 @@ export class AcpPromptTurns {
}
}
/** A hook-denied turn completed rather than stopped: its row, under one identity however often
* the agent reports the end, then the end. */
/** Hook denial completes the turn; a stable row key deduplicates repeated endings. */
export function acpTurnEndEvents(
thread: string,
turn: string,
+6 -19
View File
@@ -1,7 +1,4 @@
// Restart recovery for an ACP agent that keeps its own store: whether a message a crash left
// unconfirmed reached the agent. Presence only: a match settles it as sent, while absence proves
// nothing, so nothing is ever called undelivered. Bounded, and null on any failure; the user can
// always resend or discard, so this never stands between them and the chat.
// Recovery proves delivery only by presence; missing history never proves non-delivery.
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { agentSessionSendBodyFingerprint } from '../../shared/structured-agent-session-send-mutation'
@@ -21,24 +18,21 @@ import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-a
const RECOVERY_READ_MS = 5_000
/** A person's message as the agent's own store holds it. */
export type AcpStoredUserMessage = {
/** The store's id for it, claimed at most once. */
/** Provider ID, claimed at most once. */
id: string
blocks: NativeChatBlock[]
/** Epoch ms the agent recorded it, on this machine's clock. */
/** Epoch milliseconds on the execution host's clock. */
createdAt: number
}
/** Reads the user messages of one provider session from the agent's own store; null when it cannot. */
export type AcpStoredUserMessagesReader = (input: {
env: Record<string, string>
providerSessionId: string
signal: AbortSignal
}) => Promise<AcpStoredUserMessage[] | null>
/** Sends a crash may have left unconfirmed; a queued card was never handed over, so the agent
* cannot hold it. */
// Queued submissions never reached the agent.
function sendsInDoubt(load: JournalLoad): AgentJournalSubmission[] {
return [...load.state.submissions.values()].filter(
(submission) =>
@@ -55,13 +49,7 @@ function fingerprintOf(sessionId: string, message: AcpStoredUserMessage): string
})
}
/**
* The agent's store keeps no id of Orca's, so a stored message can only match a send by content.
* Each send the journal already accepted first claims the earliest stored copy of its text written
* no earlier than it was sent, so an identical message that did reach the agent is never offered as
* the one in doubt; what is left, from the first send in doubt on, is the window. Claiming more than
* a send's own copy only leaves a send in doubt unconfirmed, never wrongly confirmed.
*/
/** Claim accepted sends' matching copies first to avoid falsely confirming identical sends in doubt. */
function recoveryWindow(
load: JournalLoad,
inDoubt: readonly AgentJournalSubmission[],
@@ -94,7 +82,6 @@ function recoveryWindow(
)
}
/** The window for one chat; null for an agent whose store Orca does not read. */
export async function readAcpRecoveryHistory(
input: Pick<AcpStructuredSessionAdapterDeps, 'readLaunch' | 'readJournal' | 'logger'> & {
spec: Pick<AcpLaunchSpec, 'readStoredUserMessages'>
@@ -129,7 +116,7 @@ export async function readAcpRecoveryHistory(
return null
}
return {
// Absence proves nothing: neither the start of the read nor the end of the agent's work is known.
// Neither the read boundary nor completion of the agent's work is known.
boundaryConsistent: false,
turnInFlight: true,
items: recoveryWindow(load, inDoubt, messages)
@@ -44,7 +44,6 @@ export class AcpScriptedAgent {
on(method: string, handler: Handler): void {
this.methods.set(method, handler)
}
/** Confirms every `session/set_config_option`, answering `base` plus the option it set. */
confirmConfigOptions(base: readonly unknown[]): void {
this.on('session/set_config_option', (frame) => {
const { configId: id, value } = setConfigOptionSchema.parse(frame.params)
@@ -77,7 +77,7 @@ export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentCh
script.reply(frame, { configOptions })
})
script.on('session/set_model', (frame) => script.reply(frame, {}))
// Grok's subagent-stop probe: the handler refuses the missing id, so stops are offered.
// Refusing a missing ID proves the subagent-stop route exists.
script.on('_x.ai/subagent/cancel', (frame) =>
script.fail(
frame,
@@ -86,7 +86,7 @@ export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentCh
'invalid params: missing field `subagentId`'
)
)
// Its background-task stop probe, answered the same way.
// Prove the background-task Stop route the same way.
script.on('_x.ai/task/kill', (frame) =>
script.fail(
frame,
@@ -7,9 +7,7 @@ import type { AcpStructuredConnection } from './acp-structured-connection'
import { AcpScriptedAgent } from './acp-scripted-agent.test-support'
export const PID = 4242
/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio,
* with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes
* the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */
/** Real in-memory protocol with separate stream, process-exit and connection-loss lifecycles. */
export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection {
readonly agent: AcpScriptedAgent
readonly pid: number | undefined = PID
@@ -56,7 +54,7 @@ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConn
resumeReading(): void {
this.agent.stdout.resume()
}
/** What a close proves once the protocol is closed; replace it to leave the exit unproven. */
/** Override to simulate a close without proven exit. */
proveClose = async (): Promise<boolean> => {
this.exit()
return true
@@ -67,7 +65,6 @@ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConn
this.drainNotifications(error)
return this.proveClose().finally(() => super.close(error))
}
/** The agent process ends on its own (or Orca's close landed). */
exit(): void {
if (this.gone) {
return
+1 -1
View File
@@ -14,7 +14,7 @@ export type AcpSessionEvent =
| { kind: 'known'; notification: SessionNotification }
| { kind: 'unrecognized'; sessionId: string; raw: z.infer<typeof updateEnvelopeSchema> }
/** A `session/update`, typed when this build knows its kind; null when it is not one at all. */
/** Preserve unknown updates; reject malformed envelopes. */
export function readAcpSessionEvent(params: unknown): AcpSessionEvent | null {
const envelope = updateEnvelopeSchema.safeParse(params)
if (!envelope.success) {
@@ -243,7 +243,6 @@ describe('ACP caller-owned waits', () => {
const { agent, runtime } = fixture({
onRequest: (method) =>
new Promise((resolve, reject) => {
// '_vendor/silent' ignores the abort: the runtime never answers for it.
if (method !== '_vendor/silent') {
withdrawal.signal.addEventListener('abort', () =>
method === '_vendor/plan'
@@ -24,7 +24,7 @@ afterEach(() =>
describe('ACP session update compatibility', () => {
it.each([
['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }],
// Unfamiliar enum values stay typed: the generated enums are open.
// Generated enums accept future values.
['known', { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' }],
['known', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }],
[
+1 -11
View File
@@ -1,9 +1,3 @@
// What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat
// created and never exchanged a turn on, which the agent says it does not hold, is superseded:
// there was nothing to remember. Any other is replaced by a new session that names it, and the
// chat says once that the agent forgot: the row names the lost conversation, so a later start
// can tell whether it was ever written.
import { agentSessionFailureFact } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
@@ -11,17 +5,15 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timelin
import { AcpAuthRequiredError, AcpRpcError } from './acp-errors'
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
/** ACP's "resource not found": the agent holds no session under the id this chat proved. */
const ACP_RESOURCE_NOT_FOUND = -32002
export const ACP_REOPEN_FAILED = 'ACP agent could not reopen its saved session; starting a new one'
/** The fields the new session's link carries; rethrows `error` when the start must fail instead. */
export function acpReopenTakeover(
error: unknown,
resume: NonNullable<AcpStructuredLaunch['resume']>,
start: { over: boolean; now: number; warn: (fields: { scope: string; error: unknown }) => void }
): Pick<AgentSessionProviderHandleLink, 'supersedesKey' | 'replaces'> {
// Signed out, or the start is over (Close, Stop, a lost agent): a new session would not help.
// A replacement session cannot resolve sign-in failure or an abandoned start.
if (error instanceof AcpAuthRequiredError || start.over) {
throw error
}
@@ -36,12 +28,10 @@ export function acpReopenTakeover(
return { replaces: { key: resume.key, reason: 'restore-failed', replacedAt: start.now } }
}
/** The provider item key of the row saying the conversation under chain key `lostKey` was lost. */
export function acpSessionNotRestoredItem(lostKey: string): string {
return `session-not-restored:${lostKey}`
}
/** The one row a lost conversation gets: the agent no longer remembers what came before. */
export function acpSessionNotRestoredRow(
lostKey: string,
providerSessionId: string,
+1 -1
View File
@@ -315,7 +315,7 @@ describe('ACP session runtime', () => {
expect(diagnostics).toContain(
'Answered ACP permission request cancelled: handler selected an unavailable option'
)
// The session stays usable: a local permission failure is not a protocol failure.
// A local permission failure must leave the session usable.
await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress')
})
+6 -13
View File
@@ -45,16 +45,12 @@ export type AcpSessionRuntimeOptions = {
clientInfo?: InitializeRequest['clientInfo']
peer?: AcpPeerOptions
onPermission?: AcpPermissionHandler
/** Agent requests other than permissions. The handler owns its request: once `context.signal`
* aborts, send the agent's own cancelled reply, finish an answer already in progress, or throw
* (-32800). The runtime never answers for it; a request left unanswered ends at `close()`. */
/** After abort, handlers still reply or throw (-32800); close ends unanswered requests. */
onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown
/** Agent notifications other than `session/update` (protocol extensions), delivered
* synchronously in arrival order with the `subscribe` events. */
/** Delivered synchronously in arrival order with subscription events. */
onExtensionNotification?: (method: string, params: unknown) => void
onDiagnostic?: (message: string) => void
onClose?: (error: Error) => void
/** A stdout line that is not a protocol message. */
onOtherLine?: (line: string, cut?: boolean) => void
/** Authentication answers, published synchronously as responses are accepted. */
onAuthAnswered?: (signedIn: boolean) => void
@@ -133,8 +129,7 @@ export class AcpSessionRuntime {
return this.call('authenticate', { methodId }, AuthenticateResponseSchema)
}
/** A vendor extension method, answered as the agent sent it. Core methods have typed calls here,
* so a session-free caller can never create or touch a session through this. */
/** Reject core session methods for session-free discovery. */
async requestSessionFreeExtension(method: string, params: unknown): Promise<unknown> {
if (!method.includes('/') || method.startsWith('session/')) {
throw new Error(`Not an ACP extension method: ${method}`)
@@ -143,14 +138,13 @@ export class AcpSessionRuntime {
return this.peer.request(method, params, { timeoutMs: null })
}
/** Copies saved session `sessionId` into a new one the agent saves (`session/fork`) and answers
* its id; this connection starts no session of its own. Ask only an agent that advertises it. */
/** Fork without starting a session here; requires advertised fork support. */
async forkSession(sessionId: string, cwd: string): Promise<string> {
const params = { sessionId, cwd, mcpServers: [] }
return (await this.call('session/fork', params, ForkSessionResponseSchema)).sessionId
}
/** Deletes saved session `sessionId` (`session/delete`). Ask only an agent that advertises it. */
/** Requires advertised session deletion support. */
deleteSession = (sessionId: string) => this.call('session/delete', { sessionId }, z.unknown())
start(options: AcpSessionStartOptions): Promise<AcpSessionStarted> {
@@ -207,7 +201,6 @@ export class AcpSessionRuntime {
return this.call('session/set_mode', params, SetSessionModeResponseSchema)
}
/** Dialect-owned control requests share this session's transport and bounded lifetime. */
requestExtension(method: string, params: unknown, timeoutMs = 30_000): Promise<unknown> {
this.sessionId()
if (!method.startsWith('_')) {
@@ -279,7 +272,7 @@ export class AcpSessionRuntime {
if (!request) {
throw new AcpRpcError(-32602, 'Invalid ACP permission request')
}
// Whether a turn the agent began itself may ask is the caller's call; the runtime cannot see it.
// The caller decides whether agent-initiated turns may request permission.
if (request.sessionId !== this.started?.sessionId) {
return { outcome: { outcome: 'cancelled' } }
}
+3 -3
View File
@@ -52,7 +52,7 @@ export function acpSessionUpdate(
: [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }]
}
case 'user_message_chunk':
// A live echo of the send is the send's own row.
// The original send already owns a journal row.
return []
case 'tool_call':
case 'tool_call_update': {
@@ -61,7 +61,7 @@ export function acpSessionUpdate(
const tool = events[0]
const body = tool && 'body' in tool && tool.body?.kind === 'tool-call' ? tool.body : null
const subagentUpdates = body ? (dialect.toolSubagents?.(update, body) ?? []) : []
// Runs first, so a subagent this call reveals is never also taken for a background task.
// Register subagents before excluding them from background tasks.
const subagentEvents = subagents.translate(subagentUpdates, join.join, at)
const tasks = (body ? (dialect.toolBackgroundTasks?.(update, body) ?? []) : []).filter(
(task) => !subagents.has(task.taskId)
@@ -92,7 +92,7 @@ export function acpSessionUpdate(
case 'current_mode_update':
case 'config_option_update':
case 'session_info_update':
// These feed commands, options and the session title, outside the timeline.
// Options, commands and titles update outside the timeline.
return []
case 'plan_update':
case 'plan_removed':
+12 -27
View File
@@ -1,9 +1,4 @@
// Making a reservation real for an ACP agent: open its connection (which spawns and owns the
// process) and record it before any handshake, so the host publishes the child at spawn. The
// handshake (`acp-structured-handshake`) runs after: initialize, then reattach the session this chat
// proved with `session/load` (`session/resume` for an agent that cannot load) or start a new one,
// and the host receives protocol facts once that session answers. The handshake has no time bound of
// its own: the host's startup limit, or a close, Stop or quit, stops it at any point.
// Publish and track the child before handshaking so startup cancellation can reach it.
import { initializeAcpStructuredSession } from './acp-structured-handshake'
import {
@@ -33,21 +28,17 @@ import type { AcpStructuredTurnsDeps } from './acp-structured-turns'
import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
/** Frames an agent may send before its session exists; past this they are dropped. */
const MAX_EARLY_FRAMES = 2_048
export async function acquireAcpStructuredSession(input: {
acquire: StructuredAgentSessionAcquireInput
deps: AcpStructuredSessionAdapterDeps
generation: string
/** The spawn was aborted; checked until the spawn, after which `track` hands it the connection. */
/** Checked until spawn; afterward `track` gives cancellation the connection. */
abandoned: () => boolean
/** Registers the connection so a close during the acquire can stop it. */
track: (connection: AcpStructuredConnection) => void
/** The process's exit, observed while or after the session exists. */
onExit: (session: AcpStructuredChild | null) => void
/** The protocol broke with the process perhaps still running. Null while starting: then the
* start itself fails. */
/** Protocol loss may precede process exit; null means startup has not produced a session. */
onConnectionLost: (session: AcpStructuredChild | null, error: Error) => void
onSettled: AcpStructuredTurnsDeps['settle']
forceClose: (sessionId: string) => void
@@ -73,14 +64,13 @@ export async function acquireAcpStructuredSession(input: {
closedBeforeSpawn()
const launch = await prepareAcpStructuredLaunch(deps, acquire, closedBeforeSpawn)
let session: AcpStructuredChild | null = null
// A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write.
// Use a slot so closure writes do not conflict with control-flow narrowing.
const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = {
lane: null,
reattaching: false
}
const options = new AcpStructuredOptions(launch.spec.dialect)
// Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself
// has nobody waiting on it. What a settled request carries still shows in that turn.
// Agent-initiated turns cannot open interactive requests; settled request content may still show.
const prompts = new AcpStructuredPrompts(
() => slot.lane,
() => isAcpReadySession(session) && session.turns?.acceptsRequests === true,
@@ -97,7 +87,7 @@ export async function acquireAcpStructuredSession(input: {
early.push(deliver)
}
}
// No await for an agent without the flag: nothing may come between the last abort check and spawn.
// Keep the last abort check and spawn synchronous when no temp directory is needed.
const connect = spec.privateTempDirectory
? await acpPrivateTempConnect(deps, closedBeforeSpawn)
: deps.connect
@@ -114,14 +104,12 @@ export async function acquireAcpStructuredSession(input: {
...(spec.signIn ? { signIn: spec.signIn } : {}),
onPermission: (request, context) => {
if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) {
// No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer
// is cutting it short: nobody is there to ask.
// No interactive prompt remains during Stop, steering or an agent-initiated turn.
return { outcome: { outcome: 'cancelled' } }
}
// Full access never answers a dialect's question, nor an agent that enforces the mode itself.
// Do not auto-approve questions or permissions enforced by the agent's own mode.
const claimed = spec.dialect.request?.('session/request_permission', request)
if (launch.fullAccess && !spec.permissionModeConfig && !claimed) {
// Full access: Orca answers yes for the person, as the agent's own bypass flag would.
const allow = request.options.find((option) => option.kind === 'allow_once')
if (allow) {
return { outcome: { outcome: 'selected', optionId: allow.optionId } }
@@ -148,16 +136,13 @@ export async function acquireAcpStructuredSession(input: {
sessionId,
message
}),
// The protocol broke while the process may still run; the connection is already ending it.
// Its stdout ending alone is not this: the agent may still answer Stop's cancel or exit.
// Protocol loss may leave the process alive; stdout EOF alone does not trigger this.
onClose: (error) => input.onConnectionLost(session, error),
// A crash usually ends stdout first; the session's end still reads the agent's last words at
// this proven exit.
// Read final diagnostics at process exit, which may follow stdout EOF.
onExit: () => input.onExit(session)
}
)
// From here an abort closes this connection (the start's one canceller): whatever the agent left
// unanswered fails at once and the process ends, whether or not its exit is proven yet.
// From here, cancellation closes this connection even before exit is proven.
input.track(connection)
connection.subscribe((event: AcpSessionEvent) =>
whenLane(() => {
@@ -185,7 +170,7 @@ export async function acquireAcpStructuredSession(input: {
`${spec.agent} ACP agent`,
deps.readProcessStartTime
)
/** `attaching`: the lane opens inside the attach window, before any frame queued so far. */
// Begin the attach window before replaying queued frames.
const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => {
const lane = new AcpStructuredLane({
sink,
@@ -1,7 +1,5 @@
import { FakeAcpChild } from './acp-scripted-connection.test-fixture'
export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture'
// A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real
// protocol runtime, translator and assembler, and a real on-disk journal to read back.
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -69,16 +67,13 @@ export type AcpAdapterRig = {
options?: Readonly<Record<string, string>>
permissionMode?: AgentChatPermissionMode
}): ReturnType<AcpStructuredSessionAdapter['acquire']>
/** Frames Orca wrote to the agent with this method. */
sent(method: string): FakeFrame[]
/** Waits for the `index`th frame Orca writes with this method. */
frame(method: string, index?: number): Promise<FakeFrame>
settle(): Promise<void>
}
export async function openAcpAdapterRig(
options: {
/** The agent's row; Grok's unless a test drives another agent. */
spec?: AcpLaunchSpec
launch?: Partial<AcpStructuredLaunch>
initialize?: Record<string, unknown>
@@ -95,7 +90,7 @@ export async function openAcpAdapterRig(
let opened: StructuredAgentSessionProviderOpenResult | null = null
const startListeners = new Set<() => void>()
const spec = options.spec ?? GROK
// A start readies the account folder its row pins; here a scratch one.
// Use a scratch account folder for agents that prepare one.
const { prepare, resolve, environment } = spec.account
const scratch = { launchEnv: {}, stateDirectory: tmpdir() }
const account = prepare ? environment(await resolve(scratch), {}) : {}
@@ -115,7 +110,6 @@ export async function openAcpAdapterRig(
const adapter = new AcpStructuredSessionAdapter({
spec,
resolveLaunch,
// A double starts nothing, so a recovery read sees the launch a start would.
readLaunch: (identity) => resolveLaunch({ identity, spawnToken: 'recovery-read' }),
connect: (launch, connectionOptions) => {
spawned.push('spawn')
@@ -253,12 +247,10 @@ const HELLO: AgentJournalMessageItem = {
blocks: [{ type: 'text', text: 'hello' }]
}
/** A person's send of `hello` under `clientMessageId`. */
export function sendHello(rig: AcpAdapterRig, clientMessageId: string, fence = 1) {
return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: HELLO, fence })
}
/** One streamed reply chunk of the turn Grok runs under `promptId`. */
export function replyChunk(promptId: string, text: string, meta: Record<string, unknown> = {}) {
return {
sessionId: PROVIDER_SESSION,
@@ -267,7 +259,6 @@ export function replyChunk(promptId: string, text: string, meta: Record<string,
}
}
/** A shell command Grok runs inside the turn it runs under `promptId`. */
export function shellCall(promptId: string, status: 'in_progress' | 'completed') {
return {
sessionId: PROVIDER_SESSION,
@@ -7,17 +7,14 @@ export function acpAgentName(agent: string): string {
return isTuiAgent(agent) ? TUI_AGENT_DISPLAY_NAMES[agent] : agent
}
/** Every ACP agent's handles live in one protocol's id space; the agent part keeps them apart. */
export const ACP_HANDLE_TRANSPORT = 'acp'
/** Session options an ACP agent takes: its model, and the model's reasoning effort. */
const ACP_OPTION_KEYS: ReadonlySet<string> = new Set(['model', 'effort'])
export function isAcpStructuredOptionKey(key: string): boolean {
return ACP_OPTION_KEYS.has(key)
}
/** What the host knows about an ACP agent before any of its sessions runs. */
export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAgentDefinition {
const { managedRuntime } = spec
return {
@@ -25,21 +22,20 @@ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAge
handleTransport: ACP_HANDLE_TRANSPORT,
...spec.account.pin,
capabilities: {
// ACP has no stable rewind or goal method; a command arrives through `/` instead.
// ACP has no stable rewind or goal methods.
rewind: false,
compact: spec.compaction === true,
threadGoal: false,
contextUsage: true,
// Narrowed per chat by what the agent advertises at its start.
// Narrowed by each agent's advertised startup capabilities.
imagePrompts: true,
steering: 'queue',
// Orca answers every permission request the agent sends; the agent decides when it asks.
approvalEnforcement: 'orca'
},
...(managedRuntime && { managedRuntime: () => managedRuntime.pin() !== null }),
restingOptions: {
acceptsKey: isAcpStructuredOptionKey,
// The agent lists its models over the protocol once it runs; Orca keeps no list of its own.
// Populate catalogs from live agents.
fallbackModels: () => null,
effortDefaultsToModel: false
}
@@ -1,5 +1,3 @@
// Antigravity's row against its recorded ACP server (1.3.0) and the environment it launches with.
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -43,7 +41,7 @@ it('signs a first chat in, asks in default mode, and answers a question with one
initialize: result(initialized),
script: (agent) => {
replay.attach(agent)
// A fresh folder names no sign-in method until a client picks one.
// The recorded server requires explicit authentication even with a fresh folder.
agent.on('session/new', (frame) =>
agent.frames.some((sent) => sent.method === 'authenticate')
? agent.reply(frame, { ...result(opened), sessionId: PROVIDER_SESSION })
@@ -98,7 +96,7 @@ it('launches on its own private folder with file sign-in and no other Google cre
})
expect(command).toBe(ANTIGRAVITY.command)
expect(env).toMatchObject({ GEMINI_HOME: folder, AGY_ACP_FORCE_FILE_STORAGE: '1' })
// A key deleted at spawn beats its overlay, so the file sign-in must not be among them.
// Spawn deletions override overlays; preserve the file sign-in keys.
expect(envToDelete.sort()).toEqual([
'ANTIGRAVITY_HARNESS_PATH',
'GCLOUD_PROJECT',
@@ -152,7 +150,6 @@ it('runs the chat permission mode picked mid-chat as the server mode on the next
}
expect(await host.setOption(CALLER, { envelope, ...fields })).toMatchObject({ ok: true })
await send(host, value)
// The child launched with the earlier mode is replaced before the send reaches it.
const previous = child
child = await waitFor(() => {
expect(rig.child()).not.toBe(previous)
@@ -225,7 +222,7 @@ it('stops a chat that has left the gate but is not loaded yet before the operati
agent.reply(frame, { ...result(opened), sessionId: PROVIDER_SESSION })
)
})
// Its start returned, so the gate has no start to abort and no loaded chat lists it yet.
// Acquisition ended, but no loaded chat owns this child yet.
await rig.acquire()
const child = rig.child()
let ranOverLiveChild: boolean | null = null
-4
View File
@@ -1,6 +1,3 @@
// Grok signs in on the machine it runs on: on auth required, with the API key in its own launch
// environment, else the sign-in it already cached; with neither, the chat reports it signed out.
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
@@ -22,7 +19,6 @@ const BOTH = [
{ id: 'cached_token', name: 'Cached sign-in' }
]
/** Grok refuses to open a session until it is authenticated. */
function requiresSignIn(agent: AcpScriptedAgent): void {
let signedIn = false
agent.on('authenticate', (frame) => {
@@ -39,7 +39,7 @@ async function fixture() {
const hosted = await openAttachedHostRig({}, childWork.sink)
cleanup.push(async () => {
const agent = hosted.rig.child().agent
// Only cleanup answers cancellation; task-stop assertions run before this.
// Only teardown cancellation ends the prompt; Stop assertions run first.
agent.on('session/cancel', () => {
const prompt = agent.frames.findLast((frame) => frame.method === 'session/prompt')
if (prompt) {
@@ -53,7 +53,6 @@ async function fixture() {
const agent = hosted.rig.child().agent
agent.notify('session/update', replyChunk(promptIdOf(prompt), 'Starting it'))
await hosted.rows()
// As the recorded s6-background Grok run sends them.
const background = async (taskId = TASK, extra: Record<string, unknown> = {}) => {
agent.notify('_x.ai/task_backgrounded', {
sessionId: PROVIDER_SESSION,
@@ -183,7 +182,7 @@ describe('Grok background commands in the background-tasks strip', () => {
expect(f.kills().at(-1)?.params).toEqual({ sessionId: PROVIDER_SESSION, taskId: TASK })
expect(f.agent.frames.filter((frame) => frame.method === 'session/cancel')).toEqual([])
expect(f.rig.child().closed).toBe(false)
// Held until Grok's own ending arrives, so the strip keeps the row in its stopping state.
// Keep the row stopping until Grok reports its ending.
expect((await f.strip())?.tasks?.map((task) => task.id)).toEqual([
CHILD,
'acp-task:sibling-task'
@@ -225,7 +224,6 @@ describe('Grok background commands in the background-tasks strip', () => {
expect(await f.strip()).toBeNull()
})
// As for Codex and Claude: running background work must be stopped before the chat is cleared.
it('refuses /clear while a background command runs', async () => {
const f = await fixture()
await f.background()
@@ -247,8 +245,7 @@ describe('Grok background commands in the background-tasks strip', () => {
})
})
// Grok's own refusal means it killed nothing; a reply about another task proves nothing. The Stop
// is accepted either way, and its note says which.
// Refusal proves no kill; a mismatched task reply leaves cancellation unknown.
it.each([
['rpc-error', "The background task wasn't stopped."],
['envelope-error', "The background task wasn't stopped."],
@@ -303,7 +300,6 @@ describe('Grok kill route check', () => {
expect(frame.params).toEqual({})
agent.fail(frame, -32601, 'Method not found')
})
// The session still offers Stop for its subagents, whose route answered.
expect(rig.adapter.backgroundTaskStops(ADAPTER_SESSION)?.supportsTaskStop).toBe(true)
expect(evidence).toMatchObject([
{ type: 'live', child: { handle: { id: CHILD }, kind: 'command', stoppable: false } }
@@ -23,7 +23,6 @@ afterEach(async () => {
await closeProviderTimelineRigs()
})
// Answers the handshake and echoes one reply chunk for the prompt, then runs `onPrompt`.
const agentScript = (onPrompt: string): string => String.raw`
const fs = require('node:fs')
fs.writeFileSync(process.env.ORCA_TEST_PID_FILE, String(process.pid))
@@ -66,7 +65,6 @@ const CLOSES_STDOUT_MID_TURN = `
process.stdin.once('end', () => process.exit(0))
${agentScript('fs.closeSync(1)')}
`
// Starts a process that inherits its stdout and outlives it, records that process, then exits.
const EXITS_HOLDING_STDOUT = agentScript(String.raw`
const held = require('@orca/process-host').spawnProcess({
program: process.execPath,
@@ -14,7 +14,7 @@ export class AcpStructuredChildren {
readonly sessions = new Map<string, AcpStructuredChild>()
readonly starts = new ProviderAcquisitionStarts<AcpStructuredConnection>()
constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {}
/** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */
/** Requires root exit; reports unproven descendant cleanup separately. */
async close(sessionId: string): Promise<boolean> {
const connection = this.sessions.get(sessionId)?.connection
const closed = await this.stop(sessionId, true)
@@ -28,8 +28,7 @@ export class AcpStructuredChildren {
return closed
}
/** True only once every child is proven gone, or when this adapter runs none for the session:
* a failed start's child, and the session's own. */
/** Requires exit of both the failed-start child and the session child, if present. */
async stop(sessionId: string, requested = true): Promise<boolean> {
const [failedStart, session] = await Promise.all([
this.starts.stopFailed(sessionId),
@@ -60,14 +59,14 @@ export class AcpStructuredChildren {
return session.closing
}
if (requested && session.journalClosed === null) {
// A close, dispose or quit ends a running turn as a Stop does before the child goes.
// Allow the running turn to settle before closing the process.
session.closeRequested = true
await windDownAcpTurn(session, this.stopGraceMs())
if (session.ended) {
return true
}
}
// A connection loss already decided why the child ends; a later stop does not relabel it.
// Preserve a connection loss's cause across later Stop calls.
if (session.journalClosed === null) {
session.closeRequested ||= requested
session.lane.flush()
@@ -79,7 +78,6 @@ export class AcpStructuredChildren {
return proven
}
/** The child's exit is proven: the host hears it, and nothing of the child stays here. */
finish(session: AcpStructuredChild, observedAt: number): void {
if (isAcpReadySession(session)) {
endAcpStructuredSession(session, observedAt, this.deps.onEvent)
@@ -109,8 +107,7 @@ export class AcpStructuredChildren {
}
}
/** The connection broke while the child may still run: nothing more it says can be journaled, so
* the journal closes now and the child is stopped; the host hears `ended` once that is proven. */
/** Close the journal on protocol loss; notify the host after process exit is proven. */
connectionLost(session: AcpStructuredChild | null, error: Error): void {
if (
!session ||
@@ -244,7 +244,6 @@ describe('recorded child-session outcome during a full parent Stop', () => {
await hosted.rows()
vi.setSystemTime(1791496678726)
agent.notify(selectedFinished.method, selectedFinished.params)
// One saved row per subagent, which the host writes from its records.
const subagents = async () =>
(await hosted.rows()).flatMap((row) =>
row.body.kind === 'message'
+6 -8
View File
@@ -50,8 +50,7 @@ async function probeAcpSubagentStop(
}
}
/** Only the agent naming the route unknown hides Stop; any other answer leaves the Stop itself to
* report what happened. */
/** Only an explicit unknown-route response hides Stop. */
async function probeAcpBackgroundTaskStop(
connection: AcpStructuredConnection,
dialect: AcpDialect
@@ -89,7 +88,7 @@ async function stopAcpSubagent(
try {
value = await session.connection.requestExtension(request.method, request.params)
} catch (error) {
// Grok answers an ended or unknown subagent normally, so its refusal means it did nothing; anything else leaves the effect unknown.
// Grok reports missing subagents normally; a refusal proves no cancellation occurred.
if (error instanceof AcpAgentError) {
return { cancelled: false, stillRunning: true }
}
@@ -99,7 +98,7 @@ async function stopAcpSubagent(
if (result.state === 'working') {
return { cancelled: result.cancelled }
}
// Not found is no acceptance; an answer that names how the subagent finished is its own ending.
// Missing is not accepted; a terminal state supplies its own outcome.
const outcome = result.state === undefined ? undefined : acpSubagentOutcome(result.state)
return result.cancelled || outcome !== undefined
? {
@@ -119,7 +118,7 @@ async function stopAcpBackgroundTask(
try {
value = await session.connection.requestExtension(request.method, request.params)
} catch (error) {
// Grok answers a task it no longer holds normally, so a refusal means it killed nothing.
// Grok reports missing tasks normally; a refusal proves nothing was killed.
if (error instanceof AcpAgentError) {
return { cancelled: false, stillRunning: true }
}
@@ -129,15 +128,14 @@ async function stopAcpBackgroundTask(
if (outcome === 'refused') {
return { cancelled: false, stillRunning: true }
}
// A killed task's own completion ends its row. A gone one settles here, and any completion
// Grok still sends for it (exited but not yet swept) overwrites this with the real result.
// Settle missing tasks now; later provider completion replaces this with its actual result.
if (outcome === 'gone') {
session.lane.apply(session.lane.translator.reconcileBackgroundTask(taskId))
}
return { cancelled: true }
}
/** The host has already resolved these handles from this parent's stoppable child records. */
/** Handles come from the host's authoritative stoppable-child records. */
export async function stopAcpChildren(
session: AcpStructuredSession,
fence: number,
@@ -21,7 +21,6 @@ afterEach(async () => {
await closeProviderTimelineRigs()
})
/** What Grok says about one subagent, translated as the adapter would. */
function subagent(lane: AcpStructuredLane, state: 'working' | 'completed'): AcpTimelineEvent[] {
const update =
state === 'working'
@@ -24,8 +24,6 @@ export function acpChildWorkStatusSink() {
return { sink, views: () => (parent ? server.getStructuredChildWorkViews(parent) : []) }
}
/** A host holding one working Grok parent: what a translator reported becomes its child records,
* the one authority on each subagent. */
export function acpSubagentRecords() {
const { sink, views } = acpChildWorkStatusSink()
const subject = makeStructuredAgentStatusSubject(
@@ -37,7 +37,6 @@ function cleanupScriptedHost(hosted: Awaited<ReturnType<typeof openHostRig>>) {
const scriptedPrompt = prompt
if (scriptedPrompt) {
const { agent } = hosted.rig.child()
// Only teardown's actual cancel ends this fixture's scripted prompt.
agent.on('session/cancel', () => agent.reply(scriptedPrompt, { stopReason: 'cancelled' }))
}
await hosted.host.close(SESSION, 'user-close')
@@ -190,7 +189,7 @@ describe('Grok roster evidence through the existing host child store', () => {
const f = await fixture()
await f.spawn('first')
await f.spawn('sibling')
// Each saved row keeps the raw label; the shared presentation derives the ordinal, as the strip does.
// Ordinals belong to presentation; persisted labels stay unchanged.
expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label' }])
expect((await f.strip())?.tasks).toMatchObject([
{ id: 'first', kind: 'agent', description: 'Exact label' },
@@ -222,7 +221,7 @@ describe('Grok roster evidence through the existing host child store', () => {
})
})
expect(await f.targetedStop('first')).toMatchObject({ ok: true, value: { cancelled: true } })
// The accepted Stop settles that run until Grok's own ending says how it ended.
// Grok's eventual ending can revise the provisional Stop outcome.
expect(f.childWork.views()).toMatchObject([
{ providerId: 'first', membership: 'settled', outcome: 'cancelled' },
{ providerId: 'sibling', membership: 'live' }
@@ -416,7 +415,7 @@ describe('Grok roster evidence through the existing host child store', () => {
}
})
// Grok's own refusal means it did nothing; a reply about another subagent proves nothing, so it is
// Refusal proves no cancellation; a mismatched reply leaves the outcome unknown.
// unknown. The Stop is accepted either way, and its note says which.
it.each([
['rpc-error', "The background task wasn't stopped."],
+1 -1
View File
@@ -39,7 +39,7 @@ export function acpStartupFailure(
error: unknown
): SubmissionRejectionFact | undefined {
if (error instanceof AcpAgentError && acpAuthenticationRequired(session.spec.dialect, error)) {
// The agent's sign-in words, never log lines it wrote before refusing.
// Use the agent's sign-in answer, excluding preceding diagnostic output.
return acpSignInRequiredFailure(session.spec.dialect, error)
}
return undefined
@@ -27,7 +27,6 @@ afterEach(async () => {
const OPENCODE = acpLaunchSpecFor('opencode')!
const OMP = acpLaunchSpecFor('omp')!
/** The command turn the host writes before it hands `/compact` over. */
async function commandRun(rig: AcpAdapterRig): Promise<StructuredAgentSessionCommandRun> {
const turn = structuredAgentSessionCommandTurn('compact-1')
const running = agentJournalTurnBody({ turnId: turn.turnId, state: 'running', startedAt: 4_000 })
@@ -38,7 +37,6 @@ async function commandRun(rig: AcpAdapterRig): Promise<StructuredAgentSessionCom
return { clientMessageId: 'compact-1', ...turn, running }
}
/** Starts the agent, sends `/compact`, and returns its prompt frame. */
async function compact(spec: AcpLaunchSpec, frames?: AcpFixtureFrame[]) {
const rig = await openAcpAdapterRig({ spec })
new StructuredAgentRegistry([
@@ -64,7 +62,6 @@ function replyText(text: string, promptId?: string) {
}
}
/** The command's turn once the agent's answer ended it, and the rows inside it. */
async function ended(rig: AcpAdapterRig, command: StructuredAgentSessionCommandRun) {
return waitFor(async () => {
const rows = await rig.rig.rows()
+1 -6
View File
@@ -1,11 +1,6 @@
// The ACP agent as the adapter holds it: one connection that owns both the agent's process and its
// protocol (`createAcpAgentConnection`). Supervision, the process-tree teardown, Windows-safe
// spawning and stdio are the connection's; turns, Stop and steering are the adapter's.
import type { AcpAgentConnection, AcpAgentConnectionOptions } from './acp-agent-connection'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
/** What the adapter uses of a connection; tests stand in a scripted agent behind the same surface. */
export type AcpStructuredConnection = Pick<
AcpAgentConnection,
| 'initialize'
@@ -34,7 +29,7 @@ export type ConnectAcpAgent<Connection = AcpStructuredConnection> = (
options: AcpAgentConnectionOptions
) => Connection
/** Resolves once `connection`'s process exits, `signal` aborts, or `ms` pass, whichever is first. */
/** Waits for process exit, abort or timeout. */
export function waitForAcpExit(
connection: AcpStructuredConnection,
ms: number,
@@ -1,7 +1,4 @@
// Plays a recorded Grok session back as the agent behind the real adapter. Orca's own frames
// advance the recording: the agent's lines after each one are sent until the recording waits on
// Orca again. The recording's prompt ids and session id are rewritten to the ones this run uses,
// as the real agent echoes the prompt id it was sent.
// Replay recorded traffic with this run's session and prompt IDs, advancing on Orca's frames.
import { expect } from 'vitest'
import { z } from 'zod'
@@ -9,7 +6,6 @@ import type { AcpFixtureFrame } from './acp-timeline-fixture.test-support'
import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support'
import { PROVIDER_SESSION } from './acp-structured-adapter.test-support'
/** Recordings name their sessions `session-N`; a replay plays them all as this run's one. */
const RECORDED_SESSION = /"session-\d+"/g
const promptMetaSchema = z.object({ _meta: z.object({ promptId: z.string() }) })
const messageSchema = z.object({
@@ -41,7 +37,6 @@ export class GrokFixtureReplay {
agent.on('session/cancel', (frame) => this.orcaSent('session/cancel', frame))
}
/** What the recording waits for Orca to send next; null once it has played to the end. */
get awaiting(): string | null {
if (this.responseFailure !== undefined) {
throw this.responseFailure
@@ -76,7 +71,7 @@ export class GrokFixtureReplay {
this.cursor += 1
const message = this.rewrite(recorded.message)
if (message.method !== undefined && message.id !== undefined) {
// The agent asks Orca something; the recording resumes once Orca answers it.
// Resume only after Orca answers the agent's request.
void agent
.request(message.id, message.method, message.params)
.then((response) => {
@@ -1,7 +1,3 @@
// The adapter against Grok's recorded subagents (Grok 1.0.46): each recording plays back as the
// agent, and the assertions read what it reports to the host about each subagent and the reply
// rows a client would see.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
@@ -46,7 +42,6 @@ function send(rig: AcpAdapterRig, clientMessageId: string) {
type Reported = { id: string; label?: string; outcome?: string; tokens?: number }
/** Each subagent as the adapter last reported it to the host. */
function subagents(evidence: readonly AgentChildWorkEvidence[]): Reported[] {
const reported = new Map<string, Reported>()
for (const edge of evidence) {
+4 -10
View File
@@ -1,8 +1,4 @@
// An ACP child's handshake, run after the host has published it: initialize, reopen the chat's
// session or start a new one, then return its protocol facts to the host. A saved session the
// agent cannot reopen is replaced by a new one, with a warning row that any later start writes if
// this one never did; while a reopened session replays history the journal already holds, nothing
// it sends is written except context usage.
// Suppress replayed history during reattach; preserve context usage.
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
@@ -57,7 +53,7 @@ export async function initializeAcpStructuredSession(
assertStarting()
const initialized = await connection.initialize()
assertStarting()
// Chosen here, on the machine Grok runs on, from the environment it was launched with.
// Choose authentication using the execution host's launch environment.
const authMethodId = spec.authMethod?.({
advertised: (initialized.authMethods ?? []).map((method) => method.id),
env: launch.env
@@ -87,8 +83,6 @@ export async function initializeAcpStructuredSession(
now: now(),
warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId })
})
// A new session takes the old one's place, with a new lane, so nothing of the failed
// attach's window outlives it.
}
}
if (!started || !liveLane) {
@@ -104,7 +98,7 @@ export async function initializeAcpStructuredSession(
liveLane.apply(liveLane.translator.contextModels(started.response.models, now()))
const mode = spec.permissionModeConfig
if (mode) {
// A refusal fails the start: the session would otherwise run another permission mode.
// Fail startup rather than silently use a different permission mode.
const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask
const configId = options.modeConfigId()
const { configOptions } = await connection.setConfigOption(configId, requested)
@@ -140,7 +134,7 @@ export async function initializeAcpStructuredSession(
if (connection.exited || connection.closed) {
throw new Error(connection.stderrTail() || `${spec.command} exited while starting`)
}
// The chat says once per lost conversation that the agent forgot it, this start's loss included.
// Include warning rows missed by earlier failed starts.
const lost = [
...(launch.resume?.unannouncedLosses() ?? []),
...(takeover?.replaces ? [takeover.replaces.key] : [])
@@ -1,6 +1,4 @@
// Grok's session-free listing can name a model (`initialize`'s currentModelId) other than the one a
// session runs. The user's case: the listing said Grok 4.7, every chat ran Grok 4.6. Through the
// real Grok probe, adapter and host, a new chat's first frame names only what a chat ran.
// Regression: initialize listed Grok 4.7 while new sessions actually ran Grok 4.6.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire'
@@ -44,7 +42,6 @@ const EFFORT_META = {
const OTHER_CHAT = 'grok-chat-2'
/** Another Grok chat in the same workspace, as the host's record of it. */
function otherChat(store: AgentSessionRecordStore | null) {
const first = store?.getRecord(SESSION)
return first ? { ...first, sessionId: OTHER_CHAT } : undefined
@@ -58,7 +55,6 @@ afterEach(async () => {
await closeProviderTimelineRigs()
})
/** Grok's `initialize` with no session: it computes 4.7 as its current model. */
function grokProbe() {
return createAcpModelCatalogProbe(GROK, {
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
@@ -94,7 +90,6 @@ function grokProbe() {
})
}
/** What a new Grok chat's composer paints first from a host answer. */
function firstFrame(answer: AgentSessionModelCatalogResult) {
const seed = structuredAgentSessionSeedCatalog('grok')
const state = applyStructuredAgentSessionModelCatalog(
@@ -111,8 +106,7 @@ function firstFrame(answer: AgentSessionModelCatalogResult) {
return { model: select('model'), effort: select('effort') }
}
/** The host's listing, then a Grok chat opened with no model pick whose own session runs `runs`;
* `create` founds it with its first message, so only delivery starts the agent. */
/** With `create`, only first-message delivery starts the agent. */
async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMessage) {
const records: { store: AgentSessionRecordStore | null } = { store: null }
const discovery = GROK.modelDiscovery
@@ -127,7 +121,6 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe
agentReadsProjectModelConfig,
workspaceMayOverrideDefaultModel,
probes: { grok: grokProbe() },
// As the launch spec says, not by hand.
listingNamesConfiguredModel: new Set(
discovery.kind !== 'unavailable' && discovery.listingNamesConfiguredModel ? ['grok'] : []
)
@@ -150,7 +143,6 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe
: rig.host.attach(CALLER, attachParams())
expect(await opening).toMatchObject({ ok: true })
if (!create) {
// The attach answers at spawn; what the chat runs is known once its start is proven.
await rig.ready()
}
return { ...rig, catalog, listed }
@@ -167,7 +159,6 @@ describe('Grok’s default comes from what a chat with no pick runs', () => {
const { catalog, host } = await openGrokHost('grok-4.6')
expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' })
// The next new chat, in any workspace: Grok reads no project config for its model.
const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' })
expect(answer).toMatchObject({
listingNamesConfiguredModel: true,
@@ -182,10 +173,9 @@ describe('Grok’s default comes from what a chat with no pick runs', () => {
body: hostTestMessage('hello')
})
await catalog.read({ agent: 'grok', waitForListing: true })
// Delivery started the agent and handed it the opening message; nothing picked a model.
const prompt = await rig.frame('session/prompt')
rig.child().agent.reply(prompt, { stopReason: 'end_turn' })
// Learned from the start the delivery made, before any options read.
// The start taught the catalog before any options read.
expect(firstFrame(await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }))).toEqual({
model: 'grok-4.6',
effort: 'high'
@@ -199,14 +189,13 @@ describe('Grok’s default comes from what a chat with no pick runs', () => {
it('a chat’s later reads teach nothing: a newer chat’s default stands', async () => {
const { catalog, host } = await openGrokHost('grok-4.6')
// Grok's default moved since: a newer chat with no pick starts on 4.7 and says so at its start.
// A newer unpicked session reports the account's changed default.
const newer = new AcpStructuredOptions()
newer.adoptSession(
{ configOptions: SessionConfigOptionSchema.array().parse(GROK_CONFIG_OPTIONS) },
'new'
)
catalog.recordLiveListing(OTHER_CHAT, newer.startListing())
// The first chat keeps running and reads its options again, as every turn does.
await host.readOptions(SESSION)
const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' })
expect(firstFrame(answer)).toEqual({ model: 'grok-4.7', effort: 'high' })
+3 -13
View File
@@ -1,8 +1,3 @@
// A Grok crash mid-reply, through the real host: the host's settlement of the proven exit writes one
// `provider-exit:` batch whose cut-short row reports on the turn the crash ended, carrying the
// adapter's failure with Grok's last words. Whether Grok's pipes closed before its exit was seen
// changes only what the chat shows in between.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readAgentSessionFailureFact } from '../../shared/agent-session-failure'
import { journalLifecycleMutationItemId } from '../native-chat/agent-session-journal/journal-row-builders'
@@ -20,7 +15,6 @@ afterEach(async () => {
const LAST_WORDS = 'qa-cell20-last-words'
/** Grok mid-reply ("Oak") with a command running, every lifecycle batch the journal takes recorded. */
async function midReply() {
const rig = await openAttachedHostRig()
const { host } = rig
@@ -38,8 +32,7 @@ async function midReply() {
batches.push(batch)
return append(batch)
})
// A settlement plans its batch at its own turn in the write queue: recorded as it is planned, and
// only when it writes something (an empty plan writes no row).
// Capture nonempty settlements when planned in the write queue.
const appendPlanned = journal.appendPlannedLifecycleBatch
vi.spyOn(journal, 'appendPlannedLifecycleBatch').mockImplementation((input) =>
appendPlanned({
@@ -91,8 +84,7 @@ describe('a Grok crash with its exit seen first', () => {
rig.child.exit()
const settled = await settledCrash(rig)
expectOneTurnScopedCutShortRow(settled)
// The child's own end landed first, interrupted at the exit and never `unverifiable`: the host
// has no turn or call of it left to revise.
// Provider exit already interrupted the turn; only its notice remains to write.
expect(
settled.batch.mutations.map((mutation) => mutation.kind === 'item' && mutation.body.kind)
).toEqual(['status'])
@@ -103,7 +95,7 @@ describe('a Grok crash with its exit seen first', () => {
describe('a Grok crash whose pipes close before its exit is seen', () => {
it('is unverifiable only until the exit is proven; the provider-exit batch then revises the turn', async () => {
const rig = await midReply()
// The child outlives its stream until Orca's close of it is proven.
// Keep the process alive after stream loss until close is proven.
let exitNow: () => void = () => undefined
rig.child.proveClose = () =>
new Promise((resolve) => {
@@ -121,14 +113,12 @@ describe('a Grok crash whose pipes close before its exit is seen', () => {
exitNow()
const settled = await settledCrash(rig)
expectOneTurnScopedCutShortRow(settled)
// The turn's revision, its call's and the row land together.
const turnKey = settled.turn!.itemId
expect(
settled.batch.mutations.flatMap((mutation) =>
mutation.kind === 'item' ? [journalLifecycleMutationItemId(mutation)] : []
)
).toEqual(expect.arrayContaining([settled.statuses[0]!.itemId, turnKey]))
// No later stale-session pass rewrites it: nothing is left for one.
expect(rig.batches.filter((batch) => batch.settlementId.includes('stale-session'))).toEqual([])
await rig.host.close(SESSION, 'user-close')
})
@@ -1,7 +1,3 @@
// The ACP adapter behind the real host: the host's own order (a fresh sink per start, bound only
// once the start proved its owner; Close and Stop reaching a start from outside the session's
// queue), with a scripted Grok, the real record store and an on-disk journal.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
import { readAgentSessionFailureFact } from '../../shared/agent-session-failure'
@@ -35,8 +31,7 @@ afterEach(async () => {
await closeProviderTimelineRigs()
})
/** On every reopen, Grok sends what its load may carry: the saved `reply` marked as replay, and a
* task the dead process left running, ended by the restart. */
/** Replay history and end the task left behind by the prior process. */
function loads(reply: string, count: { loads: number }) {
return (agent: AcpScriptedAgent) =>
agent.on('session/load', (frame) => {
@@ -93,7 +88,6 @@ describe('reopening a Grok chat through the host', () => {
await ready()
resumed = true
await exchange('complete', false)
// Grok dies mid-reply; the host ends its record, which moves the chat's fence.
const cutAt = fence()
rig.child().exit()
await waitFor(() => expect(fence()).toBeGreaterThan(cutAt))
@@ -105,7 +99,6 @@ describe('reopening a Grok chat through the host', () => {
expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).not.toContain(
'completed'
)
// The transcript explains the cut the way a Claude or Codex chat's does.
const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' })
expect(
transcript.some(
@@ -135,7 +128,7 @@ describe('a Grok crash whose exit is not proven yet', () => {
return proves
}
child.stderr = 'panic: out of memory'
// Both pipes close as the process dies, before its exit is seen.
// Model pipes closing before the process exit is observed.
child.agent.close()
await rig.settle()
const next = await send(host, 'next')
@@ -155,7 +148,6 @@ describe('a Grok crash whose exit is not proven yet', () => {
await rig.settle()
await host.flushStreamedEvents(SESSION)
const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' })
// The crash's own notice, as for any crash; not a Stop's.
expect(
transcript.flatMap((row) =>
row.body.kind === 'status'
@@ -206,7 +198,6 @@ describe('closing or stopping a Grok chat while it starts', () => {
const attaching = host.attach(CALLER, attachParams())
await rig.frame('initialize')
const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) })
// With no close behind it.
await waitFor(() => expect(rig.child().exited).toBe(true))
expect((await attaching).ok).toBe(true)
expect(await stopping).toMatchObject({ ok: true })
@@ -224,7 +215,6 @@ describe('closing or stopping a Grok chat while it starts', () => {
}
})
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
// The chat closes; the next send has to start Grok again, and that start never answers.
await host.close(SESSION, 'user-close')
await host.send(CALLER, {
envelope: envelope('agentSession.send', { body: hello }),
@@ -297,7 +287,7 @@ describe('a Grok send whose acceptance could not be written', () => {
vi.spyOn(journal, 'acceptLate').mockRejectedValue(new Error('disk full'))
const sent = await send(host, 'hello')
const prompt = await rig.frame('session/prompt')
// Grok names the prompt only with its first frame: until then it holds the send.
// The first provider frame supplies prompt identity and proves acceptance.
await waitFor(() => expect(host.currentWork(SESSION)?.working()).toBe(true))
rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'hi'))
@@ -305,7 +295,7 @@ describe('a Grok send whose acceptance could not be written', () => {
await rig.settle()
await waitFor(() => expect(host.currentWork(SESSION)?.working()).toBe(false))
// The acceptance never landed: the row is still the handover's alone.
// Rejected journal admission leaves the send pending.
expect(
(await host.journalSnapshot(SESSION)).submissions.find((row) => row.clientMessageId === sent)
).toMatchObject({ dispatchState: 'pending' })
@@ -7,8 +7,6 @@ import {
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import { GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support'
// A running Grok chat's own model list, read through the real host, reaches the account's saved
// catalog: the next chat opens warm even when a session-free listing failed.
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
@@ -97,11 +95,9 @@ describe('a live Grok listing', () => {
stop: vi.fn(),
providerStarted: vi.fn()
}
// The rig waits for the child to turn ready: bookkeeping never fails a proven start.
const { host, rig, store, fence } = await openAttachedHostRig({}, undefined, modelCatalog)
expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1)
expect(store.getRecord(SESSION)?.lease.ownerProcess).toEqual(expect.any(Object))
// The rest of the start still lands, and live options remain readable.
await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) })
expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(2)
expect(
@@ -1,6 +1,3 @@
// A model or effort pick Grok never answers, through the real host: it must not hold the chat's
// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import {
@@ -34,7 +31,7 @@ describe('a Grok model pick Grok never answers during send', () => {
const invalid = host.cancel(CALLER, {
envelope: envelope('agentSession.cancel', {}, { payloadFingerprint: 'invalid' })
})
// The write runs outside the session's serialize, so even a refused Stop is answered at once.
// Stop bypasses the session queue even while an option write stalls.
await expect(invalid).resolves.toMatchObject({ ok: false })
const request = { envelope: envelope('agentSession.cancel', {}) }
await expect(host.cancel(CALLER, request)).resolves.toMatchObject({ ok: true })
@@ -86,7 +83,6 @@ describe('a Grok model pick Grok never answers during send', () => {
)
vi.useRealTimers()
expect(framesOf(rig.child(), 'session/prompt')).toEqual([])
// The chat's queue moved on: a later operation runs.
await expect(host.readOptions(SESSION)).resolves.toMatchObject({
current: { model: savedModel }
})
@@ -1,8 +1,3 @@
// A Grok session Grok cannot reopen, through the real host, record store, journal and launch
// resolver: a fresh session continues the chat, the chain records what it replaced, and the chat
// says so once, unless nothing was ever exchanged on the lost session. A start that succeeds
// writes the row an earlier attach failure dropped.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { readAgentSessionFailureFact } from '../../shared/agent-session-failure'
@@ -39,7 +34,6 @@ const keyOf = (nativeId: string) =>
type Lost = Map<string, { code: number; message: string }>
/** Grok opens sessions in turn and cannot reopen any session in `lost`; it reopens every other. */
function sessionsLost(loads: string[], lost: Lost) {
const opened = [PROVIDER_SESSION, FRESH, FRESH_2]
return (agent: AcpScriptedAgent) => {
@@ -102,7 +96,6 @@ async function openRestoreRig(code: number, message: string) {
readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored'
)
}
/** A send Grok answers in `providerSession`, ending its turn. */
const exchange = async (text: string, reply: string, providerSession: string) => {
await send(host, text)
const prompt = await rig.frame('session/prompt')
@@ -148,7 +141,6 @@ describe('a saved Grok session Grok cannot reopen', () => {
text: "Grok couldn't reopen its earlier session, so this chat continues in a new one. Grok doesn't remember the earlier messages."
})
// The chat keeps working in the fresh session.
await exchange('still there?', 'yes', FRESH)
const messages = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items
.filter((row) => row.body.kind === 'message')
@@ -160,7 +152,6 @@ describe('a saved Grok session Grok cannot reopen', () => {
])
)
// The next reopen loads the fresh session, and says nothing new.
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
@@ -195,10 +186,8 @@ describe('a created Grok session Grok reports missing on the first reopen', () =
})
expect(head?.supersedesKey).toBeUndefined()
expect(await warnings()).toHaveLength(1)
// The person still sees what Grok forgot.
expect(await messages()).toEqual(expect.arrayContaining(['remember 42', 'noted']))
// The next reopen loads the fresh session, and says nothing new.
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
@@ -240,7 +229,7 @@ describe('a replacement after its earlier attach failed to open the journal', ()
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
await host.close(SESSION, 'user-close')
// The journal must open before a spawned child's background handshake proves a new link.
// Fail before the spawned child's background handshake can prove a replacement link.
vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce(
new Error('journal path unavailable')
)
@@ -318,7 +307,6 @@ describe('a replacement Grok never saved, superseded after its row was written',
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH])
expect(await warnings()).toHaveLength(1)
// The newer session carries the same loss, so reopening it says nothing new.
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
@@ -1,6 +1,3 @@
// A Grok start the host aborts — at quit, by a close, or by an admitted Stop — through the one
// signal the host owns, with a scripted Grok, the real record store and an on-disk journal.
import { afterEach, describe, expect, it } from 'vitest'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
@@ -35,7 +32,7 @@ async function within<T>(promise: Promise<T>, ms: number): Promise<T | typeof ST
}
}
/** Runs `atEnd` once, right before the host ends an attach's controller: after its commit. */
/** Runs after the attach commits, before its controller ends. */
function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): void {
const aborts = host.collaboratorsForTests().runtimeState.acquireAborts
const begin = aborts.begin.bind(aborts)
@@ -55,7 +52,7 @@ function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void):
}
}
/** Runs `during` once, while the attach is still probing the previous owner, before any launch. */
/** Runs during owner probing, before launch. */
function duringOwnerProbe(host: StructuredAgentSessionHost, during: () => void): void {
const state = host.collaboratorsForTests().runtimeState
const probe = state.probeOwner.bind(state)
@@ -76,7 +73,6 @@ describe('a Grok start the host aborts', () => {
})
const attaching = host.attach(CALLER, attachParams())
await rig.frame('initialize')
// The start has no bound of its own: the quit is what ends it.
expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED)
expect((await attaching).ok).toBe(true)
expect(rig.child().exited).toBe(true)
@@ -122,7 +118,6 @@ describe('a Grok start the host aborts', () => {
it('never launches Grok for a start a Stop reached while its attach was still reconciling', async () => {
const { rig, host } = await openHostRig()
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
// The chat closes; the next send has to start Grok again.
await host.close(SESSION, 'user-close')
let stopping: ReturnType<typeof stop> | undefined
duringOwnerProbe(host, () => {
@@ -145,7 +140,6 @@ describe('a Grok start the host aborts', () => {
let second: Promise<string> | undefined
duringOwnerProbe(host, () => {
stopping = stop(host)
// Accepted while the aborted start is still unwinding.
second = send(host, 'second')
})
await send(host, 'hello')
@@ -158,7 +152,6 @@ describe('a Grok start the host aborts', () => {
it('starts Grok afresh for a message sent right after a Stop ended a hung handshake', async () => {
const { rig, host, children } = await openClosedResumableChat({ hangsHandshake: 2 })
const first = send(host, 'hello')
// The restart's own child, not the first one's.
await waitFor(() => expect(children()).toBe(2))
await rig.frame('initialize')
const stopping = stop(host)
@@ -170,8 +163,7 @@ describe('a Grok start the host aborts', () => {
})
})
/** A Grok chat the user closed; each later start loads it. `hangsHandshake`: that child never
* answers `initialize`. */
/** `hangsHandshake` selects a child that never answers initialize. */
async function openClosedResumableChat(options: { hangsHandshake?: number } = {}) {
let children = 0
let resumed = false
@@ -194,7 +186,6 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {}
return { ...rig, children: () => children }
}
/** The stopped message reads cancelled; the one sent after it reached Grok, with no failure row. */
async function expectDeliveredAfterStop(
{ rig, host }: Pick<Awaited<ReturnType<typeof openHostRig>>, 'rig' | 'host'>,
secondId: string
@@ -1,6 +1,4 @@
import { deferred } from './acp-scripted-agent.test-support'
// A Grok chat's Stop through the real host: it ends the process once Grok settles its turn, and the
// next send resumes the session.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice'
@@ -31,7 +29,7 @@ describe('a Grok chat Stop', () => {
await send(host, 'hello')
const prompt = await rig.frame('session/prompt')
first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial'))
// Grok takes a moment to wind the turn down; the host waits for it before ending the process.
// Delay cancellation completion to verify that process close waits for the turn.
const answered: { cancel: boolean; atClose: boolean | null } = { cancel: false, atClose: null }
const cancellation = deferred<void>()
first.agent.on('session/cancel', () => cancellation.resolve())
@@ -63,7 +61,6 @@ describe('a Grok chat Stop', () => {
expect(count.loads).toBe(1)
expect(framesOf(second, 'session/new')).toHaveLength(0)
expect(await messages()).toEqual(['hello', 'partial', 'again'])
// Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for.
const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' })
expect(
transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : []))
@@ -82,7 +79,7 @@ describe('a Grok chat Stop', () => {
first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial'))
await rig.settle()
await host.flushStreamedEvents(SESSION)
// A write that never completes, as a full pipe to a Grok that stopped reading.
// Model a full pipe to an agent that stopped reading.
let cancels = 0
const deadlineStarted = deferred<void>()
let deadlineElapsed = false
@@ -154,7 +151,7 @@ describe('a Grok chat Stop', () => {
await host.flushStreamedEvents(SESSION)
await stop(host)
await waitFor(() => expect(first.exited).toBe(true))
// The task ended with Grok's process; Orca cannot say how, so its row says no more than that.
// Process exit ends observation without proving the task's own outcome.
const task = (await rows()).find((row) => row.itemId.includes('background-task'))
expect(task?.body).toMatchObject({
blocks: [
@@ -196,8 +193,6 @@ describe('a Grok chat Stop naming a turn that has ended', () => {
await host.close(SESSION, 'user-close')
})
// A late Stop is an accepted no-op, as a Claude Stop is: the follow-up handed over but not yet
// echoed is left alone.
it('leaves Grok and the next prompt alone in the gap before Grok echoes it', async () => {
const { rig, host, rows, turns } = await openAttachedHostRig()
const { agent } = rig.child()
@@ -47,7 +47,7 @@ async function stoppingChild(reportsOutcome: boolean) {
stdio: [agent.stdin, agent.stdout, stderr, null, null] satisfies PipedChildProcess['stdio']
})
const spawn = vi.fn<PipedProcessSpawner>(() => child)
// Teardown is simulated: no signal or process-table operation can reach the machine.
// Stub teardown so fixture PIDs cannot reach real process operations.
teardown.mockImplementation(async () => {
child.emit('exit', 0, null)
return 'exited'
@@ -155,7 +155,6 @@ describe('Grok child outcome during a full host Stop', () => {
const [unavailable] = await fixture.rosters()
expect(fixture.childWork.views()).toMatchObject([{ membership: 'settled', outcome: 'unknown' }])
expect(unavailable.row.itemId).toBe(fixture.original.row.itemId)
// Contact was lost when the session ended, a moment the host observed.
expect(unavailable.block).toEqual({
...fixture.original.block,
state: 'unverifiable',
@@ -1,6 +1,3 @@
// The ACP adapter behind the real host, with a scripted Grok, the real record store and an
// on-disk journal.
import { expect } from 'vitest'
import { z } from 'zod'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
@@ -57,7 +54,6 @@ export const attachParams = (fence: number | null = null) =>
providerHandle: undefined
})
/** A launch that resumes the provider session once the chat has one. */
export function launch(resume: () => boolean, spec = GROK): () => Promise<AcpStructuredLaunch> {
return async () => ({
spec,
@@ -95,7 +91,7 @@ export async function openHostRig(
const state = hostTestState()
const store = await openTestAgentSessionRecordStore(state.root)
let generation = 0
// As the runtime wires it: every exit and late send settlement the adapter observes reaches the host.
// Forward lifecycle and late-send settlements as the runtime does.
const hosted: { host: StructuredAgentSessionHost | null } = { host: null }
const journalDatabase = openTestJournalHostDatabase(state.root)
const rig = await openAcpAdapterRig({
@@ -142,7 +138,6 @@ export async function openHostRig(
(await host.history({ sessionId: SESSION, direction: 'tail' })).page.items
.filter((row) => row.body.kind === 'message')
.map((row) => messageText(row.body))
/** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */
const exchange = async (text: string, end: boolean) => {
await ready()
const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal
@@ -167,7 +162,6 @@ export async function openHostRig(
return { rig, host, store, journalDatabase, fence, messages, exchange, ready }
}
/** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */
export const RESUMES = {
agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } }
}
@@ -198,7 +192,6 @@ export function stop(host: StructuredAgentSessionHost, turnId?: string) {
export const framesOf = (child: FakeAcpChild, method: string) =>
child.agent.frames.filter((frame) => frame.method === method)
/** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */
export async function openAttachedHostRig(
deps: Partial<AcpStructuredSessionAdapterDeps> & {
script?: (agent: AcpScriptedAgent) => void
@@ -10,7 +10,6 @@ import { openAcpAdapterRig, type AcpAdapterRig } from './acp-structured-adapter.
afterEach(closeProviderTimelineRigs)
const DATA = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64')
// What the chat's options report says, by the same rule as the send.
function acceptsImages(rig: AcpAdapterRig) {
return rig.adapter
.readOptions({ sessionId: SESSION, fence: 1 })
@@ -43,7 +42,6 @@ describe('ACP image prompts', () => {
]
})
await expect(acceptsImages(rig)).resolves.toBe(true)
// The start's answer, not a second handshake.
expect(rig.sent('initialize')).toHaveLength(1)
})
+7 -17
View File
@@ -1,6 +1,4 @@
// One ACP child's path into the journal: the translator turns its traffic into grammar events, and
// the shared assembler writes them. Events apply strictly in order; one the sink refuses under
// backpressure holds every later one until the sink drains, so nothing is reordered or dropped.
// Preserve event order across journal backpressure; hold later events until the sink accepts.
import type { StructuredAgentSessionChildWorkProducer } from '../native-chat/agent-session-wire/structured-agent-session-subagent-rows'
import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key'
@@ -26,26 +24,23 @@ import {
} from '../native-chat/agent-session-wire/structured-agent-session-logger'
import type { AcpTextDrop } from './acp-turn-messages'
/** How long a held event waits for the sink to say it drained before trying again on its own. */
const BACKPRESSURE_RETRY_MS = 250
/** Subagent evidence holds its place among the events: it reaches the host only after the journal
* wrote what came before it. */
// Publish child-work evidence only after preceding journal events commit.
type SubagentWork = { type: 'subagent.work'; evidence: AgentChildWorkEvidence[] }
export type AcpStructuredLaneDeps = {
sink: ProviderTimelineSink
sessionId: string
agent: string
/** The agent's name as a person reads it, for the rows that name it. */
agentName: string
generation: string
providerSessionId: string
dialect: AcpDialect
logger: StructuredAgentSessionLogger
/** A send of Orca's reached the agent: the turn it opened has its first provider event. */
/** First provider event confirms receipt of an Orca send. */
onInputAccepted: (clientMessageId: string) => void
/** The sink refused for good; nothing more this child says can be journaled. */
/** Permanent sink refusal prevents further journaling. */
onFailed: (reason: string) => void
onChildWorkEvidence?: (evidence: AgentChildWorkEvidence[]) => void
onChildWorkFailure?: (error: unknown) => void
@@ -90,7 +85,6 @@ export class AcpStructuredLane {
return this.assembler.openTurnId
}
/** The host already opened this command's turn; the agent's frames and end join that turn. */
beginCommand(command: StructuredAgentSessionCommandRun): void {
if (this.failed || this.disposed || this.backlog.length > 0) {
throw new Error('ACP timeline has not drained for this command')
@@ -114,7 +108,7 @@ export class AcpStructuredLane {
this.drain()
}
/** Resolves once `turnId` is no longer the open turn, or nothing more can be written. */
/** Resolves when the turn leaves or further writes become impossible. */
whenTurnLeaves(turnId: string): Promise<void> {
return new Promise((resolve) => {
const check = (): void => {
@@ -128,13 +122,11 @@ export class AcpStructuredLane {
})
}
/** The sink drained: whatever it held back goes now. */
retry(): void {
this.drain()
}
/** Whether the journal row `itemId` is the row of request `requestKey`, which the assembler
* writes under this acquisition's generation (any incarnation a reused key adds). */
/** Matches this acquisition's request row, including reused-key incarnations. */
isRequestRow(itemId: string, requestKey: string): boolean {
const identity = parseAgentJournalItemKey(itemId)
if (identity?.provider !== 'legacy' || identity.sessionId !== this.deps.sessionId) {
@@ -204,7 +196,7 @@ export class AcpStructuredLane {
return
}
this.backlog.shift()
// A subagent the provider reports after its session ended is no live child of it.
// Ignore live-child evidence after session end.
this.ended ||= event.type === 'session.ended' && dropped === undefined
if (event.type === 'input.accepted') {
this.deps.onInputAccepted(event.clientMessageId)
@@ -233,7 +225,6 @@ export class AcpStructuredLane {
this.deps.onFailed(reason)
}
/** False when the sink held it back; it stays first in the backlog. */
private queueSubagentWork(evidence: AgentChildWorkEvidence[]): boolean {
if (this.ended) {
return true
@@ -250,7 +241,6 @@ export class AcpStructuredLane {
return admission.accepted
}
/** A subagent's stop is offered only once the agent proved it honours one. */
private publishSubagentWork(evidence: AgentChildWorkEvidence[]): void {
const stoppable = this.deps.childStops?.().subagents === true
this.publishEvidence(
@@ -1,5 +1,4 @@
// What a start checks on this host before it spawns the agent. A runtime Orca manages is installed
// only when the person asks, so a start that finds no copy refuses rather than downloading one.
// Startup never installs a managed runtime; missing installations must fail before spawn.
import {
AgentSessionPreSpawnError,
@@ -8,7 +7,6 @@ import {
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
/** The launch a start spawns; `closedBeforeSpawn` throws once the start was abandoned. */
export function prepareAcpStructuredLaunch(
deps: Pick<AcpStructuredSessionAdapterDeps, 'spec' | 'resolveLaunch'>,
acquire: Pick<StructuredAgentSessionAcquireInput, 'identity' | 'spawnToken' | 'permissionMode'>,
@@ -23,7 +21,6 @@ export function prepareAcpStructuredLaunch(
return prepareAcpLaunch(deps, resolveLaunch, closedBeforeSpawn)
}
/** Any spawn of the agent on this host, a chat's or not: its binary installed, its account ready. */
export async function prepareAcpLaunch<
Launch extends { command: string; env: Record<string, string> }
>(
@@ -34,7 +31,7 @@ export async function prepareAcpLaunch<
const { spec } = deps
const launch = await resolve().catch(preSpawnFailure)
closedBeforeSpawn()
// A missed lookup returns the bare name; a selected Command setting resolves or refuses.
// Only a missed lookup returns the bare command; explicit Command settings resolve or fail.
if (spec.managedRuntime && launch.command === spec.command) {
throw new AgentSessionPreSpawnError(
new Error(`${spec.managedRuntime.label} is not installed on this host`),
@@ -48,7 +48,6 @@ function resolver(
}
describe('OMP ACP launch resolution', () => {
// OMP has no bypass flag: the chat's own mode alone decides whether Orca answers its requests.
it("runs `omp acp` pointed at the agent directory the chat pinned, in the chat's own mode", async () => {
for (const permissionMode of ['ask', 'full-access'] as const) {
const launch = await resolver(ompRecord())({ identity, spawnToken: 'token', permissionMode })
@@ -66,7 +65,6 @@ describe('OMP ACP launch resolution', () => {
ANTHROPIC_API_KEY: 'sk-test'
}
const launch = await resolver(ompRecord(), { launchEnv: userEnv })({ identity })
// Only the shared pane and hook keys, and an inherited scratch folder: this chat has none.
expect(launch.envToDelete).toEqual([
...STRUCTURED_CHILD_ENV_TO_DELETE,
NATIVE_CHAT_SCRATCH_DIR_ENV,
@@ -83,7 +81,7 @@ describe('OMP ACP launch resolution', () => {
'darwin'
).env
expect(child).toMatchObject(userEnv)
// A pane's identity would let OMP's Orca status extension report for this chat too.
// Pane identity would let the terminal extension report status for this chat.
expect(child.ORCA_PANE_KEY).toBeUndefined()
})
@@ -253,7 +253,6 @@ describe('OpenCode ACP launch resolution', () => {
})
})
// Since OpenCode 2.0.0 `opencode2` is an alias the 2.x installer writes beside `opencode`.
describe('OpenCode 2 ACP launch resolution', () => {
const OPENCODE2 = {
...acpLaunchSpecFor('opencode2')!,
@@ -277,7 +276,6 @@ describe('OpenCode 2 ACP launch resolution', () => {
})
})
// 1.x and 2.x below 2.0.14 never ran the private child this row needs.
it.each(['1.18.35', '2.0.13', '2.0.14-rc.1'])('refuses %s as too old', async (version) => {
await expect(launchOn(version)).rejects.toMatchObject({
installation: { agent: 'opencode2', installedVersion: version, minimumVersion: '2.0.14' }
@@ -41,7 +41,6 @@ function grokRecord(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRe
}
}
/** A journal holding one turn of each named provider session. */
function journalWithTurns(...providerSessions: string[]): JournalLoad {
const state = createJournalReducerState(identity.sessionId, 'epoch-1')
providerSessions.forEach((providerSession, index) => {
@@ -209,7 +208,6 @@ describe('ACP launch resolution', () => {
nativeId: 'acp-1'
})
const replaced = grokRecord({ providerHandleChain: [link('acp-1'), link('acp-2', 'acp-1')] })
/** A journal holding the row for `lostKey`, written while `providerSession` ran. */
const withRow = (providerSession: string): JournalLoad => {
const load = journalWithTurns()
const itemId = agentJournalItemKey(
@@ -236,7 +234,6 @@ describe('ACP launch resolution', () => {
(await resolver(record, readJournal).resolve({ identity })).resume?.unannouncedLosses()
const reads: string[] = []
// A chain that lost nothing never reads the journal.
expect(
await losses(grokRecord({ providerHandleChain: [link('acp-1')] }), () => {
reads.push('read')
@@ -1,8 +1,4 @@
// How a durable session record becomes an ACP agent launch, on the machine this runtime runs on.
//
// Every input is read back from the record the store made durable, never from the call that
// triggered the acquire: the working directory, the account home, and the provider session a
// resume names are the ones this session proved.
// Launch from durable record values for the working directory, pinned account and resume session.
import { delimiter } from 'node:path'
import { homedir } from 'node:os'
@@ -53,59 +49,46 @@ export type AcpStructuredLaunch = {
command: string
args: string[]
cwd: string
/** The child's sealed environment; a read that starts no child gets the agent's own. */
/** Sealed for spawns; recovery reads use the unsealed agent environment. */
env: Record<string, string>
/** Keys the child must not inherit from this process's own environment. */
envToDelete: string[]
fullAccess: boolean
/** The provider session to load; null starts a new one. */
resume: {
sessionId: string
/** The chain key of the session to load, which a fresh session names if it takes over. */
key: string
/** Only a session this chat created and never exchanged a turn on may be one the agent never
* saved, and so be superseded. Read only when the agent cannot reopen it. */
/** A chat-created session with no exchanged turn may never have been saved. */
mayBeUnsaved: () => boolean
/** Keys of the conversations the chain says the agent lost whose warning row the chat does not
* hold, as when the attach that would have written it failed. Read once the start succeeds. */
/** Lost conversations whose warning rows are missing; read after startup succeeds. */
unannouncedLosses: () => string[]
} | null
}
export type AcpStructuredLaunchPartDeps = {
/** The chat's journal as it stands, read without opening it; null when it has none. */
/** Reads without opening the journal. */
readJournal: (sessionId: string) => JournalLoad | null
resolveCommand?: typeof resolveCliCommand
homePath?: string
/** The environment the child process inherits at spawn; this process's own by default. */
inheritedEnv?: NodeJS.ProcessEnv
probeVersion?: AgentCliVersionReader
/** Orca's data folder on this host, which holds the spec's managed runtime. */
dataDirectory: string
} & Pick<AcpLaunchVisualsDeps, 'logger'>
/** What a launch's binary and environment are read from; a catalog probe reads the same. */
export type AcpLaunchInvocationDeps = {
/** The shared base env; re-read per acquisition. */
/** Reread per acquisition. */
resolveEnvironment: () => Promise<Record<string, string>>
/** The user's per-agent environment overlay from settings. */
resolveLaunchEnv?: (agent: string) => Record<string, string>
/** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */
/** Reread per acquisition; absent uses the stock command. */
resolveCommandSettings?: () => StructuredAgentCommandSettings
/** Rereads that env when the agent's program is not found in it; absent never retries. */
/** Optional environment refresh after a missing-program lookup. */
refreshEnvironmentAfterMiss?: (askedAt: number) => Promise<void>
/** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */
/** Resolves relative PATH entries against the launch directory. */
resolveWorkingDirectory?: () => Promise<string> | string
} & Pick<
AcpStructuredLaunchPartDeps,
'resolveCommand' | 'homePath' | 'inheritedEnv' | 'dataDirectory'
>
/** The binary a launch with `env` spawns: the applicable Command setting when set, else the stock
* binary from PATH, then the agent's own install directories. A spec with a managed runtime runs
* its installed release before those, and a copy from them only beside every pinned member; the
* bare name when none qualifies. A create's version check resolves through here too, so it asks
* about the same file. A null `dataDirectory` skips the installed release. */
/** Prefer explicit commands, then managed releases, then complete pinned copies on PATH. */
export function resolveAcpLaunchCommand(
spec: AcpLaunchSpec,
env: Readonly<Record<string, string>>,
@@ -145,8 +128,6 @@ export function resolveAcpLaunchCommand(
return (found !== spec.command && copyBesidePinnedMembers(found, pin)) || spec.command
}
/** What `command`'s `--version` says against the spec's minimums; asked only when the spec names
* some or `wantVersion`. */
export async function checkAcpLaunchVersion(
spec: AcpLaunchSpec,
launch: { command: string; cwd: string; env: Record<string, string> },
@@ -163,9 +144,7 @@ export async function checkAcpLaunchVersion(
)
}
/** The binary and environment any child of this agent runs under for `accountHome`: a session's
* launch and a catalog probe both resolve here, so neither can reach another account or install,
* and both reread the environment once when the program is missing from it. */
/** Resolve chats and probes consistently; optionally refresh once after a missing-program lookup. */
export function resolveAcpLaunchInvocation(
spec: AcpLaunchSpec,
accountHome: AgentSessionAccountHome,
@@ -197,8 +176,6 @@ async function readAcpLaunchInvocation(
return { command, env, envToDelete }
}
/** An ACP agent's part of a launch: its binary under the pinned account, the release check, the
* visuals skill, its arguments and the provider session to reattach. */
export function acpStructuredLaunchPart(
spec: AcpLaunchSpec,
deps: AcpStructuredLaunchPartDeps
@@ -222,7 +199,7 @@ export function acpStructuredLaunchPart(
)
const cwd = await basis.launchDirectory()
const visuals = spec.visualsSkill ? await basis.visuals() : null
// Again at every launch: the binary on PATH may have changed since the chat was created.
// PATH may have changed since chat creation.
const launchVersion = await checkAcpLaunchVersion(
spec,
{ command, cwd, env },
@@ -242,7 +219,6 @@ export function acpStructuredLaunchPart(
inherited: deps.inheritedEnv ?? process.env
})
: null
// The chat's own mode, as the host decided it for this start; a start naming none asks.
const fullAccess = basis.permissionMode === 'full-access'
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
return {
@@ -267,7 +243,7 @@ export function acpStructuredLaunchPart(
inheritedEnvToDelete: [
...STRUCTURED_CHILD_ENV_TO_DELETE,
...envToDelete,
// Without visuals, a folder Orca itself inherited (started from a chat) names another chat's.
// An inherited scratch directory belongs to a different chat.
...(skill ? [] : [NATIVE_CHAT_SCRATCH_DIR_ENV]),
LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV
]
@@ -275,8 +251,7 @@ export function acpStructuredLaunchPart(
}
}
/** Proof the chat holds no turn of provider session `nativeId`; a journal that does not read whole,
* or does not read at all, proves nothing. */
/** Missing journals count as empty; read errors or damaged/newer journals cannot prove no exchange. */
function nothingExchangedOn(
readJournal: AcpStructuredLaunchPartDeps['readJournal'],
sessionId: string,
@@ -303,8 +278,7 @@ function nothingExchangedOn(
return true
}
/** Derived on every start, never stored: a row an attach failure dropped is written by the next
* start that succeeds. A journal that does not read whole proves nothing, so none is written. */
/** Retry missing loss-warning rows on successful starts; unreadable journals suppress new warnings. */
function unannouncedLosses(
readJournal: AcpStructuredLaunchPartDeps['readJournal'],
sessionId: string,
@@ -337,6 +311,6 @@ function unannouncedLosses(
written.add(spelled)
}
}
// Any session's row counts: one an unused replacement wrote stays when the agent supersedes it.
// Warnings survive even if the replacement session that wrote them is superseded.
return lost.filter((key) => !written.has(providerTimelineKeyPart(acpSessionNotRestoredItem(key))))
}
@@ -111,8 +111,7 @@ describe('ACP launch visuals', () => {
expect(launch.env[NATIVE_CHAT_SCRATCH_DIR_ENV]).toBe(FOLDER)
})
// OMP's settings layers (global, profile, project, overlays) replace lists, so any config of
// Orca's would hide the user's own skill folders; a plugin folder adds beside them.
// OMP config layers replace skill lists; a plugin directory preserves the user's configured folders.
it("gives OMP the plugin folder, leaving the user's config files as they set them", async () => {
const launch = await resolve('omp', {
version: '17.0.5',
@@ -1,6 +1,3 @@
// What an ACP launch gets for inline visuals: the chat's folder, and whatever its agent needs to
// load the skill. Best effort: a chat whose agent can't load it starts without visuals.
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import type {
NativeChatVisualsLaunch,
@@ -10,12 +7,10 @@ import type { AcpLaunchSpec } from './acp-launch-specs'
import type { AcpVisualsSkillLaunch } from './acp-visuals-skill'
export type AcpLaunchVisualsDeps = {
/** Prepares a chat's scratch folder for inline visuals; absent, no chat gets visuals. */
prepareVisuals?: PrepareNativeChatVisuals
logger?: StructuredAgentSessionLogger
}
/** The environment the child will see: what it inherits, the launch's laid over, then the strips. */
function childEnvironmentView(
env: Readonly<Record<string, string>>,
inherited: NodeJS.ProcessEnv,
@@ -34,8 +29,7 @@ function childEnvironmentView(
return view
}
/** How this launch's agent loads the skill for the chat's prepared `visuals`; null leaves the chat
* without visuals. Never throws. */
/** Skill loading failures leave the chat without visuals. */
export async function loadAcpVisualsSkill(
spec: AcpLaunchSpec,
deps: AcpLaunchVisualsDeps,
@@ -2,7 +2,6 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-r
import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location'
import type { AcpLaunchSpec } from './acp-launch-specs'
/** The execution host can supervise this ACP child and has its pinned build when one is required. */
export function supportsAcpStructuredLocation(
spec: AcpLaunchSpec,
location: AgentSessionExecutionLocation,
@@ -52,7 +52,7 @@ it('starts the installed release first, else a copy beside every pinned member',
}
await expect(spawn()).rejects.toMatchObject({ reason: 'runtimeNotInstalled' })
// A server without its companion beside it is no copy.
// A server without its companion is an incomplete runtime.
const localBin = join(homePath, '.local', 'bin')
await mkdir(localBin, { recursive: true })
await writeFile(join(localBin, command), '', { mode: 0o755 })
@@ -1,6 +1,3 @@
// The adapter against OMP's recorded `omp acp` traffic (17.0.5): each recording plays back as the
// agent, and the assertions read the journal a client would see.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
@@ -151,7 +148,7 @@ describe('OMP recordings through the adapter', () => {
await waitFor(async () =>
expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }])
)
// OMP reports an exit code only when it is not zero; a completed foreground command exited 0.
// OMP omits zero exit codes for completed foreground commands.
expect(await toolRow(rig)).toMatchObject({
state: 'completed',
exitCode: 0,
@@ -170,7 +167,7 @@ describe('OMP recordings through the adapter', () => {
await waitFor(async () =>
expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }])
)
// OMP sends nothing more for the stopped command; Orca settles its row with the turn.
// OMP sends no final tool update after cancellation; settle it with the turn.
const tool = await toolRow(rig)
expect(tool).toMatchObject({
endedAs: 'interrupted',
@@ -1,7 +1,3 @@
// The adapter against OpenCode's recorded `opencode acp` traffic (1.18.31, and 2.0.29 for the
// `opencode-v2-*` ones): each recording plays back as the agent, and the assertions read the
// journal a client would see.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
@@ -114,7 +110,7 @@ describe('OpenCode recordings through the adapter', () => {
await answer(rig, replay, 'reject', 1)
await waitFor(() => expect(replay.awaiting).toBeNull())
await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }]))
// The write OpenCode repeats to a client that took no file system leaves no row.
// No filesystem capability was advertised, so the repeated write request creates no row.
expect(JSON.stringify((await rig.rig.rows()).map((row) => row.body))).not.toContain(
'fs/write_text_file'
)
@@ -152,7 +148,7 @@ describe('OpenCode 2 recordings through the adapter', () => {
})
})
// Unlike 1.x, 2.x ends the turn when a permission is rejected: it answers `cancelled`.
// OpenCode 2.x ends denied-permission turns as cancelled, unlike 1.x.
it('a rejected permission fails its tool and OpenCode ends the turn as cancelled', async () => {
const { rig, replay } = await replaying('opencode-v2-deny', 'opencode2')
await answer(rig, replay, 'reject')
+2 -2
View File
@@ -33,7 +33,7 @@ describe('ACP session options', () => {
const options = new AcpStructuredOptions()
options.adoptSession({ configOptions })
const { models, current } = options.read()
// The effort menu belongs to the model running now; nothing is anyone's default.
// Current values supply an effort menu, never a catalog default.
expect(models).toEqual([
{ id: 'model-a', label: 'Model A', isDefault: false, efforts: [] },
{
@@ -64,7 +64,7 @@ describe('ACP session options', () => {
reasoningEfforts: [{ value: 'medium', default: true }]
}
},
// The session's own effort written into the running model's meta is not its default.
// Session-selected effort metadata must not become a model default.
{
modelId: 'model-b',
name: 'Model B',
+8 -26
View File
@@ -1,7 +1,3 @@
// What a running ACP agent reports about its own choices — config options (model, reasoning
// effort), the older model list, and its `/` commands — as Orca's option and command surfaces.
// Kept as the agent last reported it; nothing here is guessed or listed by Orca itself.
import { abortSignalReason } from '../../shared/abort-signal-reason'
import type {
@@ -29,7 +25,6 @@ import {
type SelectOption = Extract<SessionConfigOption, { type: 'select' }>
/** How an Orca option key reaches the agent: a config option it declared, or the model method. */
export type AcpOptionWrite =
| { method: 'config'; configId: string; value: string }
| { method: 'model'; modelId: string }
@@ -62,16 +57,15 @@ export class AcpStructuredOptions {
private configOptions: SessionConfigOption[] = []
private models: SessionModelState | null = null
private commands: AgentSessionSlashCommand[] | undefined
// A loaded session keeps the model it ran; only a new one resolves the agent's own config.
// Only new sessions resolve configured defaults; loaded sessions retain their saved choices.
private resolvesConfig = false
readonly picked = new Set<string>()
/** Each key's value from Orca's last successful write; never the agent's report. */
/** Last successful Orca writes, distinct from agent-reported values. */
readonly applied = new Map<string, string>()
private initialChoice: Readonly<Record<string, string>> = {}
currentProof = new Set<string>()
writeRevision = 0
/** The session state a new, load or resume response reported. */
adoptSession(
response: {
configOptions?: SessionConfigOption[] | null
@@ -86,12 +80,11 @@ export class AcpStructuredOptions {
this.currentProof = new Set(Object.keys(this.initialChoice))
}
/** The native selection before any message changed this child. */
/** Native selection before any message changed this child. */
messageDefault(): Readonly<Record<string, string>> {
return this.initialChoice
}
/** Orca sent `key` to the session, or the chat's saved options hold a value for it. */
notePick(key: string): void {
this.picked.add(key)
}
@@ -124,7 +117,6 @@ export class AcpStructuredOptions {
return this.commands
}
/** Where a pick of `key` goes; null when the agent offers nothing for it. */
write(key: string, value: string): AcpOptionWrite | null {
if (key === 'model') {
const option = this.select('model')
@@ -140,11 +132,7 @@ export class AcpStructuredOptions {
return null
}
/**
* The agent's models and what this session runs. A model's effort menu and default are catalog
* facts: the dialect's per-model menu when the agent advertises one, else the session's effort
* option for the model it runs now only. The session's current values are never a default.
*/
/** Effort menus describe models; current session values never establish catalog defaults. */
read(): Pick<AgentSessionOptionsResult, 'models' | 'current'> {
const modelOption = this.select('model')
const effortOption = this.select('thought_level')
@@ -174,7 +162,7 @@ export class AcpStructuredOptions {
return {
...model,
isDefault: false,
// An empty advertised menu is no menu: the running model keeps the one its session offers.
// Fall back to the running session's menu when the advertised menu is empty.
...(advertised && advertised.efforts.length > 0 ? advertised : sessionMenu)
}
})
@@ -189,9 +177,7 @@ export class AcpStructuredOptions {
}
}
/** With no model sent since a new session began, what it runs is the agent's own config
* resolution: that scope's configured default. Null when that names no listed model; undefined
* when this session can't say. */
/** Unpicked new-session default: null if unlisted, undefined if the session cannot establish it. */
configuredDefault(): AgentModelCatalogConfiguredChoice | null | undefined {
const { models, current } = this.read()
return unpickedSessionConfiguredChoice({
@@ -202,12 +188,10 @@ export class AcpStructuredOptions {
})
}
/** What this session's start hands the host once: its listing, with what it resolved. */
startListing(): AgentModelCatalogLiveListing {
return withLiveCatalogListing(this.read(), this.configuredDefault()).catalogListing
}
/** The values the agent reports now, as the record's options would name them. */
reported(): Readonly<Record<string, string>> {
const { current } = this.read()
return {
@@ -216,7 +200,7 @@ export class AcpStructuredOptions {
}
}
/** The session's mode is its `mode`-category select, whatever the agent names it. */
// Match the mode category rather than assuming an agent's config ID.
modeConfigId(): string {
return this.select('mode')?.id ?? 'mode'
}
@@ -229,7 +213,6 @@ export class AcpStructuredOptions {
}
}
/** Images go only to an agent whose start advertised them. */
export async function acpSessionAcceptsImages(session: {
connection: Pick<AcpStructuredConnection, 'initialize'>
}): Promise<boolean> {
@@ -238,12 +221,11 @@ export async function acpSessionAcceptsImages(session: {
)
}
/** A running child's options, and whether a message to it may carry images. */
export async function readAcpLiveOptions(
session: Parameters<typeof acpSessionAcceptsImages>[0] & { options: AcpStructuredOptions }
) {
const acceptsImages = await acpSessionAcceptsImages(session)
// Only the start says what the config resolved: the session may have moved since.
// Only startup establishes config resolution; later reads may reflect changed picks.
return { ...withLiveCatalogListing(session.options.read()), acceptsImages }
}
@@ -19,7 +19,6 @@ function specFor(agent: string): AcpLaunchSpec {
return spec
}
/** Fails the first prompt with `code`, after the turn started unless `beforeTurn`. */
async function failFirstPrompt(
spec: AcpLaunchSpec,
error: { code: number; message: string; data?: unknown },
@@ -52,7 +51,7 @@ async function failFirstPrompt(
}
const WORDS = 'Model quota exceeded for this workspace; try again in 5 minutes.'
// OpenCode 1.18.31's recorded answer when its model provider fails: data is only metadata.
// OpenCode 1.18.31 provider-error data contains metadata, not user-facing failure text.
const OPENCODE_PROVIDER_ERROR = {
code: -32603,
message: 'Internal error: CAPTURE_PROVIDER_400',
+9 -21
View File
@@ -1,11 +1,4 @@
// The agent's open requests to the user (permission, question, plan approval), each held as the
// JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it.
// An answer claims the request, commits the journal compare-and-set while the claim is held, and
// only then replies, so a second client loses the commit and the agent hears exactly one answer.
// Each request is this module's to answer: once the agent cancels it, or a Stop or steer withdraws
// what is open (`withdrawAll`), an unclaimed one is answered with the agent's own cancelled reply
// and a claimed one still sends the committed answer — except a permission the agent itself
// cancelled, which the protocol answers `cancelled` the moment it is cancelled.
// Claim requests through journal commit before replying so competing clients cannot answer twice.
import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer'
import {
@@ -20,11 +13,11 @@ import type { AcpStructuredLane } from './acp-structured-lane'
type OpenRequest = {
key: string
presentation: AcpRequestPresentation
/** A permission, whose cancellation the protocol answers itself. */
/** Protocol cancellation can answer permissions immediately, even while a claim is saved. */
permission: boolean
signal: AbortSignal
claimed: boolean
/** A Stop or steer withdrew it while an answer was being saved: a failed save withdraws it. */
/** Deferred withdrawal applies if the claimed answer fails to save. */
withdrawing: boolean
settle: (reply: unknown) => void
}
@@ -34,9 +27,9 @@ export class AcpStructuredPrompts {
constructor(
private readonly lane: () => AcpStructuredLane | null,
/** Whether a request arriving now belongs to a turn that may still ask the person. */
/** Allows interactive requests for the current turn. */
private readonly admits: () => boolean,
/** Whether its turn may still show what a request answered at once carried. */
/** Allows content from requests that settled immediately. */
private readonly shows: () => boolean
) {}
@@ -44,7 +37,6 @@ export class AcpStructuredPrompts {
return this.open.size
}
/** Answers one agent request: a row the user can act on, or the translator's verdict on it. */
handle(method: string, params: unknown, context: AcpRequestContext): Promise<unknown> {
const lane = this.lane()
if (!lane || context.id === null) {
@@ -52,15 +44,13 @@ export class AcpStructuredPrompts {
}
const translated = lane.translator.request(method, params, context.id)
if (translated.settled) {
// Answered at once; what it carried shows only while its turn may still show anything.
if (this.shows()) {
lane.apply(translated.events)
}
return Promise.resolve(translated.settled.reply)
}
if (translated.presentation && !this.admits()) {
// No prompt of Orca's runs, or a Stop or steer is cutting it short: the agent hears its own
// cancelled reply and no card opens.
// Reply cancelled without opening a card when the turn can no longer ask.
return Promise.resolve(translated.presentation.reply(null))
}
lane.apply(translated.events)
@@ -86,8 +76,7 @@ export class AcpStructuredPrompts {
}
}
this.open.set(key, entry)
// The agent or a Stop gave up on it: its card can no longer be answered, and the agent hears
// its own cancelled reply. A claimed one finishes its answer.
// Unclaimed requests withdraw immediately; claimed answers finish their commit.
context.signal.addEventListener(
'abort',
() => {
@@ -129,12 +118,12 @@ export class AcpStructuredPrompts {
}
entry.settle(reply)
if (entry.permission && entry.signal.aborted) {
// Cancelled while the answer was saved: the agent already heard `cancelled`, not this answer.
// Protocol cancellation already replied while the permission answer was being saved.
throw new Error(`the agent stopped waiting for the answer to ${input.itemId}`)
}
}
/** A Stop or steer: the agent hears its own cancelled reply to everything no answer has claimed. */
/** Cancel unclaimed requests; let claimed answers finish saving. */
withdrawAll(): void {
for (const entry of this.open.values()) {
if (entry.claimed) {
@@ -150,7 +139,6 @@ export class AcpStructuredPrompts {
this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }])
}
/** The child is gone: nobody can answer for it any more. */
clear(): void {
this.open.clear()
}
@@ -1,7 +1,3 @@
// What Grok asks reaches the person only while Orca's prompt runs and may still ask: a Stop or a
// steer withdraws what is open and refuses what arrives later with Grok's own cancelled reply, with
// no card, as does a turn Grok began itself; an answer the person already gave is still sent.
import { afterEach, describe, expect, it } from 'vitest'
import {
closeProviderTimelineRigs,
@@ -13,40 +13,36 @@ import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
/** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */
// Grace begins at cancellation, before process close.
export const ACP_STOP_GRACE_MS = 4_000
/** How long a model or effort pick waits for the agent's answer, as Claude's and Codex's do. */
export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000
export type AcpStructuredSessionAdapterDeps = {
spec: AcpLaunchSpec
/** The child to spawn, sealed with the reservation's token. */
/** Sealed with the reservation's spawn token. */
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
spawnToken: string
/** The mode the host decided this start runs with; absent asks. */
/** Defaults to ask. */
permissionMode?: AgentChatPermissionMode
}) => Promise<AcpStructuredLaunch>
/** The same launch for a read that starts no child (restart recovery): the agent's env, unsealed. */
/** Recovery reads use the same launch with an unsealed environment. */
readLaunch: (identity: AgentSessionJournalIdentity) => Promise<AcpStructuredLaunch>
/** Current host arguments before protocol-specific filtering; copies open through the CLI. */
resolveLaunchArgs?: () => Promise<string[]> | string[]
/** The chat's journal as it stands, read without opening it; null when it has none. */
/** Reads without opening the journal. */
readJournal?: (sessionId: string) => JournalLoad | null
/** Orca's data folder on this host, under its instance lock. */
/** Protected by this host's instance lock. */
dataDirectory: string
/** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */
connect: ConnectAcpAgent
readProcessStartTime?: (pid: number) => Promise<number | null>
/** Every exit, expected or not: the host ends that child's record. */
onEvent?: (event: StructuredAgentSessionLifecycleEvent) => void
/** `producer`: the reporting child's own sink, which writes what the report changes. */
/** `producer` is the reporting child's sink for durable changes. */
onChildWorkEvidence?: (
sessionId: string,
evidence: AgentChildWorkEvidence[],
producer?: StructuredAgentSessionChildWorkProducer
) => void
/** A send this adapter admitted, settled once the agent answered for it. */
onDispatchSettledLate?: (
settlement: { sessionId: string; clientMessageId: string } & (
| { providerIdentity: AgentJournalItemIdentity }
@@ -58,9 +54,7 @@ export type AcpStructuredSessionAdapterDeps = {
now?: () => number
mintGeneration?: () => string
mintLinkId?: () => string
/** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */
stopGraceMs?: number
/** Bounds a pick's wait for the agent's answer; past it the pick fails. */
optionWriteTimeoutMs?: number
isWindowsProcessStartTimeAvailable?: () => boolean
}
@@ -71,7 +71,7 @@ describe('ACP Stop names a turn', () => {
await sendHello(rig, 'new')
await rig.frame('session/prompt', 1)
await rig.settle()
// The journal shows no turn running: Grok has not echoed the new prompt yet, as with Claude.
// Before the first echo, the new prompt has no running journal turn.
await expect(
rig.adapter.cancelTurn({
sessionId: SESSION,
@@ -105,11 +105,10 @@ describe('ACP Stop names a turn', () => {
resolveLiveTurnId: () => live
})
).resolves.toEqual({ cancelled: true })
// The steer's cancel, then the Stop's own bounded one.
expect(rig.sent('session/cancel')).toHaveLength(2)
expect(rig.settled).toEqual(
expect.arrayContaining([
// Held by Orca, never prompted: proof it never reached the agent.
// A held steer never reached the agent.
expect.objectContaining({
clientMessageId: 'follow-up',
state: 'rejected',
@@ -144,7 +143,7 @@ describe('ACP Stop of a turn Grok began itself', () => {
await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now())
expect(rig.sent('session/cancel')).toHaveLength(1)
expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'interrupted' })
// The host ends the child next; the adapter does not end it on its own.
// Process teardown belongs to the host.
expect(rig.child().closes).toBe(0)
})
@@ -193,7 +192,7 @@ describe('ACP connection loss', () => {
])
)
expect(rig.child().closes).toBe(1)
// Nothing was opened for a send the agent never echoed, so nothing claims it completed.
// An unacknowledged prompt never opened a turn to mark complete.
expect(await journalTurns(rig)).toEqual([])
await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/)
})
@@ -224,7 +223,6 @@ describe('ACP connection loss', () => {
child.agent.close()
await rig.settle()
expect(rig.ended).toEqual([])
// Never left Orca, so it is not recorded as unconfirmed.
expect(
await rig.adapter.dispatch({
sessionId: SESSION,
@@ -237,7 +235,7 @@ describe('ACP connection loss', () => {
reason: 'Grok stopped before this message was sent.',
rejection: { kind: 'providerExited', neverReachedAgent: true }
})
// The next start's stop of the old child is one Orca asked for; it does not rename the crash.
// Cleanup retries must preserve the earlier crash's cause.
await expect(rig.acquire()).rejects.toMatchObject({
name: 'AgentSessionAcquisitionExitUnprovenError'
})
@@ -270,7 +268,6 @@ describe('ACP connection loss', () => {
await sendHello(rig, 'garbled')
const prompt = await rig.frame('session/prompt')
rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial'))
// Usage without the totals the schema requires: the answer fails to parse.
rig.child().agent.reply(prompt, {
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 2 }
@@ -322,7 +319,6 @@ describe('ACP refusals', () => {
])
)
expect(await journalTurns(rig)).toEqual([])
// The session takes the next send.
rig.child().agent.on('session/prompt', () => {})
await sendHello(rig, 'next')
await rig.frame('session/prompt', 1)
@@ -488,7 +484,6 @@ describe('ACP session release', () => {
await rig.acquire()
expect(rig.adapter.backgroundTaskStops(SESSION)).toBeDefined()
await rig.adapter.closeSession(SESSION)
// Both answer from the entry the adapter holds for a chat; none is left.
expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined()
expect(rig.adapter.readCommands(SESSION)).toBeUndefined()
})
@@ -1,6 +1,3 @@
// The adapter against Grok's recorded traffic: each recording plays back as the agent, and the
// assertions read the journal a client would see.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
@@ -110,7 +107,7 @@ describe('Grok recordings through the adapter', () => {
await waitFor(async () =>
expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }])
)
// The recording left the shell call `in_progress`; the turn's end cuts it short.
// The recording leaves this call running; turn end must interrupt it.
const tool = (await rowsOf(rig, 'tool-call')).at(-1)
expect(tool?.body).toMatchObject({ kind: 'tool-call', state: 'failed', endedAs: 'interrupted' })
await send(rig, 'send-2')
@@ -31,7 +31,6 @@ const hello: AgentJournalMessageItem = {
blocks: [{ type: 'text', text: 'hello' }]
}
/** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */
const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } }
const resume = {
launch: {
@@ -44,8 +43,7 @@ const resume = {
}
}
/** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the
* dead process left running ended by the restart, and its context usage. */
/** Replay history, end the prior process's stale task, and report context usage. */
function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void {
agent.notify('session/update', {
sessionId: PROVIDER_SESSION,
@@ -66,7 +64,6 @@ function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void {
})
}
/** Orca's send of `hello` as `m1` and Grok's reply, ended or cut off. */
async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promise<void> {
await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, {
turnScope: { kind: 'thread' }
@@ -90,7 +87,6 @@ const notRestoredRows = async (rig: AcpAdapterRig) =>
readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored'
)
/** What Grok might send while it resumes with no replay mark: an old reply and its turn's end. */
function sendsUnmarkedWhileAttaching(agent: AcpScriptedAgent): void {
agent.notify('session/update', replyChunk('prompt:old', 'stale text'))
agent.notify('x.ai/session_notification', {
@@ -122,7 +118,6 @@ describe('the reattach window', () => {
...resumesUnmarked,
script: (agent: AcpScriptedAgent) =>
agent.on('session/load', (frame) => {
// An old reply with no end and no replay mark.
agent.notify('session/update', replyChunk('prompt:old', 'stale text'))
agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS })
})
@@ -149,7 +144,6 @@ describe('the context meter across a reopen', () => {
...resume,
initialize: RESUMES,
script: (agent) =>
// A load that replays no usage; its answer carries only the models.
agent.on('session/load', (frame) =>
agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models })
)
@@ -234,7 +228,6 @@ describe('reattaching a Grok chat the journal holds', () => {
await rig.settle()
expect(rig.sent('session/load')).toHaveLength(1)
expect(rig.sent('session/resume')).toHaveLength(0)
// No second user bubble, no stale task row, and no turn opened by the load's own traffic.
expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before)
expect(await texts(rig)).toEqual(['hello', 'hi'])
await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 2 })).resolves.toEqual({
@@ -298,7 +291,7 @@ describe('reattaching a Grok chat the journal holds', () => {
await exchange(rig, 'hi', true)
await rig.adapter.closeSession(SESSION)
const before = await rig.rig.rows()
// The host's order: a fresh sink per start, bound to the journal once the start succeeded.
// Bind a fresh acquisition sink only after startup succeeds.
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging(SESSION))
try {
const acquired = await rig.adapter.acquire({
@@ -351,10 +344,8 @@ describe('reattaching a Grok chat the journal holds', () => {
handle: { nativeId: PROVIDER_SESSION },
supersedesKey: 'acp:grok:never-saved'
})
// The failed load left the translator taking prompts.
await exchange(rig, 'hi', true)
expect(await texts(rig)).toEqual(['hello', 'hi'])
// Nothing was forgotten: the agent never saved that session.
expect(await notRestoredRows(rig)).toEqual([])
})
@@ -437,7 +428,6 @@ describe('reattaching a Grok chat the journal holds', () => {
expect(rig.ended).toMatchObject([{ cause: 'requested-close' }])
expect(rig.sent('session/new')).toEqual([])
expect(await notRestoredRows(rig)).toEqual([])
// Nothing was replaced, so nothing says it was.
expect(logger.warn).not.toHaveBeenCalledWith(ACP_REOPEN_FAILED, expect.anything())
})
@@ -1,6 +1,3 @@
// A close, dispose or quit while a turn runs: the agent is asked to cancel it and given the Stop's
// grace to end it before its process goes, as a Stop does; an idle close goes at once.
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
closeProviderTimelineRigs,
@@ -22,7 +19,6 @@ afterEach(async () => {
await closeProviderTimelineRigs()
})
/** What Orca had written, and whether the turn had ended, when it first asked the child to close. */
function watchClose(child: FakeAcpChild, turn: { ended: boolean }) {
const atClose: { cancels: number | null; turnEnded: boolean | null } = {
cancels: null,

Some files were not shown because too many files have changed in this diff Show More