Skip to content

Commit f241ce8

Browse files
NagyViktNagyVikt
andauthored
test(scenarios): add reproducible test scenarios harness (#574)
- 5 canonical multi-agent scenarios as seed.sql + inputs.jsonl + expected.json - In-process harness with vi.useFakeTimers + path normalization - pnpm scenarios / scenarios:filter / scenarios:explain / scenarios:record - 2 harness self-tests (fails-closed on missing expected, clear diff on mismatch) - Separate scenarios CI job on Node 20 Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 99936fa commit f241ce8

35 files changed

Lines changed: 2016 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,21 @@ jobs:
5050
cache: 'pnpm'
5151
- run: pnpm install --frozen-lockfile
5252
- run: bash scripts/e2e-publish.sh
53+
54+
# Reproducible multi-agent scenarios under tests/scenarios/. Kept out
55+
# of the `pnpm test` aggregate so a scenario failure stays attributable
56+
# to the harness rather than blending into the per-package test job.
57+
scenarios:
58+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
59+
runs-on: ubuntu-latest
60+
needs: build
61+
timeout-minutes: 10
62+
steps:
63+
- uses: actions/checkout@v4
64+
- uses: pnpm/action-setup@v4
65+
- uses: actions/setup-node@v4
66+
with:
67+
node-version: '20'
68+
cache: 'pnpm'
69+
- run: pnpm install --frozen-lockfile
70+
- run: pnpm scenarios

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1015,7 +1015,7 @@ validate.
10151015
- 🟡 Cursor and Gemini CLI installers exist but have less smoke coverage
10161016
- 🔵 Per-runtime smoke for claim-before-edit emission
10171017
- 🔵 Cross-runtime handoff smoke (Codex hands off to Claude, both run)
1018-
- Reproducible test fixture set under `tests/scenarios/`
1018+
- Reproducible test fixture set under `tests/scenarios/` (5 scenarios, harness self-tests, `pnpm scenarios`)
10191019

10201020
> **`time-to-healthy`: still hours**, but the time the human spends _deciding what to run_ drops sharply because every signal carries its `cmd:` and `tool:` already.
10211021
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
slug: scenarios-harness-2026-05-16
3+
---
4+
5+
# CHANGE · scenarios-harness-2026-05-16
6+
7+
## §P proposal
8+
9+
### Problem
10+
11+
README §v0.x "Multi-runtime confidence" lists "Reproducible test fixture set under `tests/scenarios/`" as the last open item. Today, multi-agent situations (claim-before-edit, cross-runtime handoff, stale-claim sweep, plan claim adoption, pre/post path mismatch) live as ad-hoc smoke tests scattered across `packages/hooks/test/` and `apps/cli/test/`. Each rebuilds its own tempdir + git repo + fake-timer scaffolding inline. Reproducing a regression means hand-porting that scaffolding into a fresh file.
12+
13+
### Proposal
14+
15+
Add a reproducible test-scenarios harness under `tests/scenarios/`. Each scenario is a directory of plaintext artifacts (no binary snapshots):
16+
17+
- `seed.sql` — applied after schema migrations against a fresh tempdir SQLite DB.
18+
- `inputs.jsonl` — one envelope per line: `{kind, at_ms, payload}` where `kind` is `lifecycle | mcp | tick`. Lifecycle flows through the same `runOmxLifecycleEnvelope` that production hooks call.
19+
- `expected.json` — normalized substrate snapshot with subset matchers (`toMatchObject` style), not full-row equality. Paths normalized to `<REPO_ROOT>`.
20+
- Optional `meta.yaml` — runtimes, tags, description.
21+
22+
A shared `_harness/` drives all scenarios via `vi.useFakeTimers` + `vi.setSystemTime(BASE_TS + at_ms)` per envelope so timing is deterministic. Embeddings forced to `provider: none` to remove network. Five canonical scenarios ship in this PR: claim-before-edit, cross-runtime handoff, stale-claim sweep, plan claim adoption, pre/post path mismatch. Two harness self-tests prove the runner fails closed on missing expected and reports a clear diff on mismatch. A separate CI job runs `pnpm scenarios` on Node 20 after `build`, kept out of `pnpm test` so failure attribution stays clean.
23+
24+
### Acceptance criteria
25+
26+
- `pnpm scenarios` runs all five scenarios plus two harness self-tests, all green.
27+
- `pnpm scenarios:filter <slug>` runs a single scenario by name.
28+
- `pnpm scenarios:explain <slug>` prints a human-readable timeline.
29+
- `pnpm scenarios:record <slug>` regenerates `expected.json` from a live run (manual trim still required for subset matcher discipline).
30+
- `.github/workflows/ci.yml` gains a `scenarios` job after `build` running on Node 20 only.
31+
- `pnpm typecheck` and `pnpm build` clean.

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,18 @@
3333
"p": "pnpm run release",
3434
"publish:cli": "bash scripts/publish-cli.sh",
3535
"publish:cli:dry-run": "bash scripts/publish-cli.sh --dry-run",
36-
"release": "changeset publish"
36+
"release": "changeset publish",
37+
"scenarios": "vitest run --config tests/scenarios/_harness/vitest.config.ts",
38+
"scenarios:filter": "vitest run --config tests/scenarios/_harness/vitest.config.ts -t",
39+
"scenarios:explain": "tsx tests/scenarios/_harness/explain.mts",
40+
"scenarios:record": "tsx tests/scenarios/_harness/record.mts"
3741
},
3842
"devDependencies": {
3943
"@biomejs/biome": "^1.9.4",
4044
"@changesets/cli": "^2.27.9",
4145
"@types/node": "^22.9.0",
4246
"tsup": "^8.3.5",
47+
"tsx": "^4.19.2",
4348
"typescript": "^5.6.3",
4449
"vitest": "^2.1.5"
4550
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
{
2+
"observations": [
3+
{
4+
"kind": "lifecycle_event",
5+
"ts_offset": 10,
6+
"metadata_subset": {
7+
"event_name": "session_start",
8+
"session_id": "codex@scenario-01",
9+
"agent": "codex",
10+
"branch": "agent/scenario/default",
11+
"binding_status": "bound_task"
12+
}
13+
},
14+
{
15+
"kind": "omx-lifecycle",
16+
"metadata_subset": {
17+
"event_id": "evt_01_session",
18+
"event_type": "session_start",
19+
"ok": true
20+
}
21+
},
22+
{
23+
"kind": "omx-lifecycle",
24+
"metadata_subset": {
25+
"event_id": "evt_01_bind",
26+
"event_type": "task_bind",
27+
"ok": true
28+
}
29+
},
30+
{
31+
"kind": "claim",
32+
"ts_offset": 40,
33+
"metadata_subset": {
34+
"kind": "claim",
35+
"source": "pre-tool-use",
36+
"file_path": "src/target.ts",
37+
"auto_claimed_before_edit": true,
38+
"tool": "Edit"
39+
}
40+
},
41+
{
42+
"kind": "claim-before-edit",
43+
"ts_offset": 40,
44+
"metadata_subset": {
45+
"kind": "claim-before-edit",
46+
"source": "pre-tool-use",
47+
"outcome": "auto_claimed_before_edit",
48+
"file_path": "src/target.ts",
49+
"tool": "Edit",
50+
"conflict": false
51+
}
52+
},
53+
{
54+
"kind": "omx-lifecycle",
55+
"ts_offset": 40,
56+
"metadata_subset": {
57+
"event_id": "evt_01_pre",
58+
"event_type": "pre_tool_use",
59+
"tool_name": "Edit",
60+
"extracted_paths": ["src/target.ts"]
61+
}
62+
},
63+
{
64+
"kind": "tool_use",
65+
"ts_offset": 60,
66+
"metadata_subset": {
67+
"tool": "Edit",
68+
"lifecycle_event_id": "evt_01_post",
69+
"parent_event_id": "evt_01_pre",
70+
"file_path": "src/target.ts"
71+
}
72+
},
73+
{
74+
"kind": "omx-lifecycle",
75+
"ts_offset": 60,
76+
"metadata_subset": {
77+
"event_id": "evt_01_post",
78+
"event_type": "post_tool_use",
79+
"parent_event_id": "evt_01_pre",
80+
"tool_name": "Edit"
81+
}
82+
}
83+
],
84+
"claims": [
85+
{
86+
"task_id": 1,
87+
"file_path": "src/target.ts",
88+
"session_id": "codex@scenario-01",
89+
"state": "active"
90+
}
91+
],
92+
"mcp_metrics": [],
93+
"lifecycle_events": [
94+
{
95+
"event_type": "session_start",
96+
"event_id": "evt_01_session"
97+
},
98+
{
99+
"event_type": "task_bind",
100+
"event_id": "evt_01_bind"
101+
},
102+
{
103+
"event_type": "pre_tool_use",
104+
"event_id": "evt_01_pre"
105+
},
106+
{
107+
"event_type": "post_tool_use",
108+
"event_id": "evt_01_post",
109+
"parent_event_id": "evt_01_pre"
110+
}
111+
]
112+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Scenario 01 — claim-before-edit
2+
# A codex session starts, binds to a task, then claims src/target.ts
3+
# before issuing a pre_tool_use + post_tool_use Edit on it.
4+
{"kind":"lifecycle","at_ms":10,"payload":{"event_id":"evt_01_session","event_name":"session_start","session_id":"codex@scenario-01","agent":"codex","branch":"agent/scenario/default"}}
5+
{"kind":"lifecycle","at_ms":20,"payload":{"event_id":"evt_01_bind","event_name":"task_bind","session_id":"codex@scenario-01","agent":"codex","branch":"agent/scenario/default"}}
6+
{"kind":"lifecycle","at_ms":40,"payload":{"event_id":"evt_01_pre","event_name":"pre_tool_use","session_id":"codex@scenario-01","agent":"codex","branch":"agent/scenario/default","tool_name":"Edit","tool_input":{"operation":"replace","paths":[{"path":"<REPO_ROOT>/src/target.ts","role":"target","kind":"file"}]}}}
7+
{"kind":"lifecycle","at_ms":60,"payload":{"event_id":"evt_01_post","event_name":"post_tool_use","parent_event_id":"evt_01_pre","session_id":"codex@scenario-01","agent":"codex","branch":"agent/scenario/default","tool_name":"Edit","tool_input":{"operation":"replace","paths":[{"path":"<REPO_ROOT>/src/target.ts","role":"target","kind":"file"}]},"tool_response":{"success":true}}}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
description: |
2+
Codex session binds to a task on agent/scenario/default and edits
3+
src/target.ts. Proves pre_tool_use synthesizes a claim-before-edit
4+
signal and that the post_tool_use observation lands with the claim
5+
already in place.
6+
runtimes:
7+
- codex
8+
tags:
9+
- claim-before-edit
10+
- lifecycle
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- No seed needed: the lifecycle session_start + task_bind events drive
2+
-- session creation and task binding by themselves.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"observations": [
3+
{
4+
"kind": "lifecycle_event",
5+
"metadata_subset": {
6+
"event_name": "session_start",
7+
"session_id": "codex@scenario-02",
8+
"agent": "codex"
9+
}
10+
},
11+
{
12+
"kind": "omx-lifecycle",
13+
"metadata_subset": {
14+
"event_id": "evt_02_codex_session",
15+
"event_type": "session_start"
16+
}
17+
},
18+
{
19+
"kind": "omx-lifecycle",
20+
"metadata_subset": {
21+
"event_id": "evt_02_codex_bind",
22+
"event_type": "task_bind"
23+
}
24+
},
25+
{
26+
"kind": "claim",
27+
"ts_offset": 30,
28+
"metadata_subset": {
29+
"kind": "claim",
30+
"file_path": "src/target.ts"
31+
}
32+
},
33+
{
34+
"kind": "relay",
35+
"ts_offset": 50,
36+
"metadata_subset": {
37+
"kind": "relay",
38+
"from_session_id": "codex@scenario-02",
39+
"from_agent": "codex",
40+
"reason": "quota"
41+
}
42+
},
43+
{
44+
"kind": "claim-weakened",
45+
"ts_offset": 50,
46+
"metadata_subset": {
47+
"kind": "claim-weakened",
48+
"file_path": "src/target.ts",
49+
"state": "handoff_pending"
50+
}
51+
},
52+
{
53+
"kind": "lifecycle_event",
54+
"ts_offset": 300000,
55+
"metadata_subset": {
56+
"event_name": "session_start",
57+
"session_id": "claude@scenario-02",
58+
"agent": "claude"
59+
}
60+
},
61+
{
62+
"kind": "omx-lifecycle",
63+
"metadata_subset": {
64+
"event_id": "evt_02_claude_session",
65+
"event_type": "session_start"
66+
}
67+
}
68+
],
69+
"claims": [
70+
{
71+
"task_id": 1,
72+
"file_path": "src/target.ts",
73+
"session_id": "claude@scenario-02",
74+
"state": "active"
75+
}
76+
],
77+
"mcp_metrics": [],
78+
"lifecycle_events": [
79+
{
80+
"event_type": "session_start",
81+
"event_id": "evt_02_codex_session"
82+
},
83+
{
84+
"event_type": "task_bind",
85+
"event_id": "evt_02_codex_bind"
86+
},
87+
{
88+
"event_type": "session_start",
89+
"event_id": "evt_02_claude_session"
90+
}
91+
]
92+
}

0 commit comments

Comments
 (0)