Skip to content

Latest commit

 

History

History
1114 lines (878 loc) · 61.9 KB

manual-testing.md

File metadata and controls

1114 lines (878 loc) · 61.9 KB

Manual Testing Guide

Audience & purpose: contributors doing pre-release or post-change QA — a companion to development.md, the developer manual. Not for end users; if you're looking for how to use Crew rather than verify a change to it, see user-guide.md.

Every automated suite (bun run check) runs without a model call and without a human watching a screen. Some things can only be verified by actually running omp, calling a tool with a real model, and looking at what comes back — this document is the complete, current list of those checks: what to run, in what order, exactly what you should see, and what it means if you don't.

Run these after any change that touches the daemon lifecycle, the IPC layer, an orchestration RPC method, an OMP tool, or the monitor — the automated suites can tell you a function returns the right value; only these checks can tell you the whole system, wired together, still behaves the way architecture.md says it does.

How to use this document

  1. Adding a check: append a new numbered section (or a lettered sub-step under an existing one) once a change needs a check no existing section covers — what to run, in what order, exactly what you should see, and what it means if you don't, matching the existing sections' shape.
  2. Revisiting: when the behavior a check verifies changes, rewrite that check in place — command, expected output, and all — rather than leaving the stale version next to a new one; a check that describes behavior the system no longer has is worse than no check.
  3. Retiring: when a check no longer applies (the feature it verified was removed or superseded), remove it from this document and record the retirement in engineering-lessons.md or an ADR, the same rule future-features.md uses for its own entries.

This document is the complete, current list of these checks — pruning it when a check goes stale is separate work from writing this rule down, and has not happened yet.

Topic index

Prerequisites

Same as development.md: Rust stable, Bun 1.3.14+, omp ≥ 17.0.7 on your PATH. Build both sides first:

bun run setup   # installs JS deps + builds the crewd runtime
bun run build   # bundles the OMP extension to dist/index.js, loaded below

Environment variables and configuration

Several environment variables control Crew's behavior. Set these once per shell session:

# Override the state directory location (must be absolute)
export CREW_STATE_DIR=/path/to/state

# Vendor CLIs (claude, codex, copilot, the local omp model server) are ordinary installed
# dependencies, and this variable forbids the observation-only invocations -- live conformance
# suites, the availability probe, #[ignore]d live tests. Only the exact value "1" disables; "0",
# empty, and absent all permit.
#
# You do not normally need to set it. `.cargo/config.toml`'s [env] block sets it for every
# CARGO-LAUNCHED process, so `cargo test` is already gated and a forgotten prefix can no longer
# spawn a vendor binary. Setting it in your shell is only needed for something cargo did not
# start. To run live conformance deliberately, turn it off by VALUE, because [env] means it is
# never absent under cargo:
#   CREW_DISABLE_VENDOR_CLI=0 CREW_LIVE_CWD=<trusted-dir> cargo test --test conformance
#
# It gates the conformance harness and test suite ONLY. A running daemon's run/submit spawns the
# real vendor CLI regardless of this variable, so any live run through omp/crewd in the sections
# below is a real, possibly billed, vendor launch:
export CREW_DISABLE_VENDOR_CLI=1

# Path override for the crewd binary (bypasses packaged binary discovery)
export OMP_CREW_BINARY="$PWD/target/debug/crewd"

State directory resolution (in precedence order) — applied identically whether the extension resolves --state-dir before spawning crewd, or you omit --state-dir on a bare crewd invocation (StateRoot::resolve, crates/runtime/src/security/mod.rs):

  1. CREW_STATE_DIR (must be absolute)
  2. $XDG_STATE_HOME/omp/crew when XDG_STATE_HOME is set (must be absolute) -- or its legacy $XDG_STATE_HOME/omp/batman sibling, if only that one exists
  3. $HOME/${PI_CONFIG_DIR:-.omp}/crew -- or its legacy $HOME/${PI_CONFIG_DIR:-.omp}/batman sibling, if only that one exists

Configuration file locations (in precedence order, lowest to highest). The CLI itself has no auto-discovery and no CREW_CONFIG-style environment variable — each layer is loaded only from a path passed explicitly via repeatable --config <path>; the extension resolves and passes these for you:

  1. User config — ~/.omp/crew.json
  2. Project config — <repo>/.omp/crew.json

Configuration files are strict JSON (crew.json, see architecture.md § Configuration and Policy) with unknown-key rejection at every depth, failing closed with the exact JSON path that named the unknown key. Example:

// ~/.omp/crew.json
{
  "limits": { "maxConcurrentWorkers": 4 },
  "retention": { "period": "30d", "maxRuns": 20 },
  "display": { "backend": "auto" },
  "security": {
    "patterns": [
      "AKIA[0-9A-Za-z]{16}",
      "sk-[a-zA-Z0-9]{32}"
    ]
  }
}

Security notes:

  • security.patterns is additive across layers (concatenated, never replaced), so a lower layer's redaction patterns can never be silently dropped by a higher one. An org pattern that fails to compile as a regex refuses the daemon's startup rather than degrading to built-in rules only.
  • Vendor CLIs are ordinary installed dependencies. CREW_DISABLE_VENDOR_CLI=1 forbids observation-only vendor invocation, and .cargo/config.toml's [env] block now sets it for every cargo-launched process, so cargo test is gated without anyone remembering; CI sets it explicitly as well. It does not gate a running daemon — run/submit spawns the real vendor CLI regardless, so an unattended run through omp/crewd can still make a billed model call.

Owning what you test

Two things get rebuilt independently — the crewd binary and the extension bundle (packages/extension/dist/index.js) — and it's easy to run a check below while actually still exercising the old build of either one. Before trusting any result in this document, make sure you're exercising the build you just made.

The extension side has two speeds. For fast iteration that keeps skills, use omp --extension ./packages/extension + bun run build — you get live source feedback immediately without losing delegation-guard rules or any other functionality. Do NOT point --extension directly at packages/extension/src/index.ts (even though it does run TypeScript directly); that silently drops all skills, rules, and other package-provided tools because a FILE path establishes no package root. The tradeoff (live edit cycle, but no provider ecosystem) is rarely worth the loss.

The daemon side does not restart itself. crewd runs detached, and OMP connects-or-spawns (ADR-0008, §1 below) — if a daemon started from your previous build of crewd is still alive, a fresh omp session just reconnects to it over its Unix socket. Rebuilding crewd and re-running a check without killing the old daemon first tests nothing new. After any Rust change:

crewd stop --repo "$PWD"   # or: pkill -f "crewd serve"

before your next omp invocation. --state-dir can be omitted here — the bare CLI resolves the same state root the extension does (see cli-reference.md's state-directory note) — but only pass it explicitly when your current shell's environment might not match the one the extension used to spawn the daemon (a different $CREW_STATE_DIR/$XDG_STATE_HOME, or none set in one shell and set in the other).

Confirm identity, don't assume it. §1's expected output includes Binary source: override — that's the extension reporting it resolved crewd via OMP_CREW_BINARY, not a downloaded release (platform.ts's resolveCrewd). If you ever see Binary source: package when you meant to test a local build, OMP_CREW_BINARY isn't set, or isn't pointing where you think.

Cheapest first check, no daemon needed. crew_doctor / /crew doctor (or cargo run -p crew-runtime -- doctor) verifies config parsing, the state directory, and rollout gates without spawning anything — run it before any section below when you just want to know "did I break something obvious," without paying for a daemon spawn or a model call.

Match the section to what you touched — you don't need to run every section every time:

You touched Run
IPC/lifecycle/journal/daemon startup §1
Event model or monitor rendering §2
An orchestration RPC method or tool §3
Adapter code (claude/codex/copilot/omp-rpc) §4b first (free); §4c only if the change could affect real vendor-CLI behavior
Workspace lease/apply/isolation §5
run/result / redaction / read-back §6
Run completion, turn-end settlement, follow-ups, the subagent guard §7
Dashboard or /crew health surface §8
Monitor resilience to daemon death §2a

1. The daemon through OMP (no model call, no extension CLI needed)

The daemon is tested through the OMP extension, which handles the full lifecycle: connecting to an existing daemon or spawning a new one (the connect-or-spawn design, ADR-0008). This is the lowest layer you can actually exercise end-to-end.

export OMP_CREW_BINARY="$PWD/target/debug/crewd"
EXT="$PWD/packages/extension"

omp --extension "$EXT" --print "/crew health"

Expect:

Crew runtime: running
Protocol: 1.0 (healthy: true)
Project: 18f82a46-....
Active runs: 0
Schema version: 1
Uptime: 0s
Binary source: override
Dashboard: http://127.0.0.1:<port>/?token=<token>

The Dashboard: line appears whenever the dashboard is enabled, and deliberately prints the full URL including its access token — one-click discoverability was chosen over hiding the token (see the comment above formatStatus in packages/extension/src/status.ts and RuntimeStatus.dashboardUrl's own doc). Don't "fix" it; do treat transcripts containing it as carrying a bearer credential. §8 tests the dashboard itself.

Run it again — same command, same repo:

omp --extension "$EXT" --print "/crew health"

Expect the same Project id, with a higher Uptime. That's the connect-or-spawn design (ADR-0008) reconnecting to the daemon it just started, not spawning a second one. If you rebuilt crewd between these two invocations and still see the same Project id, that's the gotcha in "Owning what you test" above — you reconnected to the daemon from your old build, not the new one.

What this verifies: The extension's ensureRuntime() can derive the per-repo Unix socket path (SHA-256 of the canonical VCS root, <stateDir>/repos/<repoId>/runtime.sock), spawn crewd serve detached, connect with bounded exponential backoff, negotiate protocol v1.0, and serve JSON-RPC requests. The daemon's single-instance flock locking, journal-before-shutdown, and AdapterRegistry wiring into ServerConfig.run_driver all happen inside lifecycle::serve().

What you can verify here: The CLI's serve/status/stop/schema subcommands are fully implemented and can be used directly. You can test the daemon's file-locking behavior (exit code 73 on double-serve), detached logging to runtime.log, and idle-timeout exit through the CLI itself, not just through the extension.

Direct CLI testing (alternative to extension)

The crewd CLI now provides direct access to all daemon operations:

# Start a daemon in the foreground
crewd serve --repo /path/to/repo --state-dir /path/to/state --foreground

# Query a running daemon's status
crewd status --repo /path/to/repo --state-dir /path/to/state

# Stop a running daemon
crewd stop --repo /path/to/repo --state-dir /path/to/state

# Display runtime events (replay + live)
crewd monitor --repo /path/to/repo --state-dir /path/to/state

# Export events to JSONL
crewd audit export --repo /path/to/repo --state-dir /path/to/state --output events.jsonl

# Print the JSON Schema
crewd schema

# Print the version
crewd version

All commands accept --state-dir (defaults to .crew if it exists) and --repo (required). The serve command additionally accepts --idle-seconds (optional, makes the daemon exit after N seconds with no connections and no active runs) and --foreground (logs to stderr instead of runtime.log).

2. The embedded monitor (/crew slash command, no model call)

The OMP extension registers an embedded monitor driven by a pure model.ts reducer over EventEnvelopes and a render.ts formatter. It's accessed via the /crew slash command — or via the crewd monitor CLI subcommand.

# Through OMP extension (interactive)
omp --extension "$EXT"          # no --print: stays open, interactive

Type /crew. Expect the widget to appear above the editor, showing run rows. An empty state renders "No Crew runs yet."

What this verifies: The monitor controller subscribes to the daemon's event stream via CrewClient.subscribe(fromSequence, cb), persists lastSequence via a custom pi.appendEntry('crew-monitor', {sequence}) session entry for replay-on-restart, and updates the widget on every event. The controller handles session_start (connect, then show the widget only if the journal has runs — a run-free session stays hidden until the first run event, and a dead daemon stays silent) and session_shutdown (unsubscribe).

2a. Daemon death and widget healing (no model call)

With an interactive session open and at least one run row visible (the queued run from §3a works), kill the daemon out from under it: pkill -f "crewd serve". Then take any crew action in the still-open session (/crew health is enough). Expect the daemon to be respawned by connect-or-spawn and the widget's subscription to heal and keep updating without restarting omp. Through 0.6.0 the subscription died with the daemon and stayed dead until a full omp restart; a widget that freezes while a fresh crewd is demonstrably up is that regression.

Direct CLI monitor (alternative to extension)

crewd monitor --repo /path/to/repo --state-dir /path/to/state

This connects as a display principal, replays every event from sequence 0, renders one line per contributing envelope, then follows new events live until interrupted (SIGINT/SIGTERM). A transient disconnect reconnects and replays from the highest sequence already rendered plus one, so no visible line is duplicated.

Options:

  • --run-id <id> — Render only the run matching this id (full, un-truncated form)

What this verifies: The CLI's monitor command connects to the runtime, replays events, and renders them as plain-text lines until interrupted. This is the same logic as the embedded monitor but exposed as a CLI subcommand for direct testing.

3. The orchestration tools (needs a real model call)

The 11 orchestration tools (crew_profile, crew_worker, crew_task, crew_run, crew_workspace, crew_artifact, crew_child, crew_violation, crew_message, crew_approval, crew_reconcile — see user-guide.md for what each does) are regular OMP tools the model chooses to call — this genuinely needs a model, and each step below takes something like ten seconds to a couple of minutes. Work in a scratch repository, never this one:

mkdir -p /tmp/crew-smoke && cd /tmp/crew-smoke && git init -q && git commit -q --allow-empty -m init

3a. Create a task, a worker, and submit a run

omp --extension "$EXT" --print \
  'Use crew_task to upsert a task. Then use
   crew_worker to create a worker with fingerprint "sha256:smoke" and adapter "fake". Then use
   crew_run to submit a run for that task against that worker with prompt "smoke test".
   Report the taskId and workerId plainly.'

Expect the model to report a taskId and workerId, and to say run/submit failed with adapter_unavailable — and, importantly, that it can't report a runId from that call. That last part is correct, not a bug in the model: run/submit's error response is ServiceError { code, message }, with no data field at all, so the caller genuinely has no way to learn the run's id from that one call alone.

The run was still committed as queued underneath. Look it up with a second call, using the taskId from the response above:

omp --extension "$EXT" --print \
  'Use crew_run with op "list" and taskId "<taskId from above>" to find the run that was just
   submitted. Report the runId and state plainly.'

Expect state: queued. The run is preserved even though nothing could start it — run/submit never pretends a run started that it can't back, and it never drops the run just because no adapter exists to run it (ADR-0013).

3b. Watch it live — two processes, on purpose

Open an interactive session and leave it running. This is a different invocation from the --print calls above, and it matters that it stays open for the rest of this step:

omp --extension "$EXT"          # no --print: stays open, interactive

Type /crew. Expect one line, replayed from the daemon's journal the instant this session started — it never touched the task/worker/run above, this is a brand-new session:

<runId-prefix> · queued · run queued

Now, without closing that session, open a second terminal and run the message-send call there — a separate, short-lived process that connects to the same daemon and exits on its own:

omp --extension "$EXT" --print \
  'Use crew_message to send a "question" on runId "<runId from 3a>" from workerId
   "<workerId from 3a>", taskId "<taskId from 3a>", payload "should I proceed?".'

Go back to the first terminal — the one you never touched during that second call — and look at it again. Expect it to have updated on its own, with zero input from you:

<runId-prefix> · queued · messageRecorded recorded

That's the live-broadcast path: the first session was already subscribed to the daemon's event stream, and the message-send (from a different process) got pushed to it over the socket it already had open — no reconnect, no re-typed /crew, no polling.

Only the trailing "latest activity" field changes here; the run's own state stays queued throughout, because this scenario never starts an adapter. A real crew_run against a configured worker profile walks queued -> starting -> working; when the worker's turn ends, the run moves to waitingUser and is handed back to the leader — a run is a conversation the leader closes (ADR-0027). It terminalizes through crew_run op finish (or cancel), and a vendor process that dies with no observable outcome is settled lost by the backstop — never succeeded (crates/runtime/src/adapter/run_lifecycle.rs; §7 exercises this live).

3c. Replay after a full restart

Close the first session entirely (Ctrl+C or /exit) and start a third, completely fresh one:

omp --extension "$EXT"

Type /crew again. Expect the same final line, replayed cold by a session that has never seen any of this before:

<runId-prefix> · queued · messageRecorded recorded

Nothing is lost, nothing duplicates. This is a genuinely different property from 3b's live-broadcast test — 3b required the watching session to stay open the whole time; this one requires it to be fully torn down and restarted. Both must hold; neither one proves the other.

3d. What this walkthrough can't cover

Approval creation (ApprovalService::request) is only ever invoked by an adapter reporting it needs human sign-off, and there is no approval/request RPC method — so there is no way to trigger it from a live omp session. Exercise that half of the flow with:

cargo test -p crew-runtime --test approval

which drives ApprovalService directly, the same way this walkthrough can't.

3e. Adapter model selection and persistence

crew_profile registered with an adapter that has no model configured yet never accepts a model as a decision, whatever the caller passed: in an interactive session, crew opens the model-selection dialog itself and asks the human, offering the current models for that adapter (omp's own catalogue, or the vendor's own model family table when omp has no catalogue entry for that provider — see below for which one you'll see). A leader-supplied model only preselects a row in that list if it resolves to one of the options shown; the human's own pick is what actually gets registered and persisted to the repository's .omp/crew.json. Later registrations for that adapter reuse the stored answer silently, no dialog.

Without an interactive UI attached, or if the dialog times out with no answer, or if there is nothing to offer (no catalogue entry and no vendor family table for that adapter), registration is refused with a typed model-not-configured error — nothing is registered or persisted. ompRpc and any caller-defined adapter have no catalogue and no dialog either way: an explicit model is used exactly as given (this is unchanged from before the dialog existed), and omitting it is what triggers the same model-not-configured error for them.

Once a model IS configured for an adapter, an explicit model on a later call is never a second decision — it is resolved against omp's own model catalogue (omp models ls --json, read fresh every call, never refreshed over the network) plus a small per-adapter table of vendor-defined aliases (packages/extension/src/models.ts), and the result is only ever compared to the stored answer. There are five outcomes; each is deterministic and free to exercise (write .omp/crew.json yourself first, then just crew_profile calls — no model call, no dialog):

You call crew_profile with... Resolves to What you see
An id already exact in the catalogue, e.g. claude-opus-5 Itself Registered silently — no note, nothing persisted differently from before model resolution was added (ADR-0032)
A vendor alias, e.g. haiku for claude claude-haiku-4-5 model: claude-haiku-4-5 (resolved "haiku" via claude's own alias table)
A shorthand matching exactly one catalogue id, e.g. sol for codex gpt-5.6-sol model: gpt-5.6-sol (resolved "sol" -- the only openai-codex model matching it)
A shorthand matching several catalogue ids Nothing — refused "<input>" matches N models for adapter <adapter>: <id>, <id>, ... -- name one of them exactly. (a typed model-ambiguous error; nothing is registered or persisted)
A name omp's catalogue does not know (typo, or a genuinely new/unlisted model) Used as given, not persisted model: <name> (not in omp's catalogue for <provider>; passing through UNVERIFIED -- the vendor will reject it if it is wrong)

The same resolution also decides which dialog row a leader's suggestion preselects on first use: a suggestion that resolves exact, alias, or match preselects that row; one that resolves ambiguous or unverified (or doesn't resolve at all when the catalogue is down and it isn't an alias) preselects nothing, and the dialog still opens with every real option on offer — there is no way to preselect, let alone register, a name nothing could confirm.

The alias table itself is small and vendor-sourced (claude: fable/opus/sonnet/haiku, read from the installed binary's own latest_per_family config, not its --help text; codex: the single documented gpt-5.6 → gpt-5.6-sol; copilot: none, since it defines no aliases crew can verify) — see the doc comment on VENDOR_ALIASES in models.ts before assuming it needs a new entry for some other shorthand. A test in models.test.ts reads this table's claude half back out of the installed claude binary's own baked catalogue and fails (or skips, visibly, if the binary isn't present) when they've drifted apart.

When omp's catalogue has nothing for an adapter's provider (e.g. no anthropic credentials in omp, so claude has none, even though the claude binary itself works fine), the dialog falls back to that same alias table's values instead of refusing outright — currentModels in models.ts names which source answered, and the dialog says so in its title.

Persistence follows verification, not success. An exact, alias, or match resolution is confirmed and gets written to .omp/crew.json on first use, same as before model resolution was added — and so is a dialog pick, which is a member of a verified list by construction. An unverified name (only reachable once a model is already configured, comparing an explicit override to it) runs but is deliberately not re-recorded.

The existing conflict check still applies, but now compares canonical ids, not raw spelling — a stored claude-opus-5 and an explicit opus are recognized as the same model and accepted as a no-op, not refused. A request that resolves to a genuinely different model than the one stored is still refused with the same typed error as before:

model already configured as <stored> for adapter <adapter> -- crew_profile never overwrites a
stored model; edit the repository's .omp/crew.json directly to change it (/crew config path
locates it).

configuredModel in that message is always the raw text from .omp/crew.json (the file the correction path points at), never the resolved canonical id — even when the stored value is itself a shorthand. A conflict is refused, never silently overwritten and never silently dropped (packages/extension/src/tools/profiles.ts, decideModel/resolveModelName in models.ts; see models.test.ts for worked examples of each outcome above).

Clean up

Use crewd stop to gracefully shut down the daemon:

crewd stop --repo /tmp/crew-smoke --state-dir /tmp/crew-state

Or, if the daemon is not responding:

# Find the daemon process:
pgrep -fl crewd

# Kill it if still running:
pkill -f "crewd serve"

# Remove the scratch repo:
rm -rf /tmp/crew-smoke

4. Worker adapters

Steps 1-3 never spawn a real Claude/Codex/Copilot/OMP-RPC process. This section covers the four supervised adapters, their conformance suites, and the worker coordination MCP surface.

The crewd conformance and crewd adapters CLI subcommands (see cli-reference.md) run the same fixture/live suites as the cargo test commands below and write a JSON report; crewd adapters --json is the quick one-shot check that every adapter's fixture suite still passes. Use the CLI when you want a report file or to check outside a Rust dev environment; use cargo test (below) when you want the integration test harness's own assertions and #[ignore]/live gating.

4a. Prerequisites

Four vendor CLIs, plus everything from the top-level Prerequisites above:

claude --version   # prints a version and confirms the CLI is authenticated -- do not pin the
                    # number here; it drifted from 2.1.251 to 2.1.263 in one week during wave 2
codex --version    # verified baseline: codex-cli 0.145.0 (exact match required for the
                    # schema-compatibility check — see 4b)
copilot --version  # verified baseline: GitHub Copilot CLI 1.0.73 (1.0.75 verified to work)
omp --version       # verified baseline: omp/17.0.7 (17.1.1 verified to work)

None of these baselines are a hard requirement — the conformance test suites measure what the installed CLI actually supports rather than trusting the version string; a newer patch version that still passes every fixture scenario is fine. Codex is the one exception: its adapter checks the installed binary's own generated JSON-RPC schema against a committed compatibility manifest, so an incompatible schema change (not just a version bump) fails that one check specifically, independent of everything else.

OMP_CREW_BINARY (the same override from the top-level Prerequisites) is how you point a real omp session at your dev build rather than a packaged release — set it once per shell:

export OMP_CREW_BINARY="$PWD/target/debug/crewd"

Build the daemon:

cargo build -p crew-runtime

4b. Per-adapter smoke, fixture mode (no model call)

Fixture mode runs the conformance suites against committed JSONL fixtures under fixtures/adapters/<name>/ — zero model calls, zero vendor CLI invocations. Run via cargo test:

# All four adapters, fixture mode (crewd CLI, black-box):
cargo test -p crew-runtime --test conformance

# claude-tui's own committed fixture (`fixtures/adapters/claude-tui/`):
cargo test -p crew-runtime --test claude_tui_fixture

Fixture mode is TUI-sourced now (crew-v2 gap-closure; see ADR-0026) — the headless control plane this section used to also exercise via a per-adapter test file (claude_adapter/codex_adapter/copilot_adapter/omp_rpc_adapter) is retired; those files are deleted along with it. Each vendor's own scenario probes live under adapter::tui::*_conformance (exercised via conformance and tui_adapter above), not a standalone per-adapter test binary.

Expected shape (one array element per adapter for the full test; a single-element array otherwise):

[
  {
    "adapter": "claude-tui",
    "mode": "fixture",
    "version": "2.1.220",
    "declaredCapabilities": { "protocol": "structured", "resume": "session", ... },
    "effectiveCapabilities": { "protocol": "structured", "resume": "session", ... },
    "scenarios": [
      { "name": "probe", "passed": true, "detail": "claude --version reported ...; authReady=true" },
      { "name": "read_only_start_and_progress", "passed": true, "detail": "..." },
      ...
    ],
    "passed": true
  }
]

"Pass" for one adapter means top-level "passed": true — every entry in scenarios has its own "passed": true. effectiveCapabilities only ever narrows declaredCapabilities, never widens it: a scenario failure downgrades exactly the capability it disproves (e.g. a failed approval scenario forces approvals to "none") and leaves everything else untouched. If "passed": false anywhere, read that scenario's own detail first — it names concretely what failed, not just that something did.

Every adapter's fixture report should show "passed": true throughout, with these documented, intentional exceptions — genuine gaps or environment dependencies, reported honestly rather than papered over with a fabricated pass:

Adapter Scenario(s) Why
codex-tui follow_up, cancellation_scope, session_resume, runtime_restart The installed codex-cli does not write a thread's rollout file to disk until a turn actually runs — resuming/following up/cancelling a turn on a never-turned thread needs a real (billed) turn, which fixture mode must never make. Live mode (4f.1) proves what it can for real when its gate is set.
copilot-tui session_resume, runtime_restart The installed CLI (1.0.75) does not persist a never-prompted session across a process boundary — proving full persistence needs a real turn.
copilot-tui unexpected_child_observation ACP protocol v1 has no session/update variant this adapter maps to a nested-worker observation — a genuine, currently-unimplemented gap.

4c. Live mode (requires a real vendor CLI session; makes a real, billed model call)

The headless control plane this section used to test directly against a per-adapter test file (a real, billed call reached via cargo test --test claude_live -- --ignored, etc.) is retired (crew-v2 gap-closure; see ADR-0026) — those test files are deleted along with it. See 4f.1 below, the TUI live conformance harness (crewd conformance --live --mode tui), which is now the only live path against a real vendor CLI.

4d. AdapterRegistry wiring

AdapterRegistry (the RunDriver implementation this section's conformance suites feed into) is wired into the running daemon: lifecycle::serve()'s ServerConfig sets run_driver to an AdapterRegistry instance (cargo test -p crew-runtime --test adapter_registry exercises it directly).

However, whether the registry starts an adapter is still gated by PolicyEvaluator: the limits.maxConcurrentWorkers concurrency ceiling (from crew.json), and a nested-worker check that denies unexpected child workers pre-authorization. The wider org-governance surface this evaluator used to also enforce -- model/adapter allowlists, a required-capability list, cost ceilings, and the native_discovery_reviewed rollout gate -- is retired (crew-v2 gap-closure; see future-features.md): that surface was config-sourced from the YAML org layer removed in that work, which was never actually reachable in production.

Practically: submitting a run through a live omp session with a real adapter's vendor CLI installed will attempt to start the adapter as long as the concurrency ceiling isn't reached and the worker isn't an unexpected nested one. A denial names the dimension that refused (for example concurrency ceiling 8 reached; 8 active runs), and an absent vendor CLI still reports adapter_unavailable — a separate, availability-level answer.

To exercise the registry's own start/reject/authorize/construct logic directly:

cargo test -p crew-runtime --test adapter_registry

4e. Worker MCP coordination tools

The supervised path (a real adapter's vendor process calling crew_task/crew_send through its injected MCP config) is reachable from a live omp session once the vendor CLI is installed and the merged org policy permits the adapter and model. Exercising it costs real model calls, so the deterministic check below drives the same MCP server directly instead.

The MCP server side is now a real CLI subcommand (crewd coordination-mcp --state-dir <path> --repo <path> --run-id <id>, wired in 2026-08-02 — previously the argv every adapter's MCP config already built pointed at a subcommand that didn't exist, so coordination-mcp failed immediately with clap's unrecognized-subcommand error) and the scope-token-authenticated in-process/subprocess plumbing behind it are fully built and independently tested against a real compiled crewd binary, driven as a genuine MCP client would:

cargo test -p crew-runtime --test coordination_mcp

That suite spawns the real crewd coordination-mcp --state-dir ... --repo ... --run-id ... subprocess, drives it over real stdio exactly as a supervised vendor CLI's own MCP client would, and verifies crew_task/crew_peers/crew_send/crew_request_child/ crew_publish_artifact/crew_report_blocked/crew_ask_policy all land in a real CoordinationBroker behind a real Server — including the scope/authorization negative cases (missing, expired, wrong-run, post-vendor-exit, or unrelated-process credentials all fail; a verified descendant of the same live vendor process may reconnect).

4f. TUI pane attach + out-of-band input (journal check needs no model call)

All four adapters (claude, codex, copilot, omp-rpc) default to TUI mode: each worker runs as the real vendor CLI spawned on a PTY inside a pane owned by a display backend (herdr / tmux / terminal). A viewer (or the harness) can type into that pane. Every burst of keystrokes written to a pane is journaled as a RuntimeEvent::OutOfBandInput { backend, pane_ref } (Rust type notation; see below for wire format) — the keystrokes themselves are never recorded, only that input happened and on which pane — and the run's needsReconciliation flag is set. This is the redaction-boundary guarantee for interactive control: a human steering a live run leaves an auditable trace without leaking typed content.

Manual check (observing the journal needs no model call; only starting the run does):

export OMP_CREW_BINARY="$PWD/target/debug/crewd"
EXT="$PWD/packages/extension"

# Terminal A: start a daemon + a TUI run against one of the four adapters,
# then leave it open. See user-guide.md for the exact run tool.
omp --extension "$EXT"

# Terminal B: tail the journal for the OutOfBandInput event
crewd monitor --repo "$PWD" --state-dir "$HOME/.omp/crew" | grep -i OutOfBandInput

Attach to the run's pane via the active display backend (e.g. tmux attach -t <pane-ref> for tmux, or the herdr/terminal viewer), type a few characters, and confirm:

  • Terminal B shows one outOfBandInput event per pane-write burst, carrying only backend + paneRef — no keystroke text.
  • The run's needsReconciliation flips true (visible via /crew after a crew_reconcile, or crewd audit export --repo "$PWD" --state-dir "$HOME/.omp/crew" --output /tmp/audit.jsonl and grep for the flag).

Attach-socket liveness (CREWATTACH1): when testing /crew reopen or pane persistence, the attach socket must send CREWATTACH1\n as its very first bytes; a probe that doesn't see the marker within 250ms marks the pane stale and returns -32602 instead of attaching to a fork-inherited dead socket. See cli-reference.md § Attach Socket Liveness.

4f.1 TUI live conformance harness

crewd conformance --live --mode tui walks the scenario set against the real interactive vendor CLIs on a PTY (tui is the only accepted --mode value and its default — the headless control plane this also used to reach is retired, --mode headless is a typed rejection now, see ADR-0026). --adapter takes all or one of claude, codex, copilot, ompRpc; --output <path> writes the JSON report.

# billed model calls for claude/codex/copilot; omp-rpc reaches a model only when a turn runs
CREW_DISABLE_VENDOR_CLI=0 CREW_LIVE_CWD=/tmp/crew-smoke-proj \
  ./target/debug/crewd conformance --live --mode tui --adapter all --output /tmp/live.json

Observed per-vendor outcomes (this release):

  • probe — pass: real vendor CLI reachable, declared capabilities intact, adapter spawns it on a PTY.
  • cancellation_scope — pass: cancel(CancelScope::Worker) terminates the vendor process and a ProcessExited is journaled.
  • read_only_start_and_progress / follow_up — pass for claude and omp-rpc; codex passed both earlier the same day and is currently blocked by an explicit vendor credit wall (usage_limit_exceeded recorded in its rollout); copilot's turns are refused with an explicit monthly-quota error after submit+discovery were proven. The original failures were never a "vendor limitation": the adapter typed the prompt and its Enter as one atomic write at a fixed moment after spawn. Whether the vendor's TUI processes that Enter depends on where its render loop is when the bytes land -- too early (stdin not yet wired) or mid-layout and the CR is swallowed; machine load shifts that timeline, which is why identical bytes submitted on some runs and not others. The fix is in crates/runtime/src/adapter/tui/adapter.rs: two-phase delivery (text once stdin is wired, Enter only after ENTER_IDLE_MIN output silence), plus a 150ms text-to-Enter gap on queue-style sends (an atomic text\r is swallowed whole by codex). If a scenario fails, check the vendor's own session store first: ~/.claude/projects/, ~/.codex/sessions/, ~/.copilot/session-state/ (note: current copilot versions write <session-id>/events.jsonl inside per-session directories, not flat files), ~/.omp/agent/sessions/<raw-cwd-slug>/ (omp slugs the cwd as given; it does not resolve /tmp to /private/tmp the way claude does). A rollout/session file containing your prompt but no assistant reply means billing, not the adapter.
  • session_resume — skipped: a single-process resume is not a daemon restart; genuine restart recovery is proven by the separate serve→stop→serve end-to-end smoke, not this report.

4f.2 Pane creation failures and downgrading (no model call needed)

When a requested display backend or placement is not available, the runtime attempts to downgrade to the hidden backend instead of failing the run. This downgrade is journaled as a paneDowngraded event (note camelCase in the wire format) carrying:

  • requestedBackend and requestedPlacement: what the run asked for
  • actualBackend: the backend that was used instead (always hidden on downgrade)
  • reason: a redacted error message explaining why creation failed

To observe this behavior, attempt to attach a run with a backend/placement combination the current environment doesn't support (e.g., request Workspace placement on tmux, or run in an environment with no active display server). The run should proceed with hidden backend, and the journal should contain one paneDowngraded event.

Verify via crewd audit export --repo "$PWD" --state-dir "$HOME/.omp/crew" --output /tmp/audit.jsonl and grep for paneDowngraded:

grep paneDowngraded /tmp/audit.jsonl

What this verifies: display backend selection is resilient — when a pane cannot be created at the requested backend, the runtime logs the failure and falls back gracefully instead of failing the run.

5. Cross-agent workspace isolation (requires a real adapter)

This section verifies that two parallel runs execute in separate git worktrees, each with its own isolated workspace. It exercises run/submit with workspaceMode: "isolated", the two-phase lease acquisition (allocating → materialize → activate), and the crew_peer_workspace coordination tool for cross-workspace review.

5a. Prerequisites

Everything from §4a above, with an org policy that permits the adapters and models you intend to run, and the daemon built and ready:

export OMP_CREW_BINARY="$PWD/target/debug/crewd"

mkdir -p /tmp/crew-cross-agent && cd /tmp/crew-cross-agent && git init -q && git commit -q --allow-empty -m init

5b. Register profiles and create workers

Use crew_profile to register two profiles (one per adapter), then crew_worker to create workers with those profileIds:

omp --extension "$EXT" --print \
  'Use crew_profile to register a Claude profile with adapter "claude", model "<your model>",
   source "manual-test", and startupOptions {"claude":{}}. Then register a second profile for
   "codex" with startupOptions {"codex":{}}. Report both profileIds plainly.'

Then create two workers, each with one profileId:

omp --extension "$EXT" --print \
  'Use crew_worker to create two workers, one with profileId "<profileId1>" and one with
   profileId "<profileId2>". Report both workerIds plainly.'

5c. Submit two concurrent isolated runs

Create a task, then submit two runs with workspaceMode: "isolated":

omp --extension "$EXT" --print \
  'Use crew_task to upsert a task.
   Then use crew_run to submit two runs: one for workerId "<workerId1>" and one for
   workerId "<workerId2>", both with the same taskId, workspaceMode "isolated", and prompt
   "Create a file hello.txt containing your adapter name". Report both runIds and workspacePaths
   plainly.'

Expected: Both run/submit calls return Ok with distinct runIds and distinct workspacePaths, each of the form /tmp/crew-workspace-<projectId>/<runId>. Confirm on disk:

git -C /tmp/crew-cross-agent worktree list

Both workspace paths should appear as detached worktrees.

5d. Poll runs to completion

omp --extension "$EXT" --print \
  'Use crew_run with op "get" for runId "<runId1>" and runId "<runId2>". Report each run
   state and workspacePath plainly.'

Each response must carry workspacePath and workspaceMode: "gitWorktree".

5e. Cross-workspace review via crew_peer_workspace

Verify the worker coordination surface can resolve a peer's workspace. From the worker MCP side, crew_peer_workspace { peerRunId: "<other runId>" } returns the peer's path, isolationKind, and state. A call with a runId belonging to a different task fails with "peerRunId is not a run on this task".

5f. Clean up

Release both workspaces via crew_workspace with op: "release", then verify the worktrees are gone:

git -C /tmp/crew-cross-agent worktree list

The worktrees should no longer appear. Clean up the scratch directory:

rm -rf /tmp/crew-cross-agent

6. Reading a finished run's output (run/result — needs a real model call)

Verifies Gap 2 of the multiagent-cooperation design: the model can read a worker's final answer and chain it into a second run. Work in a scratch repository:

mkdir -p /tmp/crew-result-smoke && cd /tmp/crew-result-smoke && git init -q && git commit -q --allow-empty -m init

Resolved (ADR-0027, 2026-08 fix wave): through 0.6.0, completion was keyed solely on process exit — and since the adapter deliberately keeps the vendor CLI alive after its final answer, a live run never finished on its own and this section required a manual cancel. Completion is now settled by the worker's turn end: the run moves to waitingUser with the pane still open, op: "result" is readable right there (a settled turn qualifies, terminal not required), and the leader closes the run with op: "finish". A live run still sitting in working after its answer has visibly arrived is a regression of the turn-end guard, not the old limitation.

6a. One run, one answer

omp --extension "$EXT" --print \
  'Use crew_profile to register a Claude profile (adapter "claude", a model of your choice,
   startupOptions {"claude":{}}, source "manual-test"), crew_worker to create a worker from
   that profileId, crew_task to upsert a task, and crew_run to submit a run with prompt
   "Reply with exactly the word pomegranate and nothing else". Poll crew_run op "get" until
   the state is waitingUser or terminal, then call crew_run op "result", report resultText and
   usage plainly, and close the run with op "finish".'

Expect: resultText containing exactly pomegranate; usage.inputTokens and usage.outputTokens both > 0; state waitingUser at read time, succeeded after the finish. Calling op: "result" while the run is still working is refused with run <id> is not finished (state: working) — that refusal is correct behavior, not a bug: a partial answer is never returned.

6b. Chaining: A's answer becomes B's prompt

omp --extension "$EXT" --print \
  'Call crew_run op "result" for runId "<runId from 6a>". Then submit a second run on the
   same worker and task whose prompt embeds that resultText: "The previous worker said:
   <resultText>. Reply with the fruit it named, uppercased." Poll it to waitingUser, read its
   result, finish it, and report the result plainly.'

Expect the second run's resultText to contain POMEGRANATE.

What this verifies: the full read-back path — journaled adapterMessageFinal → redaction boundary → run/result fold → Ajv-validated result → the model composing the next prompt from it. This is the chaining primitive every multi-worker synthesis flow builds on. Clean up as in §3's "Clean up".

7. Run completion semantics, live (ADR-0027 — needs a real model call)

Through 0.6.0, run completion was keyed on process exit — and since TUI adapters deliberately keep the vendor CLI alive after its final answer, a live run could never finish on its own (the first live test failed exactly here). ADR-0027 replaced that with turn-end settlement: a worker's finished turn hands the run back to the leader as waitingUser, and the run is a conversation the leader closes. Fixture mode (§4b) proves the parsing; only these checks prove the live behavior. Work in a scratch repository as in §3/§6.

7a. A finished turn settles the run; the pane stays open

Register a claude profile/worker/task and submit a short run (as in §6a). Expect, in order:

  • The pane attaches in your host (tmux → split, herdr → pane, plain terminal → a tab of that same terminal app) — pane attached: <backend> in /crew.
  • The run walks queued -> starting -> working.
  • When the answer lands, the run moves to waitingUser with the vendor CLI still running — nothing exits, and nothing needs to.
  • crew_run op finish (optional outcome succeeded | failed, default succeeded — the leader states the outcome, it is never inferred from the vendor's turn markers) terminalizes it.
  • The full submit prompt is in the journal, redacted (runPromptEvent, ADR-0028) — check /crew run <runId> or an audit export, never expect raw text.

A vendor process that dies with no observable outcome is settled lost by the backstop. Two routes to succeeded are legitimate and neither involves the turn: a clean zero exit (terminal_state_for, ADR-0023) and an explicit leader finish. A journaled turn end is not one of them — the check is that succeeded never appears without either a clean exit or a finish. A run that settles succeeded off the back of a turn end is the bug ADR-0027 exists to prevent, whether or not the turn end itself was journaled correctly.

7b. Follow-up steering into the live pane

On the waitingUser run from 7a (before finishing it), send a crew_message with kind followUp to its worker. Expect the payload to be delivered into the live pane as a follow-up turn (RunDriver::send_follow_up; delivery is two-phase — text once stdin is wired, Enter only after output silence), the run to return to working, and a re-settle to waitingUser when that turn ends. Every message kind delivers down this path; the one exception is steer, which needs the adapter's interrupt-then-compose capability and is refused with capability_unsupported on adapters without it — a typed refusal there is correct behavior, not a finding (crates/runtime/src/adapter/registry.rs).

When the run resumes (either from an explicit follow-up message or from a genuine user-authored turn in the vendor's transcript), a runResumed event is journaled with a cause field distinguishing the two paths (note camelCase in wire format):

  • cause: "followUpMessage" — the leader sent an explicit follow-up via crew_message
  • cause: "realUserTurn" — the vendor's transcript contained a genuine new user-authored entry (type "user", not a sidechain), not bookkeeping or metadata

Verify the event appears in the audit export:

crewd audit export --repo "$PWD" --state-dir "$HOME/.omp/crew" --output /tmp/audit.jsonl && grep runResumed /tmp/audit.jsonl

Expect one runResumed entry with the appropriate cause. This event documents what triggered resumption, so consumers (dashboards, tools, audit trails) can distinguish between user-directed follow-ups and resumptions triggered by new transcript content.

7c. A subagent's turn never settles the parent (isSidechain)

Submit a run whose prompt forces subagents, e.g. "Use your Task tool to dispatch two parallel subagents, each summarizing a different file; then combine their answers yourself." Expect:

  • The run stays working through every subagent's turn end — a session-file entry with isSidechain is a subagent's turn and must never settle the parent run (crates/runtime/src/adapter/tui/claude.rs; unit-proved by a_sidechain_entry_never_ends_the_parent_runs_turn, but only a live claude session dispatching real subagents proves the guard against real session files).
  • adapterNestedWorkerObserved appears in the run's activity.
  • Exactly one settlement, on the parent's own turn end. A waitingUser mid-dispatch is the guard failing, and a finding.

7d. Large prompts arrive whole

Submit a run whose prompt is well over 8 KB with the instruction at the very end (e.g. "reply with the final word of this prompt"). Expect the answer to prove the tail arrived, and the journal to carry the full redacted text. Through 0.6.0 a multi-KB prompt was silently truncated — one atomic PTY write; delivery is now two-phase with an idle-gated Enter (crates/runtime/src/adapter/tui/adapter.rs).

Timeout facts, when they occur, are decided with crew_run op timeoutAck (decision extend | nudge | abort); forcing one live requires waiting out the timeout sweep, so it's not part of the standard pass.

8. The dashboard (no model call)

/crew health prints the dashboard's full tokenized URL when the dashboard is enabled (§1). Checks:

  • Open the printed URL: the page loads, and the token disappears from the address bar — a valid ?token= on GET / is exchanged for the crew_dashboard cookie via a 303 redirect so the secret leaves the URL, browser history, and any Referer (crates/runtime/src/dashboard/mod.rs). The exchange is the design, not a leak — and a reload now renders via the cookie, so a working page after reload is correct, not a gate failure.

  • The gate itself is tested without the cookie — a private window, or:

    curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:<port>/'           # expect 401
    curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:<port>/?token=bad' # expect 401

    Expect 401 both times, and nothing else: access control runs before routing, so an unauthorized request cannot reach a handler at all — not even a 404, which would confirm which paths exist.

  • With runs present (§3a's queued run needs no model call), runs appear with their states; after any §7 run, usage and cost figures populate from adapterUsageReported events. The journaled prompt is not shown on the dashboard today (that column is future work) — read it via /crew run <runId> or an audit export instead.

  • Known adapters show their real vendor mark. claude, codex, copilot, and omp/ompRpc each render an inline vendor logo in the run/worker table instead of the plain BRAND.md colour cell; an adapter with no supplied mark (or a run whose worker row is missing, so its adapter can't be proven) falls back to the neutral colour cell exactly as before this feature — that fallback is correct, not a regression (crates/runtime/src/dashboard/page.rs::markOf, LOGOS).

  • Reload or reconnect now shows history immediately. Open the dashboard (or kill and restart your connection) after runs already exist, with nothing new happening: every already-committed run/event appears right away, replayed from the journal — before this fix, a viewer connecting after the fact saw an empty feed until the next live mutation. A brief network blip that reconnects the EventSource must not duplicate any row already shown (the client tracks rendered sequences and skips a replay/live duplicate of the same event).

  • A stale dashboard token after a daemon restart says so, and stops retrying. Restart the daemon (crewd stop --repo "$PWD" then let /crew health respawn it) without reloading the already-open dashboard page: the live indicator changes to "dashboard link expired" (title text names /crew health as the fix), not the generic "daemon not running" — the old message was actively wrong here, since the daemon is running, just behind a new per-run token this page's cookie no longer matches. The page probes /api/state on the next EventSource error specifically to tell a rejected token (401, unrecoverable — retries stop) apart from a genuinely unreachable daemon (no response after 3 failures, retries continue, "daemon not running"). Reload the page to pick up a fresh token from a new /crew health.

Reading the widget line

The /crew widget is a rounded border (drawn by packages/extension/src/monitor/render.ts::assembleBox) with an icon header (renderWidgetHeader) spliced directly into the top border line. Each row inside the box is prefixed with a per-state Nerd Font icon (render.ts::stateIcon) before the state word, and colored per-state (render.ts::stateColor). Underneath the icon, the joined structure of each row (rendered by renderRowLine) is unchanged:

<first 8 chars of runId> · <icon> <state> · [adapter/model] · [flags] · [pending approvals] · [workspace mode] · <latest activity>

— joined by ·, with any part that's undefined simply omitted. In this walkthrough there's no real adapter, so you'll only ever see the run id, state (always queued here), and latestActivity, which is set per event kind (packages/extension/src/monitor/model.ts):

Event latestActivity
RunEvent "run " + state (e.g. "run queued", "run starting", "run working")
MessageEvent "${kind} ${deliveryState}" (e.g. "messageRecorded recorded")
ApprovalEvent "approval requested: <action>" or "approval decided"
ChildEvent "child worker requested", "child worker accepted", or "child worker request denied"
AdapterProtocolHealthEvent "protocol healthy" or "protocol unhealthy: <detail>" — the vendor's own error subtype/stop reason when one was journaled
PolicyViolationRecorded "policy violation: <code>"
PolicyViolationDecided "violation decided: <resolution>"
AdapterUsageEvent "usage <inputTokens> in / <outputTokens> out", plus " ($<costUsd>)" when cost was reported
AdapterArtifactEvent "artifact <artifactKind> <artifactId>"
DisplayEvent "pane attached: <backend> (<paneRef>)" or "pane detached: <backend>"
WorkspaceEvent "workspace <kind>"

(RunFlagsEvent updates the row's flags but sets no latestActivity of its own.)

The widget caps at MAX_WIDGET_ROWS rows (now 7) — not 10 — because the host's ctx.ui.setWidget truncates array-content widgets at 10 total lines, and the border chrome (2 lines, plus a possible overflow line) has to fit inside that same 10-line budget alongside the rows. When truncated, the box appends "… N more; use /crew run <runId> for full details." as its last row, before the bottom border. The /crew run <runId> detail block is a labeled multi-line dump: Run/Task/Worker/State/Harness-model/Flags/Pending approvals/Workspace mode/Latest activity/First seen/Last event.

If something doesn't match

See code-walkthrough.md's §4 debugging playbook first — most manual-test surprises (METHOD_NOT_FOUND, an empty /crew, connect timeouts) are covered there with the exact cause. If a step in this document produces something not described here or there, that's either a real regression or a gap in this document — both are worth fixing; open an issue or extend this file, the same way the run/submit error-shape gap above was found by running the walkthrough for real and getting confused by it.