mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* fix(missions): auto-resume paused missions after gate resolution (#3166) Half-2 of #3133. Half-1 (PR #3155) made the engine emit a typed `GatePaused` outcome that pauses a mission and surfaces an `AuthRequired` status update on the user's auth tray. Until now the mission stayed Paused even after the user completed OAuth — they had to manually call `mission_resume`. This patch wires the credential- write and gate-resolve paths so paused missions auto-resume the moment the gate they were waiting on is resolved. Engine (`crates/ironclaw_engine/`) - `Mission` gains a persistent `paused_gate: Option<MissionGateInfo>`. `MissionGateInfo` moved from `runtime/mission.rs` to `types/mission.rs` and made `Serialize`/`Deserialize` so it round-trips through the store. Existing rows decode with `paused_gate = None` via `#[serde(default)]`. - `MissionManager::resume_paused_for_credential(credential_name, user_id)` walks paused missions visible to the user, matches by `Authentication.credential_name`, transitions Paused → Active, clears `paused_gate`, and kicks an immediate fire for non-Manual cadences. Returns the resumed ids. - `MissionManager::resume_paused_for_request_id(uuid, GateResolutionOutcome, user_id)` resumes by `gate_request_id` for approval/external gates. Approved → resume + fire; Denied/Cancelled → mark Failed. - `resume_mission` clears `paused_gate` so a manual resume doesn't leave stale gate state behind. - 4 new unit tests pin the matrix: `oauth_completion_resumes_paused_mission`, `unrelated_credential_write_does_not_resume_paused_mission`, `gate_resolution_approved_resumes_matching_paused_mission`, `gate_resolution_denied_marks_paused_mission_failed`. Bridge (`src/bridge/`) - New pub helpers `resume_paused_missions_for_credential` and `resume_paused_missions_for_gate_request` in `router.rs`. Both best-effort: helper failures log and swallow so an OAuth flow cannot 5xx because of stale mission state. Wiring - Gateway OAuth callback (`channels/web/features/oauth/mod.rs`) and WASM-tool OAuth completion (`extensions/manager.rs`) call `resume_paused_missions_for_credential` immediately after `oauth::store_oauth_tokens` succeeds, before resolving the foreground gate. - `/api/chat/gate/resolve` (`channels/web/features/chat/mod.rs`) fans the disposition out to `resume_paused_missions_for_gate_request` after the foreground gate is resolved. CredentialProvided is excluded — the credential write itself triggers the OAuth-side hook. Tests - The Rust live test `tests/e2e_live_mission_gmail.rs` is replaced by `tests/e2e/scenarios/test_mission_gmail_3133.py`. The Rust variant required a real Gmail OAuth token in the developer's `~/.ironclaw/` DB; the Python tier uses the deterministic mock LLM + the existing `/oauth/callback` flow, so it runs in CI. - The Python file pins three checks: the OAuth callback drives the resume hook without erroring when no paused mission matches; the browser sees the extension flip to authenticated; no thread in the gateway carries the `Status: None`+`Error: None` dual-marker fingerprint or the `consecutive code errors` surface from #2583. - Out of scope: chat-driven mission lifecycle (LLM emits `mission_create` + `mission_fire` and the child trips `tool_activate(gmail)`). The unit tests cover that state transition in isolation; adding canned mock-LLM responses for the chat path is a follow-up. - `secrets_store::create/update` (manual API-token entry) hook is also out of scope for this PR — only OAuth completion paths are wired today. Easy to extend later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): live-LLM record/replay infra for #3133 mission auto-resume Replaces the stub mock-LLM Playwright test with real live-test infrastructure that mirrors the Rust `LiveTestHarnessBuilder` record/replay pattern (`tests/support/live_harness.rs`) for the Python tier: - `tests/e2e/live_llm_proxy.py` — HTTP proxy that sits between ironclaw and a real LLM. Two modes: * RECORD (`IRONCLAW_LIVE_TEST=1`): forwards `/v1/chat/completions` to upstream (`IRONCLAW_LIVE_LLM_BASE_URL` / `..._API_KEY` / `..._MODEL`) and writes (request_hash, response) pairs to a JSON fixture. Canonicalisation strips tool-call ids and other non-deterministic fields so re-recordings match. * REPLAY: serves recorded responses by canonical-request hash. Streaming requests are re-emitted as a single SSE chunk + `[DONE]` on replay, which is sufficient for ironclaw's chunk accumulator. - `tests/e2e/live_harness.py` — `start_live_proxy(test_name)`, `is_live_mode()`, and `proxy_state()` helpers. Skips (not fails) in replay mode when the per-test fixture is missing so a fresh checkout doesn't bog down on un-recorded traces. - `tests/e2e/conftest.py` adds `mission_gmail_live_server` / `mission_gmail_live_page` fixtures. The fixture wires: * the live proxy as `LLM_BASE_URL` * `IRONCLAW_TEST_HTTP_REWRITE_MAP` routing `gmail.googleapis.com` at `mock_llm.py`'s new `/gmail/v1/users/me/{drafts,messages,...}` mock endpoints (so the gmail WASM tool's HTTP calls land deterministically) * `ENGINE_V2=true` and `AGENT_AUTO_APPROVE_TOOLS=true` so the chat-driven mission_create + fire flow runs without an administrative-approval prompt while the *authentication* gate that triggers the #3133 path remains active. - `tests/e2e/mock_llm.py` adds Gmail HTTP mocks (drafts/send/list/get) plus `/__mock/gmail/{state,reset}` so tests can assert that the gmail tool actually fired against this mock (rather than no-oping or hitting the real Gmail API). - `tests/e2e/scenarios/test_mission_gmail_3133.py` is rewritten to drive the full chat flow: chat asks for the mission, the live LLM emits `mission_create` + `mission_fire`, the child thread tries to use gmail, the auth gate fires and the mission transitions to Paused, the test completes OAuth via `/oauth/callback`, the half-2 wire (`bridge::resume_paused_missions_for_credential`) resumes the mission, and the re-fired child thread completes the draft against the gmail mock. Known limitation documented in the test docstring: when the live LLM picks Tier 1 (CodeAct / Python via Monty) for the child thread, `tool_activate` is NOT exposed as a CodeAct callable — only Tier 0 structured tool calls expose it. Recent Sonnet defaults to Tier 1, so the auth-gate path that half-1 added never fires in those traces and the child thread surfaces a tool-error narration instead. The test skips with a clear diagnostic in record mode if that happens and the trace recording becomes a useful artifact for investigating. Half-2 is verified end-to-end for the Tier 0 path by the unit tests in `crates/ironclaw_engine/src/runtime/mission.rs`. A Tier-1-resilient fix needs the engine to auto-promote "credential missing" tool errors to a `GatePaused` outcome so the Tier 1 path also writes `paused_gate` — tracked as a follow-up on the same issue thread. The fixture file is intentionally not committed in this PR — the tooling works (recording produced 12 entries against NearAI's Sonnet 4.5 in local testing) but a clean fixture requires the Tier 1 gap to be addressed first. The committed `.gitkeep` keeps the fixtures directory in tree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(engine,bridge): inline-await for Authentication gates (Tier 0 + Tier 1) PR #3157 wired Tier 0 + Tier 1 inline gate-await for Approval gates but explicitly excluded Authentication (and External) — the trait docstring on `GateController::pause` said "Restricted to Approval" and both executor paths bailed with `RuntimeError("execution paused by gate ...")` for non-Approval. That left the #3133 path partial: a mission's CodeAct child thread that hit a Gmail OAuth gate unwound to `ThreadOutcome::GatePaused`, but then the LLM in the re-entered thread saw a stale "tool returned error" instead of a clean retry against the now-present credential, and the mission either re-fired on cron (the original ghost-fire) or narrated the failure into FINAL(). This patch lets Authentication gates flow through the same inline- await path as Approval and wires the OAuth callback to wake parked VMs by credential name. Engine (`crates/ironclaw_engine/`) - `executor/scripting.rs`: `resolve_tool_future` and the `drive_inline_gate` retry loop now accept `ResumeKind::Authentication` alongside Approval. External keeps the legacy unwind path because its resolution payload (callback body) can't be handed back to a suspended call. - `executor/structured.rs`: `execute_with_inline_gate_retry` mirrors the same expansion for Tier 0 batches. - `gate/mod.rs`: drop the "Restricted to Approval" doc on `GateController::pause`. Document the new contract — the host controller is expected to deliver `GateResolution::Approved` once the credential lands in the secrets store, and the engine retries the action inline against the now-present secret. Bridge (`src/bridge/`) - `gate_controller.rs`: drop the non-Approval cancel in `BridgeGateController::pause`. Authentication is now handled the same way Approval is: register the pending gate, park on a oneshot, await resolution. Add a credential-name → request_ids index in `GateResolutions` and a new `deliver_for_credential(credential_name)` method that delivers `Approved` to every parked Authentication waiter for that credential. - `router.rs`: new pub helper `resolve_inline_gates_for_credential(credential_name)` that walks `EngineState::gate_resolutions` and calls `deliver_for_credential`. Added a `gate_resolutions` field on `EngineState` (and threaded through every test fixture) so the OAuth-callback path can reach the resolutions registry without going through the controller's internals. Wiring - `channels/web/features/oauth/mod.rs` and `extensions/manager.rs`: the OAuth completion path now fires BOTH `resolve_inline_gates_for_credential` (wakes parked Tier 0 / Tier 1 VMs for live foreground or mission child-thread executions) AND `resume_paused_missions_for_credential` (the half-2 mission-arm hook for missions whose child thread already unwound before OAuth completed). Best-effort dispatch — failures are logged inside the helpers and never block the OAuth landing page. Tests - All 27 existing `engine_v2_gate_integration` tests continue to pass unchanged. The Approval contract is unchanged and the Authentication contract was previously untestable end-to-end at the inline-await level (the old code surfaced as a `RuntimeError`). - `runtime::mission::tests` (104 tests) all pass — the engine state machine is unchanged for the half-2 mission arm. Open follow-up: a new gate_integration test that pins the Tier 1 + Authentication + credential-write resume cycle. The existing test harness already exercises the Tier 0 + Approval cycle; adding the analogous Authentication test is straightforward but out of scope for this commit (the LLM-driven trace recording in the Playwright e2e test serves as the integration-tier coverage once the chat path can drive it deterministically). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(engine): preserve legacy unwind for Authentication when controller cancels Follow-up to the previous commit. The previous change made all Authentication gates flow through `GateController::pause()`, but that broke 4 existing tests (`gate_paused_authentication_carries_credential_name`, `auth_resolution_retries_same_pending_action_without_second_pause`, `approval_chains_directly_into_auth_for_install_flow`, `install_auth_resume_followed_by_aliased_tool_call_completes_without_hanging`) that exercise the legacy `ThreadOutcome::GatePaused` re-entry path. Those tests use `CancellingGateController` (or a fixture variant that cancels) which previously surfaced as the legacy unwind because non-Approval never reached the controller; now that Authentication does reach the controller, Cancelled was being treated as denial. Fix: when the controller returns `Cancelled` for an Authentication gate, fall through to the legacy unwind path (re-raise the original `Err(GatePaused)` in Tier 0 / re-raise `RuntimeError("execution paused by gate ...")` in Tier 1). Semantically: Cancelled-on-Auth means "the controller can't resolve OAuth inline" — that's the fallback condition for missions and non-inline-aware controllers, where the engine should hand the gate up to the orchestrator so the mission can transition to Paused and the half-2 mission-arm auto-resume mechanism takes over after OAuth. `Denied` and explicit user-driven `Cancelled` from a real production controller (BridgeGateController via `/api/chat/gate/resolve`) still fail the action. Approved retries inline as before. Test updates - `AutoApprovingGateController::pause` now only auto-approves Approval gates and explicitly returns Cancelled for Authentication so the 4 legacy-path tests continue to exercise the unwind. - `approval_chains_directly_into_auth_for_install_flow` now expects the controller to observe BOTH the Approval pause AND the Authentication pause (which falls through to legacy unwind), since Authentication now goes through the controller before unwinding. - New `authentication_gate_resolves_inline_via_controller` test pins the new inline-await contract for Authentication: a controller that returns Approved (mimicking the production BridgeGateController after a credential-write hook fires) makes the action retry inline and the thread complete in a single join, with both gate and retry events recorded. All 28 `engine_v2_gate_integration` tests pass. 524 `ironclaw_engine` lib tests pass (unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(engine): make tool_activate redundant for installed-but-unauthed tools User's question: "do we need tool_activate at all?" Answer: not for the case where a tool is installed but its credential is missing. The engine should detect missing credentials at execute time and raise an Authentication gate (which the inline-await machinery from the previous commits then resolves after OAuth). The model can call the tool directly — no preceding `tool_activate(name=...)` step. Three cooperating changes ship that contract: 1. `Tool::required_credentials()` — new trait method, default empty. `WasmToolWrapper` overrides it by walking `capabilities.http.credentials` and returning every non-optional `secret_name`. Other tool kinds (built-in, MCP, etc.) inherit the empty default and are unaffected. 2. `AuthManager::check_action_auth` — extends the existing HTTP-tool credential preflight to non-HTTP tools. After the existing `is_http` early return, looks up the tool by name in the registry and consults `tool.required_credentials()`. For each declared credential it tries `resolve_secret_for_runtime`; missing ones produce `AuthCheckResult::MissingCredentials`. The bridge's `effect_adapter::execute_action` already converts that to `Err(EngineError::GatePaused { resume_kind: Authentication, ... })`, which the inline-await loops in `executor/structured.rs` and `executor/scripting.rs` (extended in the previous commits) park on `gate_controller.pause()`. After OAuth completes, `bridge::resolve_inline_gates_for_credential` delivers `Approved` and the action retries against the now-present secret. 3. `tool_surface::is_direct_ready` — `NeedsAuth` extension tools now stay on the callable surface alongside `Ready`. Pre-#3133 they were filtered out of `available_actions` and the model had to call `tool_activate` to move them to Ready. Post-fix they appear on the schema and in the CodeAct namespace; auth resolves at execute time. `NeedsSetup`, `Inactive`, `Latent`, and `AvailableNotInstalled` still fall through to capabilities-only because their resolution requires user-driven onboarding work that a credential-write hook can't supply. Test updates - `bridge::tool_surface::tests::assigns_surface_matrix_rows` — the "needs-auth extension direct action" case flips from `capabilities_only()` to `actions_only()`. - `bridge::action_projector::tests::needs_auth_provider_tools_omitted_from_available_actions` renamed to `..._stay_in_available_actions` and expectation flipped. The `NeedsSetup` companion test stays unchanged — that path still needs explicit setup via `tool_activate` / extension UI. - `bridge::effect_adapter::tests::available_actions_omit_installed_needs_auth_provider_action` renamed to `..._keep_installed_needs_auth_provider_action` and expectation flipped. - 460 bridge tests pass, 28 gate integration tests pass, 524 engine tests pass, 19 auth tests pass. Live test prompt updated - The Playwright test no longer instructs the LLM to call `tool_activate` first. The new prompt says "call the gmail tool directly — the runtime handles authentication". This matches the new contract and gives the LLM the simplest path to the gmail call. Known limitation surfaced by the live recording - The live trace against NearAI's Sonnet 4.5 still skips with the test's "mission never reached Paused" diagnostic. Inspection shows gmail is NOT registered as a callable tool with the engine's tool registry until the WASM extension is activated — install + unauthed leaves the WASM module loaded but its tool definitions un-registered. So even with the surface change in place, the CodeAct namespace doesn't see `gmail` and the LLM can't invoke it. Closing this last gap requires registering WASM tools at install time (rather than at activation time) so the engine's preflight has a tool to look up and gate. Tracked as a follow-up on the same #3166 thread; the unit-test coverage of the auto-resume mechanism is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(extensions): auto-register WASM tools with the engine registry on install User asked: "can we remove tool_activate as a tool completely? and instead register tools on install in ToolRegistry. Let's make the live test work end to end." This commit takes the first step: WASM tools are now registered with the engine's `ToolRegistry` immediately at install time, not gated behind a separate `tool_activate(name=...)` call. Combined with the previous commits' surface change (`is_direct_ready(NeedsAuth)`) and per-tool credential preflight (`AuthManager::check_action_auth` reading `Tool::required_credentials()`), the model can now call an installed-but-unauthed tool directly — the engine raises an `Authentication` gate at execute time and the inline-await machinery resumes the action after OAuth completes. `install_wasm_tool_from_url_with_caps` now invokes `activate_wasm_tool` immediately after the download lands. Any activation failure is logged and downgraded to a warning rather than unwinding the install — so a transient runtime error doesn't break install. The user can still re-run `/api/extensions/{name}/activate` in that fallback case. Verified by direct diagnostic: a chat against a fresh ironclaw with a freshly-installed (but unauthed) gmail extension now shows `gmail` registered in `/api/extensions/tools` and classified as `Inline` in the action projector (NeedsAuth status, available_actions = true). 132 extension manager tests pass. 460 bridge tests pass. 524 engine tests pass. 28 gate integration tests pass. Remaining work to make `tool_activate` fully retire and the live test pass end-to-end (not blocking this commit): 1. Remove `tool_activate` as a builtin tool, drop it from the AUTONOMOUS_TOOL_DENYLIST, drop the special-cased install-approval logic, and update the engine v2 system prompt's "Activatable Integrations" guidance. Mostly mechanical. 2. Make Tier 1 (CodeAct) mission child threads surface `ThreadOutcome::GatePaused` when an Authentication gate fires and the controller cancels (no PerExecutionContext registered for missions today). Currently the legacy fallback raises a Python `RuntimeError("execution paused by gate ...")` inside the script, which appears in stdout but isn't propagated to `CodeExecutionResult::need_approval`. The Tier 0 path already surfaces correctly via the gate_paused result_json. Closing this gap likely needs either: (a) a side-channel from `drive_inline_gate` into `execute_code`'s `need_approval` field, or (b) registering a mission-thread `PerExecutionContext` so the controller parks indefinitely on Authentication and the mission completes the action inline instead of unwinding. (b) is cleaner — missions then behave like the foreground chat path. The live test stays in the tree as the recording artifact. The diagnostic confirms the architectural change is correct (gmail is classified Inline + registered) — the remaining gap is downstream of that. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(engine): surface Tier 1 Cancelled+Authentication gates as need_approval Adds the missing wire from `drive_inline_gate`'s Cancelled+Authentication fallback to `CodeExecutionResult::need_approval`, so mission child threads running in Tier 1 (CodeAct) actually transition to Paused instead of silently dropping the gate. Before this commit: - Tier 1 mission child thread calls a tool that needs auth - Engine raises GatePaused{Authentication} - Inline-await calls `gate_controller.pause()` — no PerExecutionContext registered for missions, so the controller cancels - Cancelled+Authentication fallback in `drive_inline_gate` raises `RuntimeError("execution paused by gate ...")` - Script exits with the error in stdout - `CodeExecutionResult::need_approval` was always None - Orchestrator sees the script error but no `pending_gate` field - Mission stays Active, cron keeps re-firing — the original #3133 ghost-fire shape After this commit: - `drive_inline_gate` writes the original `ThreadOutcome::GatePaused` to a tokio `task_local!` (`PENDING_GATE_STASH`) before raising the RuntimeError - `execute_code_with_skills_inner` runs inside `PENDING_GATE_STASH.scope(RefCell::new(None), ...)` so the stash is scoped per execution - The runtime-error exit path reads the stash and threads it into `CodeExecutionResult::need_approval` - The orchestrator's `__run_code__` wrapper already converts `need_approval` into a `pending_gate` field on the script result (line ~1061 of orchestrator.rs) - The Python orchestrator reads `pending_gate`, transitions the thread to Waiting, and returns `outcome=gate_paused` - Rust converts that to `ThreadOutcome::GatePaused` - `process_mission_outcome_and_notify` sees GatePaused → mission Paused → `paused_gate` set → user sees notification → user does OAuth → half-2 mission arm auto-resumes the mission This is the missing piece to make Tier 1 missions safe under #3133. For Tier 0, the legacy unwind already produces a `gate_paused` result_json directly. For foreground threads with a wired BridgeGateController, inline-await parks indefinitely on Authentication (the new contract from earlier commits) and the Cancelled fallback never fires. 524 engine tests pass. 28 gate integration tests pass. 460 bridge tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(bridge): tone down preflight-auth + gate-cancel logs to debug Cleanup pass after the broader #3133 wiring audit. Both lines were temporarily promoted to info! during debugging; for production they should sit at debug! to avoid log noise on the happy path. No behavior change. * fix: complete Tier 1 mission auto-resume + WASM tool test rewrites for #3133 Five small fixes that together let the mission auto-resume (#3133 / #3166) flow run end-to-end through Tier 1 (CodeAct/Monty) child threads: - engine: extract `take_pending_gate_stash()` and apply it at all three Tier 1 script-error exit paths in `execute_code_with_skills_inner`. The previous fix only covered one exit; the others silently swallowed Cancelled+Authentication gates, leaving the mission Active when Monty raised the gate from a non-progress error path. - engine: deterministic sort in the trait-default `list_missions_with_shared` plus the in-memory `store_adapter::list_missions` / `list_all_missions`. Eliminates HashMap-iteration-order leakage into `mission_list` tool results so the LLM and replay tests see a stable shape. - bridge gate_controller: replace stale "no per-execution context → cancel" comment with the real reason — mission/background threads intentionally fall through to the legacy `ThreadOutcome::GatePaused` unwind so `process_mission_outcome_and_notify` (#3133 half-1) flips the mission to Paused, and `resume_paused_for_credential` (half-2) resumes after OAuth. - WASM tool wrapper: in test/debug builds, after credential injection apply `IRONCLAW_TEST_HTTP_REWRITE_MAP` to outbound URLs so live-test fixtures can route `gmail.googleapis.com` at the mock_llm endpoint the same way WASM channels already do. Also implement `Tool::required_credentials()` from the WASM capability manifest so the engine can preflight credential checks without invoking the tool. - WASM tool http_security: when `IRONCLAW_TEST_HTTP_REWRITE_MAP` is set in a debug build and the rewritten host resolves to loopback, allow it through the SSRF guard. Production paths (no env var, or release builds) keep rejecting private/internal IPs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): land live fixture for #3133 + harden replay canonicalization Records the full LLM trace for the mission auto-resume regression and makes replay deterministic across re-recordings on different machines: - live_llm_proxy: drop skill bodies from the canonical hash. Skills are local-machine state (the user's `~/.ironclaw/skills` set varies between record and replay) so hashing the body forces a fresh recording for every checkout. The skill *set* still influences the LLM's actual response — replay just doesn't re-validate it. Also collapse `[SKILL] skill:NAME ...` and `### [SKILL] skill:NAME ...` forms via a single regex so user-message and system-prompt skill blocks both normalize. - live_llm_proxy: log every chat_completions request and any upstream 4xx/5xx body to stderr. Diagnosable failures during a re-record without re-running the harness from scratch. - conftest + live_harness: when `IRONCLAW_E2E_STDERR_LOG` / `IRONCLAW_LIVE_PROXY_STDERR_LOG` are set, redirect ironclaw and proxy stderr to a file so the LLM-call sequence and any rust-side retry warnings are durable across teardown. Defaults are unchanged (PIPE) so CI behavior matches today. - fixtures/live/test_mission_gmail_draft_3133.json: fresh recording produced by `IRONCLAW_LIVE_TEST=1 IRONCLAW_LIVE_LLM_MODEL=Qwen/Qwen3.5-122B-A10B pytest scenarios/test_mission_gmail_3133.py`. Replay passes deterministically (25.87s on this box) and exercises the full Paused → OAuth → Active → gmail-draft loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: remove tool_activate; rely on auth-preflight for installed-but-unauthed tools `tool_activate` was the engine-v2 enablement entry point for installed provider tools that needed OAuth before the model could call them. Post-#3133 the same flow is covered by the auth preflight in `AuthManager::check_action_auth`: any tool with declared `required_credentials()` raises an `Authentication` gate at execute time when the credential is missing, the inline-await machinery parks the VM, and the OAuth callback resumes the action. The separate `tool_activate` step is now redundant. What's removed: - `ToolActivateTool` struct + Tool impl (`src/tools/builtin/extension_tools.rs`) - Registration sites: `ToolRegistry::register_extension_tools`, `effect_adapter` test rigs, action_projector test surface - The `tool_activate_requires_install_approval` adapter method, `matching_extension_requires_install_approval` helper, and three approval-gate tests that only fired on the `tool_activate` lookup name - The `tool_activate` entry from the autonomous tool denylist (`src/tools/autonomy.rs`, `crates/ironclaw_engine/src/gate/tool_tier.rs`), `seeded_default_permission`, the action-resolver alias map (`src/auth/extension.rs`), the schema validator, and the autonomous whitelist in `src/tools/registry.rs` - The "Activatable Integrations" prompt template's `tool_activate(name=...)` instruction; replaced with text telling the model these integrations need user setup through the IronClaw UI - `tests/e2e/scenarios/test_v2_tool_activate_surface.py` and `tests/e2e_auth_gate_traces.rs` plus its 5 LLM trace fixtures — these tested the old tool-output-detection contract that the auth preflight obsoletes; equivalent coverage lives in `bridge::effect_adapter::tests::preflight_gate_blocks_missing_credential`, the engine v2 mission unit tests, and the live mission gmail e2e What changed in the prompt: - `is_activatable_integration` now excludes `NeedsAuth` — those tools are direct-callable through the regular action inventory. `NeedsSetup`, `Inactive`, `Latent`, and `AvailableNotInstalled` still surface separately because they need user-driven setup the model can't do itself. Live fixture re-hashed: stripped `tool_activate` from each entry's `request_canonical.tools` and recomputed `request_hash` so replay matches the new (1-shorter) tool list. Replay test still passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: cargo fmt across the branch Run rustfmt on the engine and bridge files touched by the earlier mission auto-resume + tool_activate-removal commits. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(missions, gates): address PR #3366 review — atomic resume + per-user wakeup scoping Five correctness fixes from the PR review on #3366: 1. **Per-user credential wakeup (Copilot)** `GateResolutions.by_credential` now keys by `(user_id, credential_name)`. Previously a credential write under one account could deliver `Approved` to parked Authentication gates from a different account that happened to share the same credential name. `register_credential`, `deliver_for_credential`, and the public `bridge::resolve_inline_gates_for_credential` helper now all carry `user_id`. Call sites: OAuth callback handler, WASM OAuth completion in extensions manager. 2. **Atomic `resume_paused_for_request_id` (serrrfirat HIGH)** The handler now reloads the mission and re-checks `paused_gate.gate_request_id` in the same load+save round-trip, instead of relying on the snapshot it scanned for. Prevents a race where a mission re-paused on a different gate between the snapshot and the resume could be transitioned to `Active` (silently clearing the new gate) because `resume_mission` accepts both `Paused` and `Failed`. Returns `None` when the live gate no longer matches. 3. **Shared mission auto-resume runs under requesting user (serrrfirat HIGH)** For shared missions (`mission.user_id == "__shared__"`), `resume_paused_for_credential` and `resume_paused_for_request_id` now use `user_id` (the user whose credential / resolution unblocked the gate) for `fire_mission`, while keeping `mission.user_id` for the `resume_mission` ownership check. Previously the post-resume fire ran under `__shared__`, which has no per-user secret / project scope. 4. **Mission-only gate cancellation (serrrfirat MEDIUM)** `chat_gate_resolve_handler` no longer requires `thread_id` on the `Cancelled` arm. Mission-only gates have no foreground `thread_id` in the resolution payload; the mission auto-resume path now carries the `Cancelled` outcome to the mission state machine even without a foreground submission. Foreground gates that do supply `thread_id` still dispatch the structured cancellation as before. 5. **CredentialProvided now triggers mission auto-resume (serrrfirat MEDIUM)** `chat_gate_resolve_handler` now fires `mission_outcome = Approved` for `CredentialProvided` submissions, not just `Approved`. Previously, missions paused on a non-OAuth credential (e.g. user submits a Telegram bot token through the gate UI) would only auto-resume on a subsequent OAuth callback. The new mission helpers' atomic gate-id check makes the double-fire (this hook + the OAuth-callback path) idempotent. Plus six doc fixes: stale references to `bridge::resume_paused_missions_for_credential` in inline-await docstrings (`gate/mod.rs`, `executor/structured.rs`, `executor/scripting.rs`) now correctly point at `bridge::resolve_inline_gates_for_credential`. The router doc no longer claims a `secrets_store::create/update` hook that doesn't exist; updated to name the actual call sites and document the manual-credential follow-up. Test fixtures in `bridge::router::tests` (4 EngineState constructors) now share a single `Arc<GateResolutions>` between `gate_controller` and `gate_resolutions` so test paths exercising `resolve_inline_gates_for_credential` actually reach the parked waiters that `BridgeGateController::pause` registered. All 5623 lib tests pass; replay e2e still green at ~28s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): correct return type on live_llm_proxy SSE helpers Copilot review: `_emit_streamed_response` was annotated `web.StreamResponse` but the underlying `_send_sse` helper buffers the payload and returns a `web.Response`. Aligned the chain (`_emit_streamed_response`, `_send_sse_payload`, `_send_sse_lines`, `_send_sse`) on the actual return type and removed the unreachable `_start`/`started` block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: bump to retrigger workflows on PR #3366 * fix(missions, wasm, e2e): address remaining PR #3366 review feedback - chat_gate_resolve_handler: validate gate request_id once up front so every arm (including Approved/Denied) returns a uniform 400 on malformed UUIDs and the mission auto-resume hook isn't silently skipped. - extensions::manager::install_wasm_tool_from_url_with_caps: reflect activation outcome in InstallResult.message — no longer claims "installed and ready" when activate_wasm_tool fails. - tools::wasm::http_security: drop the is_unspecified() branch from the IRONCLAW_TEST_HTTP_REWRITE_MAP escape hatch; the rewrite helper only emits loopback IPs, so allowing 0.0.0.0/:: just weakened the SSRF guard in debug/test builds. - tests/e2e/live_harness.py: remove unused subprocess import and unused _find_free_port helper. - tests/e2e/live_llm_proxy.py: docstring on _normalize_skills_block said "sorted name list" while the implementation drops the entire block to a `[SKILLS]` placeholder — corrected to match behavior. [skip-regression-check] — review-feedback fixes, no behavioral regression to add a test for; existing chat-gate resolve coverage exercises the validated path, and the SSRF guard fix tightens an unreachable branch. * fix(gates, wasm, e2e): address final PR #3366 review and unblock CI - chat/gate-resolve cancel: when the client omits `thread_id` for a foreground inline-await gate, look up the owning thread from `PendingGateStore` instead of skipping dispatch — otherwise the parked VM is stranded even though the API reports success. Adds `PendingGateStore::peek_by_request_id` (user-scoped, read-only) and `bridge::get_pending_gate_by_request_id`. - tools/wasm rewrite validator: replace the hardcoded `http://...`-prefix match with URL parsing that mirrors the channel-side `is_loopback_test_rewrite_base`, accepting any http/https loopback target. Also strip IPv6 brackets in both helpers so `[::1]` actually validates. - tests/e2e/pyproject.toml: scope setuptools auto-discovery to `scenarios*` so the new `fixtures/` (live-LLM record/replay JSON) no longer trips the "multiple top-level packages" error and `pip install -e .` works in CI. Regression coverage: store-level peek_by_request_id ownership + expiry tests; loopback-rewrite parity + https-loopback acceptance tests in tools/wasm/wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1239 lines
44 KiB
Rust
1239 lines
44 KiB
Rust
//! E2E trace tests: builtin tool coverage (#573).
|
|
//!
|
|
//! Covers time (parse, diff, invalid), routine (create, list, update, delete,
|
|
//! history), job (create, status, list, cancel), and HTTP replay.
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod support;
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod tests {
|
|
use std::time::Duration;
|
|
|
|
use ironclaw::agent::routine::{RoutineAction, Trigger};
|
|
use ironclaw::context::{JobContext, JobState};
|
|
use uuid::Uuid;
|
|
|
|
use crate::support::test_rig::{TestRig, TestRigBuilder};
|
|
use crate::support::trace_llm::{
|
|
LlmTrace, RequestHint, TraceResponse, TraceStep, TraceToolCall, TraceTurn,
|
|
};
|
|
|
|
fn text_step(content: &str) -> TraceStep {
|
|
TraceStep {
|
|
request_hint: None,
|
|
response: TraceResponse::Text {
|
|
content: content.to_string(),
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
},
|
|
expected_tool_results: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn hinted_text_step(content: &str, last_user_message_contains: &str) -> TraceStep {
|
|
TraceStep {
|
|
request_hint: Some(RequestHint {
|
|
last_user_message_contains: Some(last_user_message_contains.to_string()),
|
|
min_message_count: None,
|
|
}),
|
|
response: TraceResponse::Text {
|
|
content: content.to_string(),
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
},
|
|
expected_tool_results: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn extract_job_id(response: &str) -> Option<Uuid> {
|
|
response
|
|
.split(|c: char| !(c.is_ascii_hexdigit() || c == '-'))
|
|
.find_map(|token| Uuid::parse_str(token).ok())
|
|
}
|
|
|
|
async fn resolve_created_job_id(
|
|
rig: &TestRig,
|
|
responses: &[ironclaw::channels::OutgoingResponse],
|
|
expected_title: &str,
|
|
) -> Uuid {
|
|
if let Some(job_id) = responses
|
|
.iter()
|
|
.find_map(|response| extract_job_id(&response.content))
|
|
{
|
|
return job_id;
|
|
}
|
|
|
|
rig.database()
|
|
.list_agent_jobs_for_user("test-user")
|
|
.await
|
|
.expect("list_agent_jobs_for_user should succeed")
|
|
.into_iter()
|
|
.find(|job| job.title == expected_title)
|
|
.map(|job| job.id)
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"failed to resolve job id for title {expected_title:?}; responses were: {:?}",
|
|
responses
|
|
.iter()
|
|
.map(|response| &response.content)
|
|
.collect::<Vec<_>>()
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn wait_for_job_state(rig: &TestRig, job_id: Uuid, expected: JobState) -> JobContext {
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
|
|
|
loop {
|
|
if let Some(job) = rig
|
|
.database()
|
|
.get_job(job_id)
|
|
.await
|
|
.expect("get_job should succeed")
|
|
&& job.state == expected
|
|
{
|
|
return job;
|
|
}
|
|
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"job {job_id} did not reach state {expected:?} before timeout"
|
|
);
|
|
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
|
|
fn requests_contain(requests: &[Vec<ironclaw_llm::ChatMessage>], needle: &str) -> bool {
|
|
requests
|
|
.iter()
|
|
.flatten()
|
|
.any(|message| message.content.contains(needle))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 1: time_parse_and_diff
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn time_parse_and_diff() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/time_parse_diff.json"
|
|
))
|
|
.expect("failed to load time_parse_diff.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.with_skills()
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Parse a time and compute a diff").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// Time tool should have been called twice (parse + diff).
|
|
let started = rig.tool_calls_started();
|
|
let time_count = started.iter().filter(|n| n.as_str() == "time").count();
|
|
assert!(
|
|
time_count >= 2,
|
|
"Expected >= 2 time tool calls, got {time_count}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 2: time_parse_invalid
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn time_parse_invalid() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/time_parse_invalid.json"
|
|
))
|
|
.expect("failed to load time_parse_invalid.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.with_skills()
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Parse an invalid timestamp").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// The time tool call should have failed (invalid timestamp).
|
|
let completed = rig.tool_calls_completed();
|
|
let time_results: Vec<_> = completed
|
|
.iter()
|
|
.filter(|(name, _)| name == "time")
|
|
.collect();
|
|
assert!(!time_results.is_empty(), "Expected time tool to be called");
|
|
assert!(
|
|
time_results.iter().any(|(_, ok)| !ok),
|
|
"Expected at least one failed time call: {time_results:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 3: routine_create_list
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_create_list() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_create_list.json"
|
|
))
|
|
.expect("failed to load routine_create_list.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.with_skills()
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a daily routine and list all routines")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// Both routine_create and routine_list should have succeeded.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "routine_create" && *ok),
|
|
"routine_create should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "routine_list" && *ok),
|
|
"routine_list should succeed: {completed:?}"
|
|
);
|
|
|
|
let routine = rig
|
|
.database()
|
|
.get_routine_by_name(rig.owner_id(), "daily-check")
|
|
.await
|
|
.expect("get_routine_by_name")
|
|
.expect("daily-check should exist");
|
|
|
|
match &routine.trigger {
|
|
Trigger::Cron { schedule, timezone } => {
|
|
assert_eq!(schedule, "0 0 9 * * * *");
|
|
assert_eq!(timezone.as_deref(), Some("America/New_York"));
|
|
}
|
|
other => panic!("expected cron trigger, got {other:?}"),
|
|
}
|
|
|
|
match &routine.action {
|
|
RoutineAction::Lightweight {
|
|
prompt,
|
|
context_paths,
|
|
use_tools,
|
|
max_tool_rounds,
|
|
..
|
|
} => {
|
|
assert!(prompt.contains("Check system status"));
|
|
assert_eq!(context_paths, &vec!["context/priorities.md".to_string()]);
|
|
assert!(*use_tools, "lightweight routine should keep use_tools=true");
|
|
assert_eq!(*max_tool_rounds, 2);
|
|
}
|
|
other => panic!("expected lightweight routine action, got {other:?}"),
|
|
}
|
|
|
|
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
|
|
assert_eq!(routine.notify.user.as_deref(), Some("ops-team"));
|
|
assert_eq!(routine.guardrails.cooldown.as_secs(), 600);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 4: routine_update_delete
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_update_delete() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_update_delete.json"
|
|
))
|
|
.expect("failed to load routine_update_delete.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create, update, and delete a routine")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let started = rig.tool_calls_started();
|
|
assert!(
|
|
started.contains(&"routine_create".to_string()),
|
|
"routine_create not started"
|
|
);
|
|
assert!(
|
|
started.contains(&"routine_update".to_string()),
|
|
"routine_update not started"
|
|
);
|
|
assert!(
|
|
started.contains(&"routine_delete".to_string()),
|
|
"routine_delete not started"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 5: routine_update_fail_delete_fallback
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_update_fail_delete_fallback() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json"
|
|
))
|
|
.expect("failed to load routine_update_fail_delete_fallback.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Try converting a routine trigger, then recover by deleting it")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "routine_update" && !ok),
|
|
"routine_update should fail in this regression path: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "routine_delete" && *ok),
|
|
"routine_delete should recover successfully via preserved routine identity: {completed:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 6: routine_manual_create_defaults_to_tools_enabled
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_manual_create_defaults_to_tools_enabled() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_manual_create.json"
|
|
))
|
|
.expect("failed to load routine_manual_create.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a manual routine for bug triage")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let routine = rig
|
|
.database()
|
|
.get_routine_by_name("test-user", "manual-triage")
|
|
.await
|
|
.expect("get_routine_by_name")
|
|
.expect("manual-triage should exist");
|
|
|
|
assert!(matches!(routine.trigger, Trigger::Manual));
|
|
assert!(
|
|
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if *use_tools),
|
|
"manual routine should default to lightweight with tools enabled: {:?}",
|
|
routine.action
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 7: routine_manual_create_explicit_no_tools
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_manual_create_explicit_no_tools() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_manual_create_no_tools.json"
|
|
))
|
|
.expect("failed to load routine_manual_create_no_tools.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a manual routine for quiet text-only bug triage")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let routine = rig
|
|
.database()
|
|
.get_routine_by_name("test-user", "manual-triage-no-tools")
|
|
.await
|
|
.expect("get_routine_by_name")
|
|
.expect("manual-triage-no-tools should exist");
|
|
|
|
assert!(matches!(routine.trigger, Trigger::Manual));
|
|
assert!(
|
|
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if !*use_tools),
|
|
"manual routine should preserve explicit use_tools=false: {:?}",
|
|
routine.action
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: routine_history
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_history() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_history.json"
|
|
))
|
|
.expect("failed to load routine_history.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a routine and check its history")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let started = rig.tool_calls_started();
|
|
assert!(
|
|
started.contains(&"routine_create".to_string()),
|
|
"routine_create missing"
|
|
);
|
|
assert!(
|
|
started.contains(&"routine_history".to_string()),
|
|
"routine_history missing"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: routine_system_event_emit
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_system_event_emit() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_system_event_emit.json"
|
|
))
|
|
.expect("failed to load routine_system_event_emit.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a system-event routine and emit an event")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "event_emit" && *ok),
|
|
"event_emit should succeed: {completed:?}"
|
|
);
|
|
|
|
let results = rig.tool_results();
|
|
let emit_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "event_emit")
|
|
.expect("event_emit result missing");
|
|
assert!(
|
|
emit_result.1.contains("fired_routines"),
|
|
"event_emit should report fired routine count: {:?}",
|
|
emit_result.1
|
|
);
|
|
// Verify at least one routine actually fired (not just that the key exists).
|
|
let emit_json: serde_json::Value =
|
|
serde_json::from_str(&emit_result.1).expect("event_emit result should be valid JSON");
|
|
assert!(
|
|
emit_json["fired_routines"].as_u64().unwrap_or(0) > 0,
|
|
"event_emit should have fired at least one routine: {:?}",
|
|
emit_result.1
|
|
);
|
|
|
|
let routine = rig
|
|
.database()
|
|
.get_routine_by_name("test-user", "gh-issue-emit-test")
|
|
.await
|
|
.expect("get_routine_by_name")
|
|
.expect("gh-issue-emit-test should exist");
|
|
|
|
match &routine.trigger {
|
|
Trigger::SystemEvent {
|
|
source,
|
|
event_type,
|
|
filters,
|
|
} => {
|
|
assert_eq!(source, "github");
|
|
assert_eq!(event_type, "issue.opened");
|
|
assert_eq!(
|
|
filters.get("repository").map(String::as_str),
|
|
Some("nearai/ironclaw")
|
|
);
|
|
assert_eq!(filters.get("priority").map(String::as_str), Some("p1"));
|
|
}
|
|
other => panic!("expected system_event trigger, got {other:?}"),
|
|
}
|
|
|
|
match &routine.action {
|
|
RoutineAction::FullJob { description, .. } => {
|
|
assert!(description.contains("Summarize the new issue"));
|
|
}
|
|
other => panic!("expected full_job action, got {other:?}"),
|
|
}
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: routine_create_grouped
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_create_grouped() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_create_grouped.json"
|
|
))
|
|
.expect("failed to load routine_create_grouped.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a grouped cron routine with delivery settings")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let routine = rig
|
|
.database()
|
|
.get_routine_by_name("test-user", "weekday-digest")
|
|
.await
|
|
.expect("get_routine_by_name")
|
|
.expect("weekday-digest should exist");
|
|
|
|
match &routine.trigger {
|
|
Trigger::Cron { schedule, timezone } => {
|
|
assert_eq!(schedule, "0 0 9 * * MON-FRI *");
|
|
assert_eq!(timezone.as_deref(), Some("UTC"));
|
|
}
|
|
other => panic!("expected cron trigger, got {other:?}"),
|
|
}
|
|
|
|
match &routine.action {
|
|
RoutineAction::FullJob { description, .. } => {
|
|
assert!(description.contains("Prepare the morning digest"));
|
|
}
|
|
other => panic!("expected full_job action, got {other:?}"),
|
|
}
|
|
|
|
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
|
|
assert_eq!(routine.notify.user.as_deref(), Some("ops-team"));
|
|
assert_eq!(routine.guardrails.cooldown.as_secs(), 30);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 9: routine_system_event_emit_grouped
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn routine_system_event_emit_grouped() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/routine_system_event_emit_grouped.json"
|
|
))
|
|
.expect("failed to load routine_system_event_emit_grouped.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a grouped system-event routine and emit a matching event")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let routine = rig
|
|
.database()
|
|
.get_routine_by_name("test-user", "grouped-gh-issue-watch")
|
|
.await
|
|
.expect("get_routine_by_name")
|
|
.expect("grouped-gh-issue-watch should exist");
|
|
|
|
match &routine.trigger {
|
|
Trigger::SystemEvent {
|
|
source,
|
|
event_type,
|
|
filters,
|
|
} => {
|
|
assert_eq!(source, "github");
|
|
assert_eq!(event_type, "issue.opened");
|
|
assert_eq!(
|
|
filters.get("repository").map(String::as_str),
|
|
Some("nearai/ironclaw")
|
|
);
|
|
assert_eq!(filters.get("priority").map(String::as_str), Some("p1"));
|
|
}
|
|
other => panic!("expected system_event trigger, got {other:?}"),
|
|
}
|
|
|
|
let results = rig.tool_results();
|
|
let emit_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "event_emit")
|
|
.expect("event_emit result missing");
|
|
let emit_json: serde_json::Value =
|
|
serde_json::from_str(&emit_result.1).expect("event_emit result should be valid JSON");
|
|
assert!(
|
|
emit_json["fired_routines"].as_u64().unwrap_or(0) > 0,
|
|
"event_emit should have fired at least one grouped routine: {:?}",
|
|
emit_result.1
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 10: skill_install_routine_webhook_sim
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn skill_install_routine_webhook_sim() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json"
|
|
))
|
|
.expect("failed to load skill_install_routine_webhook_sim.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_skills()
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Install the workflow skill template and simulate a webhook routine run")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, _)| n == "skill_install"),
|
|
"skill_install should be called: {completed:?}"
|
|
);
|
|
for tool in &["routine_create", "event_emit", "routine_history"] {
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == tool && *ok),
|
|
"{tool} should succeed: {completed:?}"
|
|
);
|
|
}
|
|
|
|
let results = rig.tool_results();
|
|
let emit_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "event_emit")
|
|
.expect("event_emit result missing");
|
|
assert!(
|
|
emit_result.1.contains("fired_routines"),
|
|
"event_emit should include fired_routines: {:?}",
|
|
emit_result.1
|
|
);
|
|
|
|
let _history_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "routine_history")
|
|
.expect("routine_history result missing");
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: job_create_status
|
|
// -----------------------------------------------------------------------
|
|
// Uses {{call_cj_1.job_id}} template to forward the dynamic UUID from
|
|
// create_job's result into job_status's arguments.
|
|
|
|
#[tokio::test]
|
|
async fn job_create_status() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/job_create_status.json"
|
|
))
|
|
.expect("failed to load job_create_status.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a job and check its status").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// Both tools should have succeeded.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "create_job" && *ok),
|
|
"create_job should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "job_status" && *ok),
|
|
"job_status should succeed: {completed:?}"
|
|
);
|
|
|
|
// Verify tool results contain expected content.
|
|
let results = rig.tool_results();
|
|
let create_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "create_job")
|
|
.expect("create_job result missing");
|
|
assert!(
|
|
create_result.1.contains("job_id"),
|
|
"create_job should return a job_id: {:?}",
|
|
create_result.1
|
|
);
|
|
assert!(
|
|
create_result.1.contains("in_progress"),
|
|
"create_job should dispatch through the scheduler, not stay pending: {:?}",
|
|
create_result.1
|
|
);
|
|
assert!(
|
|
!create_result.1.contains("scheduler unavailable"),
|
|
"create_job should not fall back to the unscheduled path: {:?}",
|
|
create_result.1
|
|
);
|
|
let status_result = results
|
|
.iter()
|
|
.find(|(n, _)| n == "job_status")
|
|
.expect("job_status result missing");
|
|
assert!(
|
|
status_result.1.contains("Test analysis job"),
|
|
"job_status should return the job title: {:?}",
|
|
status_result.1
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8a: command_job_fails_fast_on_repeated_empty_tool_completions
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn command_job_fails_fast_on_repeated_empty_tool_completions() {
|
|
let trace = LlmTrace::single_turn(
|
|
"test-empty-tool-recovery-fail",
|
|
"(worker only)",
|
|
vec![
|
|
text_step(""),
|
|
text_step(""),
|
|
hinted_text_step("", "valid arguments"),
|
|
text_step(""),
|
|
hinted_text_step("", "Do not call any more tools in the next reply."),
|
|
],
|
|
);
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace)
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("/job reproduce empty tool completion loop")
|
|
.await;
|
|
let create_responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
let job_id = resolve_created_job_id(
|
|
&rig,
|
|
&create_responses,
|
|
"reproduce empty tool completion loop",
|
|
)
|
|
.await;
|
|
|
|
let job = wait_for_job_state(&rig, job_id, JobState::Failed).await;
|
|
assert_eq!(job.title, "reproduce empty tool completion loop");
|
|
|
|
let failure_reason = rig
|
|
.database()
|
|
.get_agent_job_failure_reason(job_id)
|
|
.await
|
|
.expect("get_agent_job_failure_reason should succeed")
|
|
.expect("failed job should persist a failure reason");
|
|
assert!(
|
|
failure_reason
|
|
.contains("repeatedly returned empty or malformed tool-completion responses"),
|
|
"unexpected failure reason: {failure_reason}"
|
|
);
|
|
assert!(
|
|
!failure_reason.contains("max iterations"),
|
|
"failure should not surface as iteration exhaustion: {failure_reason}"
|
|
);
|
|
|
|
assert_eq!(
|
|
rig.llm_call_count(),
|
|
5,
|
|
"worker should stop after the bounded recovery flow"
|
|
);
|
|
assert!(
|
|
!rig.collect_metrics().await.hit_iteration_limit,
|
|
"bounded recovery should stop before iteration-limit reporting"
|
|
);
|
|
|
|
let requests = rig.captured_llm_requests();
|
|
assert!(
|
|
requests_contain(&requests, "call it now with valid arguments"),
|
|
"expected targeted tool-mode recovery nudge in worker requests"
|
|
);
|
|
assert!(
|
|
requests_contain(&requests, "Do not call any more tools in the next reply."),
|
|
"expected forced text-only recovery prompt in worker requests"
|
|
);
|
|
|
|
rig.clear().await;
|
|
rig.send_message(&format!("/status {}", job_id)).await;
|
|
let status_responses = rig.wait_for_responses(1, Duration::from_secs(5)).await;
|
|
assert!(
|
|
status_responses[0].content.contains("Status: Failed"),
|
|
"unexpected status response: {:?}",
|
|
status_responses[0].content
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8b: command_job_text_recovery_can_complete
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn command_job_text_recovery_can_complete() {
|
|
let trace = LlmTrace::single_turn(
|
|
"test-empty-tool-recovery-success",
|
|
"(worker only)",
|
|
vec![
|
|
text_step(""),
|
|
text_step(""),
|
|
hinted_text_step("", "valid arguments"),
|
|
text_step(""),
|
|
hinted_text_step(
|
|
"The job is complete. I finished the requested work and there is nothing left to do.",
|
|
"Do not call any more tools in the next reply.",
|
|
),
|
|
],
|
|
);
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace)
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("/job recover after malformed tool completions")
|
|
.await;
|
|
let create_responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
let job_id = resolve_created_job_id(
|
|
&rig,
|
|
&create_responses,
|
|
"recover after empty tool completions",
|
|
)
|
|
.await;
|
|
|
|
let job = wait_for_job_state(&rig, job_id, JobState::Completed).await;
|
|
assert_eq!(job.title, "recover after malformed tool completions");
|
|
|
|
assert_eq!(
|
|
rig.llm_call_count(),
|
|
5,
|
|
"worker should complete within the bounded recovery flow"
|
|
);
|
|
|
|
let requests = rig.captured_llm_requests();
|
|
assert!(
|
|
requests_contain(&requests, "call it now with valid arguments"),
|
|
"expected targeted tool-mode recovery nudge in worker requests"
|
|
);
|
|
assert!(
|
|
requests_contain(&requests, "Do not call any more tools in the next reply."),
|
|
"expected forced text-only recovery prompt in worker requests"
|
|
);
|
|
|
|
rig.clear().await;
|
|
rig.send_message(&format!("/status {}", job_id)).await;
|
|
let status_responses = rig.wait_for_responses(1, Duration::from_secs(5)).await;
|
|
assert!(
|
|
status_responses[0].content.contains("Status: Completed"),
|
|
"unexpected status response: {:?}",
|
|
status_responses[0].content
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 9: job_list_cancel
|
|
// -----------------------------------------------------------------------
|
|
// Uses {{call_cj_lc.job_id}} template to forward the dynamic UUID from
|
|
// create_job into cancel_job.
|
|
|
|
#[tokio::test]
|
|
async fn job_list_cancel() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/job_list_cancel.json"
|
|
))
|
|
.expect("failed to load job_list_cancel.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Create a job, list jobs, then cancel it")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// All three tools should have succeeded.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "create_job" && *ok),
|
|
"create_job should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "list_jobs" && *ok),
|
|
"list_jobs should succeed: {completed:?}"
|
|
);
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "cancel_job" && *ok),
|
|
"cancel_job should succeed: {completed:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: http_get_with_replay
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn http_get_with_replay() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/http_get_replay.json"
|
|
))
|
|
.expect("failed to load http_get_replay.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("Make an http GET request").await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// HTTP tool should have succeeded with the replayed exchange.
|
|
let completed = rig.tool_calls_completed();
|
|
assert!(
|
|
completed.iter().any(|(n, ok)| n == "http" && *ok),
|
|
"http tool should succeed: {completed:?}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test: tool_info_discovery (three-level detail)
|
|
// -----------------------------------------------------------------------
|
|
// Verifies the tool_info built-in returns:
|
|
// - Default (no include_schema): name, description, parameter names array
|
|
// - `detail: "summary"`: curated summary guidance
|
|
// - With include_schema: true: adds full typed JSON Schema
|
|
|
|
#[tokio::test]
|
|
async fn tool_info_discovery() {
|
|
let trace = LlmTrace::from_file(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/llm_traces/tools/tool_info_discovery.json"
|
|
))
|
|
.expect("failed to load tool_info_discovery.json");
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("What is the schema for the echo and time tools?")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
// tool_info should have been called three times (echo + routine_create + time), all succeeding.
|
|
let completed = rig.tool_calls_completed();
|
|
let tool_info_calls: Vec<_> = completed.iter().filter(|(n, _)| n == "tool_info").collect();
|
|
assert_eq!(
|
|
tool_info_calls.len(),
|
|
3,
|
|
"Expected 3 tool_info calls, got {tool_info_calls:?}"
|
|
);
|
|
assert!(
|
|
tool_info_calls.iter().all(|(_, ok)| *ok),
|
|
"All tool_info calls should succeed: {tool_info_calls:?}"
|
|
);
|
|
|
|
// Verify the results contain expected fields.
|
|
let results = rig.tool_results();
|
|
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
|
|
let info_json: Vec<serde_json::Value> = info_results
|
|
.iter()
|
|
.map(|(_, preview)| {
|
|
serde_json::from_str(preview)
|
|
.expect("tool_info result preview should be valid JSON")
|
|
})
|
|
.collect();
|
|
|
|
// First call was for "echo" (default, no include_schema) — result should
|
|
// contain "echo" and "parameters" as an array of names (not full schema).
|
|
let echo_json = info_json
|
|
.iter()
|
|
.find(|info| info["name"] == "echo")
|
|
.expect("tool_info result should contain 'echo'");
|
|
assert!(
|
|
echo_json["parameters"]
|
|
.as_array()
|
|
.is_some_and(|params| params.iter().any(|param| param == "message")),
|
|
"echo default result should list 'message' parameter name: {:?}",
|
|
echo_json
|
|
);
|
|
// Default mode should NOT include the full "schema" key
|
|
assert!(
|
|
echo_json.get("schema").is_none(),
|
|
"Default tool_info should not include schema field: {:?}",
|
|
echo_json
|
|
);
|
|
|
|
// Second call was for "routine_create" with detail: "summary" — result
|
|
// should contain a summary object with rules/examples.
|
|
let routine_json = info_json
|
|
.iter()
|
|
.find(|info| info["name"] == "routine_create")
|
|
.expect("tool_info result should contain 'routine_create'");
|
|
assert!(
|
|
routine_json.get("summary").is_some(),
|
|
"detail: summary should include summary field: {:?}",
|
|
routine_json
|
|
);
|
|
assert!(
|
|
routine_json["summary"]["conditional_requirements"]
|
|
.as_array()
|
|
.is_some_and(|rules| rules.iter().any(|rule| {
|
|
rule.as_str()
|
|
.is_some_and(|rule| rule.contains("request.kind='cron'"))
|
|
})),
|
|
"routine_create summary should mention cron requirement: {:?}",
|
|
routine_json
|
|
);
|
|
|
|
// Third call was for "time" with include_schema: true — result should
|
|
// contain "time", "schema" field with full object.
|
|
let time_json = info_json
|
|
.iter()
|
|
.find(|info| info["name"] == "time")
|
|
.expect("tool_info result should contain 'time'");
|
|
assert!(
|
|
time_json.get("schema").is_some(),
|
|
"include_schema: true should include schema field: {:?}",
|
|
time_json
|
|
);
|
|
assert!(
|
|
time_json["schema"]["properties"].is_object(),
|
|
"schema should have properties: {:?}",
|
|
time_json
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_info_clarifies_message_and_channel_setup_roles() {
|
|
let trace = LlmTrace::new(
|
|
"test-tool-info-channel-message-clarity",
|
|
vec![TraceTurn {
|
|
user_input: "How do message and channels differ?".to_string(),
|
|
steps: vec![
|
|
TraceStep {
|
|
request_hint: None,
|
|
response: TraceResponse::ToolCalls {
|
|
tool_calls: vec![TraceToolCall {
|
|
id: "call_tool_info_message".to_string(),
|
|
name: "tool_info".to_string(),
|
|
arguments: serde_json::json!({"name": "message"}),
|
|
}],
|
|
input_tokens: 100,
|
|
output_tokens: 20,
|
|
},
|
|
expected_tool_results: Vec::new(),
|
|
},
|
|
TraceStep {
|
|
request_hint: None,
|
|
response: TraceResponse::ToolCalls {
|
|
tool_calls: vec![TraceToolCall {
|
|
id: "call_tool_info_tool_search".to_string(),
|
|
name: "tool_info".to_string(),
|
|
arguments: serde_json::json!({"name": "tool_search"}),
|
|
}],
|
|
input_tokens: 140,
|
|
output_tokens: 20,
|
|
},
|
|
expected_tool_results: Vec::new(),
|
|
},
|
|
TraceStep {
|
|
request_hint: None,
|
|
response: TraceResponse::Text {
|
|
content: "I checked both tool descriptions.".to_string(),
|
|
input_tokens: 220,
|
|
output_tokens: 30,
|
|
},
|
|
expected_tool_results: Vec::new(),
|
|
},
|
|
],
|
|
expects: Default::default(),
|
|
}],
|
|
);
|
|
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_auto_approve_tools(true)
|
|
.build()
|
|
.await;
|
|
|
|
rig.send_message("How do message and channels differ?")
|
|
.await;
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
|
|
let results = rig.tool_results();
|
|
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
|
|
assert_eq!(info_results.len(), 2, "Expected two tool_info results");
|
|
|
|
let info_json: Vec<serde_json::Value> = info_results
|
|
.iter()
|
|
.map(|(_, preview)| {
|
|
serde_json::from_str(preview)
|
|
.expect("tool_info result preview should be valid JSON")
|
|
})
|
|
.collect();
|
|
|
|
let message_json = info_json
|
|
.iter()
|
|
.find(|info| info["name"] == "message")
|
|
.expect("tool_info result should contain 'message'");
|
|
let message_description = message_json["description"]
|
|
.as_str()
|
|
.expect("message description should be a string");
|
|
assert!(
|
|
message_description.contains("Use normal assistant output to reply"),
|
|
"message description should distinguish normal replies: {message_description}"
|
|
);
|
|
assert!(
|
|
message_description.contains("proactive notifications"),
|
|
"message description should describe proactive sends: {message_description}"
|
|
);
|
|
|
|
let tool_search_json = info_json
|
|
.iter()
|
|
.find(|info| info["name"] == "tool_search")
|
|
.expect("tool_info result should contain 'tool_search'");
|
|
let tool_search_description = tool_search_json["description"]
|
|
.as_str()
|
|
.expect("tool_search description should be a string");
|
|
assert!(
|
|
tool_search_description.contains("`tool_install`")
|
|
&& tool_search_description.contains("its tools become directly callable"),
|
|
"tool_search description should describe install + direct-callable post-#3133 \
|
|
contract: {tool_search_description}"
|
|
);
|
|
assert!(
|
|
tool_search_description
|
|
.to_ascii_lowercase()
|
|
.contains("use the `message` tool for proactive outbound sends"),
|
|
"tool_search description should point outbound sends to message: {tool_search_description}"
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
}
|