mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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 } : {})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 ?? []),
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
},
|
||||
|
||||
@@ -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', () => {})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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"}'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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' } }
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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."],
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user