From b53f7f7c5dd3036710b654d56276e20037c21bff Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 20 May 2026 20:01:04 -0300 Subject: [PATCH 01/16] Simplify harness ingress and async run::start path. Console calls harness::trigger with a flat payload; harness always starts run::start. Remove run::start_and_wait, cwd state keys, and initial agent::events replay at kickoff so the FSM owns turn lifecycle. --- console/web/src/lib/backend/real.ts | 5 +- harness-node/README.md | 2 +- harness-node/config.yaml | 1 - harness-node/docs/architecture.md | 2 +- .../docs/workers/turn-orchestrator.md | 16 +- harness-node/src/harness/register.ts | 5 +- harness-node/src/harness/trigger.ts | 86 ++++---- harness-node/src/turn-orchestrator/config.ts | 4 +- .../src/turn-orchestrator/on-terminal.ts | 57 ------ .../src/turn-orchestrator/persistence.ts | 10 - .../src/turn-orchestrator/register.ts | 36 +--- .../src/turn-orchestrator/run-start.ts | 130 +++--------- harness-node/src/turn-orchestrator/state.ts | 2 - .../turn-orchestrator/states/provisioning.ts | 3 +- harness-node/tests/harness/policy.test.ts | 1 - harness-node/tests/harness/trigger.test.ts | 86 +++----- .../tests/turn-orchestrator/config.test.ts | 7 +- .../tests/turn-orchestrator/functions.test.ts | 1 - .../turn-orchestrator/on-abort-signal.test.ts | 2 +- .../turn-orchestrator/on-terminal.test.ts | 192 ------------------ .../tests/turn-orchestrator/run-start.test.ts | 12 +- iii-permissions.yaml | 1 - 22 files changed, 130 insertions(+), 531 deletions(-) delete mode 100644 harness-node/src/turn-orchestrator/on-terminal.ts delete mode 100644 harness-node/tests/turn-orchestrator/on-terminal.test.ts diff --git a/console/web/src/lib/backend/real.ts b/console/web/src/lib/backend/real.ts index 51ed2c32..9364b520 100644 --- a/console/web/src/lib/backend/real.ts +++ b/console/web/src/lib/backend/real.ts @@ -101,7 +101,6 @@ async function* realStream( let kickoffError: Error | null = null client .call('harness::trigger', { - function_id: 'run::start', session_id: sessionId, message_id: messageId, payload: { @@ -123,7 +122,7 @@ async function* realStream( .catch((err) => { kickoffError = err instanceof Error ? err : new Error(String(err)) if (import.meta.env.DEV) { - console.warn('[real-backend] harness::trigger run::start failed', err) + console.warn('[real-backend] harness::trigger failed', err) } wake() }) @@ -134,7 +133,7 @@ async function* realStream( const err = kickoffError as Error yield { kind: 'assistant-token', - token: `harness::trigger run::start failed — ${err.message}`, + token: `harness::trigger failed — ${err.message}`, } yield { kind: 'assistant-end' } return diff --git a/harness-node/README.md b/harness-node/README.md index e4783ccc..aef8c636 100644 --- a/harness-node/README.md +++ b/harness-node/README.md @@ -14,7 +14,7 @@ alongside `harness-node` over the iii bus. |---|---|---| | `src/harness/` | `ui::subscribe`/`unsubscribe`, `harness::fs::read_inline`, `policy::check_permissions` | Meta-worker; loads `iii-permissions.yaml`; spins up `ui::*` fanout pumps. | | `src/approval-gate/` | `approval::resolve` | Routes operator decisions to per-call `turn::approval_resume` fns (registered by turn-orchestrator). | -| `src/turn-orchestrator/` | `run::start`, `run::start_and_wait`, `agent::trigger`, `turn::step` | Durable FSM driving each agent turn; chokepoint dispatcher. | +| `src/turn-orchestrator/` | `run::start`, `agent::trigger`, `turn::step` | Durable FSM driving each agent turn; chokepoint dispatcher. | | `src/session/` | `session-tree::*` (11 fns), `session-inbox::*` (3 fns) | Branching session storage + per-session inbox queues. | | `src/llm-budget/` | `budget::*` (14 fns) | Workspace + agent LLM spend caps. | | `src/hook-fanout/` | `hook-fanout::publish_collect` | Generic publish-and-collect over a stream topic. | diff --git a/harness-node/config.yaml b/harness-node/config.yaml index 340049b6..d7c9340e 100644 --- a/harness-node/config.yaml +++ b/harness-node/config.yaml @@ -8,7 +8,6 @@ engine_url: "ws://127.0.0.1:49134" permissions_path: "./iii-permissions.yaml" # turn-orchestrator -sync_default_timeout_ms: 120000 system_default_skills: - "iii://iii-directory/index" diff --git a/harness-node/docs/architecture.md b/harness-node/docs/architecture.md index fe2facd1..c3468da5 100644 --- a/harness-node/docs/architecture.md +++ b/harness-node/docs/architecture.md @@ -166,7 +166,7 @@ Deny shorthands (`!function_id` in the YAML): `approval::resolve`, `policy::check_permissions`, `hook-fanout::publish_collect`, `state::set`, `state::update`, `state::delete`, `stream::set`, `iii::durable::publish`, `auth::set_token`, `auth::delete_token`, `oauth::anthropic::login`, -`oauth::openai-codex::login`, `run::start`, `run::start_and_wait`, +`oauth::openai-codex::login`, `run::start`, `router::stream_assistant`, `router::abort`. Bare-string allow rules: `state::get`, `state::list`, diff --git a/harness-node/docs/workers/turn-orchestrator.md b/harness-node/docs/workers/turn-orchestrator.md index 3fe71ea5..9e8b132e 100644 --- a/harness-node/docs/workers/turn-orchestrator.md +++ b/harness-node/docs/workers/turn-orchestrator.md @@ -23,14 +23,11 @@ unreachable → deny with a `gate_unavailable` `DenialEnvelope`. ## Registered functions - `run::start` — Start a durable agent session and return immediately. -- `run::start_and_wait` — Start a durable agent session and block until terminal (test/dev convenience). - `turn::step` — Run one durable state machine transition for a session. - `turn::get_state` — Read the current `TurnStateRecord` for a session (or null for unknown sessions). UI clients use this on reload to recover any in-progress modals (e.g. `function_awaiting_approval`) without reading iii state directly. - `agent::trigger` — LLM-facing dispatcher: dispatches an iii function and returns a FunctionResult. - `turn::is_abort_signal_set` — Condition function bound to the agent-scope state trigger; matches `state:created`/`state:updated` writes that set `session//abort_signal` to `true`. - `turn::on_abort_signal` — State trigger adapter: publishes `turn::step_requested` when the abort signal is set so the FSM advances on the next safe boundary. -- `turn::is_terminal_state_write` — Condition function bound to the terminal state trigger; matches writes to `session//turn_state` whose `new_value.state === 'stopped'`. -- `turn::on_terminal_state` — State trigger adapter: resolves the in-process waiter installed by `run::start_and_wait` for that session id. - `turn::is_stepable_record_write` — Condition function bound to the record-written state trigger; matches `turn_state` writes whose `new_value.state` is non-terminal and non-parking (i.e. excludes `stopped` and `function_awaiting_approval`). - `turn::on_record_written` — State trigger adapter: directly triggers `turn::step` for the affected session, so saving the record is itself the wake-up event. - `turn::is_turn_state_write` — Condition function bound to the turn-state-changed trigger; matches every `state:created` / `state:updated` write to `session//turn_state` regardless of FSM state. @@ -40,7 +37,6 @@ unreachable → deny with a `gate_unavailable` `DenialEnvelope`. - **Durable subscriber** on `turn::step_requested` → `turn::step`. Registered in [src/turn-orchestrator/subscriber.ts](harness-node/src/turn-orchestrator/subscriber.ts). Each `step` loads the `TurnStateRecord`, runs one transition, saves it back, and re-publishes `turn::step_requested` unless the run is terminal **or** paused on approvals (`function_awaiting_approval`). Paused turns are woken when `approval::resolve` or abort triggers a per-call `turn::approval_resume` function (see [workers/approval-gate.md](workers/approval-gate.md)). - **State trigger** on `scope: agent` gated by `condition_function_id: turn::is_abort_signal_set` → `turn::on_abort_signal`. Registered in [src/turn-orchestrator/on-abort-signal.ts](harness-node/src/turn-orchestrator/on-abort-signal.ts). Publishes `turn::step_requested` the moment `session//abort_signal` is set to `true`, so the FSM advances to `steering_check` (and observes the abort) on the next safe boundary without waiting for the current step to time out. -- **State trigger** on `scope: agent` gated by `condition_function_id: turn::is_terminal_state_write` → `turn::on_terminal_state`. Registered in [src/turn-orchestrator/on-terminal.ts](harness-node/src/turn-orchestrator/on-terminal.ts). Fires on the `session//turn_state` write that lands `stopped`; the handler resolves the per-session waiter installed by `run::start_and_wait` so the sync wrapper returns without polling. - **State trigger** on `scope: agent` gated by `condition_function_id: turn::is_stepable_record_write` → `turn::on_record_written`. Registered in [src/turn-orchestrator/on-record-written.ts](harness-node/src/turn-orchestrator/on-record-written.ts). Directly triggers `turn::step` for the affected session on every non-terminal, non-parking `session//turn_state` write. Replaces the imperative `publishStep` self-publish — saving the record is now the wake. - **State trigger** on `scope: agent` gated by `condition_function_id: turn::is_turn_state_write` → `turn::on_turn_state_changed`. Registered in [src/turn-orchestrator/on-turn-state-changed.ts](harness-node/src/turn-orchestrator/on-turn-state-changed.ts). Fires on every `session//turn_state` write (created or updated) and emits a `turn_state_changed` event to `agent::events` carrying the full new (and prior) record so the UI can derive pending approvals from state rather than from a signal event. @@ -79,9 +75,7 @@ All keys live under iii state scope `agent`. From |---|---| | `session//turn_state` | Serialised `TurnStateRecord`. | | `session//messages` | Active path `AgentMessage[]`; mirrored into `session-tree::*` on every save. | -| `session//run_request` | The original `run::start` payload (provider, model, system_prompt, mode, image, idle_timeout_secs, cwd, cwd_hash). | -| `session//cwd` | Working directory for the sandbox. | -| `harness/cwd//last_session_id` | Reverse index from `cwd_hash` to the last session that ran there. | +| `session//run_request` | The original `run::start` payload (provider, model, system_prompt, mode, image, idle_timeout_secs). | | `session//sandbox_id` | Active sandbox handle. | | `session//function_schemas` | Cached tool schemas exposed to the model. | | `session//tool_schemas` | Legacy alias of `function_schemas`. | @@ -104,9 +98,6 @@ decisions back into the prepared snapshot. From the top-level `turn-orchestrator` section of [config.yaml](harness-node/config.yaml): -- `sync_default_timeout_ms` (default `120000`) — wall-clock cap on a - `run::start_and_wait` call; if the terminal state trigger doesn't - resolve the waiter within this many ms, the wrapper throws. - `system_default_skills` (default `["iii://iii-directory/index"]`) — skills the bootstrap step downloads into the session's system prompt context. @@ -123,10 +114,9 @@ From | File | Purpose | |---|---| | [src/turn-orchestrator/main.ts](harness-node/src/turn-orchestrator/main.ts) | Binary entry point. | -| [src/turn-orchestrator/register.ts](harness-node/src/turn-orchestrator/register.ts) | Composes `run::start*`, `agent::trigger`, `turn::step`, the abort-signal and terminal-state state triggers, and kicks off the bootstrap. | -| [src/turn-orchestrator/run-start.ts](harness-node/src/turn-orchestrator/run-start.ts) | `run::start` + `run::start_and_wait` handlers and the `publishStep` helper. `executeSync` installs a terminal-state waiter, kicks the run, then races the waiter against `sync_default_timeout_ms` — no polling. | +| [src/turn-orchestrator/register.ts](harness-node/src/turn-orchestrator/register.ts) | Composes `run::start`, `agent::trigger`, `turn::step`, abort-signal and record-written state triggers, and kicks off the bootstrap. | +| [src/turn-orchestrator/run-start.ts](harness-node/src/turn-orchestrator/run-start.ts) | `run::start` handler — persists run config and messages, seeds `turn_state`, and wakes the FSM via the record-written state trigger. | | [src/turn-orchestrator/get-state.ts](harness-node/src/turn-orchestrator/get-state.ts) | `turn::get_state` — one-shot reader that returns the current `TurnStateRecord` for a session. UI clients call this on reload to recover in-progress modals; the orchestrator owns the state schema/key layout so clients never read iii state directly. | -| [src/turn-orchestrator/on-terminal.ts](harness-node/src/turn-orchestrator/on-terminal.ts) | State trigger adapter — `turn::is_terminal_state_write` (condition) + `turn::on_terminal_state` (handler) — plus the in-process `installTerminalWaiter` / `clearTerminalWaiter` API used by `executeSync` to await a terminal `turn_state` write reactively. | | [src/turn-orchestrator/agent-trigger.ts](harness-node/src/turn-orchestrator/agent-trigger.ts) | The dispatcher chokepoint; `dispatchWithHook` runs `consultBefore` before triggering the function and returns `result` / `deny` / `pending`. | | [src/turn-orchestrator/hook.ts](harness-node/src/turn-orchestrator/hook.ts) | `consultBefore` — calls `policy::check_permissions` directly (5 s timeout) and maps the reply via `parsePolicyReply` (`approval-gate/schemas.ts`) to `allow` / `pending` / `deny`; fails closed with a `gate_unavailable` envelope. `publishAfter` still routes through `hook-fanout::publish_collect` for the after-hook fanout path. | | [src/turn-orchestrator/approval-resume.ts](harness-node/src/turn-orchestrator/approval-resume.ts) | Per-call `turn::approval_resume` registration, handler (persist + `turn::step`), and startup recovery for parked sessions. | diff --git a/harness-node/src/harness/register.ts b/harness-node/src/harness/register.ts index c502c338..82b6ba3f 100644 --- a/harness-node/src/harness/register.ts +++ b/harness-node/src/harness/register.ts @@ -9,13 +9,16 @@ import { loadAndWatch } from './policy/handle.js'; import { FanoutState, registerSubscriptions } from './ui-subscribe.js'; export async function register(iii: ISdk, ctx: { configPath: string; url: string }): Promise { + const fanoutState = new FanoutState(); + const cfg = await loadConfig(ctx.configPath); const harness = loadHarnessConfig(cfg); + registerTrigger(iii); - const fanoutState = new FanoutState(); registerSubscriptions(iii, fanoutState); spawnPumps(iii, fanoutState); registerFs(iii, ctx.url); + const handle = await loadAndWatch(harness.permissions_path); registerPolicy(iii, handle); } diff --git a/harness-node/src/harness/trigger.ts b/harness-node/src/harness/trigger.ts index edd97df0..c24d34b7 100644 --- a/harness-node/src/harness/trigger.ts +++ b/harness-node/src/harness/trigger.ts @@ -1,50 +1,56 @@ /** - * `harness::trigger` — browser → bus bridge. + * `harness::trigger` — browser kickoff for `run::start`. * - * Accepts `{ function_id, session_id?, message_id?, payload }` (or the same - * fields at the top level over WS), calls `iii.trigger` for the inner - * function, and returns `{ status_code, headers, body }`. - * - * console/web routes chat turns through this function instead of calling - * `run::start` directly so `instrumentHandler` can read `session_id` and - * `message_id` from the outer request and stamp OTel baggage before the - * nested trigger runs. That keeps "Group by session" / "Group by message" - * working in the traces UI (`engine::traces::group_by`). + * console/web sends `{ session_id?, message_id?, payload }`; this handler + * stamps OTel baggage from the outer ids, then forwards `payload` to + * `run::start` on the turn-orchestrator worker. */ +import type { RemoteFunctionHandler } from 'iii-sdk'; +import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; -import { unwrapBody } from '../runtime/handler.js'; +import { + RunStartPayloadSchema, + type RunStartPayload, + type RunStartResult, +} from '../turn-orchestrator/run-start.js'; + +const HarnessTriggerInputSchema = z.object({ + session_id: z.string().optional(), + message_id: z.string().optional(), + payload: RunStartPayloadSchema, +}); + +export type HarnessTriggerInput = z.infer; -/** Upper bound for a single harness::trigger. Mirrors the Rust constant. */ +export interface HarnessTriggerResponse { + status_code: number; + headers: Record; + body: RunStartResult; +} + +/** Upper bound for a single `harness::trigger`. */ const BRIDGE_TIMEOUT_MS = 600_000; export function register(iii: ISdk): void { - iii.registerFunction( - 'harness::trigger', - async (input: unknown) => { - const body = unwrapBody(input); - const functionId = body.function_id; - if (typeof functionId !== 'string' || functionId.length === 0) { - throw new Error('harness::trigger: missing function_id'); - } - const inner = - body.payload && typeof body.payload === 'object' - ? (body.payload as Record) - : {}; - const result = await iii.trigger({ - function_id: functionId, - payload: inner, - timeoutMs: BRIDGE_TIMEOUT_MS, - }); - return { - status_code: 200, - headers: { 'content-type': 'application/json' }, - body: result, - }; - }, - { - description: - 'Forward {function_id, payload} to iii.trigger and return the result. Used by console/web to reach the bus over the iii-browser-sdk.', - }, - ); + const handler: RemoteFunctionHandler = async ( + input, + ) => { + const body = HarnessTriggerInputSchema.parse(input); + const result = await iii.trigger({ + function_id: 'run::start', + payload: body.payload, + timeoutMs: BRIDGE_TIMEOUT_MS, + }); + return { + status_code: 200, + headers: { 'content-type': 'application/json' }, + body: result, + }; + }; + + iii.registerFunction('harness::trigger', handler, { + description: + 'Browser kickoff: forward payload to run::start. Used by console/web over the iii-browser-sdk.', + }); } diff --git a/harness-node/src/turn-orchestrator/config.ts b/harness-node/src/turn-orchestrator/config.ts index f994fba2..d066bee9 100644 --- a/harness-node/src/turn-orchestrator/config.ts +++ b/harness-node/src/turn-orchestrator/config.ts @@ -1,13 +1,11 @@ -import { getNumber, getStringArray } from '../runtime/config.js'; +import { getStringArray } from '../runtime/config.js'; export type TurnOrchestratorConfig = { - sync_default_timeout_ms: number; system_default_skills: string[]; }; export function loadOrchestratorConfig(cfg: Record): TurnOrchestratorConfig { return { - sync_default_timeout_ms: getNumber(cfg, 'sync_default_timeout_ms', 120_000), system_default_skills: getStringArray(cfg, 'system_default_skills', [ 'iii://iii-directory/index', ]), diff --git a/harness-node/src/turn-orchestrator/on-terminal.ts b/harness-node/src/turn-orchestrator/on-terminal.ts deleted file mode 100644 index 1f6ac9b4..00000000 --- a/harness-node/src/turn-orchestrator/on-terminal.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Reactive wake for `run::start_and_wait`. A `state` trigger on - * `scope: 'agent'` filtered to `session//turn_state` writes whose - * `new_value.state === 'stopped'` fires this handler, which resolves the - * in-process waiter installed by `executeSync`. Replaces the previous - * - * Mirror of the canonical pattern in `on-abort-signal.ts` and the - * per-call approval resume functions in `approval-resume.ts`. - */ - -export const HANDLER_FN_ID = 'turn::on_terminal_state'; -export const CONDITION_FN_ID = 'turn::is_terminal_state_write'; - -const TURN_STATE_KEY_RE = /^session\/([^/]+)\/turn_state$/; - -const pending = new Map void>(); - -export const __pendingForTest = pending; - -export function isTerminalStateWrite(event: unknown): boolean { - if (!event || typeof event !== 'object') return false; - const obj = event as Record; - if (obj.event_type !== 'state:created' && obj.event_type !== 'state:updated') return false; - const key = obj.key; - if (typeof key !== 'string' || !TURN_STATE_KEY_RE.test(key)) return false; - const nv = obj.new_value; - if (!nv || typeof nv !== 'object') return false; - return (nv as Record).state === 'stopped'; -} - -function extractSessionId(key: string): string | null { - const m = TURN_STATE_KEY_RE.exec(key); - return m ? (m[1] ?? null) : null; -} - -export function installTerminalWaiter(session_id: string): Promise { - return new Promise((resolve) => { - pending.set(session_id, resolve); - }); -} - -export function clearTerminalWaiter(session_id: string): void { - pending.delete(session_id); -} - -export function handleTerminalStateWrite(event: unknown): void { - if (!event || typeof event !== 'object') return; - const obj = event as Record; - const key = obj.key; - if (typeof key !== 'string') return; - const session_id = extractSessionId(key); - if (!session_id) return; - const resolver = pending.get(session_id); - if (!resolver) return; - pending.delete(session_id); - resolver(); -} diff --git a/harness-node/src/turn-orchestrator/persistence.ts b/harness-node/src/turn-orchestrator/persistence.ts index 05fc40a2..19a8bc4f 100644 --- a/harness-node/src/turn-orchestrator/persistence.ts +++ b/harness-node/src/turn-orchestrator/persistence.ts @@ -8,8 +8,6 @@ import type { AgentMessage } from '../types/agent-message.js'; import type { FunctionCall, FunctionResult } from '../types/function.js'; import { type TurnStateRecord, - cwdIndexKey, - cwdKey, functionSchemasKey, lastSessionTreeLenKey, messagesKey, @@ -143,14 +141,6 @@ export async function loadRunRequest( return v && typeof v === 'object' ? (v as Record) : {}; } -export async function saveCwd(iii: ISdk, session_id: string, cwd: string): Promise { - await stateSet(iii, cwdKey(session_id), cwd); -} - -export async function saveCwdIndex(iii: ISdk, cwd_hash: string, session_id: string): Promise { - await stateSet(iii, cwdIndexKey(cwd_hash), session_id); -} - export async function loadSandboxId(iii: ISdk, session_id: string): Promise { const v = await stateGet(iii, sandboxIdKey(session_id)); return typeof v === 'string' ? v : null; diff --git a/harness-node/src/turn-orchestrator/register.ts b/harness-node/src/turn-orchestrator/register.ts index 7ea54662..882c60f5 100644 --- a/harness-node/src/turn-orchestrator/register.ts +++ b/harness-node/src/turn-orchestrator/register.ts @@ -22,12 +22,6 @@ import { handleTurnStateWrite, isTurnStateWrite, } from './on-turn-state-changed.js'; -import { - CONDITION_FN_ID as TERMINAL_CONDITION_FN, - HANDLER_FN_ID as TERMINAL_HANDLER_FN, - handleTerminalStateWrite, - isTerminalStateWrite, -} from './on-terminal.js'; import { register as registerRunStart } from './run-start.js'; import { recoverPendingApprovals } from './approval-resume.js'; import { register as registerSubscriber } from './subscriber.js'; @@ -35,7 +29,7 @@ import { register as registerSubscriber } from './subscriber.js'; export async function register(iii: ISdk, ctx: { configPath: string }): Promise { const cfg = await loadConfig(ctx.configPath); const orchestratorCfg = loadOrchestratorConfig(cfg); - registerRunStart(iii, orchestratorCfg); + registerRunStart(iii); registerAgentTrigger(iii); registerSubscriber(iii, orchestratorCfg); await recoverPendingApprovals(iii); @@ -64,34 +58,6 @@ export async function register(iii: ISdk, ctx: { configPath: string }): Promise< }, }); - iii.registerFunction( - TERMINAL_CONDITION_FN, - async (event: unknown) => isTerminalStateWrite(event), - { - description: 'Condition: state event sets session//turn_state to state="stopped".', - }, - ); - - iii.registerFunction( - TERMINAL_HANDLER_FN, - async (event: unknown) => { - handleTerminalStateWrite(event); - }, - { - description: - 'State trigger adapter on scope=agent for terminal turn_state writes; resolves the run::start_and_wait waiter for that session.', - }, - ); - - iii.registerTrigger({ - type: 'state', - function_id: TERMINAL_HANDLER_FN, - config: { - scope: 'agent', - condition_function_id: TERMINAL_CONDITION_FN, - }, - }); - iii.registerFunction( RECORD_CONDITION_FN, async (event: unknown) => isStepableRecordWrite(event), diff --git a/harness-node/src/turn-orchestrator/run-start.ts b/harness-node/src/turn-orchestrator/run-start.ts index d8524a64..8638fd1c 100644 --- a/harness-node/src/turn-orchestrator/run-start.ts +++ b/harness-node/src/turn-orchestrator/run-start.ts @@ -1,115 +1,51 @@ /** - * `run::start` and `run::start_and_wait`. Mirrors - * `turn-orchestrator/src/run_start.rs`. + * `run::start`. Mirrors `turn-orchestrator/src/run_start.rs`. */ -import { requireString } from '../runtime/handler.js'; +import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; -import type { AgentEvent } from '../types/agent-event.js'; import type { AgentMessage } from '../types/agent-message.js'; -import type { TurnOrchestratorConfig } from './config.js'; -import { emit } from './events.js'; -import { clearTerminalWaiter, installTerminalWaiter } from './on-terminal.js'; import * as persistence from './persistence.js'; import { newRecord } from './state.js'; - -export const FUNCTION_ID = 'run::start'; -export const SYNC_FUNCTION_ID = 'run::start_and_wait'; - -function buildRunRequest(payload: Record): Record { - return { - provider: payload.provider ?? '', - model: payload.model ?? '', - system_prompt: payload.system_prompt ?? '', - mode: payload.mode ?? null, - image: payload.image ?? 'python', - idle_timeout_secs: payload.idle_timeout_secs ?? 300, - cwd: payload.cwd ?? null, - cwd_hash: payload.cwd_hash ?? null, - }; -} - -function buildInitialEventPlan(messages: AgentMessage[]): AgentEvent[] { - const plan: AgentEvent[] = [{ type: 'agent_start' }]; - for (const m of messages) { - plan.push({ type: 'message_start', message: m }); - plan.push({ type: 'message_end', message: m }); - } - return plan; -} - -export async function execute(iii: ISdk, payload: unknown): Promise<{ session_id: string }> { - const obj = (payload ?? {}) as Record; - const session_id = requireString(obj, 'session_id'); - const max_turns = typeof obj.max_turns === 'number' ? obj.max_turns : undefined; - const request = buildRunRequest(obj); - const initial_messages = Array.isArray(obj.messages) ? (obj.messages as AgentMessage[]) : []; - - await persistence.saveRunRequest(iii, session_id, request); - await persistence.saveMessages(iii, session_id, initial_messages); - - if (typeof request.cwd === 'string') { - await persistence.saveCwd(iii, session_id, request.cwd as string); - if (typeof request.cwd_hash === 'string') { - await persistence.saveCwdIndex(iii, request.cwd_hash as string, session_id); - } - } - - for (const evt of buildInitialEventPlan(initial_messages)) { - await emit(iii, session_id, evt); - } +import type { Mode } from './system-prompt.js'; + +export const RunStartPayloadSchema = z.object({ + session_id: z.string().min(1), + message_id: z.string().optional(), + provider: z.string(), + model: z.string(), + mode: z.enum(['plan', 'ask', 'agent'] satisfies [Mode, Mode, Mode]).optional(), + messages: z.custom((v) => Array.isArray(v)).default([]), + max_turns: z.number().optional(), + system_prompt: z.string().default(''), + image: z.string().default('python'), + idle_timeout_secs: z.number().default(300), +}); + +export type RunStartPayload = z.infer; + +export type RunStartResult = { session_id: string }; + +export async function execute(iii: ISdk, payload: RunStartPayload): Promise { + const { session_id, messages, max_turns, message_id: _message_id, ...run } = payload; + + await persistence.saveRunRequest(iii, session_id, { + ...run, + mode: run.mode ?? null, + }); + await persistence.saveMessages(iii, session_id, messages); const record = newRecord(session_id, max_turns); await persistence.saveRecord(iii, record); return { session_id }; } -export async function executeSync( - iii: ISdk, - cfg: TurnOrchestratorConfig, - payload: unknown, -): Promise<{ session_id: string; messages: AgentMessage[]; turn_count: number }> { - const obj = (payload ?? {}) as Record; - const session_id = requireString(obj, 'session_id'); - const timeout_ms = - typeof obj.timeout_ms === 'number' ? obj.timeout_ms : cfg.sync_default_timeout_ms; - - // Install the waiter BEFORE kicking the run so the terminal turn_state - // write — which fires the `turn::on_terminal_state` state trigger — is - // guaranteed to find an entry to resolve. - const terminal = installTerminalWaiter(session_id); - try { - await execute(iii, payload); - - const winner = await new Promise<'terminal' | 'timeout'>((resolve) => { - const timer = setTimeout(() => resolve('timeout'), timeout_ms); - terminal.then(() => { - clearTimeout(timer); - resolve('terminal'); - }); - }); - - if (winner === 'timeout') { - throw new Error(`run::start_and_wait timed out after ${timeout_ms} ms`); - } - - const rec = await persistence.loadRecord(iii, session_id); - const messages = await persistence.loadMessages(iii, session_id); - return { session_id, messages, turn_count: rec?.turn_count ?? 0 }; - } finally { - clearTerminalWaiter(session_id); - } -} - -export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { - iii.registerFunction(FUNCTION_ID, async (payload: unknown) => execute(iii, payload), { - description: 'Start a durable agent session and return immediately.', - }); +export function register(iii: ISdk): void { iii.registerFunction( - SYNC_FUNCTION_ID, - async (payload: unknown) => executeSync(iii, cfg, payload), + 'run::start', + async (payload: unknown) => execute(iii, RunStartPayloadSchema.parse(payload)), { - description: 'Start a durable agent session and block until terminal (test/dev convenience).', + description: 'Start a durable agent session and return immediately.', }, ); } diff --git a/harness-node/src/turn-orchestrator/state.ts b/harness-node/src/turn-orchestrator/state.ts index f4b43a9a..3db9b2bf 100644 --- a/harness-node/src/turn-orchestrator/state.ts +++ b/harness-node/src/turn-orchestrator/state.ts @@ -67,8 +67,6 @@ export function isTerminal(rec: TurnStateRecord): boolean { export const messagesKey = (sid: string) => `session/${sid}/messages`; export const turnStateKey = (sid: string) => `session/${sid}/turn_state`; export const runRequestKey = (sid: string) => `session/${sid}/run_request`; -export const cwdKey = (sid: string) => `session/${sid}/cwd`; -export const cwdIndexKey = (hash: string) => `harness/cwd/${hash}/last_session_id`; export const sandboxIdKey = (sid: string) => `session/${sid}/sandbox_id`; export const functionSchemasKey = (sid: string) => `session/${sid}/function_schemas`; export const toolSchemasKey = (sid: string) => `session/${sid}/tool_schemas`; diff --git a/harness-node/src/turn-orchestrator/states/provisioning.ts b/harness-node/src/turn-orchestrator/states/provisioning.ts index 67d7cd9a..ea3a65f5 100644 --- a/harness-node/src/turn-orchestrator/states/provisioning.ts +++ b/harness-node/src/turn-orchestrator/states/provisioning.ts @@ -76,14 +76,13 @@ export async function handleProvisioning( const overrideRaw = request.system_prompt; const override = typeof overrideRaw === 'string' && overrideRaw.length > 0 ? overrideRaw : null; - const cwd = typeof request.cwd === 'string' ? (request.cwd as string) : null; const mode = asMode(request.mode); const [skillsIndex, bodies] = await Promise.all([ fetchSkillsIndex(iii), fetchDefaultSkills(iii, cfg.system_default_skills), ]); - const prompt = buildSystemPrompt(bodies, cwd, override, mode, skillsIndex); + const prompt = buildSystemPrompt(bodies, null, override, mode, skillsIndex); const updated = { ...request, system_prompt: prompt }; await persistence.saveRunRequest(iii, rec.session_id, updated); diff --git a/harness-node/tests/harness/policy.test.ts b/harness-node/tests/harness/policy.test.ts index e75c4a04..96dc4ce4 100644 --- a/harness-node/tests/harness/policy.test.ts +++ b/harness-node/tests/harness/policy.test.ts @@ -412,7 +412,6 @@ describe('shipped iii-permissions.yaml', () => { 'auth::set_token', 'auth::delete_token', 'run::start', - 'run::start_and_wait', 'router::stream_assistant', 'router::abort', ]; diff --git a/harness-node/tests/harness/trigger.test.ts b/harness-node/tests/harness/trigger.test.ts index 561fad6d..d12859ec 100644 --- a/harness-node/tests/harness/trigger.test.ts +++ b/harness-node/tests/harness/trigger.test.ts @@ -1,11 +1,7 @@ /** * Contract test for `harness::trigger`. * - * Mirrors the Rust harness bridge (see `workers/harness/src/lib.rs:103-159`) - * by forwarding `{ function_id, payload }` to `iii.trigger` and wrapping the - * result in an HTTP-style envelope. This ensures console/web can route - * browser-originated chat turns through a single instrumented bus function — - * the same pattern `workers/harness/web/src/App.tsx` uses over HTTP. + * console/web forwards chat kickoff via a single flat payload over WS. */ import { describe, expect, it, vi } from 'vitest'; @@ -28,6 +24,19 @@ function makeFakeSdk(triggerResult: unknown = { ok: true }) { return { sdk, registered, trigger }; } +const runStartPayload = { + session_id: 'sess-1', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + messages: [ + { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'hi' }], + timestamp: Date.now(), + }, + ], +}; + describe('harness::trigger', () => { it('registers a handler under id "harness::trigger"', () => { const { sdk, registered } = makeFakeSdk(); @@ -35,74 +44,43 @@ describe('harness::trigger', () => { expect(registered.has('harness::trigger')).toBe(true); }); - it('forwards body.function_id and body.payload to iii.trigger', async () => { + it('forwards payload to run::start', async () => { const { sdk, registered, trigger } = makeFakeSdk({ session_id: 'sess' }); register(sdk); const handler = registered.get('harness::trigger')?.handler; if (!handler) throw new Error('handler not registered'); const result = (await handler({ - body: { - function_id: 'run::start', - session_id: 'sess-1', - message_id: 'msg-1', - payload: { session_id: 'sess-1', provider: 'anthropic', model: 'claude-sonnet-4-6' }, - }, + session_id: 'sess-1', + message_id: 'msg-1', + payload: runStartPayload, })) as Record; expect(trigger).toHaveBeenCalledTimes(1); const triggerArg = trigger.mock.calls[0]?.[0] as Record; expect(triggerArg.function_id).toBe('run::start'); - expect(triggerArg.payload).toEqual({ - session_id: 'sess-1', - provider: 'anthropic', - model: 'claude-sonnet-4-6', + expect(triggerArg.payload).toMatchObject(runStartPayload); + expect(triggerArg.payload).toMatchObject({ + system_prompt: '', + image: 'python', + idle_timeout_secs: 300, }); expect(result.status_code).toBe(200); expect(result.body).toEqual({ session_id: 'sess' }); }); - it('falls back to top-level when body envelope is absent (WS shape)', async () => { - const { sdk, registered, trigger } = makeFakeSdk(); - register(sdk); - const handler = registered.get('harness::trigger')?.handler; - if (!handler) throw new Error('handler not registered'); - - await handler({ - function_id: 'run::start', - session_id: 'sess-2', - message_id: 'msg-2', - payload: { session_id: 'sess-2', provider: 'openai', model: 'gpt-4o' }, - }); - - const triggerArg = trigger.mock.calls[0]?.[0] as Record; - expect(triggerArg.function_id).toBe('run::start'); - expect(triggerArg.payload).toEqual({ - session_id: 'sess-2', - provider: 'openai', - model: 'gpt-4o', - }); - }); - - it('defaults payload to an empty object when omitted', async () => { - const { sdk, registered, trigger } = makeFakeSdk(); - register(sdk); - const handler = registered.get('harness::trigger')?.handler; - if (!handler) throw new Error('handler not registered'); - - await handler({ body: { function_id: 'state::get' } }); - - const triggerArg = trigger.mock.calls[0]?.[0] as Record; - expect(triggerArg.payload).toEqual({}); - }); - - it('throws when function_id is missing', async () => { + it('rejects invalid run::start payload', async () => { const { sdk, registered } = makeFakeSdk(); register(sdk); const handler = registered.get('harness::trigger')?.handler; if (!handler) throw new Error('handler not registered'); - await expect(handler({ body: { payload: {} } })).rejects.toThrow(/missing function_id/); + await expect( + handler({ + session_id: 'sess-1', + payload: { provider: 'openai' }, + }), + ).rejects.toThrow(); }); it('surfaces trigger errors (no swallowing)', async () => { @@ -120,7 +98,9 @@ describe('harness::trigger', () => { if (!triggerHandler) throw new Error('handler not registered'); await expect( // biome-ignore lint/style/noNonNullAssertion: defined above - triggerHandler!({ body: { function_id: 'run::start', payload: {} } }), + triggerHandler!({ + payload: runStartPayload, + }), ).rejects.toThrow(/boom/); }); }); diff --git a/harness-node/tests/turn-orchestrator/config.test.ts b/harness-node/tests/turn-orchestrator/config.test.ts index de967d60..7948a32b 100644 --- a/harness-node/tests/turn-orchestrator/config.test.ts +++ b/harness-node/tests/turn-orchestrator/config.test.ts @@ -2,18 +2,15 @@ import { describe, expect, it } from 'vitest'; import { loadOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; describe('loadOrchestratorConfig', () => { - it('applies defaults for sync timeout and system skills', () => { + it('applies defaults for system skills', () => { const cfg = loadOrchestratorConfig({}); - expect(cfg.sync_default_timeout_ms).toBe(120_000); expect(cfg.system_default_skills).toEqual(['iii://iii-directory/index']); }); - it('reads sync_default_timeout_ms and system_default_skills from config', () => { + it('reads system_default_skills from config', () => { const cfg = loadOrchestratorConfig({ - sync_default_timeout_ms: 60_000, system_default_skills: ['skill-a'], }); - expect(cfg.sync_default_timeout_ms).toBe(60_000); expect(cfg.system_default_skills).toEqual(['skill-a']); }); }); diff --git a/harness-node/tests/turn-orchestrator/functions.test.ts b/harness-node/tests/turn-orchestrator/functions.test.ts index dcec557a..b8120acc 100644 --- a/harness-node/tests/turn-orchestrator/functions.test.ts +++ b/harness-node/tests/turn-orchestrator/functions.test.ts @@ -10,7 +10,6 @@ import * as approvalResumeModule from '../../src/turn-orchestrator/approval-resu import { handleExecute } from '../../src/turn-orchestrator/states/functions.js'; const cfg: TurnOrchestratorConfig = { - sync_default_timeout_ms: 120_000, system_default_skills: [], }; diff --git a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts index 2355e23b..aad66b26 100644 --- a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts +++ b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts @@ -77,7 +77,7 @@ describe('isAbortSignalWrite condition', () => { isAbortSignalWrite({ event_type: 'state:updated', scope: 'agent', - key: 'harness/cwd/abc/last_session_id', + key: 'harness/index/abc/last_session_id', old_value: null, new_value: 'sess-1', message_type: 'state', diff --git a/harness-node/tests/turn-orchestrator/on-terminal.test.ts b/harness-node/tests/turn-orchestrator/on-terminal.test.ts deleted file mode 100644 index 96624767..00000000 --- a/harness-node/tests/turn-orchestrator/on-terminal.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - __pendingForTest as pending, - clearTerminalWaiter, - handleTerminalStateWrite, - installTerminalWaiter, - isTerminalStateWrite, -} from '../../src/turn-orchestrator/on-terminal.js'; - -afterEach(() => { - pending.clear(); -}); - -describe('isTerminalStateWrite condition', () => { - it('matches state:updated on turn_state with state === "stopped"', () => { - expect( - isTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'tearing_down' }, - new_value: { state: 'stopped' }, - message_type: 'state', - }), - ).toBe(true); - }); - - it('matches state:created with state === "stopped" (replay edge case)', () => { - expect( - isTerminalStateWrite({ - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: null, - new_value: { state: 'stopped' }, - message_type: 'state', - }), - ).toBe(true); - }); - - it('skips non-terminal turn_state writes', () => { - expect( - isTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'awaiting_assistant' }, - new_value: { state: 'function_execute' }, - message_type: 'state', - }), - ).toBe(false); - }); - - it('skips state:deleted', () => { - expect( - isTerminalStateWrite({ - event_type: 'state:deleted', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'stopped' }, - new_value: null, - message_type: 'state', - }), - ).toBe(false); - }); - - it('skips writes to keys other than turn_state', () => { - expect( - isTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/abort_signal', - old_value: false, - new_value: true, - message_type: 'state', - }), - ).toBe(false); - }); - - it('skips writes where new_value lacks a state field', () => { - expect( - isTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'tearing_down' }, - new_value: { turn_count: 3 }, - message_type: 'state', - }), - ).toBe(false); - }); -}); - -describe('installTerminalWaiter + handleTerminalStateWrite', () => { - it('resolves the waiter when a terminal write fires for that session', async () => { - const waiter = installTerminalWaiter('sess-abc'); - - handleTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'tearing_down' }, - new_value: { state: 'stopped' }, - message_type: 'state', - }); - - await expect(waiter).resolves.toBeUndefined(); - }); - - it('ignores writes for unrelated sessions', async () => { - const waiter = installTerminalWaiter('sess-abc'); - let resolved = false; - waiter.then(() => { - resolved = true; - }); - - handleTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-xyz/turn_state', - old_value: { state: 'tearing_down' }, - new_value: { state: 'stopped' }, - message_type: 'state', - }); - - await Promise.resolve(); - expect(resolved).toBe(false); - - clearTerminalWaiter('sess-abc'); - }); - - it('clearTerminalWaiter removes the waiter without resolving it', async () => { - const waiter = installTerminalWaiter('sess-abc'); - let settled = false; - waiter.then(() => { - settled = true; - }); - - clearTerminalWaiter('sess-abc'); - - handleTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'tearing_down' }, - new_value: { state: 'stopped' }, - message_type: 'state', - }); - - await Promise.resolve(); - expect(settled).toBe(false); - }); - - it('handleTerminalStateWrite is a no-op for malformed events', () => { - expect(() => handleTerminalStateWrite(null)).not.toThrow(); - expect(() => - handleTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'not/a/match', - new_value: { state: 'stopped' }, - message_type: 'state', - }), - ).not.toThrow(); - }); - - it('multiple terminal writes for the same session resolve the waiter exactly once', async () => { - const waiter = installTerminalWaiter('sess-abc'); - const resolver = vi.fn(); - waiter.then(resolver); - - handleTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'tearing_down' }, - new_value: { state: 'stopped' }, - message_type: 'state', - }); - handleTerminalStateWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'stopped' }, - new_value: { state: 'stopped' }, - message_type: 'state', - }); - - await Promise.resolve(); - expect(resolver).toHaveBeenCalledTimes(1); - }); -}); diff --git a/harness-node/tests/turn-orchestrator/run-start.test.ts b/harness-node/tests/turn-orchestrator/run-start.test.ts index c429cbc0..e4a3cc9f 100644 --- a/harness-node/tests/turn-orchestrator/run-start.test.ts +++ b/harness-node/tests/turn-orchestrator/run-start.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import { FUNCTION_ID, SYNC_FUNCTION_ID, execute } from '../../src/turn-orchestrator/run-start.js'; +import { execute } from '../../src/turn-orchestrator/run-start.js'; type TriggerCall = { function_id: string; payload: unknown }; @@ -15,13 +15,6 @@ function fakeIii(): { iii: ISdk; calls: TriggerCall[] } { return { iii, calls }; } -describe('run-start constants', () => { - it('exposes only start function ids', () => { - expect(FUNCTION_ID).toBe('run::start'); - expect(SYNC_FUNCTION_ID).toBe('run::start_and_wait'); - }); -}); - describe('execute', () => { it('saves initial session state to wake the reactive step trigger', async () => { const { iii, calls } = fakeIii(); @@ -33,9 +26,6 @@ describe('execute', () => { messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }], timestamp: 1 }], }); - // The reactive wake is now state-driven: the turn_state write at - // state='provisioning' is what the on-record-written trigger picks up. - // run-start no longer self-publishes turn::step_requested. const turnStateSet = calls.find( (c) => c.function_id === 'state::set' && diff --git a/iii-permissions.yaml b/iii-permissions.yaml index 0440daad..591be678 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -21,7 +21,6 @@ rules: - '!oauth::anthropic::login' - '!oauth::openai-codex::login' - '!run::start' - - '!run::start_and_wait' - '!router::stream_assistant' - '!router::abort' From 160e87bb91cc15d76c0ee7bea6943affb36373e2 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 20 May 2026 20:08:27 -0300 Subject: [PATCH 02/16] Simplify turn_state trigger adapters with Zod boundary parsing. Replace manual typeof/Record parsing with shared TurnStateWriteEventSchema and parseTurnStateWrite, matching the run-start/trigger refactor style. --- .../turn-orchestrator/on-record-written.ts | 34 ++++-------- .../on-turn-state-changed.ts | 54 +++++++++++++------ .../integration/on-record-written.e2e.test.ts | 7 +-- .../on-record-written.test.ts | 2 +- 4 files changed, 50 insertions(+), 47 deletions(-) diff --git a/harness-node/src/turn-orchestrator/on-record-written.ts b/harness-node/src/turn-orchestrator/on-record-written.ts index 28c48f08..83dd5664 100644 --- a/harness-node/src/turn-orchestrator/on-record-written.ts +++ b/harness-node/src/turn-orchestrator/on-record-written.ts @@ -14,15 +14,13 @@ import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; +import { parseTurnStateWrite } from './on-turn-state-changed.js'; +import { STEP_TOPIC } from './subscriber.js'; import type { TurnState } from './state.js'; -import { STEP_FN_ID, STEP_TOPIC } from './subscriber.js'; -export { STEP_FN_ID }; export const HANDLER_FN_ID = 'turn::on_record_written'; export const CONDITION_FN_ID = 'turn::is_stepable_record_write'; -const TURN_STATE_KEY_RE = /^session\/(?[^/]+)\/turn_state$/; - const NON_STEPABLE_STATES: ReadonlySet = new Set([ 'stopped', 'function_awaiting_approval', @@ -41,30 +39,18 @@ type StepableWrite = { * even if the condition was bypassed. */ function parseStepableWrite(event: unknown): StepableWrite | null { - if (!event || typeof event !== 'object') return null; - const obj = event as Record; - - if (obj.event_type !== 'state:created' && obj.event_type !== 'state:updated') return null; - - const key = obj.key; - if (typeof key !== 'string') return null; - const session_id = TURN_STATE_KEY_RE.exec(key)?.groups?.session_id; - if (!session_id) return null; + const parsed = parseTurnStateWrite(event); + if (!parsed) return null; - const nv = obj.new_value; - if (!nv || typeof nv !== 'object') return null; - const state = (nv as Record).state; - if (typeof state !== 'string') return null; - if (NON_STEPABLE_STATES.has(state as TurnState)) return null; + const state = parsed.new_value.state as TurnState; + if (NON_STEPABLE_STATES.has(state)) return null; - if (obj.event_type === 'state:updated') { - const ov = obj.old_value; - const old_state = - ov && typeof ov === 'object' ? (ov as Record).state : undefined; + if (parsed.event_type === 'state:updated') { + const old_state = parsed.old_value?.state; if (typeof old_state === 'string' && old_state === state) return null; } - return { session_id, state: state as TurnState }; + return { session_id: parsed.session_id, state }; } export function isStepableRecordWrite(event: unknown): boolean { @@ -77,7 +63,7 @@ export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Prom try { await iii.trigger({ - function_id: STEP_FN_ID, + function_id: 'turn::step', payload: { session_id: parsed.session_id }, }); return; diff --git a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts index a1e18942..62ef04dc 100644 --- a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts +++ b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts @@ -5,47 +5,67 @@ * so it can derive pending approvals from state directly. */ +import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; import { emit } from './events.js'; +import { turnStateKey } from './state.js'; export const HANDLER_FN_ID = 'turn::on_turn_state_changed'; export const CONDITION_FN_ID = 'turn::is_turn_state_write'; -const TURN_STATE_KEY_RE = /^session\/(?[^/]+)\/turn_state$/; +const TurnStateRecordValueSchema = z.object({ state: z.string() }).passthrough(); -type ParsedWrite = { +export const TurnStateWriteEventSchema = z.object({ + event_type: z.enum(['state:created', 'state:updated']), + key: z.string(), + new_value: TurnStateRecordValueSchema, + old_value: z.unknown().optional(), +}); + +export type ParsedTurnStateWrite = { session_id: string; event_type: 'state:created' | 'state:updated'; new_value: Record; old_value?: Record; }; -function parseWrite(event: unknown): ParsedWrite | null { - if (!event || typeof event !== 'object') return null; - const obj = event as Record; - if (obj.event_type !== 'state:created' && obj.event_type !== 'state:updated') return null; - const key = obj.key; - if (typeof key !== 'string') return null; - const session_id = TURN_STATE_KEY_RE.exec(key)?.groups?.session_id; +function sessionIdFromTurnStateKey(key: string): string | null { + const match = /^session\/([^/]+)\/turn_state$/.exec(key); + const session_id = match?.[1]; + if (!session_id || turnStateKey(session_id) !== key) return null; + return session_id; +} + +/** Shared parse for agent-scope turn_state create/update events. */ +export function parseTurnStateWrite(event: unknown): ParsedTurnStateWrite | null { + const parsed = TurnStateWriteEventSchema.safeParse(event); + if (!parsed.success) return null; + + const session_id = sessionIdFromTurnStateKey(parsed.data.key); if (!session_id) return null; - const nv = obj.new_value; - if (!nv || typeof nv !== 'object') return null; - const ov = obj.old_value; + + const old_value = + parsed.data.old_value && + typeof parsed.data.old_value === 'object' && + parsed.data.old_value !== null + ? (parsed.data.old_value as Record) + : undefined; + return { session_id, - event_type: obj.event_type, - new_value: nv as Record, - old_value: ov && typeof ov === 'object' ? (ov as Record) : undefined, + event_type: parsed.data.event_type, + new_value: parsed.data.new_value as Record, + ...(old_value !== undefined && { old_value }), }; } export function isTurnStateWrite(event: unknown): boolean { - return parseWrite(event) !== null; + return parseTurnStateWrite(event) !== null; } export async function handleTurnStateWrite(iii: ISdk, event: unknown): Promise { - const parsed = parseWrite(event); + const parsed = parseTurnStateWrite(event); if (!parsed) return; try { diff --git a/harness-node/tests/integration/on-record-written.e2e.test.ts b/harness-node/tests/integration/on-record-written.e2e.test.ts index 17692ebf..e0e97f42 100644 --- a/harness-node/tests/integration/on-record-written.e2e.test.ts +++ b/harness-node/tests/integration/on-record-written.e2e.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { - STEP_FN_ID, handleStepableRecordWrite, isStepableRecordWrite, } from '../../src/turn-orchestrator/on-record-written.js'; +import { STEP_FN_ID } from '../../src/turn-orchestrator/subscriber.js'; function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> } { const stateStore = new Map(); @@ -87,10 +87,7 @@ describe('turn-step reactive wake', () => { }); await Promise.resolve(); - expect(stepInvocations).toEqual([ - { session_id: 'sess-b' }, - { session_id: 'sess-b' }, - ]); + expect(stepInvocations).toEqual([{ session_id: 'sess-b' }, { session_id: 'sess-b' }]); }); it('parking in function_awaiting_approval does NOT wake', async () => { diff --git a/harness-node/tests/turn-orchestrator/on-record-written.test.ts b/harness-node/tests/turn-orchestrator/on-record-written.test.ts index 49c4f6bb..84d31a9a 100644 --- a/harness-node/tests/turn-orchestrator/on-record-written.test.ts +++ b/harness-node/tests/turn-orchestrator/on-record-written.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { - STEP_FN_ID, handleStepableRecordWrite, isStepableRecordWrite, } from '../../src/turn-orchestrator/on-record-written.js'; +import { STEP_FN_ID } from '../../src/turn-orchestrator/subscriber.js'; describe('isStepableRecordWrite condition', () => { it('matches turn_state writes with a non-terminal, non-awaiting state', () => { From 59c79f548e88ecdcee3aeddcad844b8648ead934 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 20 May 2026 20:11:52 -0300 Subject: [PATCH 03/16] Colocate turn-orchestrator registration with handler modules. Each state-trigger adapter now exports register() with inline function ids, matching run-start.ts. register.ts only composes modules; exported ID constants removed. --- .../src/turn-orchestrator/agent-trigger.ts | 3 +- .../src/turn-orchestrator/approval-resume.ts | 3 +- .../src/turn-orchestrator/get-state.ts | 6 +- .../src/turn-orchestrator/on-abort-signal.ts | 34 +++++- .../turn-orchestrator/on-record-written.ts | 35 +++++- .../on-turn-state-changed.ts | 31 +++++- .../src/turn-orchestrator/register.ts | 100 ++---------------- .../src/turn-orchestrator/subscriber.ts | 7 +- .../integration/on-record-written.e2e.test.ts | 3 +- .../turn-orchestrator/agent-trigger.test.ts | 4 +- .../tests/turn-orchestrator/get-state.test.ts | 6 +- .../turn-orchestrator/on-abort-signal.test.ts | 3 +- .../on-record-written.test.ts | 7 +- .../on-turn-state-changed.test.ts | 7 -- 14 files changed, 108 insertions(+), 141 deletions(-) diff --git a/harness-node/src/turn-orchestrator/agent-trigger.ts b/harness-node/src/turn-orchestrator/agent-trigger.ts index 3eca814e..8e182aa8 100644 --- a/harness-node/src/turn-orchestrator/agent-trigger.ts +++ b/harness-node/src/turn-orchestrator/agent-trigger.ts @@ -15,7 +15,6 @@ import type { FunctionCall, FunctionResult } from '../types/function.js'; import { type DenialEnvelope, consultBefore, gateUnavailableEnvelope } from './hook.js'; export const TOOL_NAME = 'agent_trigger'; -export const FUNCTION_ID = 'agent::trigger'; export type DispatchResult = | { kind: 'result'; result: FunctionResult } @@ -185,7 +184,7 @@ export async function dispatch( export function register(iii: ISdk): void { iii.registerFunction( - FUNCTION_ID, + 'agent::trigger', async (payload: unknown) => { const obj = (payload ?? {}) as Record; const session_id = typeof obj.session_id === 'string' ? obj.session_id : ''; diff --git a/harness-node/src/turn-orchestrator/approval-resume.ts b/harness-node/src/turn-orchestrator/approval-resume.ts index 18435a1c..66f05192 100644 --- a/harness-node/src/turn-orchestrator/approval-resume.ts +++ b/harness-node/src/turn-orchestrator/approval-resume.ts @@ -19,7 +19,6 @@ import { stateSet, } from '../runtime/state.js'; import type { TurnStateRecord } from './state.js'; -import { STEP_FN_ID } from './subscriber.js'; const resumeRefs = new Map(); const TURN_STATE_KEY_RE = /^session\/[^/]+\/turn_state$/; @@ -90,7 +89,7 @@ async function handleApprovalResume( } try { - await iii.trigger({ function_id: STEP_FN_ID, payload: { session_id } }); + await iii.trigger({ function_id: 'turn::step', payload: { session_id } }); } catch (err) { logger.warn('approval resume: turn::step invoke failed', { session_id, err: String(err) }); } diff --git a/harness-node/src/turn-orchestrator/get-state.ts b/harness-node/src/turn-orchestrator/get-state.ts index d30f88a4..549e62ab 100644 --- a/harness-node/src/turn-orchestrator/get-state.ts +++ b/harness-node/src/turn-orchestrator/get-state.ts @@ -12,8 +12,6 @@ import type { ISdk } from '../runtime/iii.js'; import * as persistence from './persistence.js'; import type { TurnStateRecord } from './state.js'; -export const FUNCTION_ID = 'turn::get_state'; - export async function execute(iii: ISdk, payload: unknown): Promise { const obj = (payload ?? {}) as Record; const session_id = requireString(obj, 'session_id'); @@ -21,8 +19,8 @@ export async function execute(iii: ISdk, payload: unknown): Promise execute(iii, payload), { + iii.registerFunction('turn::get_state', async (payload: unknown) => execute(iii, payload), { description: - "Read the current turn_state record for a session. Returns null if the session is unknown. UI clients use this on page reload to recover any in-progress modals (e.g. function_awaiting_approval) without reading iii state directly.", + 'Read the current turn_state record for a session. Returns null if the session is unknown. UI clients use this on page reload to recover any in-progress modals (e.g. function_awaiting_approval) without reading iii state directly.', }); } diff --git a/harness-node/src/turn-orchestrator/on-abort-signal.ts b/harness-node/src/turn-orchestrator/on-abort-signal.ts index 578ba25b..742c945b 100644 --- a/harness-node/src/turn-orchestrator/on-abort-signal.ts +++ b/harness-node/src/turn-orchestrator/on-abort-signal.ts @@ -19,9 +19,6 @@ import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; -export const STEP_TOPIC = 'turn::step_requested'; -export const HANDLER_FN_ID = 'turn::on_abort_signal'; -export const CONDITION_FN_ID = 'turn::is_abort_signal_set'; const ABORT_SIGNAL_KEY_RE = /^session\/([^/]+)\/abort_signal$/; export function isAbortSignalWrite(event: unknown): boolean { @@ -50,7 +47,7 @@ export async function handleAbortSignalWrite(iii: ISdk, event: unknown): Promise try { await iii.trigger({ function_id: 'iii::durable::publish', - payload: { topic: STEP_TOPIC, data: { session_id } }, + payload: { topic: 'turn::step_requested', data: { session_id } }, }); } catch (err) { logger.warn('turn::on_abort_signal: publish failed', { @@ -59,3 +56,32 @@ export async function handleAbortSignalWrite(iii: ISdk, event: unknown): Promise }); } } + +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::is_abort_signal_set', + async (event: unknown) => isAbortSignalWrite(event), + { + description: + 'Condition: state event sets session//abort_signal = true (state:created or state:updated).', + }, + ); + + iii.registerFunction( + 'turn::on_abort_signal', + async (event: unknown) => handleAbortSignalWrite(iii, event), + { + description: + 'State trigger adapter on scope=agent for abort_signal writes; publishes turn::step_requested so the orchestrator picks up the abort promptly.', + }, + ); + + iii.registerTrigger({ + type: 'state', + function_id: 'turn::on_abort_signal', + config: { + scope: 'agent', + condition_function_id: 'turn::is_abort_signal_set', + }, + }); +} diff --git a/harness-node/src/turn-orchestrator/on-record-written.ts b/harness-node/src/turn-orchestrator/on-record-written.ts index 83dd5664..a7cce774 100644 --- a/harness-node/src/turn-orchestrator/on-record-written.ts +++ b/harness-node/src/turn-orchestrator/on-record-written.ts @@ -15,12 +15,8 @@ import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; import { parseTurnStateWrite } from './on-turn-state-changed.js'; -import { STEP_TOPIC } from './subscriber.js'; import type { TurnState } from './state.js'; -export const HANDLER_FN_ID = 'turn::on_record_written'; -export const CONDITION_FN_ID = 'turn::is_stepable_record_write'; - const NON_STEPABLE_STATES: ReadonlySet = new Set([ 'stopped', 'function_awaiting_approval', @@ -80,7 +76,7 @@ export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Prom try { await iii.trigger({ function_id: 'iii::durable::publish', - payload: { topic: STEP_TOPIC, data: { session_id: parsed.session_id } }, + payload: { topic: 'turn::step_requested', data: { session_id: parsed.session_id } }, }); } catch (publishErr) { logger.error( @@ -90,3 +86,32 @@ export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Prom } } } + +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::is_stepable_record_write', + async (event: unknown) => isStepableRecordWrite(event), + { + description: + 'Condition: state event sets session//turn_state to a stepable state (excludes stopped + function_awaiting_approval).', + }, + ); + + iii.registerFunction( + 'turn::on_record_written', + async (event: unknown) => handleStepableRecordWrite(iii, event), + { + description: + 'State trigger adapter on scope=agent for stepable turn_state writes; invokes turn::step. Replaces the imperative publishStep self-publish.', + }, + ); + + iii.registerTrigger({ + type: 'state', + function_id: 'turn::on_record_written', + config: { + scope: 'agent', + condition_function_id: 'turn::is_stepable_record_write', + }, + }); +} diff --git a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts index 62ef04dc..41154980 100644 --- a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts +++ b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts @@ -11,9 +11,6 @@ import { logger } from '../runtime/otel.js'; import { emit } from './events.js'; import { turnStateKey } from './state.js'; -export const HANDLER_FN_ID = 'turn::on_turn_state_changed'; -export const CONDITION_FN_ID = 'turn::is_turn_state_write'; - const TurnStateRecordValueSchema = z.object({ state: z.string() }).passthrough(); export const TurnStateWriteEventSchema = z.object({ @@ -82,3 +79,31 @@ export async function handleTurnStateWrite(iii: ISdk, event: unknown): Promise isTurnStateWrite(event), + { + description: 'Condition: state event is a write to session//turn_state.', + }, + ); + + iii.registerFunction( + 'turn::on_turn_state_changed', + async (event: unknown) => handleTurnStateWrite(iii, event), + { + description: + 'State trigger adapter on scope=agent for turn_state writes; emits turn_state_changed on agent::events for the subscribed UI.', + }, + ); + + iii.registerTrigger({ + type: 'state', + function_id: 'turn::on_turn_state_changed', + config: { + scope: 'agent', + condition_function_id: 'turn::is_turn_state_write', + }, + }); +} diff --git a/harness-node/src/turn-orchestrator/register.ts b/harness-node/src/turn-orchestrator/register.ts index 882c60f5..f9c441cc 100644 --- a/harness-node/src/turn-orchestrator/register.ts +++ b/harness-node/src/turn-orchestrator/register.ts @@ -4,24 +4,9 @@ import { register as registerAgentTrigger } from './agent-trigger.js'; import * as bootstrap from './bootstrap.js'; import { loadOrchestratorConfig } from './config.js'; import { register as registerGetState } from './get-state.js'; -import { - CONDITION_FN_ID as ABORT_CONDITION_FN, - HANDLER_FN_ID as ABORT_HANDLER_FN, - handleAbortSignalWrite, - isAbortSignalWrite, -} from './on-abort-signal.js'; -import { - CONDITION_FN_ID as RECORD_CONDITION_FN, - HANDLER_FN_ID as RECORD_HANDLER_FN, - handleStepableRecordWrite, - isStepableRecordWrite, -} from './on-record-written.js'; -import { - CONDITION_FN_ID as TURN_STATE_CHANGED_CONDITION_FN, - HANDLER_FN_ID as TURN_STATE_CHANGED_HANDLER_FN, - handleTurnStateWrite, - isTurnStateWrite, -} from './on-turn-state-changed.js'; +import { register as registerOnAbortSignal } from './on-abort-signal.js'; +import { register as registerOnRecordWritten } from './on-record-written.js'; +import { register as registerOnTurnStateChanged } from './on-turn-state-changed.js'; import { register as registerRunStart } from './run-start.js'; import { recoverPendingApprovals } from './approval-resume.js'; import { register as registerSubscriber } from './subscriber.js'; @@ -34,82 +19,9 @@ export async function register(iii: ISdk, ctx: { configPath: string }): Promise< registerSubscriber(iii, orchestratorCfg); await recoverPendingApprovals(iii); registerGetState(iii); - - iii.registerFunction(ABORT_CONDITION_FN, async (event: unknown) => isAbortSignalWrite(event), { - description: - 'Condition: state event sets session//abort_signal = true (state:created or state:updated).', - }); - - iii.registerFunction( - ABORT_HANDLER_FN, - async (event: unknown) => handleAbortSignalWrite(iii, event), - { - description: - 'State trigger adapter on scope=agent for abort_signal writes; publishes turn::step_requested so the orchestrator picks up the abort promptly.', - }, - ); - - iii.registerTrigger({ - type: 'state', - function_id: ABORT_HANDLER_FN, - config: { - scope: 'agent', - condition_function_id: ABORT_CONDITION_FN, - }, - }); - - iii.registerFunction( - RECORD_CONDITION_FN, - async (event: unknown) => isStepableRecordWrite(event), - { - description: - 'Condition: state event sets session//turn_state to a stepable state (excludes stopped + function_awaiting_approval).', - }, - ); - - iii.registerFunction( - RECORD_HANDLER_FN, - async (event: unknown) => handleStepableRecordWrite(iii, event), - { - description: - 'State trigger adapter on scope=agent for stepable turn_state writes; invokes turn::step. Replaces the imperative publishStep self-publish.', - }, - ); - - iii.registerTrigger({ - type: 'state', - function_id: RECORD_HANDLER_FN, - config: { - scope: 'agent', - condition_function_id: RECORD_CONDITION_FN, - }, - }); - - iii.registerFunction( - TURN_STATE_CHANGED_CONDITION_FN, - async (event: unknown) => isTurnStateWrite(event), - { - description: 'Condition: state event is a write to session//turn_state.', - }, - ); - - iii.registerFunction( - TURN_STATE_CHANGED_HANDLER_FN, - async (event: unknown) => handleTurnStateWrite(iii, event), - { - description: - 'State trigger adapter on scope=agent for turn_state writes; emits turn_state_changed on agent::events for the subscribed UI.', - }, - ); - - iii.registerTrigger({ - type: 'state', - function_id: TURN_STATE_CHANGED_HANDLER_FN, - config: { - scope: 'agent', - condition_function_id: TURN_STATE_CHANGED_CONDITION_FN, - }, - }); + registerOnAbortSignal(iii); + registerOnRecordWritten(iii); + registerOnTurnStateChanged(iii); // Bootstrap best-effort skill download in the background. void bootstrap.run(iii, orchestratorCfg); diff --git a/harness-node/src/turn-orchestrator/subscriber.ts b/harness-node/src/turn-orchestrator/subscriber.ts index 99d36623..352a2f51 100644 --- a/harness-node/src/turn-orchestrator/subscriber.ts +++ b/harness-node/src/turn-orchestrator/subscriber.ts @@ -9,8 +9,7 @@ import * as persistence from './persistence.js'; import { isTerminal } from './state.js'; import { step } from './transitions.js'; -export const STEP_FN_ID = 'turn::step'; -export const STEP_TOPIC = 'turn::step_requested'; +const STEP_TOPIC = 'turn::step_requested'; function extractSessionId(payload: unknown): string | null { if (!payload || typeof payload !== 'object') return null; @@ -52,12 +51,12 @@ export async function execute( } export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { - iii.registerFunction(STEP_FN_ID, async (payload: unknown) => execute(iii, cfg, payload), { + iii.registerFunction('turn::step', async (payload: unknown) => execute(iii, cfg, payload), { description: 'Run one durable state machine transition for a session.', }); iii.registerTrigger({ type: 'durable:subscriber', - function_id: STEP_FN_ID, + function_id: 'turn::step', config: { topic: STEP_TOPIC }, }); } diff --git a/harness-node/tests/integration/on-record-written.e2e.test.ts b/harness-node/tests/integration/on-record-written.e2e.test.ts index e0e97f42..cd0b11a6 100644 --- a/harness-node/tests/integration/on-record-written.e2e.test.ts +++ b/harness-node/tests/integration/on-record-written.e2e.test.ts @@ -4,7 +4,6 @@ import { handleStepableRecordWrite, isStepableRecordWrite, } from '../../src/turn-orchestrator/on-record-written.js'; -import { STEP_FN_ID } from '../../src/turn-orchestrator/subscriber.js'; function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> } { const stateStore = new Map(); @@ -34,7 +33,7 @@ function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> return null; } - if (function_id === STEP_FN_ID) { + if (function_id === 'turn::step') { stepInvocations.push(payload as { session_id: string }); return null; } diff --git a/harness-node/tests/turn-orchestrator/agent-trigger.test.ts b/harness-node/tests/turn-orchestrator/agent-trigger.test.ts index e1062684..9eeea6cc 100644 --- a/harness-node/tests/turn-orchestrator/agent-trigger.test.ts +++ b/harness-node/tests/turn-orchestrator/agent-trigger.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import type { DispatchResult } from '../../src/turn-orchestrator/agent-trigger.js'; import { - FUNCTION_ID, TOOL_NAME, agentTriggerTool, dispatchWithHook, @@ -23,9 +22,8 @@ describe('agent_trigger tool schema', () => { expect(params.required).toEqual(['function']); }); - it('TOOL_NAME and FUNCTION_ID are stable', () => { + it('TOOL_NAME is stable', () => { expect(TOOL_NAME).toBe('agent_trigger'); - expect(FUNCTION_ID).toBe('agent::trigger'); }); }); diff --git a/harness-node/tests/turn-orchestrator/get-state.test.ts b/harness-node/tests/turn-orchestrator/get-state.test.ts index 7d6cacfb..24303494 100644 --- a/harness-node/tests/turn-orchestrator/get-state.test.ts +++ b/harness-node/tests/turn-orchestrator/get-state.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import { FUNCTION_ID, execute } from '../../src/turn-orchestrator/get-state.js'; +import { execute } from '../../src/turn-orchestrator/get-state.js'; import { newRecord } from '../../src/turn-orchestrator/state.js'; describe('turn::get_state', () => { - it('exposes the canonical function id', () => { - expect(FUNCTION_ID).toBe('turn::get_state'); - }); - it('returns the turn_state record for a known session via persistence.loadRecord', async () => { const rec = newRecord('sess-abc'); rec.state = 'function_awaiting_approval'; diff --git a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts index aad66b26..592d0ffc 100644 --- a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts +++ b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { - STEP_TOPIC, handleAbortSignalWrite, isAbortSignalWrite, } from '../../src/turn-orchestrator/on-abort-signal.js'; @@ -108,7 +107,7 @@ describe('handleAbortSignalWrite', () => { expect(triggers).toHaveLength(1); expect(triggers[0]?.function_id).toBe('iii::durable::publish'); expect(triggers[0]?.payload).toMatchObject({ - topic: STEP_TOPIC, + topic: 'turn::step_requested', data: { session_id: 'sess-abc' }, }); }); diff --git a/harness-node/tests/turn-orchestrator/on-record-written.test.ts b/harness-node/tests/turn-orchestrator/on-record-written.test.ts index 84d31a9a..eb648949 100644 --- a/harness-node/tests/turn-orchestrator/on-record-written.test.ts +++ b/harness-node/tests/turn-orchestrator/on-record-written.test.ts @@ -4,7 +4,6 @@ import { handleStepableRecordWrite, isStepableRecordWrite, } from '../../src/turn-orchestrator/on-record-written.js'; -import { STEP_FN_ID } from '../../src/turn-orchestrator/subscriber.js'; describe('isStepableRecordWrite condition', () => { it('matches turn_state writes with a non-terminal, non-awaiting state', () => { @@ -152,7 +151,7 @@ describe('handleStepableRecordWrite', () => { }); expect(triggers).toHaveLength(1); - expect(triggers[0]?.function_id).toBe(STEP_FN_ID); + expect(triggers[0]?.function_id).toBe('turn::step'); expect(triggers[0]?.payload).toEqual({ session_id: 'sess-abc' }); }); @@ -175,7 +174,7 @@ describe('handleStepableRecordWrite', () => { trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { triggers.push(req); // Fail the direct turn::step invoke; let the durable publish succeed. - if (req.function_id === STEP_FN_ID) { + if (req.function_id === 'turn::step') { throw new Error('engine down'); } return null; @@ -192,7 +191,7 @@ describe('handleStepableRecordWrite', () => { }); expect(triggers).toHaveLength(2); - expect(triggers[0]?.function_id).toBe(STEP_FN_ID); + expect(triggers[0]?.function_id).toBe('turn::step'); expect(triggers[1]?.function_id).toBe('iii::durable::publish'); expect(triggers[1]?.payload).toEqual({ topic: 'turn::step_requested', diff --git a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts b/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts index 881fd516..d0d6d265 100644 --- a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts +++ b/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { - CONDITION_FN_ID, handleTurnStateWrite, isTurnStateWrite, } from '../../src/turn-orchestrator/on-turn-state-changed.js'; @@ -21,12 +20,6 @@ function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unkno return { iii, emits }; } -describe('CONDITION_FN_ID', () => { - it('is the stable string the trigger config will reference', () => { - expect(CONDITION_FN_ID).toBe('turn::is_turn_state_write'); - }); -}); - describe('isTurnStateWrite', () => { it('returns true for state:created on session//turn_state', () => { expect( From 1bffc94b40d3ed00c6921fa08b867d6bf3bf7621 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 20 May 2026 20:14:07 -0300 Subject: [PATCH 04/16] Remove isStepableRecordWrite wrapper and inline parseStepableWrite in the register condition. --- .../turn-orchestrator/on-record-written.ts | 14 ++---- .../integration/on-record-written.e2e.test.ts | 4 +- .../on-record-written.test.ts | 44 +++++++++---------- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/harness-node/src/turn-orchestrator/on-record-written.ts b/harness-node/src/turn-orchestrator/on-record-written.ts index a7cce774..23c4a16c 100644 --- a/harness-node/src/turn-orchestrator/on-record-written.ts +++ b/harness-node/src/turn-orchestrator/on-record-written.ts @@ -34,7 +34,7 @@ type StepableWrite = { * so they can't drift — and the handler can't fire on a parking-state write * even if the condition was bypassed. */ -function parseStepableWrite(event: unknown): StepableWrite | null { +export function parseStepableWrite(event: unknown): StepableWrite | null { const parsed = parseTurnStateWrite(event); if (!parsed) return null; @@ -49,10 +49,6 @@ function parseStepableWrite(event: unknown): StepableWrite | null { return { session_id: parsed.session_id, state }; } -export function isStepableRecordWrite(event: unknown): boolean { - return parseStepableWrite(event) !== null; -} - export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Promise { const parsed = parseStepableWrite(event); if (!parsed) return; @@ -64,11 +60,7 @@ export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Prom }); return; } catch (err) { - // Direct invoke failed (timeout, transient throw, etc). The triggering - // state write already landed, so without a durable retry the session - // would sit stuck in this state forever. Fall back to publishing - // `turn::step_requested` so the durable subscriber on `subscriber.ts` - // buffers + retries. + logger.warn( 'turn::on_record_written: direct turn::step failed; falling back to durable publish', { session_id: parsed.session_id, err: String(err) }, @@ -90,7 +82,7 @@ export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Prom export function register(iii: ISdk): void { iii.registerFunction( 'turn::is_stepable_record_write', - async (event: unknown) => isStepableRecordWrite(event), + async (event: unknown) => parseStepableWrite(event) !== null, { description: 'Condition: state event sets session//turn_state to a stepable state (excludes stopped + function_awaiting_approval).', diff --git a/harness-node/tests/integration/on-record-written.e2e.test.ts b/harness-node/tests/integration/on-record-written.e2e.test.ts index cd0b11a6..979b79bb 100644 --- a/harness-node/tests/integration/on-record-written.e2e.test.ts +++ b/harness-node/tests/integration/on-record-written.e2e.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { handleStepableRecordWrite, - isStepableRecordWrite, + parseStepableWrite, } from '../../src/turn-orchestrator/on-record-written.js'; function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> } { @@ -24,7 +24,7 @@ function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> new_value: p.value, message_type: 'state', }; - if (isStepableRecordWrite(event)) { + if (parseStepableWrite(event) !== null) { queueMicrotask(() => { void handleStepableRecordWrite(iii as unknown as ISdk, event); }); diff --git a/harness-node/tests/turn-orchestrator/on-record-written.test.ts b/harness-node/tests/turn-orchestrator/on-record-written.test.ts index eb648949..25b5789c 100644 --- a/harness-node/tests/turn-orchestrator/on-record-written.test.ts +++ b/harness-node/tests/turn-orchestrator/on-record-written.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { handleStepableRecordWrite, - isStepableRecordWrite, + parseStepableWrite, } from '../../src/turn-orchestrator/on-record-written.js'; -describe('isStepableRecordWrite condition', () => { +describe('parseStepableWrite condition', () => { it('matches turn_state writes with a non-terminal, non-awaiting state', () => { expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:created', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -16,10 +16,10 @@ describe('isStepableRecordWrite condition', () => { new_value: { state: 'provisioning' }, message_type: 'state', }), - ).toBe(true); + ).toEqual({ session_id: 'sess-abc', state: 'provisioning' }); expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -27,12 +27,12 @@ describe('isStepableRecordWrite condition', () => { new_value: { state: 'awaiting_assistant' }, message_type: 'state', }), - ).toBe(true); + ).toEqual({ session_id: 'sess-abc', state: 'awaiting_assistant' }); }); it('rejects terminal state (stopped)', () => { expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -40,12 +40,12 @@ describe('isStepableRecordWrite condition', () => { new_value: { state: 'stopped' }, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); it('rejects function_awaiting_approval (orchestrator parks here)', () => { expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -53,12 +53,12 @@ describe('isStepableRecordWrite condition', () => { new_value: { state: 'function_awaiting_approval' }, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); it('rejects state:deleted', () => { expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:deleted', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -66,12 +66,12 @@ describe('isStepableRecordWrite condition', () => { new_value: null, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); it('rejects non-turn_state keys in the agent scope', () => { expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/abort_signal', @@ -79,12 +79,12 @@ describe('isStepableRecordWrite condition', () => { new_value: true, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); it('rejects same-state writes (old_value.state === new_value.state)', () => { expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -92,10 +92,10 @@ describe('isStepableRecordWrite condition', () => { new_value: { state: 'function_prepare' }, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -103,12 +103,12 @@ describe('isStepableRecordWrite condition', () => { new_value: { state: 'function_execute' }, message_type: 'state', }), - ).toBe(true); + ).toEqual({ session_id: 'sess-abc', state: 'function_execute' }); }); it('rejects writes whose new_value lacks a string state', () => { expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -116,10 +116,10 @@ describe('isStepableRecordWrite condition', () => { new_value: { not_state: 'provisioning' }, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); expect( - isStepableRecordWrite({ + parseStepableWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -127,7 +127,7 @@ describe('isStepableRecordWrite condition', () => { new_value: null, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); }); From 11c95b1a795909f7ac1f728b9893ac190453df0f Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 20 May 2026 20:26:11 -0300 Subject: [PATCH 05/16] Split stepOnStepableWrite from the raw event adapter so parse narrowing is type-enforced. Share Zod turn_state write parsing between adapters; add handler tests for direct invokes that bypass the engine condition gate. --- .../turn-orchestrator/on-record-written.ts | 61 ++++++++----------- .../on-turn-state-changed.ts | 57 ++++++----------- .../on-record-written.test.ts | 15 ++++- .../on-turn-state-changed.test.ts | 40 +++++++----- 4 files changed, 80 insertions(+), 93 deletions(-) diff --git a/harness-node/src/turn-orchestrator/on-record-written.ts b/harness-node/src/turn-orchestrator/on-record-written.ts index 23c4a16c..630bba6b 100644 --- a/harness-node/src/turn-orchestrator/on-record-written.ts +++ b/harness-node/src/turn-orchestrator/on-record-written.ts @@ -14,71 +14,58 @@ import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; -import { parseTurnStateWrite } from './on-turn-state-changed.js'; +import { TurnStateWriteEventSchema, type ParsedTurnStateWrite } from './on-turn-state-changed.js'; import type { TurnState } from './state.js'; -const NON_STEPABLE_STATES: ReadonlySet = new Set([ - 'stopped', - 'function_awaiting_approval', -]); +const NON_STEPABLE_STATES = new Set(['stopped', 'function_awaiting_approval']); -type StepableWrite = { - session_id: string; - state: TurnState; -}; +const StepableTurnStateWriteSchema = TurnStateWriteEventSchema.refine( + (data) => !NON_STEPABLE_STATES.has(data.new_value.state as TurnState), +).refine( + (data) => data.event_type !== 'state:updated' || data.old_value?.state !== data.new_value.state, +); -/** - * One source of truth for "is this a stepable turn_state write?". Returns the - * extracted session_id + state on match, null otherwise. Both the condition - * (boolean check) and the handler (needs the session_id) route through this, - * so they can't drift — and the handler can't fire on a parking-state write - * even if the condition was bypassed. - */ -export function parseStepableWrite(event: unknown): StepableWrite | null { - const parsed = parseTurnStateWrite(event); - if (!parsed) return null; - - const state = parsed.new_value.state as TurnState; - if (NON_STEPABLE_STATES.has(state)) return null; +export type StepableWrite = Pick & { state: TurnState }; - if (parsed.event_type === 'state:updated') { - const old_state = parsed.old_value?.state; - if (typeof old_state === 'string' && old_state === state) return null; - } - - return { session_id: parsed.session_id, state }; +export function parseStepableWrite(event: unknown): StepableWrite | null { + const result = StepableTurnStateWriteSchema.safeParse(event); + if (!result.success) return null; + return { session_id: result.data.session_id, state: result.data.new_value.state as TurnState }; } -export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Promise { - const parsed = parseStepableWrite(event); - if (!parsed) return; - +export async function stepOnStepableWrite(iii: ISdk, write: StepableWrite): Promise { try { await iii.trigger({ function_id: 'turn::step', - payload: { session_id: parsed.session_id }, + payload: { session_id: write.session_id }, }); return; } catch (err) { - logger.warn( 'turn::on_record_written: direct turn::step failed; falling back to durable publish', - { session_id: parsed.session_id, err: String(err) }, + { session_id: write.session_id, err: String(err) }, ); try { await iii.trigger({ function_id: 'iii::durable::publish', - payload: { topic: 'turn::step_requested', data: { session_id: parsed.session_id } }, + payload: { topic: 'turn::step_requested', data: { session_id: write.session_id } }, }); } catch (publishErr) { logger.error( 'turn::on_record_written: durable publish fallback also failed; session may be stuck', - { session_id: parsed.session_id, err: String(publishErr) }, + { session_id: write.session_id, err: String(publishErr) }, ); } } } +export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Promise { + const write = parseStepableWrite(event); + if (write) { + await stepOnStepableWrite(iii, write); + } +} + export function register(iii: ISdk): void { iii.registerFunction( 'turn::is_stepable_record_write', diff --git a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts index 41154980..5d868ad1 100644 --- a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts +++ b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts @@ -9,56 +9,33 @@ import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; import { emit } from './events.js'; -import { turnStateKey } from './state.js'; const TurnStateRecordValueSchema = z.object({ state: z.string() }).passthrough(); -export const TurnStateWriteEventSchema = z.object({ +const AgentTurnStateWriteEventSchema = z.object({ + type: z.literal('state').optional(), + scope: z.literal('agent').optional(), event_type: z.enum(['state:created', 'state:updated']), - key: z.string(), + key: z.string().regex(/^session\/[^/]+\/turn_state$/), new_value: TurnStateRecordValueSchema, - old_value: z.unknown().optional(), + old_value: TurnStateRecordValueSchema.nullish(), }); -export type ParsedTurnStateWrite = { - session_id: string; - event_type: 'state:created' | 'state:updated'; - new_value: Record; - old_value?: Record; -}; - -function sessionIdFromTurnStateKey(key: string): string | null { - const match = /^session\/([^/]+)\/turn_state$/.exec(key); - const session_id = match?.[1]; - if (!session_id || turnStateKey(session_id) !== key) return null; - return session_id; -} - -/** Shared parse for agent-scope turn_state create/update events. */ -export function parseTurnStateWrite(event: unknown): ParsedTurnStateWrite | null { - const parsed = TurnStateWriteEventSchema.safeParse(event); - if (!parsed.success) return null; - - const session_id = sessionIdFromTurnStateKey(parsed.data.key); - if (!session_id) return null; - - const old_value = - parsed.data.old_value && - typeof parsed.data.old_value === 'object' && - parsed.data.old_value !== null - ? (parsed.data.old_value as Record) - : undefined; - +export const TurnStateWriteEventSchema = AgentTurnStateWriteEventSchema.transform((data) => { + const session_id = data.key.slice('session/'.length, -'/turn_state'.length); return { session_id, - event_type: parsed.data.event_type, - new_value: parsed.data.new_value as Record, - ...(old_value !== undefined && { old_value }), + event_type: data.event_type, + new_value: data.new_value as Record, + ...(data.old_value != null && { old_value: data.old_value as Record }), }; -} +}); -export function isTurnStateWrite(event: unknown): boolean { - return parseTurnStateWrite(event) !== null; +export type ParsedTurnStateWrite = z.infer; + +export function parseTurnStateWrite(event: unknown): ParsedTurnStateWrite | null { + const result = TurnStateWriteEventSchema.safeParse(event); + return result.success ? result.data : null; } export async function handleTurnStateWrite(iii: ISdk, event: unknown): Promise { @@ -83,7 +60,7 @@ export async function handleTurnStateWrite(iii: ISdk, event: unknown): Promise isTurnStateWrite(event), + async (event: unknown) => parseTurnStateWrite(event) !== null, { description: 'Condition: state event is a write to session//turn_state.', }, diff --git a/harness-node/tests/turn-orchestrator/on-record-written.test.ts b/harness-node/tests/turn-orchestrator/on-record-written.test.ts index 25b5789c..4e9aa53d 100644 --- a/harness-node/tests/turn-orchestrator/on-record-written.test.ts +++ b/harness-node/tests/turn-orchestrator/on-record-written.test.ts @@ -155,7 +155,7 @@ describe('handleStepableRecordWrite', () => { expect(triggers[0]?.payload).toEqual({ session_id: 'sess-abc' }); }); - it('no-ops when key does not match the turn_state pattern', async () => { + it('no-ops when the event is not stepable (direct invoke bypasses engine condition)', async () => { const iii = { trigger: vi.fn() } as unknown as ISdk; await handleStepableRecordWrite(iii, { event_type: 'state:updated', @@ -168,6 +168,19 @@ describe('handleStepableRecordWrite', () => { expect(iii.trigger).not.toHaveBeenCalled(); }); + it('no-ops on same-state turn_state updates (direct invoke bypasses engine condition)', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + await handleStepableRecordWrite(iii, { + event_type: 'state:updated', + scope: 'agent', + key: 'session/sess-abc/turn_state', + old_value: { state: 'function_prepare' }, + new_value: { state: 'function_prepare' }, + message_type: 'state', + }); + expect(iii.trigger).not.toHaveBeenCalled(); + }); + it('falls back to durable publish when the direct turn::step invoke fails', async () => { const triggers: Array<{ function_id: string; payload: unknown }> = []; const iii = { diff --git a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts b/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts index d0d6d265..aa912b0b 100644 --- a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts +++ b/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { handleTurnStateWrite, - isTurnStateWrite, + parseTurnStateWrite, } from '../../src/turn-orchestrator/on-turn-state-changed.js'; function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unknown }> } { @@ -20,45 +20,55 @@ function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unkno return { iii, emits }; } -describe('isTurnStateWrite', () => { - it('returns true for state:created on session//turn_state', () => { +describe('parseTurnStateWrite', () => { + it('parses state:created on session//turn_state', () => { expect( - isTurnStateWrite({ + parseTurnStateWrite({ event_type: 'state:created', key: 'session/sess-a/turn_state', new_value: { state: 'provisioning' }, }), - ).toBe(true); + ).toEqual({ + session_id: 'sess-a', + event_type: 'state:created', + new_value: { state: 'provisioning' }, + }); }); - it('returns true for state:updated on session//turn_state', () => { + it('parses state:updated on session//turn_state', () => { expect( - isTurnStateWrite({ + parseTurnStateWrite({ event_type: 'state:updated', key: 'session/sess-a/turn_state', new_value: { state: 'function_awaiting_approval' }, old_value: { state: 'function_execute' }, }), - ).toBe(true); + ).toEqual({ + session_id: 'sess-a', + event_type: 'state:updated', + new_value: { state: 'function_awaiting_approval' }, + old_value: { state: 'function_execute' }, + }); }); - it('returns false for non-turn_state agent keys', () => { + it('rejects non-turn_state agent keys', () => { expect( - isTurnStateWrite({ + parseTurnStateWrite({ event_type: 'state:created', key: 'session/sess-a/abort_signal', - new_value: true, + new_value: { state: 'true' }, }), - ).toBe(false); + ).toBeNull(); }); - it('returns false for state:deleted', () => { + it('rejects state:deleted', () => { expect( - isTurnStateWrite({ + parseTurnStateWrite({ event_type: 'state:deleted', key: 'session/sess-a/turn_state', + new_value: { state: 'provisioning' }, }), - ).toBe(false); + ).toBeNull(); }); }); From 49f37e66e446390e2e95e28254c032c1bd660f22 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 21 May 2026 17:46:13 -0300 Subject: [PATCH 06/16] Refactor turn-orchestrator to streamline state handling and improve type safety - Simplified `parseStepableWrite` to only return `session_id`, removing the state from its output. - Inlined the handling of stepable writes directly in `handleStepableRecordWrite`, eliminating the fallback to durable publish. - Updated tests to reflect changes in the `parseStepableWrite` output and removed obsolete tests related to fallback logic. - Introduced `StepPayloadSchema` for consistent payload validation in the `turn::step` function, ensuring only valid session IDs are processed. - Added new tests for `StepPayloadSchema` to validate input shapes and error handling. These changes enhance clarity and maintainability of the turn orchestrator's state management logic. --- .../turn-orchestrator/on-record-written.ts | 41 ++----- .../src/turn-orchestrator/subscriber.ts | 57 +++++----- .../on-record-written.test.ts | 56 +-------- .../turn-orchestrator/subscriber.test.ts | 106 ++++++++++++++++++ 4 files changed, 149 insertions(+), 111 deletions(-) create mode 100644 harness-node/tests/turn-orchestrator/subscriber.test.ts diff --git a/harness-node/src/turn-orchestrator/on-record-written.ts b/harness-node/src/turn-orchestrator/on-record-written.ts index 630bba6b..2bc1903f 100644 --- a/harness-node/src/turn-orchestrator/on-record-written.ts +++ b/harness-node/src/turn-orchestrator/on-record-written.ts @@ -13,8 +13,7 @@ */ import type { ISdk } from '../runtime/iii.js'; -import { logger } from '../runtime/otel.js'; -import { TurnStateWriteEventSchema, type ParsedTurnStateWrite } from './on-turn-state-changed.js'; +import { TurnStateWriteEventSchema } from './on-turn-state-changed.js'; import type { TurnState } from './state.js'; const NON_STEPABLE_STATES = new Set(['stopped', 'function_awaiting_approval']); @@ -25,45 +24,21 @@ const StepableTurnStateWriteSchema = TurnStateWriteEventSchema.refine( (data) => data.event_type !== 'state:updated' || data.old_value?.state !== data.new_value.state, ); -export type StepableWrite = Pick & { state: TurnState }; +export type StepableWrite = { session_id: string }; export function parseStepableWrite(event: unknown): StepableWrite | null { const result = StepableTurnStateWriteSchema.safeParse(event); if (!result.success) return null; - return { session_id: result.data.session_id, state: result.data.new_value.state as TurnState }; -} - -export async function stepOnStepableWrite(iii: ISdk, write: StepableWrite): Promise { - try { - await iii.trigger({ - function_id: 'turn::step', - payload: { session_id: write.session_id }, - }); - return; - } catch (err) { - logger.warn( - 'turn::on_record_written: direct turn::step failed; falling back to durable publish', - { session_id: write.session_id, err: String(err) }, - ); - try { - await iii.trigger({ - function_id: 'iii::durable::publish', - payload: { topic: 'turn::step_requested', data: { session_id: write.session_id } }, - }); - } catch (publishErr) { - logger.error( - 'turn::on_record_written: durable publish fallback also failed; session may be stuck', - { session_id: write.session_id, err: String(publishErr) }, - ); - } - } + return { session_id: result.data.session_id }; } export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Promise { const write = parseStepableWrite(event); - if (write) { - await stepOnStepableWrite(iii, write); - } + if (!write) return; + await iii.trigger({ + function_id: 'turn::step', + payload: { session_id: write.session_id }, + }); } export function register(iii: ISdk): void { diff --git a/harness-node/src/turn-orchestrator/subscriber.ts b/harness-node/src/turn-orchestrator/subscriber.ts index 352a2f51..ce8f3a6e 100644 --- a/harness-node/src/turn-orchestrator/subscriber.ts +++ b/harness-node/src/turn-orchestrator/subscriber.ts @@ -1,40 +1,43 @@ /** - * `turn::step` durable subscriber. + * `turn::step` — one FSM transition for a session. + * + * **Incoming** (both paths deliver the same flat shape): + * - Direct `iii.trigger`: `{ session_id }` from `on-record-written`, `approval-resume` + * - `durable:subscriber` on `turn::step_requested`: `{ session_id }` only — producers + * call `iii::durable::publish` with `{ topic, data: { session_id } }` but the engine + * enqueues `data`, not the publish envelope + * + * **Outgoing**: `StepResult` — never throws for unknown/terminal; throws on transition failure */ +import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; import type { TurnOrchestratorConfig } from './config.js'; import * as persistence from './persistence.js'; -import { isTerminal } from './state.js'; +import { isTerminal, type TurnState } from './state.js'; import { step } from './transitions.js'; -const STEP_TOPIC = 'turn::step_requested'; +export const StepPayloadSchema = z.object({ + session_id: z.string().min(1), +}); -function extractSessionId(payload: unknown): string | null { - if (!payload || typeof payload !== 'object') return null; - const obj = payload as Record; - const inner = - obj.payload && typeof obj.payload === 'object' - ? (obj.payload as Record) - : obj.data && typeof obj.data === 'object' - ? (obj.data as Record) - : obj; - return typeof inner.session_id === 'string' ? inner.session_id : null; -} +export type StepPayload = z.infer; + +export type StepResult = + | { ok: true; terminal: true } + | { ok: true; from_state: TurnState; to_state: TurnState } + | { ok: false; reason: 'unknown_session' }; export async function execute( iii: ISdk, cfg: TurnOrchestratorConfig, - payload: unknown, -): Promise { - const session_id = extractSessionId(payload); - if (!session_id) { - throw new Error('turn::step_requested payload missing session_id'); - } + payload: StepPayload, +): Promise { + const { session_id } = payload; const rec = await persistence.loadRecord(iii, session_id); if (!rec) { - logger.warn('turn::step_requested for unknown session', { session_id }); + logger.warn('turn::step for unknown session', { session_id }); return { ok: false, reason: 'unknown_session' }; } if (isTerminal(rec)) { @@ -51,12 +54,16 @@ export async function execute( } export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { - iii.registerFunction('turn::step', async (payload: unknown) => execute(iii, cfg, payload), { - description: 'Run one durable state machine transition for a session.', - }); + iii.registerFunction( + 'turn::step', + async (payload: unknown) => execute(iii, cfg, StepPayloadSchema.parse(payload)), + { + description: 'Run one durable state machine transition for a session.', + }, + ); iii.registerTrigger({ type: 'durable:subscriber', function_id: 'turn::step', - config: { topic: STEP_TOPIC }, + config: { topic: 'turn::step_requested' }, }); } diff --git a/harness-node/tests/turn-orchestrator/on-record-written.test.ts b/harness-node/tests/turn-orchestrator/on-record-written.test.ts index 4e9aa53d..41ce3e27 100644 --- a/harness-node/tests/turn-orchestrator/on-record-written.test.ts +++ b/harness-node/tests/turn-orchestrator/on-record-written.test.ts @@ -16,7 +16,7 @@ describe('parseStepableWrite condition', () => { new_value: { state: 'provisioning' }, message_type: 'state', }), - ).toEqual({ session_id: 'sess-abc', state: 'provisioning' }); + ).toEqual({ session_id: 'sess-abc' }); expect( parseStepableWrite({ @@ -27,7 +27,7 @@ describe('parseStepableWrite condition', () => { new_value: { state: 'awaiting_assistant' }, message_type: 'state', }), - ).toEqual({ session_id: 'sess-abc', state: 'awaiting_assistant' }); + ).toEqual({ session_id: 'sess-abc' }); }); it('rejects terminal state (stopped)', () => { @@ -103,7 +103,7 @@ describe('parseStepableWrite condition', () => { new_value: { state: 'function_execute' }, message_type: 'state', }), - ).toEqual({ session_id: 'sess-abc', state: 'function_execute' }); + ).toEqual({ session_id: 'sess-abc' }); }); it('rejects writes whose new_value lacks a string state', () => { @@ -180,54 +180,4 @@ describe('handleStepableRecordWrite', () => { }); expect(iii.trigger).not.toHaveBeenCalled(); }); - - it('falls back to durable publish when the direct turn::step invoke fails', async () => { - const triggers: Array<{ function_id: string; payload: unknown }> = []; - const iii = { - trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { - triggers.push(req); - // Fail the direct turn::step invoke; let the durable publish succeed. - if (req.function_id === 'turn::step') { - throw new Error('engine down'); - } - return null; - }), - } as unknown as ISdk; - - await handleStepableRecordWrite(iii, { - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: null, - new_value: { state: 'provisioning' }, - message_type: 'state', - }); - - expect(triggers).toHaveLength(2); - expect(triggers[0]?.function_id).toBe('turn::step'); - expect(triggers[1]?.function_id).toBe('iii::durable::publish'); - expect(triggers[1]?.payload).toEqual({ - topic: 'turn::step_requested', - data: { session_id: 'sess-abc' }, - }); - }); - - it('swallows when BOTH the direct invoke and durable publish fallback fail', async () => { - const iii = { - trigger: vi.fn(async () => { - throw new Error('engine down'); - }), - } as unknown as ISdk; - - await expect( - handleStepableRecordWrite(iii, { - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: null, - new_value: { state: 'provisioning' }, - message_type: 'state', - }), - ).resolves.toBeUndefined(); - }); }); diff --git a/harness-node/tests/turn-orchestrator/subscriber.test.ts b/harness-node/tests/turn-orchestrator/subscriber.test.ts new file mode 100644 index 00000000..1425081d --- /dev/null +++ b/harness-node/tests/turn-orchestrator/subscriber.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ISdk } from '../../src/runtime/iii.js'; +import type { TurnOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; +import * as persistence from '../../src/turn-orchestrator/persistence.js'; +import { newRecord } from '../../src/turn-orchestrator/state.js'; +import * as transitions from '../../src/turn-orchestrator/transitions.js'; +import { execute, StepPayloadSchema } from '../../src/turn-orchestrator/subscriber.js'; + +const cfg: TurnOrchestratorConfig = { system_default_skills: [] }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('StepPayloadSchema', () => { + it('accepts the flat shape every in-repo caller uses', () => { + expect(StepPayloadSchema.parse({ session_id: 'sess-abc' })).toEqual({ + session_id: 'sess-abc', + }); + }); + + it('strips extra keys (engine may add metadata later)', () => { + expect(StepPayloadSchema.parse({ session_id: 's1', trace_id: 't1' })).toEqual({ + session_id: 's1', + }); + }); + + it('rejects publish envelope shapes — durable subscriber receives data only', () => { + expect(() => + StepPayloadSchema.parse({ + topic: 'turn::step_requested', + data: { session_id: 's1' }, + }), + ).toThrow(); + }); + + it('rejects nested payload wrappers (no in-repo caller uses them)', () => { + expect(() => StepPayloadSchema.parse({ data: { session_id: 's1' } })).toThrow(); + expect(() => StepPayloadSchema.parse({ payload: { session_id: 's1' } })).toThrow(); + }); + + it('rejects missing, empty, or non-string session_id', () => { + expect(() => StepPayloadSchema.parse({})).toThrow(); + expect(() => StepPayloadSchema.parse({ session_id: '' })).toThrow(); + expect(() => StepPayloadSchema.parse({ session_id: 42 })).toThrow(); + expect(() => StepPayloadSchema.parse({ session_id: null })).toThrow(); + expect(() => StepPayloadSchema.parse(null)).toThrow(); + expect(() => StepPayloadSchema.parse(undefined)).toThrow(); + }); +}); + +describe('execute', () => { + it('returns unknown_session when the record does not exist', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(null); + + await expect(execute(iii, cfg, { session_id: 'missing' })).resolves.toEqual({ + ok: false, + reason: 'unknown_session', + }); + }); + + it('returns terminal without stepping when the record is stopped', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'stopped'; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + const stepSpy = vi.spyOn(transitions, 'step'); + + await expect(execute(iii, cfg, { session_id: 's1' })).resolves.toEqual({ + ok: true, + terminal: true, + }); + expect(stepSpy).not.toHaveBeenCalled(); + }); + + it('steps, persists, and returns from_state/to_state on success', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'provisioning'; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + vi.spyOn(transitions, 'step').mockImplementation(async (_iii, _cfg, r) => { + r.state = 'awaiting_assistant'; + }); + const saveSpy = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(undefined); + + await expect(execute(iii, cfg, { session_id: 's1' })).resolves.toEqual({ + ok: true, + from_state: 'provisioning', + to_state: 'awaiting_assistant', + }); + expect(saveSpy).toHaveBeenCalledWith(iii, rec); + }); + + it('throws with from_state when transition fails', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'function_execute'; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + vi.spyOn(transitions, 'step').mockRejectedValue(new Error('sandbox gone')); + + await expect(execute(iii, cfg, { session_id: 's1' })).rejects.toThrow( + 'transition from function_execute failed: Error: sandbox gone', + ); + }); +}); From 0cb0c823bd8feedcea77dc244d415ee7858f8a54 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 22 May 2026 19:27:20 -0300 Subject: [PATCH 07/16] Enqueue turn wakes on the turn-step FIFO queue and gate durable steps. Replace turn::step_requested publishes with queue enqueues in approval resume and abort handling, add turn::should_step filtering for unknown or terminal sessions, and tighten get_state with Zod payload parsing. --- .../src/turn-orchestrator/approval-resume.ts | 11 +- .../src/turn-orchestrator/get-state.ts | 37 ++-- .../src/turn-orchestrator/on-abort-signal.ts | 63 ++++--- .../turn-orchestrator/on-record-written.ts | 20 ++- .../on-turn-state-changed.ts | 14 +- .../src/turn-orchestrator/run-start.ts | 7 + .../turn-orchestrator/states/provisioning.ts | 68 +++++--- .../turn-orchestrator/states/tearing-down.ts | 18 +- .../src/turn-orchestrator/subscriber.ts | 54 +++--- .../integration/on-record-written.e2e.test.ts | 11 +- .../turn-orchestrator/approval-resume.test.ts | 25 +-- .../tests/turn-orchestrator/get-state.test.ts | 46 ++++- .../turn-orchestrator/on-abort-signal.test.ts | 161 +++++++++++++----- .../on-record-written.test.ts | 44 ++++- .../on-turn-state-changed.test.ts | 107 +++++++----- .../turn-orchestrator/provisioning.test.ts | 148 ++++++++++++++++ .../tests/turn-orchestrator/run-start.test.ts | 139 ++++++++++++++- .../turn-orchestrator/subscriber.test.ts | 68 ++++++-- .../turn-orchestrator/tearing-down.test.ts | 16 +- 19 files changed, 826 insertions(+), 231 deletions(-) create mode 100644 harness-node/tests/turn-orchestrator/provisioning.test.ts diff --git a/harness-node/src/turn-orchestrator/approval-resume.ts b/harness-node/src/turn-orchestrator/approval-resume.ts index 66f05192..fd3901dd 100644 --- a/harness-node/src/turn-orchestrator/approval-resume.ts +++ b/harness-node/src/turn-orchestrator/approval-resume.ts @@ -1,7 +1,7 @@ /** * Per-call resume functions for parked approvals. Registered when a call * enters `function_awaiting_approval`; invoked by `approval::resolve` or - * abort. Persists to scope `approvals` and wakes `turn::step`. + * abort. Persists to scope `approvals` and publishes `turn::step_requested`. */ import { @@ -89,9 +89,12 @@ async function handleApprovalResume( } try { - await iii.trigger({ function_id: 'turn::step', payload: { session_id } }); + await iii.trigger({ + function_id: 'iii::durable::publish', + payload: { topic: 'turn::step_requested', data: { session_id } }, + }); } catch (err) { - logger.warn('approval resume: turn::step invoke failed', { session_id, err: String(err) }); + logger.warn('approval resume: turn step wake failed', { session_id, err: String(err) }); } unregisterApprovalResume(fnId); @@ -111,7 +114,7 @@ export function registerApprovalResume( async (payload: unknown) => handleApprovalResume(iii, session_id, function_call_id, payload), { description: - 'Resume a parked approval: persist decision to approvals scope and wake turn::step.', + 'Resume a parked approval: persist decision to approvals scope and publish turn::step_requested.', }, ); resumeRefs.set(fnId, ref); diff --git a/harness-node/src/turn-orchestrator/get-state.ts b/harness-node/src/turn-orchestrator/get-state.ts index 549e62ab..7cfdb77c 100644 --- a/harness-node/src/turn-orchestrator/get-state.ts +++ b/harness-node/src/turn-orchestrator/get-state.ts @@ -1,26 +1,33 @@ /** - * `turn::get_state` — one-shot reader for a session's current - * turn_state record. Exists so UI clients can recover pending modals - * after a page reload without reaching into iii state from the - * browser. The orchestrator owns the turn_state schema and key - * layout; clients call this and get back the record (or null for - * an unknown session). + * `turn::get_state` — one-shot reader for a session's current turn_state record. + * + * **Incoming**: flat `{ session_id }` from `console/web` real backend (page reload recovery) + * **Outgoing**: `TurnStateRecord | null` — null when the session is unknown */ -import { requireString } from '../runtime/handler.js'; +import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import * as persistence from './persistence.js'; import type { TurnStateRecord } from './state.js'; -export async function execute(iii: ISdk, payload: unknown): Promise { - const obj = (payload ?? {}) as Record; - const session_id = requireString(obj, 'session_id'); - return persistence.loadRecord(iii, session_id); +export const GetStatePayloadSchema = z.object({ + session_id: z.string().min(1), +}); + +export type GetStatePayload = z.infer; +export type GetStateResult = TurnStateRecord | null; + +export async function execute(iii: ISdk, payload: GetStatePayload): Promise { + return persistence.loadRecord(iii, payload.session_id); } export function register(iii: ISdk): void { - iii.registerFunction('turn::get_state', async (payload: unknown) => execute(iii, payload), { - description: - 'Read the current turn_state record for a session. Returns null if the session is unknown. UI clients use this on page reload to recover any in-progress modals (e.g. function_awaiting_approval) without reading iii state directly.', - }); + iii.registerFunction( + 'turn::get_state', + async (payload: unknown) => execute(iii, GetStatePayloadSchema.parse(payload)), + { + description: + 'Read the current turn_state record for a session. Returns null if the session is unknown. UI clients use this on page reload to recover any in-progress modals (e.g. function_awaiting_approval) without reading iii state directly.', + }, + ); } diff --git a/harness-node/src/turn-orchestrator/on-abort-signal.ts b/harness-node/src/turn-orchestrator/on-abort-signal.ts index 742c945b..705d4cd9 100644 --- a/harness-node/src/turn-orchestrator/on-abort-signal.ts +++ b/harness-node/src/turn-orchestrator/on-abort-signal.ts @@ -12,51 +12,64 @@ * soon as the current one finishes — which is the earliest moment we * can safely react. * - * Mirror of the canonical pattern in - * `harness-node/src/harness/fanout/sessions-poll.ts`. + * **Incoming**: agent-scope `state:created` / `state:updated` on + * `session//abort_signal` with `new_value === true` (from `state::set` via + * `performAbortSideEffects` / `router::abort`). Same envelope the engine passes + * to state trigger adapters. + * + * **Outgoing**: `iii::durable::publish` with `{ topic: 'turn::step_requested', + * data: { session_id } }`; durable subscriber receives flat `{ session_id }` only. */ +import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; -const ABORT_SIGNAL_KEY_RE = /^session\/([^/]+)\/abort_signal$/; +const AgentAbortSignalWriteEventSchema = z.object({ + type: z.literal('state').optional(), + scope: z.literal('agent').optional(), + event_type: z.enum(['state:created', 'state:updated']), + key: z.string().regex(/^session\/[^/]+\/abort_signal$/), + new_value: z.literal(true), + old_value: z.union([z.literal(true), z.literal(false), z.null()]).optional(), +}); -export function isAbortSignalWrite(event: unknown): boolean { - if (!event || typeof event !== 'object') return false; - const obj = event as Record; - if (obj.event_type !== 'state:created' && obj.event_type !== 'state:updated') return false; - if (obj.new_value !== true) return false; - const key = obj.key; - if (typeof key !== 'string') return false; - return ABORT_SIGNAL_KEY_RE.test(key); -} +export const AbortSignalWriteEventSchema = AgentAbortSignalWriteEventSchema.transform((data) => { + const session_id = data.key.slice('session/'.length, -'/abort_signal'.length); + return { session_id }; +}); -function extractSessionId(key: string): string | null { - const m = ABORT_SIGNAL_KEY_RE.exec(key); - return m ? (m[1] ?? null) : null; +export type ParsedAbortSignalWrite = z.infer; + +export function parseAbortSignalWrite(event: unknown): ParsedAbortSignalWrite | null { + const result = AbortSignalWriteEventSchema.safeParse(event); + return result.success ? result.data : null; } -export async function handleAbortSignalWrite(iii: ISdk, event: unknown): Promise { - if (!event || typeof event !== 'object') return; - const obj = event as Record; - const key = obj.key; - if (typeof key !== 'string') return; - const session_id = extractSessionId(key); - if (!session_id) return; +export function isAbortSignalWrite(event: unknown): boolean { + return parseAbortSignalWrite(event) !== null; +} +export async function execute(iii: ISdk, write: ParsedAbortSignalWrite): Promise { try { await iii.trigger({ function_id: 'iii::durable::publish', - payload: { topic: 'turn::step_requested', data: { session_id } }, + payload: { topic: 'turn::step_requested', data: { session_id: write.session_id } }, }); } catch (err) { - logger.warn('turn::on_abort_signal: publish failed', { - session_id, + logger.warn('turn::on_abort_signal: wake failed', { + session_id: write.session_id, err: String(err), }); } } +export async function handleAbortSignalWrite(iii: ISdk, event: unknown): Promise { + const write = parseAbortSignalWrite(event); + if (!write) return; + await execute(iii, write); +} + export function register(iii: ISdk): void { iii.registerFunction( 'turn::is_abort_signal_set', diff --git a/harness-node/src/turn-orchestrator/on-record-written.ts b/harness-node/src/turn-orchestrator/on-record-written.ts index 2bc1903f..47c44b10 100644 --- a/harness-node/src/turn-orchestrator/on-record-written.ts +++ b/harness-node/src/turn-orchestrator/on-record-written.ts @@ -1,9 +1,15 @@ /** * Self-loop wake: a state trigger on `scope: 'agent'` filtered by the * turn_state key shape and a stepable state TRANSITION (new state differs - * from old, non-terminal, non-awaiting) invokes `turn::step`. Saving the - * record on a real transition is the wake — replaces the durable - * `turn::step_requested` self-publish that used to live in `subscriber.ts`. + * from old, non-terminal, non-awaiting) publishes `turn::step_requested`. + * Saving the record on a real transition is the wake. + * + * **Incoming**: agent state write event (`TurnStateWriteEventSchema`) where + * `new_value.state` is stepable (not `stopped` / `function_awaiting_approval`) + * and `state:updated` changes `old_value.state`. + * **Outgoing**: `iii::durable::publish` with + * `{ topic: 'turn::step_requested', data: { session_id } }` — the durable + * subscriber receives flat `{ session_id }` only. * * Same-state writes (e.g. `handlePrepare` calling `saveRecord` while still * in `function_prepare` to persist normalized calls) MUST NOT wake step, @@ -18,7 +24,7 @@ import type { TurnState } from './state.js'; const NON_STEPABLE_STATES = new Set(['stopped', 'function_awaiting_approval']); -const StepableTurnStateWriteSchema = TurnStateWriteEventSchema.refine( +export const StepableTurnStateWriteSchema = TurnStateWriteEventSchema.refine( (data) => !NON_STEPABLE_STATES.has(data.new_value.state as TurnState), ).refine( (data) => data.event_type !== 'state:updated' || data.old_value?.state !== data.new_value.state, @@ -36,8 +42,8 @@ export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Prom const write = parseStepableWrite(event); if (!write) return; await iii.trigger({ - function_id: 'turn::step', - payload: { session_id: write.session_id }, + function_id: 'iii::durable::publish', + payload: { topic: 'turn::step_requested', data: { session_id: write.session_id } }, }); } @@ -56,7 +62,7 @@ export function register(iii: ISdk): void { async (event: unknown) => handleStepableRecordWrite(iii, event), { description: - 'State trigger adapter on scope=agent for stepable turn_state writes; invokes turn::step. Replaces the imperative publishStep self-publish.', + 'State trigger adapter on scope=agent for stepable turn_state writes; publishes turn::step_requested.', }, ); diff --git a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts index 5d868ad1..ac4beeda 100644 --- a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts +++ b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts @@ -1,8 +1,12 @@ /** - * State-trigger adapter that mirrors `on-record-written` but emits a - * `turn_state_changed` agent event instead of triggering `turn::step`. - * Gives the frontend a live signal carrying the new turn_state record - * so it can derive pending approvals from state directly. + * State-trigger adapter on `scope: 'agent'` for writes to + * `session//turn_state`. Emits `turn_state_changed` on `agent::events` + * so the UI can derive pending approvals from live state. + * + * **Incoming**: agent state write event from the iii engine (`event_type`, + * `scope`, `key`, `old_value`, `new_value`, `message_type`; key must match + * `session//turn_state`) + * **Outgoing**: void — side effect via `emit()`; swallow emit failures (log only) */ import { z } from 'zod'; @@ -31,7 +35,7 @@ export const TurnStateWriteEventSchema = AgentTurnStateWriteEventSchema.transfor }; }); -export type ParsedTurnStateWrite = z.infer; +type ParsedTurnStateWrite = z.infer; export function parseTurnStateWrite(event: unknown): ParsedTurnStateWrite | null { const result = TurnStateWriteEventSchema.safeParse(event); diff --git a/harness-node/src/turn-orchestrator/run-start.ts b/harness-node/src/turn-orchestrator/run-start.ts index 8638fd1c..3c1b1ecb 100644 --- a/harness-node/src/turn-orchestrator/run-start.ts +++ b/harness-node/src/turn-orchestrator/run-start.ts @@ -1,5 +1,12 @@ /** * `run::start`. Mirrors `turn-orchestrator/src/run_start.rs`. + * + * **Incoming**: flat run request from `harness::trigger` (`body.payload` after + * `HarnessTriggerInputSchema` parse); console/web sends + * `{ session_id, message_id?, provider, model, mode?, messages }` and omits + * `system_prompt`, `image`, `idle_timeout_secs`, `max_turns` (schema defaults). + * **Outgoing**: `{ session_id }` — persists run config, messages, and seeds + * `turn_state` to provisioning via `saveRecord`. */ import { z } from 'zod'; diff --git a/harness-node/src/turn-orchestrator/states/provisioning.ts b/harness-node/src/turn-orchestrator/states/provisioning.ts index ea3a65f5..11115418 100644 --- a/harness-node/src/turn-orchestrator/states/provisioning.ts +++ b/harness-node/src/turn-orchestrator/states/provisioning.ts @@ -1,3 +1,14 @@ +/** + * `provisioning`. First FSM step after `run::start`: materialize tool schemas, + * assemble the system prompt, persist the enriched run request, then advance. + * + * **Incoming**: provisioning `TurnStateRecord` (`rec.session_id`) plus persisted + * run request from `persistence.loadRunRequest` (written by `run::start`). + * **Outgoing**: `transitionTo(rec, 'awaiting_assistant')`; side effects: + * `saveFunctionSchemas` (agent_trigger), `saveRunRequest` (built prompt), + * directory skill fetches for default skills + index. + */ + import type { ISdk } from '../../runtime/iii.js'; import { logger } from '../../runtime/otel.js'; import { agentTriggerTool } from '../agent-trigger.js'; @@ -11,11 +22,40 @@ import { defaultSkillBody, } from '../system-prompt.js'; -function asMode(value: unknown): Mode | null { +type RunRequest = { + provider: string; + model: string; + mode: Mode | null; + system_prompt: string; + image: string; + idle_timeout_secs: number; +}; + +const FETCH_TIMEOUT_MS = 10_000; + +function parseMode(value: unknown): Mode | null { return value === 'plan' || value === 'ask' || value === 'agent' ? value : null; } -const FETCH_TIMEOUT_MS = 10_000; +export function parseRunRequest(raw: Record): RunRequest { + return { + provider: typeof raw.provider === 'string' ? raw.provider : '', + model: typeof raw.model === 'string' ? raw.model : '', + mode: parseMode(raw.mode), + system_prompt: typeof raw.system_prompt === 'string' ? raw.system_prompt : '', + image: typeof raw.image === 'string' ? raw.image : 'python', + idle_timeout_secs: typeof raw.idle_timeout_secs === 'number' ? raw.idle_timeout_secs : 300, + }; +} + +export function parseDirectoryBody(resp: unknown): string | null { + if (typeof resp === 'string') return resp; + if (resp && typeof resp === 'object') { + const body = (resp as { body?: unknown }).body; + if (typeof body === 'string') return body; + } + return null; +} async function fetchSkill(iii: ISdk, id: string): Promise { try { @@ -24,12 +64,7 @@ async function fetchSkill(iii: ISdk, id: string): Promise { payload: { id }, timeoutMs: FETCH_TIMEOUT_MS, }); - if (typeof resp === 'string') return resp; - if (resp && typeof resp === 'object') { - const body = (resp as Record).body; - if (typeof body === 'string') return body; - } - return null; + return parseDirectoryBody(resp); } catch (err) { logger.warn('directory::skills::get failed', { id, err: String(err) }); return null; @@ -53,11 +88,8 @@ async function fetchSkillsIndex(iii: ISdk): Promise { payload: {}, timeoutMs: FETCH_TIMEOUT_MS, }); - if (resp && typeof resp === 'object') { - const body = (resp as Record).body; - if (typeof body === 'string' && body.length > 0) return body; - } - return null; + const body = parseDirectoryBody(resp); + return body && body.length > 0 ? body : null; } catch (err) { logger.warn('directory::skills::index failed', { err: String(err) }); return null; @@ -69,22 +101,20 @@ export async function handleProvisioning( cfg: TurnOrchestratorConfig, rec: TurnStateRecord, ): Promise { - const request = await persistence.loadRunRequest(iii, rec.session_id); + const request = parseRunRequest(await persistence.loadRunRequest(iii, rec.session_id)); // The single tool LLMs see is `agent_trigger`. await persistence.saveFunctionSchemas(iii, rec.session_id, [agentTriggerTool()]); - const overrideRaw = request.system_prompt; - const override = typeof overrideRaw === 'string' && overrideRaw.length > 0 ? overrideRaw : null; - const mode = asMode(request.mode); + const override = request.system_prompt.length > 0 ? request.system_prompt : null; const [skillsIndex, bodies] = await Promise.all([ fetchSkillsIndex(iii), fetchDefaultSkills(iii, cfg.system_default_skills), ]); - const prompt = buildSystemPrompt(bodies, null, override, mode, skillsIndex); + const prompt = buildSystemPrompt(bodies, null, override, request.mode, skillsIndex); - const updated = { ...request, system_prompt: prompt }; + const updated: RunRequest = { ...request, system_prompt: prompt }; await persistence.saveRunRequest(iii, rec.session_id, updated); transitionTo(rec, 'awaiting_assistant'); diff --git a/harness-node/src/turn-orchestrator/states/tearing-down.ts b/harness-node/src/turn-orchestrator/states/tearing-down.ts index abdafc63..cb509582 100644 --- a/harness-node/src/turn-orchestrator/states/tearing-down.ts +++ b/harness-node/src/turn-orchestrator/states/tearing-down.ts @@ -1,14 +1,27 @@ +/** + * `tearing_down` — stop sandbox, emit `agent_end`, transition to `stopped`. + * + * **Incoming**: `TurnStateRecord` with `state: 'tearing_down'` from `step()` + * when the FSM enters teardown (abort, max turns, normal end-turn, etc.). + * **Outgoing**: mutates `rec` via `transitionTo(rec, 'stopped')`; emits + * `agent_end` with session messages; calls `sandbox::stop` when a sandbox id + * exists (best-effort, logs on failure). + */ + import type { ISdk } from '../../runtime/iii.js'; import { logger } from '../../runtime/otel.js'; +import type { AgentMessage } from '../../types/agent-message.js'; import { emit } from '../events.js'; import * as persistence from '../persistence.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; +type SandboxStopPayload = { sandbox_id: string; wait: true }; + export async function handleTearingDown(iii: ISdk, rec: TurnStateRecord): Promise { const sandbox_id = await persistence.loadSandboxId(iii, rec.session_id); if (sandbox_id) { try { - await iii.trigger({ + await iii.trigger({ function_id: 'sandbox::stop', payload: { sandbox_id, wait: true }, timeoutMs: 60_000, @@ -20,8 +33,7 @@ export async function handleTearingDown(iii: ISdk, rec: TurnStateRecord): Promis }); } } - const messages = await persistence.loadMessages(iii, rec.session_id); + const messages: AgentMessage[] = await persistence.loadMessages(iii, rec.session_id); await emit(iii, rec.session_id, { type: 'agent_end', messages }); transitionTo(rec, 'stopped'); } -// reload 1779112003 diff --git a/harness-node/src/turn-orchestrator/subscriber.ts b/harness-node/src/turn-orchestrator/subscriber.ts index ce8f3a6e..f7793414 100644 --- a/harness-node/src/turn-orchestrator/subscriber.ts +++ b/harness-node/src/turn-orchestrator/subscriber.ts @@ -1,21 +1,23 @@ /** * `turn::step` — one FSM transition for a session. * - * **Incoming** (both paths deliver the same flat shape): - * - Direct `iii.trigger`: `{ session_id }` from `on-record-written`, `approval-resume` - * - `durable:subscriber` on `turn::step_requested`: `{ session_id }` only — producers - * call `iii::durable::publish` with `{ topic, data: { session_id } }` but the engine - * enqueues `data`, not the publish envelope + * **Incoming**: flat `{ session_id }` from durable subscriber (`turn::step_requested`), + * direct `iii.trigger('turn::step', …)`, and integration tests — same shape. + * Producers publish via `iii::durable::publish` with `{ topic, data: { session_id } }`; + * the engine enqueues `data` only. * - * **Outgoing**: `StepResult` — never throws for unknown/terminal; throws on transition failure + * **Outgoing**: `StepResult` with pre/post `TurnState`; throws on missing session + * (invariant) or transition failure. `turn::should_step` soft-filters unknown/terminal + * sessions before the durable subscriber invokes `turn::step`. */ +import type { StateGetInput } from 'iii-sdk/state'; import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; import type { TurnOrchestratorConfig } from './config.js'; import * as persistence from './persistence.js'; -import { isTerminal, type TurnState } from './state.js'; +import { isTerminal, turnStateKey, type TurnState, type TurnStateRecord } from './state.js'; import { step } from './transitions.js'; export const StepPayloadSchema = z.object({ @@ -24,10 +26,18 @@ export const StepPayloadSchema = z.object({ export type StepPayload = z.infer; -export type StepResult = - | { ok: true; terminal: true } - | { ok: true; from_state: TurnState; to_state: TurnState } - | { ok: false; reason: 'unknown_session' }; +export type StepResult = { ok: true; from_state: TurnState; to_state: TurnState }; + +export async function shouldStep(iii: ISdk, payload: unknown): Promise { + const parsed = StepPayloadSchema.safeParse(payload); + if (!parsed.success) return false; + const rec = await persistence.loadRecord(iii, parsed.data.session_id); + if (!rec) { + logger.warn('turn::step for unknown session', { session_id: parsed.data.session_id }); + return false; + } + return !isTerminal(rec); +} export async function execute( iii: ISdk, @@ -35,14 +45,10 @@ export async function execute( payload: StepPayload, ): Promise { const { session_id } = payload; - const rec = await persistence.loadRecord(iii, session_id); - if (!rec) { - logger.warn('turn::step for unknown session', { session_id }); - return { ok: false, reason: 'unknown_session' }; - } - if (isTerminal(rec)) { - return { ok: true, terminal: true }; - } + const rec = await iii.trigger({ + function_id: 'state::get', + payload: { scope: 'agent', key: turnStateKey(session_id) }, + }); const from_state = rec.state; try { await step(iii, cfg, rec); @@ -54,6 +60,11 @@ export async function execute( } export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { + iii.registerFunction('turn::should_step', async (payload: unknown) => shouldStep(iii, payload), { + description: + 'Condition: durable turn::step_requested payload has a known, non-terminal session.', + }); + iii.registerFunction( 'turn::step', async (payload: unknown) => execute(iii, cfg, StepPayloadSchema.parse(payload)), @@ -64,6 +75,9 @@ export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { iii.registerTrigger({ type: 'durable:subscriber', function_id: 'turn::step', - config: { topic: 'turn::step_requested' }, + config: { + topic: 'turn::step_requested', + condition_function_id: 'turn::should_step', + }, }); } diff --git a/harness-node/tests/integration/on-record-written.e2e.test.ts b/harness-node/tests/integration/on-record-written.e2e.test.ts index 979b79bb..7d88a08f 100644 --- a/harness-node/tests/integration/on-record-written.e2e.test.ts +++ b/harness-node/tests/integration/on-record-written.e2e.test.ts @@ -33,8 +33,11 @@ function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> return null; } - if (function_id === 'turn::step') { - stepInvocations.push(payload as { session_id: string }); + if (function_id === 'iii::durable::publish') { + const p = payload as { topic: string; data: { session_id: string } }; + if (p.topic === 'turn::step_requested') { + stepInvocations.push({ session_id: p.data.session_id }); + } return null; } @@ -46,7 +49,7 @@ function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> } describe('turn-step reactive wake', () => { - it('writing session//turn_state with a stepable state invokes turn::step', async () => { + it('writing session//turn_state with a stepable state publishes turn::step_requested', async () => { const { iii, stepInvocations } = fakeIii(); await iii.trigger({ @@ -63,7 +66,7 @@ describe('turn-step reactive wake', () => { expect(stepInvocations).toEqual([{ session_id: 'sess-a' }]); }); - it('subsequent transitions also wake turn::step', async () => { + it('subsequent transitions also publish turn::step_requested', async () => { const { iii, stepInvocations } = fakeIii(); await iii.trigger({ diff --git a/harness-node/tests/turn-orchestrator/approval-resume.test.ts b/harness-node/tests/turn-orchestrator/approval-resume.test.ts index 7b71bc3b..5b094bbf 100644 --- a/harness-node/tests/turn-orchestrator/approval-resume.test.ts +++ b/harness-node/tests/turn-orchestrator/approval-resume.test.ts @@ -20,7 +20,7 @@ function makeIiiWithRegistry( agentTurnStates: TurnStateRecord[] = [], ) { const registered = new Map(); - const stepCalls: Array<{ session_id: string }> = []; + const wakeCalls: Array<{ session_id: string }> = []; const iii = { registerFunction: vi.fn((fnId: string, handler: (payload: unknown) => Promise) => { @@ -45,15 +45,18 @@ function makeIiiWithRegistry( if (function_id === 'state::list') { return agentTurnStates; } - if (function_id === 'turn::step') { - stepCalls.push(payload as { session_id: string }); + if (function_id === 'iii::durable::publish') { + const p = payload as { topic: string; data: { session_id: string } }; + if (p.topic === 'turn::step_requested') { + wakeCalls.push({ session_id: p.data.session_id }); + } return null; } return null; }), } as unknown as ISdk; - return { iii, registered, stepCalls, stateStore }; + return { iii, registered, wakeCalls, stateStore }; } afterEach(() => { @@ -85,15 +88,15 @@ describe('registerApprovalResume', () => { }); describe('approval resume handler', () => { - it('persists decision, triggers turn::step, and unregisters', async () => { - const { iii, registered, stepCalls, stateStore } = makeIiiWithRegistry(); + it('persists decision, publishes turn::step_requested, and unregisters', async () => { + const { iii, registered, wakeCalls, stateStore } = makeIiiWithRegistry(); registerApprovalResume(iii, 's1', 'fc-1'); const entry = registered.get('turn::approval_resume::s1/fc-1'); expect(entry).toBeDefined(); await entry!.handler({ decision: 'allow', reason: null }); expect(stateStore.get('approvals/s1/fc-1')).toEqual({ decision: 'allow', reason: null }); - expect(stepCalls).toEqual([{ session_id: 's1' }]); + expect(wakeCalls).toEqual([{ session_id: 's1' }]); expect(entry!.unregister).toHaveBeenCalled(); }); @@ -111,15 +114,15 @@ describe('approval resume handler', () => { }); }); - it('does not trigger turn::step again after unregister on second invoke', async () => { - const { iii, registered, stepCalls } = makeIiiWithRegistry(); + it('does not publish turn::step_requested again after unregister on second invoke', async () => { + const { iii, registered, wakeCalls } = makeIiiWithRegistry(); registerApprovalResume(iii, 's1', 'fc-1'); const entry = registered.get('turn::approval_resume::s1/fc-1')!; await entry.handler({ decision: 'deny', reason: 'nope' }); - stepCalls.length = 0; + wakeCalls.length = 0; await entry.handler({ decision: 'allow', reason: null }); - expect(stepCalls).toHaveLength(0); + expect(wakeCalls).toHaveLength(0); }); }); diff --git a/harness-node/tests/turn-orchestrator/get-state.test.ts b/harness-node/tests/turn-orchestrator/get-state.test.ts index 24303494..63717084 100644 --- a/harness-node/tests/turn-orchestrator/get-state.test.ts +++ b/harness-node/tests/turn-orchestrator/get-state.test.ts @@ -1,9 +1,46 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import { execute } from '../../src/turn-orchestrator/get-state.js'; +import { execute, GetStatePayloadSchema } from '../../src/turn-orchestrator/get-state.js'; import { newRecord } from '../../src/turn-orchestrator/state.js'; -describe('turn::get_state', () => { +describe('GetStatePayloadSchema', () => { + it('accepts the flat shape the real backend sends', () => { + expect(GetStatePayloadSchema.parse({ session_id: 'sess-abc' })).toEqual({ + session_id: 'sess-abc', + }); + }); + + it('strips extra keys (engine may add metadata later)', () => { + expect(GetStatePayloadSchema.parse({ session_id: 's1', trace_id: 't1' })).toEqual({ + session_id: 's1', + }); + }); + + it('rejects publish envelope shapes — no in-repo caller wraps get_state', () => { + expect(() => + GetStatePayloadSchema.parse({ + topic: 'turn::step_requested', + data: { session_id: 's1' }, + }), + ).toThrow(); + }); + + it('rejects nested payload wrappers (no in-repo caller uses them)', () => { + expect(() => GetStatePayloadSchema.parse({ data: { session_id: 's1' } })).toThrow(); + expect(() => GetStatePayloadSchema.parse({ payload: { session_id: 's1' } })).toThrow(); + }); + + it('rejects missing, empty, or non-string session_id', () => { + expect(() => GetStatePayloadSchema.parse({})).toThrow(); + expect(() => GetStatePayloadSchema.parse({ session_id: '' })).toThrow(); + expect(() => GetStatePayloadSchema.parse({ session_id: 42 })).toThrow(); + expect(() => GetStatePayloadSchema.parse({ session_id: null })).toThrow(); + expect(() => GetStatePayloadSchema.parse(null)).toThrow(); + expect(() => GetStatePayloadSchema.parse(undefined)).toThrow(); + }); +}); + +describe('turn::get_state execute', () => { it('returns the turn_state record for a known session via persistence.loadRecord', async () => { const rec = newRecord('sess-abc'); rec.state = 'function_awaiting_approval'; @@ -30,9 +67,4 @@ describe('turn::get_state', () => { const out = await execute(iii, { session_id: 'unknown' }); expect(out).toBeNull(); }); - - it('throws on missing session_id', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - await expect(execute(iii, {})).rejects.toThrow(/session_id/); - }); }); diff --git a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts index 592d0ffc..2991415c 100644 --- a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts +++ b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts @@ -1,40 +1,88 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { + AbortSignalWriteEventSchema, + execute, handleAbortSignalWrite, isAbortSignalWrite, + parseAbortSignalWrite, } from '../../src/turn-orchestrator/on-abort-signal.js'; -describe('isAbortSignalWrite condition', () => { - it('matches session//abort_signal with new_value === true', () => { - expect( - isAbortSignalWrite({ - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-abc/abort_signal', - old_value: null, - new_value: true, - message_type: 'state', +const matchingEvent = { + event_type: 'state:created' as const, + scope: 'agent' as const, + key: 'session/sess-abc/abort_signal', + old_value: null, + new_value: true as const, + message_type: 'state', +}; + +describe('AbortSignalWriteEventSchema', () => { + it('accepts the agent state write shape from state::set / engine triggers', () => { + expect(AbortSignalWriteEventSchema.parse(matchingEvent)).toEqual({ + session_id: 'sess-abc', + }); + }); + + it('rejects durable publish envelope shapes (not a state trigger event)', () => { + expect(() => + AbortSignalWriteEventSchema.parse({ + topic: 'turn::step_requested', + data: { session_id: 's1' }, }), - ).toBe(true); + ).toThrow(); }); - it('matches state:updated transitioning to true', () => { - expect( - isAbortSignalWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/abort_signal', - old_value: false, - new_value: true, - message_type: 'state', + it('rejects nested payload wrappers', () => { + expect(() => AbortSignalWriteEventSchema.parse({ payload: matchingEvent })).toThrow(); + expect(() => AbortSignalWriteEventSchema.parse({ data: matchingEvent })).toThrow(); + }); + + it('rejects missing key, wrong new_value, or non-abort_signal keys', () => { + expect(() => AbortSignalWriteEventSchema.parse({})).toThrow(); + expect(() => + AbortSignalWriteEventSchema.parse({ + ...matchingEvent, + key: 'session/sess-abc/turn_state', + }), + ).toThrow(); + expect(() => + AbortSignalWriteEventSchema.parse({ + ...matchingEvent, + new_value: false, + }), + ).toThrow(); + expect(() => + AbortSignalWriteEventSchema.parse({ + ...matchingEvent, + event_type: 'state:deleted', }), - ).toBe(true); + ).toThrow(); + expect(() => AbortSignalWriteEventSchema.parse(null)).toThrow(); + }); +}); + +describe('parseAbortSignalWrite condition', () => { + it('matches session//abort_signal with new_value === true', () => { + expect(parseAbortSignalWrite(matchingEvent)).toEqual({ session_id: 'sess-abc' }); + expect(isAbortSignalWrite(matchingEvent)).toBe(true); + }); + + it('matches state:updated transitioning to true', () => { + const event = { + event_type: 'state:updated' as const, + scope: 'agent' as const, + key: 'session/sess-abc/abort_signal', + old_value: false, + new_value: true as const, + message_type: 'state', + }; + expect(parseAbortSignalWrite(event)).toEqual({ session_id: 'sess-abc' }); }); it('skips state:deleted', () => { expect( - isAbortSignalWrite({ + parseAbortSignalWrite({ event_type: 'state:deleted', scope: 'agent', key: 'session/sess-abc/abort_signal', @@ -42,12 +90,12 @@ describe('isAbortSignalWrite condition', () => { new_value: null, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); it('skips writes that set the signal to false (idempotent clears)', () => { expect( - isAbortSignalWrite({ + parseAbortSignalWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/abort_signal', @@ -55,12 +103,12 @@ describe('isAbortSignalWrite condition', () => { new_value: false, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); it('skips non-abort_signal keys in the agent scope', () => { expect( - isAbortSignalWrite({ + parseAbortSignalWrite({ event_type: 'state:updated', scope: 'agent', key: 'session/sess-abc/turn_state', @@ -68,12 +116,12 @@ describe('isAbortSignalWrite condition', () => { new_value: { state: 'function_execute' }, message_type: 'state', }), - ).toBe(false); + ).toBeNull(); }); it('skips top-level non-session keys', () => { expect( - isAbortSignalWrite({ + parseAbortSignalWrite({ event_type: 'state:updated', scope: 'agent', key: 'harness/index/abc/last_session_id', @@ -81,7 +129,38 @@ describe('isAbortSignalWrite condition', () => { new_value: 'sess-1', message_type: 'state', }), - ).toBe(false); + ).toBeNull(); + }); +}); + +describe('execute', () => { + it('publishes turn::step_requested via durable publish', async () => { + const triggers: Array<{ function_id: string; payload: unknown }> = []; + const iii = { + trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { + triggers.push(req); + return null; + }), + } as unknown as ISdk; + + await execute(iii, { session_id: 'sess-abc' }); + + expect(triggers).toHaveLength(1); + expect(triggers[0]?.function_id).toBe('iii::durable::publish'); + expect(triggers[0]?.payload).toEqual({ + topic: 'turn::step_requested', + data: { session_id: 'sess-abc' }, + }); + }); + + it('swallows publish failures (logs only, never rethrows)', async () => { + const iii = { + trigger: vi.fn(async () => { + throw new Error('durable down'); + }), + } as unknown as ISdk; + + await expect(execute(iii, { session_id: 'sess-abc' })).resolves.toBeUndefined(); }); }); @@ -95,18 +174,11 @@ describe('handleAbortSignalWrite', () => { }), } as unknown as ISdk; - await handleAbortSignalWrite(iii, { - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-abc/abort_signal', - old_value: null, - new_value: true, - message_type: 'state', - }); + await handleAbortSignalWrite(iii, matchingEvent); expect(triggers).toHaveLength(1); expect(triggers[0]?.function_id).toBe('iii::durable::publish'); - expect(triggers[0]?.payload).toMatchObject({ + expect(triggers[0]?.payload).toEqual({ topic: 'turn::step_requested', data: { session_id: 'sess-abc' }, }); @@ -124,4 +196,17 @@ describe('handleAbortSignalWrite', () => { }); expect(iii.trigger).not.toHaveBeenCalled(); }); + + it('no-ops when new_value is not true (direct invoke bypasses engine condition)', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + await handleAbortSignalWrite(iii, { + event_type: 'state:updated', + scope: 'agent', + key: 'session/sess-abc/abort_signal', + old_value: true, + new_value: false, + message_type: 'state', + }); + expect(iii.trigger).not.toHaveBeenCalled(); + }); }); diff --git a/harness-node/tests/turn-orchestrator/on-record-written.test.ts b/harness-node/tests/turn-orchestrator/on-record-written.test.ts index 41ce3e27..660be9b7 100644 --- a/harness-node/tests/turn-orchestrator/on-record-written.test.ts +++ b/harness-node/tests/turn-orchestrator/on-record-written.test.ts @@ -6,6 +6,16 @@ import { } from '../../src/turn-orchestrator/on-record-written.js'; describe('parseStepableWrite condition', () => { + it('accepts minimal required fields (matches TurnStateWriteEventSchema callers)', () => { + expect( + parseStepableWrite({ + event_type: 'state:created', + key: 'session/sess-a/turn_state', + new_value: { state: 'provisioning' }, + }), + ).toEqual({ session_id: 'sess-a' }); + }); + it('matches turn_state writes with a non-terminal, non-awaiting state', () => { expect( parseStepableWrite({ @@ -106,6 +116,31 @@ describe('parseStepableWrite condition', () => { ).toEqual({ session_id: 'sess-abc' }); }); + it('rejects publish envelope shapes — state triggers receive flat events', () => { + expect( + parseStepableWrite({ + topic: 'turn::step_requested', + data: { session_id: 'sess-abc' }, + }), + ).toBeNull(); + }); + + it('rejects nested payload wrappers (no in-repo caller uses them)', () => { + const inner = { + event_type: 'state:created', + key: 'session/sess-abc/turn_state', + new_value: { state: 'provisioning' }, + }; + expect(parseStepableWrite({ data: inner })).toBeNull(); + expect(parseStepableWrite({ payload: inner })).toBeNull(); + }); + + it('rejects null, undefined, and non-object events', () => { + expect(parseStepableWrite(null)).toBeNull(); + expect(parseStepableWrite(undefined)).toBeNull(); + expect(parseStepableWrite('state:created')).toBeNull(); + }); + it('rejects writes whose new_value lacks a string state', () => { expect( parseStepableWrite({ @@ -132,7 +167,7 @@ describe('parseStepableWrite condition', () => { }); describe('handleStepableRecordWrite', () => { - it('extracts session_id and invokes turn::step directly', async () => { + it('extracts session_id and publishes turn::step_requested', async () => { const triggers: Array<{ function_id: string; payload: unknown }> = []; const iii = { trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { @@ -151,8 +186,11 @@ describe('handleStepableRecordWrite', () => { }); expect(triggers).toHaveLength(1); - expect(triggers[0]?.function_id).toBe('turn::step'); - expect(triggers[0]?.payload).toEqual({ session_id: 'sess-abc' }); + expect(triggers[0]?.function_id).toBe('iii::durable::publish'); + expect(triggers[0]?.payload).toEqual({ + topic: 'turn::step_requested', + data: { session_id: 'sess-abc' }, + }); }); it('no-ops when the event is not stepable (direct invoke bypasses engine condition)', async () => { diff --git a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts b/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts index aa912b0b..52b8accb 100644 --- a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts +++ b/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts @@ -2,9 +2,29 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { handleTurnStateWrite, + isTurnStateWrite, parseTurnStateWrite, + TurnStateWriteEventSchema, } from '../../src/turn-orchestrator/on-turn-state-changed.js'; +const canonicalCreated = { + event_type: 'state:created' as const, + scope: 'agent' as const, + key: 'session/sess-a/turn_state', + old_value: null, + new_value: { state: 'provisioning' }, + message_type: 'state' as const, +}; + +const canonicalUpdated = { + event_type: 'state:updated' as const, + scope: 'agent' as const, + key: 'session/sess-a/turn_state', + old_value: { state: 'function_execute' }, + new_value: { state: 'function_awaiting_approval' }, + message_type: 'state' as const, +}; + function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unknown }> } { const emits: Array<{ session_id: string; event: unknown }> = []; const iii = { @@ -20,55 +40,75 @@ function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unkno return { iii, emits }; } -describe('parseTurnStateWrite', () => { - it('parses state:created on session//turn_state', () => { - expect( - parseTurnStateWrite({ - event_type: 'state:created', - key: 'session/sess-a/turn_state', - new_value: { state: 'provisioning' }, - }), - ).toEqual({ +describe('TurnStateWriteEventSchema / isTurnStateWrite', () => { + it('accepts the canonical agent state write shape from the iii engine', () => { + expect(TurnStateWriteEventSchema.parse(canonicalCreated)).toEqual({ session_id: 'sess-a', event_type: 'state:created', new_value: { state: 'provisioning' }, }); + + expect(TurnStateWriteEventSchema.parse(canonicalUpdated)).toEqual({ + session_id: 'sess-a', + event_type: 'state:updated', + new_value: { state: 'function_awaiting_approval' }, + old_value: { state: 'function_execute' }, + }); + + expect(isTurnStateWrite(canonicalCreated)).toBe(true); + expect(isTurnStateWrite(canonicalUpdated)).toBe(true); }); - it('parses state:updated on session//turn_state', () => { + it('accepts minimal shapes without optional engine metadata', () => { expect( parseTurnStateWrite({ - event_type: 'state:updated', + event_type: 'state:created', key: 'session/sess-a/turn_state', - new_value: { state: 'function_awaiting_approval' }, - old_value: { state: 'function_execute' }, + new_value: { state: 'provisioning' }, }), ).toEqual({ session_id: 'sess-a', - event_type: 'state:updated', - new_value: { state: 'function_awaiting_approval' }, - old_value: { state: 'function_execute' }, + event_type: 'state:created', + new_value: { state: 'provisioning' }, }); }); + it('rejects nested payload wrappers (no in-repo caller uses them)', () => { + expect(() => TurnStateWriteEventSchema.parse({ payload: canonicalCreated })).toThrow(); + expect(() => TurnStateWriteEventSchema.parse({ data: canonicalCreated })).toThrow(); + expect(isTurnStateWrite({ payload: canonicalCreated })).toBe(false); + }); + it('rejects non-turn_state agent keys', () => { expect( - parseTurnStateWrite({ - event_type: 'state:created', + isTurnStateWrite({ + ...canonicalCreated, key: 'session/sess-a/abort_signal', new_value: { state: 'true' }, }), - ).toBeNull(); + ).toBe(false); }); it('rejects state:deleted', () => { expect( - parseTurnStateWrite({ + isTurnStateWrite({ event_type: 'state:deleted', + scope: 'agent', key: 'session/sess-a/turn_state', - new_value: { state: 'provisioning' }, + old_value: { state: 'provisioning' }, + new_value: null, + message_type: 'state', }), - ).toBeNull(); + ).toBe(false); + }); + + it('rejects missing key, empty session id segment, or malformed new_value', () => { + expect(isTurnStateWrite({ ...canonicalCreated, key: undefined })).toBe(false); + expect(isTurnStateWrite({ ...canonicalCreated, key: 'session//turn_state' })).toBe(false); + expect(isTurnStateWrite({ ...canonicalCreated, new_value: { not_state: 'x' } })).toBe(false); + expect(isTurnStateWrite({ ...canonicalCreated, new_value: null })).toBe(false); + expect(isTurnStateWrite(null)).toBe(false); + expect(isTurnStateWrite(undefined)).toBe(false); }); }); @@ -76,8 +116,7 @@ describe('handleTurnStateWrite', () => { it('emits turn_state_changed on agent::events with group_id = session_id', async () => { const { iii, emits } = fakeIii(); await handleTurnStateWrite(iii, { - event_type: 'state:updated', - key: 'session/sess-a/turn_state', + ...canonicalUpdated, new_value: { state: 'function_awaiting_approval', awaiting_approval: [] }, old_value: { state: 'function_execute', awaiting_approval: null }, }); @@ -91,12 +130,15 @@ describe('handleTurnStateWrite', () => { }); }); - it('is a no-op when the event does not match the condition', async () => { + it('no-ops when the event does not match the condition (direct invoke bypasses engine condition)', async () => { const { iii, emits } = fakeIii(); await handleTurnStateWrite(iii, { event_type: 'state:created', + scope: 'agent', key: 'session/sess-a/abort_signal', + old_value: null, new_value: true, + message_type: 'state', }); expect(emits).toEqual([]); }); @@ -107,23 +149,12 @@ describe('handleTurnStateWrite', () => { throw new Error('stream::set down'); }), } as unknown as ISdk; - // Should NOT throw. - await expect( - handleTurnStateWrite(iii, { - event_type: 'state:created', - key: 'session/sess-a/turn_state', - new_value: { state: 'provisioning' }, - }), - ).resolves.toBeUndefined(); + await expect(handleTurnStateWrite(iii, canonicalCreated)).resolves.toBeUndefined(); }); it('omits old_value from the emitted event when state:created', async () => { const { iii, emits } = fakeIii(); - await handleTurnStateWrite(iii, { - event_type: 'state:created', - key: 'session/sess-a/turn_state', - new_value: { state: 'provisioning' }, - }); + await handleTurnStateWrite(iii, canonicalCreated); expect(emits).toHaveLength(1); const event = emits[0]?.event as Record; expect(event.type).toBe('turn_state_changed'); diff --git a/harness-node/tests/turn-orchestrator/provisioning.test.ts b/harness-node/tests/turn-orchestrator/provisioning.test.ts new file mode 100644 index 00000000..662e87a5 --- /dev/null +++ b/harness-node/tests/turn-orchestrator/provisioning.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ISdk } from '../../src/runtime/iii.js'; +import * as persistence from '../../src/turn-orchestrator/persistence.js'; +import { type TurnStateRecord, newRecord } from '../../src/turn-orchestrator/state.js'; +import { + handleProvisioning, + parseDirectoryBody, + parseRunRequest, +} from '../../src/turn-orchestrator/states/provisioning.js'; + +type TriggerCall = { function_id: string; payload: unknown; timeoutMs?: number }; + +function fakeIii(responses: Record = {}): { iii: ISdk; calls: TriggerCall[] } { + const calls: TriggerCall[] = []; + const iii = { + trigger: async (req: { + function_id: string; + payload: T; + timeoutMs?: number; + }): Promise => { + calls.push({ + function_id: req.function_id, + payload: req.payload, + timeoutMs: req.timeoutMs, + }); + return (responses[req.function_id] ?? null) as R; + }, + } as unknown as ISdk; + return { iii, calls }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('parseRunRequest', () => { + it('maps persisted run::start fields with defaults for missing keys', () => { + expect(parseRunRequest({})).toEqual({ + provider: '', + model: '', + mode: null, + system_prompt: '', + image: 'python', + idle_timeout_secs: 300, + }); + }); + + it('rejects invalid mode values', () => { + expect(parseRunRequest({ mode: 'invalid' }).mode).toBeNull(); + expect(parseRunRequest({ mode: 'plan' }).mode).toBe('plan'); + }); +}); + +describe('parseDirectoryBody', () => { + it('accepts bare string and wrapped body responses', () => { + expect(parseDirectoryBody('raw')).toBe('raw'); + expect(parseDirectoryBody({ body: 'wrapped' })).toBe('wrapped'); + }); + + it('rejects empty wrapped body and non-string shapes', () => { + expect(parseDirectoryBody({ body: '' })).toBe(''); + expect(parseDirectoryBody({ body: 1 })).toBeNull(); + expect(parseDirectoryBody(null)).toBeNull(); + }); +}); + +describe('handleProvisioning', () => { + it('materializes schemas, persists built prompt, and advances to awaiting_assistant', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; + const { iii, calls } = fakeIii({ + 'directory::skills::index': { body: 'INDEX' }, + 'directory::skills::get': { body: 'SKILL' }, + }); + const cfg = { system_default_skills: ['iii://iii-directory/index'] }; + + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ + provider: 'openai', + model: 'gpt-4', + mode: 'agent', + system_prompt: '', + image: 'python', + idle_timeout_secs: 300, + }); + const saveSchemas = vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); + const saveRunRequest = vi.spyOn(persistence, 'saveRunRequest').mockResolvedValue(); + + await handleProvisioning(iii, cfg, rec); + + expect(rec.state).toBe('awaiting_assistant'); + expect(saveSchemas).toHaveBeenCalledWith(iii, 's1', [ + expect.objectContaining({ name: 'agent_trigger' }), + ]); + expect(saveRunRequest).toHaveBeenCalledWith( + iii, + 's1', + expect.objectContaining({ + provider: 'openai', + model: 'gpt-4', + system_prompt: expect.stringContaining('operating in agent mode'), + }), + ); + expect(calls.some((c) => c.function_id === 'directory::skills::index')).toBe(true); + expect(calls.some((c) => c.function_id === 'directory::skills::get')).toBe(true); + }); + + it('preserves a non-empty caller override verbatim', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; + const { iii } = fakeIii(); + const cfg = { system_default_skills: [] as string[] }; + + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ + provider: 'openai', + model: 'gpt-4', + system_prompt: 'custom override', + }); + vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); + const saveRunRequest = vi.spyOn(persistence, 'saveRunRequest').mockResolvedValue(); + + await handleProvisioning(iii, cfg, rec); + + expect(saveRunRequest).toHaveBeenCalledWith( + iii, + 's1', + expect.objectContaining({ system_prompt: 'custom override' }), + ); + }); + + it('continues when directory fetches fail', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; + const { iii } = fakeIii(); + const cfg = { system_default_skills: ['iii://missing'] }; + + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({}); + vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); + const saveRunRequest = vi.spyOn(persistence, 'saveRunRequest').mockResolvedValue(); + + await handleProvisioning(iii, cfg, rec); + + expect(rec.state).toBe('awaiting_assistant'); + expect(saveRunRequest).toHaveBeenCalledWith( + iii, + 's1', + expect.objectContaining({ + system_prompt: expect.stringContaining('You are an iii agent worker'), + }), + ); + }); +}); diff --git a/harness-node/tests/turn-orchestrator/run-start.test.ts b/harness-node/tests/turn-orchestrator/run-start.test.ts index e4a3cc9f..40be172e 100644 --- a/harness-node/tests/turn-orchestrator/run-start.test.ts +++ b/harness-node/tests/turn-orchestrator/run-start.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import { execute } from '../../src/turn-orchestrator/run-start.js'; +import { RunStartPayloadSchema, execute, register } from '../../src/turn-orchestrator/run-start.js'; type TriggerCall = { function_id: string; payload: unknown }; @@ -11,26 +11,147 @@ function fakeIii(): { iii: ISdk; calls: TriggerCall[] } { calls.push({ function_id: req.function_id, payload: req.payload }); return null as R; }, + registerFunction: vi.fn(), } as unknown as ISdk; return { iii, calls }; } +/** Shape console/web sends inside harness::trigger payload (real.ts). */ +const consoleRunStartPayload = { + session_id: 'sess-1', + message_id: 'msg-1', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + mode: 'agent' as const, + messages: [ + { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'hi' }], + timestamp: Date.now(), + }, + ], +}; + +/** Minimal shape harness/trigger.test.ts forwards to run::start. */ +const harnessRunStartPayload = { + session_id: 'sess-1', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + messages: [ + { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'hi' }], + timestamp: Date.now(), + }, + ], +}; + +describe('RunStartPayloadSchema', () => { + it('accepts the console/web payload shape', () => { + expect(RunStartPayloadSchema.parse(consoleRunStartPayload)).toMatchObject({ + session_id: 'sess-1', + message_id: 'msg-1', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + mode: 'agent', + system_prompt: '', + image: 'python', + idle_timeout_secs: 300, + messages: consoleRunStartPayload.messages, + }); + }); + + it('accepts the minimal harness::trigger test payload with defaults', () => { + expect(RunStartPayloadSchema.parse(harnessRunStartPayload)).toMatchObject({ + session_id: 'sess-1', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: '', + image: 'python', + idle_timeout_secs: 300, + messages: harnessRunStartPayload.messages, + }); + }); + + it('rejects harness::trigger envelope shapes — run::start receives payload only', () => { + expect(() => + RunStartPayloadSchema.parse({ + session_id: 'outer', + message_id: 'msg-1', + payload: harnessRunStartPayload, + }), + ).toThrow(); + }); + + it('rejects nested payload/data wrappers (no in-repo caller uses them)', () => { + expect(() => RunStartPayloadSchema.parse({ data: harnessRunStartPayload })).toThrow(); + expect(() => RunStartPayloadSchema.parse({ payload: harnessRunStartPayload })).toThrow(); + }); + + it('rejects missing or invalid required fields', () => { + expect(() => RunStartPayloadSchema.parse({})).toThrow(); + expect(() => RunStartPayloadSchema.parse({ session_id: '' })).toThrow(); + expect(() => RunStartPayloadSchema.parse({ session_id: 's1' })).toThrow(); + expect(() => RunStartPayloadSchema.parse({ session_id: 's1', provider: 'p' })).toThrow(); + expect(() => + RunStartPayloadSchema.parse({ session_id: 42, provider: 'p', model: 'm' }), + ).toThrow(); + expect(() => + RunStartPayloadSchema.parse({ session_id: 's1', provider: 'p', model: 'm', mode: 'invalid' }), + ).toThrow(); + expect(() => RunStartPayloadSchema.parse(null)).toThrow(); + expect(() => RunStartPayloadSchema.parse(undefined)).toThrow(); + }); +}); + +describe('register', () => { + it('registers run::start and parses payload at the unknown boundary', async () => { + const registered = new Map Promise>(); + const iii = { + registerFunction: (fnId: string, handler: (payload: unknown) => Promise) => { + registered.set(fnId, handler); + }, + trigger: vi.fn(async () => null), + } as unknown as ISdk; + + register(iii); + const handler = registered.get('run::start'); + expect(handler).toBeDefined(); + + const result = await handler!(harnessRunStartPayload); + expect(result).toEqual({ session_id: 'sess-1' }); + }); + + it('rejects invalid payloads at register boundary', async () => { + const registered = new Map Promise>(); + const iii = { + registerFunction: (fnId: string, handler: (payload: unknown) => Promise) => { + registered.set(fnId, handler); + }, + trigger: vi.fn(async () => null), + } as unknown as ISdk; + + register(iii); + const handler = registered.get('run::start'); + expect(handler).toBeDefined(); + + await expect(handler!({ provider: 'openai' })).rejects.toThrow(); + }); +}); + describe('execute', () => { it('saves initial session state to wake the reactive step trigger', async () => { const { iii, calls } = fakeIii(); - await execute(iii, { - session_id: 's1', - provider: 'openai', - model: 'gpt-test', - messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }], timestamp: 1 }], - }); + const result = await execute(iii, RunStartPayloadSchema.parse(harnessRunStartPayload)); + + expect(result).toEqual({ session_id: 'sess-1' }); const turnStateSet = calls.find( (c) => c.function_id === 'state::set' && (c.payload as { scope?: string; key?: string }).scope === 'agent' && - (c.payload as { scope?: string; key?: string }).key === 'session/s1/turn_state', + (c.payload as { scope?: string; key?: string }).key === 'session/sess-1/turn_state', ); expect(turnStateSet).toBeDefined(); expect((turnStateSet?.payload as { value: { state: string } }).value.state).toBe( diff --git a/harness-node/tests/turn-orchestrator/subscriber.test.ts b/harness-node/tests/turn-orchestrator/subscriber.test.ts index 1425081d..5519edd6 100644 --- a/harness-node/tests/turn-orchestrator/subscriber.test.ts +++ b/harness-node/tests/turn-orchestrator/subscriber.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import type { TurnOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; -import { newRecord } from '../../src/turn-orchestrator/state.js'; +import { newRecord, turnStateKey } from '../../src/turn-orchestrator/state.js'; import * as transitions from '../../src/turn-orchestrator/transitions.js'; -import { execute, StepPayloadSchema } from '../../src/turn-orchestrator/subscriber.js'; +import { execute, shouldStep, StepPayloadSchema } from '../../src/turn-orchestrator/subscriber.js'; const cfg: TurnOrchestratorConfig = { system_default_skills: [] }; @@ -49,36 +49,70 @@ describe('StepPayloadSchema', () => { }); }); -describe('execute', () => { - it('returns unknown_session when the record does not exist', async () => { +describe('shouldStep', () => { + it('returns false when the record does not exist', async () => { const iii = { trigger: vi.fn() } as unknown as ISdk; vi.spyOn(persistence, 'loadRecord').mockResolvedValue(null); - await expect(execute(iii, cfg, { session_id: 'missing' })).resolves.toEqual({ - ok: false, - reason: 'unknown_session', - }); + await expect(shouldStep(iii, { session_id: 'missing' })).resolves.toBe(false); }); - it('returns terminal without stepping when the record is stopped', async () => { + it('returns false when the record is stopped', async () => { const iii = { trigger: vi.fn() } as unknown as ISdk; const rec = newRecord('s1'); rec.state = 'stopped'; vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); - const stepSpy = vi.spyOn(transitions, 'step'); - await expect(execute(iii, cfg, { session_id: 's1' })).resolves.toEqual({ - ok: true, - terminal: true, - }); - expect(stepSpy).not.toHaveBeenCalled(); + await expect(shouldStep(iii, { session_id: 's1' })).resolves.toBe(false); }); - it('steps, persists, and returns from_state/to_state on success', async () => { + it('returns true for a known non-terminal session', async () => { const iii = { trigger: vi.fn() } as unknown as ISdk; const rec = newRecord('s1'); rec.state = 'provisioning'; vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + + await expect(shouldStep(iii, { session_id: 's1' })).resolves.toBe(true); + }); + + it('rejects malformed payloads', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + const loadSpy = vi.spyOn(persistence, 'loadRecord'); + + await expect(shouldStep(iii, {})).resolves.toBe(false); + expect(loadSpy).not.toHaveBeenCalled(); + }); +}); + +function mockTurnStateGet(iii: ISdk, rec: ReturnType | null): void { + vi.mocked(iii.trigger).mockImplementation(async (req) => { + if ( + req.function_id === 'state::get' && + req.payload && + typeof req.payload === 'object' && + (req.payload as { key?: string }).key === turnStateKey(rec?.session_id ?? 'missing') + ) { + return rec; + } + throw new Error(`unexpected trigger ${req.function_id}`); + }); +} + +describe('execute', () => { + it('throws when the record does not exist', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + mockTurnStateGet(iii, null); + + await expect(execute(iii, cfg, { session_id: 'missing' })).rejects.toThrow( + 'turn::step invariant: missing session missing', + ); + }); + + it('steps, persists, and returns from_state/to_state on success', async () => { + const iii = { trigger: vi.fn() } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'provisioning'; + mockTurnStateGet(iii, rec); vi.spyOn(transitions, 'step').mockImplementation(async (_iii, _cfg, r) => { r.state = 'awaiting_assistant'; }); @@ -96,7 +130,7 @@ describe('execute', () => { const iii = { trigger: vi.fn() } as unknown as ISdk; const rec = newRecord('s1'); rec.state = 'function_execute'; - vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + mockTurnStateGet(iii, rec); vi.spyOn(transitions, 'step').mockRejectedValue(new Error('sandbox gone')); await expect(execute(iii, cfg, { session_id: 's1' })).rejects.toThrow( diff --git a/harness-node/tests/turn-orchestrator/tearing-down.test.ts b/harness-node/tests/turn-orchestrator/tearing-down.test.ts index 763fb420..0aca5f3c 100644 --- a/harness-node/tests/turn-orchestrator/tearing-down.test.ts +++ b/harness-node/tests/turn-orchestrator/tearing-down.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; +import type { AgentMessage } from '../../src/types/agent-message.js'; +import * as events from '../../src/turn-orchestrator/events.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; import { type TurnStateRecord, newRecord } from '../../src/turn-orchestrator/state.js'; import { handleTearingDown } from '../../src/turn-orchestrator/states/tearing-down.js'; @@ -30,24 +32,26 @@ afterEach(() => { }); describe('handleTearingDown', () => { - it('proceeds with normal teardown without approval::consume resurrection', async () => { + it('transitions to stopped and emits agent_end with session messages', async () => { const rec: TurnStateRecord = { ...newRecord('s1'), state: 'tearing_down' }; - const { iii, calls } = fakeIii(); + const messages: AgentMessage[] = [{ role: 'user', content: 'hi' }]; + const { iii } = fakeIii(); vi.spyOn(persistence, 'loadSandboxId').mockResolvedValue(null); - vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue(messages); + const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); await handleTearingDown(iii, rec); expect(rec.state).toBe('stopped'); - expect(calls.some((c) => c.function_id === 'approval::consume')).toBe(false); - expect(calls.some((c) => c.function_id === 'stream::set')).toBe(true); + expect(emitSpy).toHaveBeenCalledWith(iii, 's1', { type: 'agent_end', messages }); }); - it('stops the sandbox before ending the agent when a sandbox id exists', async () => { + it('calls sandbox::stop before ending the agent when a sandbox id exists', async () => { const rec: TurnStateRecord = { ...newRecord('s1'), state: 'tearing_down' }; const { iii, calls } = fakeIii(); vi.spyOn(persistence, 'loadSandboxId').mockResolvedValue('sandbox-1'); vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(events, 'emit').mockResolvedValue(undefined); await handleTearingDown(iii, rec); From c983544924e4d29c5969518bbc5cc3d95930c345 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 22 May 2026 19:27:32 -0300 Subject: [PATCH 08/16] Split turn-orchestrator into per-state queue handlers. Replace the monolithic turn::step subscriber with turn::{state} functions enqueued on turn-step, consolidate UI turn_state_changed emits into persistence saves, and merge assistant/function states for a simpler FSM. --- harness-node/engine.config.yaml | 21 + harness-node/src/runtime/iii.ts | 2 +- .../src/turn-orchestrator/approval-resume.ts | 10 +- .../src/turn-orchestrator/on-abort-signal.ts | 13 +- .../turn-orchestrator/on-record-written.ts | 77 ---- .../on-turn-state-changed.ts | 90 ----- .../src/turn-orchestrator/persistence.ts | 27 +- .../src/turn-orchestrator/register.ts | 23 +- harness-node/src/turn-orchestrator/state.ts | 7 +- .../states/assistant-finished.ts | 120 ++++++ .../{assistant.ts => assistant-streaming.ts} | 188 ++++----- .../states/function-awaiting-approval.ts | 133 +++++++ .../{functions.ts => function-execute.ts} | 375 +++++++----------- .../src/turn-orchestrator/states/index.ts | 11 + .../turn-orchestrator/states/provisioning.ts | 51 ++- .../states/{steering.ts => steering-check.ts} | 61 ++- .../turn-orchestrator/states/tearing-down.ts | 44 +- .../src/turn-orchestrator/subscriber.ts | 83 ---- .../src/turn-orchestrator/transitions.ts | 44 -- .../src/turn-orchestrator/turn-state-write.ts | 30 ++ .../turn-orchestrator/turn-step-payload.ts | 31 ++ harness-node/src/turn-orchestrator/wake.ts | 45 +++ .../integration/approval-resume.e2e.test.ts | 154 ++++--- .../integration/on-record-written.e2e.test.ts | 198 +++++---- .../turn-orchestrator/approval-resume.test.ts | 77 ++-- .../tests/turn-orchestrator/assistant.test.ts | 276 ++++++++++++- .../awaiting-approval.test.ts | 2 +- .../tests/turn-orchestrator/functions.test.ts | 200 +++++++++- .../turn-orchestrator/on-abort-signal.test.ts | 65 +-- .../on-record-written.test.ts | 221 ----------- .../on-turn-state-changed.test.ts | 163 -------- .../turn-orchestrator/provisioning.test.ts | 68 +++- .../tests/turn-orchestrator/run-start.test.ts | 20 +- .../tests/turn-orchestrator/state.test.ts | 10 +- .../tests/turn-orchestrator/steering.test.ts | 223 ++++++++++- .../turn-orchestrator/subscriber.test.ts | 140 ------- .../turn-state-write.test.ts | 62 +++ .../tests/turn-orchestrator/wake.test.ts | 94 +++++ 38 files changed, 1951 insertions(+), 1508 deletions(-) create mode 100644 harness-node/engine.config.yaml delete mode 100644 harness-node/src/turn-orchestrator/on-record-written.ts delete mode 100644 harness-node/src/turn-orchestrator/on-turn-state-changed.ts create mode 100644 harness-node/src/turn-orchestrator/states/assistant-finished.ts rename harness-node/src/turn-orchestrator/states/{assistant.ts => assistant-streaming.ts} (64%) create mode 100644 harness-node/src/turn-orchestrator/states/function-awaiting-approval.ts rename harness-node/src/turn-orchestrator/states/{functions.ts => function-execute.ts} (60%) create mode 100644 harness-node/src/turn-orchestrator/states/index.ts rename harness-node/src/turn-orchestrator/states/{steering.ts => steering-check.ts} (69%) delete mode 100644 harness-node/src/turn-orchestrator/subscriber.ts delete mode 100644 harness-node/src/turn-orchestrator/transitions.ts create mode 100644 harness-node/src/turn-orchestrator/turn-state-write.ts create mode 100644 harness-node/src/turn-orchestrator/turn-step-payload.ts create mode 100644 harness-node/src/turn-orchestrator/wake.ts delete mode 100644 harness-node/tests/turn-orchestrator/on-record-written.test.ts delete mode 100644 harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts delete mode 100644 harness-node/tests/turn-orchestrator/subscriber.test.ts create mode 100644 harness-node/tests/turn-orchestrator/turn-state-write.test.ts create mode 100644 harness-node/tests/turn-orchestrator/wake.test.ts diff --git a/harness-node/engine.config.yaml b/harness-node/engine.config.yaml new file mode 100644 index 00000000..f39de797 --- /dev/null +++ b/harness-node/engine.config.yaml @@ -0,0 +1,21 @@ +# iii engine configuration for harness-node local/dev. +# Pass to the engine via `iii -c engine.config.yaml`. +# +# Worker binaries use harness-node/config.yaml separately (--config ./config.yaml). + +workers: + - name: iii-queue + config: + adapter: + name: builtin + queue_configs: + turn-step: + type: fifo + message_group_field: session_id + max_retries: 5 + concurrency: 1 + + - name: iii-state + config: + adapter: + name: file_based diff --git a/harness-node/src/runtime/iii.ts b/harness-node/src/runtime/iii.ts index a0b03e09..220da8b3 100644 --- a/harness-node/src/runtime/iii.ts +++ b/harness-node/src/runtime/iii.ts @@ -4,7 +4,7 @@ * mock the SDK in tests. */ -export { registerWorker } from 'iii-sdk'; +export { registerWorker, TriggerAction } from 'iii-sdk'; export type { ISdk, Channel, diff --git a/harness-node/src/turn-orchestrator/approval-resume.ts b/harness-node/src/turn-orchestrator/approval-resume.ts index fd3901dd..93acf7b7 100644 --- a/harness-node/src/turn-orchestrator/approval-resume.ts +++ b/harness-node/src/turn-orchestrator/approval-resume.ts @@ -1,7 +1,7 @@ /** * Per-call resume functions for parked approvals. Registered when a call * enters `function_awaiting_approval`; invoked by `approval::resolve` or - * abort. Persists to scope `approvals` and publishes `turn::step_requested`. + * abort. Persists to scope `approvals` and enqueues `turn::{state}` via wakeFromRecord. */ import { @@ -19,6 +19,7 @@ import { stateSet, } from '../runtime/state.js'; import type { TurnStateRecord } from './state.js'; +import { wakeFromRecord } from './wake.js'; const resumeRefs = new Map(); const TURN_STATE_KEY_RE = /^session\/[^/]+\/turn_state$/; @@ -89,10 +90,7 @@ async function handleApprovalResume( } try { - await iii.trigger({ - function_id: 'iii::durable::publish', - payload: { topic: 'turn::step_requested', data: { session_id } }, - }); + await wakeFromRecord(iii, session_id); } catch (err) { logger.warn('approval resume: turn step wake failed', { session_id, err: String(err) }); } @@ -114,7 +112,7 @@ export function registerApprovalResume( async (payload: unknown) => handleApprovalResume(iii, session_id, function_call_id, payload), { description: - 'Resume a parked approval: persist decision to approvals scope and publish turn::step_requested.', + 'Resume a parked approval: persist decision to approvals scope and enqueue turn::{state}.', }, ); resumeRefs.set(fnId, ref); diff --git a/harness-node/src/turn-orchestrator/on-abort-signal.ts b/harness-node/src/turn-orchestrator/on-abort-signal.ts index 705d4cd9..dd580528 100644 --- a/harness-node/src/turn-orchestrator/on-abort-signal.ts +++ b/harness-node/src/turn-orchestrator/on-abort-signal.ts @@ -2,7 +2,7 @@ * Reactive abort wake. A `state` trigger on `scope: 'agent'` filtered by * the abort_signal key shape (`session//abort_signal`) and a * `new_value === true` write fires this adapter, which publishes - * `turn::step_requested` so the orchestrator's FSM advances to + * `turn::{state}` on the durable FIFO queue so the orchestrator's FSM advances to * `steering_check` and observes the abort flag promptly. * * Without this wake, a session mid-streaming would only check @@ -17,13 +17,13 @@ * `performAbortSideEffects` / `router::abort`). Same envelope the engine passes * to state trigger adapters. * - * **Outgoing**: `iii::durable::publish` with `{ topic: 'turn::step_requested', - * data: { session_id } }`; durable subscriber receives flat `{ session_id }` only. + * **Outgoing**: `wakeFromRecord` enqueues `{ session_id }` on the `turn-step` queue. */ import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; +import { wakeFromRecord } from './wake.js'; const AgentAbortSignalWriteEventSchema = z.object({ type: z.literal('state').optional(), @@ -52,10 +52,7 @@ export function isAbortSignalWrite(event: unknown): boolean { export async function execute(iii: ISdk, write: ParsedAbortSignalWrite): Promise { try { - await iii.trigger({ - function_id: 'iii::durable::publish', - payload: { topic: 'turn::step_requested', data: { session_id: write.session_id } }, - }); + await wakeFromRecord(iii, write.session_id); } catch (err) { logger.warn('turn::on_abort_signal: wake failed', { session_id: write.session_id, @@ -85,7 +82,7 @@ export function register(iii: ISdk): void { async (event: unknown) => handleAbortSignalWrite(iii, event), { description: - 'State trigger adapter on scope=agent for abort_signal writes; publishes turn::step_requested so the orchestrator picks up the abort promptly.', + 'State trigger adapter on scope=agent for abort_signal writes; enqueues turn::{state} so the orchestrator picks up the abort promptly.', }, ); diff --git a/harness-node/src/turn-orchestrator/on-record-written.ts b/harness-node/src/turn-orchestrator/on-record-written.ts deleted file mode 100644 index 47c44b10..00000000 --- a/harness-node/src/turn-orchestrator/on-record-written.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Self-loop wake: a state trigger on `scope: 'agent'` filtered by the - * turn_state key shape and a stepable state TRANSITION (new state differs - * from old, non-terminal, non-awaiting) publishes `turn::step_requested`. - * Saving the record on a real transition is the wake. - * - * **Incoming**: agent state write event (`TurnStateWriteEventSchema`) where - * `new_value.state` is stepable (not `stopped` / `function_awaiting_approval`) - * and `state:updated` changes `old_value.state`. - * **Outgoing**: `iii::durable::publish` with - * `{ topic: 'turn::step_requested', data: { session_id } }` — the durable - * subscriber receives flat `{ session_id }` only. - * - * Same-state writes (e.g. `handlePrepare` calling `saveRecord` while still - * in `function_prepare` to persist normalized calls) MUST NOT wake step, - * otherwise the orchestrator races itself: a duplicate `turn::step` runs - * the same handler again, re-emitting events and re-persisting prepared - * calls. We filter those out by requiring `new_value.state !== old_value.state`. - */ - -import type { ISdk } from '../runtime/iii.js'; -import { TurnStateWriteEventSchema } from './on-turn-state-changed.js'; -import type { TurnState } from './state.js'; - -const NON_STEPABLE_STATES = new Set(['stopped', 'function_awaiting_approval']); - -export const StepableTurnStateWriteSchema = TurnStateWriteEventSchema.refine( - (data) => !NON_STEPABLE_STATES.has(data.new_value.state as TurnState), -).refine( - (data) => data.event_type !== 'state:updated' || data.old_value?.state !== data.new_value.state, -); - -export type StepableWrite = { session_id: string }; - -export function parseStepableWrite(event: unknown): StepableWrite | null { - const result = StepableTurnStateWriteSchema.safeParse(event); - if (!result.success) return null; - return { session_id: result.data.session_id }; -} - -export async function handleStepableRecordWrite(iii: ISdk, event: unknown): Promise { - const write = parseStepableWrite(event); - if (!write) return; - await iii.trigger({ - function_id: 'iii::durable::publish', - payload: { topic: 'turn::step_requested', data: { session_id: write.session_id } }, - }); -} - -export function register(iii: ISdk): void { - iii.registerFunction( - 'turn::is_stepable_record_write', - async (event: unknown) => parseStepableWrite(event) !== null, - { - description: - 'Condition: state event sets session//turn_state to a stepable state (excludes stopped + function_awaiting_approval).', - }, - ); - - iii.registerFunction( - 'turn::on_record_written', - async (event: unknown) => handleStepableRecordWrite(iii, event), - { - description: - 'State trigger adapter on scope=agent for stepable turn_state writes; publishes turn::step_requested.', - }, - ); - - iii.registerTrigger({ - type: 'state', - function_id: 'turn::on_record_written', - config: { - scope: 'agent', - condition_function_id: 'turn::is_stepable_record_write', - }, - }); -} diff --git a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts b/harness-node/src/turn-orchestrator/on-turn-state-changed.ts deleted file mode 100644 index ac4beeda..00000000 --- a/harness-node/src/turn-orchestrator/on-turn-state-changed.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * State-trigger adapter on `scope: 'agent'` for writes to - * `session//turn_state`. Emits `turn_state_changed` on `agent::events` - * so the UI can derive pending approvals from live state. - * - * **Incoming**: agent state write event from the iii engine (`event_type`, - * `scope`, `key`, `old_value`, `new_value`, `message_type`; key must match - * `session//turn_state`) - * **Outgoing**: void — side effect via `emit()`; swallow emit failures (log only) - */ - -import { z } from 'zod'; -import type { ISdk } from '../runtime/iii.js'; -import { logger } from '../runtime/otel.js'; -import { emit } from './events.js'; - -const TurnStateRecordValueSchema = z.object({ state: z.string() }).passthrough(); - -const AgentTurnStateWriteEventSchema = z.object({ - type: z.literal('state').optional(), - scope: z.literal('agent').optional(), - event_type: z.enum(['state:created', 'state:updated']), - key: z.string().regex(/^session\/[^/]+\/turn_state$/), - new_value: TurnStateRecordValueSchema, - old_value: TurnStateRecordValueSchema.nullish(), -}); - -export const TurnStateWriteEventSchema = AgentTurnStateWriteEventSchema.transform((data) => { - const session_id = data.key.slice('session/'.length, -'/turn_state'.length); - return { - session_id, - event_type: data.event_type, - new_value: data.new_value as Record, - ...(data.old_value != null && { old_value: data.old_value as Record }), - }; -}); - -type ParsedTurnStateWrite = z.infer; - -export function parseTurnStateWrite(event: unknown): ParsedTurnStateWrite | null { - const result = TurnStateWriteEventSchema.safeParse(event); - return result.success ? result.data : null; -} - -export async function handleTurnStateWrite(iii: ISdk, event: unknown): Promise { - const parsed = parseTurnStateWrite(event); - if (!parsed) return; - - try { - await emit(iii, parsed.session_id, { - type: 'turn_state_changed', - event_type: parsed.event_type, - new_value: parsed.new_value, - ...(parsed.old_value !== undefined && { old_value: parsed.old_value }), - }); - } catch (err) { - logger.warn('turn::on_turn_state_changed: emit failed', { - session_id: parsed.session_id, - err: String(err), - }); - } -} - -export function register(iii: ISdk): void { - iii.registerFunction( - 'turn::is_turn_state_write', - async (event: unknown) => parseTurnStateWrite(event) !== null, - { - description: 'Condition: state event is a write to session//turn_state.', - }, - ); - - iii.registerFunction( - 'turn::on_turn_state_changed', - async (event: unknown) => handleTurnStateWrite(iii, event), - { - description: - 'State trigger adapter on scope=agent for turn_state writes; emits turn_state_changed on agent::events for the subscribed UI.', - }, - ); - - iii.registerTrigger({ - type: 'state', - function_id: 'turn::on_turn_state_changed', - config: { - scope: 'agent', - condition_function_id: 'turn::is_turn_state_write', - }, - }); -} diff --git a/harness-node/src/turn-orchestrator/persistence.ts b/harness-node/src/turn-orchestrator/persistence.ts index 19a8bc4f..ef62efc7 100644 --- a/harness-node/src/turn-orchestrator/persistence.ts +++ b/harness-node/src/turn-orchestrator/persistence.ts @@ -7,6 +7,7 @@ import { logger } from '../runtime/otel.js'; import type { AgentMessage } from '../types/agent-message.js'; import type { FunctionCall, FunctionResult } from '../types/function.js'; import { + type TurnState, type TurnStateRecord, functionSchemasKey, lastSessionTreeLenKey, @@ -16,6 +17,8 @@ import { toolSchemasKey, turnStateKey, } from './state.js'; +import { emitTurnStateChanged } from './turn-state-write.js'; +import { shouldWakeStep, wakeState } from './wake.js'; const SCOPE = 'agent'; @@ -49,8 +52,30 @@ export async function loadRecord(iii: ISdk, session_id: string): Promise { +/** Persist turn_state and emit UI event — no FSM wake (mid-handler saves). */ +export async function persistRecord(iii: ISdk, rec: TurnStateRecord): Promise { + const previous = await loadRecord(iii, rec.session_id); + const eventType = previous === null ? 'state:created' : 'state:updated'; + await stateSet(iii, turnStateKey(rec.session_id), rec); + + await emitTurnStateChanged( + iii, + rec.session_id, + eventType, + rec as unknown as Record, + previous !== null ? (previous as unknown as Record) : undefined, + ); +} + +export async function saveRecord(iii: ISdk, rec: TurnStateRecord): Promise { + const previous = await loadRecord(iii, rec.session_id); + const previousState: TurnState | null = previous?.state ?? null; + await persistRecord(iii, rec); + + if (shouldWakeStep(previousState, rec.state)) { + await wakeState(iii, rec.session_id, rec.state); + } } export async function loadMessages(iii: ISdk, session_id: string): Promise { diff --git a/harness-node/src/turn-orchestrator/register.ts b/harness-node/src/turn-orchestrator/register.ts index f9c441cc..09ffa932 100644 --- a/harness-node/src/turn-orchestrator/register.ts +++ b/harness-node/src/turn-orchestrator/register.ts @@ -5,24 +5,33 @@ import * as bootstrap from './bootstrap.js'; import { loadOrchestratorConfig } from './config.js'; import { register as registerGetState } from './get-state.js'; import { register as registerOnAbortSignal } from './on-abort-signal.js'; -import { register as registerOnRecordWritten } from './on-record-written.js'; -import { register as registerOnTurnStateChanged } from './on-turn-state-changed.js'; import { register as registerRunStart } from './run-start.js'; import { recoverPendingApprovals } from './approval-resume.js'; -import { register as registerSubscriber } from './subscriber.js'; +import { + registerAssistantFinished, + registerAssistantStreaming, + registerFunctionAwaitingApproval, + registerFunctionExecute, + registerProvisioning, + registerSteeringCheck, + registerTearingDown, +} from './states/index.js'; export async function register(iii: ISdk, ctx: { configPath: string }): Promise { const cfg = await loadConfig(ctx.configPath); const orchestratorCfg = loadOrchestratorConfig(cfg); registerRunStart(iii); registerAgentTrigger(iii); - registerSubscriber(iii, orchestratorCfg); + registerProvisioning(iii, orchestratorCfg); + registerAssistantStreaming(iii); + registerAssistantFinished(iii); + registerFunctionExecute(iii); + registerFunctionAwaitingApproval(iii); + registerSteeringCheck(iii); + registerTearingDown(iii); await recoverPendingApprovals(iii); registerGetState(iii); registerOnAbortSignal(iii); - registerOnRecordWritten(iii); - registerOnTurnStateChanged(iii); - // Bootstrap best-effort skill download in the background. void bootstrap.run(iii, orchestratorCfg); } diff --git a/harness-node/src/turn-orchestrator/state.ts b/harness-node/src/turn-orchestrator/state.ts index 3db9b2bf..37358f21 100644 --- a/harness-node/src/turn-orchestrator/state.ts +++ b/harness-node/src/turn-orchestrator/state.ts @@ -8,13 +8,10 @@ import type { FunctionCall } from '../types/function.js'; export type TurnState = | 'provisioning' - | 'awaiting_assistant' | 'assistant_streaming' | 'assistant_finished' - | 'function_prepare' | 'function_execute' | 'function_awaiting_approval' - | 'function_finalize' | 'steering_check' | 'tearing_down' | 'stopped'; @@ -64,6 +61,10 @@ export function isTerminal(rec: TurnStateRecord): boolean { return rec.state === 'stopped'; } +export function turnFnId(state: TurnState): string { + return `turn::${state}`; +} + export const messagesKey = (sid: string) => `session/${sid}/messages`; export const turnStateKey = (sid: string) => `session/${sid}/turn_state`; export const runRequestKey = (sid: string) => `session/${sid}/run_request`; diff --git a/harness-node/src/turn-orchestrator/states/assistant-finished.ts b/harness-node/src/turn-orchestrator/states/assistant-finished.ts new file mode 100644 index 00000000..1fa328ca --- /dev/null +++ b/harness-node/src/turn-orchestrator/states/assistant-finished.ts @@ -0,0 +1,120 @@ +/** + * `turn::assistant_finished`. Persist assistant message and route to steering or function execute. + * + * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. + * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. + */ + +import type { ISdk } from '../../runtime/iii.js'; +import type { AgentEvent } from '../../types/agent-event.js'; +import type { AssistantMessage } from '../../types/agent-message.js'; +import type { FunctionCall } from '../../types/function.js'; +import { TOOL_NAME } from '../agent-trigger.js'; +import { emit } from '../events.js'; +import type { PreparedEntry } from '../persistence.js'; +import * as persistence from '../persistence.js'; +import { type TurnStateRecord, transitionTo } from '../state.js'; +import { + TurnStepPayloadSchema, + type TurnStepPayload, + type TurnStepResult, + staleSkipResult, +} from '../turn-step-payload.js'; + +function unwrapAgentTrigger(fc: FunctionCall): FunctionCall { + if (fc.function_id !== TOOL_NAME) return fc; + const args = (fc.arguments ?? {}) as Record; + const fn = typeof args.function === 'string' ? args.function : ''; + const payload = args.payload ?? {}; + return { id: fc.id, function_id: fn, arguments: payload }; +} + +function extractFunctionCalls(msg: AssistantMessage): FunctionCall[] { + const out: FunctionCall[] = []; + for (const b of msg.content) { + if (b.type === 'function_call') { + out.push({ id: b.id, function_id: b.function_id, arguments: b.arguments }); + } + } + return out; +} + +function assistantLifecycleEvents(asst: AssistantMessage): AgentEvent[] { + return [ + { type: 'message_start', message: asst }, + { type: 'message_end', message: asst }, + ]; +} + +export async function handleFinished(iii: ISdk, rec: TurnStateRecord): Promise { + const asst = rec.last_assistant; + if (!asst) { + throw new Error('assistant_finished without last_assistant'); + } + for (const evt of assistantLifecycleEvents(asst)) { + await emit(iii, rec.session_id, evt); + } + const isErrorOrAborted = asst.stop_reason === 'error' || asst.stop_reason === 'aborted'; + if (!isErrorOrAborted) { + const messages = await persistence.loadMessages(iii, rec.session_id); + messages.push(asst); + await persistence.saveMessages(iii, rec.session_id, messages); + } + + if (isErrorOrAborted) { + await emit(iii, rec.session_id, { + type: 'turn_end', + message: asst, + function_results: [], + }); + rec.turn_end_emitted = true; + transitionTo(rec, 'tearing_down'); + return; + } + const calls = extractFunctionCalls(asst); + if (calls.length === 0) { + transitionTo(rec, 'steering_check'); + return; + } + + rec.function_results = []; + rec.pending_function_calls = calls.map(unwrapAgentTrigger); + + const prepared: PreparedEntry[] = rec.pending_function_calls.map((fc) => ({ + function_call: fc, + blocked: null, + })); + + await persistence.saveExecutedCalls(iii, rec.session_id, []); + await persistence.savePreparedCalls(iii, rec.session_id, prepared); + transitionTo(rec, 'function_execute'); +} + +export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error(`turn::assistant_finished invariant: missing session ${payload.session_id}`); + } + const skipped = staleSkipResult('assistant_finished', rec); + if (skipped) return skipped; + + const from_state = rec.state; + try { + await handleFinished(iii, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); + } + await persistence.saveRecord(iii, rec); + return { ok: true, from_state, to_state: rec.state }; +} + +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::assistant_finished', + async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + { + description: + 'Run one durable FSM transition for session in state assistant_finished: finalize assistant and route onward.', + }, + ); +} diff --git a/harness-node/src/turn-orchestrator/states/assistant.ts b/harness-node/src/turn-orchestrator/states/assistant-streaming.ts similarity index 64% rename from harness-node/src/turn-orchestrator/states/assistant.ts rename to harness-node/src/turn-orchestrator/states/assistant-streaming.ts index d60e132e..29317c7c 100644 --- a/harness-node/src/turn-orchestrator/states/assistant.ts +++ b/harness-node/src/turn-orchestrator/states/assistant-streaming.ts @@ -1,56 +1,27 @@ /** - * `awaiting_assistant`, `assistant_streaming`, `assistant_finished`. Phase 2.A - * channel-based streaming lives in `handleStreaming`. + * `turn::assistant_streaming`. Start turn, stream provider response, advance to finished. + * + * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. + * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. */ import type { ISdk, StreamChannelRef } from '../../runtime/iii.js'; import { logger } from '../../runtime/otel.js'; -import type { AgentEvent } from '../../types/agent-event.js'; -import type { AgentMessage, AssistantMessage } from '../../types/agent-message.js'; -import type { ContentBlock } from '../../types/content.js'; -import type { AgentFunction, FunctionCall } from '../../types/function.js'; +import type { AssistantMessage } from '../../types/agent-message.js'; +import type { AgentFunction } from '../../types/function.js'; import type { ProviderStreamInput } from '../../types/provider.js'; -import type { AssistantMessageEvent, StopReason } from '../../types/stream-event.js'; +import type { AssistantMessageEvent } from '../../types/stream-event.js'; import { emit } from '../events.js'; import * as persistence from '../persistence.js'; -import { buildInput, decide, targetFunctionId } from '../provider-router.js'; import { runPreflight } from '../preflight.js'; +import { buildInput, decide, targetFunctionId } from '../provider-router.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; - -export async function handleAwaiting(iii: ISdk, rec: TurnStateRecord): Promise { - if (rec.max_turns !== undefined && rec.turn_count >= rec.max_turns) { - const cap = rec.max_turns ?? 0; - const exhausted: AssistantMessage = { - role: 'assistant', - content: [{ type: 'text', text: `loop stopped: max_turns (${cap}) reached` }], - stop_reason: 'end', - error_message: null, - error_kind: null, - usage: null, - model: '', - provider: '', - timestamp: Date.now(), - }; - await emit(iii, rec.session_id, { type: 'message_start', message: exhausted }); - await emit(iii, rec.session_id, { type: 'message_end', message: exhausted }); - await emit(iii, rec.session_id, { - type: 'turn_end', - message: exhausted, - function_results: [], - }); - rec.turn_end_emitted = true; - rec.last_assistant = exhausted; - const messages = await persistence.loadMessages(iii, rec.session_id); - messages.push(exhausted); - await persistence.saveMessages(iii, rec.session_id, messages); - transitionTo(rec, 'tearing_down'); - return; - } - rec.turn_count++; - rec.turn_end_emitted = false; - await emit(iii, rec.session_id, { type: 'turn_start' }); - transitionTo(rec, 'assistant_streaming'); -} +import { + TurnStepPayloadSchema, + type TurnStepPayload, + type TurnStepResult, + staleSkipResult, +} from '../turn-step-payload.js'; function eventPartial(ev: AssistantMessageEvent): AssistantMessage | null { if ('partial' in ev) return ev.partial; @@ -89,12 +60,6 @@ function syntheticErrorAssistant( }; } -/** - * Strip iii-sdk's `IIIInvocationError: invocation_failed: ` framing from - * a thrown trigger error so the user-visible message is just the - * underlying cause (e.g. "auth::get_token returned no credential for - * provider=openai"). - */ function formatProviderError(err: unknown): string { const raw = err instanceof Error ? err.message : String(err); return raw @@ -104,6 +69,38 @@ function formatProviderError(err: unknown): string { } export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise { + if (rec.max_turns !== undefined && rec.turn_count >= rec.max_turns) { + const cap = rec.max_turns ?? 0; + const exhausted: AssistantMessage = { + role: 'assistant', + content: [{ type: 'text', text: `loop stopped: max_turns (${cap}) reached` }], + stop_reason: 'end', + error_message: null, + error_kind: null, + usage: null, + model: '', + provider: '', + timestamp: Date.now(), + }; + await emit(iii, rec.session_id, { type: 'message_start', message: exhausted }); + await emit(iii, rec.session_id, { type: 'message_end', message: exhausted }); + await emit(iii, rec.session_id, { + type: 'turn_end', + message: exhausted, + function_results: [], + }); + rec.turn_end_emitted = true; + rec.last_assistant = exhausted; + const messages = await persistence.loadMessages(iii, rec.session_id); + messages.push(exhausted); + await persistence.saveMessages(iii, rec.session_id, messages); + transitionTo(rec, 'tearing_down'); + return; + } + rec.turn_count++; + rec.turn_end_emitted = false; + await emit(iii, rec.session_id, { type: 'turn_start' }); + const request = await persistence.loadRunRequest(iii, rec.session_id); let messages = await persistence.loadMessages(iii, rec.session_id); const schemas = await persistence.loadFunctionSchemas(iii, rec.session_id); @@ -117,12 +114,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< const decision = decide({ provider, model }); const targetFn = targetFunctionId(decision); - // Pre-flight: if projected token usage would overflow the model's context - // window, trigger compact_now synchronously before opening the provider - // channel. On compaction, reload messages so the provider sees the trimmed - // history. ContextOverflowError / CompactionBusyError propagate up and are - // handled as a transient error by the step loop (the session will retry on - // the next wake or surface the error to the caller). const preflightResult = await runPreflight( iii, rec.session_id, @@ -134,7 +125,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< messages = await persistence.loadMessages(iii, rec.session_id); } - // Open a channel; provider writes AssistantMessageEvent JSON into it. let channel: Awaited>; try { channel = await iii.createChannel(); @@ -162,9 +152,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< fn(); } }); - // iii-sdk@0.12.0's ChannelReader.onMessage doesn't open the read-side - // WebSocket — only stream.read / readAll do. Without this resume(), the - // provider's writes are dropped engine-side and the queue stays empty. channel.reader.stream.resume(); const input: ProviderStreamInput = buildInput( @@ -175,9 +162,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< tools, ); - // Capture the trigger error (if any) so the synthetic assistant message - // below carries the *actual* cause (e.g. "no credential for - // provider=openai") instead of a generic "channel closed without final". let triggerError: string | null = null; const triggerPromise = iii .trigger({ @@ -237,7 +221,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< } continue; } - // Terminal event: capture final message and break out. if (event.type === 'done') final = event.message; else final = event.error; done = true; @@ -255,11 +238,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< if (finalMsg) { rec.last_assistant = finalMsg; } else { - // Trigger failed or channel closed without a terminal frame. The - // provider didn't get to stream any text_delta events, so the UI - // never populated its renderer. Emit a synthetic message_update - // carrying the error as a text_delta so the existing UI translator - // (which assumes deltas drive the chat text) shows the error. const errorText = triggerError ?? 'provider channel closed without final'; const synthetic = syntheticErrorAssistant(decision.provider, decision.model, errorText); await emit(iii, rec.session_id, { @@ -272,59 +250,31 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< transitionTo(rec, 'assistant_finished'); } -function extractFunctionCalls(msg: AssistantMessage): FunctionCall[] { - const out: FunctionCall[] = []; - for (const b of msg.content) { - if (b.type === 'function_call') { - out.push({ id: b.id, function_id: b.function_id, arguments: b.arguments }); - } +export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error(`turn::assistant_streaming invariant: missing session ${payload.session_id}`); } - return out; -} - -export function assistantLifecycleEvents(asst: AssistantMessage): AgentEvent[] { - return [ - { type: 'message_start', message: asst }, - { type: 'message_end', message: asst }, - ]; -} + const skipped = staleSkipResult('assistant_streaming', rec); + if (skipped) return skipped; -export async function handleFinished(iii: ISdk, rec: TurnStateRecord): Promise { - const asst = rec.last_assistant; - if (!asst) { - throw new Error('assistant_finished without last_assistant'); - } - for (const evt of assistantLifecycleEvents(asst)) { - await emit(iii, rec.session_id, evt); - } - const isErrorOrAborted = asst.stop_reason === 'error' || asst.stop_reason === 'aborted'; - // Error/aborted assistant messages (e.g. provider auth failures, - // network blips, user aborts) are surfaced to the UI via the - // MessageStart/MessageEnd events emitted above, but we deliberately - // keep them out of the session's persisted message history so the - // LLM's next-turn context doesn't accumulate transient infra noise. - if (!isErrorOrAborted) { - const messages = await persistence.loadMessages(iii, rec.session_id); - messages.push(asst); - await persistence.saveMessages(iii, rec.session_id, messages); + const from_state = rec.state; + try { + await handleStreaming(iii, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); } + await persistence.saveRecord(iii, rec); + return { ok: true, from_state, to_state: rec.state }; +} - if (isErrorOrAborted) { - await emit(iii, rec.session_id, { - type: 'turn_end', - message: asst, - function_results: [], - }); - rec.turn_end_emitted = true; - transitionTo(rec, 'tearing_down'); - return; - } - const calls = extractFunctionCalls(asst); - if (calls.length === 0) { - transitionTo(rec, 'steering_check'); - } else { - rec.pending_function_calls = calls; - transitionTo(rec, 'function_prepare'); - } +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::assistant_streaming', + async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + { + description: + 'Run one durable FSM transition for session in state assistant_streaming: start turn and stream provider response.', + }, + ); } -// reload 1779112003 diff --git a/harness-node/src/turn-orchestrator/states/function-awaiting-approval.ts b/harness-node/src/turn-orchestrator/states/function-awaiting-approval.ts new file mode 100644 index 00000000..fdf64095 --- /dev/null +++ b/harness-node/src/turn-orchestrator/states/function-awaiting-approval.ts @@ -0,0 +1,133 @@ +/** + * `turn::function_awaiting_approval`. Read approval decisions and resume execute. + * + * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. + * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. + */ + +import { ApprovalResumePayloadSchema, STATE_SCOPE } from '../../approval-gate/schemas.js'; +import type { z } from 'zod'; +import type { ISdk } from '../../runtime/iii.js'; +import type { FunctionResult } from '../../types/function.js'; +import { text } from '../../types/content.js'; +import * as persistence from '../persistence.js'; +import { type TurnStateRecord, transitionTo } from '../state.js'; +import { + TurnStepPayloadSchema, + type TurnStepPayload, + type TurnStepResult, + staleSkipResult, +} from '../turn-step-payload.js'; + +export type ApprovalDecision = z.infer; + +/** Decode stored approval decision from `state::get` (scope `approvals`). */ +export function parseApprovalDecision(value: unknown): ApprovalDecision | null { + const parsed = ApprovalResumePayloadSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +async function readDecision( + iii: ISdk, + session_id: string, + function_call_id: string, +): Promise { + const key = `${session_id}/${function_call_id}`; + const raw = await iii.trigger({ + function_id: 'state::get', + payload: { scope: STATE_SCOPE, key }, + }); + return parseApprovalDecision(raw); +} + +function denialResultFromDecision(decision: ApprovalDecision): FunctionResult { + const reason = + decision.reason ?? (decision.decision === 'aborted' ? 'session_aborted' : 'denied'); + const message = + decision.decision === 'aborted' + ? `Function call aborted: ${reason}` + : `Permission denied by user: ${reason}`; + return { + content: [text(message)], + details: { + approval_denied: true, + decision: decision.decision, + reason, + }, + terminate: false, + }; +} + +export async function handleAwaitingApproval(iii: ISdk, rec: TurnStateRecord): Promise { + const awaiting = rec.awaiting_approval ?? []; + if (awaiting.length === 0) { + transitionTo(rec, 'function_execute'); + return; + } + + const decisions = await Promise.all( + awaiting.map((entry) => readDecision(iii, rec.session_id, entry.function_call_id)), + ); + + if (decisions.some((decision) => decision === null)) { + return; + } + + const prepared = await persistence.loadPreparedCalls(iii, rec.session_id); + for (let i = 0; i < awaiting.length; i++) { + const entry = awaiting[i]; + const decision = decisions[i]; + if (!entry || !decision) continue; + const idx = prepared.findIndex( + (preparedEntry) => preparedEntry.function_call.id === entry.function_call_id, + ); + if (idx < 0) continue; + const current = prepared[idx]; + if (!current) continue; + if (decision.decision === 'allow') { + prepared[idx] = { ...current, pre_approved: true, blocked: null }; + } else { + prepared[idx] = { + ...current, + pre_approved: false, + blocked: denialResultFromDecision(decision), + }; + } + } + + await persistence.savePreparedCalls(iii, rec.session_id, prepared); + + rec.awaiting_approval = []; + transitionTo(rec, 'function_execute'); +} + +export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error( + `turn::function_awaiting_approval invariant: missing session ${payload.session_id}`, + ); + } + const skipped = staleSkipResult('function_awaiting_approval', rec); + if (skipped) return skipped; + + const from_state = rec.state; + try { + await handleAwaitingApproval(iii, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); + } + await persistence.saveRecord(iii, rec); + return { ok: true, from_state, to_state: rec.state }; +} + +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::function_awaiting_approval', + async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + { + description: + 'Run one durable FSM transition for session in state function_awaiting_approval: read approval decisions and resume.', + }, + ); +} diff --git a/harness-node/src/turn-orchestrator/states/functions.ts b/harness-node/src/turn-orchestrator/states/function-execute.ts similarity index 60% rename from harness-node/src/turn-orchestrator/states/functions.ts rename to harness-node/src/turn-orchestrator/states/function-execute.ts index fdd1f2e6..44d0e1d7 100644 --- a/harness-node/src/turn-orchestrator/states/functions.ts +++ b/harness-node/src/turn-orchestrator/states/function-execute.ts @@ -1,9 +1,10 @@ /** - * `function_prepare`, `function_execute`, `function_finalize`. Mirrors - * `turn-orchestrator/src/states/functions.rs`. + * `turn::function_execute`. Run prepared function calls, finalize results, route onward. + * + * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. + * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. */ -import { STATE_SCOPE } from '../../approval-gate/schemas.js'; import type { ISdk } from '../../runtime/iii.js'; import type { AgentEvent } from '../../types/agent-event.js'; import type { @@ -11,52 +12,134 @@ import type { AssistantMessage, FunctionResultMessage, } from '../../types/agent-message.js'; -import { text } from '../../types/content.js'; import type { FunctionCall, FunctionResult } from '../../types/function.js'; -import { TOOL_NAME, dispatchWithHook, isErrorResult } from '../agent-trigger.js'; +import { text } from '../../types/content.js'; +import { dispatchWithHook, isErrorResult } from '../agent-trigger.js'; import { registerApprovalResume } from '../approval-resume.js'; -import type { TurnOrchestratorConfig } from '../config.js'; import { emit } from '../events.js'; import { publishAfter } from '../hook.js'; -import type { PreparedEntry } from '../persistence.js'; import * as persistence from '../persistence.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; +import { + TurnStepPayloadSchema, + type TurnStepPayload, + type TurnStepResult, + staleSkipResult, +} from '../turn-step-payload.js'; -type ApprovalDecisionRecord = { - decision: 'allow' | 'deny' | 'aborted'; - reason: string | null; -}; +function triggerErrorResult(function_id: string, err: unknown): FunctionResult { + const message = + err && typeof err === 'object' && typeof (err as Record).message === 'string' + ? ((err as Record).message as string) + : String(err); + const details = { + error: 'trigger_failed', + function: function_id, + message, + }; + return { + content: [text(JSON.stringify(details))], + details, + terminate: false, + }; +} -function unwrapAgentTrigger(fc: FunctionCall): FunctionCall { - if (fc.function_id !== TOOL_NAME) return fc; - const args = (fc.arguments ?? {}) as Record; - const fn = typeof args.function === 'string' ? args.function : ''; - const payload = args.payload ?? {}; - return { id: fc.id, function_id: fn, arguments: payload }; +function decodeOrPassthroughResult(value: unknown): FunctionResult { + if ( + value && + typeof value === 'object' && + Array.isArray((value as Record).content) + ) { + const obj = value as Record; + return { + content: obj.content as FunctionResult['content'], + details: obj.details ?? {}, + terminate: typeof obj.terminate === 'boolean' ? obj.terminate : false, + }; + } + const textBody = typeof value === 'string' ? value : JSON.stringify(value); + return { + content: [text(textBody)], + details: value, + terminate: false, + }; } -export async function handlePrepare(iii: ISdk, rec: TurnStateRecord): Promise { - rec.function_results = []; - const raw = rec.pending_function_calls; - rec.pending_function_calls = raw.map(unwrapAgentTrigger); +function buildFunctionExecutionEnd( + fc: FunctionCall, + result: FunctionResult, + is_error: boolean, + duration_ms: number, +): AgentEvent { + return { + type: 'function_execution_end', + function_call_id: fc.id, + function_id: fc.function_id, + result, + is_error, + duration_ms, + }; +} - const prepared: PreparedEntry[] = rec.pending_function_calls.map((fc) => ({ - function_call: fc, - blocked: null, - })); +function buildFinalizeLifecycle( + asst: AssistantMessage, + results: FunctionResultMessage[], +): AgentEvent[] { + const out: AgentEvent[] = []; + for (const r of results) { + out.push({ type: 'message_start', message: r }); + out.push({ type: 'message_end', message: r }); + } + out.push({ type: 'turn_end', message: asst, function_results: results }); + return out; +} - await persistence.saveRecord(iii, rec); - await persistence.saveExecutedCalls(iii, rec.session_id, []); - await persistence.savePreparedCalls(iii, rec.session_id, prepared); +async function finalizeExecutedCalls(iii: ISdk, rec: TurnStateRecord): Promise { + const executed = await persistence.loadExecutedCalls(iii, rec.session_id); + const function_results: FunctionResultMessage[] = []; + let all_terminate = executed.length > 0; + for (const e of executed) { + let result = e.result; + const merged = await publishAfter(iii, e.function_call, result); + if ( + merged && + typeof merged === 'object' && + Array.isArray((merged as Record).content) + ) { + result = merged as FunctionResult; + } + if (!result.terminate) all_terminate = false; + function_results.push({ + role: 'function_result', + function_call_id: e.function_call.id, + function_id: e.function_call.function_id, + content: result.content, + details: result.details, + is_error: e.is_error, + timestamp: Date.now(), + }); + } + const messages = await persistence.loadMessages(iii, rec.session_id); + for (const r of function_results) messages.push(r as AgentMessage); + await persistence.saveMessages(iii, rec.session_id, messages); - transitionTo(rec, 'function_execute'); + const asst = rec.last_assistant; + if (!asst) { + rec.function_results = function_results; + rec.pending_function_calls = []; + transitionTo(rec, all_terminate ? 'tearing_down' : 'steering_check'); + return; + } + for (const evt of buildFinalizeLifecycle(asst, function_results)) { + await emit(iii, rec.session_id, evt); + } + rec.turn_end_emitted = true; + rec.function_results = function_results; + rec.pending_function_calls = []; + transitionTo(rec, all_terminate ? 'tearing_down' : 'steering_check'); } -export async function handleExecute( - iii: ISdk, - cfg: TurnOrchestratorConfig, - rec: TurnStateRecord, -): Promise { +export async function handleExecute(iii: ISdk, rec: TurnStateRecord): Promise { const prepared = await persistence.loadPreparedCalls(iii, rec.session_id); const results = await persistence.loadExecutedCalls(iii, rec.session_id); @@ -68,15 +151,10 @@ export async function handleExecute( function_id: fc.function_id, args: fc.arguments, }); - /* `startedAt` is captured right after the start emit so the measured - window matches what the consumer sees on the wire. Each non-replay - branch computes its own delta; `existing` reuses the persisted one. */ const startedAt = Date.now(); const existing = persistence.findExecutedCall(results, fc.id); if (existing) { - /* Replay: reuse the persisted duration so a resumed run shows the - original timing, not the ~0ms it takes to re-emit. */ await emit( iii, rec.session_id, @@ -117,8 +195,6 @@ export async function handleExecute( if (entry.blocked) { const result = entry.blocked; const is_error = true; - /* Denial is effectively instant — local delta captures whatever - time the persist + emit roundtrip takes, which is honest. */ const duration_ms = Date.now() - startedAt; persistence.upsertExecutedCall(results, { function_call: fc, @@ -131,7 +207,6 @@ export async function handleExecute( continue; } - // Augment the per-call args with session/fc context — same as Rust. let augmented_args: unknown; if (fc.arguments && typeof fc.arguments === 'object' && !Array.isArray(fc.arguments)) { augmented_args = { ...(fc.arguments as Record) }; @@ -157,9 +232,6 @@ export async function handleExecute( const out = await dispatchWithHook(iii, augmentedFc, rec.session_id); if (out.kind === 'pending') { - /* No end emit; `startedAt` is discarded. On resume, the loop re-enters - and a fresh `function_execution_start` resets the timer — approval - wait time is naturally excluded from the eventual duration. */ rec.awaiting_approval = rec.awaiting_approval ?? []; rec.awaiting_approval.push({ function_call_id: fc.id, @@ -182,205 +254,38 @@ export async function handleExecute( duration_ms, }); - // Kick off persistence in parallel with the user-facing emit so the UI's - // fcall-end lands ~one trigger round-trip sooner. We still await both - // before the next iteration so ordering and durability are preserved. const savePromise = persistence.saveExecutedCalls(iii, rec.session_id, results); await emit(iii, rec.session_id, buildFunctionExecutionEnd(fc, result, is_error, duration_ms)); await savePromise; } - transitionTo(rec, 'function_finalize'); -} - -function triggerErrorResult(function_id: string, err: unknown): FunctionResult { - const message = - err && typeof err === 'object' && typeof (err as Record).message === 'string' - ? ((err as Record).message as string) - : String(err); - const details = { - error: 'trigger_failed', - function: function_id, - message, - }; - return { - content: [text(JSON.stringify(details))], - details, - terminate: false, - }; + await finalizeExecutedCalls(iii, rec); } -function decodeOrPassthroughResult(value: unknown): FunctionResult { - if ( - value && - typeof value === 'object' && - Array.isArray((value as Record).content) - ) { - const obj = value as Record; - return { - content: obj.content as FunctionResult['content'], - details: obj.details ?? {}, - terminate: typeof obj.terminate === 'boolean' ? obj.terminate : false, - }; +export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error(`turn::function_execute invariant: missing session ${payload.session_id}`); } - const textBody = typeof value === 'string' ? value : JSON.stringify(value); - return { - content: [text(textBody)], - details: value, - terminate: false, - }; -} - -async function readDecision( - iii: ISdk, - session_id: string, - function_call_id: string, -): Promise { - const key = `${session_id}/${function_call_id}`; - const raw = await iii.trigger({ - function_id: 'state::get', - payload: { scope: STATE_SCOPE, key }, - }); - if (!raw || typeof raw !== 'object') return null; - const obj = raw as Record; - const decision = obj.decision; - if (decision !== 'allow' && decision !== 'deny' && decision !== 'aborted') return null; - return { - decision, - reason: typeof obj.reason === 'string' ? obj.reason : null, - }; + const skipped = staleSkipResult('function_execute', rec); + if (skipped) return skipped; + + const from_state = rec.state; + try { + await handleExecute(iii, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); + } + await persistence.saveRecord(iii, rec); + return { ok: true, from_state, to_state: rec.state }; } -function denialResultFromDecision(decision: ApprovalDecisionRecord): FunctionResult { - const reason = - decision.reason ?? (decision.decision === 'aborted' ? 'session_aborted' : 'denied'); - const message = - decision.decision === 'aborted' - ? `Function call aborted: ${reason}` - : `Permission denied by user: ${reason}`; - return { - content: [text(message)], - details: { - approval_denied: true, - decision: decision.decision, - reason, +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::function_execute', + async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + { + description: + 'Run one durable FSM transition for session in state function_execute: dispatch prepared calls and finalize results.', }, - terminate: false, - }; -} - -export async function handleAwaitingApproval(iii: ISdk, rec: TurnStateRecord): Promise { - const awaiting = rec.awaiting_approval ?? []; - if (awaiting.length === 0) { - transitionTo(rec, 'function_execute'); - return; - } - - const decisions = await Promise.all( - awaiting.map((entry) => readDecision(iii, rec.session_id, entry.function_call_id)), ); - - if (decisions.some((decision) => decision === null)) { - return; - } - - const prepared = await persistence.loadPreparedCalls(iii, rec.session_id); - for (let i = 0; i < awaiting.length; i++) { - const entry = awaiting[i]; - const decision = decisions[i]; - if (!entry || !decision) continue; - const idx = prepared.findIndex( - (preparedEntry) => preparedEntry.function_call.id === entry.function_call_id, - ); - if (idx < 0) continue; - const current = prepared[idx]; - if (!current) continue; - if (decision.decision === 'allow') { - prepared[idx] = { ...current, pre_approved: true, blocked: null }; - } else { - prepared[idx] = { - ...current, - pre_approved: false, - blocked: denialResultFromDecision(decision), - }; - } - } - - await persistence.savePreparedCalls(iii, rec.session_id, prepared); - - rec.awaiting_approval = []; - transitionTo(rec, 'function_execute'); -} - -function buildFunctionExecutionEnd( - fc: FunctionCall, - result: FunctionResult, - is_error: boolean, - duration_ms: number, -): AgentEvent { - return { - type: 'function_execution_end', - function_call_id: fc.id, - function_id: fc.function_id, - result, - is_error, - duration_ms, - }; -} - -function buildFinalizeLifecycle( - asst: AssistantMessage, - results: FunctionResultMessage[], -): AgentEvent[] { - const out: AgentEvent[] = []; - for (const r of results) { - out.push({ type: 'message_start', message: r }); - out.push({ type: 'message_end', message: r }); - } - out.push({ type: 'turn_end', message: asst, function_results: results }); - return out; -} - -export async function handleFinalize(iii: ISdk, rec: TurnStateRecord): Promise { - const executed = await persistence.loadExecutedCalls(iii, rec.session_id); - const function_results: FunctionResultMessage[] = []; - let all_terminate = executed.length > 0; - for (const e of executed) { - let result = e.result; - const merged = await publishAfter(iii, e.function_call, result); - if ( - merged && - typeof merged === 'object' && - Array.isArray((merged as Record).content) - ) { - result = merged as FunctionResult; - } - if (!result.terminate) all_terminate = false; - function_results.push({ - role: 'function_result', - function_call_id: e.function_call.id, - function_id: e.function_call.function_id, - content: result.content, - details: result.details, - is_error: e.is_error, - timestamp: Date.now(), - }); - } - const messages = await persistence.loadMessages(iii, rec.session_id); - for (const r of function_results) messages.push(r as AgentMessage); - await persistence.saveMessages(iii, rec.session_id, messages); - - const asst = rec.last_assistant; - if (!asst) { - rec.function_results = function_results; - rec.pending_function_calls = []; - transitionTo(rec, all_terminate ? 'tearing_down' : 'steering_check'); - return; - } - for (const evt of buildFinalizeLifecycle(asst, function_results)) { - await emit(iii, rec.session_id, evt); - } - rec.turn_end_emitted = true; - rec.function_results = function_results; - rec.pending_function_calls = []; - transitionTo(rec, all_terminate ? 'tearing_down' : 'steering_check'); } diff --git a/harness-node/src/turn-orchestrator/states/index.ts b/harness-node/src/turn-orchestrator/states/index.ts new file mode 100644 index 00000000..e7865709 --- /dev/null +++ b/harness-node/src/turn-orchestrator/states/index.ts @@ -0,0 +1,11 @@ +/** + * Re-export per-state register functions. Each `turn::{state}` lives in its own file. + */ + +export { register as registerProvisioning } from './provisioning.js'; +export { register as registerAssistantStreaming } from './assistant-streaming.js'; +export { register as registerAssistantFinished } from './assistant-finished.js'; +export { register as registerFunctionExecute } from './function-execute.js'; +export { register as registerFunctionAwaitingApproval } from './function-awaiting-approval.js'; +export { register as registerSteeringCheck } from './steering-check.js'; +export { register as registerTearingDown } from './tearing-down.js'; diff --git a/harness-node/src/turn-orchestrator/states/provisioning.ts b/harness-node/src/turn-orchestrator/states/provisioning.ts index 11115418..e756ce97 100644 --- a/harness-node/src/turn-orchestrator/states/provisioning.ts +++ b/harness-node/src/turn-orchestrator/states/provisioning.ts @@ -1,12 +1,9 @@ /** - * `provisioning`. First FSM step after `run::start`: materialize tool schemas, + * `turn::provisioning`. First FSM step after `run::start`: materialize tool schemas, * assemble the system prompt, persist the enriched run request, then advance. * - * **Incoming**: provisioning `TurnStateRecord` (`rec.session_id`) plus persisted - * run request from `persistence.loadRunRequest` (written by `run::start`). - * **Outgoing**: `transitionTo(rec, 'awaiting_assistant')`; side effects: - * `saveFunctionSchemas` (agent_trigger), `saveRunRequest` (built prompt), - * directory skill fetches for default skills + index. + * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. + * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. */ import type { ISdk } from '../../runtime/iii.js'; @@ -15,6 +12,12 @@ import { agentTriggerTool } from '../agent-trigger.js'; import type { TurnOrchestratorConfig } from '../config.js'; import * as persistence from '../persistence.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; +import { + TurnStepPayloadSchema, + type TurnStepPayload, + type TurnStepResult, + staleSkipResult, +} from '../turn-step-payload.js'; import { type DefaultSkillBody, type Mode, @@ -103,7 +106,6 @@ export async function handleProvisioning( ): Promise { const request = parseRunRequest(await persistence.loadRunRequest(iii, rec.session_id)); - // The single tool LLMs see is `agent_trigger`. await persistence.saveFunctionSchemas(iii, rec.session_id, [agentTriggerTool()]); const override = request.system_prompt.length > 0 ? request.system_prompt : null; @@ -117,5 +119,38 @@ export async function handleProvisioning( const updated: RunRequest = { ...request, system_prompt: prompt }; await persistence.saveRunRequest(iii, rec.session_id, updated); - transitionTo(rec, 'awaiting_assistant'); + transitionTo(rec, 'assistant_streaming'); +} + +export async function execute( + iii: ISdk, + cfg: TurnOrchestratorConfig, + payload: TurnStepPayload, +): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error(`turn::provisioning invariant: missing session ${payload.session_id}`); + } + const skipped = staleSkipResult('provisioning', rec); + if (skipped) return skipped; + + const from_state = rec.state; + try { + await handleProvisioning(iii, cfg, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); + } + await persistence.saveRecord(iii, rec); + return { ok: true, from_state, to_state: rec.state }; +} + +export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { + iii.registerFunction( + 'turn::provisioning', + async (payload: unknown) => execute(iii, cfg, TurnStepPayloadSchema.parse(payload)), + { + description: + 'Run one durable FSM transition for session in state provisioning: materialize tool schemas, build system prompt, advance to assistant_streaming.', + }, + ); } diff --git a/harness-node/src/turn-orchestrator/states/steering.ts b/harness-node/src/turn-orchestrator/states/steering-check.ts similarity index 69% rename from harness-node/src/turn-orchestrator/states/steering.ts rename to harness-node/src/turn-orchestrator/states/steering-check.ts index f59b3208..f2ed953a 100644 --- a/harness-node/src/turn-orchestrator/states/steering.ts +++ b/harness-node/src/turn-orchestrator/states/steering-check.ts @@ -1,7 +1,8 @@ /** - * `steering_check`. Drains the steering / followup inbox queues and the - * abort flag, then routes onward. Mirrors - * `turn-orchestrator/src/states/steering.rs`. + * `turn::steering_check`. Drains steering / followup inboxes and the abort flag, then routes onward. + * + * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. + * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. */ import type { ISdk } from '../../runtime/iii.js'; @@ -9,6 +10,12 @@ import type { AgentMessage, AssistantMessage } from '../../types/agent-message.j import { emit } from '../events.js'; import * as persistence from '../persistence.js'; import { type TurnStateRecord, abortSignalKey, transitionTo } from '../state.js'; +import { + TurnStepPayloadSchema, + type TurnStepPayload, + type TurnStepResult, + staleSkipResult, +} from '../turn-step-payload.js'; export type SteeringRoute = | 'abort' @@ -17,6 +24,7 @@ export type SteeringRoute = | 'continue_after_function' | 'end_turn'; +/** Pure priority router — no I/O. */ export function route( abort: boolean, has_steering: boolean, @@ -122,28 +130,20 @@ export async function handleSteering(iii: ISdk, rec: TurnStateRecord): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error(`turn::steering_check invariant: missing session ${payload.session_id}`); + } + const skipped = staleSkipResult('steering_check', rec); + if (skipped) return skipped; + + const from_state = rec.state; + try { + await handleSteering(iii, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); + } + await persistence.saveRecord(iii, rec); + return { ok: true, from_state, to_state: rec.state }; +} + +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::steering_check', + async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + { + description: + 'Run one durable FSM transition for session in state steering_check: drain inboxes and route onward.', + }, + ); +} diff --git a/harness-node/src/turn-orchestrator/states/tearing-down.ts b/harness-node/src/turn-orchestrator/states/tearing-down.ts index cb509582..4381fed8 100644 --- a/harness-node/src/turn-orchestrator/states/tearing-down.ts +++ b/harness-node/src/turn-orchestrator/states/tearing-down.ts @@ -1,11 +1,8 @@ /** - * `tearing_down` — stop sandbox, emit `agent_end`, transition to `stopped`. + * `turn::tearing_down`. Stop sandbox, emit `agent_end`, transition to `stopped`. * - * **Incoming**: `TurnStateRecord` with `state: 'tearing_down'` from `step()` - * when the FSM enters teardown (abort, max turns, normal end-turn, etc.). - * **Outgoing**: mutates `rec` via `transitionTo(rec, 'stopped')`; emits - * `agent_end` with session messages; calls `sandbox::stop` when a sandbox id - * exists (best-effort, logs on failure). + * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. + * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. */ import type { ISdk } from '../../runtime/iii.js'; @@ -14,6 +11,12 @@ import type { AgentMessage } from '../../types/agent-message.js'; import { emit } from '../events.js'; import * as persistence from '../persistence.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; +import { + TurnStepPayloadSchema, + type TurnStepPayload, + type TurnStepResult, + staleSkipResult, +} from '../turn-step-payload.js'; type SandboxStopPayload = { sandbox_id: string; wait: true }; @@ -37,3 +40,32 @@ export async function handleTearingDown(iii: ISdk, rec: TurnStateRecord): Promis await emit(iii, rec.session_id, { type: 'agent_end', messages }); transitionTo(rec, 'stopped'); } + +export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error(`turn::tearing_down invariant: missing session ${payload.session_id}`); + } + const skipped = staleSkipResult('tearing_down', rec); + if (skipped) return skipped; + + const from_state = rec.state; + try { + await handleTearingDown(iii, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); + } + await persistence.saveRecord(iii, rec); + return { ok: true, from_state, to_state: rec.state }; +} + +export function register(iii: ISdk): void { + iii.registerFunction( + 'turn::tearing_down', + async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + { + description: + 'Run one durable FSM transition for session in state tearing_down: stop sandbox and mark stopped.', + }, + ); +} diff --git a/harness-node/src/turn-orchestrator/subscriber.ts b/harness-node/src/turn-orchestrator/subscriber.ts deleted file mode 100644 index f7793414..00000000 --- a/harness-node/src/turn-orchestrator/subscriber.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * `turn::step` — one FSM transition for a session. - * - * **Incoming**: flat `{ session_id }` from durable subscriber (`turn::step_requested`), - * direct `iii.trigger('turn::step', …)`, and integration tests — same shape. - * Producers publish via `iii::durable::publish` with `{ topic, data: { session_id } }`; - * the engine enqueues `data` only. - * - * **Outgoing**: `StepResult` with pre/post `TurnState`; throws on missing session - * (invariant) or transition failure. `turn::should_step` soft-filters unknown/terminal - * sessions before the durable subscriber invokes `turn::step`. - */ - -import type { StateGetInput } from 'iii-sdk/state'; -import { z } from 'zod'; -import type { ISdk } from '../runtime/iii.js'; -import { logger } from '../runtime/otel.js'; -import type { TurnOrchestratorConfig } from './config.js'; -import * as persistence from './persistence.js'; -import { isTerminal, turnStateKey, type TurnState, type TurnStateRecord } from './state.js'; -import { step } from './transitions.js'; - -export const StepPayloadSchema = z.object({ - session_id: z.string().min(1), -}); - -export type StepPayload = z.infer; - -export type StepResult = { ok: true; from_state: TurnState; to_state: TurnState }; - -export async function shouldStep(iii: ISdk, payload: unknown): Promise { - const parsed = StepPayloadSchema.safeParse(payload); - if (!parsed.success) return false; - const rec = await persistence.loadRecord(iii, parsed.data.session_id); - if (!rec) { - logger.warn('turn::step for unknown session', { session_id: parsed.data.session_id }); - return false; - } - return !isTerminal(rec); -} - -export async function execute( - iii: ISdk, - cfg: TurnOrchestratorConfig, - payload: StepPayload, -): Promise { - const { session_id } = payload; - const rec = await iii.trigger({ - function_id: 'state::get', - payload: { scope: 'agent', key: turnStateKey(session_id) }, - }); - const from_state = rec.state; - try { - await step(iii, cfg, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - -export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { - iii.registerFunction('turn::should_step', async (payload: unknown) => shouldStep(iii, payload), { - description: - 'Condition: durable turn::step_requested payload has a known, non-terminal session.', - }); - - iii.registerFunction( - 'turn::step', - async (payload: unknown) => execute(iii, cfg, StepPayloadSchema.parse(payload)), - { - description: 'Run one durable state machine transition for a session.', - }, - ); - iii.registerTrigger({ - type: 'durable:subscriber', - function_id: 'turn::step', - config: { - topic: 'turn::step_requested', - condition_function_id: 'turn::should_step', - }, - }); -} diff --git a/harness-node/src/turn-orchestrator/transitions.ts b/harness-node/src/turn-orchestrator/transitions.ts deleted file mode 100644 index 6ccc07d0..00000000 --- a/harness-node/src/turn-orchestrator/transitions.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ISdk } from '../runtime/iii.js'; -import type { TurnOrchestratorConfig } from './config.js'; -import type { TurnStateRecord } from './state.js'; -import { handleAwaiting, handleFinished, handleStreaming } from './states/assistant.js'; -import { - handleAwaitingApproval, - handleExecute, - handleFinalize, - handlePrepare, -} from './states/functions.js'; -import { handleProvisioning } from './states/provisioning.js'; -import { handleSteering } from './states/steering.js'; -import { handleTearingDown } from './states/tearing-down.js'; - -export async function step( - iii: ISdk, - cfg: TurnOrchestratorConfig, - rec: TurnStateRecord, -): Promise { - switch (rec.state) { - case 'provisioning': - return handleProvisioning(iii, cfg, rec); - case 'awaiting_assistant': - return handleAwaiting(iii, rec); - case 'assistant_streaming': - return handleStreaming(iii, rec); - case 'assistant_finished': - return handleFinished(iii, rec); - case 'function_prepare': - return handlePrepare(iii, rec); - case 'function_execute': - return handleExecute(iii, cfg, rec); - case 'function_awaiting_approval': - return handleAwaitingApproval(iii, rec); - case 'function_finalize': - return handleFinalize(iii, rec); - case 'steering_check': - return handleSteering(iii, rec); - case 'tearing_down': - return handleTearingDown(iii, rec); - case 'stopped': - return; // idempotent terminal - } -} diff --git a/harness-node/src/turn-orchestrator/turn-state-write.ts b/harness-node/src/turn-orchestrator/turn-state-write.ts new file mode 100644 index 00000000..296f4a7e --- /dev/null +++ b/harness-node/src/turn-orchestrator/turn-state-write.ts @@ -0,0 +1,30 @@ +/** + * UI notification when agent-scope turn_state is persisted via `saveRecord` / + * `persistRecord`. + */ + +import type { ISdk } from '../runtime/iii.js'; +import { logger } from '../runtime/otel.js'; +import { emit } from './events.js'; + +export async function emitTurnStateChanged( + iii: ISdk, + session_id: string, + event_type: 'state:created' | 'state:updated', + new_value: Record, + old_value?: Record, +): Promise { + try { + await emit(iii, session_id, { + type: 'turn_state_changed', + event_type, + new_value, + ...(old_value !== undefined && { old_value }), + }); + } catch (err) { + logger.warn('emitTurnStateChanged failed', { + session_id, + err: String(err), + }); + } +} diff --git a/harness-node/src/turn-orchestrator/turn-step-payload.ts b/harness-node/src/turn-orchestrator/turn-step-payload.ts new file mode 100644 index 00000000..90f8882c --- /dev/null +++ b/harness-node/src/turn-orchestrator/turn-step-payload.ts @@ -0,0 +1,31 @@ +/** + * Shared payload and result types for `turn::{state}` iii functions. + */ + +import { z } from 'zod'; +import { logger } from '../runtime/otel.js'; +import type { TurnState, TurnStateRecord } from './state.js'; + +export const TurnStepPayloadSchema = z.object({ + session_id: z.string().min(1), +}); + +export type TurnStepPayload = z.infer; + +export type TurnStepResult = + | { ok: true; from_state: TurnState; to_state: TurnState } + | { ok: true; skipped: true; reason: 'stale' }; + +/** Returns a stale skip result when the queue message no longer matches persisted state. */ +export function staleSkipResult( + expectedState: TurnState, + rec: TurnStateRecord, +): TurnStepResult | null { + if (rec.state === expectedState) return null; + logger.warn(`turn::${expectedState} skipped: stale queue message`, { + session_id: rec.session_id, + expected: expectedState, + actual: rec.state, + }); + return { ok: true, skipped: true, reason: 'stale' }; +} diff --git a/harness-node/src/turn-orchestrator/wake.ts b/harness-node/src/turn-orchestrator/wake.ts new file mode 100644 index 00000000..ec57e29b --- /dev/null +++ b/harness-node/src/turn-orchestrator/wake.ts @@ -0,0 +1,45 @@ +/** + * Durable FSM wake via iii-queue FIFO `turn-step`. Enqueues `turn::{state}` per + * persisted turn_state, not a generic dispatcher. + */ + +import { TriggerAction, type ISdk } from '../runtime/iii.js'; +import { logger } from '../runtime/otel.js'; +import * as persistence from './persistence.js'; +import { turnFnId, type TurnState, type TurnStateRecord } from './state.js'; + +export const TURN_STEP_QUEUE = 'turn-step'; + +const NON_STEPABLE_STATES = new Set(['stopped', 'function_awaiting_approval']); + +/** True when a persisted turn_state transition should enqueue `turn::{newState}`. */ +export function shouldWakeStep(previousState: TurnState | null, newState: TurnState): boolean { + if (NON_STEPABLE_STATES.has(newState)) return false; + if (previousState !== null && previousState === newState) return false; + return true; +} + +/** Guard before enqueueing from approval/abort — skip terminal sessions. */ +export function shouldRunStep(rec: TurnStateRecord | null): boolean { + if (!rec) return false; + return rec.state !== 'stopped'; +} + +export async function wakeState(iii: ISdk, session_id: string, state: TurnState): Promise { + try { + await iii.trigger({ + function_id: turnFnId(state), + payload: { session_id }, + action: TriggerAction.Enqueue({ queue: TURN_STEP_QUEUE }), + }); + } catch (err) { + logger.warn('wakeState failed', { session_id, state, err: String(err) }); + } +} + +/** Enqueue the handler for the session's current persisted state (approval/abort). */ +export async function wakeFromRecord(iii: ISdk, session_id: string): Promise { + const rec = await persistence.loadRecord(iii, session_id); + if (!rec || !shouldRunStep(rec)) return; + await wakeState(iii, session_id, rec.state); +} diff --git a/harness-node/tests/integration/approval-resume.e2e.test.ts b/harness-node/tests/integration/approval-resume.e2e.test.ts index 1b2b6736..77d20819 100644 --- a/harness-node/tests/integration/approval-resume.e2e.test.ts +++ b/harness-node/tests/integration/approval-resume.e2e.test.ts @@ -9,10 +9,20 @@ import { isAbortSignalWrite, } from '../../src/turn-orchestrator/on-abort-signal.js'; import type { ISdk } from '../../src/runtime/iii.js'; +import { newRecord, turnStateKey } from '../../src/turn-orchestrator/state.js'; -function fakeIii(): { iii: ISdk; stepTriggers: Array<{ session_id: string }> } { +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function fakeIii(): { + iii: ISdk; + wakeTriggers: Array<{ session_id: string; function_id: string }>; + stateStore: Map; +} { const stateStore = new Map(); - const stepTriggers: Array<{ session_id: string }> = []; + const wakeTriggers: Array<{ session_id: string; function_id: string }> = []; const handlers = new Map Promise>(); const iii = { @@ -20,59 +30,62 @@ function fakeIii(): { iii: ISdk; stepTriggers: Array<{ session_id: string }> } { handlers.set(fnId, handler); return { unregister: vi.fn() }; }), - trigger: vi.fn(async ({ function_id, payload }: { function_id: string; payload: unknown }) => { - if (function_id === 'state::set') { - const p = payload as { scope: string; key: string; value: unknown }; - const fullKey = `${p.scope}/${p.key}`; - const old_value = stateStore.get(fullKey) ?? null; - stateStore.set(fullKey, p.value); - if (p.scope === 'agent') { - const event = { - event_type: old_value == null ? 'state:created' : 'state:updated', - scope: p.scope, - key: p.key, - old_value, - new_value: p.value, - message_type: 'state', - }; - if (isAbortSignalWrite(event)) { - queueMicrotask(() => { - void handleAbortSignalWrite(iii as unknown as ISdk, event); - }); + trigger: vi.fn( + async ({ + function_id, + payload, + action, + }: { + function_id: string; + payload: unknown; + action?: unknown; + }) => { + if (function_id === 'state::set') { + const p = payload as { scope: string; key: string; value: unknown }; + const fullKey = `${p.scope}/${p.key}`; + const old_value = stateStore.get(fullKey) ?? null; + stateStore.set(fullKey, p.value); + if (p.scope === 'agent') { + const event = { + event_type: old_value == null ? 'state:created' : 'state:updated', + scope: p.scope, + key: p.key, + old_value, + new_value: p.value, + message_type: 'state', + }; + if (isAbortSignalWrite(event)) { + queueMicrotask(() => { + void handleAbortSignalWrite(iii as unknown as ISdk, event); + }); + } } + return null; } - return null; - } - if (function_id === 'state::get') { - const p = payload as { scope: string; key: string }; - return stateStore.get(`${p.scope}/${p.key}`) ?? null; - } - - if (function_id === 'turn::step') { - stepTriggers.push(payload as { session_id: string }); - return null; - } + if (function_id === 'state::get') { + const p = payload as { scope: string; key: string }; + return stateStore.get(`${p.scope}/${p.key}`) ?? null; + } - const handler = handlers.get(function_id); - if (handler) { - await handler(payload); - return null; - } + if (function_id.startsWith('turn::') && action != null) { + const p = payload as { session_id: string }; + wakeTriggers.push({ session_id: p.session_id, function_id }); + return null; + } - if (function_id === 'iii::durable::publish') { - const p = payload as { topic: string; data: { session_id: string } }; - if (p.topic === 'turn::step_requested') { - stepTriggers.push({ session_id: p.data.session_id }); + const handler = handlers.get(function_id); + if (handler) { + await handler(payload); + return null; } - return null; - } - return null; - }), + return null; + }, + ), }; - return { iii: iii as unknown as ISdk, stepTriggers }; + return { iii: iii as unknown as ISdk, wakeTriggers, stateStore }; } describe('approval resume reactive trigger', () => { @@ -80,8 +93,11 @@ describe('approval resume reactive trigger', () => { clearApprovalResumeRegistry(); }); - it('approval::resolve via resume fn automatically triggers turn::step', async () => { - const { iii, stepTriggers } = fakeIii(); + it('approval::resolve via resume fn automatically enqueues turn::{state}', async () => { + const { iii, wakeTriggers, stateStore } = fakeIii(); + const rec = newRecord('sess-x'); + rec.state = 'function_awaiting_approval'; + stateStore.set(`agent/${turnStateKey('sess-x')}`, rec); registerApprovalResume(iii, 'sess-x', 'fc-1'); const out = await handleResolveRequest(iii, { @@ -91,14 +107,20 @@ describe('approval resume reactive trigger', () => { }); expect(out).toEqual({ ok: true }); - await Promise.resolve(); + await flushMicrotasks(); - expect(stepTriggers).toHaveLength(1); - expect(stepTriggers[0]).toMatchObject({ session_id: 'sess-x' }); + expect(wakeTriggers).toHaveLength(1); + expect(wakeTriggers[0]).toMatchObject({ + session_id: 'sess-x', + function_id: 'turn::function_awaiting_approval', + }); }); - it('writing session//abort_signal=true wakes turn::step (via durable publish)', async () => { - const { iii, stepTriggers } = fakeIii(); + it('writing session//abort_signal=true enqueues turn::{state}', async () => { + const { iii, wakeTriggers, stateStore } = fakeIii(); + const rec = newRecord('sess-abort'); + rec.state = 'assistant_streaming'; + stateStore.set(`agent/${turnStateKey('sess-abort')}`, rec); await iii.trigger({ function_id: 'state::set', @@ -109,33 +131,39 @@ describe('approval resume reactive trigger', () => { }, }); - await Promise.resolve(); + await flushMicrotasks(); - expect(stepTriggers).toHaveLength(1); - expect(stepTriggers[0]).toMatchObject({ session_id: 'sess-abort' }); + expect(wakeTriggers).toHaveLength(1); + expect(wakeTriggers[0]).toMatchObject({ + session_id: 'sess-abort', + function_id: 'turn::assistant_streaming', + }); }); it('writing session//abort_signal=false does NOT trigger (condition rejects clears)', async () => { - const { iii, stepTriggers } = fakeIii(); + const { iii, wakeTriggers, stateStore } = fakeIii(); + const rec = newRecord('sess-clear'); + rec.state = 'function_execute'; + stateStore.set(`agent/${turnStateKey('sess-clear')}`, rec); await iii.trigger({ function_id: 'state::set', payload: { scope: 'agent', key: 'session/sess-clear/abort_signal', value: true }, }); - await Promise.resolve(); - stepTriggers.length = 0; + await flushMicrotasks(); + wakeTriggers.length = 0; await iii.trigger({ function_id: 'state::set', payload: { scope: 'agent', key: 'session/sess-clear/abort_signal', value: false }, }); - await Promise.resolve(); + await flushMicrotasks(); - expect(stepTriggers).toHaveLength(0); + expect(wakeTriggers).toHaveLength(0); }); it('writing an unrelated agent-scope key does NOT trigger', async () => { - const { iii, stepTriggers } = fakeIii(); + const { iii, wakeTriggers } = fakeIii(); await iii.trigger({ function_id: 'state::set', @@ -147,6 +175,6 @@ describe('approval resume reactive trigger', () => { }); await Promise.resolve(); - expect(stepTriggers).toHaveLength(0); + expect(wakeTriggers).toHaveLength(0); }); }); diff --git a/harness-node/tests/integration/on-record-written.e2e.test.ts b/harness-node/tests/integration/on-record-written.e2e.test.ts index 7d88a08f..156630e6 100644 --- a/harness-node/tests/integration/on-record-written.e2e.test.ts +++ b/harness-node/tests/integration/on-record-written.e2e.test.ts @@ -1,142 +1,122 @@ import { describe, expect, it, vi } from 'vitest'; +import { TriggerAction } from '../../src/runtime/iii.js'; import type { ISdk } from '../../src/runtime/iii.js'; -import { - handleStepableRecordWrite, - parseStepableWrite, -} from '../../src/turn-orchestrator/on-record-written.js'; - -function fakeIii(): { iii: ISdk; stepInvocations: Array<{ session_id: string }> } { +import * as persistence from '../../src/turn-orchestrator/persistence.js'; +import { newRecord } from '../../src/turn-orchestrator/state.js'; + +function fakeIii(): { + iii: ISdk; + wakeInvocations: Array<{ session_id: string; function_id: string; action?: unknown }>; + stateStore: Map; +} { const stateStore = new Map(); - const stepInvocations: Array<{ session_id: string }> = []; + const wakeInvocations: Array<{ session_id: string; function_id: string; action?: unknown }> = []; const iii = { - trigger: vi.fn(async ({ function_id, payload }: { function_id: string; payload: unknown }) => { - if (function_id === 'state::set') { - const p = payload as { scope: string; key: string; value: unknown }; - const fullKey = `${p.scope}/${p.key}`; - const old_value = stateStore.get(fullKey) ?? null; - stateStore.set(fullKey, p.value); - if (p.scope === 'agent') { - const event = { - event_type: old_value == null ? 'state:created' : 'state:updated', - scope: p.scope, - key: p.key, - old_value, - new_value: p.value, - message_type: 'state', - }; - if (parseStepableWrite(event) !== null) { - queueMicrotask(() => { - void handleStepableRecordWrite(iii as unknown as ISdk, event); - }); - } + trigger: vi.fn( + async ({ + function_id, + payload, + action, + }: { function_id: string; payload: unknown; action?: unknown }) => { + if (function_id === 'state::get') { + const p = payload as { scope: string; key: string }; + const v = stateStore.get(`${p.scope}/${p.key}`); + return v === undefined ? null : structuredClone(v); } - return null; - } - if (function_id === 'iii::durable::publish') { - const p = payload as { topic: string; data: { session_id: string } }; - if (p.topic === 'turn::step_requested') { - stepInvocations.push({ session_id: p.data.session_id }); + if (function_id === 'state::set') { + const p = payload as { scope: string; key: string; value: unknown }; + stateStore.set(`${p.scope}/${p.key}`, structuredClone(p.value)); + return null; + } + + if (function_id === 'state::update') { + return { old_value: 0 }; } - return null; - } - return null; - }), + if (function_id.startsWith('turn::')) { + const p = payload as { session_id: string }; + wakeInvocations.push({ session_id: p.session_id, function_id, action }); + return null; + } + + return null; + }, + ), }; - return { iii: iii as unknown as ISdk, stepInvocations }; + return { iii: iii as unknown as ISdk, wakeInvocations, stateStore }; } -describe('turn-step reactive wake', () => { - it('writing session//turn_state with a stepable state publishes turn::step_requested', async () => { - const { iii, stepInvocations } = fakeIii(); +describe('saveRecord wake integration', () => { + it('writing a new stepable turn_state enqueues turn::provisioning', async () => { + const { iii, wakeInvocations } = fakeIii(); + const rec = newRecord('sess-a'); + rec.state = 'provisioning'; - await iii.trigger({ - function_id: 'state::set', - payload: { - scope: 'agent', - key: 'session/sess-a/turn_state', - value: { state: 'provisioning' }, - }, - }); + await persistence.saveRecord(iii, rec); - await Promise.resolve(); - - expect(stepInvocations).toEqual([{ session_id: 'sess-a' }]); + expect(wakeInvocations).toEqual([ + { + session_id: 'sess-a', + function_id: 'turn::provisioning', + action: TriggerAction.Enqueue({ queue: 'turn-step' }), + }, + ]); }); - it('subsequent transitions also publish turn::step_requested', async () => { - const { iii, stepInvocations } = fakeIii(); + it('subsequent transitions enqueue turn::{newState}', async () => { + const { iii, wakeInvocations } = fakeIii(); + const rec = newRecord('sess-b'); + rec.state = 'provisioning'; + await persistence.saveRecord(iii, rec); + + rec.state = 'assistant_streaming'; + await persistence.saveRecord(iii, rec); - await iii.trigger({ - function_id: 'state::set', - payload: { - scope: 'agent', - key: 'session/sess-b/turn_state', - value: { state: 'provisioning' }, + expect(wakeInvocations).toEqual([ + { + session_id: 'sess-b', + function_id: 'turn::provisioning', + action: TriggerAction.Enqueue({ queue: 'turn-step' }), }, - }); - await Promise.resolve(); - - await iii.trigger({ - function_id: 'state::set', - payload: { - scope: 'agent', - key: 'session/sess-b/turn_state', - value: { state: 'awaiting_assistant' }, + { + session_id: 'sess-b', + function_id: 'turn::assistant_streaming', + action: TriggerAction.Enqueue({ queue: 'turn-step' }), }, - }); - await Promise.resolve(); - - expect(stepInvocations).toEqual([{ session_id: 'sess-b' }, { session_id: 'sess-b' }]); + ]); }); it('parking in function_awaiting_approval does NOT wake', async () => { - const { iii, stepInvocations } = fakeIii(); - - await iii.trigger({ - function_id: 'state::set', - payload: { - scope: 'agent', - key: 'session/sess-c/turn_state', - value: { state: 'function_awaiting_approval' }, - }, - }); - await Promise.resolve(); + const { iii, wakeInvocations } = fakeIii(); + const rec = newRecord('sess-c'); + rec.state = 'function_awaiting_approval'; - expect(stepInvocations).toEqual([]); + await persistence.saveRecord(iii, rec); + + expect(wakeInvocations).toEqual([]); }); it('terminal stopped state does NOT wake', async () => { - const { iii, stepInvocations } = fakeIii(); - - await iii.trigger({ - function_id: 'state::set', - payload: { - scope: 'agent', - key: 'session/sess-d/turn_state', - value: { state: 'stopped' }, - }, - }); - await Promise.resolve(); + const { iii, wakeInvocations } = fakeIii(); + const rec = newRecord('sess-d'); + rec.state = 'stopped'; - expect(stepInvocations).toEqual([]); + await persistence.saveRecord(iii, rec); + + expect(wakeInvocations).toEqual([]); }); - it('non-turn_state agent keys do NOT wake (no leakage from abort_signal etc.)', async () => { - const { iii, stepInvocations } = fakeIii(); + it('same-state re-save does NOT wake', async () => { + const { iii, wakeInvocations } = fakeIii(); + const rec = newRecord('sess-e'); + rec.state = 'function_execute'; + await persistence.saveRecord(iii, rec); + wakeInvocations.length = 0; - await iii.trigger({ - function_id: 'state::set', - payload: { - scope: 'agent', - key: 'session/sess-e/abort_signal', - value: true, - }, - }); - await Promise.resolve(); + await persistence.saveRecord(iii, rec); - expect(stepInvocations).toEqual([]); + expect(wakeInvocations).toEqual([]); }); }); diff --git a/harness-node/tests/turn-orchestrator/approval-resume.test.ts b/harness-node/tests/turn-orchestrator/approval-resume.test.ts index 5b094bbf..5fe307df 100644 --- a/harness-node/tests/turn-orchestrator/approval-resume.test.ts +++ b/harness-node/tests/turn-orchestrator/approval-resume.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ISdk } from '../../src/runtime/iii.js'; +import { TriggerAction, type ISdk } from '../../src/runtime/iii.js'; import { approvalResumeFnId } from '../../src/approval-gate/schemas.js'; import { clearApprovalResumeRegistry, @@ -13,14 +13,18 @@ type RegisteredFn = { unregister: ReturnType; }; -import type { TurnStateRecord } from '../../src/turn-orchestrator/state.js'; +import { + newRecord, + turnStateKey, + type TurnStateRecord, +} from '../../src/turn-orchestrator/state.js'; function makeIiiWithRegistry( stateStore = new Map(), agentTurnStates: TurnStateRecord[] = [], ) { const registered = new Map(); - const wakeCalls: Array<{ session_id: string }> = []; + const wakeCalls: Array<{ session_id: string; action?: unknown; function_id?: string }> = []; const iii = { registerFunction: vi.fn((fnId: string, handler: (payload: unknown) => Promise) => { @@ -32,28 +36,40 @@ function makeIiiWithRegistry( registered.set(fnId, entry); return { unregister: entry.unregister }; }), - trigger: vi.fn(async ({ function_id, payload }: { function_id: string; payload: unknown }) => { - if (function_id === 'state::get') { - const p = payload as { scope: string; key: string }; - return stateStore.get(`${p.scope}/${p.key}`) ?? null; - } - if (function_id === 'state::set') { - const p = payload as { scope: string; key: string; value: unknown }; - stateStore.set(`${p.scope}/${p.key}`, p.value); - return null; - } - if (function_id === 'state::list') { - return agentTurnStates; - } - if (function_id === 'iii::durable::publish') { - const p = payload as { topic: string; data: { session_id: string } }; - if (p.topic === 'turn::step_requested') { - wakeCalls.push({ session_id: p.data.session_id }); + trigger: vi.fn( + async ({ + function_id, + payload, + action, + }: { + function_id: string; + payload: unknown; + action?: unknown; + }) => { + if (function_id === 'state::get') { + const p = payload as { scope: string; key: string }; + return stateStore.get(`${p.scope}/${p.key}`) ?? null; + } + if (function_id === 'state::set') { + const p = payload as { scope: string; key: string; value: unknown }; + stateStore.set(`${p.scope}/${p.key}`, p.value); + return null; + } + if (function_id === 'state::list') { + return agentTurnStates; + } + if (function_id.startsWith('turn::') && function_id !== 'turn::on_abort_signal') { + const p = payload as { session_id: string }; + wakeCalls.push({ + session_id: p.session_id, + action, + function_id, + }); + return null; } return null; - } - return null; - }), + }, + ), } as unknown as ISdk; return { iii, registered, wakeCalls, stateStore }; @@ -88,15 +104,24 @@ describe('registerApprovalResume', () => { }); describe('approval resume handler', () => { - it('persists decision, publishes turn::step_requested, and unregisters', async () => { + it('persists decision, enqueues turn::{state}, and unregisters', async () => { const { iii, registered, wakeCalls, stateStore } = makeIiiWithRegistry(); + const rec = newRecord('s1'); + rec.state = 'function_awaiting_approval'; + stateStore.set(`agent/${turnStateKey('s1')}`, rec); registerApprovalResume(iii, 's1', 'fc-1'); const entry = registered.get('turn::approval_resume::s1/fc-1'); expect(entry).toBeDefined(); await entry!.handler({ decision: 'allow', reason: null }); expect(stateStore.get('approvals/s1/fc-1')).toEqual({ decision: 'allow', reason: null }); - expect(wakeCalls).toEqual([{ session_id: 's1' }]); + expect(wakeCalls).toEqual([ + { + session_id: 's1', + function_id: 'turn::function_awaiting_approval', + action: TriggerAction.Enqueue({ queue: 'turn-step' }), + }, + ]); expect(entry!.unregister).toHaveBeenCalled(); }); @@ -114,7 +139,7 @@ describe('approval resume handler', () => { }); }); - it('does not publish turn::step_requested again after unregister on second invoke', async () => { + it('does not enqueue turn::{state} again after unregister on second invoke', async () => { const { iii, registered, wakeCalls } = makeIiiWithRegistry(); registerApprovalResume(iii, 's1', 'fc-1'); const entry = registered.get('turn::approval_resume::s1/fc-1')!; diff --git a/harness-node/tests/turn-orchestrator/assistant.test.ts b/harness-node/tests/turn-orchestrator/assistant.test.ts index 95ab8055..9666af7d 100644 --- a/harness-node/tests/turn-orchestrator/assistant.test.ts +++ b/harness-node/tests/turn-orchestrator/assistant.test.ts @@ -1,32 +1,286 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; +import type { AssistantMessage } from '../../src/types/agent-message.js'; +import { TOOL_NAME } from '../../src/turn-orchestrator/agent-trigger.js'; +import * as persistence from '../../src/turn-orchestrator/persistence.js'; +import * as preflightModule from '../../src/turn-orchestrator/preflight.js'; import { type TurnStateRecord, newRecord } from '../../src/turn-orchestrator/state.js'; -import { handleAwaiting } from '../../src/turn-orchestrator/states/assistant.js'; +import { handleFinished } from '../../src/turn-orchestrator/states/assistant-finished.js'; +import { handleStreaming } from '../../src/turn-orchestrator/states/assistant-streaming.js'; -type TriggerCall = { function_id: string; payload: unknown }; +type TriggerCall = { function_id: string; payload: unknown; timeoutMs?: number }; -function fakeIii(): { iii: ISdk; calls: TriggerCall[] } { +function fakeIii(overrides?: Partial): { iii: ISdk; calls: TriggerCall[] } { const calls: TriggerCall[] = []; const iii = { - trigger: async (req: { function_id: string; payload: T }): Promise => { - calls.push({ function_id: req.function_id, payload: req.payload }); + trigger: async (req: { + function_id: string; + payload: T; + timeoutMs?: number; + }): Promise => { + calls.push({ + function_id: req.function_id, + payload: req.payload, + timeoutMs: req.timeoutMs, + }); return null as R; }, + ...overrides, } as unknown as ISdk; return { iii, calls }; } -describe('handleAwaiting', () => { +function assistant(overrides: Partial = {}): AssistantMessage { + return { + role: 'assistant', + content: [{ type: 'text', text: 'hello' }], + stop_reason: 'end', + error_message: null, + error_kind: null, + usage: null, + model: 'gpt-4o', + provider: 'openai', + timestamp: 1, + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('handleStreaming turn start', () => { it('starts a normal assistant turn without approval::consume resurrection', async () => { - const rec: TurnStateRecord = { ...newRecord('s1'), state: 'awaiting_assistant' }; - const { iii, calls } = fakeIii(); + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'assistant_streaming' }; + const { iii, calls } = fakeIii({ + createChannel: async () => { + throw new Error('channel unavailable'); + }, + }); + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ + provider: 'openai', + model: 'gpt-4o', + }); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'loadFunctionSchemas').mockResolvedValue([]); + vi.spyOn(preflightModule, 'runPreflight').mockResolvedValue('ok'); - await handleAwaiting(iii, rec); + await handleStreaming(iii, rec); - expect(rec.state).toBe('assistant_streaming'); expect(rec.turn_count).toBe(1); expect(rec.turn_end_emitted).toBe(false); expect(calls.some((c) => c.function_id === 'approval::consume')).toBe(false); expect(calls.some((c) => c.function_id === 'stream::set')).toBe(true); }); + + it('exhausts max_turns and transitions to tearing_down', async () => { + const rec: TurnStateRecord = { + ...newRecord('s1', 2), + state: 'assistant_streaming', + turn_count: 2, + }; + const { iii, calls } = fakeIii(); + const saveSpy = vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + + await handleStreaming(iii, rec); + + expect(rec.state).toBe('tearing_down'); + expect(rec.turn_end_emitted).toBe(true); + expect(rec.last_assistant?.content[0]).toEqual({ + type: 'text', + text: 'loop stopped: max_turns (2) reached', + }); + expect(saveSpy).toHaveBeenCalledOnce(); + expect(calls.some((c) => c.function_id === 'stream::set')).toBe(true); + }); +}); + +describe('handleStreaming', () => { + it('transitions to assistant_finished with synthetic error when createChannel fails', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'assistant_streaming' }; + const { iii } = fakeIii({ + createChannel: async () => { + throw new Error('channel unavailable'); + }, + }); + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ + provider: 'openai', + model: 'gpt-4o', + }); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'loadFunctionSchemas').mockResolvedValue([]); + vi.spyOn(preflightModule, 'runPreflight').mockResolvedValue('ok'); + + await handleStreaming(iii, rec); + + expect(rec.state).toBe('assistant_finished'); + expect(rec.last_assistant?.stop_reason).toBe('error'); + expect(rec.last_assistant?.error_message).toContain('create_channel failed'); + }); + + it('captures provider done frame and transitions to assistant_finished', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'assistant_streaming' }; + const finalMsg = assistant({ content: [{ type: 'text', text: 'done reply' }] }); + let deliver: ((msg: string) => void) | null = null; + + const { iii } = fakeIii({ + createChannel: async () => ({ + writerRef: {}, + reader: { + onMessage: (cb: (msg: string) => void) => { + deliver = cb; + }, + stream: { + resume: () => { + deliver?.( + JSON.stringify({ + type: 'done', + message: finalMsg, + }), + ); + }, + }, + }, + }), + }); + + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ + provider: 'openai', + model: 'gpt-4o', + }); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'loadFunctionSchemas').mockResolvedValue([]); + vi.spyOn(preflightModule, 'runPreflight').mockResolvedValue('ok'); + + await handleStreaming(iii, rec); + + expect(rec.state).toBe('assistant_finished'); + expect(rec.last_assistant).toEqual(finalMsg); + }); +}); + +describe('handleFinished', () => { + it('throws when last_assistant is missing', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'assistant_finished' }; + const { iii } = fakeIii(); + + await expect(handleFinished(iii, rec)).rejects.toThrow( + 'assistant_finished without last_assistant', + ); + }); + + it('routes error assistant to tearing_down without persisting transcript', async () => { + const rec: TurnStateRecord = { + ...newRecord('s1'), + state: 'assistant_finished', + last_assistant: assistant({ stop_reason: 'error', error_message: 'auth failed' }), + }; + const { iii } = fakeIii(); + const saveSpy = vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + + await handleFinished(iii, rec); + + expect(rec.state).toBe('tearing_down'); + expect(rec.turn_end_emitted).toBe(true); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('routes text-only assistant to steering_check and persists message', async () => { + const rec: TurnStateRecord = { + ...newRecord('s1'), + state: 'assistant_finished', + last_assistant: assistant(), + }; + const { iii } = fakeIii(); + const saveSpy = vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + + await handleFinished(iii, rec); + + expect(rec.state).toBe('steering_check'); + expect(rec.pending_function_calls).toEqual([]); + expect(saveSpy).toHaveBeenCalledOnce(); + }); + + it('prepares function calls and transitions to function_execute', async () => { + const rec: TurnStateRecord = { + ...newRecord('s1'), + state: 'assistant_finished', + last_assistant: assistant({ + content: [ + { + type: 'function_call', + id: 'fc-1', + function_id: 'shell::run', + arguments: { command: 'ls' }, + }, + ], + }), + }; + const { iii } = fakeIii(); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + const saveExecutedSpy = vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); + const savePreparedSpy = vi.spyOn(persistence, 'savePreparedCalls').mockResolvedValue(undefined); + + await handleFinished(iii, rec); + + expect(rec.state).toBe('function_execute'); + expect(rec.function_results).toEqual([]); + expect(rec.pending_function_calls).toEqual([ + { id: 'fc-1', function_id: 'shell::run', arguments: { command: 'ls' } }, + ]); + expect(saveExecutedSpy).toHaveBeenCalledWith(iii, 's1', []); + expect(savePreparedSpy).toHaveBeenCalledWith(iii, 's1', [ + { + function_call: { id: 'fc-1', function_id: 'shell::run', arguments: { command: 'ls' } }, + blocked: null, + }, + ]); + }); + + it('unwraps agent_trigger wrappers when preparing function calls', async () => { + const rec: TurnStateRecord = { + ...newRecord('s1'), + state: 'assistant_finished', + last_assistant: assistant({ + content: [ + { + type: 'function_call', + id: 'fc-wrap', + function_id: TOOL_NAME, + arguments: { function: 'shell::run', payload: { command: 'ls' } }, + }, + { + type: 'function_call', + id: 'fc-direct', + function_id: 'shell::echo', + arguments: { text: 'hi' }, + }, + ], + }), + }; + const { iii } = fakeIii(); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); + const savePreparedSpy = vi.spyOn(persistence, 'savePreparedCalls').mockResolvedValue(undefined); + + await handleFinished(iii, rec); + + expect(rec.state).toBe('function_execute'); + const prepared = savePreparedSpy.mock.calls[0]?.[2]; + expect(prepared).toEqual([ + { + function_call: { id: 'fc-wrap', function_id: 'shell::run', arguments: { command: 'ls' } }, + blocked: null, + }, + { + function_call: { id: 'fc-direct', function_id: 'shell::echo', arguments: { text: 'hi' } }, + blocked: null, + }, + ]); + }); }); diff --git a/harness-node/tests/turn-orchestrator/awaiting-approval.test.ts b/harness-node/tests/turn-orchestrator/awaiting-approval.test.ts index 9c1e4705..8153d4d8 100644 --- a/harness-node/tests/turn-orchestrator/awaiting-approval.test.ts +++ b/harness-node/tests/turn-orchestrator/awaiting-approval.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; import type { TurnStateRecord } from '../../src/turn-orchestrator/state.js'; -import { handleAwaitingApproval } from '../../src/turn-orchestrator/states/functions.js'; +import { handleAwaitingApproval } from '../../src/turn-orchestrator/states/function-awaiting-approval.js'; function fakeIii(stateGetImpl: (scope: string, key: string) => unknown): ISdk { return { diff --git a/harness-node/tests/turn-orchestrator/functions.test.ts b/harness-node/tests/turn-orchestrator/functions.test.ts index b8120acc..7f04fa53 100644 --- a/harness-node/tests/turn-orchestrator/functions.test.ts +++ b/harness-node/tests/turn-orchestrator/functions.test.ts @@ -1,22 +1,58 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import * as agentTriggerModule from '../../src/turn-orchestrator/agent-trigger.js'; -import type { TurnOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; +import * as events from '../../src/turn-orchestrator/events.js'; import * as hookModule from '../../src/turn-orchestrator/hook.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; import type { TurnStateRecord } from '../../src/turn-orchestrator/state.js'; import { newRecord } from '../../src/turn-orchestrator/state.js'; +import * as agentTriggerModule from '../../src/turn-orchestrator/agent-trigger.js'; import * as approvalResumeModule from '../../src/turn-orchestrator/approval-resume.js'; -import { handleExecute } from '../../src/turn-orchestrator/states/functions.js'; - -const cfg: TurnOrchestratorConfig = { - system_default_skills: [], -}; +import { parseApprovalDecision } from '../../src/turn-orchestrator/states/function-awaiting-approval.js'; +import { handleExecute } from '../../src/turn-orchestrator/states/function-execute.js'; afterEach(() => { vi.restoreAllMocks(); }); +function mockFinalizePersistence(): void { + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(hookModule, 'publishAfter').mockResolvedValue(undefined); +} + +describe('parseApprovalDecision', () => { + it('accepts allow/deny/aborted with nullable reason (stored approval shape)', () => { + expect(parseApprovalDecision({ decision: 'allow', reason: null })).toEqual({ + decision: 'allow', + reason: null, + }); + expect(parseApprovalDecision({ decision: 'deny', reason: 'policy' })).toEqual({ + decision: 'deny', + reason: 'policy', + }); + expect(parseApprovalDecision({ decision: 'aborted', reason: 'session_aborted' })).toEqual({ + decision: 'aborted', + reason: 'session_aborted', + }); + }); + + it('rejects speculative wrapper envelopes no caller stores', () => { + expect(parseApprovalDecision({ data: { decision: 'allow', reason: null } })).toBeNull(); + expect(parseApprovalDecision({ payload: { decision: 'allow', reason: null } })).toBeNull(); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['missing decision', { reason: null }], + ['empty decision', { decision: '', reason: null }], + ['unknown decision', { decision: 'needs_approval', reason: null }], + ['numeric reason', { decision: 'allow', reason: 7 }], + ] as const)('rejects bad shape: %s', (_label, value) => { + expect(parseApprovalDecision(value)).toBeNull(); + }); +}); + describe('handleExecute new flow', () => { it('pushes the call onto awaiting_approval and transitions to function_awaiting_approval on pending', async () => { const dispatchSpy = vi.spyOn(agentTriggerModule, 'dispatchWithHook'); @@ -41,7 +77,7 @@ describe('handleExecute new flow', () => { ]); vi.spyOn(persistence, 'loadExecutedCalls').mockResolvedValue([]); vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); - await handleExecute(iii, cfg, rec); + await handleExecute(iii, rec); expect(rec.state).toBe('function_awaiting_approval'); expect(rec.awaiting_approval).toHaveLength(1); @@ -70,7 +106,7 @@ describe('handleExecute new flow', () => { vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); const consultBeforeSpy = vi.spyOn(hookModule, 'consultBefore'); - await handleExecute(iii, cfg, rec); + await handleExecute(iii, rec); expect(consultBeforeSpy).not.toHaveBeenCalled(); const triggerCalls = triggerSpy.mock.calls.map( @@ -103,10 +139,11 @@ describe('handleExecute new flow', () => { ]); vi.spyOn(persistence, 'loadExecutedCalls').mockResolvedValue([]); const saveSpy = vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); + mockFinalizePersistence(); - await expect(handleExecute(iii, cfg, rec)).resolves.toBeUndefined(); + await expect(handleExecute(iii, rec)).resolves.toBeUndefined(); - expect(rec.state).toBe('function_finalize'); + expect(rec.state).toBe('steering_check'); expect(saveSpy).toHaveBeenCalled(); const lastSave = saveSpy.mock.calls.at(-1)?.[2] as Array<{ is_error: boolean; @@ -139,12 +176,149 @@ describe('handleExecute new flow', () => { ]); vi.spyOn(persistence, 'loadExecutedCalls').mockResolvedValue([]); vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); - await handleExecute(iii, cfg, rec); + mockFinalizePersistence(); + await handleExecute(iii, rec); const shellCalls = triggerSpy.mock.calls.filter( (call) => (call[0] as { function_id: string }).function_id === 'shell::run', ); expect(shellCalls).toHaveLength(0); - expect(rec.state).toBe('function_finalize'); + expect(rec.state).toBe('steering_check'); + }); + + it('replays persisted executed calls without re-dispatching', async () => { + const dispatchSpy = vi.spyOn(agentTriggerModule, 'dispatchWithHook'); + const triggerSpy = vi.fn().mockResolvedValue(null); + const iii = { trigger: triggerSpy } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'function_execute'; + + const existingResult = { + content: [{ type: 'text' as const, text: 'cached' }], + details: {}, + terminate: false, + }; + vi.spyOn(persistence, 'loadPreparedCalls').mockResolvedValue([ + { + function_call: { id: 'fc-1', function_id: 'shell::run', arguments: {} }, + blocked: null, + }, + ]); + vi.spyOn(persistence, 'loadExecutedCalls').mockResolvedValue([ + { + function_call: { id: 'fc-1', function_id: 'shell::run', arguments: {} }, + result: existingResult, + is_error: false, + duration_ms: 42, + }, + ]); + vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); + mockFinalizePersistence(); + + await handleExecute(iii, rec); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(rec.state).toBe('steering_check'); + }); + + it('transitions to steering_check after a successful hook dispatch', async () => { + vi.spyOn(agentTriggerModule, 'dispatchWithHook').mockResolvedValueOnce({ + kind: 'result', + result: { + content: [{ type: 'text' as const, text: 'ok' }], + details: {}, + terminate: false, + }, + }); + const iii = { trigger: vi.fn().mockResolvedValue(null) } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'function_execute'; + + vi.spyOn(persistence, 'loadPreparedCalls').mockResolvedValue([ + { + function_call: { id: 'fc-1', function_id: 'shell::run', arguments: {} }, + blocked: null, + }, + ]); + vi.spyOn(persistence, 'loadExecutedCalls').mockResolvedValue([]); + vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); + mockFinalizePersistence(); + + await handleExecute(iii, rec); + + expect(rec.state).toBe('steering_check'); + }); + + it('transitions to steering_check when last_assistant is missing after execute', async () => { + const iii = { trigger: vi.fn().mockResolvedValue(null) } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'function_execute'; + rec.last_assistant = null; + + vi.spyOn(persistence, 'loadPreparedCalls').mockResolvedValue([]); + vi.spyOn(persistence, 'loadExecutedCalls').mockResolvedValue([ + { + function_call: { id: 'fc-1', function_id: 'shell::run', arguments: {} }, + result: { + content: [{ type: 'text' as const, text: 'ok' }], + details: {}, + terminate: false, + }, + is_error: false, + duration_ms: 1, + }, + ]); + vi.spyOn(hookModule, 'publishAfter').mockResolvedValue(undefined); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleExecute(iii, rec); + + expect(rec.state).toBe('steering_check'); + expect(rec.pending_function_calls).toEqual([]); + expect(rec.function_results).toHaveLength(1); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('emits turn lifecycle and sets turn_end_emitted when last_assistant is present', async () => { + const iii = { trigger: vi.fn().mockResolvedValue(null) } as unknown as ISdk; + const rec = newRecord('s1'); + rec.state = 'function_execute'; + rec.last_assistant = { + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + stop_reason: 'end', + error_message: null, + error_kind: null, + usage: null, + model: 'm', + provider: 'p', + timestamp: 1, + }; + + vi.spyOn(persistence, 'loadPreparedCalls').mockResolvedValue([]); + vi.spyOn(persistence, 'loadExecutedCalls').mockResolvedValue([ + { + function_call: { id: 'fc-1', function_id: 'shell::run', arguments: {} }, + result: { + content: [{ type: 'text' as const, text: 'ok' }], + details: {}, + terminate: false, + }, + is_error: false, + duration_ms: 1, + }, + ]); + vi.spyOn(hookModule, 'publishAfter').mockResolvedValue(undefined); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleExecute(iii, rec); + + expect(rec.state).toBe('steering_check'); + expect(rec.turn_end_emitted).toBe(true); + expect(emitSpy.mock.calls.some((call) => call[2]?.type === 'turn_end')).toBe(true); }); }); diff --git a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts index 2991415c..20e7b6fb 100644 --- a/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts +++ b/harness-node/tests/turn-orchestrator/on-abort-signal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ISdk } from '../../src/runtime/iii.js'; +import { TriggerAction, type ISdk } from '../../src/runtime/iii.js'; import { AbortSignalWriteEventSchema, execute, @@ -7,6 +7,7 @@ import { isAbortSignalWrite, parseAbortSignalWrite, } from '../../src/turn-orchestrator/on-abort-signal.js'; +import { newRecord } from '../../src/turn-orchestrator/state.js'; const matchingEvent = { event_type: 'state:created' as const, @@ -133,29 +134,41 @@ describe('parseAbortSignalWrite condition', () => { }); }); +function mockIiiWithTurnState(rec: ReturnType): { + iii: ISdk; + triggers: Array<{ function_id: string; payload: unknown; action?: unknown }>; +} { + const triggers: Array<{ function_id: string; payload: unknown; action?: unknown }> = []; + const iii = { + trigger: vi.fn(async (req: { function_id: string; payload: unknown; action?: unknown }) => { + if (req.function_id === 'state::get') return rec; + triggers.push(req); + return null; + }), + } as unknown as ISdk; + return { iii, triggers }; +} + describe('execute', () => { - it('publishes turn::step_requested via durable publish', async () => { - const triggers: Array<{ function_id: string; payload: unknown }> = []; - const iii = { - trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { - triggers.push(req); - return null; - }), - } as unknown as ISdk; + it('enqueues turn::{state} on the turn-step FIFO queue', async () => { + const rec = newRecord('sess-abc'); + rec.state = 'assistant_streaming'; + const { iii, triggers } = mockIiiWithTurnState(rec); await execute(iii, { session_id: 'sess-abc' }); expect(triggers).toHaveLength(1); - expect(triggers[0]?.function_id).toBe('iii::durable::publish'); - expect(triggers[0]?.payload).toEqual({ - topic: 'turn::step_requested', - data: { session_id: 'sess-abc' }, - }); + expect(triggers[0]?.function_id).toBe('turn::assistant_streaming'); + expect(triggers[0]?.payload).toEqual({ session_id: 'sess-abc' }); + expect(triggers[0]?.action).toEqual(TriggerAction.Enqueue({ queue: 'turn-step' })); }); - it('swallows publish failures (logs only, never rethrows)', async () => { + it('swallows enqueue failures (logs only, never rethrows)', async () => { + const rec = newRecord('sess-abc'); + rec.state = 'provisioning'; const iii = { - trigger: vi.fn(async () => { + trigger: vi.fn(async (req: { function_id: string }) => { + if (req.function_id === 'state::get') return rec; throw new Error('durable down'); }), } as unknown as ISdk; @@ -165,23 +178,17 @@ describe('execute', () => { }); describe('handleAbortSignalWrite', () => { - it('extracts session_id and publishes turn::step_requested', async () => { - const triggers: Array<{ function_id: string; payload: unknown }> = []; - const iii = { - trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { - triggers.push(req); - return null; - }), - } as unknown as ISdk; + it('extracts session_id and enqueues turn::{state}', async () => { + const rec = newRecord('sess-abc'); + rec.state = 'function_execute'; + const { iii, triggers } = mockIiiWithTurnState(rec); await handleAbortSignalWrite(iii, matchingEvent); expect(triggers).toHaveLength(1); - expect(triggers[0]?.function_id).toBe('iii::durable::publish'); - expect(triggers[0]?.payload).toEqual({ - topic: 'turn::step_requested', - data: { session_id: 'sess-abc' }, - }); + expect(triggers[0]?.function_id).toBe('turn::function_execute'); + expect(triggers[0]?.payload).toEqual({ session_id: 'sess-abc' }); + expect(triggers[0]?.action).toEqual(TriggerAction.Enqueue({ queue: 'turn-step' })); }); it('no-ops when key does not match the abort_signal pattern', async () => { diff --git a/harness-node/tests/turn-orchestrator/on-record-written.test.ts b/harness-node/tests/turn-orchestrator/on-record-written.test.ts deleted file mode 100644 index 660be9b7..00000000 --- a/harness-node/tests/turn-orchestrator/on-record-written.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { ISdk } from '../../src/runtime/iii.js'; -import { - handleStepableRecordWrite, - parseStepableWrite, -} from '../../src/turn-orchestrator/on-record-written.js'; - -describe('parseStepableWrite condition', () => { - it('accepts minimal required fields (matches TurnStateWriteEventSchema callers)', () => { - expect( - parseStepableWrite({ - event_type: 'state:created', - key: 'session/sess-a/turn_state', - new_value: { state: 'provisioning' }, - }), - ).toEqual({ session_id: 'sess-a' }); - }); - - it('matches turn_state writes with a non-terminal, non-awaiting state', () => { - expect( - parseStepableWrite({ - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: null, - new_value: { state: 'provisioning' }, - message_type: 'state', - }), - ).toEqual({ session_id: 'sess-abc' }); - - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'provisioning' }, - new_value: { state: 'awaiting_assistant' }, - message_type: 'state', - }), - ).toEqual({ session_id: 'sess-abc' }); - }); - - it('rejects terminal state (stopped)', () => { - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'tearing_down' }, - new_value: { state: 'stopped' }, - message_type: 'state', - }), - ).toBeNull(); - }); - - it('rejects function_awaiting_approval (orchestrator parks here)', () => { - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'function_prepare' }, - new_value: { state: 'function_awaiting_approval' }, - message_type: 'state', - }), - ).toBeNull(); - }); - - it('rejects state:deleted', () => { - expect( - parseStepableWrite({ - event_type: 'state:deleted', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'provisioning' }, - new_value: null, - message_type: 'state', - }), - ).toBeNull(); - }); - - it('rejects non-turn_state keys in the agent scope', () => { - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/abort_signal', - old_value: null, - new_value: true, - message_type: 'state', - }), - ).toBeNull(); - }); - - it('rejects same-state writes (old_value.state === new_value.state)', () => { - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'function_prepare' }, - new_value: { state: 'function_prepare' }, - message_type: 'state', - }), - ).toBeNull(); - - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'function_prepare' }, - new_value: { state: 'function_execute' }, - message_type: 'state', - }), - ).toEqual({ session_id: 'sess-abc' }); - }); - - it('rejects publish envelope shapes — state triggers receive flat events', () => { - expect( - parseStepableWrite({ - topic: 'turn::step_requested', - data: { session_id: 'sess-abc' }, - }), - ).toBeNull(); - }); - - it('rejects nested payload wrappers (no in-repo caller uses them)', () => { - const inner = { - event_type: 'state:created', - key: 'session/sess-abc/turn_state', - new_value: { state: 'provisioning' }, - }; - expect(parseStepableWrite({ data: inner })).toBeNull(); - expect(parseStepableWrite({ payload: inner })).toBeNull(); - }); - - it('rejects null, undefined, and non-object events', () => { - expect(parseStepableWrite(null)).toBeNull(); - expect(parseStepableWrite(undefined)).toBeNull(); - expect(parseStepableWrite('state:created')).toBeNull(); - }); - - it('rejects writes whose new_value lacks a string state', () => { - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: null, - new_value: { not_state: 'provisioning' }, - message_type: 'state', - }), - ).toBeNull(); - - expect( - parseStepableWrite({ - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: null, - new_value: null, - message_type: 'state', - }), - ).toBeNull(); - }); -}); - -describe('handleStepableRecordWrite', () => { - it('extracts session_id and publishes turn::step_requested', async () => { - const triggers: Array<{ function_id: string; payload: unknown }> = []; - const iii = { - trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { - triggers.push(req); - return null; - }), - } as unknown as ISdk; - - await handleStepableRecordWrite(iii, { - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: null, - new_value: { state: 'provisioning' }, - message_type: 'state', - }); - - expect(triggers).toHaveLength(1); - expect(triggers[0]?.function_id).toBe('iii::durable::publish'); - expect(triggers[0]?.payload).toEqual({ - topic: 'turn::step_requested', - data: { session_id: 'sess-abc' }, - }); - }); - - it('no-ops when the event is not stepable (direct invoke bypasses engine condition)', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - await handleStepableRecordWrite(iii, { - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/abort_signal', - old_value: null, - new_value: true, - message_type: 'state', - }); - expect(iii.trigger).not.toHaveBeenCalled(); - }); - - it('no-ops on same-state turn_state updates (direct invoke bypasses engine condition)', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - await handleStepableRecordWrite(iii, { - event_type: 'state:updated', - scope: 'agent', - key: 'session/sess-abc/turn_state', - old_value: { state: 'function_prepare' }, - new_value: { state: 'function_prepare' }, - message_type: 'state', - }); - expect(iii.trigger).not.toHaveBeenCalled(); - }); -}); diff --git a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts b/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts deleted file mode 100644 index 52b8accb..00000000 --- a/harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { ISdk } from '../../src/runtime/iii.js'; -import { - handleTurnStateWrite, - isTurnStateWrite, - parseTurnStateWrite, - TurnStateWriteEventSchema, -} from '../../src/turn-orchestrator/on-turn-state-changed.js'; - -const canonicalCreated = { - event_type: 'state:created' as const, - scope: 'agent' as const, - key: 'session/sess-a/turn_state', - old_value: null, - new_value: { state: 'provisioning' }, - message_type: 'state' as const, -}; - -const canonicalUpdated = { - event_type: 'state:updated' as const, - scope: 'agent' as const, - key: 'session/sess-a/turn_state', - old_value: { state: 'function_execute' }, - new_value: { state: 'function_awaiting_approval' }, - message_type: 'state' as const, -}; - -function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unknown }> } { - const emits: Array<{ session_id: string; event: unknown }> = []; - const iii = { - trigger: vi.fn(async ({ function_id, payload }: { function_id: string; payload: unknown }) => { - if (function_id === 'stream::set') { - const p = payload as { group_id: string; data: unknown }; - emits.push({ session_id: p.group_id, event: p.data }); - return null; - } - return null; - }), - } as unknown as ISdk; - return { iii, emits }; -} - -describe('TurnStateWriteEventSchema / isTurnStateWrite', () => { - it('accepts the canonical agent state write shape from the iii engine', () => { - expect(TurnStateWriteEventSchema.parse(canonicalCreated)).toEqual({ - session_id: 'sess-a', - event_type: 'state:created', - new_value: { state: 'provisioning' }, - }); - - expect(TurnStateWriteEventSchema.parse(canonicalUpdated)).toEqual({ - session_id: 'sess-a', - event_type: 'state:updated', - new_value: { state: 'function_awaiting_approval' }, - old_value: { state: 'function_execute' }, - }); - - expect(isTurnStateWrite(canonicalCreated)).toBe(true); - expect(isTurnStateWrite(canonicalUpdated)).toBe(true); - }); - - it('accepts minimal shapes without optional engine metadata', () => { - expect( - parseTurnStateWrite({ - event_type: 'state:created', - key: 'session/sess-a/turn_state', - new_value: { state: 'provisioning' }, - }), - ).toEqual({ - session_id: 'sess-a', - event_type: 'state:created', - new_value: { state: 'provisioning' }, - }); - }); - - it('rejects nested payload wrappers (no in-repo caller uses them)', () => { - expect(() => TurnStateWriteEventSchema.parse({ payload: canonicalCreated })).toThrow(); - expect(() => TurnStateWriteEventSchema.parse({ data: canonicalCreated })).toThrow(); - expect(isTurnStateWrite({ payload: canonicalCreated })).toBe(false); - }); - - it('rejects non-turn_state agent keys', () => { - expect( - isTurnStateWrite({ - ...canonicalCreated, - key: 'session/sess-a/abort_signal', - new_value: { state: 'true' }, - }), - ).toBe(false); - }); - - it('rejects state:deleted', () => { - expect( - isTurnStateWrite({ - event_type: 'state:deleted', - scope: 'agent', - key: 'session/sess-a/turn_state', - old_value: { state: 'provisioning' }, - new_value: null, - message_type: 'state', - }), - ).toBe(false); - }); - - it('rejects missing key, empty session id segment, or malformed new_value', () => { - expect(isTurnStateWrite({ ...canonicalCreated, key: undefined })).toBe(false); - expect(isTurnStateWrite({ ...canonicalCreated, key: 'session//turn_state' })).toBe(false); - expect(isTurnStateWrite({ ...canonicalCreated, new_value: { not_state: 'x' } })).toBe(false); - expect(isTurnStateWrite({ ...canonicalCreated, new_value: null })).toBe(false); - expect(isTurnStateWrite(null)).toBe(false); - expect(isTurnStateWrite(undefined)).toBe(false); - }); -}); - -describe('handleTurnStateWrite', () => { - it('emits turn_state_changed on agent::events with group_id = session_id', async () => { - const { iii, emits } = fakeIii(); - await handleTurnStateWrite(iii, { - ...canonicalUpdated, - new_value: { state: 'function_awaiting_approval', awaiting_approval: [] }, - old_value: { state: 'function_execute', awaiting_approval: null }, - }); - expect(emits).toHaveLength(1); - expect(emits[0]?.session_id).toBe('sess-a'); - expect(emits[0]?.event).toMatchObject({ - type: 'turn_state_changed', - event_type: 'state:updated', - new_value: { state: 'function_awaiting_approval' }, - old_value: { state: 'function_execute' }, - }); - }); - - it('no-ops when the event does not match the condition (direct invoke bypasses engine condition)', async () => { - const { iii, emits } = fakeIii(); - await handleTurnStateWrite(iii, { - event_type: 'state:created', - scope: 'agent', - key: 'session/sess-a/abort_signal', - old_value: null, - new_value: true, - message_type: 'state', - }); - expect(emits).toEqual([]); - }); - - it('swallows emit failures (logs only, never rethrows)', async () => { - const iii = { - trigger: vi.fn(async () => { - throw new Error('stream::set down'); - }), - } as unknown as ISdk; - await expect(handleTurnStateWrite(iii, canonicalCreated)).resolves.toBeUndefined(); - }); - - it('omits old_value from the emitted event when state:created', async () => { - const { iii, emits } = fakeIii(); - await handleTurnStateWrite(iii, canonicalCreated); - expect(emits).toHaveLength(1); - const event = emits[0]?.event as Record; - expect(event.type).toBe('turn_state_changed'); - expect('old_value' in event).toBe(false); - }); -}); diff --git a/harness-node/tests/turn-orchestrator/provisioning.test.ts b/harness-node/tests/turn-orchestrator/provisioning.test.ts index 662e87a5..9c87123c 100644 --- a/harness-node/tests/turn-orchestrator/provisioning.test.ts +++ b/harness-node/tests/turn-orchestrator/provisioning.test.ts @@ -1,8 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; +import type { TurnOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; import { type TurnStateRecord, newRecord } from '../../src/turn-orchestrator/state.js'; +import { TurnStepPayloadSchema } from '../../src/turn-orchestrator/turn-step-payload.js'; import { + execute, handleProvisioning, parseDirectoryBody, parseRunRequest, @@ -65,7 +68,7 @@ describe('parseDirectoryBody', () => { }); describe('handleProvisioning', () => { - it('materializes schemas, persists built prompt, and advances to awaiting_assistant', async () => { + it('materializes schemas, persists built prompt, and advances to assistant_streaming', async () => { const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; const { iii, calls } = fakeIii({ 'directory::skills::index': { body: 'INDEX' }, @@ -86,7 +89,7 @@ describe('handleProvisioning', () => { await handleProvisioning(iii, cfg, rec); - expect(rec.state).toBe('awaiting_assistant'); + expect(rec.state).toBe('assistant_streaming'); expect(saveSchemas).toHaveBeenCalledWith(iii, 's1', [ expect.objectContaining({ name: 'agent_trigger' }), ]); @@ -136,7 +139,7 @@ describe('handleProvisioning', () => { await handleProvisioning(iii, cfg, rec); - expect(rec.state).toBe('awaiting_assistant'); + expect(rec.state).toBe('assistant_streaming'); expect(saveRunRequest).toHaveBeenCalledWith( iii, 's1', @@ -146,3 +149,62 @@ describe('handleProvisioning', () => { ); }); }); + +describe('TurnStepPayloadSchema', () => { + it('accepts the flat shape every in-repo caller uses', () => { + expect(TurnStepPayloadSchema.parse({ session_id: 's1' })).toEqual({ session_id: 's1' }); + }); +}); + +describe('execute', () => { + const cfg: TurnOrchestratorConfig = { system_default_skills: [] }; + + it('throws when the session record is missing', async () => { + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(null); + + await expect(execute({} as ISdk, cfg, { session_id: 'missing' })).rejects.toThrow( + 'turn::provisioning invariant: missing session missing', + ); + }); + + it('returns stale skip when persisted state drifted', async () => { + const rec = { ...newRecord('s1'), state: 'assistant_streaming' as const }; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + const saveRecord = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); + + const result = await execute({} as ISdk, cfg, { session_id: 's1' }); + + expect(result).toEqual({ ok: true, skipped: true, reason: 'stale' }); + expect(saveRecord).not.toHaveBeenCalled(); + }); + + it('runs handleProvisioning, saves the record, and returns transition metadata', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; + const { iii } = fakeIii(); + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + const saveRecord = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({}); + vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); + vi.spyOn(persistence, 'saveRunRequest').mockResolvedValue(); + + const result = await execute(iii, cfg, { session_id: 's1' }); + + expect(saveRecord).toHaveBeenCalledWith(iii, rec); + expect(result).toEqual({ + ok: true, + from_state: 'provisioning', + to_state: 'assistant_streaming', + }); + }); + + it('wraps handler failures as transition errors', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); + vi.spyOn(persistence, 'loadRunRequest').mockRejectedValue(new Error('boom')); + + await expect(execute({} as ISdk, cfg, { session_id: 's1' })).rejects.toThrow( + 'transition from provisioning failed: Error: boom', + ); + }); +}); diff --git a/harness-node/tests/turn-orchestrator/run-start.test.ts b/harness-node/tests/turn-orchestrator/run-start.test.ts index 40be172e..ee6c2bff 100644 --- a/harness-node/tests/turn-orchestrator/run-start.test.ts +++ b/harness-node/tests/turn-orchestrator/run-start.test.ts @@ -1,14 +1,18 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ISdk } from '../../src/runtime/iii.js'; +import { TriggerAction, type ISdk } from '../../src/runtime/iii.js'; import { RunStartPayloadSchema, execute, register } from '../../src/turn-orchestrator/run-start.js'; -type TriggerCall = { function_id: string; payload: unknown }; +type TriggerCall = { function_id: string; payload: unknown; action?: unknown }; function fakeIii(): { iii: ISdk; calls: TriggerCall[] } { const calls: TriggerCall[] = []; const iii = { - trigger: async (req: { function_id: string; payload: T }): Promise => { - calls.push({ function_id: req.function_id, payload: req.payload }); + trigger: async (req: { + function_id: string; + payload: T; + action?: unknown; + }): Promise => { + calls.push({ function_id: req.function_id, payload: req.payload, action: req.action }); return null as R; }, registerFunction: vi.fn(), @@ -140,7 +144,7 @@ describe('register', () => { }); describe('execute', () => { - it('saves initial session state to wake the reactive step trigger', async () => { + it('saves initial session state and enqueues turn::provisioning via saveRecord wake', async () => { const { iii, calls } = fakeIii(); const result = await execute(iii, RunStartPayloadSchema.parse(harnessRunStartPayload)); @@ -158,7 +162,9 @@ describe('execute', () => { 'provisioning', ); - const publish = calls.find((c) => c.function_id === 'iii::durable::publish'); - expect(publish).toBeUndefined(); + const wake = calls.find((c) => c.function_id === 'turn::provisioning'); + expect(wake).toBeDefined(); + expect(wake?.payload).toEqual({ session_id: 'sess-1' }); + expect(wake?.action).toEqual(TriggerAction.Enqueue({ queue: 'turn-step' })); }); }); diff --git a/harness-node/tests/turn-orchestrator/state.test.ts b/harness-node/tests/turn-orchestrator/state.test.ts index 9b0b7b91..574b6d57 100644 --- a/harness-node/tests/turn-orchestrator/state.test.ts +++ b/harness-node/tests/turn-orchestrator/state.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import type { TurnOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; import type { AwaitingApprovalEntry, TurnState, @@ -13,7 +12,7 @@ import { transitionTo, turnStateKey, } from '../../src/turn-orchestrator/state.js'; -import { step } from '../../src/turn-orchestrator/transitions.js'; +import { handleAwaitingApproval } from '../../src/turn-orchestrator/states/function-awaiting-approval.js'; describe('TurnStateRecord', () => { it('starts in provisioning', () => { @@ -64,14 +63,13 @@ describe('awaiting_approval field', () => { }); }); -describe('step dispatches function_awaiting_approval', () => { - it('runs the awaiting-approval handler for that state', async () => { - const cfg = {} as TurnOrchestratorConfig; +describe('handleAwaitingApproval with empty queue', () => { + it('advances to function_execute when awaiting_approval is empty', async () => { const rec = newRecord('s1'); transitionTo(rec, 'function_awaiting_approval'); rec.awaiting_approval = []; - await step({} as ISdk, cfg, rec); + await handleAwaitingApproval({} as ISdk, rec); expect(rec.state).toBe('function_execute'); }); diff --git a/harness-node/tests/turn-orchestrator/steering.test.ts b/harness-node/tests/turn-orchestrator/steering.test.ts index 5dcd090d..97bcec51 100644 --- a/harness-node/tests/turn-orchestrator/steering.test.ts +++ b/harness-node/tests/turn-orchestrator/steering.test.ts @@ -1,24 +1,223 @@ -import { describe, expect, it } from 'vitest'; -import { route } from '../../src/turn-orchestrator/states/steering.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ISdk } from '../../src/runtime/iii.js'; +import type { AgentMessage } from '../../src/types/agent-message.js'; +import * as events from '../../src/turn-orchestrator/events.js'; +import * as persistence from '../../src/turn-orchestrator/persistence.js'; +import { + abortSignalKey, + newRecord, + type TurnStateRecord, +} from '../../src/turn-orchestrator/state.js'; +import { handleSteering, route } from '../../src/turn-orchestrator/states/steering-check.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe('steering route()', () => { - it('abort wins over everything', () => { - expect(route(true, true, true, true)).toBe('abort'); + it.each([ + [true, true, true, true, 'abort'], + [true, false, false, false, 'abort'], + [false, true, true, true, 'steering'], + [false, true, false, false, 'steering'], + [false, false, true, true, 'followup'], + [false, false, true, false, 'followup'], + [false, false, false, true, 'continue_after_function'], + [false, false, false, false, 'end_turn'], + ] as const)( + 'route(%s, %s, %s, %s) -> %s', + (abort, has_steering, has_followup, has_function_results, expected) => { + expect(route(abort, has_steering, has_followup, has_function_results)).toBe(expected); + }, + ); +}); + +function userMessage(text: string): AgentMessage { + return { role: 'user', content: [{ type: 'text', text }] }; +} + +function makeIii( + opts: { + abort?: boolean; + steeringItems?: AgentMessage[]; + followupItems?: AgentMessage[]; + } = {}, +) { + const { abort = false, steeringItems = [], followupItems = [] } = opts; + const drainCalls: Array<{ name: string; session_id: string }> = []; + + const iii = { + trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { + if (req.function_id === 'state::get') { + const p = req.payload as { key: string }; + if (p.key.endsWith('/abort_signal')) return abort ? true : null; + return null; + } + if (req.function_id === 'session-inbox::drain') { + const p = req.payload as { name: string; session_id: string }; + drainCalls.push(p); + if (p.name === 'steering') return { items: steeringItems }; + if (p.name === 'followup') return { items: followupItems }; + return { items: [] }; + } + if (req.function_id === 'state::update') return { old_value: 0 }; + if (req.function_id === 'stream::set') return null; + return null; + }), + } as unknown as ISdk; + + return { iii, drainCalls }; +} + +function steeringRec( + session_id: string, + overrides: Partial = {}, +): TurnStateRecord { + const rec = newRecord(session_id); + rec.state = 'steering_check'; + return { ...rec, ...overrides }; +} + +describe('handleSteering', () => { + it('abort: persists aborted assistant, emits turn_end, transitions to tearing_down', async () => { + const { iii } = makeIii({ abort: true }); + const rec = steeringRec('s1'); + const loadSpy = vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + const saveSpy = vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleSteering(iii, rec); + + expect(rec.state).toBe('tearing_down'); + expect(rec.turn_end_emitted).toBe(true); + expect(rec.last_assistant?.stop_reason).toBe('aborted'); + expect(loadSpy).toHaveBeenCalledWith(iii, 's1'); + expect(saveSpy).toHaveBeenCalledWith( + iii, + 's1', + expect.arrayContaining([expect.objectContaining({ stop_reason: 'aborted' })]), + ); + expect(emitSpy).toHaveBeenCalledWith( + iii, + 's1', + expect.objectContaining({ + type: 'turn_end', + message: expect.objectContaining({ stop_reason: 'aborted' }), + }), + ); }); - it('steering takes precedence over followup and function results', () => { - expect(route(false, true, true, true)).toBe('steering'); + it('abort: skips inbox drains', async () => { + const { iii, drainCalls } = makeIii({ + abort: true, + steeringItems: [userMessage('steer')], + followupItems: [userMessage('follow')], + }); + const rec = steeringRec('s1'); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleSteering(iii, rec); + + expect(drainCalls).toHaveLength(0); }); - it('followup takes precedence over function results', () => { - expect(route(false, false, true, true)).toBe('followup'); + it('steering: appends drained messages and transitions to assistant_streaming', async () => { + const steeringItems = [userMessage('steer-me')]; + const { iii } = makeIii({ steeringItems }); + const rec = steeringRec('s1', { + function_results: [{ role: 'function_result', content: [] }] as never, + }); + const loadSpy = vi.spyOn(persistence, 'loadMessages').mockResolvedValue([userMessage('prior')]); + const saveSpy = vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleSteering(iii, rec); + + expect(rec.state).toBe('assistant_streaming'); + expect(rec.function_results).toEqual([]); + expect(rec.turn_end_emitted).toBe(true); + expect(saveSpy).toHaveBeenCalledWith(iii, 's1', [userMessage('prior'), ...steeringItems]); + expect(loadSpy).toHaveBeenCalled(); }); - it('function results route to continue_after_function', () => { - expect(route(false, false, false, true)).toBe('continue_after_function'); + it('followup: drains followup when steering queue is empty', async () => { + const followupItems = [userMessage('follow-up')]; + const { iii, drainCalls } = makeIii({ followupItems }); + const rec = steeringRec('s1'); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + const saveSpy = vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleSteering(iii, rec); + + expect(rec.state).toBe('assistant_streaming'); + expect(drainCalls.map((c) => c.name)).toEqual(['steering', 'followup']); + expect(saveSpy).toHaveBeenCalledWith(iii, 's1', followupItems); + }); + + it('followup: skipped when steering queue has items', async () => { + const { iii, drainCalls } = makeIii({ + steeringItems: [userMessage('steer')], + followupItems: [userMessage('follow')], + }); + const rec = steeringRec('s1'); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleSteering(iii, rec); + + expect(drainCalls.map((c) => c.name)).toEqual(['steering']); + expect(rec.state).toBe('assistant_streaming'); }); - it('nothing pending -> end_turn', () => { - expect(route(false, false, false, false)).toBe('end_turn'); + it('continue_after_function: clears function_results without reloading messages', async () => { + const { iii } = makeIii(); + const rec = steeringRec('s1', { + function_results: [{ role: 'function_result', content: [] }] as never, + turn_end_emitted: true, + }); + const loadSpy = vi.spyOn(persistence, 'loadMessages'); + const emitSpy = vi.spyOn(events, 'emit'); + + await handleSteering(iii, rec); + + expect(rec.state).toBe('assistant_streaming'); + expect(rec.function_results).toEqual([]); + expect(loadSpy).not.toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('end_turn: emits turn_end once and transitions to tearing_down', async () => { + const { iii } = makeIii(); + const rec = steeringRec('s1'); + const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); + const loadSpy = vi.spyOn(persistence, 'loadMessages'); + + await handleSteering(iii, rec); + + expect(rec.state).toBe('tearing_down'); + expect(rec.turn_end_emitted).toBe(true); + expect(emitSpy).toHaveBeenCalledWith(iii, 's1', expect.objectContaining({ type: 'turn_end' })); + expect(loadSpy).not.toHaveBeenCalled(); + }); + + it('reads abort via state::get on abort_signal key', async () => { + const { iii } = makeIii({ abort: true }); + const rec = steeringRec('s1'); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(events, 'emit').mockResolvedValue(undefined); + + await handleSteering(iii, rec); + + expect(iii.trigger).toHaveBeenCalledWith( + expect.objectContaining({ + function_id: 'state::get', + payload: { scope: 'agent', key: abortSignalKey('s1') }, + }), + ); }); }); diff --git a/harness-node/tests/turn-orchestrator/subscriber.test.ts b/harness-node/tests/turn-orchestrator/subscriber.test.ts deleted file mode 100644 index 5519edd6..00000000 --- a/harness-node/tests/turn-orchestrator/subscriber.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ISdk } from '../../src/runtime/iii.js'; -import type { TurnOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; -import * as persistence from '../../src/turn-orchestrator/persistence.js'; -import { newRecord, turnStateKey } from '../../src/turn-orchestrator/state.js'; -import * as transitions from '../../src/turn-orchestrator/transitions.js'; -import { execute, shouldStep, StepPayloadSchema } from '../../src/turn-orchestrator/subscriber.js'; - -const cfg: TurnOrchestratorConfig = { system_default_skills: [] }; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('StepPayloadSchema', () => { - it('accepts the flat shape every in-repo caller uses', () => { - expect(StepPayloadSchema.parse({ session_id: 'sess-abc' })).toEqual({ - session_id: 'sess-abc', - }); - }); - - it('strips extra keys (engine may add metadata later)', () => { - expect(StepPayloadSchema.parse({ session_id: 's1', trace_id: 't1' })).toEqual({ - session_id: 's1', - }); - }); - - it('rejects publish envelope shapes — durable subscriber receives data only', () => { - expect(() => - StepPayloadSchema.parse({ - topic: 'turn::step_requested', - data: { session_id: 's1' }, - }), - ).toThrow(); - }); - - it('rejects nested payload wrappers (no in-repo caller uses them)', () => { - expect(() => StepPayloadSchema.parse({ data: { session_id: 's1' } })).toThrow(); - expect(() => StepPayloadSchema.parse({ payload: { session_id: 's1' } })).toThrow(); - }); - - it('rejects missing, empty, or non-string session_id', () => { - expect(() => StepPayloadSchema.parse({})).toThrow(); - expect(() => StepPayloadSchema.parse({ session_id: '' })).toThrow(); - expect(() => StepPayloadSchema.parse({ session_id: 42 })).toThrow(); - expect(() => StepPayloadSchema.parse({ session_id: null })).toThrow(); - expect(() => StepPayloadSchema.parse(null)).toThrow(); - expect(() => StepPayloadSchema.parse(undefined)).toThrow(); - }); -}); - -describe('shouldStep', () => { - it('returns false when the record does not exist', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - vi.spyOn(persistence, 'loadRecord').mockResolvedValue(null); - - await expect(shouldStep(iii, { session_id: 'missing' })).resolves.toBe(false); - }); - - it('returns false when the record is stopped', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - const rec = newRecord('s1'); - rec.state = 'stopped'; - vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); - - await expect(shouldStep(iii, { session_id: 's1' })).resolves.toBe(false); - }); - - it('returns true for a known non-terminal session', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - const rec = newRecord('s1'); - rec.state = 'provisioning'; - vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); - - await expect(shouldStep(iii, { session_id: 's1' })).resolves.toBe(true); - }); - - it('rejects malformed payloads', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - const loadSpy = vi.spyOn(persistence, 'loadRecord'); - - await expect(shouldStep(iii, {})).resolves.toBe(false); - expect(loadSpy).not.toHaveBeenCalled(); - }); -}); - -function mockTurnStateGet(iii: ISdk, rec: ReturnType | null): void { - vi.mocked(iii.trigger).mockImplementation(async (req) => { - if ( - req.function_id === 'state::get' && - req.payload && - typeof req.payload === 'object' && - (req.payload as { key?: string }).key === turnStateKey(rec?.session_id ?? 'missing') - ) { - return rec; - } - throw new Error(`unexpected trigger ${req.function_id}`); - }); -} - -describe('execute', () => { - it('throws when the record does not exist', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - mockTurnStateGet(iii, null); - - await expect(execute(iii, cfg, { session_id: 'missing' })).rejects.toThrow( - 'turn::step invariant: missing session missing', - ); - }); - - it('steps, persists, and returns from_state/to_state on success', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - const rec = newRecord('s1'); - rec.state = 'provisioning'; - mockTurnStateGet(iii, rec); - vi.spyOn(transitions, 'step').mockImplementation(async (_iii, _cfg, r) => { - r.state = 'awaiting_assistant'; - }); - const saveSpy = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(undefined); - - await expect(execute(iii, cfg, { session_id: 's1' })).resolves.toEqual({ - ok: true, - from_state: 'provisioning', - to_state: 'awaiting_assistant', - }); - expect(saveSpy).toHaveBeenCalledWith(iii, rec); - }); - - it('throws with from_state when transition fails', async () => { - const iii = { trigger: vi.fn() } as unknown as ISdk; - const rec = newRecord('s1'); - rec.state = 'function_execute'; - mockTurnStateGet(iii, rec); - vi.spyOn(transitions, 'step').mockRejectedValue(new Error('sandbox gone')); - - await expect(execute(iii, cfg, { session_id: 's1' })).rejects.toThrow( - 'transition from function_execute failed: Error: sandbox gone', - ); - }); -}); diff --git a/harness-node/tests/turn-orchestrator/turn-state-write.test.ts b/harness-node/tests/turn-orchestrator/turn-state-write.test.ts new file mode 100644 index 00000000..468500c1 --- /dev/null +++ b/harness-node/tests/turn-orchestrator/turn-state-write.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ISdk } from '../../src/runtime/iii.js'; +import { emitTurnStateChanged } from '../../src/turn-orchestrator/turn-state-write.js'; + +function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unknown }> } { + const emits: Array<{ session_id: string; event: unknown }> = []; + const iii = { + trigger: vi.fn(async ({ function_id, payload }: { function_id: string; payload: unknown }) => { + if (function_id === 'stream::set') { + const p = payload as { group_id: string; data: unknown }; + emits.push({ session_id: p.group_id, event: p.data }); + return null; + } + if (function_id === 'state::update') { + return { old_value: 0 }; + } + return null; + }), + } as unknown as ISdk; + return { iii, emits }; +} + +describe('emitTurnStateChanged', () => { + it('emits turn_state_changed on agent::events with group_id = session_id', async () => { + const { iii, emits } = fakeIii(); + await emitTurnStateChanged( + iii, + 'sess-a', + 'state:updated', + { state: 'function_awaiting_approval', awaiting_approval: [] }, + { state: 'function_execute', awaiting_approval: null }, + ); + expect(emits).toHaveLength(1); + expect(emits[0]?.session_id).toBe('sess-a'); + expect(emits[0]?.event).toMatchObject({ + type: 'turn_state_changed', + event_type: 'state:updated', + new_value: { state: 'function_awaiting_approval' }, + old_value: { state: 'function_execute' }, + }); + }); + + it('swallows emit failures (logs only, never rethrows)', async () => { + const iii = { + trigger: vi.fn(async () => { + throw new Error('stream::set down'); + }), + } as unknown as ISdk; + await expect( + emitTurnStateChanged(iii, 'sess-a', 'state:created', { state: 'provisioning' }), + ).resolves.toBeUndefined(); + }); + + it('omits old_value from the emitted event when state:created', async () => { + const { iii, emits } = fakeIii(); + await emitTurnStateChanged(iii, 'sess-a', 'state:created', { state: 'provisioning' }); + expect(emits).toHaveLength(1); + const event = emits[0]?.event as Record; + expect(event.type).toBe('turn_state_changed'); + expect('old_value' in event).toBe(false); + }); +}); diff --git a/harness-node/tests/turn-orchestrator/wake.test.ts b/harness-node/tests/turn-orchestrator/wake.test.ts new file mode 100644 index 00000000..115fa729 --- /dev/null +++ b/harness-node/tests/turn-orchestrator/wake.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TriggerAction } from '../../src/runtime/iii.js'; +import type { ISdk } from '../../src/runtime/iii.js'; +import { newRecord } from '../../src/turn-orchestrator/state.js'; +import { shouldWakeStep, wakeFromRecord, wakeState } from '../../src/turn-orchestrator/wake.js'; + +describe('shouldWakeStep', () => { + it('accepts first write to a stepable state', () => { + expect(shouldWakeStep(null, 'provisioning')).toBe(true); + }); + + it('accepts transitions to another stepable state', () => { + expect(shouldWakeStep('provisioning', 'assistant_streaming')).toBe(true); + expect(shouldWakeStep('assistant_finished', 'function_execute')).toBe(true); + }); + + it('rejects terminal state (stopped)', () => { + expect(shouldWakeStep('tearing_down', 'stopped')).toBe(false); + }); + + it('rejects function_awaiting_approval (orchestrator parks here)', () => { + expect(shouldWakeStep('function_execute', 'function_awaiting_approval')).toBe(false); + }); + + it('rejects same-state writes', () => { + expect(shouldWakeStep('function_execute', 'function_execute')).toBe(false); + }); +}); + +describe('wakeState', () => { + it('enqueues turn::{state} on the turn-step FIFO queue', async () => { + const triggers: Array<{ function_id: string; payload: unknown; action?: unknown }> = []; + const iii = { + trigger: vi.fn(async (req: { function_id: string; payload: unknown; action?: unknown }) => { + triggers.push(req); + return null; + }), + } as unknown as ISdk; + + await wakeState(iii, 'sess-abc', 'assistant_streaming'); + + expect(triggers).toHaveLength(1); + expect(triggers[0]?.function_id).toBe('turn::assistant_streaming'); + expect(triggers[0]?.payload).toEqual({ session_id: 'sess-abc' }); + expect(triggers[0]?.action).toEqual(TriggerAction.Enqueue({ queue: 'turn-step' })); + }); + + it('swallows enqueue failures (logs only, never rethrows)', async () => { + const iii = { + trigger: vi.fn(async () => { + throw new Error('queue down'); + }), + } as unknown as ISdk; + + await expect(wakeState(iii, 'sess-abc', 'provisioning')).resolves.toBeUndefined(); + }); +}); + +describe('wakeFromRecord', () => { + it('enqueues turn::{currentState} from persisted record', async () => { + const rec = newRecord('sess-x'); + rec.state = 'function_awaiting_approval'; + const triggers: Array<{ function_id: string; payload: unknown; action?: unknown }> = []; + const iii = { + trigger: vi.fn(async (req: { function_id: string; payload: unknown; action?: unknown }) => { + if (req.function_id === 'state::get') { + return rec; + } + triggers.push(req); + return null; + }), + } as unknown as ISdk; + + await wakeFromRecord(iii, 'sess-x'); + + expect(triggers).toHaveLength(1); + expect(triggers[0]?.function_id).toBe('turn::function_awaiting_approval'); + expect(triggers[0]?.payload).toEqual({ session_id: 'sess-x' }); + }); + + it('no-ops when session is stopped', async () => { + const rec = newRecord('sess-y'); + rec.state = 'stopped'; + const iii = { + trigger: vi.fn(async (req: { function_id: string }) => { + if (req.function_id === 'state::get') return rec; + return null; + }), + } as unknown as ISdk; + + await wakeFromRecord(iii, 'sess-y'); + expect(iii.trigger).toHaveBeenCalledTimes(1); + }); +}); From 4467fabe6dc939c42fb7d3b3df291d24a3a031c9 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Sat, 23 May 2026 18:20:07 -0300 Subject: [PATCH 09/16] style: apply biome formatting to register and steering test --- harness/src/harness/register.ts | 4 ++-- harness/tests/turn-orchestrator/steering.test.ts | 15 ++++----------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/harness/src/harness/register.ts b/harness/src/harness/register.ts index 82b6ba3f..0d1a09d5 100644 --- a/harness/src/harness/register.ts +++ b/harness/src/harness/register.ts @@ -13,12 +13,12 @@ export async function register(iii: ISdk, ctx: { configPath: string; url: string const cfg = await loadConfig(ctx.configPath); const harness = loadHarnessConfig(cfg); - + registerTrigger(iii); registerSubscriptions(iii, fanoutState); spawnPumps(iii, fanoutState); registerFs(iii, ctx.url); - + const handle = await loadAndWatch(harness.permissions_path); registerPolicy(iii, handle); } diff --git a/harness/tests/turn-orchestrator/steering.test.ts b/harness/tests/turn-orchestrator/steering.test.ts index 97bcec51..28477cb1 100644 --- a/harness/tests/turn-orchestrator/steering.test.ts +++ b/harness/tests/turn-orchestrator/steering.test.ts @@ -24,12 +24,9 @@ describe('steering route()', () => { [false, false, true, false, 'followup'], [false, false, false, true, 'continue_after_function'], [false, false, false, false, 'end_turn'], - ] as const)( - 'route(%s, %s, %s, %s) -> %s', - (abort, has_steering, has_followup, has_function_results, expected) => { - expect(route(abort, has_steering, has_followup, has_function_results)).toBe(expected); - }, - ); + ] as const)('route(%s, %s, %s, %s) -> %s', (abort, has_steering, has_followup, has_function_results, expected) => { + expect(route(abort, has_steering, has_followup, has_function_results)).toBe(expected); + }); }); function userMessage(text: string): AgentMessage { @@ -37,11 +34,7 @@ function userMessage(text: string): AgentMessage { } function makeIii( - opts: { - abort?: boolean; - steeringItems?: AgentMessage[]; - followupItems?: AgentMessage[]; - } = {}, + opts: { abort?: boolean; steeringItems?: AgentMessage[]; followupItems?: AgentMessage[] } = {}, ) { const { abort = false, steeringItems = [], followupItems = [] } = opts; const drainCalls: Array<{ name: string; session_id: string }> = []; From 829b60edc626c8268cbf15663316875f3f1c0bcd Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Sun, 24 May 2026 07:03:06 -0300 Subject: [PATCH 10/16] refactor: rename message_end to message_complete and update related translations - Updated the event type from `message_end` to `message_complete` in the README and codebase for consistency. - Adjusted the translation logic in `handler.rs` to handle the new event type, ensuring proper rendering of assistant messages. - Modified tests to reflect the change in event type, ensuring all related functionality is covered. - Cleaned up comments and documentation to align with the new terminology. --- acp/README.md | 2 +- acp/src/handler.rs | 21 +- console/web/src/lib/backend/translate.test.ts | 66 +- console/web/src/lib/backend/translate.ts | 76 +- console/web/src/types/iii-agent-event.ts | 25 +- harness-node/pnpm-lock.yaml | 1955 +++++++++++++++++ harness/README.md | 2 +- harness/docs/architecture.md | 4 +- harness/docs/workers/turn-orchestrator.md | 11 +- harness/src/harness/trigger.ts | 2 +- .../src/turn-orchestrator/agent-trigger.ts | 177 +- harness/src/turn-orchestrator/get-state.ts | 16 +- .../src/turn-orchestrator/on-abort-signal.ts | 18 +- harness/src/turn-orchestrator/persistence.ts | 75 +- harness/src/turn-orchestrator/register.ts | 2 - harness/src/turn-orchestrator/run-request.ts | 28 + harness/src/turn-orchestrator/run-start.ts | 29 +- .../src/turn-orchestrator/run-transition.ts | 53 + harness/src/turn-orchestrator/schemas.ts | 57 + harness/src/turn-orchestrator/state.ts | 17 +- .../states/assistant-finished.ts | 72 +- .../states/assistant-streaming.ts | 47 +- .../states/function-awaiting-approval.ts | 33 +- .../states/function-execute.ts | 225 +- .../turn-orchestrator/states/provisioning.ts | 69 +- .../states/steering-check.ts | 31 +- .../turn-orchestrator/states/tearing-down.ts | 53 +- .../turn-orchestrator/turn-step-payload.ts | 31 - harness/src/types/agent-event.ts | 8 +- harness/tests/harness/trigger.test.ts | 2 - .../integration/on-record-written.e2e.test.ts | 34 +- .../turn-orchestrator/agent-trigger.test.ts | 38 +- .../tests/turn-orchestrator/assistant.test.ts | 39 + .../tests/turn-orchestrator/functions.test.ts | 11 +- .../tests/turn-orchestrator/get-state.test.ts | 3 +- .../turn-orchestrator/on-abort-signal.test.ts | 2 +- .../turn-orchestrator/provisioning.test.ts | 115 +- .../turn-orchestrator/run-request.test.ts | 38 + .../tests/turn-orchestrator/run-start.test.ts | 7 +- .../turn-orchestrator/run-transition.test.ts | 94 + .../turn-orchestrator/tearing-down.test.ts | 16 - 41 files changed, 2769 insertions(+), 835 deletions(-) create mode 100644 harness-node/pnpm-lock.yaml create mode 100644 harness/src/turn-orchestrator/run-request.ts create mode 100644 harness/src/turn-orchestrator/run-transition.ts create mode 100644 harness/src/turn-orchestrator/schemas.ts delete mode 100644 harness/src/turn-orchestrator/turn-step-payload.ts create mode 100644 harness/tests/turn-orchestrator/run-request.test.ts create mode 100644 harness/tests/turn-orchestrator/run-transition.test.ts diff --git a/acp/README.md b/acp/README.md index d1ec7972..dbc0128f 100644 --- a/acp/README.md +++ b/acp/README.md @@ -281,7 +281,7 @@ connection at startup and translates each event: |---|---| | `message_update { llm_event: text_delta }` | `agent_message_chunk` | | `message_update { llm_event: thinking_delta }` | `agent_thought_chunk` | -| `message_end` (assistant role, full text) | `agent_message_chunk` (one shot) | +| `message_complete` (assistant role, full text) | `agent_message_chunk` (one shot) | | `tool_execution_start` | `tool_call` (status: `in_progress`) | | `tool_execution_end` | `tool_call_update` (status: `completed`/`failed`) | | other | dropped | diff --git a/acp/src/handler.rs b/acp/src/handler.rs index 0f9f73b9..6c7e3291 100644 --- a/acp/src/handler.rs +++ b/acp/src/handler.rs @@ -800,14 +800,11 @@ fn translate_agent_event(event: &Value) -> Option> { "content": { "type": "text", "text": delta }, })]) } - // turn-orchestrator (current head) does not emit message_update - // text deltas — provider-router consumes the streaming response - // internally and returns the fully-assembled assistant message, - // surfaced as a single message_end event. Translate those to one - // agent_message_chunk per text content block so Zed renders the - // full reply. message_start and tool-result message_end variants - // are dropped to avoid duplication. - "message_end" => { + // Batch/non-delta clients receive the fully-assembled assistant + // message as a single message_complete event. Translate those to + // one agent_message_chunk per text content block so Zed renders the + // full reply. + "message_complete" => { let message = event.get("message")?; if message.get("role").and_then(|v| v.as_str()) != Some("assistant") { return None; @@ -1047,9 +1044,9 @@ mod tests { } #[test] - fn translate_message_end_assistant_emits_chunk() { + fn translate_message_complete_assistant_emits_chunk() { let ev = json!({ - "type": "message_end", + "type": "message_complete", "message": { "role": "assistant", "content": [{ "type": "text", "text": "hi there" }], @@ -1064,9 +1061,9 @@ mod tests { } #[test] - fn translate_message_end_user_dropped() { + fn translate_message_complete_user_dropped() { let ev = json!({ - "type": "message_end", + "type": "message_complete", "message": { "role": "user", "content": [{ "type": "text", "text": "x" }], diff --git a/console/web/src/lib/backend/translate.test.ts b/console/web/src/lib/backend/translate.test.ts index d22832fa..750f61d6 100644 --- a/console/web/src/lib/backend/translate.test.ts +++ b/console/web/src/lib/backend/translate.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AgentEvent } from '@/types/iii-agent-event' import { createTurnStateTranslator, translateAgentEvent } from './translate' -describe('translateAgentEvent — message_end stop_reason surfacing', () => { +describe('translateAgentEvent — message_complete', () => { const baseAssistant = { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'partial reply…' }], @@ -11,18 +11,36 @@ describe('translateAgentEvent — message_end stop_reason surfacing', () => { timestamp: 0, } - it('emits ONLY assistant-end for a clean stop_reason="end"', () => { + it('emits ONLY assistant-end for a clean stop_reason="end" when body was streamed', () => { const event: AgentEvent = { - type: 'message_end', + type: 'message_complete', message: { ...baseAssistant, stop_reason: 'end' }, + body_streamed: true, } expect(translateAgentEvent(event)).toEqual([{ kind: 'assistant-end' }]) }) + it('emits assistant-token blocks and assistant-end for a non-streamed batch message', () => { + const event: AgentEvent = { + type: 'message_complete', + message: { + ...baseAssistant, + stop_reason: 'end', + content: [{ type: 'text', text: 'hello batch' }], + }, + body_streamed: false, + } + expect(translateAgentEvent(event)).toEqual([ + { kind: 'assistant-token', token: 'hello batch' }, + { kind: 'assistant-end' }, + ]) + }) + it('emits assistant-end + stop-reason notice when the turn hit max_tokens (stop_reason="length")', () => { const event: AgentEvent = { - type: 'message_end', + type: 'message_complete', message: { ...baseAssistant, stop_reason: 'length' }, + body_streamed: true, } const out = translateAgentEvent(event) expect(out[0]).toEqual({ kind: 'assistant-end' }) @@ -31,12 +49,14 @@ describe('translateAgentEvent — message_end stop_reason surfacing', () => { it('emits assistant-end + stop-reason notice carrying error_message when stop_reason="error"', () => { const event: AgentEvent = { - type: 'message_end', + type: 'message_complete', message: { ...baseAssistant, stop_reason: 'error', - error_message: 'lmstudio stream closed mid-response after ~3214 output tokens', + error_message: + 'lmstudio stream closed mid-response after ~3214 output tokens', }, + body_streamed: true, } const out = translateAgentEvent(event) expect(out[0]).toEqual({ kind: 'assistant-end' }) @@ -49,8 +69,9 @@ describe('translateAgentEvent — message_end stop_reason surfacing', () => { it('emits assistant-end + stop-reason on abort', () => { const event: AgentEvent = { - type: 'message_end', + type: 'message_complete', message: { ...baseAssistant, stop_reason: 'aborted' }, + body_streamed: true, } const out = translateAgentEvent(event) expect(out).toHaveLength(2) @@ -59,15 +80,16 @@ describe('translateAgentEvent — message_end stop_reason surfacing', () => { it('does NOT emit a stop-reason notice for function_call (turn will continue)', () => { const event: AgentEvent = { - type: 'message_end', + type: 'message_complete', message: { ...baseAssistant, stop_reason: 'function_call' }, + body_streamed: true, } expect(translateAgentEvent(event)).toEqual([{ kind: 'assistant-end' }]) }) - it('returns [] for non-assistant message_end (user/function_result messages)', () => { + it('returns [] for non-assistant message_complete (user/function_result messages)', () => { const event: AgentEvent = { - type: 'message_end', + type: 'message_complete', message: { role: 'user', content: [{ type: 'text', text: 'hi' }], @@ -79,8 +101,9 @@ describe('translateAgentEvent — message_end stop_reason surfacing', () => { it('omits the error_message field when none was provided', () => { const event: AgentEvent = { - type: 'message_end', + type: 'message_complete', message: { ...baseAssistant, stop_reason: 'length' }, + body_streamed: true, } const out = translateAgentEvent(event) expect(out[1]).toEqual({ kind: 'stop-reason', reason: 'length' }) @@ -136,7 +159,8 @@ describe('translateAgentEvent — compaction_done', () => { } const out = translateAgentEvent(event, 'sess-y') expect( - (out[0] as { kind: 'compaction'; tailStartId: string | null }).tailStartId, + (out[0] as { kind: 'compaction'; tailStartId: string | null }) + .tailStartId, ).toBeNull() }) }) @@ -227,7 +251,11 @@ describe('createTurnStateTranslator', () => { old_value: { state: 'function_awaiting_approval', awaiting_approval: [ - { function_call_id: 'fc-1', function_id: 'shell::shell', args: {} }, + { + function_call_id: 'fc-1', + function_id: 'shell::shell', + args: {}, + }, ], }, }, @@ -245,12 +273,20 @@ describe('createTurnStateTranslator', () => { ], } translate( - { type: 'turn_state_changed', event_type: 'state:created', new_value: pending }, + { + type: 'turn_state_changed', + event_type: 'state:created', + new_value: pending, + }, 'sess-a', ) expect( translate( - { type: 'turn_state_changed', event_type: 'state:created', new_value: pending }, + { + type: 'turn_state_changed', + event_type: 'state:created', + new_value: pending, + }, 'sess-b', ), ).toHaveLength(1) diff --git a/console/web/src/lib/backend/translate.ts b/console/web/src/lib/backend/translate.ts index 541ccf62..ecafb630 100644 --- a/console/web/src/lib/backend/translate.ts +++ b/console/web/src/lib/backend/translate.ts @@ -3,13 +3,12 @@ * `agent::events`) to console/web's `StreamEvent` contract documented in * `PLAYGROUND.md`. * - * Phase 2.A: `turn-orchestrator` now emits `MessageUpdate` events with a + * Phase 2.A: `turn-orchestrator` emits `message_update` events with a * provider `AssistantMessageEvent` payload for every non-terminal frame, * so token-by-token streaming flows through the `message_update` branch - * below. The terminal `MessageStart`/`MessageEnd` for the assistant - * message are still emitted, but `translateMessageStart` no longer - * re-emits the body (the deltas already populated the renderer); it - * just emits any function-call blocks that ride on the same message. + * below. Terminal assistant turns emit a single `message_complete` event; + * when `body_streamed` is false the full body is translated here, otherwise + * only `assistant-end` (+ abnormal `stop-reason`) is emitted. * * Wire mapping: * - `text_delta` → `assistant-token { token: delta }` @@ -23,7 +22,7 @@ * - `function_execution_start` → `fcall-start` (with args). * - `function_execution_end` → `fcall-end` (with result). * - `agent_end` → `assistant-end`. - * - `agent_start` / `turn_start` / `turn_end` / `message_end` / + * - `agent_start` / `turn_start` / `turn_end` / * `function_execution_update` → noop. * - `turn_state_changed` → noop (routed through `createTurnStateTranslator`). */ @@ -31,7 +30,6 @@ import type { AgentEvent, AgentMessage, - AssistantMessage, AssistantMessageEvent, ContentBlock, FunctionResult, @@ -41,7 +39,10 @@ import { diffPending, type PendingApproval } from './pending-approvals-store' import { pendingApprovalsFromTurnState } from './turn-state-mirror' import type { StreamEvent } from './types' -export function translateAgentEvent(event: AgentEvent, sessionId?: string): StreamEvent[] { +export function translateAgentEvent( + event: AgentEvent, + sessionId?: string, +): StreamEvent[] { switch (event.type) { case 'agent_start': case 'turn_start': @@ -52,16 +53,15 @@ export function translateAgentEvent(event: AgentEvent, sessionId?: string): Stre case 'turn_state_changed': return [] - case 'message_end': - if (event.message.role !== 'assistant') return [] - return translateAssistantMessageEnd(event.message) + case 'message_complete': + return translateMessageComplete( + event.message, + event.body_streamed === true, + ) case 'message_update': return translateMessageUpdate(event.llm_event) - case 'message_start': - return translateMessageStart(event.message) - case 'function_execution_start': return [ { @@ -110,7 +110,7 @@ export function translateAgentEvent(event: AgentEvent, sessionId?: string): Stre * `AgentEvent.MessageUpdate.llm_event`) into the StreamEvent contract. * Non-terminal text and thinking deltas drive the renderer; everything * else is silently dropped — the terminal `Done`/`Error` event is - * mirrored by a `MessageEnd` (and ultimately by `agent_end` → + * mirrored by `message_complete` (and ultimately by `agent_end` → * `assistant-end`), so we don't need to surface them here. */ function translateMessageUpdate(llm: AssistantMessageEvent): StreamEvent[] { @@ -130,31 +130,20 @@ function translateMessageUpdate(llm: AssistantMessageEvent): StreamEvent[] { } } -function translateMessageStart(message: AgentMessage): StreamEvent[] { +function translateMessageComplete( + message: AgentMessage, + bodyStreamed: boolean, +): StreamEvent[] { if (message.role !== 'assistant') { return [] } - const hasStreamableContent = message.content.some((b) => b.type === 'text' || b.type === 'thinking') - - if (hasStreamableContent) { - // The provider streamed; nothing to re-emit. - return [] - } const out: StreamEvent[] = [] - for (const block of message.content) { - appendBlock(block, out) + if (!bodyStreamed) { + for (const block of message.content) { + appendBlock(block, out) + } } - return out -} - -/** - * Emits `assistant-end` plus, when the turn terminated abnormally, a - * `stop-reason` notice so the UI can render a system message with the - * cause. Pre-fix this branch dropped `stop_reason` and `error_message` - * on the floor — the user saw a truncated reply with no diagnostic. - */ -function translateAssistantMessageEnd(message: AssistantMessage): StreamEvent[] { - const out: StreamEvent[] = [{ kind: 'assistant-end' }] + out.push({ kind: 'assistant-end' }) const stop = message.stop_reason as | 'end' | 'length' @@ -162,14 +151,13 @@ function translateAssistantMessageEnd(message: AssistantMessage): StreamEvent[] | 'aborted' | 'function_call' | undefined - // Clean ends and tool-call hops don't need a notice — the next turn - // will visibly continue. We only surface terminal anomalies. if (stop === 'length' || stop === 'error' || stop === 'aborted') { out.push({ kind: 'stop-reason', reason: stop, message: - typeof message.error_message === 'string' && message.error_message.length > 0 + typeof message.error_message === 'string' && + message.error_message.length > 0 ? message.error_message : undefined, }) @@ -211,7 +199,10 @@ function appendBlock(block: ContentBlock, out: StreamEvent[]): void { * through its existing path. Removing the entry from our mirror is * bookkeeping only. */ -export function createTurnStateTranslator(): (event: TurnStateChangedEvent, sessionId: string) => StreamEvent[] { +export function createTurnStateTranslator(): ( + event: TurnStateChangedEvent, + sessionId: string, +) => StreamEvent[] { const mirrors = new Map() return (event, sessionId) => { const prev = mirrors.get(sessionId) ?? [] @@ -239,7 +230,12 @@ export function createTurnStateTranslator(): (event: TurnStateChangedEvent, sess * `kind: 'denied'`). */ function wrapErrorOutput(result: FunctionResult): { - error: { kind: string; message: string; details: unknown; content: ContentBlock[] } + error: { + kind: string + message: string + details: unknown + content: ContentBlock[] + } } { return { error: { diff --git a/console/web/src/types/iii-agent-event.ts b/console/web/src/types/iii-agent-event.ts index cbfb6cff..f1a4c268 100644 --- a/console/web/src/types/iii-agent-event.ts +++ b/console/web/src/types/iii-agent-event.ts @@ -92,7 +92,11 @@ export interface CustomMessage { timestamp: number } -export type AgentMessage = UserMessage | AssistantMessage | FunctionResultMessage | CustomMessage +export type AgentMessage = + | UserMessage + | AssistantMessage + | FunctionResultMessage + | CustomMessage /** Function call request emitted by an assistant message. */ export interface FunctionCall { @@ -114,8 +118,8 @@ export interface FunctionResult { * * The orchestrator forwards each event verbatim inside an * `AgentEvent::MessageUpdate` so the frontend can render token-by-token - * (Phase 2.A). `Done`/`Error` are terminal — the orchestrator emits a - * separate `MessageStart`/`MessageEnd` pair after them. + * (Phase 2.A). `Done`/`Error` are terminal — the orchestrator emits + * `message_complete` after them. */ export type AssistantMessageEvent = | { type: 'start'; partial: AssistantMessage } @@ -140,7 +144,12 @@ export type AssistantMessageEvent = type: 'stop' stop_reason: 'end' | 'length' | 'function_call' | 'aborted' | 'error' error_message?: string - error_kind?: 'auth_expired' | 'rate_limited' | 'context_overflow' | 'transient' | 'permanent' + error_kind?: + | 'auth_expired' + | 'rate_limited' + | 'context_overflow' + | 'transient' + | 'permanent' } | { type: 'done'; message: AssistantMessage } | { type: 'error'; error: AssistantMessage } @@ -160,13 +169,17 @@ export type AgentEvent = message: AgentMessage function_results: FunctionResultMessage[] } - | { type: 'message_start'; message: AgentMessage } | { type: 'message_update' message: AgentMessage llm_event: AssistantMessageEvent } - | { type: 'message_end'; message: AgentMessage } + | { + type: 'message_complete' + message: AgentMessage + /** When true, text/thinking were already delivered via message_update. */ + body_streamed?: boolean + } | { type: 'function_execution_start' function_call_id: string diff --git a/harness-node/pnpm-lock.yaml b/harness-node/pnpm-lock.yaml new file mode 100644 index 00000000..4bc21128 --- /dev/null +++ b/harness-node/pnpm-lock.yaml @@ -0,0 +1,1955 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + chokidar: + specifier: ^3.6.0 + version: 3.6.0 + commander: + specifier: ^12.1.0 + version: 12.1.0 + iii-sdk: + specifier: ^0.12.0 + version: 0.12.0 + pino: + specifier: ^9.5.0 + version: 9.14.0 + uuid: + specifier: ^11.0.3 + version: 11.1.1 + yaml: + specifier: ^2.6.1 + version: 2.9.0 + zod: + specifier: ^3.23.8 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.24.1 + version: 3.25.2(zod@3.25.76) + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@opentelemetry/context-async-hooks': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@types/node': + specifier: ^22.10.5 + version: 22.19.19 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + tsx: + specifier: ^4.19.2 + version: 4.22.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.19) + +packages: + + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@1.30.1': + resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.57.2': + resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.57.2': + resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@1.30.1': + resolution: {integrity: sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@1.30.1': + resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.57.2': + resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.30.1': + resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@1.30.1': + resolution: {integrity: sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/shimmer@1.2.0': + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + iii-sdk@0.12.0: + resolution: {integrity: sha512-Y638PCUeJVGkYjpIvkBgVFXB9WmmbGHsRiRo02E+oph1ShOwmTDJ1LlSFn2h/dZC6cxgOOFwsMTltpGM6j1A+w==} + + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + protobufjs@7.5.9: + resolution: {integrity: sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==} + engines: {node: '>=12.0.0'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsx@4.22.1: + resolution: {integrity: sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.8.0 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + protobufjs: 7.5.9 + + '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + semver: 7.8.0 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@pinojs/redact@0.4.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/shimmer@1.2.0': {} + + '@types/uuid@10.0.0': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cjs-module-lexer@1.4.3: {} + + commander@12.1.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + iii-sdk@0.12.0: + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + long@5.3.2: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + normalize-path@3.0.0: {} + + on-exit-leak-free@2.1.2: {} + + path-parse@1.0.7: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-warning@5.0.0: {} + + protobufjs@7.5.9: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.19.19 + long: 5.3.2 + + quick-format-unescaped@4.0.4: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + real-require@0.2.0: {} + + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + safe-stable-stringify@2.5.0: {} + + semver@7.8.0: {} + + shimmer@1.2.1: {} + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsx@4.22.1: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uuid@11.1.1: {} + + vite-node@2.1.9(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.19): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.20.1: {} + + yaml@2.9.0: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/harness/README.md b/harness/README.md index c1b41bf5..3a8bfcd6 100644 --- a/harness/README.md +++ b/harness/README.md @@ -14,7 +14,7 @@ alongside `harness` over the iii bus. |---|---|---| | `src/harness/` | `ui::subscribe`/`unsubscribe`, `harness::fs::read_inline`, `policy::check_permissions` | Meta-worker; loads `iii-permissions.yaml`; spins up `ui::*` fanout pumps. | | `src/approval-gate/` | `approval::resolve` | Routes operator decisions to per-call `turn::approval_resume` fns (registered by turn-orchestrator). | -| `src/turn-orchestrator/` | `run::start`, `agent::trigger`, `turn::step` | Durable FSM driving each agent turn; chokepoint dispatcher. | +| `src/turn-orchestrator/` | `run::start`, `turn::{state}`, `turn::get_state` | Durable FSM driving each agent turn; `dispatchWithHook` approval chokepoint. | | `src/session/` | `session-tree::*` (11 fns), `session-inbox::*` (3 fns) | Branching session storage + per-session inbox queues. | | `src/llm-budget/` | `budget::*` (14 fns) | Workspace + agent LLM spend caps. | | `src/hook-fanout/` | `hook-fanout::publish_collect` | Generic publish-and-collect over a stream topic. | diff --git a/harness/docs/architecture.md b/harness/docs/architecture.md index 018e2686..08501ea3 100644 --- a/harness/docs/architecture.md +++ b/harness/docs/architecture.md @@ -19,7 +19,7 @@ workers. | Worker | Folder | Role | Doc | |---|---|---|---| | harness | [src/harness/](harness/src/harness/) | Meta-worker; loads `iii-permissions.yaml`, exposes `harness::trigger` (WS ingestion bridge — see [Telemetry & trace correlation](#telemetry--trace-correlation)) / `policy::check_permissions` / `ui::*`, spins up `agent::events` fan-out. | [workers/harness.md](harness/docs/workers/harness.md) | -| turn-orchestrator | [src/turn-orchestrator/](harness/src/turn-orchestrator/) | Durable FSM driving each agent turn; chokepoint dispatcher for `agent::trigger`. | [workers/turn-orchestrator.md](harness/docs/workers/turn-orchestrator.md) | +| turn-orchestrator | [src/turn-orchestrator/](harness/src/turn-orchestrator/) | Durable FSM driving each agent turn; `dispatchWithHook` approval chokepoint. | [workers/turn-orchestrator.md](harness/docs/workers/turn-orchestrator.md) | | approval-gate | [src/approval-gate/](harness/src/approval-gate/) | Registers `approval::resolve` and shared approval wire schemas; routes decisions to per-call `turn::approval_resume` fns owned by the turn-orchestrator. | [workers/approval-gate.md](harness/docs/workers/approval-gate.md) | | session | [src/session/](harness/src/session/) | Branching session storage (`session-tree::*`) plus per-session inbox queues (`session-inbox::*`). | [workers/session.md](harness/docs/workers/session.md) | | llm-budget | [src/llm-budget/](harness/src/llm-budget/) | Workspace + agent LLM spend caps with alerts, forecast, period rollover. | [workers/llm-budget.md](harness/docs/workers/llm-budget.md) | @@ -69,7 +69,7 @@ flowchart LR turnOrch -- "provider::*::stream" --> provKimi turnOrch -- "provider::*::stream" --> provLms turnOrch -- "consultBefore: policy::check_permissions" --> harness - turnOrch -- "agent::trigger → hook-fanout::publish_collect (after-hook)" --> hook + turnOrch -- "publishAfter → hook-fanout::publish_collect (after-hook)" --> hook turnOrch -- "session-tree::* mirror" --> session turnOrch -- "state::* persistence" --> state diff --git a/harness/docs/workers/turn-orchestrator.md b/harness/docs/workers/turn-orchestrator.md index dd540e3f..fc13212a 100644 --- a/harness/docs/workers/turn-orchestrator.md +++ b/harness/docs/workers/turn-orchestrator.md @@ -10,12 +10,12 @@ immediately; the rest of the work happens inside the durable `turn::step` state machine, woken once per state transition by a publish to the `turn::step_requested` topic. The FSM provisions the sandbox, streams the assistant turn from a provider, executes any returned function calls -through the `agent::trigger` chokepoint, emits `agent::events` for the +through `dispatchWithHook`, emits `agent::events` for the harness fanout, and persists everything to iii state so the run survives restarts. -`agent::trigger` is the single dispatcher every agent-issued tool call passes -through. It runs `consultBefore` before forwarding to the target function +`dispatchWithHook` in [agent-trigger.ts](harness/src/turn-orchestrator/agent-trigger.ts) is the single +dispatcher every agent-issued tool call passes through. It runs `consultBefore` before forwarding to the target function id. `consultBefore` triggers `policy::check_permissions` directly (5 s timeout) and maps the reply to allow / deny / pending. Fail-closed: policy unreachable → deny with a `gate_unavailable` `DenialEnvelope`. @@ -25,7 +25,6 @@ unreachable → deny with a `gate_unavailable` `DenialEnvelope`. - `run::start` — Start a durable agent session and return immediately. - `turn::step` — Run one durable state machine transition for a session. - `turn::get_state` — Read the current `TurnStateRecord` for a session (or null for unknown sessions). UI clients use this on reload to recover any in-progress modals (e.g. `function_awaiting_approval`) without reading iii state directly. -- `agent::trigger` — LLM-facing dispatcher: dispatches an iii function and returns a FunctionResult. - `turn::is_abort_signal_set` — Condition function bound to the agent-scope state trigger; matches `state:created`/`state:updated` writes that set `session//abort_signal` to `true`. - `turn::on_abort_signal` — State trigger adapter: publishes `turn::step_requested` when the abort signal is set so the FSM advances on the next safe boundary. - `turn::is_stepable_record_write` — Condition function bound to the record-written state trigger; matches `turn_state` writes whose `new_value.state` is non-terminal and non-parking (i.e. excludes `stopped` and `function_awaiting_approval`). @@ -54,7 +53,7 @@ The 11 states from | `assistant_streaming` | same | Drain the channel; relay events. | | `assistant_finished` | same | Persist the final `AssistantMessage`; pick next state. | | `function_prepare` | [states/functions.ts](harness/src/turn-orchestrator/states/functions.ts) | Snapshot the pending function calls. | -| `function_execute` | same | Run each call via `dispatchWithHook` → `agent::trigger`. If the gate returns `pending`, append the call to `awaiting_approval` and transition to `function_awaiting_approval` (the rest of the batch is left for the resumed step). Each call is bracketed by a `function_execution_start` / `function_execution_end` pair; the `end` event carries `duration_ms` (wall-clock between the matching start and end), persisted on `ExecutedEntry` so resumed runs replay the original timing instead of the ~0ms it takes to re-emit. Approval wait time is naturally excluded — pending calls return without an end emit, and the resumed step re-emits a fresh start that resets the timer. | +| `function_execute` | same | Run each call via `dispatchWithHook` (pre-approved resume calls use `triggerFunctionCall` and skip the gate). If the gate returns `pending`, append the call to `awaiting_approval` and transition to `function_awaiting_approval` (the rest of the batch is left for the resumed step). Each call is bracketed by a `function_execution_start` / `function_execution_end` pair; the `end` event carries `duration_ms` (wall-clock between the matching start and end), persisted on `ExecutedEntry` so resumed runs replay the original timing instead of the ~0ms it takes to re-emit. Approval wait time is naturally excluded — pending calls return without an end emit, and the resumed step re-emits a fresh start that resets the timer. | | `function_awaiting_approval` | same (`handleAwaitingApproval`) | Read `approvals//` for every entry in `awaiting_approval`. While any decision is still missing, return without stepping (the next `turn::approval_resume` invoke will wake `turn::step`). When all decisions are present, fold them into the prepared snapshot — `allow` → `pre_approved: true`, `deny`/`aborted` → `blocked` with a denial result — clear `awaiting_approval`, and transition back to `function_execute`. | | `function_finalize` | same | Persist results; emit `function_call_end` + `turn_end` events. | | `steering_check` | [states/steering.ts](harness/src/turn-orchestrator/states/steering.ts) | Decide whether to continue, stop, or hit `max_turns`. | @@ -114,7 +113,7 @@ From | File | Purpose | |---|---| | [src/turn-orchestrator/main.ts](harness/src/turn-orchestrator/main.ts) | Binary entry point. | -| [src/turn-orchestrator/register.ts](harness/src/turn-orchestrator/register.ts) | Composes `run::start`, `agent::trigger`, `turn::step`, abort-signal and record-written state triggers, and kicks off the bootstrap. | +| [src/turn-orchestrator/register.ts](harness/src/turn-orchestrator/register.ts) | Composes `run::start`, per-state `turn::{state}` handlers, abort-signal trigger, and kicks off the bootstrap. | | [src/turn-orchestrator/run-start.ts](harness/src/turn-orchestrator/run-start.ts) | `run::start` handler — persists run config and messages, seeds `turn_state`, and wakes the FSM via the record-written state trigger. | | [src/turn-orchestrator/get-state.ts](harness/src/turn-orchestrator/get-state.ts) | `turn::get_state` — one-shot reader that returns the current `TurnStateRecord` for a session. UI clients call this on reload to recover in-progress modals; the orchestrator owns the state schema/key layout so clients never read iii state directly. | | [src/turn-orchestrator/agent-trigger.ts](harness/src/turn-orchestrator/agent-trigger.ts) | The dispatcher chokepoint; `dispatchWithHook` runs `consultBefore` before triggering the function and returns `result` / `deny` / `pending`. | diff --git a/harness/src/harness/trigger.ts b/harness/src/harness/trigger.ts index d169c5e9..92534365 100644 --- a/harness/src/harness/trigger.ts +++ b/harness/src/harness/trigger.ts @@ -18,7 +18,7 @@ import { RunStartPayloadSchema, type RunStartPayload, type RunStartResult, -} from '../turn-orchestrator/run-start.js'; +} from '../turn-orchestrator/schemas.js'; const HarnessTriggerInputSchema = z.object({ session_id: z.string().optional(), diff --git a/harness/src/turn-orchestrator/agent-trigger.ts b/harness/src/turn-orchestrator/agent-trigger.ts index 7b72d47d..a24aeae2 100644 --- a/harness/src/turn-orchestrator/agent-trigger.ts +++ b/harness/src/turn-orchestrator/agent-trigger.ts @@ -1,15 +1,14 @@ /** - * `agent::trigger` dispatcher + chokepoint. Mirrors - * `turn-orchestrator/src/agent_call.rs`. + * Agent tool-call dispatcher + approval chokepoint. * - * `dispatchWithHook` is the single chokepoint: every agent-issued tool - * call goes through `consultBefore` before reaching the inner trigger. - * Fail-closed: a hook timeout / error / missing subscriber denies the - * call with a `gate_unavailable` envelope (Phase 2.B §F). + * `dispatchWithHook` is the single chokepoint for FSM-issued calls: every + * agent tool call goes through `consultBefore` before reaching the inner + * trigger. `triggerFunctionCall` is the shared trigger/decode/error path + * used by both the hook gate and pre-approved resume execution. */ -import { uuidLike } from '../runtime/ids.js'; import type { ISdk } from '../runtime/iii.js'; +import { z } from 'zod'; import type { ContentBlock } from '../types/content.js'; import type { FunctionCall, FunctionResult } from '../types/function.js'; import { type DenialEnvelope, consultBefore, gateUnavailableEnvelope } from './hook.js'; @@ -21,6 +20,21 @@ export type DispatchResult = | { kind: 'deny'; result: FunctionResult } | { kind: 'pending' }; +export function missingFunctionResult(): FunctionResult { + return errorResult({ + error: 'missing_function', + message: 'agent_trigger requires a non-empty `function` string field', + }); +} + +export function unwrapAgentTrigger(fc: FunctionCall): FunctionCall { + if (fc.function_id !== TOOL_NAME) return fc; + const args = (fc.arguments ?? {}) as Record; + const fn = typeof args.function === 'string' ? args.function : ''; + const payload = args.payload ?? {}; + return { id: fc.id, function_id: fn, arguments: payload }; +} + export function agentTriggerTool(): unknown { return { name: TOOL_NAME, @@ -59,7 +73,7 @@ function denialResult(denial: DenialEnvelope): FunctionResult { }; } -function decodeOrPassthrough(value: unknown): FunctionResult { +export function decodeOrPassthrough(value: unknown): FunctionResult { if ( value && typeof value === 'object' && @@ -90,26 +104,6 @@ function isFunctionNotFound(err: unknown): boolean { return false; } -/** - * Build the `hint` field on a `function_not_found` result. Models - * regularly confuse the SKILL id (`sandbox/skills/sandbox/create`, the - * on-disk path returned by `directory::skills::list`) with the FUNCTION - * id (`sandbox::create`, what `agent_trigger` actually expects) and - * then retry the same wrong id 3+ times before recovering. When the - * caller's `function_id` contains a `/` we can usually reconstruct the - * canonical worker::fn form and surface it as a "did you mean" — that - * collapses the typical 4-turn recovery to a 2-turn recovery. - * - * Cases recognised: - * - `/skills//` → `::` (the canonical skill-id - * shape produced by `directory::skills::list` for a how-to that - * declares `function_id:` in its frontmatter). - * - `/` → `::` (weaker guess, but - * matches what models often hallucinate as a shorthand). - * - * Anything else (no `/`, or shapes we can't confidently rewrite) gets - * the generic hint pointing at the skills surface. - */ export function functionNotFoundHint(badFunctionId: string): string { if (!badFunctionId.includes('/')) { return 'load the relevant skill via directory::skills::get, or check the function id'; @@ -122,131 +116,60 @@ export function functionNotFoundHint(badFunctionId: string): string { const segments = badFunctionId.split('/').filter((s) => s.length > 0); let suggestion: string | null = null; if (segments.length >= 4 && segments[1] === 'skills' && segments[0] === segments[2]) { - // sandbox/skills/sandbox/create → sandbox::create - // worker-a/skills/worker-a/nested/fn → worker-a::nested::fn suggestion = `${segments[0]}::${segments.slice(3).join('::')}`; } else if (segments.length === 2 && segments[1] !== 'index') { - // sandbox/create → sandbox::create (also catches accidental - // `/` shorthand the model invented from the skill path) suggestion = `${segments[0]}::${segments[1]}`; } return suggestion ? `Did you mean \`${suggestion}\`? ${generic}` : generic; } -function isTimeout(err: unknown): boolean { - if (!err || typeof err !== 'object') return false; - const obj = err as Record; - if (obj.code === 'timeout') return true; - if (typeof obj.message === 'string' && /^Timeout|timed out/.test(obj.message)) return true; - return false; -} -export async function dispatchWithHook( +/** Trigger a function call and normalize success/error into a FunctionResult. */ +export async function triggerFunctionCall( iii: ISdk, function_call: FunctionCall, - session_id: string | undefined, -): Promise { - if (!function_call.function_id || function_call.function_id.length === 0) { - return { - kind: 'result', - result: errorResult({ - error: 'missing_function', - message: 'agent_trigger requires a non-empty `function` string field', - }), - }; - } - const outcome = await consultBefore(iii, function_call); - if (outcome.kind === 'deny') return { kind: 'deny', result: denialResult(outcome.denial) }; - if (outcome.kind === 'pending') { - return { kind: 'pending' }; - } - +): Promise { try { const value = await iii.trigger({ function_id: function_call.function_id, payload: function_call.arguments ?? {}, }); - return { kind: 'result', result: decodeOrPassthrough(value) }; + return decodeOrPassthrough(value); } catch (err) { if (isFunctionNotFound(err)) { - return { - kind: 'result', - result: errorResult({ - error: 'function_not_found', - function: function_call.function_id, - hint: functionNotFoundHint(function_call.function_id), - }), - }; - } - if (isTimeout(err)) { - return { - kind: 'result', - result: errorResult({ - error: 'timeout', - function: function_call.function_id, - message: String(err), - }), - }; + return errorResult({ + error: 'function_not_found', + function: function_call.function_id, + hint: functionNotFoundHint(function_call.function_id), + }); } - return { - kind: 'deny', - result: denialResult( - gateUnavailableEnvelope(function_call.function_id, `trigger_failed: ${String(err)}`), - ), - }; + return denialResult( + gateUnavailableEnvelope(function_call.function_id, `trigger_failed: ${String(err)}`), + ); } } -export async function dispatch( +export async function dispatchWithHook( iii: ISdk, - session_id: string, - fn: unknown, - payload: unknown, -): Promise { - if (typeof fn !== 'string' || fn.length === 0) { - return errorResult({ - error: 'missing_function', - message: 'agent_trigger requires a non-empty `function` string field', - }); + function_call: FunctionCall, +): Promise { + const outcome = await consultBefore(iii, function_call); + if (outcome.kind === 'deny') { + return { kind: 'deny', result: denialResult(outcome.denial) }; } - const fc: FunctionCall = { - id: `agent_trigger-${uuidLike()}`, - function_id: fn, - arguments: payload ?? {}, - }; - const out = await dispatchWithHook(iii, fc, session_id); - if (out.kind === 'pending') { - return errorResult({ - error: 'awaiting_approval', - function: fc.function_id, - message: 'This call requires human approval. Approve via the console and retry.', - }); + if (outcome.kind === 'pending') { + return { kind: 'pending' }; } - return out.result; -} -export function register(iii: ISdk): void { - iii.registerFunction( - 'agent::trigger', - async (payload: unknown) => { - const obj = (payload ?? {}) as Record; - const session_id = typeof obj.session_id === 'string' ? obj.session_id : ''; - const fn = obj.function; - const inner = obj.payload ?? {}; - return await dispatch(iii, session_id, fn, inner); - }, - { - description: - 'LLM-facing dispatcher: dispatches an iii function and returns a FunctionResult.', - }, - ); + const result = await triggerFunctionCall(iii, function_call); + return { kind: 'result', result }; } +const errorResultDetailsSchema = z.union([ + z.object({ error: z.string() }), + z.object({ status: z.literal('denied') }), +]); + export function isErrorResult(result: FunctionResult): boolean { - const details = result.details; - if (!details || typeof details !== 'object') return false; - const obj = details as Record; - if (typeof obj.error === 'string') return true; - if (obj.status === 'denied') return true; - return false; + return errorResultDetailsSchema.safeParse(result.details).success; } diff --git a/harness/src/turn-orchestrator/get-state.ts b/harness/src/turn-orchestrator/get-state.ts index 7cfdb77c..0aaf60d5 100644 --- a/harness/src/turn-orchestrator/get-state.ts +++ b/harness/src/turn-orchestrator/get-state.ts @@ -5,17 +5,13 @@ * **Outgoing**: `TurnStateRecord | null` — null when the session is unknown */ -import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import * as persistence from './persistence.js'; -import type { TurnStateRecord } from './state.js'; - -export const GetStatePayloadSchema = z.object({ - session_id: z.string().min(1), -}); - -export type GetStatePayload = z.infer; -export type GetStateResult = TurnStateRecord | null; +import { + GetStatePayloadSchema, + type GetStatePayload, + type GetStateResult, +} from './schemas.js'; export async function execute(iii: ISdk, payload: GetStatePayload): Promise { return persistence.loadRecord(iii, payload.session_id); @@ -24,7 +20,7 @@ export async function execute(iii: ISdk, payload: GetStatePayload): Promise execute(iii, GetStatePayloadSchema.parse(payload)), + async (payload: GetStatePayload) => execute(iii, GetStatePayloadSchema.parse(payload)), { description: 'Read the current turn_state record for a session. Returns null if the session is unknown. UI clients use this on page reload to recover any in-progress modals (e.g. function_awaiting_approval) without reading iii state directly.', diff --git a/harness/src/turn-orchestrator/on-abort-signal.ts b/harness/src/turn-orchestrator/on-abort-signal.ts index dd580528..a5d2788b 100644 --- a/harness/src/turn-orchestrator/on-abort-signal.ts +++ b/harness/src/turn-orchestrator/on-abort-signal.ts @@ -20,27 +20,11 @@ * **Outgoing**: `wakeFromRecord` enqueues `{ session_id }` on the `turn-step` queue. */ -import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; +import { AbortSignalWriteEventSchema, type ParsedAbortSignalWrite } from './schemas.js'; import { wakeFromRecord } from './wake.js'; -const AgentAbortSignalWriteEventSchema = z.object({ - type: z.literal('state').optional(), - scope: z.literal('agent').optional(), - event_type: z.enum(['state:created', 'state:updated']), - key: z.string().regex(/^session\/[^/]+\/abort_signal$/), - new_value: z.literal(true), - old_value: z.union([z.literal(true), z.literal(false), z.null()]).optional(), -}); - -export const AbortSignalWriteEventSchema = AgentAbortSignalWriteEventSchema.transform((data) => { - const session_id = data.key.slice('session/'.length, -'/abort_signal'.length); - return { session_id }; -}); - -export type ParsedAbortSignalWrite = z.infer; - export function parseAbortSignalWrite(event: unknown): ParsedAbortSignalWrite | null { const result = AbortSignalWriteEventSchema.safeParse(event); return result.success ? result.data : null; diff --git a/harness/src/turn-orchestrator/persistence.ts b/harness/src/turn-orchestrator/persistence.ts index ef62efc7..9a1c785e 100644 --- a/harness/src/turn-orchestrator/persistence.ts +++ b/harness/src/turn-orchestrator/persistence.ts @@ -6,15 +6,13 @@ import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; import type { AgentMessage } from '../types/agent-message.js'; import type { FunctionCall, FunctionResult } from '../types/function.js'; +import { type RunRequest, parseRunRequest } from './run-request.js'; import { - type TurnState, type TurnStateRecord, functionSchemasKey, lastSessionTreeLenKey, messagesKey, runRequestKey, - sandboxIdKey, - toolSchemasKey, turnStateKey, } from './state.js'; import { emitTurnStateChanged } from './turn-state-write.js'; @@ -52,10 +50,18 @@ export async function loadRecord(iii: ISdk, session_id: string): Promise { - const previous = await loadRecord(iii, rec.session_id); - const eventType = previous === null ? 'state:created' : 'state:updated'; +/** + * Persist turn_state and emit UI event — no FSM wake (mid-handler saves). + * Pass `previous` (the pre-write record) to skip the `state::get` that would + * otherwise re-read it; omit it and the prior value is loaded here. + */ +export async function persistRecord( + iii: ISdk, + rec: TurnStateRecord, + previous?: TurnStateRecord | null, +): Promise { + const prev = previous !== undefined ? previous : await loadRecord(iii, rec.session_id); + const eventType = prev === null ? 'state:created' : 'state:updated'; await stateSet(iii, turnStateKey(rec.session_id), rec); @@ -64,16 +70,19 @@ export async function persistRecord(iii: ISdk, rec: TurnStateRecord): Promise, - previous !== null ? (previous as unknown as Record) : undefined, + prev !== null ? (prev as unknown as Record) : undefined, ); } -export async function saveRecord(iii: ISdk, rec: TurnStateRecord): Promise { - const previous = await loadRecord(iii, rec.session_id); - const previousState: TurnState | null = previous?.state ?? null; - await persistRecord(iii, rec); +export async function saveRecord( + iii: ISdk, + rec: TurnStateRecord, + previous?: TurnStateRecord | null, +): Promise { + const prev = previous !== undefined ? previous : await loadRecord(iii, rec.session_id); + await persistRecord(iii, rec, prev); - if (shouldWakeStep(previousState, rec.state)) { + if (shouldWakeStep(prev?.state ?? null, rec.state)) { await wakeState(iii, rec.session_id, rec.state); } } @@ -158,17 +167,9 @@ export async function saveRunRequest( await stateSet(iii, runRequestKey(session_id), request); } -export async function loadRunRequest( - iii: ISdk, - session_id: string, -): Promise> { +export async function loadRunRequest(iii: ISdk, session_id: string): Promise { const v = await stateGet(iii, runRequestKey(session_id)); - return v && typeof v === 'object' ? (v as Record) : {}; -} - -export async function loadSandboxId(iii: ISdk, session_id: string): Promise { - const v = await stateGet(iii, sandboxIdKey(session_id)); - return typeof v === 'string' ? v : null; + return parseRunRequest(v && typeof v === 'object' ? (v as Record) : {}); } export async function saveFunctionSchemas( @@ -181,29 +182,17 @@ export async function saveFunctionSchemas( export async function loadFunctionSchemas(iii: ISdk, session_id: string): Promise { const v = await stateGet(iii, functionSchemasKey(session_id)); - if (Array.isArray(v)) return v; - const legacy = await stateGet(iii, toolSchemasKey(session_id)); - if (Array.isArray(legacy)) return legacy; - return []; + return Array.isArray(v) ? v : []; } const PREPARED_KEY = 'function_prepared'; const EXECUTED_KEY = 'function_executed'; -const LEGACY_PREPARED_KEY = 'tool_prepared'; -const LEGACY_EXECUTED_KEY = 'tool_executed'; const stagingKey = (sid: string, suffix: string) => `session/${sid}/${suffix}`; -async function stagingGetWithLegacy( - iii: ISdk, - session_id: string, - newSuffix: string, - legacySuffix: string, -): Promise { - const v = await stateGet(iii, stagingKey(session_id, newSuffix)); - if (Array.isArray(v)) return v; - const legacy = await stateGet(iii, stagingKey(session_id, legacySuffix)); - return Array.isArray(legacy) ? legacy : []; +async function stagingGet(iii: ISdk, session_id: string, suffix: string): Promise { + const v = await stateGet(iii, stagingKey(session_id, suffix)); + return Array.isArray(v) ? v : []; } export type PreparedEntry = { @@ -237,12 +226,12 @@ export async function savePreparedCalls( } export async function loadPreparedCalls(iii: ISdk, session_id: string): Promise { - const items = await stagingGetWithLegacy(iii, session_id, PREPARED_KEY, LEGACY_PREPARED_KEY); + const items = await stagingGet(iii, session_id, PREPARED_KEY); const out: PreparedEntry[] = []; for (const it of items) { if (!it || typeof it !== 'object') continue; const obj = it as Record; - const fc = (obj.function_call ?? obj.tool_call) as FunctionCall | undefined; + const fc = obj.function_call as FunctionCall | undefined; if (!fc) continue; const blocked = (obj.blocked as FunctionResult | null) ?? null; const pre_approved = obj.pre_approved === true; @@ -260,12 +249,12 @@ export async function saveExecutedCalls( } export async function loadExecutedCalls(iii: ISdk, session_id: string): Promise { - const items = await stagingGetWithLegacy(iii, session_id, EXECUTED_KEY, LEGACY_EXECUTED_KEY); + const items = await stagingGet(iii, session_id, EXECUTED_KEY); const out: ExecutedEntry[] = []; for (const it of items) { if (!it || typeof it !== 'object') continue; const obj = it as Record; - const fc = (obj.function_call ?? obj.tool_call) as FunctionCall | undefined; + const fc = obj.function_call as FunctionCall | undefined; const result = obj.result as FunctionResult | undefined; if (!fc || !result) continue; out.push({ diff --git a/harness/src/turn-orchestrator/register.ts b/harness/src/turn-orchestrator/register.ts index 09ffa932..de49c286 100644 --- a/harness/src/turn-orchestrator/register.ts +++ b/harness/src/turn-orchestrator/register.ts @@ -1,6 +1,5 @@ import { loadConfig } from '../runtime/config.js'; import type { ISdk } from '../runtime/iii.js'; -import { register as registerAgentTrigger } from './agent-trigger.js'; import * as bootstrap from './bootstrap.js'; import { loadOrchestratorConfig } from './config.js'; import { register as registerGetState } from './get-state.js'; @@ -21,7 +20,6 @@ export async function register(iii: ISdk, ctx: { configPath: string }): Promise< const cfg = await loadConfig(ctx.configPath); const orchestratorCfg = loadOrchestratorConfig(cfg); registerRunStart(iii); - registerAgentTrigger(iii); registerProvisioning(iii, orchestratorCfg); registerAssistantStreaming(iii); registerAssistantFinished(iii); diff --git a/harness/src/turn-orchestrator/run-request.ts b/harness/src/turn-orchestrator/run-request.ts new file mode 100644 index 00000000..23d8ca5a --- /dev/null +++ b/harness/src/turn-orchestrator/run-request.ts @@ -0,0 +1,28 @@ +/** + * The persisted run request and its single typed parser. `loadRunRequest` + * (persistence) parses the raw `session//run_request` value through + * `parseRunRequest` once, so every consumer reads a fully-typed `RunRequest` + * instead of re-guarding `unknown` fields. + */ + +import type { Mode } from './system-prompt.js'; + +export type RunRequest = { + provider: string; + model: string; + mode: Mode | null; + system_prompt: string; +}; + +function parseMode(value: unknown): Mode | null { + return value === 'plan' || value === 'ask' || value === 'agent' ? value : null; +} + +export function parseRunRequest(raw: Record): RunRequest { + return { + provider: typeof raw.provider === 'string' ? raw.provider : '', + model: typeof raw.model === 'string' ? raw.model : '', + mode: parseMode(raw.mode), + system_prompt: typeof raw.system_prompt === 'string' ? raw.system_prompt : '', + }; +} diff --git a/harness/src/turn-orchestrator/run-start.ts b/harness/src/turn-orchestrator/run-start.ts index 3c1b1ecb..6b81b1d7 100644 --- a/harness/src/turn-orchestrator/run-start.ts +++ b/harness/src/turn-orchestrator/run-start.ts @@ -4,34 +4,19 @@ * **Incoming**: flat run request from `harness::trigger` (`body.payload` after * `HarnessTriggerInputSchema` parse); console/web sends * `{ session_id, message_id?, provider, model, mode?, messages }` and omits - * `system_prompt`, `image`, `idle_timeout_secs`, `max_turns` (schema defaults). + * `system_prompt`, `max_turns` (schema defaults). * **Outgoing**: `{ session_id }` — persists run config, messages, and seeds * `turn_state` to provisioning via `saveRecord`. */ -import { z } from 'zod'; import type { ISdk } from '../runtime/iii.js'; -import type { AgentMessage } from '../types/agent-message.js'; import * as persistence from './persistence.js'; +import { + RunStartPayloadSchema, + type RunStartPayload, + type RunStartResult, +} from './schemas.js'; import { newRecord } from './state.js'; -import type { Mode } from './system-prompt.js'; - -export const RunStartPayloadSchema = z.object({ - session_id: z.string().min(1), - message_id: z.string().optional(), - provider: z.string(), - model: z.string(), - mode: z.enum(['plan', 'ask', 'agent'] satisfies [Mode, Mode, Mode]).optional(), - messages: z.custom((v) => Array.isArray(v)).default([]), - max_turns: z.number().optional(), - system_prompt: z.string().default(''), - image: z.string().default('python'), - idle_timeout_secs: z.number().default(300), -}); - -export type RunStartPayload = z.infer; - -export type RunStartResult = { session_id: string }; export async function execute(iii: ISdk, payload: RunStartPayload): Promise { const { session_id, messages, max_turns, message_id: _message_id, ...run } = payload; @@ -50,7 +35,7 @@ export async function execute(iii: ISdk, payload: RunStartPayload): Promise execute(iii, RunStartPayloadSchema.parse(payload)), + async (payload: RunStartPayload) => execute(iii, RunStartPayloadSchema.parse(payload)), { description: 'Start a durable agent session and return immediately.', }, diff --git a/harness/src/turn-orchestrator/run-transition.ts b/harness/src/turn-orchestrator/run-transition.ts new file mode 100644 index 00000000..0f142761 --- /dev/null +++ b/harness/src/turn-orchestrator/run-transition.ts @@ -0,0 +1,53 @@ +/** + * Shared FSM transition runner. Every `turn::{state}` function performs the + * same load → null-check → stale-skip → handle → save sequence; this owns it so + * each per-state file only contributes its handler. + * + * The record loaded here is snapshotted before the handler mutates it and + * threaded into `saveRecord`, so the save path needs no extra `state::get` to + * compute the wake decision or the UI event's `old_value` — one read per + * transition instead of three. + */ + +import type { ISdk } from '../runtime/iii.js'; +import { logger } from '../runtime/otel.js'; +import * as persistence from './persistence.js'; +import { type TurnStepPayload, type TurnStepResult } from './schemas.js'; +import { type TurnState, type TurnStateRecord, cloneRecord } from './state.js'; + +export type TransitionHandler = (iii: ISdk, rec: TurnStateRecord) => Promise; + +/** Returns a stale skip result when the queue message no longer matches persisted state. */ +function staleSkipResult(expectedState: TurnState, rec: TurnStateRecord): TurnStepResult | null { + if (rec.state === expectedState) return null; + logger.warn(`turn::${expectedState} skipped: stale queue message`, { + session_id: rec.session_id, + expected: expectedState, + actual: rec.state, + }); + return { ok: true, skipped: true, reason: 'stale' }; +} + +export async function runTransition( + iii: ISdk, + state: TurnState, + handle: TransitionHandler, + payload: TurnStepPayload, +): Promise { + const rec = await persistence.loadRecord(iii, payload.session_id); + if (!rec) { + throw new Error(`turn::${state} invariant: missing session ${payload.session_id}`); + } + const skipped = staleSkipResult(state, rec); + if (skipped) return skipped; + + const previous = cloneRecord(rec); + const from_state = rec.state; + try { + await handle(iii, rec); + } catch (err) { + throw new Error(`transition from ${from_state} failed: ${String(err)}`); + } + await persistence.saveRecord(iii, rec, previous); + return { ok: true, from_state, to_state: rec.state }; +} diff --git a/harness/src/turn-orchestrator/schemas.ts b/harness/src/turn-orchestrator/schemas.ts new file mode 100644 index 00000000..4ece3fdc --- /dev/null +++ b/harness/src/turn-orchestrator/schemas.ts @@ -0,0 +1,57 @@ +/** + * Registered-function I/O contracts for turn-orchestrator. Every payload schema + * and payload/result type for the worker's `iii.registerFunction` handlers lives + * here, so the contract surface is readable in one place. Handlers import the + * schema (to `.parse` at the boundary) and the inferred types from this file. + */ + +import { z } from 'zod'; +import type { AgentMessage } from '../types/agent-message.js'; +import type { TurnState, TurnStateRecord } from './state.js'; +import type { Mode } from './system-prompt.js'; + +/** Shared `{ session_id }` payload — `turn::{state}` steps and `turn::get_state`. */ +export const SessionIdPayloadSchema = z.object({ + session_id: z.string().min(1), +}); + +// --- run::start --- +export const RunStartPayloadSchema = SessionIdPayloadSchema.extend({ + message_id: z.string().optional(), + provider: z.string(), + model: z.string(), + mode: z.enum(['plan', 'ask', 'agent'] satisfies [Mode, Mode, Mode]).optional(), + messages: z.custom((v) => Array.isArray(v)).default([]), + max_turns: z.number().optional(), + system_prompt: z.string().default(''), +}); +export type RunStartPayload = z.infer; +export type RunStartResult = { session_id: string }; + +// --- turn::{state} durable step --- +export const TurnStepPayloadSchema = SessionIdPayloadSchema; +export type TurnStepPayload = z.infer; +export type TurnStepResult = + | { ok: true; from_state: TurnState; to_state: TurnState } + | { ok: true; skipped: true; reason: 'stale' }; + +// --- turn::get_state --- +export const GetStatePayloadSchema = SessionIdPayloadSchema; +export type GetStatePayload = z.infer; +export type GetStateResult = TurnStateRecord | null; + +// --- turn::is_abort_signal_set / turn::on_abort_signal (agent-scope state event) --- +const AgentAbortSignalWriteEventSchema = z.object({ + type: z.literal('state').optional(), + scope: z.literal('agent').optional(), + event_type: z.enum(['state:created', 'state:updated']), + key: z.string().regex(/^session\/[^/]+\/abort_signal$/), + new_value: z.literal(true), + old_value: z.union([z.literal(true), z.literal(false), z.null()]).optional(), +}); + +export const AbortSignalWriteEventSchema = AgentAbortSignalWriteEventSchema.transform((data) => { + const session_id = data.key.slice('session/'.length, -'/abort_signal'.length); + return { session_id }; +}); +export type ParsedAbortSignalWrite = z.infer; diff --git a/harness/src/turn-orchestrator/state.ts b/harness/src/turn-orchestrator/state.ts index 37358f21..5d080693 100644 --- a/harness/src/turn-orchestrator/state.ts +++ b/harness/src/turn-orchestrator/state.ts @@ -34,6 +34,8 @@ export type TurnStateRecord = { started_at_ms: number; updated_at_ms: number; awaiting_approval?: AwaitingApprovalEntry[]; + /** Set during assistant_streaming when message_update deltas were emitted. */ + assistant_body_streamed?: boolean; }; export function newRecord(session_id: string, max_turns?: number): TurnStateRecord { @@ -57,6 +59,16 @@ export function transitionTo(rec: TurnStateRecord, next: TurnState): void { rec.updated_at_ms = Date.now(); } +/** + * Deep copy of a record via JSON round-trip — faithful to a `state::get` + * reload (the record is persisted as JSON), so the runner can snapshot the + * pre-mutation record and thread it into `saveRecord` instead of paying a + * second `state::get` to recover the previous state. + */ +export function cloneRecord(rec: TurnStateRecord): TurnStateRecord { + return JSON.parse(JSON.stringify(rec)) as TurnStateRecord; +} + export function isTerminal(rec: TurnStateRecord): boolean { return rec.state === 'stopped'; } @@ -68,12 +80,7 @@ export function turnFnId(state: TurnState): string { export const messagesKey = (sid: string) => `session/${sid}/messages`; export const turnStateKey = (sid: string) => `session/${sid}/turn_state`; export const runRequestKey = (sid: string) => `session/${sid}/run_request`; -export const sandboxIdKey = (sid: string) => `session/${sid}/sandbox_id`; export const functionSchemasKey = (sid: string) => `session/${sid}/function_schemas`; -export const toolSchemasKey = (sid: string) => `session/${sid}/tool_schemas`; export const lastSessionTreeLenKey = (sid: string) => `session/${sid}/session_tree_mirror_len`; -export const lastCompactionAtKey = (sid: string) => `session/${sid}/last_compaction_at`; -export const lastCompactionConsumedAtKey = (sid: string) => - `session/${sid}/last_compaction_consumed_at`; export const eventCounterKey = (sid: string) => `session/${sid}/event_counter`; export const abortSignalKey = (sid: string) => `session/${sid}/abort_signal`; diff --git a/harness/src/turn-orchestrator/states/assistant-finished.ts b/harness/src/turn-orchestrator/states/assistant-finished.ts index 1ca46c80..b7be2e8c 100644 --- a/harness/src/turn-orchestrator/states/assistant-finished.ts +++ b/harness/src/turn-orchestrator/states/assistant-finished.ts @@ -10,25 +10,13 @@ import { logger } from '../../runtime/otel.js'; import type { AgentEvent } from '../../types/agent-event.js'; import type { AssistantMessage } from '../../types/agent-message.js'; import type { FunctionCall } from '../../types/function.js'; -import { TOOL_NAME } from '../agent-trigger.js'; +import { missingFunctionResult, unwrapAgentTrigger } from '../agent-trigger.js'; import { emit } from '../events.js'; import type { PreparedEntry } from '../persistence.js'; import * as persistence from '../persistence.js'; +import { runTransition } from '../run-transition.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; -import { - TurnStepPayloadSchema, - type TurnStepPayload, - type TurnStepResult, - staleSkipResult, -} from '../turn-step-payload.js'; - -function unwrapAgentTrigger(fc: FunctionCall): FunctionCall { - if (fc.function_id !== TOOL_NAME) return fc; - const args = (fc.arguments ?? {}) as Record; - const fn = typeof args.function === 'string' ? args.function : ''; - const payload = args.payload ?? {}; - return { id: fc.id, function_id: fn, arguments: payload }; -} +import { TurnStepPayloadSchema, type TurnStepPayload } from '../schemas.js'; function extractFunctionCalls(msg: AssistantMessage): FunctionCall[] { const out: FunctionCall[] = []; @@ -40,11 +28,11 @@ function extractFunctionCalls(msg: AssistantMessage): FunctionCall[] { return out; } -function assistantLifecycleEvents(asst: AssistantMessage): AgentEvent[] { - return [ - { type: 'message_start', message: asst }, - { type: 'message_end', message: asst }, - ]; +function assistantMessageComplete( + asst: AssistantMessage, + body_streamed: boolean, +): AgentEvent { + return { type: 'message_complete', message: asst, body_streamed }; } export async function handleFinished(iii: ISdk, rec: TurnStateRecord): Promise { @@ -52,13 +40,15 @@ export async function handleFinished(iii: ISdk, rec: TurnStateRecord): Promise ({ - function_call: fc, - blocked: null, - })); + const prepared: PreparedEntry[] = calls.map((raw) => { + const function_call = unwrapAgentTrigger(raw); + if (!function_call.function_id) { + return { function_call, blocked: missingFunctionResult() }; + } + return { function_call, blocked: null }; + }); await persistence.saveExecutedCalls(iii, rec.session_id, []); await persistence.savePreparedCalls(iii, rec.session_id, prepared); transitionTo(rec, 'function_execute'); } -export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { - const rec = await persistence.loadRecord(iii, payload.session_id); - if (!rec) { - throw new Error(`turn::assistant_finished invariant: missing session ${payload.session_id}`); - } - const skipped = staleSkipResult('assistant_finished', rec); - if (skipped) return skipped; - - const from_state = rec.state; - try { - await handleFinished(iii, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - export function register(iii: ISdk): void { iii.registerFunction( 'turn::assistant_finished', - async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + async (payload: TurnStepPayload) => { + const parsed = TurnStepPayloadSchema.parse(payload); + return runTransition(iii, 'assistant_finished', handleFinished, parsed); + }, { description: 'Run one durable FSM transition for session in state assistant_finished: finalize assistant and route onward.', diff --git a/harness/src/turn-orchestrator/states/assistant-streaming.ts b/harness/src/turn-orchestrator/states/assistant-streaming.ts index 29317c7c..c0df48e3 100644 --- a/harness/src/turn-orchestrator/states/assistant-streaming.ts +++ b/harness/src/turn-orchestrator/states/assistant-streaming.ts @@ -15,13 +15,9 @@ import { emit } from '../events.js'; import * as persistence from '../persistence.js'; import { runPreflight } from '../preflight.js'; import { buildInput, decide, targetFunctionId } from '../provider-router.js'; +import { runTransition } from '../run-transition.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; -import { - TurnStepPayloadSchema, - type TurnStepPayload, - type TurnStepResult, - staleSkipResult, -} from '../turn-step-payload.js'; +import { TurnStepPayloadSchema, type TurnStepPayload } from '../schemas.js'; function eventPartial(ev: AssistantMessageEvent): AssistantMessage | null { if ('partial' in ev) return ev.partial; @@ -82,8 +78,11 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< provider: '', timestamp: Date.now(), }; - await emit(iii, rec.session_id, { type: 'message_start', message: exhausted }); - await emit(iii, rec.session_id, { type: 'message_end', message: exhausted }); + await emit(iii, rec.session_id, { + type: 'message_complete', + message: exhausted, + body_streamed: false, + }); await emit(iii, rec.session_id, { type: 'turn_end', message: exhausted, @@ -99,16 +98,14 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< } rec.turn_count++; rec.turn_end_emitted = false; + rec.assistant_body_streamed = false; await emit(iii, rec.session_id, { type: 'turn_start' }); const request = await persistence.loadRunRequest(iii, rec.session_id); let messages = await persistence.loadMessages(iii, rec.session_id); const schemas = await persistence.loadFunctionSchemas(iii, rec.session_id); - const provider = typeof request.provider === 'string' ? (request.provider as string) : ''; - const model = typeof request.model === 'string' ? (request.model as string) : ''; - const system_prompt = - typeof request.system_prompt === 'string' ? (request.system_prompt as string) : null; + const { provider, model, system_prompt } = request; const tools = (Array.isArray(schemas) ? schemas : []) as AgentFunction[]; const decision = decide({ provider, model }); @@ -206,6 +203,9 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< message: partial, llm_event: event, }); + if (event.type === 'text_delta' || event.type === 'thinking_delta') { + rec.assistant_body_streamed = true; + } if (event.type === 'functioncall_start' || event.type === 'functioncall_delta') { const fc = latestFunctionCall(partial); if (fc) { @@ -250,28 +250,13 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< transitionTo(rec, 'assistant_finished'); } -export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { - const rec = await persistence.loadRecord(iii, payload.session_id); - if (!rec) { - throw new Error(`turn::assistant_streaming invariant: missing session ${payload.session_id}`); - } - const skipped = staleSkipResult('assistant_streaming', rec); - if (skipped) return skipped; - - const from_state = rec.state; - try { - await handleStreaming(iii, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - export function register(iii: ISdk): void { iii.registerFunction( 'turn::assistant_streaming', - async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + async (payload: TurnStepPayload) => { + const parsed = TurnStepPayloadSchema.parse(payload); + return runTransition(iii, 'assistant_streaming', handleStreaming, parsed); + }, { description: 'Run one durable FSM transition for session in state assistant_streaming: start turn and stream provider response.', diff --git a/harness/src/turn-orchestrator/states/function-awaiting-approval.ts b/harness/src/turn-orchestrator/states/function-awaiting-approval.ts index fdf64095..2ddcc936 100644 --- a/harness/src/turn-orchestrator/states/function-awaiting-approval.ts +++ b/harness/src/turn-orchestrator/states/function-awaiting-approval.ts @@ -11,13 +11,9 @@ import type { ISdk } from '../../runtime/iii.js'; import type { FunctionResult } from '../../types/function.js'; import { text } from '../../types/content.js'; import * as persistence from '../persistence.js'; +import { runTransition } from '../run-transition.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; -import { - TurnStepPayloadSchema, - type TurnStepPayload, - type TurnStepResult, - staleSkipResult, -} from '../turn-step-payload.js'; +import { TurnStepPayloadSchema, type TurnStepPayload } from '../schemas.js'; export type ApprovalDecision = z.infer; @@ -101,30 +97,13 @@ export async function handleAwaitingApproval(iii: ISdk, rec: TurnStateRecord): P transitionTo(rec, 'function_execute'); } -export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { - const rec = await persistence.loadRecord(iii, payload.session_id); - if (!rec) { - throw new Error( - `turn::function_awaiting_approval invariant: missing session ${payload.session_id}`, - ); - } - const skipped = staleSkipResult('function_awaiting_approval', rec); - if (skipped) return skipped; - - const from_state = rec.state; - try { - await handleAwaitingApproval(iii, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - export function register(iii: ISdk): void { iii.registerFunction( 'turn::function_awaiting_approval', - async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + async (payload: TurnStepPayload) => { + const parsed = TurnStepPayloadSchema.parse(payload); + return runTransition(iii, 'function_awaiting_approval', handleAwaitingApproval, parsed); + }, { description: 'Run one durable FSM transition for session in state function_awaiting_approval: read approval decisions and resume.', diff --git a/harness/src/turn-orchestrator/states/function-execute.ts b/harness/src/turn-orchestrator/states/function-execute.ts index c45b339b..87fe86aa 100644 --- a/harness/src/turn-orchestrator/states/function-execute.ts +++ b/harness/src/turn-orchestrator/states/function-execute.ts @@ -14,57 +14,15 @@ import type { FunctionResultMessage, } from '../../types/agent-message.js'; import type { FunctionCall, FunctionResult } from '../../types/function.js'; -import { text } from '../../types/content.js'; -import { dispatchWithHook, isErrorResult } from '../agent-trigger.js'; +import { dispatchWithHook, isErrorResult, triggerFunctionCall } from '../agent-trigger.js'; import { registerApprovalResume } from '../approval-resume.js'; import { emit } from '../events.js'; import { publishAfter } from '../hook.js'; import * as persistence from '../persistence.js'; +import type { ExecutedEntry } from '../persistence.js'; +import { runTransition } from '../run-transition.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; -import { - TurnStepPayloadSchema, - type TurnStepPayload, - type TurnStepResult, - staleSkipResult, -} from '../turn-step-payload.js'; - -function triggerErrorResult(function_id: string, err: unknown): FunctionResult { - const message = - err && typeof err === 'object' && typeof (err as Record).message === 'string' - ? ((err as Record).message as string) - : String(err); - const details = { - error: 'trigger_failed', - function: function_id, - message, - }; - return { - content: [text(JSON.stringify(details))], - details, - terminate: false, - }; -} - -function decodeOrPassthroughResult(value: unknown): FunctionResult { - if ( - value && - typeof value === 'object' && - Array.isArray((value as Record).content) - ) { - const obj = value as Record; - return { - content: obj.content as FunctionResult['content'], - details: obj.details ?? {}, - terminate: typeof obj.terminate === 'boolean' ? obj.terminate : false, - }; - } - const textBody = typeof value === 'string' ? value : JSON.stringify(value); - return { - content: [text(textBody)], - details: value, - terminate: false, - }; -} +import { TurnStepPayloadSchema, type TurnStepPayload } from '../schemas.js'; function buildFunctionExecutionEnd( fc: FunctionCall, @@ -82,16 +40,53 @@ function buildFunctionExecutionEnd( }; } +function augmentFunctionCall(fc: FunctionCall, session_id: string): FunctionCall { + let augmented_args: unknown; + if (fc.arguments && typeof fc.arguments === 'object' && !Array.isArray(fc.arguments)) { + augmented_args = { ...(fc.arguments as Record) }; + } else { + augmented_args = { arguments: fc.arguments }; + } + if (typeof augmented_args === 'object' && augmented_args !== null) { + const obj = augmented_args as Record; + obj.session_id = session_id; + obj.function_call_id = fc.id; + obj.function_id = fc.function_id; + obj.function_call = { + id: fc.id, + function_id: fc.function_id, + arguments: fc.arguments, + }; + } + return { id: fc.id, function_id: fc.function_id, arguments: augmented_args }; +} + +async function commitExecutedCall( + iii: ISdk, + rec: TurnStateRecord, + results: ExecutedEntry[], + fc: FunctionCall, + result: FunctionResult, + startedAt: number, + is_error?: boolean, +): Promise { + const duration_ms = Date.now() - startedAt; + const error = is_error ?? isErrorResult(result); + persistence.upsertExecutedCall(results, { + function_call: fc, + result, + is_error: error, + duration_ms, + }); + await persistence.saveExecutedCalls(iii, rec.session_id, results); + await emit(iii, rec.session_id, buildFunctionExecutionEnd(fc, result, error, duration_ms)); +} + function buildFinalizeLifecycle( asst: AssistantMessage, results: FunctionResultMessage[], ): AgentEvent[] { - const out: AgentEvent[] = []; - for (const r of results) { - out.push({ type: 'message_start', message: r }); - out.push({ type: 'message_end', message: r }); - } - out.push({ type: 'turn_end', message: asst, function_results: results }); + const out: AgentEvent[] = [{ type: 'turn_end', message: asst, function_results: results }]; return out; } @@ -180,17 +175,6 @@ async function finalizeExecutedCalls(iii: ISdk, rec: TurnStateRecord): Promise({ - function_id: fc.function_id, - payload: fc.arguments ?? {}, - }); - result = decodeOrPassthroughResult(value); - is_error = isErrorResult(result); - } catch (err) { - result = triggerErrorResult(fc.function_id, err); - is_error = true; - } - - duration_ms = Date.now() - startedAt; - persistence.upsertExecutedCall(results, { - function_call: fc, - result, - is_error, - duration_ms, - }); - - await persistence.saveExecutedCalls(iii, rec.session_id, results); - await emit(iii, rec.session_id, buildFunctionExecutionEnd(fc, result, is_error, duration_ms)); + await commitExecutedCall( + iii, + rec, + results, + fc, + await triggerFunctionCall(iii, fc), + startedAt, + ); continue; } if (entry.blocked) { - const result = entry.blocked; - const is_error = true; - const duration_ms = Date.now() - startedAt; - persistence.upsertExecutedCall(results, { - function_call: fc, - result, - is_error, - duration_ms, - }); - await persistence.saveExecutedCalls(iii, rec.session_id, results); - await emit(iii, rec.session_id, buildFunctionExecutionEnd(fc, result, is_error, duration_ms)); + await commitExecutedCall(iii, rec, results, fc, entry.blocked, startedAt, true); continue; } - let augmented_args: unknown; - if (fc.arguments && typeof fc.arguments === 'object' && !Array.isArray(fc.arguments)) { - augmented_args = { ...(fc.arguments as Record) }; - } else { - augmented_args = { arguments: fc.arguments }; - } - if (typeof augmented_args === 'object' && augmented_args !== null) { - const obj = augmented_args as Record; - obj.session_id = rec.session_id; - obj.function_call_id = fc.id; - obj.function_id = fc.function_id; - obj.function_call = { - id: fc.id, - function_id: fc.function_id, - arguments: fc.arguments, - }; - } - const augmentedFc: FunctionCall = { - id: fc.id, - function_id: fc.function_id, - arguments: augmented_args, - }; - const out = await dispatchWithHook(iii, augmentedFc, rec.session_id); - + const out = await dispatchWithHook(iii, augmentFunctionCall(fc, rec.session_id)); if (out.kind === 'pending') { rec.awaiting_approval = rec.awaiting_approval ?? []; rec.awaiting_approval.push({ @@ -306,46 +247,18 @@ export async function handleExecute(iii: ISdk, rec: TurnStateRecord): Promise { - const rec = await persistence.loadRecord(iii, payload.session_id); - if (!rec) { - throw new Error(`turn::function_execute invariant: missing session ${payload.session_id}`); - } - const skipped = staleSkipResult('function_execute', rec); - if (skipped) return skipped; - - const from_state = rec.state; - try { - await handleExecute(iii, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - export function register(iii: ISdk): void { iii.registerFunction( 'turn::function_execute', - async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + async (payload: TurnStepPayload) => { + const parsed = TurnStepPayloadSchema.parse(payload); + return runTransition(iii, 'function_execute', handleExecute, parsed); + }, { description: 'Run one durable FSM transition for session in state function_execute: dispatch prepared calls and finalize results.', diff --git a/harness/src/turn-orchestrator/states/provisioning.ts b/harness/src/turn-orchestrator/states/provisioning.ts index e756ce97..ae141a2f 100644 --- a/harness/src/turn-orchestrator/states/provisioning.ts +++ b/harness/src/turn-orchestrator/states/provisioning.ts @@ -11,46 +11,14 @@ import { logger } from '../../runtime/otel.js'; import { agentTriggerTool } from '../agent-trigger.js'; import type { TurnOrchestratorConfig } from '../config.js'; import * as persistence from '../persistence.js'; +import { type RunRequest } from '../run-request.js'; +import { runTransition } from '../run-transition.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; -import { - TurnStepPayloadSchema, - type TurnStepPayload, - type TurnStepResult, - staleSkipResult, -} from '../turn-step-payload.js'; -import { - type DefaultSkillBody, - type Mode, - buildSystemPrompt, - defaultSkillBody, -} from '../system-prompt.js'; - -type RunRequest = { - provider: string; - model: string; - mode: Mode | null; - system_prompt: string; - image: string; - idle_timeout_secs: number; -}; +import { TurnStepPayloadSchema, type TurnStepPayload } from '../schemas.js'; +import { type DefaultSkillBody, buildSystemPrompt, defaultSkillBody } from '../system-prompt.js'; const FETCH_TIMEOUT_MS = 10_000; -function parseMode(value: unknown): Mode | null { - return value === 'plan' || value === 'ask' || value === 'agent' ? value : null; -} - -export function parseRunRequest(raw: Record): RunRequest { - return { - provider: typeof raw.provider === 'string' ? raw.provider : '', - model: typeof raw.model === 'string' ? raw.model : '', - mode: parseMode(raw.mode), - system_prompt: typeof raw.system_prompt === 'string' ? raw.system_prompt : '', - image: typeof raw.image === 'string' ? raw.image : 'python', - idle_timeout_secs: typeof raw.idle_timeout_secs === 'number' ? raw.idle_timeout_secs : 300, - }; -} - export function parseDirectoryBody(resp: unknown): string | null { if (typeof resp === 'string') return resp; if (resp && typeof resp === 'object') { @@ -104,7 +72,7 @@ export async function handleProvisioning( cfg: TurnOrchestratorConfig, rec: TurnStateRecord, ): Promise { - const request = parseRunRequest(await persistence.loadRunRequest(iii, rec.session_id)); + const request = await persistence.loadRunRequest(iii, rec.session_id); await persistence.saveFunctionSchemas(iii, rec.session_id, [agentTriggerTool()]); @@ -122,32 +90,13 @@ export async function handleProvisioning( transitionTo(rec, 'assistant_streaming'); } -export async function execute( - iii: ISdk, - cfg: TurnOrchestratorConfig, - payload: TurnStepPayload, -): Promise { - const rec = await persistence.loadRecord(iii, payload.session_id); - if (!rec) { - throw new Error(`turn::provisioning invariant: missing session ${payload.session_id}`); - } - const skipped = staleSkipResult('provisioning', rec); - if (skipped) return skipped; - - const from_state = rec.state; - try { - await handleProvisioning(iii, cfg, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { iii.registerFunction( 'turn::provisioning', - async (payload: unknown) => execute(iii, cfg, TurnStepPayloadSchema.parse(payload)), + async (payload: TurnStepPayload) => { + const parsed = TurnStepPayloadSchema.parse(payload); + return runTransition(iii, 'provisioning', (i, rec) => handleProvisioning(i, cfg, rec), parsed); + }, { description: 'Run one durable FSM transition for session in state provisioning: materialize tool schemas, build system prompt, advance to assistant_streaming.', diff --git a/harness/src/turn-orchestrator/states/steering-check.ts b/harness/src/turn-orchestrator/states/steering-check.ts index f2ed953a..77a0f6a5 100644 --- a/harness/src/turn-orchestrator/states/steering-check.ts +++ b/harness/src/turn-orchestrator/states/steering-check.ts @@ -9,13 +9,9 @@ import type { ISdk } from '../../runtime/iii.js'; import type { AgentMessage, AssistantMessage } from '../../types/agent-message.js'; import { emit } from '../events.js'; import * as persistence from '../persistence.js'; +import { runTransition } from '../run-transition.js'; import { type TurnStateRecord, abortSignalKey, transitionTo } from '../state.js'; -import { - TurnStepPayloadSchema, - type TurnStepPayload, - type TurnStepResult, - staleSkipResult, -} from '../turn-step-payload.js'; +import { TurnStepPayloadSchema, type TurnStepPayload } from '../schemas.js'; export type SteeringRoute = | 'abort' @@ -154,28 +150,13 @@ export async function handleSteering(iii: ISdk, rec: TurnStateRecord): Promise { - const rec = await persistence.loadRecord(iii, payload.session_id); - if (!rec) { - throw new Error(`turn::steering_check invariant: missing session ${payload.session_id}`); - } - const skipped = staleSkipResult('steering_check', rec); - if (skipped) return skipped; - - const from_state = rec.state; - try { - await handleSteering(iii, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - export function register(iii: ISdk): void { iii.registerFunction( 'turn::steering_check', - async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + async (payload: TurnStepPayload) => { + const parsed = TurnStepPayloadSchema.parse(payload); + return runTransition(iii, 'steering_check', handleSteering, parsed); + }, { description: 'Run one durable FSM transition for session in state steering_check: drain inboxes and route onward.', diff --git a/harness/src/turn-orchestrator/states/tearing-down.ts b/harness/src/turn-orchestrator/states/tearing-down.ts index 4381fed8..d9af5420 100644 --- a/harness/src/turn-orchestrator/states/tearing-down.ts +++ b/harness/src/turn-orchestrator/states/tearing-down.ts @@ -1,71 +1,34 @@ /** - * `turn::tearing_down`. Stop sandbox, emit `agent_end`, transition to `stopped`. + * `turn::tearing_down`. Emit `agent_end` and transition to `stopped`. * * **Incoming**: flat `{ session_id }` via FIFO enqueue on `turn-step`. * **Outgoing**: `{ ok, from_state, to_state }` on success; stale skip when state drifted. */ import type { ISdk } from '../../runtime/iii.js'; -import { logger } from '../../runtime/otel.js'; import type { AgentMessage } from '../../types/agent-message.js'; import { emit } from '../events.js'; import * as persistence from '../persistence.js'; +import { runTransition } from '../run-transition.js'; import { type TurnStateRecord, transitionTo } from '../state.js'; -import { - TurnStepPayloadSchema, - type TurnStepPayload, - type TurnStepResult, - staleSkipResult, -} from '../turn-step-payload.js'; - -type SandboxStopPayload = { sandbox_id: string; wait: true }; +import { TurnStepPayloadSchema, type TurnStepPayload } from '../schemas.js'; export async function handleTearingDown(iii: ISdk, rec: TurnStateRecord): Promise { - const sandbox_id = await persistence.loadSandboxId(iii, rec.session_id); - if (sandbox_id) { - try { - await iii.trigger({ - function_id: 'sandbox::stop', - payload: { sandbox_id, wait: true }, - timeoutMs: 60_000, - }); - } catch (err) { - logger.warn('sandbox::stop failed during teardown', { - sandbox_id, - err: String(err), - }); - } - } const messages: AgentMessage[] = await persistence.loadMessages(iii, rec.session_id); await emit(iii, rec.session_id, { type: 'agent_end', messages }); transitionTo(rec, 'stopped'); } -export async function execute(iii: ISdk, payload: TurnStepPayload): Promise { - const rec = await persistence.loadRecord(iii, payload.session_id); - if (!rec) { - throw new Error(`turn::tearing_down invariant: missing session ${payload.session_id}`); - } - const skipped = staleSkipResult('tearing_down', rec); - if (skipped) return skipped; - - const from_state = rec.state; - try { - await handleTearingDown(iii, rec); - } catch (err) { - throw new Error(`transition from ${from_state} failed: ${String(err)}`); - } - await persistence.saveRecord(iii, rec); - return { ok: true, from_state, to_state: rec.state }; -} - export function register(iii: ISdk): void { iii.registerFunction( 'turn::tearing_down', - async (payload: unknown) => execute(iii, TurnStepPayloadSchema.parse(payload)), + async (payload: TurnStepPayload) => { + const parsed = TurnStepPayloadSchema.parse(payload); + return runTransition(iii, 'tearing_down', handleTearingDown, parsed); + }, { description: - 'Run one durable FSM transition for session in state tearing_down: stop sandbox and mark stopped.', + 'Run one durable FSM transition for session in state tearing_down: emit agent_end and mark stopped.', }, ); } diff --git a/harness/src/turn-orchestrator/turn-step-payload.ts b/harness/src/turn-orchestrator/turn-step-payload.ts deleted file mode 100644 index 90f8882c..00000000 --- a/harness/src/turn-orchestrator/turn-step-payload.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Shared payload and result types for `turn::{state}` iii functions. - */ - -import { z } from 'zod'; -import { logger } from '../runtime/otel.js'; -import type { TurnState, TurnStateRecord } from './state.js'; - -export const TurnStepPayloadSchema = z.object({ - session_id: z.string().min(1), -}); - -export type TurnStepPayload = z.infer; - -export type TurnStepResult = - | { ok: true; from_state: TurnState; to_state: TurnState } - | { ok: true; skipped: true; reason: 'stale' }; - -/** Returns a stale skip result when the queue message no longer matches persisted state. */ -export function staleSkipResult( - expectedState: TurnState, - rec: TurnStateRecord, -): TurnStepResult | null { - if (rec.state === expectedState) return null; - logger.warn(`turn::${expectedState} skipped: stale queue message`, { - session_id: rec.session_id, - expected: expectedState, - actual: rec.state, - }); - return { ok: true, skipped: true, reason: 'stale' }; -} diff --git a/harness/src/types/agent-event.ts b/harness/src/types/agent-event.ts index 7aca3413..acb7ea1b 100644 --- a/harness/src/types/agent-event.ts +++ b/harness/src/types/agent-event.ts @@ -19,13 +19,17 @@ export type AgentEvent = message: AgentMessage; function_results: FunctionResultMessage[]; } - | { type: 'message_start'; message: AgentMessage } | { type: 'message_update'; message: AgentMessage; llm_event: AssistantMessageEvent; } - | { type: 'message_end'; message: AgentMessage } + | { + type: 'message_complete'; + message: AgentMessage; + /** When true, text/thinking were already delivered via message_update. */ + body_streamed?: boolean; + } | { type: 'function_execution_start'; function_call_id: string; diff --git a/harness/tests/harness/trigger.test.ts b/harness/tests/harness/trigger.test.ts index d12859ec..53cdcd40 100644 --- a/harness/tests/harness/trigger.test.ts +++ b/harness/tests/harness/trigger.test.ts @@ -62,8 +62,6 @@ describe('harness::trigger', () => { expect(triggerArg.payload).toMatchObject(runStartPayload); expect(triggerArg.payload).toMatchObject({ system_prompt: '', - image: 'python', - idle_timeout_secs: 300, }); expect(result.status_code).toBe(200); expect(result.body).toEqual({ session_id: 'sess' }); diff --git a/harness/tests/integration/on-record-written.e2e.test.ts b/harness/tests/integration/on-record-written.e2e.test.ts index 081b5c03..bac27500 100644 --- a/harness/tests/integration/on-record-written.e2e.test.ts +++ b/harness/tests/integration/on-record-written.e2e.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TriggerAction } from '../../src/runtime/iii.js'; import type { ISdk } from '../../src/runtime/iii.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; -import { newRecord } from '../../src/turn-orchestrator/state.js'; +import { newRecord, turnStateKey } from '../../src/turn-orchestrator/state.js'; function fakeIii(): { iii: ISdk; @@ -124,3 +124,35 @@ describe('saveRecord wake integration', () => { expect(wakeInvocations).toEqual([]); }); }); + +function turnStateGets(iii: ISdk, session_id: string): number { + const trigger = iii.trigger as unknown as { + mock: { calls: Array<[{ function_id: string; payload?: { key?: string } }]> }; + }; + return trigger.mock.calls.filter( + ([arg]) => arg.function_id === 'state::get' && arg.payload?.key === turnStateKey(session_id), + ).length; +} + +describe('saveRecord read elimination (#5)', () => { + it('2-arg saveRecord reads turn_state exactly once (no double load)', async () => { + const { iii } = fakeIii(); + const rec = newRecord('sess-r1'); + rec.state = 'provisioning'; + + await persistence.saveRecord(iii, rec); + + expect(turnStateGets(iii, 'sess-r1')).toBe(1); + }); + + it('saveRecord with a threaded previous reads turn_state zero times', async () => { + const { iii } = fakeIii(); + const previous = newRecord('sess-r2'); + previous.state = 'provisioning'; + const next = { ...previous, state: 'assistant_streaming' as const }; + + await persistence.saveRecord(iii, next, previous); + + expect(turnStateGets(iii, 'sess-r2')).toBe(0); + }); +}); diff --git a/harness/tests/turn-orchestrator/agent-trigger.test.ts b/harness/tests/turn-orchestrator/agent-trigger.test.ts index e00ad084..f4d0d83e 100644 --- a/harness/tests/turn-orchestrator/agent-trigger.test.ts +++ b/harness/tests/turn-orchestrator/agent-trigger.test.ts @@ -7,6 +7,7 @@ import { dispatchWithHook, functionNotFoundHint, isErrorResult, + triggerFunctionCall, } from '../../src/turn-orchestrator/agent-trigger.js'; import * as hookModule from '../../src/turn-orchestrator/hook.js'; @@ -77,14 +78,32 @@ describe('isErrorResult', () => { }); }); +describe('triggerFunctionCall', () => { + it('returns gate_unavailable denial on trigger failure', async () => { + const iii = { + trigger: vi.fn().mockRejectedValue(new Error('handler error')), + } as unknown as ISdk; + const result = await triggerFunctionCall(iii, { + id: 'fc-1', + function_id: 'shell::fs::write', + arguments: {}, + }); + expect(isErrorResult(result)).toBe(true); + expect(result.details).toMatchObject({ + status: 'denied', + denied_by: 'gate_unavailable', + function_id: 'shell::fs::write', + }); + }); +}); + describe('dispatchWithHook returns DispatchResult', () => { it('returns kind:pending when consultBefore returns pending', async () => { vi.spyOn(hookModule, 'consultBefore').mockResolvedValue({ kind: 'pending' }); const iii = { trigger: vi.fn() } as unknown as ISdk; const out = await dispatchWithHook( iii, - { id: 'fc-1', function_id: 'shell::run', arguments: { command: 'ls' } }, - 's1', + { id: 'fc-1', function_id: 'shell::run', arguments: { command: 'ls' } } ); expect(out.kind).toBe('pending'); }); @@ -103,8 +122,7 @@ describe('dispatchWithHook returns DispatchResult', () => { const iii = { trigger: vi.fn() } as unknown as ISdk; const out = await dispatchWithHook( iii, - { id: 'fc-1', function_id: 'shell::run', arguments: {} }, - 's1', + { id: 'fc-1', function_id: 'shell::run', arguments: {} } ); expect(out.kind).toBe('deny'); if (out.kind === 'deny') { @@ -119,8 +137,7 @@ describe('dispatchWithHook returns DispatchResult', () => { } as unknown as ISdk; const out = await dispatchWithHook( iii, - { id: 'fc-1', function_id: 'shell::run', arguments: {} }, - 's1', + { id: 'fc-1', function_id: 'shell::run', arguments: {} } ); expect(out.kind).toBe('result'); }); @@ -141,8 +158,7 @@ describe('dispatchWithHook returns DispatchResult', () => { id: 'fc-1', function_id: 'sandbox/skills/sandbox/create', arguments: { image: 'node' }, - }, - 's1', + } ); expect(out.kind).toBe('result'); if (out.kind !== 'result') return; @@ -160,8 +176,7 @@ describe('dispatchWithHook returns DispatchResult', () => { } as unknown as ISdk; const out = await dispatchWithHook( iii, - { id: 'fc-1', function_id: 'some/odd/three-segment/id', arguments: {} }, - 's1', + { id: 'fc-1', function_id: 'some/odd/three-segment/id', arguments: {} } ); if (out.kind !== 'result') throw new Error('expected result kind'); const details = out.result.details as Record; @@ -179,8 +194,7 @@ describe('dispatchWithHook returns DispatchResult', () => { } as unknown as ISdk; const out = await dispatchWithHook( iii, - { id: 'fc-1', function_id: 'misspelled', arguments: {} }, - 's1', + { id: 'fc-1', function_id: 'misspelled', arguments: {} } ); if (out.kind !== 'result') throw new Error('expected result kind'); const details = out.result.details as Record; diff --git a/harness/tests/turn-orchestrator/assistant.test.ts b/harness/tests/turn-orchestrator/assistant.test.ts index 205382c0..cd61e2f2 100644 --- a/harness/tests/turn-orchestrator/assistant.test.ts +++ b/harness/tests/turn-orchestrator/assistant.test.ts @@ -60,6 +60,8 @@ describe('handleStreaming turn start', () => { vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ provider: 'openai', model: 'gpt-4o', + mode: null, + system_prompt: '', }); vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); vi.spyOn(persistence, 'loadFunctionSchemas').mockResolvedValue([]); @@ -107,6 +109,8 @@ describe('handleStreaming', () => { vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ provider: 'openai', model: 'gpt-4o', + mode: null, + system_prompt: '', }); vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); vi.spyOn(persistence, 'loadFunctionSchemas').mockResolvedValue([]); @@ -148,6 +152,8 @@ describe('handleStreaming', () => { vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ provider: 'openai', model: 'gpt-4o', + mode: null, + system_prompt: '', }); vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); vi.spyOn(persistence, 'loadFunctionSchemas').mockResolvedValue([]); @@ -322,4 +328,37 @@ describe('handleFinished', () => { }, ]); }); + + it('blocks agent_trigger calls with missing or empty function at prepare time', async () => { + const rec: TurnStateRecord = { + ...newRecord('s1'), + state: 'assistant_finished', + last_assistant: assistant({ + content: [ + { + type: 'function_call', + id: 'fc-bad', + function_id: TOOL_NAME, + arguments: { payload: { command: 'ls' } }, + }, + ], + }), + }; + const { iii } = fakeIii(); + vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); + vi.spyOn(persistence, 'saveMessages').mockResolvedValue(undefined); + vi.spyOn(persistence, 'saveExecutedCalls').mockResolvedValue(undefined); + const savePreparedSpy = vi.spyOn(persistence, 'savePreparedCalls').mockResolvedValue(undefined); + + await handleFinished(iii, rec); + + expect(rec.state).toBe('function_execute'); + const prepared = savePreparedSpy.mock.calls[0]?.[2]; + expect(prepared?.[0]?.function_call).toEqual({ + id: 'fc-bad', + function_id: '', + arguments: { command: 'ls' }, + }); + expect(prepared?.[0]?.blocked?.details).toMatchObject({ error: 'missing_function' }); + }); }); diff --git a/harness/tests/turn-orchestrator/functions.test.ts b/harness/tests/turn-orchestrator/functions.test.ts index 97c6c42d..f2d76b44 100644 --- a/harness/tests/turn-orchestrator/functions.test.ts +++ b/harness/tests/turn-orchestrator/functions.test.ts @@ -85,7 +85,7 @@ describe('handleExecute new flow', () => { expect(registerResumeSpy).toHaveBeenCalledWith(iii, 's1', 'fc-1'); }); - it('skips dispatchWithHook on pre_approved entries and calls iii.trigger directly', async () => { + it('skips consultBefore on pre_approved entries and uses triggerFunctionCall', async () => { const triggerSpy = vi.fn().mockResolvedValue({ ok: true }); const iii = { trigger: triggerSpy } as unknown as ISdk; const rec: TurnStateRecord = newRecord('s1'); @@ -115,7 +115,7 @@ describe('handleExecute new flow', () => { expect(triggerCalls).toContain('shell::run'); }); - it('synthesizes an error result when a pre_approved trigger rejects (does not throw out of handleExecute)', async () => { + it('synthesizes a gate_unavailable denial when a pre_approved trigger rejects', async () => { const triggerSpy = vi.fn(async (req: { function_id: string }) => { if (req.function_id === 'shell::fs::write') { throw new Error('handler error: {"code":"S210","message":"bad write payload"}'); @@ -154,9 +154,10 @@ describe('handleExecute new flow', () => { .find((arr) => Array.isArray(arr) && arr.length > 0); expect(savedResults?.[0]?.is_error).toBe(true); const details = savedResults?.[0]?.result.details as Record; - expect(details?.error).toBe('trigger_failed'); - expect(details?.function).toBe('shell::fs::write'); - expect(String(details?.message)).toContain('S210'); + expect(details?.status).toBe('denied'); + expect(details?.denied_by).toBe('gate_unavailable'); + expect(details?.function_id).toBe('shell::fs::write'); + expect(String(details?.reason)).toContain('S210'); }); it('emits denial result without dispatching when blocked is set', async () => { diff --git a/harness/tests/turn-orchestrator/get-state.test.ts b/harness/tests/turn-orchestrator/get-state.test.ts index 63717084..5c76220f 100644 --- a/harness/tests/turn-orchestrator/get-state.test.ts +++ b/harness/tests/turn-orchestrator/get-state.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import { execute, GetStatePayloadSchema } from '../../src/turn-orchestrator/get-state.js'; +import { execute } from '../../src/turn-orchestrator/get-state.js'; import { newRecord } from '../../src/turn-orchestrator/state.js'; +import { GetStatePayloadSchema } from '../../src/turn-orchestrator/schemas.js'; describe('GetStatePayloadSchema', () => { it('accepts the flat shape the real backend sends', () => { diff --git a/harness/tests/turn-orchestrator/on-abort-signal.test.ts b/harness/tests/turn-orchestrator/on-abort-signal.test.ts index 20e7b6fb..e185cea3 100644 --- a/harness/tests/turn-orchestrator/on-abort-signal.test.ts +++ b/harness/tests/turn-orchestrator/on-abort-signal.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { TriggerAction, type ISdk } from '../../src/runtime/iii.js'; import { - AbortSignalWriteEventSchema, execute, handleAbortSignalWrite, isAbortSignalWrite, parseAbortSignalWrite, } from '../../src/turn-orchestrator/on-abort-signal.js'; +import { AbortSignalWriteEventSchema } from '../../src/turn-orchestrator/schemas.js'; import { newRecord } from '../../src/turn-orchestrator/state.js'; const matchingEvent = { diff --git a/harness/tests/turn-orchestrator/provisioning.test.ts b/harness/tests/turn-orchestrator/provisioning.test.ts index 9c87123c..251ec424 100644 --- a/harness/tests/turn-orchestrator/provisioning.test.ts +++ b/harness/tests/turn-orchestrator/provisioning.test.ts @@ -3,12 +3,11 @@ import type { ISdk } from '../../src/runtime/iii.js'; import type { TurnOrchestratorConfig } from '../../src/turn-orchestrator/config.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; import { type TurnStateRecord, newRecord } from '../../src/turn-orchestrator/state.js'; -import { TurnStepPayloadSchema } from '../../src/turn-orchestrator/turn-step-payload.js'; +import { TurnStepPayloadSchema } from '../../src/turn-orchestrator/schemas.js'; import { - execute, handleProvisioning, parseDirectoryBody, - parseRunRequest, + register, } from '../../src/turn-orchestrator/states/provisioning.js'; type TriggerCall = { function_id: string; payload: unknown; timeoutMs?: number }; @@ -36,24 +35,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('parseRunRequest', () => { - it('maps persisted run::start fields with defaults for missing keys', () => { - expect(parseRunRequest({})).toEqual({ - provider: '', - model: '', - mode: null, - system_prompt: '', - image: 'python', - idle_timeout_secs: 300, - }); - }); - - it('rejects invalid mode values', () => { - expect(parseRunRequest({ mode: 'invalid' }).mode).toBeNull(); - expect(parseRunRequest({ mode: 'plan' }).mode).toBe('plan'); - }); -}); - describe('parseDirectoryBody', () => { it('accepts bare string and wrapped body responses', () => { expect(parseDirectoryBody('raw')).toBe('raw'); @@ -81,8 +62,6 @@ describe('handleProvisioning', () => { model: 'gpt-4', mode: 'agent', system_prompt: '', - image: 'python', - idle_timeout_secs: 300, }); const saveSchemas = vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); const saveRunRequest = vi.spyOn(persistence, 'saveRunRequest').mockResolvedValue(); @@ -114,6 +93,7 @@ describe('handleProvisioning', () => { vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ provider: 'openai', model: 'gpt-4', + mode: null, system_prompt: 'custom override', }); vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); @@ -133,7 +113,12 @@ describe('handleProvisioning', () => { const { iii } = fakeIii(); const cfg = { system_default_skills: ['iii://missing'] }; - vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({}); + vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ + provider: '', + model: '', + mode: null, + system_prompt: '', + }); vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); const saveRunRequest = vi.spyOn(persistence, 'saveRunRequest').mockResolvedValue(); @@ -156,40 +141,59 @@ describe('TurnStepPayloadSchema', () => { }); }); -describe('execute', () => { +describe('register', () => { const cfg: TurnOrchestratorConfig = { system_default_skills: [] }; - it('throws when the session record is missing', async () => { - vi.spyOn(persistence, 'loadRecord').mockResolvedValue(null); - - await expect(execute({} as ISdk, cfg, { session_id: 'missing' })).rejects.toThrow( - 'turn::provisioning invariant: missing session missing', - ); - }); - - it('returns stale skip when persisted state drifted', async () => { - const rec = { ...newRecord('s1'), state: 'assistant_streaming' as const }; - vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); - const saveRecord = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); - - const result = await execute({} as ISdk, cfg, { session_id: 's1' }); - - expect(result).toEqual({ ok: true, skipped: true, reason: 'stale' }); - expect(saveRecord).not.toHaveBeenCalled(); - }); - - it('runs handleProvisioning, saves the record, and returns transition metadata', async () => { + type Handler = (payload: unknown) => Promise; + + function captureHandler(): { iii: ISdk; getHandler: () => Handler; getId: () => string } { + let handler: Handler | null = null; + let registeredId = ''; + const iii = { + registerFunction: (id: string, fn: Handler) => { + registeredId = id; + handler = fn; + return { unregister: () => {} }; + }, + trigger: async () => null, + } as unknown as ISdk; + return { + iii, + getHandler: () => { + if (!handler) throw new Error('handler not registered'); + return handler; + }, + getId: () => registeredId, + }; + } + + it('registers turn::provisioning, threads cfg into the runner, and returns metadata', async () => { const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; - const { iii } = fakeIii(); vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); const saveRecord = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); - vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({}); + const loadRunRequest = vi.spyOn(persistence, 'loadRunRequest').mockResolvedValue({ + provider: '', + model: '', + mode: null, + system_prompt: '', + }); vi.spyOn(persistence, 'saveFunctionSchemas').mockResolvedValue(); vi.spyOn(persistence, 'saveRunRequest').mockResolvedValue(); - const result = await execute(iii, cfg, { session_id: 's1' }); + const { iii, getHandler, getId } = captureHandler(); + register(iii, cfg); + expect(getId()).toBe('turn::provisioning'); - expect(saveRecord).toHaveBeenCalledWith(iii, rec); + const result = await getHandler()({ session_id: 's1' }); + + // cfg flows through to handleProvisioning (which reads the run request), + // and the runner threads the pre-mutation snapshot into saveRecord. + expect(loadRunRequest).toHaveBeenCalledWith(iii, 's1'); + expect(saveRecord).toHaveBeenCalledWith( + iii, + rec, + expect.objectContaining({ state: 'provisioning' }), + ); expect(result).toEqual({ ok: true, from_state: 'provisioning', @@ -197,14 +201,9 @@ describe('execute', () => { }); }); - it('wraps handler failures as transition errors', async () => { - const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; - vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); - vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); - vi.spyOn(persistence, 'loadRunRequest').mockRejectedValue(new Error('boom')); - - await expect(execute({} as ISdk, cfg, { session_id: 's1' })).rejects.toThrow( - 'transition from provisioning failed: Error: boom', - ); + it('rejects payloads missing session_id', async () => { + const { iii, getHandler } = captureHandler(); + register(iii, cfg); + await expect(getHandler()({})).rejects.toThrow(); }); }); diff --git a/harness/tests/turn-orchestrator/run-request.test.ts b/harness/tests/turn-orchestrator/run-request.test.ts new file mode 100644 index 00000000..745557b8 --- /dev/null +++ b/harness/tests/turn-orchestrator/run-request.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { parseRunRequest } from '../../src/turn-orchestrator/run-request.js'; + +describe('parseRunRequest', () => { + it('maps persisted run::start fields with defaults for missing keys', () => { + expect(parseRunRequest({})).toEqual({ + provider: '', + model: '', + mode: null, + system_prompt: '', + }); + }); + + it('passes through provided string fields', () => { + expect(parseRunRequest({ provider: 'openai', model: 'gpt-4', system_prompt: 'hi' })).toEqual({ + provider: 'openai', + model: 'gpt-4', + mode: null, + system_prompt: 'hi', + }); + }); + + it('rejects invalid mode values and accepts valid ones', () => { + expect(parseRunRequest({ mode: 'invalid' }).mode).toBeNull(); + expect(parseRunRequest({ mode: 'plan' }).mode).toBe('plan'); + expect(parseRunRequest({ mode: 'ask' }).mode).toBe('ask'); + expect(parseRunRequest({ mode: 'agent' }).mode).toBe('agent'); + }); + + it('coerces non-string fields to defaults', () => { + expect(parseRunRequest({ provider: 123, model: null, system_prompt: {} })).toEqual({ + provider: '', + model: '', + mode: null, + system_prompt: '', + }); + }); +}); diff --git a/harness/tests/turn-orchestrator/run-start.test.ts b/harness/tests/turn-orchestrator/run-start.test.ts index ee6c2bff..7e08a4ad 100644 --- a/harness/tests/turn-orchestrator/run-start.test.ts +++ b/harness/tests/turn-orchestrator/run-start.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TriggerAction, type ISdk } from '../../src/runtime/iii.js'; -import { RunStartPayloadSchema, execute, register } from '../../src/turn-orchestrator/run-start.js'; +import { execute, register } from '../../src/turn-orchestrator/run-start.js'; +import { RunStartPayloadSchema } from '../../src/turn-orchestrator/schemas.js'; type TriggerCall = { function_id: string; payload: unknown; action?: unknown }; @@ -59,8 +60,6 @@ describe('RunStartPayloadSchema', () => { model: 'claude-sonnet-4-6', mode: 'agent', system_prompt: '', - image: 'python', - idle_timeout_secs: 300, messages: consoleRunStartPayload.messages, }); }); @@ -71,8 +70,6 @@ describe('RunStartPayloadSchema', () => { provider: 'anthropic', model: 'claude-sonnet-4-6', system_prompt: '', - image: 'python', - idle_timeout_secs: 300, messages: harnessRunStartPayload.messages, }); }); diff --git a/harness/tests/turn-orchestrator/run-transition.test.ts b/harness/tests/turn-orchestrator/run-transition.test.ts new file mode 100644 index 00000000..374f5b59 --- /dev/null +++ b/harness/tests/turn-orchestrator/run-transition.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ISdk } from '../../src/runtime/iii.js'; +import * as persistence from '../../src/turn-orchestrator/persistence.js'; +import { runTransition } from '../../src/turn-orchestrator/run-transition.js'; +import { type TurnStateRecord, newRecord, transitionTo } from '../../src/turn-orchestrator/state.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('runTransition', () => { + it('throws when the session record is missing, without running the handler', async () => { + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(null); + const handle = vi.fn(); + + await expect( + runTransition({} as ISdk, 'provisioning', handle, { session_id: 'missing' }), + ).rejects.toThrow('turn::provisioning invariant: missing session missing'); + expect(handle).not.toHaveBeenCalled(); + }); + + it('returns a stale skip without running the handler or saving', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'assistant_streaming' }; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + const saveRecord = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); + const handle = vi.fn(); + + const result = await runTransition({} as ISdk, 'provisioning', handle, { session_id: 's1' }); + + expect(result).toEqual({ ok: true, skipped: true, reason: 'stale' }); + expect(handle).not.toHaveBeenCalled(); + expect(saveRecord).not.toHaveBeenCalled(); + }); + + it('runs the handler and threads the pre-mutation snapshot into saveRecord', async () => { + const iii = {} as ISdk; + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'provisioning' }; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + const saveRecord = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); + const handle = vi.fn(async (_iii: ISdk, r: TurnStateRecord) => { + transitionTo(r, 'assistant_streaming'); + }); + + const result = await runTransition(iii, 'provisioning', handle, { session_id: 's1' }); + + expect(handle).toHaveBeenCalledWith(iii, rec); + expect(saveRecord).toHaveBeenCalledWith( + iii, + rec, + expect.objectContaining({ state: 'provisioning' }), + ); + expect(result).toEqual({ + ok: true, + from_state: 'provisioning', + to_state: 'assistant_streaming', + }); + }); + + it('snapshots a deep copy so in-place handler mutation does not leak into previous', async () => { + const iii = {} as ISdk; + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'function_execute' }; + rec.awaiting_approval = []; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + let captured: TurnStateRecord | null | undefined; + vi.spyOn(persistence, 'saveRecord').mockImplementation(async (_i, _r, previous) => { + captured = previous; + }); + const handle = vi.fn(async (_iii: ISdk, r: TurnStateRecord) => { + r.awaiting_approval?.push({ function_call_id: 'fc-1', function_id: 'f', args: {} }); + transitionTo(r, 'function_awaiting_approval'); + }); + + await runTransition(iii, 'function_execute', handle, { session_id: 's1' }); + + // The snapshot reflects state BEFORE the handler ran, even though the + // handler mutated rec.awaiting_approval in place. + expect(captured?.state).toBe('function_execute'); + expect(captured?.awaiting_approval).toEqual([]); + }); + + it('wraps handler failures as transition errors tagged with the from-state', async () => { + const rec: TurnStateRecord = { ...newRecord('s1'), state: 'steering_check' }; + vi.spyOn(persistence, 'loadRecord').mockResolvedValue(rec); + const saveRecord = vi.spyOn(persistence, 'saveRecord').mockResolvedValue(); + const handle = vi.fn(async () => { + throw new Error('boom'); + }); + + await expect( + runTransition({} as ISdk, 'steering_check', handle, { session_id: 's1' }), + ).rejects.toThrow('transition from steering_check failed: Error: boom'); + expect(saveRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/harness/tests/turn-orchestrator/tearing-down.test.ts b/harness/tests/turn-orchestrator/tearing-down.test.ts index 0aca5f3c..cc3ed045 100644 --- a/harness/tests/turn-orchestrator/tearing-down.test.ts +++ b/harness/tests/turn-orchestrator/tearing-down.test.ts @@ -36,7 +36,6 @@ describe('handleTearingDown', () => { const rec: TurnStateRecord = { ...newRecord('s1'), state: 'tearing_down' }; const messages: AgentMessage[] = [{ role: 'user', content: 'hi' }]; const { iii } = fakeIii(); - vi.spyOn(persistence, 'loadSandboxId').mockResolvedValue(null); vi.spyOn(persistence, 'loadMessages').mockResolvedValue(messages); const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); @@ -45,19 +44,4 @@ describe('handleTearingDown', () => { expect(rec.state).toBe('stopped'); expect(emitSpy).toHaveBeenCalledWith(iii, 's1', { type: 'agent_end', messages }); }); - - it('calls sandbox::stop before ending the agent when a sandbox id exists', async () => { - const rec: TurnStateRecord = { ...newRecord('s1'), state: 'tearing_down' }; - const { iii, calls } = fakeIii(); - vi.spyOn(persistence, 'loadSandboxId').mockResolvedValue('sandbox-1'); - vi.spyOn(persistence, 'loadMessages').mockResolvedValue([]); - vi.spyOn(events, 'emit').mockResolvedValue(undefined); - - await handleTearingDown(iii, rec); - - const sandboxCall = calls.find((c) => c.function_id === 'sandbox::stop'); - expect(sandboxCall?.payload).toEqual({ sandbox_id: 'sandbox-1', wait: true }); - expect(sandboxCall?.timeoutMs).toBe(60_000); - expect(rec.state).toBe('stopped'); - }); }); From 360ed05c44f2ad817ab70ac23cef7d623a80e018 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Sun, 24 May 2026 07:07:02 -0300 Subject: [PATCH 11/16] chore: remove pnpm-lock.yaml file - Deleted the pnpm-lock.yaml file from the project, which may indicate a shift in package management strategy or a cleanup of unused files. --- harness-node/pnpm-lock.yaml | 1955 ----------------------------------- 1 file changed, 1955 deletions(-) delete mode 100644 harness-node/pnpm-lock.yaml diff --git a/harness-node/pnpm-lock.yaml b/harness-node/pnpm-lock.yaml deleted file mode 100644 index 4bc21128..00000000 --- a/harness-node/pnpm-lock.yaml +++ /dev/null @@ -1,1955 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@opentelemetry/api': - specifier: ^1.9.0 - version: 1.9.1 - chokidar: - specifier: ^3.6.0 - version: 3.6.0 - commander: - specifier: ^12.1.0 - version: 12.1.0 - iii-sdk: - specifier: ^0.12.0 - version: 0.12.0 - pino: - specifier: ^9.5.0 - version: 9.14.0 - uuid: - specifier: ^11.0.3 - version: 11.1.1 - yaml: - specifier: ^2.6.1 - version: 2.9.0 - zod: - specifier: ^3.23.8 - version: 3.25.76 - zod-to-json-schema: - specifier: ^3.24.1 - version: 3.25.2(zod@3.25.76) - devDependencies: - '@biomejs/biome': - specifier: ^1.9.4 - version: 1.9.4 - '@opentelemetry/context-async-hooks': - specifier: ^1.30.0 - version: 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': - specifier: ^1.30.0 - version: 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': - specifier: ^1.30.0 - version: 1.30.1(@opentelemetry/api@1.9.1) - '@types/node': - specifier: ^22.10.5 - version: 22.19.19 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 - tsx: - specifier: ^4.19.2 - version: 4.22.1 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - vitest: - specifier: ^2.1.8 - version: 2.1.9(@types/node@22.19.19) - -packages: - - '@biomejs/biome@1.9.4': - resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@1.9.4': - resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@1.9.4': - resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@1.9.4': - resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - - '@biomejs/cli-linux-arm64@1.9.4': - resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - - '@biomejs/cli-linux-x64-musl@1.9.4': - resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - - '@biomejs/cli-linux-x64@1.9.4': - resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - - '@biomejs/cli-win32-arm64@1.9.4': - resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@1.9.4': - resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@opentelemetry/api-logs@0.57.2': - resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} - engines: {node: '>=14'} - - '@opentelemetry/api@1.9.1': - resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} - engines: {node: '>=8.0.0'} - - '@opentelemetry/context-async-hooks@1.30.1': - resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/core@1.30.1': - resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/instrumentation@0.57.2': - resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/otlp-transformer@0.57.2': - resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/propagator-b3@1.30.1': - resolution: {integrity: sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/propagator-jaeger@1.30.1': - resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/resources@1.30.1': - resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/sdk-logs@0.57.2': - resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.4.0 <1.10.0' - - '@opentelemetry/sdk-metrics@1.30.1': - resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/sdk-trace-base@1.30.1': - resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/sdk-trace-node@1.30.1': - resolution: {integrity: sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/semantic-conventions@1.28.0': - resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} - engines: {node: '>=14'} - - '@opentelemetry/semantic-conventions@1.41.1': - resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} - engines: {node: '>=14'} - - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - - '@protobufjs/codegen@2.0.5': - resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - - '@protobufjs/fetch@1.1.1': - resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} - - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - - '@protobufjs/utf8@1.1.1': - resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} - - '@rollup/rollup-android-arm-eabi@4.60.4': - resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.4': - resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.4': - resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.4': - resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.4': - resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.4': - resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.60.4': - resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.60.4': - resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.60.4': - resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.60.4': - resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.60.4': - resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.60.4': - resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.60.4': - resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.60.4': - resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.60.4': - resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.60.4': - resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.60.4': - resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.60.4': - resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.60.4': - resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.4': - resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.4': - resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.4': - resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.4': - resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.4': - resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} - cpu: [x64] - os: [win32] - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/node@22.19.19': - resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} - - '@types/shimmer@1.2.0': - resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} - - '@types/uuid@10.0.0': - resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} - - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - - acorn-import-attributes@1.9.5: - resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} - peerDependencies: - acorn: ^8 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} - engines: {node: '>=18'} - - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} - engines: {node: '>=18'} - hasBin: true - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} - - iii-sdk@0.12.0: - resolution: {integrity: sha512-Y638PCUeJVGkYjpIvkBgVFXB9WmmbGHsRiRo02E+oph1ShOwmTDJ1LlSFn2h/dZC6cxgOOFwsMTltpGM6j1A+w==} - - import-in-the-middle@1.15.0: - resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - module-details-from-path@1.0.4: - resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - pino-abstract-transport@2.0.0: - resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@9.14.0: - resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} - hasBin: true - - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} - engines: {node: ^10 || ^12 || >=14} - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - - protobufjs@7.5.9: - resolution: {integrity: sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==} - engines: {node: '>=12.0.0'} - - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - require-in-the-middle@7.5.2: - resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} - engines: {node: '>=8.6.0'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - rollup@4.60.4: - resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - - shimmer@1.2.1: - resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - thread-stream@3.1.0: - resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} - engines: {node: '>=14.0.0'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - tsx@4.22.1: - resolution: {integrity: sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} - engines: {node: '>= 14.6'} - hasBin: true - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@biomejs/biome@1.9.4': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 1.9.4 - '@biomejs/cli-darwin-x64': 1.9.4 - '@biomejs/cli-linux-arm64': 1.9.4 - '@biomejs/cli-linux-arm64-musl': 1.9.4 - '@biomejs/cli-linux-x64': 1.9.4 - '@biomejs/cli-linux-x64-musl': 1.9.4 - '@biomejs/cli-win32-arm64': 1.9.4 - '@biomejs/cli-win32-x64': 1.9.4 - - '@biomejs/cli-darwin-arm64@1.9.4': - optional: true - - '@biomejs/cli-darwin-x64@1.9.4': - optional: true - - '@biomejs/cli-linux-arm64-musl@1.9.4': - optional: true - - '@biomejs/cli-linux-arm64@1.9.4': - optional: true - - '@biomejs/cli-linux-x64-musl@1.9.4': - optional: true - - '@biomejs/cli-linux-x64@1.9.4': - optional: true - - '@biomejs/cli-win32-arm64@1.9.4': - optional: true - - '@biomejs/cli-win32-x64@1.9.4': - optional: true - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/aix-ppc64@0.28.0': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.28.0': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-arm@0.28.0': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/android-x64@0.28.0': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.28.0': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.28.0': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.28.0': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.28.0': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.28.0': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-arm@0.28.0': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.28.0': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.28.0': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.28.0': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.28.0': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.28.0': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.28.0': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/linux-x64@0.28.0': - optional: true - - '@esbuild/netbsd-arm64@0.28.0': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.28.0': - optional: true - - '@esbuild/openbsd-arm64@0.28.0': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.28.0': - optional: true - - '@esbuild/openharmony-arm64@0.28.0': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.28.0': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.28.0': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.28.0': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@esbuild/win32-x64@0.28.0': - optional: true - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@opentelemetry/api-logs@0.57.2': - dependencies: - '@opentelemetry/api': 1.9.1 - - '@opentelemetry/api@1.9.1': {} - - '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - - '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.28.0 - - '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.57.2 - '@types/shimmer': 1.2.0 - import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2 - semver: 7.8.0 - shimmer: 1.2.1 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) - protobufjs: 7.5.9 - - '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - - '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - - '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.28.0 - - '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) - - '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) - - '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.28.0 - - '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) - semver: 7.8.0 - - '@opentelemetry/semantic-conventions@1.28.0': {} - - '@opentelemetry/semantic-conventions@1.41.1': {} - - '@pinojs/redact@0.4.0': {} - - '@protobufjs/aspromise@1.1.2': {} - - '@protobufjs/base64@1.1.2': {} - - '@protobufjs/codegen@2.0.5': {} - - '@protobufjs/eventemitter@1.1.0': {} - - '@protobufjs/fetch@1.1.1': - dependencies: - '@protobufjs/aspromise': 1.1.2 - - '@protobufjs/float@1.0.2': {} - - '@protobufjs/inquire@1.1.2': {} - - '@protobufjs/path@1.1.2': {} - - '@protobufjs/pool@1.1.0': {} - - '@protobufjs/utf8@1.1.1': {} - - '@rollup/rollup-android-arm-eabi@4.60.4': - optional: true - - '@rollup/rollup-android-arm64@4.60.4': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.4': - optional: true - - '@rollup/rollup-darwin-x64@4.60.4': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.4': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.4': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.4': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.4': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.4': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.4': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.4': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.4': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.4': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.4': - optional: true - - '@types/estree@1.0.8': {} - - '@types/estree@1.0.9': {} - - '@types/node@22.19.19': - dependencies: - undici-types: 6.21.0 - - '@types/shimmer@1.2.0': {} - - '@types/uuid@10.0.0': {} - - '@vitest/expect@2.1.9': - dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - tinyrainbow: 1.2.0 - - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@22.19.19) - - '@vitest/pretty-format@2.1.9': - dependencies: - tinyrainbow: 1.2.0 - - '@vitest/runner@2.1.9': - dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 - - '@vitest/snapshot@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - magic-string: 0.30.21 - pathe: 1.1.2 - - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 - - '@vitest/utils@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - loupe: 3.2.1 - tinyrainbow: 1.2.0 - - acorn-import-attributes@1.9.5(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - assertion-error@2.0.1: {} - - atomic-sleep@1.0.0: {} - - binary-extensions@2.3.0: {} - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - cac@6.7.14: {} - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - check-error@2.1.3: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - cjs-module-lexer@1.4.3: {} - - commander@12.1.0: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@5.0.2: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - esbuild@0.28.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - expect-type@1.3.0: {} - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - - iii-sdk@0.12.0: - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.41.1 - ws: 8.20.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - import-in-the-middle@1.15.0: - dependencies: - acorn: 8.16.0 - acorn-import-attributes: 1.9.5(acorn@8.16.0) - cjs-module-lexer: 1.4.3 - module-details-from-path: 1.0.4 - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.3 - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - long@5.3.2: {} - - loupe@3.2.1: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - module-details-from-path@1.0.4: {} - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - normalize-path@3.0.0: {} - - on-exit-leak-free@2.1.2: {} - - path-parse@1.0.7: {} - - pathe@1.1.2: {} - - pathval@2.0.1: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - pino-abstract-transport@2.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.1.0: {} - - pino@9.14.0: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 2.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 3.1.0 - - postcss@8.5.14: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - process-warning@5.0.0: {} - - protobufjs@7.5.9: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.1 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.1 - '@types/node': 22.19.19 - long: 5.3.2 - - quick-format-unescaped@4.0.4: {} - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - - real-require@0.2.0: {} - - require-in-the-middle@7.5.2: - dependencies: - debug: 4.4.3 - module-details-from-path: 1.0.4 - resolve: 1.22.12 - transitivePeerDependencies: - - supports-color - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - rollup@4.60.4: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.4 - '@rollup/rollup-android-arm64': 4.60.4 - '@rollup/rollup-darwin-arm64': 4.60.4 - '@rollup/rollup-darwin-x64': 4.60.4 - '@rollup/rollup-freebsd-arm64': 4.60.4 - '@rollup/rollup-freebsd-x64': 4.60.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 - '@rollup/rollup-linux-arm-musleabihf': 4.60.4 - '@rollup/rollup-linux-arm64-gnu': 4.60.4 - '@rollup/rollup-linux-arm64-musl': 4.60.4 - '@rollup/rollup-linux-loong64-gnu': 4.60.4 - '@rollup/rollup-linux-loong64-musl': 4.60.4 - '@rollup/rollup-linux-ppc64-gnu': 4.60.4 - '@rollup/rollup-linux-ppc64-musl': 4.60.4 - '@rollup/rollup-linux-riscv64-gnu': 4.60.4 - '@rollup/rollup-linux-riscv64-musl': 4.60.4 - '@rollup/rollup-linux-s390x-gnu': 4.60.4 - '@rollup/rollup-linux-x64-gnu': 4.60.4 - '@rollup/rollup-linux-x64-musl': 4.60.4 - '@rollup/rollup-openbsd-x64': 4.60.4 - '@rollup/rollup-openharmony-arm64': 4.60.4 - '@rollup/rollup-win32-arm64-msvc': 4.60.4 - '@rollup/rollup-win32-ia32-msvc': 4.60.4 - '@rollup/rollup-win32-x64-gnu': 4.60.4 - '@rollup/rollup-win32-x64-msvc': 4.60.4 - fsevents: 2.3.3 - - safe-stable-stringify@2.5.0: {} - - semver@7.8.0: {} - - shimmer@1.2.1: {} - - siginfo@2.0.0: {} - - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - - source-map-js@1.2.1: {} - - split2@4.2.0: {} - - stackback@0.0.2: {} - - std-env@3.10.0: {} - - supports-preserve-symlinks-flag@1.0.0: {} - - thread-stream@3.1.0: - dependencies: - real-require: 0.2.0 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinypool@1.1.1: {} - - tinyrainbow@1.2.0: {} - - tinyspy@3.0.2: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - tsx@4.22.1: - dependencies: - esbuild: 0.28.0 - optionalDependencies: - fsevents: 2.3.3 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - uuid@11.1.1: {} - - vite-node@2.1.9(@types/node@22.19.19): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@22.19.19) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@22.19.19): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.14 - rollup: 4.60.4 - optionalDependencies: - '@types/node': 22.19.19 - fsevents: 2.3.3 - - vitest@2.1.9(@types/node@22.19.19): - dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@22.19.19) - vite-node: 2.1.9(@types/node@22.19.19) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.19.19 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - ws@8.20.1: {} - - yaml@2.9.0: {} - - zod-to-json-schema@3.25.2(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod@3.25.76: {} From 2f912dc5d2294dc1b34fffc396f18c9094ca7670 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Sun, 24 May 2026 16:48:30 -0300 Subject: [PATCH 12/16] refactor: consolidate event translation logic into createAgentEventTranslator - Replaced the previous createTurnStateTranslator with a new createAgentEventTranslator function that handles all agent event translations, including `turn_state_changed`. - Updated the realStream function to utilize the new translator, simplifying the event handling process. - Removed deprecated translation functions and cleaned up related comments and documentation for clarity. - Adjusted tests to ensure coverage of the new translation logic and verify correct event handling. --- acp/src/handler.rs | 4 +- console/web/src/lib/backend/real.ts | 9 +- console/web/src/lib/backend/translate.test.ts | 42 +- console/web/src/lib/backend/translate.ts | 201 +-- console/web/src/types/iii-agent-event.ts | 12 - docs/harness-flow.html | 1320 +++++++++++++++++ harness/docs/workers/turn-orchestrator.md | 2 +- .../states/assistant-streaming.ts | 25 - harness/src/types/agent-event.ts | 9 - harness/tests/integration/wire-parity.test.ts | 5 - .../tests/turn-orchestrator/assistant.test.ts | 2 +- 11 files changed, 1427 insertions(+), 204 deletions(-) create mode 100644 docs/harness-flow.html diff --git a/acp/src/handler.rs b/acp/src/handler.rs index 6c7e3291..d34db11c 100644 --- a/acp/src/handler.rs +++ b/acp/src/handler.rs @@ -1039,8 +1039,8 @@ mod tests { #[test] fn translate_unknown_event_drops_silently() { - assert!(translate_agent_event(&json!({ "type": "agent_start" })).is_none()); - assert!(translate_agent_event(&json!({ "type": "turn_start" })).is_none()); + assert!(translate_agent_event(&json!({ "type": "not_a_real_event" })).is_none()); + assert!(translate_agent_event(&json!({ "type": "message_start" })).is_none()); } #[test] diff --git a/console/web/src/lib/backend/real.ts b/console/web/src/lib/backend/real.ts index 9364b520..02164d7a 100644 --- a/console/web/src/lib/backend/real.ts +++ b/console/web/src/lib/backend/real.ts @@ -12,7 +12,7 @@ import type { AgentMessage, SessionEventEnvelope, } from '@/types/iii-agent-event' -import { createTurnStateTranslator, translateAgentEvent } from './translate' +import { createAgentEventTranslator } from './translate' import type { ChatBackend, ChatStreamOptions, @@ -75,7 +75,7 @@ async function* realStream( }) subscribed = true - const turnStateTranslator = createTurnStateTranslator() + const { translate } = createAgentEventTranslator() client .call | null>('turn::get_state', { @@ -147,10 +147,7 @@ async function* realStream( if (kickoffError) continue const event = queue.shift() if (!event) continue - const streamEvents = - event.type === 'turn_state_changed' - ? turnStateTranslator(event, sessionId) - : translateAgentEvent(event, sessionId) + const streamEvents = translate(event, sessionId) for (const streamEvent of streamEvents) { yield streamEvent } diff --git a/console/web/src/lib/backend/translate.test.ts b/console/web/src/lib/backend/translate.test.ts index 750f61d6..82f2d8c4 100644 --- a/console/web/src/lib/backend/translate.test.ts +++ b/console/web/src/lib/backend/translate.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import type { AgentEvent } from '@/types/iii-agent-event' -import { createTurnStateTranslator, translateAgentEvent } from './translate' +import { createAgentEventTranslator } from './translate' + +describe('createAgentEventTranslator — message_complete', () => { + const { translate } = createAgentEventTranslator() -describe('translateAgentEvent — message_complete', () => { const baseAssistant = { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'partial reply…' }], @@ -17,7 +19,7 @@ describe('translateAgentEvent — message_complete', () => { message: { ...baseAssistant, stop_reason: 'end' }, body_streamed: true, } - expect(translateAgentEvent(event)).toEqual([{ kind: 'assistant-end' }]) + expect(translate(event)).toEqual([{ kind: 'assistant-end' }]) }) it('emits assistant-token blocks and assistant-end for a non-streamed batch message', () => { @@ -30,7 +32,7 @@ describe('translateAgentEvent — message_complete', () => { }, body_streamed: false, } - expect(translateAgentEvent(event)).toEqual([ + expect(translate(event)).toEqual([ { kind: 'assistant-token', token: 'hello batch' }, { kind: 'assistant-end' }, ]) @@ -42,7 +44,7 @@ describe('translateAgentEvent — message_complete', () => { message: { ...baseAssistant, stop_reason: 'length' }, body_streamed: true, } - const out = translateAgentEvent(event) + const out = translate(event) expect(out[0]).toEqual({ kind: 'assistant-end' }) expect(out[1]).toMatchObject({ kind: 'stop-reason', reason: 'length' }) }) @@ -58,7 +60,7 @@ describe('translateAgentEvent — message_complete', () => { }, body_streamed: true, } - const out = translateAgentEvent(event) + const out = translate(event) expect(out[0]).toEqual({ kind: 'assistant-end' }) expect(out[1]).toEqual({ kind: 'stop-reason', @@ -73,7 +75,7 @@ describe('translateAgentEvent — message_complete', () => { message: { ...baseAssistant, stop_reason: 'aborted' }, body_streamed: true, } - const out = translateAgentEvent(event) + const out = translate(event) expect(out).toHaveLength(2) expect((out[1] as { kind: string; reason: string }).reason).toBe('aborted') }) @@ -84,7 +86,7 @@ describe('translateAgentEvent — message_complete', () => { message: { ...baseAssistant, stop_reason: 'function_call' }, body_streamed: true, } - expect(translateAgentEvent(event)).toEqual([{ kind: 'assistant-end' }]) + expect(translate(event)).toEqual([{ kind: 'assistant-end' }]) }) it('returns [] for non-assistant message_complete (user/function_result messages)', () => { @@ -96,7 +98,7 @@ describe('translateAgentEvent — message_complete', () => { timestamp: 0, }, } - expect(translateAgentEvent(event)).toEqual([]) + expect(translate(event)).toEqual([]) }) it('omits the error_message field when none was provided', () => { @@ -105,12 +107,14 @@ describe('translateAgentEvent — message_complete', () => { message: { ...baseAssistant, stop_reason: 'length' }, body_streamed: true, } - const out = translateAgentEvent(event) + const out = translate(event) expect(out[1]).toEqual({ kind: 'stop-reason', reason: 'length' }) }) }) -describe('translateAgentEvent — compaction_done', () => { +describe('createAgentEventTranslator — compaction_done', () => { + const { translate } = createAgentEventTranslator() + it('translates compaction_done to a single compaction StreamEvent carrying the summary + tokens_before', () => { const event: AgentEvent = { type: 'compaction_done', @@ -120,7 +124,7 @@ describe('translateAgentEvent — compaction_done', () => { compaction_entry_id: 'entry-c-1', tail_start_id: 'entry-t-1', } - expect(translateAgentEvent(event, 'sess-1')).toEqual([ + expect(translate(event, 'sess-1')).toEqual([ { kind: 'compaction', mode: 'async', @@ -141,7 +145,7 @@ describe('translateAgentEvent — compaction_done', () => { compaction_entry_id: 'e', tail_start_id: null, } - const out = translateAgentEvent(event, 'sess-x') + const out = translate(event, 'sess-x') expect(out).toHaveLength(1) expect( (out[0] as { kind: 'compaction'; mode: 'sync' | 'async' }).mode, @@ -157,7 +161,7 @@ describe('translateAgentEvent — compaction_done', () => { compaction_entry_id: 'e', tail_start_id: null, } - const out = translateAgentEvent(event, 'sess-y') + const out = translate(event, 'sess-y') expect( (out[0] as { kind: 'compaction'; tailStartId: string | null }) .tailStartId, @@ -165,9 +169,9 @@ describe('translateAgentEvent — compaction_done', () => { }) }) -describe('createTurnStateTranslator', () => { +describe('createAgentEventTranslator — turn_state_changed', () => { it('emits fcall-start { pendingApproval: true } when a new entry appears', () => { - const translate = createTurnStateTranslator() + const { translate } = createAgentEventTranslator() const event: AgentEvent = { type: 'turn_state_changed', event_type: 'state:updated', @@ -199,7 +203,7 @@ describe('createTurnStateTranslator', () => { }) it('emits nothing when the awaiting_approval list is unchanged', () => { - const translate = createTurnStateTranslator() + const { translate } = createAgentEventTranslator() const same = { state: 'function_awaiting_approval', awaiting_approval: [ @@ -228,7 +232,7 @@ describe('createTurnStateTranslator', () => { }) it('emits nothing when state leaves function_awaiting_approval (the orchestrator emits the matching function_execution_end)', () => { - const translate = createTurnStateTranslator() + const { translate } = createAgentEventTranslator() translate( { type: 'turn_state_changed', @@ -265,7 +269,7 @@ describe('createTurnStateTranslator', () => { }) it('partitions mirrors by sessionId so two chats do not interfere', () => { - const translate = createTurnStateTranslator() + const { translate } = createAgentEventTranslator() const pending = { state: 'function_awaiting_approval', awaiting_approval: [ diff --git a/console/web/src/lib/backend/translate.ts b/console/web/src/lib/backend/translate.ts index ecafb630..54130ee9 100644 --- a/console/web/src/lib/backend/translate.ts +++ b/console/web/src/lib/backend/translate.ts @@ -3,12 +3,9 @@ * `agent::events`) to console/web's `StreamEvent` contract documented in * `PLAYGROUND.md`. * - * Phase 2.A: `turn-orchestrator` emits `message_update` events with a - * provider `AssistantMessageEvent` payload for every non-terminal frame, - * so token-by-token streaming flows through the `message_update` branch - * below. Terminal assistant turns emit a single `message_complete` event; - * when `body_streamed` is false the full body is translated here, otherwise - * only `assistant-end` (+ abnormal `stop-reason`) is emitted. + * Use `createAgentEventTranslator()` for a stateful translator that handles + * the full event surface, including `turn_state_changed` pending-approval + * mirroring. * * Wire mapping: * - `text_delta` → `assistant-token { token: delta }` @@ -22,9 +19,8 @@ * - `function_execution_start` → `fcall-start` (with args). * - `function_execution_end` → `fcall-end` (with result). * - `agent_end` → `assistant-end`. - * - `agent_start` / `turn_start` / `turn_end` / - * `function_execution_update` → noop. - * - `turn_state_changed` → noop (routed through `createTurnStateTranslator`). + * - `turn_state_changed` → pending-approval `fcall-start` (stateful). + * - `turn_end` → not translated (async compaction listens on raw wire). */ import type { @@ -33,86 +29,93 @@ import type { AssistantMessageEvent, ContentBlock, FunctionResult, - TurnStateChangedEvent, } from '@/types/iii-agent-event' import { diffPending, type PendingApproval } from './pending-approvals-store' import { pendingApprovalsFromTurnState } from './turn-state-mirror' import type { StreamEvent } from './types' -export function translateAgentEvent( - event: AgentEvent, - sessionId?: string, -): StreamEvent[] { - switch (event.type) { - case 'agent_start': - case 'turn_start': - case 'turn_end': - case 'function_execution_update': - return [] +export function createAgentEventTranslator(): { + translate(event: AgentEvent, sessionId?: string): StreamEvent[] +} { + const mirrors = new Map() - case 'turn_state_changed': - return [] + function translateTurnStateChanged( + event: Extract, + sessionId: string, + ): StreamEvent[] { + const prev = mirrors.get(sessionId) ?? [] + const next = pendingApprovalsFromTurnState(event.new_value) + mirrors.set(sessionId, next) + const { added } = diffPending(prev, next) + return added.map((entry) => ({ + kind: 'fcall-start' as const, + functionId: entry.function_id, + input: entry.args, + pendingApproval: true, + functionCallId: entry.function_call_id, + sessionId, + })) + } - case 'message_complete': - return translateMessageComplete( - event.message, - event.body_streamed === true, - ) + function translate(event: AgentEvent, sessionId?: string): StreamEvent[] { + switch (event.type) { + case 'turn_state_changed': + return sessionId ? translateTurnStateChanged(event, sessionId) : [] - case 'message_update': - return translateMessageUpdate(event.llm_event) + case 'message_complete': + return translateMessageComplete( + event.message, + event.body_streamed === true, + ) - case 'function_execution_start': - return [ - { - kind: 'fcall-start', - functionId: event.function_id, - input: event.args, - functionCallId: event.function_call_id, - sessionId, - }, - ] + case 'message_update': + return translateMessageUpdate(event.llm_event) - case 'function_execution_end': - return [ - { - kind: 'fcall-end', - /* Soft failures are surfaced in the UI via the canonical - `{ error: { kind, message, ... } }` shape (see PLAYGROUND.md - "Error semantics"). The harness sends a raw FunctionResult and a - sibling `is_error: true` flag; the canonical shape is a UI - concern, so the wrap lives here rather than on the orchestrator - side. Keeps the wire format provider-agnostic. */ - output: event.is_error ? wrapErrorOutput(event.result) : event.result, - durationMs: event.duration_ms, - }, - ] + case 'function_execution_start': + return [ + { + kind: 'fcall-start', + functionId: event.function_id, + input: event.args, + functionCallId: event.function_call_id, + sessionId, + }, + ] - case 'agent_end': - return [{ kind: 'assistant-end' }] + case 'function_execution_end': + return [ + { + kind: 'fcall-end', + output: event.is_error + ? wrapErrorOutput(event.result) + : event.result, + durationMs: event.duration_ms, + }, + ] - case 'compaction_done': - return [ - { - kind: 'compaction', - mode: event.mode, - summaryText: event.summary_text, - tokensBefore: event.tokens_before, - compactionEntryId: event.compaction_entry_id, - tailStartId: event.tail_start_id, - }, - ] + case 'agent_end': + return [{ kind: 'assistant-end' }] + + case 'turn_end': + return [] + + case 'compaction_done': + return [ + { + kind: 'compaction', + mode: event.mode, + summaryText: event.summary_text, + tokensBefore: event.tokens_before, + compactionEntryId: event.compaction_entry_id, + tailStartId: event.tail_start_id, + }, + ] + } } + + return { translate } } -/** - * Phase 2.A: translate a provider `AssistantMessageEvent` (carried inside - * `AgentEvent.MessageUpdate.llm_event`) into the StreamEvent contract. - * Non-terminal text and thinking deltas drive the renderer; everything - * else is silently dropped — the terminal `Done`/`Error` event is - * mirrored by `message_complete` (and ultimately by `agent_end` → - * `assistant-end`), so we don't need to surface them here. - */ function translateMessageUpdate(llm: AssistantMessageEvent): StreamEvent[] { switch (llm.type) { case 'text_delta': @@ -186,49 +189,6 @@ function appendBlock(block: ContentBlock, out: StreamEvent[]): void { } } -/** - * Stateful translator for `turn_state_changed` events. Holds a per-session - * mirror of the previous `awaiting_approval` list so it can emit a - * `fcall-start { pendingApproval: true }` exactly once per new pending - * call, and suppress duplicates when the same record is re-broadcast - * (the backend emits on every turn_state write, not just transitions - * into the parking state). - * - * No `fcall-end` is emitted when the list shrinks — the orchestrator - * already fires `function_execution_end` for resolved/denied calls - * through its existing path. Removing the entry from our mirror is - * bookkeeping only. - */ -export function createTurnStateTranslator(): ( - event: TurnStateChangedEvent, - sessionId: string, -) => StreamEvent[] { - const mirrors = new Map() - return (event, sessionId) => { - const prev = mirrors.get(sessionId) ?? [] - const next = pendingApprovalsFromTurnState(event.new_value) - mirrors.set(sessionId, next) - const { added } = diffPending(prev, next) - return added.map((entry) => ({ - kind: 'fcall-start' as const, - functionId: entry.function_id, - input: entry.args, - pendingApproval: true, - functionCallId: entry.function_call_id, - sessionId, - })) - } -} - -/* - * Wrap a soft function-execution failure into the canonical - * `{ error: { kind, message, details, content } }` shape consumed by the - * group accordion's failed counter and the embedded fcall's error view. - * `details` / `content` carry the raw payload so the expanded response - * pane still has everything to render. `kind: 'function_error'` matches - * the rest of the translator's deny path (`approval_resolved` uses - * `kind: 'denied'`). - */ function wrapErrorOutput(result: FunctionResult): { error: { kind: string @@ -247,16 +207,9 @@ function wrapErrorOutput(result: FunctionResult): { } } -/** - * Pull a one-line message out of a FunctionResult's content blocks for the - * canonical `error.message`. First non-empty text block wins; otherwise we - * fall back to a generic string so the UI always has something to render. - */ function deriveErrorMessage(content: ContentBlock[]): string { for (const block of content) { if (block.type === 'text' && block.text.length > 0) { - // Collapse to a single line — the message field is the header; the - // full multi-line payload remains under error.content / details. return block.text.replace(/\s+/g, ' ').trim() } } diff --git a/console/web/src/types/iii-agent-event.ts b/console/web/src/types/iii-agent-event.ts index f1a4c268..bba2839c 100644 --- a/console/web/src/types/iii-agent-event.ts +++ b/console/web/src/types/iii-agent-event.ts @@ -156,14 +156,9 @@ export type AssistantMessageEvent = /** * Discriminated `AgentEvent` matching the wire shape on `agent::events`. - * `MessageUpdate` and `FunctionExecutionUpdate` are present in the enum but - * not emitted by today's turn-orchestrator — the translator stubs them out - * but accepts them so a Phase 2 backend lands without a frontend change. */ export type AgentEvent = - | { type: 'agent_start' } | { type: 'agent_end'; messages: AgentMessage[] } - | { type: 'turn_start' } | { type: 'turn_end' message: AgentMessage @@ -186,13 +181,6 @@ export type AgentEvent = function_id: string args: unknown } - | { - type: 'function_execution_update' - function_call_id: string - function_id: string - args: unknown - partial_result: unknown - } | { type: 'function_execution_end' function_call_id: string diff --git a/docs/harness-flow.html b/docs/harness-flow.html new file mode 100644 index 00000000..da3c63cd --- /dev/null +++ b/docs/harness-flow.html @@ -0,0 +1,1320 @@ + + + + +iii harness — end-to-end information flow + + + + + + + +
+
+ iii / harness +

End-to-end information flow

+ 15 stages · browser → bus → provider → function → transcript +
+ +
+ + + + + + + + + + + + + 01 · CLIENT + 02 · ENGINE INGRESS + 03 · BUS + 04 · ORCHESTRATION + 05 · EXTERNAL + + HOOK SUBSCRIBERS + FUNCTION CALL + PERSISTENCE + + + + + + + + + + browser :5173 + REACT UI + bridge.ts → POST + /bridge/trigger + + + + + + + engine · :3111 + iii-http + accepts http triggers + routes to bus + + + + + + + iii-harness · binding + bridge::trigger + unwraps {fn_id, payload} + iii.trigger(...) + + + + + + iii bus · ws :49134 + + + + + + + orchestration + turn-orchestrator + run::start + durable state machine + + + + + + + routing + provider-router + router::stream_assistant + → provider worker + + + + + + + provider + provider- + anthropic + streams Messages API + + + + external · https + Anthropic API + api.anthropic.com + + + + + + + + + + policy-denylist + blocks denied function ids + + + + + + llm-budget + caps spend · forecast + + + + + + approval-gate + requests user approval + + + + + + + hook-fanout + publish_collect + + + + + + + + + + agent_call · dispatch + thin pass-through + + + + + + shell-bash + sandbox::exec + + + + + + shell-filesystem + read · write · glob + + + + + + + + + + session-tree + transcript · iii-state + + + + + + agent::events + iii-stream · join group + + + + + + iii-state + kv · file_based + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + + + +
+ + session · turn 0 + +
+ Speed +
+ + + + +
+
+
+
+ + + + diff --git a/harness/docs/workers/turn-orchestrator.md b/harness/docs/workers/turn-orchestrator.md index fc13212a..4619cbd3 100644 --- a/harness/docs/workers/turn-orchestrator.md +++ b/harness/docs/workers/turn-orchestrator.md @@ -50,7 +50,7 @@ The 11 states from |---|---|---| | `provisioning` | [states/provisioning.ts](harness/src/turn-orchestrator/states/provisioning.ts) | Boot the sandbox, prime the system prompt, fetch function schemas. | | `awaiting_assistant` | [states/assistant.ts](harness/src/turn-orchestrator/states/assistant.ts) | Request an assistant turn via `provider::::stream`. | -| `assistant_streaming` | same | Drain the channel; relay events. | +| `assistant_streaming` | same | Drain the provider channel; relay `message_update` (token/thinking deltas) on `agent::events`. Tool args appear at `function_execution_start` when execute runs — no `turn_start` or streaming `function_execution_update` events. | | `assistant_finished` | same | Persist the final `AssistantMessage`; pick next state. | | `function_prepare` | [states/functions.ts](harness/src/turn-orchestrator/states/functions.ts) | Snapshot the pending function calls. | | `function_execute` | same | Run each call via `dispatchWithHook` (pre-approved resume calls use `triggerFunctionCall` and skip the gate). If the gate returns `pending`, append the call to `awaiting_approval` and transition to `function_awaiting_approval` (the rest of the batch is left for the resumed step). Each call is bracketed by a `function_execution_start` / `function_execution_end` pair; the `end` event carries `duration_ms` (wall-clock between the matching start and end), persisted on `ExecutedEntry` so resumed runs replay the original timing instead of the ~0ms it takes to re-emit. Approval wait time is naturally excluded — pending calls return without an end emit, and the resumed step re-emits a fresh start that resets the timer. | diff --git a/harness/src/turn-orchestrator/states/assistant-streaming.ts b/harness/src/turn-orchestrator/states/assistant-streaming.ts index c0df48e3..35f3b912 100644 --- a/harness/src/turn-orchestrator/states/assistant-streaming.ts +++ b/harness/src/turn-orchestrator/states/assistant-streaming.ts @@ -26,18 +26,6 @@ function eventPartial(ev: AssistantMessageEvent): AssistantMessage | null { return null; } -function latestFunctionCall( - msg: AssistantMessage, -): { id: string; function_id: string; args: unknown } | null { - for (let i = msg.content.length - 1; i >= 0; i--) { - const b = msg.content[i]; - if (b?.type === 'function_call') { - return { id: b.id, function_id: b.function_id, args: b.arguments }; - } - } - return null; -} - function syntheticErrorAssistant( provider: string, model: string, @@ -99,7 +87,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< rec.turn_count++; rec.turn_end_emitted = false; rec.assistant_body_streamed = false; - await emit(iii, rec.session_id, { type: 'turn_start' }); const request = await persistence.loadRunRequest(iii, rec.session_id); let messages = await persistence.loadMessages(iii, rec.session_id); @@ -206,18 +193,6 @@ export async function handleStreaming(iii: ISdk, rec: TurnStateRecord): Promise< if (event.type === 'text_delta' || event.type === 'thinking_delta') { rec.assistant_body_streamed = true; } - if (event.type === 'functioncall_start' || event.type === 'functioncall_delta') { - const fc = latestFunctionCall(partial); - if (fc) { - await emit(iii, rec.session_id, { - type: 'function_execution_update', - function_call_id: fc.id, - function_id: fc.function_id, - args: fc.args, - partial_result: null, - }); - } - } } continue; } diff --git a/harness/src/types/agent-event.ts b/harness/src/types/agent-event.ts index acb7ea1b..83076cf8 100644 --- a/harness/src/types/agent-event.ts +++ b/harness/src/types/agent-event.ts @@ -11,9 +11,7 @@ import type { FunctionResult } from './function.js'; import type { AssistantMessageEvent } from './stream-event.js'; export type AgentEvent = - | { type: 'agent_start' } | { type: 'agent_end'; messages: AgentMessage[] } - | { type: 'turn_start' } | { type: 'turn_end'; message: AgentMessage; @@ -36,13 +34,6 @@ export type AgentEvent = function_id: string; args: unknown; } - | { - type: 'function_execution_update'; - function_call_id: string; - function_id: string; - args: unknown; - partial_result: unknown; - } | { type: 'function_execution_end'; function_call_id: string; diff --git a/harness/tests/integration/wire-parity.test.ts b/harness/tests/integration/wire-parity.test.ts index e3dd094e..5b285a56 100644 --- a/harness/tests/integration/wire-parity.test.ts +++ b/harness/tests/integration/wire-parity.test.ts @@ -11,11 +11,6 @@ import type { AssistantMessage } from '../../src/types/agent-message.js'; import { formatFunctionResultContent } from '../../src/types/wire.js'; describe('AgentEvent wire shape', () => { - it('agent_start serialises to {"type":"agent_start"}', () => { - const evt: AgentEvent = { type: 'agent_start' }; - expect(JSON.parse(JSON.stringify(evt))).toEqual({ type: 'agent_start' }); - }); - it('turn_end carries message + function_results', () => { const asst: AssistantMessage = { role: 'assistant', diff --git a/harness/tests/turn-orchestrator/assistant.test.ts b/harness/tests/turn-orchestrator/assistant.test.ts index cd61e2f2..b218fd6d 100644 --- a/harness/tests/turn-orchestrator/assistant.test.ts +++ b/harness/tests/turn-orchestrator/assistant.test.ts @@ -72,7 +72,7 @@ describe('handleStreaming turn start', () => { expect(rec.turn_count).toBe(1); expect(rec.turn_end_emitted).toBe(false); expect(calls.some((c) => c.function_id === 'approval::consume')).toBe(false); - expect(calls.some((c) => c.function_id === 'stream::set')).toBe(true); + expect(calls.some((c) => c.function_id === 'stream::set')).toBe(false); }); it('exhausts max_turns and transitions to tearing_down', async () => { From 624dac15480a99690fbe62c30d0df9f82b489061 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Sun, 24 May 2026 16:49:27 -0300 Subject: [PATCH 13/16] delete: remove harness-flow.html file - Deleted the harness-flow.html file, which contained the end-to-end information flow documentation for the project. This may indicate a shift in documentation strategy or a move towards a different format for presenting this information. --- docs/harness-flow.html | 1320 ---------------------------------------- 1 file changed, 1320 deletions(-) delete mode 100644 docs/harness-flow.html diff --git a/docs/harness-flow.html b/docs/harness-flow.html deleted file mode 100644 index da3c63cd..00000000 --- a/docs/harness-flow.html +++ /dev/null @@ -1,1320 +0,0 @@ - - - - -iii harness — end-to-end information flow - - - - - - - -
-
- iii / harness -

End-to-end information flow

- 15 stages · browser → bus → provider → function → transcript -
- -
- - - - - - - - - - - - - 01 · CLIENT - 02 · ENGINE INGRESS - 03 · BUS - 04 · ORCHESTRATION - 05 · EXTERNAL - - HOOK SUBSCRIBERS - FUNCTION CALL - PERSISTENCE - - - - - - - - - - browser :5173 - REACT UI - bridge.ts → POST - /bridge/trigger - - - - - - - engine · :3111 - iii-http - accepts http triggers - routes to bus - - - - - - - iii-harness · binding - bridge::trigger - unwraps {fn_id, payload} - iii.trigger(...) - - - - - - iii bus · ws :49134 - - - - - - - orchestration - turn-orchestrator - run::start - durable state machine - - - - - - - routing - provider-router - router::stream_assistant - → provider worker - - - - - - - provider - provider- - anthropic - streams Messages API - - - - external · https - Anthropic API - api.anthropic.com - - - - - - - - - - policy-denylist - blocks denied function ids - - - - - - llm-budget - caps spend · forecast - - - - - - approval-gate - requests user approval - - - - - - - hook-fanout - publish_collect - - - - - - - - - - agent_call · dispatch - thin pass-through - - - - - - shell-bash - sandbox::exec - - - - - - shell-filesystem - read · write · glob - - - - - - - - - - session-tree - transcript · iii-state - - - - - - agent::events - iii-stream · join group - - - - - - iii-state - kv · file_based - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- - - - -
- - session · turn 0 - -
- Speed -
- - - - -
-
-
-
- - - - From cdf44e1f6615fdb80ae0d0e1dac52635f6be974e Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 25 May 2026 05:35:28 -0300 Subject: [PATCH 14/16] chore: ignore local harness-node build output --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 61e4325e..72e1974e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,7 @@ harness/config.yaml .worktrees/ CLAUDE.md data/ -docs/superpowers/ \ No newline at end of file +docs/superpowers/ + +# Local scratch (untracked, not part of any branch) +harness-node/ \ No newline at end of file From 92d13c67c1eaac9d4af1733bbc86b8e6d14d25b5 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 25 May 2026 05:41:06 -0300 Subject: [PATCH 15/16] style: apply biome 2.4.10 formatting to turn-orchestrator files CI lints with biome 2.4.10 while the repo pins ^1.9.4; reformat the files whose 2.x layout differed to clear the 7 format errors. --- .../src/turn-orchestrator/agent-trigger.ts | 1 - harness/src/turn-orchestrator/get-state.ts | 6 +- harness/src/turn-orchestrator/run-start.ts | 6 +- .../states/assistant-finished.ts | 5 +- .../turn-orchestrator/states/provisioning.ts | 7 ++- .../turn-orchestrator/agent-trigger.test.ts | 58 ++++++++++--------- .../turn-orchestrator/run-transition.test.ts | 6 +- 7 files changed, 44 insertions(+), 45 deletions(-) diff --git a/harness/src/turn-orchestrator/agent-trigger.ts b/harness/src/turn-orchestrator/agent-trigger.ts index a24aeae2..76f3a545 100644 --- a/harness/src/turn-orchestrator/agent-trigger.ts +++ b/harness/src/turn-orchestrator/agent-trigger.ts @@ -123,7 +123,6 @@ export function functionNotFoundHint(badFunctionId: string): string { return suggestion ? `Did you mean \`${suggestion}\`? ${generic}` : generic; } - /** Trigger a function call and normalize success/error into a FunctionResult. */ export async function triggerFunctionCall( iii: ISdk, diff --git a/harness/src/turn-orchestrator/get-state.ts b/harness/src/turn-orchestrator/get-state.ts index 0aaf60d5..927c6c80 100644 --- a/harness/src/turn-orchestrator/get-state.ts +++ b/harness/src/turn-orchestrator/get-state.ts @@ -7,11 +7,7 @@ import type { ISdk } from '../runtime/iii.js'; import * as persistence from './persistence.js'; -import { - GetStatePayloadSchema, - type GetStatePayload, - type GetStateResult, -} from './schemas.js'; +import { GetStatePayloadSchema, type GetStatePayload, type GetStateResult } from './schemas.js'; export async function execute(iii: ISdk, payload: GetStatePayload): Promise { return persistence.loadRecord(iii, payload.session_id); diff --git a/harness/src/turn-orchestrator/run-start.ts b/harness/src/turn-orchestrator/run-start.ts index 6b81b1d7..d6112a95 100644 --- a/harness/src/turn-orchestrator/run-start.ts +++ b/harness/src/turn-orchestrator/run-start.ts @@ -11,11 +11,7 @@ import type { ISdk } from '../runtime/iii.js'; import * as persistence from './persistence.js'; -import { - RunStartPayloadSchema, - type RunStartPayload, - type RunStartResult, -} from './schemas.js'; +import { RunStartPayloadSchema, type RunStartPayload, type RunStartResult } from './schemas.js'; import { newRecord } from './state.js'; export async function execute(iii: ISdk, payload: RunStartPayload): Promise { diff --git a/harness/src/turn-orchestrator/states/assistant-finished.ts b/harness/src/turn-orchestrator/states/assistant-finished.ts index b7be2e8c..d3b59f5a 100644 --- a/harness/src/turn-orchestrator/states/assistant-finished.ts +++ b/harness/src/turn-orchestrator/states/assistant-finished.ts @@ -28,10 +28,7 @@ function extractFunctionCalls(msg: AssistantMessage): FunctionCall[] { return out; } -function assistantMessageComplete( - asst: AssistantMessage, - body_streamed: boolean, -): AgentEvent { +function assistantMessageComplete(asst: AssistantMessage, body_streamed: boolean): AgentEvent { return { type: 'message_complete', message: asst, body_streamed }; } diff --git a/harness/src/turn-orchestrator/states/provisioning.ts b/harness/src/turn-orchestrator/states/provisioning.ts index ae141a2f..8ca88284 100644 --- a/harness/src/turn-orchestrator/states/provisioning.ts +++ b/harness/src/turn-orchestrator/states/provisioning.ts @@ -95,7 +95,12 @@ export function register(iii: ISdk, cfg: TurnOrchestratorConfig): void { 'turn::provisioning', async (payload: TurnStepPayload) => { const parsed = TurnStepPayloadSchema.parse(payload); - return runTransition(iii, 'provisioning', (i, rec) => handleProvisioning(i, cfg, rec), parsed); + return runTransition( + iii, + 'provisioning', + (i, rec) => handleProvisioning(i, cfg, rec), + parsed, + ); }, { description: diff --git a/harness/tests/turn-orchestrator/agent-trigger.test.ts b/harness/tests/turn-orchestrator/agent-trigger.test.ts index f4d0d83e..f5053dd6 100644 --- a/harness/tests/turn-orchestrator/agent-trigger.test.ts +++ b/harness/tests/turn-orchestrator/agent-trigger.test.ts @@ -101,10 +101,11 @@ describe('dispatchWithHook returns DispatchResult', () => { it('returns kind:pending when consultBefore returns pending', async () => { vi.spyOn(hookModule, 'consultBefore').mockResolvedValue({ kind: 'pending' }); const iii = { trigger: vi.fn() } as unknown as ISdk; - const out = await dispatchWithHook( - iii, - { id: 'fc-1', function_id: 'shell::run', arguments: { command: 'ls' } } - ); + const out = await dispatchWithHook(iii, { + id: 'fc-1', + function_id: 'shell::run', + arguments: { command: 'ls' }, + }); expect(out.kind).toBe('pending'); }); @@ -120,10 +121,11 @@ describe('dispatchWithHook returns DispatchResult', () => { }, }); const iii = { trigger: vi.fn() } as unknown as ISdk; - const out = await dispatchWithHook( - iii, - { id: 'fc-1', function_id: 'shell::run', arguments: {} } - ); + const out = await dispatchWithHook(iii, { + id: 'fc-1', + function_id: 'shell::run', + arguments: {}, + }); expect(out.kind).toBe('deny'); if (out.kind === 'deny') { expect(out.result.details).toMatchObject({ status: 'denied' }); @@ -135,10 +137,11 @@ describe('dispatchWithHook returns DispatchResult', () => { const iii = { trigger: vi.fn().mockResolvedValue({ ok: true }), } as unknown as ISdk; - const out = await dispatchWithHook( - iii, - { id: 'fc-1', function_id: 'shell::run', arguments: {} } - ); + const out = await dispatchWithHook(iii, { + id: 'fc-1', + function_id: 'shell::run', + arguments: {}, + }); expect(out.kind).toBe('result'); }); @@ -152,14 +155,11 @@ describe('dispatchWithHook returns DispatchResult', () => { const iii = { trigger: vi.fn().mockRejectedValue({ code: 'function_not_found' }), } as unknown as ISdk; - const out = await dispatchWithHook( - iii, - { - id: 'fc-1', - function_id: 'sandbox/skills/sandbox/create', - arguments: { image: 'node' }, - } - ); + const out = await dispatchWithHook(iii, { + id: 'fc-1', + function_id: 'sandbox/skills/sandbox/create', + arguments: { image: 'node' }, + }); expect(out.kind).toBe('result'); if (out.kind !== 'result') return; const details = out.result.details as Record; @@ -174,10 +174,11 @@ describe('dispatchWithHook returns DispatchResult', () => { const iii = { trigger: vi.fn().mockRejectedValue({ code: 'function_not_found' }), } as unknown as ISdk; - const out = await dispatchWithHook( - iii, - { id: 'fc-1', function_id: 'some/odd/three-segment/id', arguments: {} } - ); + const out = await dispatchWithHook(iii, { + id: 'fc-1', + function_id: 'some/odd/three-segment/id', + arguments: {}, + }); if (out.kind !== 'result') throw new Error('expected result kind'); const details = out.result.details as Record; // No "Did you mean" — three-segment ids don't match the @@ -192,10 +193,11 @@ describe('dispatchWithHook returns DispatchResult', () => { const iii = { trigger: vi.fn().mockRejectedValue({ code: 'function_not_found' }), } as unknown as ISdk; - const out = await dispatchWithHook( - iii, - { id: 'fc-1', function_id: 'misspelled', arguments: {} } - ); + const out = await dispatchWithHook(iii, { + id: 'fc-1', + function_id: 'misspelled', + arguments: {}, + }); if (out.kind !== 'result') throw new Error('expected result kind'); const details = out.result.details as Record; expect(details.hint).toBe( diff --git a/harness/tests/turn-orchestrator/run-transition.test.ts b/harness/tests/turn-orchestrator/run-transition.test.ts index 374f5b59..b25761fb 100644 --- a/harness/tests/turn-orchestrator/run-transition.test.ts +++ b/harness/tests/turn-orchestrator/run-transition.test.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import * as persistence from '../../src/turn-orchestrator/persistence.js'; import { runTransition } from '../../src/turn-orchestrator/run-transition.js'; -import { type TurnStateRecord, newRecord, transitionTo } from '../../src/turn-orchestrator/state.js'; +import { + type TurnStateRecord, + newRecord, + transitionTo, +} from '../../src/turn-orchestrator/state.js'; afterEach(() => { vi.restoreAllMocks(); From b93ad5267324ecc4b59bc3f0e38d51000729fefc Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Mon, 25 May 2026 05:53:47 -0300 Subject: [PATCH 16/16] docs(harness): document harness::trigger flat run::start ingress harness::trigger no longer forwards a client-supplied function_id; it takes a flat payload and always invokes run::start. Update harness.md and the architecture telemetry/ingestion-bridge section to match. --- harness/docs/architecture.md | 16 ++++++++-------- harness/docs/workers/harness.md | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/harness/docs/architecture.md b/harness/docs/architecture.md index 08501ea3..3cbd10da 100644 --- a/harness/docs/architecture.md +++ b/harness/docs/architecture.md @@ -281,13 +281,13 @@ to write to stderr unchanged. **`harness::trigger` as the WS ingestion bridge.** Browser-originated requests hit `harness::trigger` (see [src/harness/trigger.ts](harness/src/harness/trigger.ts)), NOT -`run::start` directly. The wrapping `instrumentHandler` reads -`session_id`/`message_id` from the outer body and seeds baggage; the -handler then forwards to `iii.trigger` with the inner `function_id` / -`payload`. This is the symmetric counterpart of the Rust harness bridge -(`workers/harness/src/lib.rs:103-159`; legacy bus id `harness::call`) and -means the span tree looks the same regardless of whether the request -landed on a Rust or Node deployment. +`run::start` directly. The request body is `{session_id?, message_id?, +payload}` with a flat `run::start` payload; the wrapping +`instrumentHandler` reads `session_id`/`message_id` from the outer body and +seeds baggage, then the handler forwards `payload` to `run::start` (the +target function id is fixed, not client-supplied). Going through this hop +seeds the baggage before the nested `run::start` span opens, so the span +tree carries the session/message ids end-to-end. ```mermaid sequenceDiagram @@ -297,7 +297,7 @@ sequenceDiagram participant Inner as run::start (turn-orchestrator) participant Trace as engine traces UI - Web->>Bridge: {function_id:"run::start", session_id, message_id, payload} + Web->>Bridge: {session_id, message_id, payload} Wrap->>Wrap: open span "harness.harness::trigger", stamp ids, push baggage Bridge->>Inner: iii.trigger(run::start, payload) -- baggage propagated Wrap->>Wrap: open span "harness.run::start", inherit ids from baggage diff --git a/harness/docs/workers/harness.md b/harness/docs/workers/harness.md index cd3b7fca..0e21f0b6 100644 --- a/harness/docs/workers/harness.md +++ b/harness/docs/workers/harness.md @@ -18,7 +18,7 @@ that drive transitions; its fan-out trigger is a passive stream subscriber. ## Registered functions -- `harness::trigger` — Forward `{function_id, session_id?, message_id?, payload}` to `iii.trigger` and return the result wrapped in an HTTP-style `{status_code, headers, body}` envelope. Used by console/web so the harness span wrapper can seed `iii.session.id` / `iii.message.id` baggage from the outer body (see [architecture.md § Telemetry & trace correlation](harness/docs/architecture.md#telemetry--trace-correlation)). Port of `workers/harness/src/lib.rs:103-159`. +- `harness::trigger` — Browser kickoff for a chat turn: take `{session_id?, message_id?, payload}` (where `payload` is a flat `run::start` payload), forward `payload` to `run::start`, and return the result wrapped in an HTTP-style `{status_code, headers, body}` envelope. The target function id is always `run::start` — clients don't choose it. Routing through this hop (instead of calling `run::start` directly) lets the harness span wrapper seed `iii.session.id` / `iii.message.id` baggage from the outer body (see [architecture.md § Telemetry & trace correlation](harness/docs/architecture.md#telemetry--trace-correlation)). - `ui::subscribe` — Register a browser's interest in a session (or all sessions if session_id is null). - `ui::unsubscribe` — Remove a browser's subscription to a session (or its all-sessions sub if session_id is null). - `harness::fs::read_inline` — Read a host file via shell::fs::read, drain its channel, and return a `{content:[{text}], details:{size, truncated, bytes_read}}` envelope (max 256 KiB inline by default). @@ -74,7 +74,7 @@ From [src/harness/iii.worker.yaml](harness/src/harness/iii.worker.yaml): | [src/harness/main.ts](harness/src/harness/main.ts) | Binary entry point (`iii-harness`). | | [src/harness/register.ts](harness/src/harness/register.ts) | Composes the worker's bus surface; called by both `main.ts` and the composite [src/index.ts](harness/src/index.ts). | | [src/harness/config.ts](harness/src/harness/config.ts) | Loads `engine_url` + `permissions_path` from `config.yaml`. | -| [src/harness/trigger.ts](harness/src/harness/trigger.ts) | `harness::trigger` handler — WS ingestion bridge for browser-originated requests. Forwards `{function_id, payload}` to `iii.trigger`; the wrapping `instrumentHandler` (see `runtime/otel.ts`) reads `session_id`/`message_id` from the outer body and seeds baggage. Port of `workers/harness/src/lib.rs:103-159`. | +| [src/harness/trigger.ts](harness/src/harness/trigger.ts) | `harness::trigger` handler — WS ingestion bridge for browser-originated chat turns. Forwards the flat `payload` to `run::start` (target function id hard-coded, not client-supplied); the wrapping `instrumentHandler` (see `runtime/otel.ts`) reads `session_id`/`message_id` from the outer body and seeds baggage. | | [src/harness/ui-subscribe.ts](harness/src/harness/ui-subscribe.ts) | In-memory `FanoutState` plus `ui::subscribe` / `ui::unsubscribe`. | | [src/harness/fs.ts](harness/src/harness/fs.ts) | `harness::fs::read_inline` — wraps `shell::fs::read` and inlines the channel into the legacy `{content, details}` envelope. | | [src/harness/policy/check-permissions.ts](harness/src/harness/policy/check-permissions.ts) | `registerPolicy` — registers `policy::check_permissions` and maps a `Decision` to the wire reply (`allow` / `deny` / `needs_approval`). |