Repository navigation
feat(approval-gate): iii-native pause-and-resume with reactive wakes - #152
Conversation
Replace the hardcoded `ModelId` union and four-entry `MODELS` constant
with composite `provider::<catalog_model_id>` keys sourced from the
harness-node models-catalog worker.
- `lib/catalog-model-key.ts`: parse/format `provider::id` (uses `::`
separator constant `CATALOG_MODEL_KEY_SEP`).
- `lib/models-catalog.ts`: client wrapper around `models::list` /
`models::get` for the picker.
- `hooks/use-model-picker-source.ts`: live-updating picker source with a
static fallback for when the engine is down or the mock backend is in
use.
- `types/chat.ts`: `ModelId` widened to `string`; `STATIC_MODEL_OPTIONS`
+ `DEFAULT_MODEL` replace the old enum-shaped `MODELS`.
- `components/chat/ModelPicker.tsx`: accept `options` + `loading` props;
fall back to `{id, label}` from the current value if the catalog is
empty so the picker is never blank.
- `components/chat/Composer.tsx`, `components/chat/ChatView.tsx`,
`pages/Chat.tsx`, `hooks/use-conversations.ts`,
`pages/Examples/sections/*`, `pages/Playground/index.tsx`: thread the
catalog-shaped model ids through; legacy heuristic ids without `::`
still get the old `claude*` / `gemini*` provider guess in
`resolveRunParams`.
- `lib/backend/real.ts`: `resolveRunParams` parses `provider::id` and
falls back to the heuristic for legacy ids; doc comment updated.
This commit removes the debug instrumentation code that was previously used to log session events to a local ingestion endpoint. The removal streamlines the event handling process in the `realStream` function, improving code clarity and maintainability.
…ring
Port the fail-closed approval-gate redesign from the Rust workspace to
harness-node and finish wiring the console/web UI so chat sessions can
approve/deny intercepted tool calls without polling.
Backend:
- intercept-at-gate: subscriber returns block_reply with status
pending|denied; resolved decisions are drained later via
approval::consume; pending entries can be force-resolved via
approval::sweep_session
- run::resume rebuilds terminal TurnStateRecord (preserving turn_count
+ max_turns) and publishes a fresh turn::step_requested
- handle_resolve emits approval_resolved + asks the orchestrator to
resume via a single iii::durable::publish on turn::step_requested;
no polling loop and no explicit timeout (the bus default is the
right backstop)
- subscriber detects terminal records and rebuilds via
build_resume_plan before stepping
- hook-fanout merge_first_block_wins preserves the full blocking reply
(status, function_call_id, subscriber, approval_gate, denial)
- publish-collect emits publish.{ok,error} + publish_failed so the
orchestrator can fail closed on bus unavailability
- handle_awaiting + tearing_down each call approval::consume first;
on non-empty result they stage the resolved entries as prepared
calls and transition (or resurrect) into FunctionExecute
- router::abort composes [state::set abort, approval::sweep_session]
- session-tree sorts entries by (timestamp, id) so replies always
appear in causal order
Console/web:
- approve/deny buttons call approval::resolve via the iii client
- the ui::session::event stream stays subscribed for the chat
lifetime (no auto-close on first agent_end) so multi-turn
approvals continue to flow
- approval prompts auto-scroll into view; submitting state shows
disabled buttons + visible feedback while waiting for the agent
to resume
- fcall-start events deduplicate on functionCallId; pending_approval
placeholders suppress their fcall-end until the real result lands
Tests: TDD'd helpers, intercept/consume/sweep handlers, resume
plumbing, the orchestrator integration points, and an end-to-end
approval-resume.e2e covering intercept -> resolve -> publish ->
subscriber wake -> rebuild -> consume -> execute.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…-native-approval-resume # Conflicts: # console/web/src/components/chat/ModelPicker.tsx # console/web/src/hooks/use-conversations.ts # console/web/src/lib/backend/real.ts # console/web/src/pages/Chat.tsx
skill-check — worker0 verified, 26 skipped (no docs/).
Three for three. Nicely done. |
…iggers Replaces the polling mechanism for session updates with a reactive approach using state triggers. The new implementation filters state events for session creation and directly pushes updates to subscribers without polling. This change enhances efficiency and reduces latency in session updates, streamlining the fanout process. Key components include condition and handler functions for session creation events, and a direct trigger registration for state changes.
* Make approval pause explicit in the turn state machine
The iii-native approval flow needs a non-terminal state where the orchestrator can wait without tearing down the turn or rebuilding from provisioning. This adds the state value and locks its non-terminal behavior in the state test.
Constraint: Plan task 1 targets harness-node TypeScript only; Rust workers remain untouched.
Rejected: Keep using stopped/resume as the pause marker | later tasks remove that detour.
Confidence: high
Scope-risk: narrow
Tested: pnpm test tests/turn-orchestrator/state.test.ts
Not-tested: Full harness-node suite deferred until the batch verification step
* Let the turn record own approval waits
The pause-in-place design needs the orchestrator record to carry the function calls that are waiting on human decisions. This adds the awaiting_approval entry shape without changing any behavior yet.
Constraint: Field remains optional so fresh and existing records continue to deserialize without migration.
Rejected: Store pending calls only in approval-gate state | the simplification moves ownership into the orchestrator FSM.
Confidence: high
Scope-risk: narrow
Tested: pnpm test tests/turn-orchestrator/state.test.ts
Tested: pnpm typecheck
Not-tested: Full harness-node suite deferred until the batch verification step
* Make prepared function calls extensible for approvals
The upcoming pause-in-place approval handler needs to persist per-call metadata without overloading tuple positions. This converts prepared calls to an object shape and adds the optional pre_approved flag while preserving the current execution behavior.
Constraint: Existing staged values may use function_call or legacy tool_call keys, so the loader keeps both forms.
Rejected: Add a parallel pre-approved lookup table | it would split one prepared-call concern across two state keys.
Confidence: medium
Scope-risk: moderate
Directive: Keep PreparedEntry as the single persisted shape for prepared-call execution metadata.
Tested: pnpm test tests/turn-orchestrator/persistence-prepared.test.ts tests/turn-orchestrator/functions.test.ts tests/turn-orchestrator/assistant.test.ts tests/turn-orchestrator/tearing-down.test.ts
Tested: pnpm test tests/turn-orchestrator
Tested: pnpm test
Tested: pnpm typecheck
Tested: pnpm lint
* Define dispatch outcomes before approval cutover
The approval pause cutover needs dispatchWithHook to distinguish successful execution, hard denial, and pending approval. This commit introduces the discriminated result type before changing runtime dispatch behavior.
Constraint: Task 4 is type-only; dispatchWithHook still returns FunctionResult until the later refactor task.
Rejected: Change dispatch behavior now | the plan keeps the behavioral cutover staged.
Confidence: high
Scope-risk: narrow
Tested: pnpm test tests/turn-orchestrator/agent-call.test.ts
Tested: pnpm typecheck
Not-tested: Full harness-node suite deferred until batch verification
* Resume approved calls from an explicit wait state
The new approval pause state needs a handler that stays idle until every awaited decision is present, then updates prepared calls with either pre_approved metadata or a denial result before returning to function execution.
Constraint: Decisions are read from the approvals state scope using the existing session/function-call key shape.
Rejected: Poll approval::consume from generic assistant or teardown states | the simplification makes awaiting approval an explicit FSM state.
Confidence: medium
Scope-risk: moderate
Directive: Keep missing decisions as a no-op so durable turn::step_requested wakeups remain idempotent.
Tested: pnpm test tests/turn-orchestrator/awaiting-approval.test.ts
Tested: pnpm test tests/turn-orchestrator
Tested: pnpm typecheck
Not-tested: Full harness-node suite deferred until batch verification
* Route approval waits through the turn transition switch
The FSM can now enter function_awaiting_approval, so the central step dispatcher must route that state to the new handler instead of silently doing nothing.
Constraint: This only wires the state; handleExecute has not yet been cut over to produce it.
Rejected: Depend on fallthrough/no-op behavior | the wait state needs explicit wake handling from turn::step_requested.
Confidence: high
Scope-risk: narrow
Tested: pnpm test tests/turn-orchestrator/state.test.ts
Tested: pnpm test tests/turn-orchestrator
Tested: pnpm typecheck
Not-tested: Full harness-node suite deferred until batch verification
* Normalize awaiting-approval files for Biome
The new wait-state handler passed behavior and type checks, but Biome required import ordering and line wrapping before the batch could be called clean.
Constraint: Formatting-only follow-up after Task 5 and Task 6 verification.
Confidence: high
Scope-risk: narrow
Tested: pnpm test
Tested: pnpm typecheck
Tested: pnpm lint
* Return structured outcomes from hooked dispatch
The approval cutover needs dispatchWithHook to report whether a call ran, was denied, or is waiting for human approval. This changes the dispatcher API to return DispatchResult and keeps the LLM-facing agent::call wrapper unwrapped as a FunctionResult.
Constraint: handleExecute still consumes the old return shape until the next cutover task.
Rejected: Keep pending as a placeholder FunctionResult | the new FSM state owns approval waiting explicitly.
Confidence: medium
Scope-risk: moderate
Directive: Callers must branch on DispatchResult.kind before treating the value as a FunctionResult.
Tested: pnpm test tests/turn-orchestrator/agent-call.test.ts tests/turn-orchestrator/functions.test.ts
Not-tested: pnpm typecheck fails until handleExecute is cut over in Task 9
* Pause function execution in the approval wait state
handleExecute now treats pending approval as a real FSM pause instead of manufacturing a placeholder function result. It also resumes pre-approved calls without re-entering the hook and emits denial results without dispatching blocked calls.
Constraint: Old placeholder helper tests are skipped until the later cleanup task removes that machinery.
Rejected: Keep pending approval as a terminating FunctionResult | that leaks an artificial result into the transcript and keeps the resume detour alive.
Confidence: medium
Scope-risk: moderate
Directive: Preserve the existing executed-call check before dispatching so retrying a step does not duplicate approved function execution.
Tested: pnpm test tests/turn-orchestrator/functions.test.ts
Tested: pnpm test tests/turn-orchestrator
Tested: pnpm typecheck
Tested: pnpm test tests/integration/approval-resume.e2e.test.ts
Tested: pnpm test
Tested: pnpm lint
* Keep approval gate pending state in the orchestrator
The gate no longer writes a pending state-bus record when policy asks for human approval. It emits approval_requested and returns a pending hook reply; the turn orchestrator owns the pending-call list on its state record.
Constraint: The approvals state bus now stores resolved decisions only.
Rejected: Keep handleIntercept in the gate path | it preserves the obsolete pending-record lifecycle and duplicates orchestrator state.
Confidence: medium
Scope-risk: moderate
Directive: Do not add state-bus pending writes back to policy::approval_gate; pending ownership belongs to TurnStateRecord.awaiting_approval.
Tested: pnpm test tests/approval-gate/gate-subscriber.test.ts tests/approval-gate/events.test.ts
Tested: pnpm typecheck
* Drop approval request expiry from event types
Approval prompts no longer time out through the gate, so the shared event types should not promise an expires_at timestamp. The console typecheck also needed the currently-unused mode parameter marked intentionally unused.
Constraint: No timeout sweep behavior remains in the target approval-request event contract.
Rejected: Keep expires_at as optional | it would preserve dead UI expectations around automatic expiry.
Confidence: high
Scope-risk: narrow
Tested: pnpm typecheck in harness-node
Tested: pnpm typecheck in console/web
* Remove obsolete approval intercept lifecycle
The gate-subscriber now handles needs_approval directly and the orchestrator owns pending approval state, so the old intercept helper and its state-writing tests are dead. The old approval-resume integration is paused until the new pause-in-place integration lands later in the plan.
Constraint: Task 26 is responsible for replacing the legacy integration coverage.
Rejected: Keep intercept.ts as compatibility shim | no production caller remains and it would preserve obsolete pending-record semantics.
Confidence: medium
Scope-risk: moderate
Directive: Do not reintroduce approval-gate/intercept; route policy::approval_gate pending replies through gate-subscriber directly.
Tested: pnpm typecheck in harness-node
Tested: pnpm test tests/approval-gate tests/integration/approval-resume.e2e.test.ts
* Normalize console backend imports
Biome sorted the touched console backend imports after the approval event type cleanup. This keeps the batch verification clean without changing runtime behavior.
Constraint: Formatting-only follow-up from console/web verification.
Confidence: high
Scope-risk: narrow
Tested: pnpm typecheck in console/web
Tested: pnpm exec biome check src/lib/backend/real.ts src/types/iii-agent-event.ts in console/web
* Let approval resolution overwrite the wait record
The orchestrator now pauses in a native awaiting-approval state and reads the final decision directly from the state bus, so resolve no longer needs to validate or mutate a pending lifecycle record. It writes the compact decision payload and wakes the turn subscriber once the write succeeds.
Constraint: Keep approval::list_pending export until its registration is removed in the later registry cleanup task.
Rejected: Preserve pending/status/resolved_at transitions | the paused turn only needs decision and reason, and stale lifecycle fields recreate the old consume path.
Confidence: high
Scope-risk: narrow
Directive: Do not reintroduce preexisting pending-record checks here; the paused turn owns waiting semantics.
Tested: pnpm --filter harness-node test tests/approval-gate/pending.test.ts
* Retire wake-failed approval events
Resolve now writes a decision and publishes the turn wake directly. There is no retry wrapper that converts wake errors into stream events, so keeping approval_wake_failed advertised a lifecycle branch that no longer exists.
Constraint: Preserve approval_requested and approval_resolved event envelopes unchanged for existing consumers.
Rejected: Keep an unused event helper for compatibility | no caller remains and the event would only describe obsolete resume failure handling.
Confidence: high
Scope-risk: narrow
Directive: Add a new lifecycle event only when a real runtime branch emits it.
Tested: rg -n "emitApprovalWakeFailed|approval_wake_failed" harness-node/src harness-node/tests
Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/approval-gate
* Remove approval placeholder resurrection helpers
Approval waits now stay inside the function FSM, so the old placeholder, consume, and resurrection helpers no longer describe the runtime path. Finalize appends real function results directly, awaiting-assistant no longer polls approval::consume, and teardown no longer resurrects terminal turns.
Constraint: Keep the paused-turn approval path in function_awaiting_approval; resolved decisions are read from state during function execution resumption.
Rejected: Leave consume helpers until a later pass | deleting the helpers without their callers would break typecheck, and keeping them would preserve dead behavior.
Confidence: high
Scope-risk: moderate
Directive: Do not add transcript placeholder replacement back unless function_awaiting_approval is removed or the transcript again stores synthetic pending results.
Tested: pnpm --filter harness-node test tests/turn-orchestrator
Tested: pnpm --filter harness-node typecheck
Tested: pnpm --filter harness-node lint
* Drop obsolete approval list and consume APIs
Approval decisions are now read directly by the paused function state, so the separate list_pending and consume endpoints are dead runtime surface. Removing them keeps the approval gate contract to resolve, sweep-on-abort, and the policy subscriber.
Constraint: Keep approval::sweep_session registered until abort cleanup is converted to inline state cleanup.
Rejected: Remove sweep in the same pass | router::abort still calls approval::sweep_session and that cleanup belongs to the abort task.
Confidence: high
Scope-risk: narrow
Directive: Do not re-add list_pending or consume for resume; function_awaiting_approval is the resume boundary.
Tested: pnpm --filter harness-node test tests/approval-gate tests/harness/policy.test.ts tests/turn-orchestrator/assistant.test.ts tests/turn-orchestrator/tearing-down.test.ts
Tested: pnpm --filter harness-node lint && pnpm --filter harness-node typecheck && pnpm --filter harness-node test
* Wake turns from approval state writes
Approval decisions are now intended to be reactive: the state write is the event that wakes the orchestrator. This adds the adapter that turns an approvals-scope state trigger into the existing turn::step_requested durable publish.
Constraint: State trigger payload keys use the approval key shape <session_id>/<function_call_id>.
Rejected: Debounce approval decision writes | repeated wakes are harmless and debouncing would add state to a pure adapter.
Confidence: high
Scope-risk: narrow
Directive: Keep this adapter thin; orchestration policy belongs in function_awaiting_approval.
Tested: pnpm --filter harness-node test tests/approval-gate/on-decision-written.test.ts
* Register approval decision state trigger
The approval gate now exposes the on-decision adapter as a state trigger on the approvals scope. Any write to an approval decision key can wake the turn orchestrator without the writer knowing about turn::step_requested.
Constraint: Register with scope only and no key filter so every approvals/<session>/<call> decision write is observed.
Rejected: Keep wake-up only in approval::resolve | abort and future override writers would still need duplicated publish logic.
Confidence: high
Scope-risk: narrow
Directive: Keep the trigger scope tied to loadApprovalGateConfig so custom approval scopes keep working.
Tested: pnpm --filter harness-node typecheck
Tested: pnpm --filter harness-node test tests/approval-gate
* Let approval resolve rely on reactive wakeups
The approvals state trigger now owns turn wake-up, so approval::resolve should only persist the decision and emit approval_resolved. Removing resumeSession eliminates the duplicated explicit durable publish from the resolve path.
Constraint: approval::on_decision_written is registered on the approvals scope and publishes turn::step_requested after the state write.
Rejected: Keep resolve publishing as a fallback | it would double-wake sessions once the state trigger is active and blur responsibility for the wake-up.
Confidence: high
Scope-risk: narrow
Directive: Writers to the approvals scope should not publish turn::step_requested directly; the state trigger is the boundary.
Tested: pnpm --filter harness-node test tests/approval-gate/pending.test.ts
Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/approval-gate && pnpm --filter harness-node lint
* Abort approvals through state decisions
The abort path now writes aborted approval decisions directly for turns paused in function_awaiting_approval. Those writes are enough to wake the orchestrator through the approvals state trigger, so abort side effects no longer call sweep_session or publish turn::step_requested.
Constraint: This branch did not yet have performAbortSideEffects, so this applies the missing inline cleanup and the reactive no-publish behavior together.
Rejected: Keep approval::sweep_session in abort side effects | it preserves the old lifecycle endpoint and bypasses the decision-write trigger boundary.
Confidence: high
Scope-risk: moderate
Directive: Abort approval cleanup should write approvals/<session>/<call> decisions and let the state trigger wake the FSM.
Tested: pnpm --filter harness-node test tests/turn-orchestrator/abort.test.ts
Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/turn-orchestrator/abort.test.ts && pnpm --filter harness-node lint
* Cover reactive approval wake integration
The integration fixture now simulates the engine firing a state trigger after approvals-scope writes. A direct decision write proves the adapter publishes turn::step_requested without approval::resolve doing it explicitly.
Constraint: The fake state trigger runs on a microtask to match the engine's async trigger behavior closely enough for this unit-level integration test.
Rejected: Restore the skipped legacy approval-resume contract | it tested the old explicit resume path rather than the reactive state-trigger boundary.
Confidence: high
Scope-risk: narrow
Directive: Approval resume integration should model approvals state writes as the wake source.
Tested: pnpm --filter harness-node test tests/integration/approval-resume.e2e.test.ts
Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/integration/approval-resume.e2e.test.ts && pnpm --filter harness-node lint
* Remove terminal resume path from turn stepping
Approval waits now pause inside function_awaiting_approval and wake through approvals state writes. Terminal-session resurrection via run::resume and buildResumePlan is dead behavior, so turn::step now treats terminal records as terminal and no longer republishes while waiting for approval.
Constraint: Keep publishStep for the normal FSM pump after non-terminal transitions.
Rejected: Preserve run::resume for compatibility | no active approval path calls it and it would revive the old stopped-session model.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce terminal resurrection unless approval waiting leaves the FSM again.
Tested: pnpm --filter harness-node test tests/turn-orchestrator
Tested: pnpm --filter harness-node typecheck
Tested: pnpm --filter harness-node lint
* Drop approval sweep and timeout remnants
The gate no longer owns pending-record lifecycles or timeouts. Abort writes aborted decisions inline and approval decisions wake through the state trigger, so approval::sweep_session, buildPendingRecord, and approval_gate.default_timeout_ms are dead surface.
Constraint: Leave unrelated hook-fanout and turn-orchestrator timeout config intact.
Rejected: Keep sweep as an internal fallback | no caller remains and it depends on the deleted pending record shape.
Confidence: high
Scope-risk: narrow
Directive: Approval cleanup should write decision records directly; do not restore pending/resolved sweep state.
Tested: pnpm --filter harness-node test tests/approval-gate tests/turn-orchestrator/abort.test.ts
Tested: pnpm --filter harness-node typecheck
Tested: pnpm --filter harness-node lint
* Remove stale approval placeholder branches
The approval wait path no longer emits synthetic pending function results, so console translation does not need to suppress pending_approval function_execution_end events. The hook comment now describes the actual pending dispatch result instead of the removed placeholder flow.
Constraint: Preserve approval_requested and approval_resolved UI event translation.
Rejected: Keep placeholder suppression defensively | it preserves a deleted wire shape and hides unexpected function results.
Confidence: high
Scope-risk: narrow
Directive: Pending approval UI should be driven by approval_requested, not synthetic function_result placeholders.
Tested: pnpm --filter chat-app typecheck
Tested: pnpm --filter chat-app exec biome check src/lib/backend/translate.ts
Tested: pnpm --filter harness-node test tests/turn-orchestrator/agent-call.test.ts && pnpm --filter harness-node lint
* Clean console approval lint drift
Repo-wide lint surfaced small console issues around approval resolve handlers and import ordering. Removing non-null assertions and stale suppressions keeps the cleanup branch lintable without changing approval UI behavior.
Constraint: Keep existing approval resolve callback contract unchanged.
Rejected: Leave console lint failures as unrelated | the approval callback warnings are on this feature surface and are cheap to make safe.
Confidence: high
Scope-risk: narrow
Directive: Keep approval IDs narrowed before invoking resolve callbacks instead of using non-null assertions.
Tested: pnpm --filter chat-app lint && pnpm --filter chat-app typecheck
Tested: pnpm -r lint
* fix(approval-gate): match engine state:* event_type values
* test(approval-gate): use engine state:* event_type values
* test(integration): fake-iii emits engine-correct event_type for state writes
* feat(approval-gate): filter approvals state trigger with condition function
Add isDecisionWrite/CONDITION_FN_ID to on-decision-written.ts and wire
condition_function_id into the state trigger config in register.ts so the
iii engine skips the handler on state:deleted and writes lacking a decision
field, without touching the in-handler guard (removed in Task 5).
* refactor(approval-gate): handler relies on engine-side condition for filtering
* feat(turn-orchestrator): adapter + condition for reactive abort wake
* feat(turn-orchestrator): register reactive abort_signal state trigger
* test(integration): abort_signal write triggers reactive wake
* chore: final cleanup after reactive trigger follow-up
Fix biome import-order lint: move `import type { ISdk }` before the
value imports in on-abort-signal.test.ts.
This commit deletes the `events.ts` file, which contained the `emitApprovalRequested` and `emitApprovalResolved` functions, as their functionality has been integrated directly into the `gate-subscriber` and `pending` modules. Corresponding test cases in `events.test.ts` have also been removed to reflect this change. The new implementation uses `streamSet` directly for emitting approval events, streamlining the approval gate flow.
…152) * feat(console): drive model picker from harness-node models-catalog Replace the hardcoded `ModelId` union and four-entry `MODELS` constant with composite `provider::<catalog_model_id>` keys sourced from the harness-node models-catalog worker. - `lib/catalog-model-key.ts`: parse/format `provider::id` (uses `::` separator constant `CATALOG_MODEL_KEY_SEP`). - `lib/models-catalog.ts`: client wrapper around `models::list` / `models::get` for the picker. - `hooks/use-model-picker-source.ts`: live-updating picker source with a static fallback for when the engine is down or the mock backend is in use. - `types/chat.ts`: `ModelId` widened to `string`; `STATIC_MODEL_OPTIONS` + `DEFAULT_MODEL` replace the old enum-shaped `MODELS`. - `components/chat/ModelPicker.tsx`: accept `options` + `loading` props; fall back to `{id, label}` from the current value if the catalog is empty so the picker is never blank. - `components/chat/Composer.tsx`, `components/chat/ChatView.tsx`, `pages/Chat.tsx`, `hooks/use-conversations.ts`, `pages/Examples/sections/*`, `pages/Playground/index.tsx`: thread the catalog-shaped model ids through; legacy heuristic ids without `::` still get the old `claude*` / `gemini*` provider guess in `resolveRunParams`. - `lib/backend/real.ts`: `resolveRunParams` parses `provider::id` and falls back to the heuristic for legacy ids; doc comment updated. * refactor(real): remove debug instrumentation from session event handling This commit removes the debug instrumentation code that was previously used to log session events to a local ingestion endpoint. The removal streamlines the event handling process in the `realStream` function, improving code clarity and maintainability. * feat(approval-gate): port PR #150 with iii-native resume + console wiring Port the fail-closed approval-gate redesign from the Rust workspace to harness-node and finish wiring the console/web UI so chat sessions can approve/deny intercepted tool calls without polling. Backend: - intercept-at-gate: subscriber returns block_reply with status pending|denied; resolved decisions are drained later via approval::consume; pending entries can be force-resolved via approval::sweep_session - run::resume rebuilds terminal TurnStateRecord (preserving turn_count + max_turns) and publishes a fresh turn::step_requested - handle_resolve emits approval_resolved + asks the orchestrator to resume via a single iii::durable::publish on turn::step_requested; no polling loop and no explicit timeout (the bus default is the right backstop) - subscriber detects terminal records and rebuilds via build_resume_plan before stepping - hook-fanout merge_first_block_wins preserves the full blocking reply (status, function_call_id, subscriber, approval_gate, denial) - publish-collect emits publish.{ok,error} + publish_failed so the orchestrator can fail closed on bus unavailability - handle_awaiting + tearing_down each call approval::consume first; on non-empty result they stage the resolved entries as prepared calls and transition (or resurrect) into FunctionExecute - router::abort composes [state::set abort, approval::sweep_session] - session-tree sorts entries by (timestamp, id) so replies always appear in causal order Console/web: - approve/deny buttons call approval::resolve via the iii client - the ui::session::event stream stays subscribed for the chat lifetime (no auto-close on first agent_end) so multi-turn approvals continue to flow - approval prompts auto-scroll into view; submitting state shows disabled buttons + visible feedback while waiting for the agent to resume - fcall-start events deduplicate on functionCallId; pending_approval placeholders suppress their fcall-end until the real result lands Tests: TDD'd helpers, intercept/consume/sweep handlers, resume plumbing, the orchestrator integration points, and an end-to-end approval-resume.e2e covering intercept -> resolve -> publish -> subscriber wake -> rebuild -> consume -> execute. * feat(sessions-poll): implement reactive sessions fanout with state triggers Replaces the polling mechanism for session updates with a reactive approach using state triggers. The new implementation filters state events for session creation and directly pushes updates to subscribers without polling. This change enhances efficiency and reduces latency in session updates, streamlining the fanout process. Key components include condition and handler functions for session creation events, and a direct trigger registration for state changes. * Simplify iii-native approval gate flow (#154) * Make approval pause explicit in the turn state machine The iii-native approval flow needs a non-terminal state where the orchestrator can wait without tearing down the turn or rebuilding from provisioning. This adds the state value and locks its non-terminal behavior in the state test. Constraint: Plan task 1 targets harness-node TypeScript only; Rust workers remain untouched. Rejected: Keep using stopped/resume as the pause marker | later tasks remove that detour. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/state.test.ts Not-tested: Full harness-node suite deferred until the batch verification step * Let the turn record own approval waits The pause-in-place design needs the orchestrator record to carry the function calls that are waiting on human decisions. This adds the awaiting_approval entry shape without changing any behavior yet. Constraint: Field remains optional so fresh and existing records continue to deserialize without migration. Rejected: Store pending calls only in approval-gate state | the simplification moves ownership into the orchestrator FSM. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/state.test.ts Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until the batch verification step * Make prepared function calls extensible for approvals The upcoming pause-in-place approval handler needs to persist per-call metadata without overloading tuple positions. This converts prepared calls to an object shape and adds the optional pre_approved flag while preserving the current execution behavior. Constraint: Existing staged values may use function_call or legacy tool_call keys, so the loader keeps both forms. Rejected: Add a parallel pre-approved lookup table | it would split one prepared-call concern across two state keys. Confidence: medium Scope-risk: moderate Directive: Keep PreparedEntry as the single persisted shape for prepared-call execution metadata. Tested: pnpm test tests/turn-orchestrator/persistence-prepared.test.ts tests/turn-orchestrator/functions.test.ts tests/turn-orchestrator/assistant.test.ts tests/turn-orchestrator/tearing-down.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm test Tested: pnpm typecheck Tested: pnpm lint * Define dispatch outcomes before approval cutover The approval pause cutover needs dispatchWithHook to distinguish successful execution, hard denial, and pending approval. This commit introduces the discriminated result type before changing runtime dispatch behavior. Constraint: Task 4 is type-only; dispatchWithHook still returns FunctionResult until the later refactor task. Rejected: Change dispatch behavior now | the plan keeps the behavioral cutover staged. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/agent-call.test.ts Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until batch verification * Resume approved calls from an explicit wait state The new approval pause state needs a handler that stays idle until every awaited decision is present, then updates prepared calls with either pre_approved metadata or a denial result before returning to function execution. Constraint: Decisions are read from the approvals state scope using the existing session/function-call key shape. Rejected: Poll approval::consume from generic assistant or teardown states | the simplification makes awaiting approval an explicit FSM state. Confidence: medium Scope-risk: moderate Directive: Keep missing decisions as a no-op so durable turn::step_requested wakeups remain idempotent. Tested: pnpm test tests/turn-orchestrator/awaiting-approval.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until batch verification * Route approval waits through the turn transition switch The FSM can now enter function_awaiting_approval, so the central step dispatcher must route that state to the new handler instead of silently doing nothing. Constraint: This only wires the state; handleExecute has not yet been cut over to produce it. Rejected: Depend on fallthrough/no-op behavior | the wait state needs explicit wake handling from turn::step_requested. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/state.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until batch verification * Normalize awaiting-approval files for Biome The new wait-state handler passed behavior and type checks, but Biome required import ordering and line wrapping before the batch could be called clean. Constraint: Formatting-only follow-up after Task 5 and Task 6 verification. Confidence: high Scope-risk: narrow Tested: pnpm test Tested: pnpm typecheck Tested: pnpm lint * Return structured outcomes from hooked dispatch The approval cutover needs dispatchWithHook to report whether a call ran, was denied, or is waiting for human approval. This changes the dispatcher API to return DispatchResult and keeps the LLM-facing agent::call wrapper unwrapped as a FunctionResult. Constraint: handleExecute still consumes the old return shape until the next cutover task. Rejected: Keep pending as a placeholder FunctionResult | the new FSM state owns approval waiting explicitly. Confidence: medium Scope-risk: moderate Directive: Callers must branch on DispatchResult.kind before treating the value as a FunctionResult. Tested: pnpm test tests/turn-orchestrator/agent-call.test.ts tests/turn-orchestrator/functions.test.ts Not-tested: pnpm typecheck fails until handleExecute is cut over in Task 9 * Pause function execution in the approval wait state handleExecute now treats pending approval as a real FSM pause instead of manufacturing a placeholder function result. It also resumes pre-approved calls without re-entering the hook and emits denial results without dispatching blocked calls. Constraint: Old placeholder helper tests are skipped until the later cleanup task removes that machinery. Rejected: Keep pending approval as a terminating FunctionResult | that leaks an artificial result into the transcript and keeps the resume detour alive. Confidence: medium Scope-risk: moderate Directive: Preserve the existing executed-call check before dispatching so retrying a step does not duplicate approved function execution. Tested: pnpm test tests/turn-orchestrator/functions.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm typecheck Tested: pnpm test tests/integration/approval-resume.e2e.test.ts Tested: pnpm test Tested: pnpm lint * Keep approval gate pending state in the orchestrator The gate no longer writes a pending state-bus record when policy asks for human approval. It emits approval_requested and returns a pending hook reply; the turn orchestrator owns the pending-call list on its state record. Constraint: The approvals state bus now stores resolved decisions only. Rejected: Keep handleIntercept in the gate path | it preserves the obsolete pending-record lifecycle and duplicates orchestrator state. Confidence: medium Scope-risk: moderate Directive: Do not add state-bus pending writes back to policy::approval_gate; pending ownership belongs to TurnStateRecord.awaiting_approval. Tested: pnpm test tests/approval-gate/gate-subscriber.test.ts tests/approval-gate/events.test.ts Tested: pnpm typecheck * Drop approval request expiry from event types Approval prompts no longer time out through the gate, so the shared event types should not promise an expires_at timestamp. The console typecheck also needed the currently-unused mode parameter marked intentionally unused. Constraint: No timeout sweep behavior remains in the target approval-request event contract. Rejected: Keep expires_at as optional | it would preserve dead UI expectations around automatic expiry. Confidence: high Scope-risk: narrow Tested: pnpm typecheck in harness-node Tested: pnpm typecheck in console/web * Remove obsolete approval intercept lifecycle The gate-subscriber now handles needs_approval directly and the orchestrator owns pending approval state, so the old intercept helper and its state-writing tests are dead. The old approval-resume integration is paused until the new pause-in-place integration lands later in the plan. Constraint: Task 26 is responsible for replacing the legacy integration coverage. Rejected: Keep intercept.ts as compatibility shim | no production caller remains and it would preserve obsolete pending-record semantics. Confidence: medium Scope-risk: moderate Directive: Do not reintroduce approval-gate/intercept; route policy::approval_gate pending replies through gate-subscriber directly. Tested: pnpm typecheck in harness-node Tested: pnpm test tests/approval-gate tests/integration/approval-resume.e2e.test.ts * Normalize console backend imports Biome sorted the touched console backend imports after the approval event type cleanup. This keeps the batch verification clean without changing runtime behavior. Constraint: Formatting-only follow-up from console/web verification. Confidence: high Scope-risk: narrow Tested: pnpm typecheck in console/web Tested: pnpm exec biome check src/lib/backend/real.ts src/types/iii-agent-event.ts in console/web * Let approval resolution overwrite the wait record The orchestrator now pauses in a native awaiting-approval state and reads the final decision directly from the state bus, so resolve no longer needs to validate or mutate a pending lifecycle record. It writes the compact decision payload and wakes the turn subscriber once the write succeeds. Constraint: Keep approval::list_pending export until its registration is removed in the later registry cleanup task. Rejected: Preserve pending/status/resolved_at transitions | the paused turn only needs decision and reason, and stale lifecycle fields recreate the old consume path. Confidence: high Scope-risk: narrow Directive: Do not reintroduce preexisting pending-record checks here; the paused turn owns waiting semantics. Tested: pnpm --filter harness-node test tests/approval-gate/pending.test.ts * Retire wake-failed approval events Resolve now writes a decision and publishes the turn wake directly. There is no retry wrapper that converts wake errors into stream events, so keeping approval_wake_failed advertised a lifecycle branch that no longer exists. Constraint: Preserve approval_requested and approval_resolved event envelopes unchanged for existing consumers. Rejected: Keep an unused event helper for compatibility | no caller remains and the event would only describe obsolete resume failure handling. Confidence: high Scope-risk: narrow Directive: Add a new lifecycle event only when a real runtime branch emits it. Tested: rg -n "emitApprovalWakeFailed|approval_wake_failed" harness-node/src harness-node/tests Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/approval-gate * Remove approval placeholder resurrection helpers Approval waits now stay inside the function FSM, so the old placeholder, consume, and resurrection helpers no longer describe the runtime path. Finalize appends real function results directly, awaiting-assistant no longer polls approval::consume, and teardown no longer resurrects terminal turns. Constraint: Keep the paused-turn approval path in function_awaiting_approval; resolved decisions are read from state during function execution resumption. Rejected: Leave consume helpers until a later pass | deleting the helpers without their callers would break typecheck, and keeping them would preserve dead behavior. Confidence: high Scope-risk: moderate Directive: Do not add transcript placeholder replacement back unless function_awaiting_approval is removed or the transcript again stores synthetic pending results. Tested: pnpm --filter harness-node test tests/turn-orchestrator Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node lint * Drop obsolete approval list and consume APIs Approval decisions are now read directly by the paused function state, so the separate list_pending and consume endpoints are dead runtime surface. Removing them keeps the approval gate contract to resolve, sweep-on-abort, and the policy subscriber. Constraint: Keep approval::sweep_session registered until abort cleanup is converted to inline state cleanup. Rejected: Remove sweep in the same pass | router::abort still calls approval::sweep_session and that cleanup belongs to the abort task. Confidence: high Scope-risk: narrow Directive: Do not re-add list_pending or consume for resume; function_awaiting_approval is the resume boundary. Tested: pnpm --filter harness-node test tests/approval-gate tests/harness/policy.test.ts tests/turn-orchestrator/assistant.test.ts tests/turn-orchestrator/tearing-down.test.ts Tested: pnpm --filter harness-node lint && pnpm --filter harness-node typecheck && pnpm --filter harness-node test * Wake turns from approval state writes Approval decisions are now intended to be reactive: the state write is the event that wakes the orchestrator. This adds the adapter that turns an approvals-scope state trigger into the existing turn::step_requested durable publish. Constraint: State trigger payload keys use the approval key shape <session_id>/<function_call_id>. Rejected: Debounce approval decision writes | repeated wakes are harmless and debouncing would add state to a pure adapter. Confidence: high Scope-risk: narrow Directive: Keep this adapter thin; orchestration policy belongs in function_awaiting_approval. Tested: pnpm --filter harness-node test tests/approval-gate/on-decision-written.test.ts * Register approval decision state trigger The approval gate now exposes the on-decision adapter as a state trigger on the approvals scope. Any write to an approval decision key can wake the turn orchestrator without the writer knowing about turn::step_requested. Constraint: Register with scope only and no key filter so every approvals/<session>/<call> decision write is observed. Rejected: Keep wake-up only in approval::resolve | abort and future override writers would still need duplicated publish logic. Confidence: high Scope-risk: narrow Directive: Keep the trigger scope tied to loadApprovalGateConfig so custom approval scopes keep working. Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node test tests/approval-gate * Let approval resolve rely on reactive wakeups The approvals state trigger now owns turn wake-up, so approval::resolve should only persist the decision and emit approval_resolved. Removing resumeSession eliminates the duplicated explicit durable publish from the resolve path. Constraint: approval::on_decision_written is registered on the approvals scope and publishes turn::step_requested after the state write. Rejected: Keep resolve publishing as a fallback | it would double-wake sessions once the state trigger is active and blur responsibility for the wake-up. Confidence: high Scope-risk: narrow Directive: Writers to the approvals scope should not publish turn::step_requested directly; the state trigger is the boundary. Tested: pnpm --filter harness-node test tests/approval-gate/pending.test.ts Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/approval-gate && pnpm --filter harness-node lint * Abort approvals through state decisions The abort path now writes aborted approval decisions directly for turns paused in function_awaiting_approval. Those writes are enough to wake the orchestrator through the approvals state trigger, so abort side effects no longer call sweep_session or publish turn::step_requested. Constraint: This branch did not yet have performAbortSideEffects, so this applies the missing inline cleanup and the reactive no-publish behavior together. Rejected: Keep approval::sweep_session in abort side effects | it preserves the old lifecycle endpoint and bypasses the decision-write trigger boundary. Confidence: high Scope-risk: moderate Directive: Abort approval cleanup should write approvals/<session>/<call> decisions and let the state trigger wake the FSM. Tested: pnpm --filter harness-node test tests/turn-orchestrator/abort.test.ts Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/turn-orchestrator/abort.test.ts && pnpm --filter harness-node lint * Cover reactive approval wake integration The integration fixture now simulates the engine firing a state trigger after approvals-scope writes. A direct decision write proves the adapter publishes turn::step_requested without approval::resolve doing it explicitly. Constraint: The fake state trigger runs on a microtask to match the engine's async trigger behavior closely enough for this unit-level integration test. Rejected: Restore the skipped legacy approval-resume contract | it tested the old explicit resume path rather than the reactive state-trigger boundary. Confidence: high Scope-risk: narrow Directive: Approval resume integration should model approvals state writes as the wake source. Tested: pnpm --filter harness-node test tests/integration/approval-resume.e2e.test.ts Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/integration/approval-resume.e2e.test.ts && pnpm --filter harness-node lint * Remove terminal resume path from turn stepping Approval waits now pause inside function_awaiting_approval and wake through approvals state writes. Terminal-session resurrection via run::resume and buildResumePlan is dead behavior, so turn::step now treats terminal records as terminal and no longer republishes while waiting for approval. Constraint: Keep publishStep for the normal FSM pump after non-terminal transitions. Rejected: Preserve run::resume for compatibility | no active approval path calls it and it would revive the old stopped-session model. Confidence: high Scope-risk: moderate Directive: Do not reintroduce terminal resurrection unless approval waiting leaves the FSM again. Tested: pnpm --filter harness-node test tests/turn-orchestrator Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node lint * Drop approval sweep and timeout remnants The gate no longer owns pending-record lifecycles or timeouts. Abort writes aborted decisions inline and approval decisions wake through the state trigger, so approval::sweep_session, buildPendingRecord, and approval_gate.default_timeout_ms are dead surface. Constraint: Leave unrelated hook-fanout and turn-orchestrator timeout config intact. Rejected: Keep sweep as an internal fallback | no caller remains and it depends on the deleted pending record shape. Confidence: high Scope-risk: narrow Directive: Approval cleanup should write decision records directly; do not restore pending/resolved sweep state. Tested: pnpm --filter harness-node test tests/approval-gate tests/turn-orchestrator/abort.test.ts Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node lint * Remove stale approval placeholder branches The approval wait path no longer emits synthetic pending function results, so console translation does not need to suppress pending_approval function_execution_end events. The hook comment now describes the actual pending dispatch result instead of the removed placeholder flow. Constraint: Preserve approval_requested and approval_resolved UI event translation. Rejected: Keep placeholder suppression defensively | it preserves a deleted wire shape and hides unexpected function results. Confidence: high Scope-risk: narrow Directive: Pending approval UI should be driven by approval_requested, not synthetic function_result placeholders. Tested: pnpm --filter chat-app typecheck Tested: pnpm --filter chat-app exec biome check src/lib/backend/translate.ts Tested: pnpm --filter harness-node test tests/turn-orchestrator/agent-call.test.ts && pnpm --filter harness-node lint * Clean console approval lint drift Repo-wide lint surfaced small console issues around approval resolve handlers and import ordering. Removing non-null assertions and stale suppressions keeps the cleanup branch lintable without changing approval UI behavior. Constraint: Keep existing approval resolve callback contract unchanged. Rejected: Leave console lint failures as unrelated | the approval callback warnings are on this feature surface and are cheap to make safe. Confidence: high Scope-risk: narrow Directive: Keep approval IDs narrowed before invoking resolve callbacks instead of using non-null assertions. Tested: pnpm --filter chat-app lint && pnpm --filter chat-app typecheck Tested: pnpm -r lint * fix(approval-gate): match engine state:* event_type values * test(approval-gate): use engine state:* event_type values * test(integration): fake-iii emits engine-correct event_type for state writes * feat(approval-gate): filter approvals state trigger with condition function Add isDecisionWrite/CONDITION_FN_ID to on-decision-written.ts and wire condition_function_id into the state trigger config in register.ts so the iii engine skips the handler on state:deleted and writes lacking a decision field, without touching the in-handler guard (removed in Task 5). * refactor(approval-gate): handler relies on engine-side condition for filtering * feat(turn-orchestrator): adapter + condition for reactive abort wake * feat(turn-orchestrator): register reactive abort_signal state trigger * test(integration): abort_signal write triggers reactive wake * chore: final cleanup after reactive trigger follow-up Fix biome import-order lint: move `import type { ISdk }` before the value imports in on-abort-signal.test.ts. * Remove obsolete approval event handling and tests This commit deletes the `events.ts` file, which contained the `emitApprovalRequested` and `emitApprovalResolved` functions, as their functionality has been integrated directly into the `gate-subscriber` and `pending` modules. Corresponding test cases in `events.test.ts` have also been removed to reflect this change. The new implementation uses `streamSet` directly for emitting approval events, streamlining the approval gate flow.
…156) * feat(approval-gate): iii-native pause-and-resume with reactive wakes (#152) * feat(console): drive model picker from harness-node models-catalog Replace the hardcoded `ModelId` union and four-entry `MODELS` constant with composite `provider::<catalog_model_id>` keys sourced from the harness-node models-catalog worker. - `lib/catalog-model-key.ts`: parse/format `provider::id` (uses `::` separator constant `CATALOG_MODEL_KEY_SEP`). - `lib/models-catalog.ts`: client wrapper around `models::list` / `models::get` for the picker. - `hooks/use-model-picker-source.ts`: live-updating picker source with a static fallback for when the engine is down or the mock backend is in use. - `types/chat.ts`: `ModelId` widened to `string`; `STATIC_MODEL_OPTIONS` + `DEFAULT_MODEL` replace the old enum-shaped `MODELS`. - `components/chat/ModelPicker.tsx`: accept `options` + `loading` props; fall back to `{id, label}` from the current value if the catalog is empty so the picker is never blank. - `components/chat/Composer.tsx`, `components/chat/ChatView.tsx`, `pages/Chat.tsx`, `hooks/use-conversations.ts`, `pages/Examples/sections/*`, `pages/Playground/index.tsx`: thread the catalog-shaped model ids through; legacy heuristic ids without `::` still get the old `claude*` / `gemini*` provider guess in `resolveRunParams`. - `lib/backend/real.ts`: `resolveRunParams` parses `provider::id` and falls back to the heuristic for legacy ids; doc comment updated. * refactor(real): remove debug instrumentation from session event handling This commit removes the debug instrumentation code that was previously used to log session events to a local ingestion endpoint. The removal streamlines the event handling process in the `realStream` function, improving code clarity and maintainability. * feat(approval-gate): port PR #150 with iii-native resume + console wiring Port the fail-closed approval-gate redesign from the Rust workspace to harness-node and finish wiring the console/web UI so chat sessions can approve/deny intercepted tool calls without polling. Backend: - intercept-at-gate: subscriber returns block_reply with status pending|denied; resolved decisions are drained later via approval::consume; pending entries can be force-resolved via approval::sweep_session - run::resume rebuilds terminal TurnStateRecord (preserving turn_count + max_turns) and publishes a fresh turn::step_requested - handle_resolve emits approval_resolved + asks the orchestrator to resume via a single iii::durable::publish on turn::step_requested; no polling loop and no explicit timeout (the bus default is the right backstop) - subscriber detects terminal records and rebuilds via build_resume_plan before stepping - hook-fanout merge_first_block_wins preserves the full blocking reply (status, function_call_id, subscriber, approval_gate, denial) - publish-collect emits publish.{ok,error} + publish_failed so the orchestrator can fail closed on bus unavailability - handle_awaiting + tearing_down each call approval::consume first; on non-empty result they stage the resolved entries as prepared calls and transition (or resurrect) into FunctionExecute - router::abort composes [state::set abort, approval::sweep_session] - session-tree sorts entries by (timestamp, id) so replies always appear in causal order Console/web: - approve/deny buttons call approval::resolve via the iii client - the ui::session::event stream stays subscribed for the chat lifetime (no auto-close on first agent_end) so multi-turn approvals continue to flow - approval prompts auto-scroll into view; submitting state shows disabled buttons + visible feedback while waiting for the agent to resume - fcall-start events deduplicate on functionCallId; pending_approval placeholders suppress their fcall-end until the real result lands Tests: TDD'd helpers, intercept/consume/sweep handlers, resume plumbing, the orchestrator integration points, and an end-to-end approval-resume.e2e covering intercept -> resolve -> publish -> subscriber wake -> rebuild -> consume -> execute. * feat(sessions-poll): implement reactive sessions fanout with state triggers Replaces the polling mechanism for session updates with a reactive approach using state triggers. The new implementation filters state events for session creation and directly pushes updates to subscribers without polling. This change enhances efficiency and reduces latency in session updates, streamlining the fanout process. Key components include condition and handler functions for session creation events, and a direct trigger registration for state changes. * Simplify iii-native approval gate flow (#154) * Make approval pause explicit in the turn state machine The iii-native approval flow needs a non-terminal state where the orchestrator can wait without tearing down the turn or rebuilding from provisioning. This adds the state value and locks its non-terminal behavior in the state test. Constraint: Plan task 1 targets harness-node TypeScript only; Rust workers remain untouched. Rejected: Keep using stopped/resume as the pause marker | later tasks remove that detour. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/state.test.ts Not-tested: Full harness-node suite deferred until the batch verification step * Let the turn record own approval waits The pause-in-place design needs the orchestrator record to carry the function calls that are waiting on human decisions. This adds the awaiting_approval entry shape without changing any behavior yet. Constraint: Field remains optional so fresh and existing records continue to deserialize without migration. Rejected: Store pending calls only in approval-gate state | the simplification moves ownership into the orchestrator FSM. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/state.test.ts Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until the batch verification step * Make prepared function calls extensible for approvals The upcoming pause-in-place approval handler needs to persist per-call metadata without overloading tuple positions. This converts prepared calls to an object shape and adds the optional pre_approved flag while preserving the current execution behavior. Constraint: Existing staged values may use function_call or legacy tool_call keys, so the loader keeps both forms. Rejected: Add a parallel pre-approved lookup table | it would split one prepared-call concern across two state keys. Confidence: medium Scope-risk: moderate Directive: Keep PreparedEntry as the single persisted shape for prepared-call execution metadata. Tested: pnpm test tests/turn-orchestrator/persistence-prepared.test.ts tests/turn-orchestrator/functions.test.ts tests/turn-orchestrator/assistant.test.ts tests/turn-orchestrator/tearing-down.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm test Tested: pnpm typecheck Tested: pnpm lint * Define dispatch outcomes before approval cutover The approval pause cutover needs dispatchWithHook to distinguish successful execution, hard denial, and pending approval. This commit introduces the discriminated result type before changing runtime dispatch behavior. Constraint: Task 4 is type-only; dispatchWithHook still returns FunctionResult until the later refactor task. Rejected: Change dispatch behavior now | the plan keeps the behavioral cutover staged. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/agent-call.test.ts Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until batch verification * Resume approved calls from an explicit wait state The new approval pause state needs a handler that stays idle until every awaited decision is present, then updates prepared calls with either pre_approved metadata or a denial result before returning to function execution. Constraint: Decisions are read from the approvals state scope using the existing session/function-call key shape. Rejected: Poll approval::consume from generic assistant or teardown states | the simplification makes awaiting approval an explicit FSM state. Confidence: medium Scope-risk: moderate Directive: Keep missing decisions as a no-op so durable turn::step_requested wakeups remain idempotent. Tested: pnpm test tests/turn-orchestrator/awaiting-approval.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until batch verification * Route approval waits through the turn transition switch The FSM can now enter function_awaiting_approval, so the central step dispatcher must route that state to the new handler instead of silently doing nothing. Constraint: This only wires the state; handleExecute has not yet been cut over to produce it. Rejected: Depend on fallthrough/no-op behavior | the wait state needs explicit wake handling from turn::step_requested. Confidence: high Scope-risk: narrow Tested: pnpm test tests/turn-orchestrator/state.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm typecheck Not-tested: Full harness-node suite deferred until batch verification * Normalize awaiting-approval files for Biome The new wait-state handler passed behavior and type checks, but Biome required import ordering and line wrapping before the batch could be called clean. Constraint: Formatting-only follow-up after Task 5 and Task 6 verification. Confidence: high Scope-risk: narrow Tested: pnpm test Tested: pnpm typecheck Tested: pnpm lint * Return structured outcomes from hooked dispatch The approval cutover needs dispatchWithHook to report whether a call ran, was denied, or is waiting for human approval. This changes the dispatcher API to return DispatchResult and keeps the LLM-facing agent::call wrapper unwrapped as a FunctionResult. Constraint: handleExecute still consumes the old return shape until the next cutover task. Rejected: Keep pending as a placeholder FunctionResult | the new FSM state owns approval waiting explicitly. Confidence: medium Scope-risk: moderate Directive: Callers must branch on DispatchResult.kind before treating the value as a FunctionResult. Tested: pnpm test tests/turn-orchestrator/agent-call.test.ts tests/turn-orchestrator/functions.test.ts Not-tested: pnpm typecheck fails until handleExecute is cut over in Task 9 * Pause function execution in the approval wait state handleExecute now treats pending approval as a real FSM pause instead of manufacturing a placeholder function result. It also resumes pre-approved calls without re-entering the hook and emits denial results without dispatching blocked calls. Constraint: Old placeholder helper tests are skipped until the later cleanup task removes that machinery. Rejected: Keep pending approval as a terminating FunctionResult | that leaks an artificial result into the transcript and keeps the resume detour alive. Confidence: medium Scope-risk: moderate Directive: Preserve the existing executed-call check before dispatching so retrying a step does not duplicate approved function execution. Tested: pnpm test tests/turn-orchestrator/functions.test.ts Tested: pnpm test tests/turn-orchestrator Tested: pnpm typecheck Tested: pnpm test tests/integration/approval-resume.e2e.test.ts Tested: pnpm test Tested: pnpm lint * Keep approval gate pending state in the orchestrator The gate no longer writes a pending state-bus record when policy asks for human approval. It emits approval_requested and returns a pending hook reply; the turn orchestrator owns the pending-call list on its state record. Constraint: The approvals state bus now stores resolved decisions only. Rejected: Keep handleIntercept in the gate path | it preserves the obsolete pending-record lifecycle and duplicates orchestrator state. Confidence: medium Scope-risk: moderate Directive: Do not add state-bus pending writes back to policy::approval_gate; pending ownership belongs to TurnStateRecord.awaiting_approval. Tested: pnpm test tests/approval-gate/gate-subscriber.test.ts tests/approval-gate/events.test.ts Tested: pnpm typecheck * Drop approval request expiry from event types Approval prompts no longer time out through the gate, so the shared event types should not promise an expires_at timestamp. The console typecheck also needed the currently-unused mode parameter marked intentionally unused. Constraint: No timeout sweep behavior remains in the target approval-request event contract. Rejected: Keep expires_at as optional | it would preserve dead UI expectations around automatic expiry. Confidence: high Scope-risk: narrow Tested: pnpm typecheck in harness-node Tested: pnpm typecheck in console/web * Remove obsolete approval intercept lifecycle The gate-subscriber now handles needs_approval directly and the orchestrator owns pending approval state, so the old intercept helper and its state-writing tests are dead. The old approval-resume integration is paused until the new pause-in-place integration lands later in the plan. Constraint: Task 26 is responsible for replacing the legacy integration coverage. Rejected: Keep intercept.ts as compatibility shim | no production caller remains and it would preserve obsolete pending-record semantics. Confidence: medium Scope-risk: moderate Directive: Do not reintroduce approval-gate/intercept; route policy::approval_gate pending replies through gate-subscriber directly. Tested: pnpm typecheck in harness-node Tested: pnpm test tests/approval-gate tests/integration/approval-resume.e2e.test.ts * Normalize console backend imports Biome sorted the touched console backend imports after the approval event type cleanup. This keeps the batch verification clean without changing runtime behavior. Constraint: Formatting-only follow-up from console/web verification. Confidence: high Scope-risk: narrow Tested: pnpm typecheck in console/web Tested: pnpm exec biome check src/lib/backend/real.ts src/types/iii-agent-event.ts in console/web * Let approval resolution overwrite the wait record The orchestrator now pauses in a native awaiting-approval state and reads the final decision directly from the state bus, so resolve no longer needs to validate or mutate a pending lifecycle record. It writes the compact decision payload and wakes the turn subscriber once the write succeeds. Constraint: Keep approval::list_pending export until its registration is removed in the later registry cleanup task. Rejected: Preserve pending/status/resolved_at transitions | the paused turn only needs decision and reason, and stale lifecycle fields recreate the old consume path. Confidence: high Scope-risk: narrow Directive: Do not reintroduce preexisting pending-record checks here; the paused turn owns waiting semantics. Tested: pnpm --filter harness-node test tests/approval-gate/pending.test.ts * Retire wake-failed approval events Resolve now writes a decision and publishes the turn wake directly. There is no retry wrapper that converts wake errors into stream events, so keeping approval_wake_failed advertised a lifecycle branch that no longer exists. Constraint: Preserve approval_requested and approval_resolved event envelopes unchanged for existing consumers. Rejected: Keep an unused event helper for compatibility | no caller remains and the event would only describe obsolete resume failure handling. Confidence: high Scope-risk: narrow Directive: Add a new lifecycle event only when a real runtime branch emits it. Tested: rg -n "emitApprovalWakeFailed|approval_wake_failed" harness-node/src harness-node/tests Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/approval-gate * Remove approval placeholder resurrection helpers Approval waits now stay inside the function FSM, so the old placeholder, consume, and resurrection helpers no longer describe the runtime path. Finalize appends real function results directly, awaiting-assistant no longer polls approval::consume, and teardown no longer resurrects terminal turns. Constraint: Keep the paused-turn approval path in function_awaiting_approval; resolved decisions are read from state during function execution resumption. Rejected: Leave consume helpers until a later pass | deleting the helpers without their callers would break typecheck, and keeping them would preserve dead behavior. Confidence: high Scope-risk: moderate Directive: Do not add transcript placeholder replacement back unless function_awaiting_approval is removed or the transcript again stores synthetic pending results. Tested: pnpm --filter harness-node test tests/turn-orchestrator Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node lint * Drop obsolete approval list and consume APIs Approval decisions are now read directly by the paused function state, so the separate list_pending and consume endpoints are dead runtime surface. Removing them keeps the approval gate contract to resolve, sweep-on-abort, and the policy subscriber. Constraint: Keep approval::sweep_session registered until abort cleanup is converted to inline state cleanup. Rejected: Remove sweep in the same pass | router::abort still calls approval::sweep_session and that cleanup belongs to the abort task. Confidence: high Scope-risk: narrow Directive: Do not re-add list_pending or consume for resume; function_awaiting_approval is the resume boundary. Tested: pnpm --filter harness-node test tests/approval-gate tests/harness/policy.test.ts tests/turn-orchestrator/assistant.test.ts tests/turn-orchestrator/tearing-down.test.ts Tested: pnpm --filter harness-node lint && pnpm --filter harness-node typecheck && pnpm --filter harness-node test * Wake turns from approval state writes Approval decisions are now intended to be reactive: the state write is the event that wakes the orchestrator. This adds the adapter that turns an approvals-scope state trigger into the existing turn::step_requested durable publish. Constraint: State trigger payload keys use the approval key shape <session_id>/<function_call_id>. Rejected: Debounce approval decision writes | repeated wakes are harmless and debouncing would add state to a pure adapter. Confidence: high Scope-risk: narrow Directive: Keep this adapter thin; orchestration policy belongs in function_awaiting_approval. Tested: pnpm --filter harness-node test tests/approval-gate/on-decision-written.test.ts * Register approval decision state trigger The approval gate now exposes the on-decision adapter as a state trigger on the approvals scope. Any write to an approval decision key can wake the turn orchestrator without the writer knowing about turn::step_requested. Constraint: Register with scope only and no key filter so every approvals/<session>/<call> decision write is observed. Rejected: Keep wake-up only in approval::resolve | abort and future override writers would still need duplicated publish logic. Confidence: high Scope-risk: narrow Directive: Keep the trigger scope tied to loadApprovalGateConfig so custom approval scopes keep working. Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node test tests/approval-gate * Let approval resolve rely on reactive wakeups The approvals state trigger now owns turn wake-up, so approval::resolve should only persist the decision and emit approval_resolved. Removing resumeSession eliminates the duplicated explicit durable publish from the resolve path. Constraint: approval::on_decision_written is registered on the approvals scope and publishes turn::step_requested after the state write. Rejected: Keep resolve publishing as a fallback | it would double-wake sessions once the state trigger is active and blur responsibility for the wake-up. Confidence: high Scope-risk: narrow Directive: Writers to the approvals scope should not publish turn::step_requested directly; the state trigger is the boundary. Tested: pnpm --filter harness-node test tests/approval-gate/pending.test.ts Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/approval-gate && pnpm --filter harness-node lint * Abort approvals through state decisions The abort path now writes aborted approval decisions directly for turns paused in function_awaiting_approval. Those writes are enough to wake the orchestrator through the approvals state trigger, so abort side effects no longer call sweep_session or publish turn::step_requested. Constraint: This branch did not yet have performAbortSideEffects, so this applies the missing inline cleanup and the reactive no-publish behavior together. Rejected: Keep approval::sweep_session in abort side effects | it preserves the old lifecycle endpoint and bypasses the decision-write trigger boundary. Confidence: high Scope-risk: moderate Directive: Abort approval cleanup should write approvals/<session>/<call> decisions and let the state trigger wake the FSM. Tested: pnpm --filter harness-node test tests/turn-orchestrator/abort.test.ts Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/turn-orchestrator/abort.test.ts && pnpm --filter harness-node lint * Cover reactive approval wake integration The integration fixture now simulates the engine firing a state trigger after approvals-scope writes. A direct decision write proves the adapter publishes turn::step_requested without approval::resolve doing it explicitly. Constraint: The fake state trigger runs on a microtask to match the engine's async trigger behavior closely enough for this unit-level integration test. Rejected: Restore the skipped legacy approval-resume contract | it tested the old explicit resume path rather than the reactive state-trigger boundary. Confidence: high Scope-risk: narrow Directive: Approval resume integration should model approvals state writes as the wake source. Tested: pnpm --filter harness-node test tests/integration/approval-resume.e2e.test.ts Tested: pnpm --filter harness-node typecheck && pnpm --filter harness-node test tests/integration/approval-resume.e2e.test.ts && pnpm --filter harness-node lint * Remove terminal resume path from turn stepping Approval waits now pause inside function_awaiting_approval and wake through approvals state writes. Terminal-session resurrection via run::resume and buildResumePlan is dead behavior, so turn::step now treats terminal records as terminal and no longer republishes while waiting for approval. Constraint: Keep publishStep for the normal FSM pump after non-terminal transitions. Rejected: Preserve run::resume for compatibility | no active approval path calls it and it would revive the old stopped-session model. Confidence: high Scope-risk: moderate Directive: Do not reintroduce terminal resurrection unless approval waiting leaves the FSM again. Tested: pnpm --filter harness-node test tests/turn-orchestrator Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node lint * Drop approval sweep and timeout remnants The gate no longer owns pending-record lifecycles or timeouts. Abort writes aborted decisions inline and approval decisions wake through the state trigger, so approval::sweep_session, buildPendingRecord, and approval_gate.default_timeout_ms are dead surface. Constraint: Leave unrelated hook-fanout and turn-orchestrator timeout config intact. Rejected: Keep sweep as an internal fallback | no caller remains and it depends on the deleted pending record shape. Confidence: high Scope-risk: narrow Directive: Approval cleanup should write decision records directly; do not restore pending/resolved sweep state. Tested: pnpm --filter harness-node test tests/approval-gate tests/turn-orchestrator/abort.test.ts Tested: pnpm --filter harness-node typecheck Tested: pnpm --filter harness-node lint * Remove stale approval placeholder branches The approval wait path no longer emits synthetic pending function results, so console translation does not need to suppress pending_approval function_execution_end events. The hook comment now describes the actual pending dispatch result instead of the removed placeholder flow. Constraint: Preserve approval_requested and approval_resolved UI event translation. Rejected: Keep placeholder suppression defensively | it preserves a deleted wire shape and hides unexpected function results. Confidence: high Scope-risk: narrow Directive: Pending approval UI should be driven by approval_requested, not synthetic function_result placeholders. Tested: pnpm --filter chat-app typecheck Tested: pnpm --filter chat-app exec biome check src/lib/backend/translate.ts Tested: pnpm --filter harness-node test tests/turn-orchestrator/agent-call.test.ts && pnpm --filter harness-node lint * Clean console approval lint drift Repo-wide lint surfaced small console issues around approval resolve handlers and import ordering. Removing non-null assertions and stale suppressions keeps the cleanup branch lintable without changing approval UI behavior. Constraint: Keep existing approval resolve callback contract unchanged. Rejected: Leave console lint failures as unrelated | the approval callback warnings are on this feature surface and are cheap to make safe. Confidence: high Scope-risk: narrow Directive: Keep approval IDs narrowed before invoking resolve callbacks instead of using non-null assertions. Tested: pnpm --filter chat-app lint && pnpm --filter chat-app typecheck Tested: pnpm -r lint * fix(approval-gate): match engine state:* event_type values * test(approval-gate): use engine state:* event_type values * test(integration): fake-iii emits engine-correct event_type for state writes * feat(approval-gate): filter approvals state trigger with condition function Add isDecisionWrite/CONDITION_FN_ID to on-decision-written.ts and wire condition_function_id into the state trigger config in register.ts so the iii engine skips the handler on state:deleted and writes lacking a decision field, without touching the in-handler guard (removed in Task 5). * refactor(approval-gate): handler relies on engine-side condition for filtering * feat(turn-orchestrator): adapter + condition for reactive abort wake * feat(turn-orchestrator): register reactive abort_signal state trigger * test(integration): abort_signal write triggers reactive wake * chore: final cleanup after reactive trigger follow-up Fix biome import-order lint: move `import type { ISdk }` before the value imports in on-abort-signal.test.ts. * Remove obsolete approval event handling and tests This commit deletes the `events.ts` file, which contained the `emitApprovalRequested` and `emitApprovalResolved` functions, as their functionality has been integrated directly into the `gate-subscriber` and `pending` modules. Corresponding test cases in `events.test.ts` have also been removed to reflect this change. The new implementation uses `streamSet` directly for emitting approval events, streamlining the approval gate flow. * docs(harness-node): refresh architecture for reactive approval resume Sync the harness-node docs introduced on main with this branch's pause-and-resume refactor: 11-state FSM with function_awaiting_approval, state-trigger wakes on the approvals and agent scopes, hook-fanout's stream-trigger reply path, and the harness sessions fanout moving from a 1 Hz state::list diff to a state trigger. Also drop poll_interval_ms from PublishCollectConfig — it's leftover from the old polling implementation and unused in the reactive path. * feat(turn-orchestrator): reactive run::start_and_wait via terminal state trigger executeSync now installs an in-process waiter keyed by session_id, kicks the run, and races the waiter against sync_default_timeout_ms instead of polling persistence every sync_poll_interval_ms. The waiter is resolved by a new `state` trigger on scope=agent gated by turn::is_terminal_state_write, which fires the moment the FSM lands `session/<sid>/turn_state` with state=stopped. Drops sync_poll_interval_ms from the config type, default loader, and config.yaml — the only consumer is gone. * feat(console): wire real backend to approval::resolve - Add realResolveApproval calling approval::resolve over the iii bus and expose it via ChatBackend.resolveApproval so the chat UI can approve or deny pending function calls. - Thread sessionId into translateAgentEvent so approval-related stream events carry their session context. - Drop the stale "Phase 3 adds approve/deny buttons" comment now that the flow is live end-to-end.
Summary
Native iii approval flow: the gate replies allow / deny / pending directly on the hook reply; the turn FSM owns waiting through an explicit
function_awaiting_approvalstate; reactive iii state triggers (not durable publishes) wake the turn when a decision is written. Console gets approve / deny buttons and survives multi-turn approvals on a single stream.How it works
Gate (subscriber on
policy::permission_check)policy::approval_gateconsultspolicy::check_permissionsfor each incoming function call and writes its decision on the reply stream:{ block: false }.{ block: true, reason, denial }; emitsfunction_call_deniedonagent::events.{ block: true, status: 'pending' }; emitsapproval_requested. The gate persists nothing — the orchestrator owns waiting.Orchestrator pause:
function_awaiting_approvalWhen
handleExecutefinds a prepared call whose hook reply ispending, the FSM transitions tofunction_awaiting_approval. The turn record carries the awaited function-call ids. There is no polling, no scheduled timeout, no placeholder transcript message.Reactive wakes (replace the durable
turn::step_requestedself-publish)Three iii state triggers drive the turn forward. Each uses a server-side
condition_function_idso the engine drops irrelevant writes (deletes, unrelated keys) before invoking the adapter.turn::on_record_writtenagentapproval::on_decision_writtenapprovalsdecisionfieldturn::on_abort_signalagentabort_signal = truewritesSaving the next turn record IS the wake. Writing an approval decision IS the wake.
Resolve and abort write decisions directly
approval::resolvewritesapprovals/<session>/<call> = { decision, reason }and emitsapproval_resolved. The state trigger handles the wake.aborteddecisions inline for each awaited call. The same trigger picks them up.approval::consume,approval::list_pending,approval::sweep_session,run::resume, and the placeholder-resurrect helpers are removed — the paused FSM state is the lifecycle.Console / web
approval::resolvevia the iii client and show asubmittingstate while the turn resumes.ui::session::eventstays subscribed for the chat lifetime instead of exiting on the firstagent_end, so a second approval inside the same chat still reaches the UI.fcall-startdedupes onfunction_call_id(the same id fires forfunction_execution_startandapproval_requested) — the second event patches the existing message instead of spawning a duplicate.MessageListauto-scrolls to a newly arrived pending approval.agent_endand re-arms on the next activity event so a multi-turn resume looks live.Test plan
pnpm --filter harness-node typecheck && lint && testpnpm --filter chat-app typecheck && lintabortedand the UI clears.