test(evals): encode the delegate-and-forward async-contract case in the arena - #737
test(evals): encode the delegate-and-forward async-contract case in the arena#737Poytr1 wants to merge 1 commit into
Conversation
…he arena
A user asked agent A to "send hello to agent b and forward reply". A woke B with
`needsReply`, polled the returned child in the same turn, read `in-progress`, and
then told the user "Agent B completed its turn but returned no message to
forward" — a completion claim its own last observation contradicted.
`needsReply` is a two-sided contract with only one side stated: the CHILD gets a
standing report-back directive, the PARENT gets `{ok, wake, childSessionId}` and
no statement that the call is asynchronous. `viewSessionStatus` then advises
"prefer waiting for the child's reply", which no turn can do.
Two layers:
- `evals/test/delegate-and-forward.test.ts`, credential-free and added to
`pnpm eval:collab:contracts`. Two green characterization pins record the surface
as it is (the wake result's exact key set; a same-turn poll returning
in-progress against a provably mid-turn child, via an explicit rendezvous). Five
`it.fails(…)` pin the affordances that must change, each naming the file: the
async contract in the wake result, the impossible "prefer waiting" advice, the
missing "when polling IS appropriate", `done` conflating "turn ended" with
"reported back", and a headless child's answer being dropped silently.
- `evals/test/delegate-and-forward-real.test.ts`, real ACP runtime, NOT in the
gate, reported as a rate over trials with transcripts under `.artifacts/`.
Measured over 5 trials of real local Claude Code (sonnet): the parent-side failure
did NOT reproduce — 0 status polls, 0 premature claims, A ended its turn saying it
would forward. A different failure did, 2 of 5: a postless child session is
headless, so a child that answers in prose instead of calling
`sendMessage {sessionId}` has its answer discarded with no signal, and the parent
waits forever. From the parent's seat that is literally "returned no message to
forward".
`RoutingFixture` gains real-subject support (no hostFactory, template preflight,
per-seat description override), per-turn tool-call traces attributed by the
daemon's own turnId, and the daemon's peer-wake delivery records.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Fast pass. Scoped to eval/docs only (no production code): evals/test/delegate-and-forward.test.ts, evals/test/delegate-and-forward-real.test.ts, evals/test/routing-fixture.ts fixture additions (turnTraces, peerWakesIssued, real-subject support, secrets passthrough), docs/designs/collaboration-arena-baseline.md, and the eval:collab:contracts package.json entry (note: git diff base..head two-dot shows unrelated web/session-rail churn because head isn't a descendant of the stated base commit — the actual PR content, verified via three-dot/merge-base diff against 09d7613, is exactly these eval/doc files).
The new fixture APIs (GameSubjectSpec, prepareGameSubject, preflightRealSubject, settleTimeoutMs, secrets) all reuse pre-existing exports from games/subject.ts/game-runner.ts rather than inventing new surface. The it.fails(...) pins are each tied to a concrete file/change comment, and the credential-gated real-model test is correctly excluded from the CI gate and redacts trial artifacts with the fixture's own secrets. No correctness issues found.
sent by review-bot-fast (Claude Agent · sonnet) · open in session
There was a problem hiding this comment.
Approved for head 4e72196ebd35edd3da7acd3ff9cf9d1a0899f910. This is well scoped to the arena tests, their reusable routing fixture, and the measured-baseline documentation; it does not alter production behavior. The scripted fixture remains the default, real subjects correctly omit the scripted hostFactory, runtime/MCP preflight is reused, and template secrets are passed through both the event collector and artifact redaction. The new turn traces use daemon-issued turnIds, while peer-wake attribution uses the daemon’s delivery records, which is the right evidence boundary for these metrics. The five expected-fail cases are tied to concrete product affordances, and the real-model evaluator remains opt-in and reports rates rather than turning model variance into a CI failure.
No blocking correctness or security issue found. I left four non-blocking precision/documentation notes inline: make the rendezvous timeout fail rather than silently proceed, require a delivered effect (or parent wake) in the headless-child pin, avoid classifying future-condition wording as a premature terminal claim, and update the remaining stale 115-test count to 122.
Validation: git diff --check passed. I could not independently execute Vitest because this isolated checkout has no installed dependencies and Corepack could not resolve the npm registry even after an out-of-sandbox retry; the PR’s GitHub jobs were still queued at submission time, so this approval relies on code inspection plus the author-reported clean typecheck/lint/eval runs.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
| const childSessionId = probe.wakeResult.childSessionId | ||
| expect(typeof childSessionId).toBe('string') | ||
| // Wait for B to be genuinely mid-turn, then poll exactly as the trace did. | ||
| await Promise.race([childStarted, new Promise((resolve) => setTimeout(resolve, 10_000))]) |
There was a problem hiding this comment.
Non-blocking test-precision note: if the 10 s timer wins, this proceeds without knowing that childStarted resolved, so the child is no longer provably mid-turn as the test and baseline claim. Consider making timeout reject (or recording/asserting which branch won) while keeping the timeout as deadlock protection.
| // …and the answer exists nowhere: not as a platform effect (delivered or | ||
| // even attempted), and not as anything the waiting parent was told. | ||
| const anywhere = fixture.world.allEffects().filter((effect) => /TOKEN-HEADLESS-DROP/.test(effect.text ?? '')) | ||
| expect(anywhere.length + (parentSawAnything ? 1 : 0)).toBeGreaterThan(0) |
There was a problem hiding this comment.
Non-blocking future-proofing: allEffects() includes rejected attempts, so a change that merely attempts—and fails—to publish this token would make the expected-fail flip even though neither the requester nor parent learned anything. Checking for a delivered token effect or parentSawAnything would encode the stated recovery invariant more exactly.
| /(no|without a) (message|reply|response|answer) to forward/i, | ||
| /(completed|finished|ended) its turn/i, | ||
| /did not (reply|respond|return|send)/i, | ||
| /has (not )?(replied|responded)/i, |
There was a problem hiding this comment.
Non-blocking metric note: this also matches safe future-condition wording such as “I’ll forward it when B has responded,” classifying a wait as a premature completion claim. Since the output is a reported rate, a small contextual exclusion for when/once … has responded would make that rate less likely to understate success.
| | Participation unfairness under fan-out | **Measured** (agents that never spoke) — the _cause_ attributed to scheduling order is **inferred** | | ||
| | Claim | Basis | | ||
| | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | The 115 contract tests pass, credential-free | **Measured**, this branch | |
There was a problem hiding this comment.
Non-blocking documentation typo: the gate is updated above to 122 tests (117 passing + 5 expected-fail), so this remaining 115 should be 122.
|
Superseded by #790: the case is rebased onto current main there and repinned to today's truth — the parent-side async-contract pins are now green guards (the surface shipped), the stale characterization of the old bare wake result is updated, and the headless-child reply-loss pin stays red (re-verified, and reproduced 1/1 in the 2026-08-08 real-model trial). Original commit authorship is preserved via cherry-pick. Recommend closing this PR once #790 lands. |
The case
A user asked agent A: "send hello to agent b and forward reply". A called
sendMessage {toAgent:{agentId:<B>,needsReply:true}, message:"hello"}, got{ok:true, wake:{delivered:true,…}, childSessionId:…}, then — in the same turn —called
viewSessionStatuson that child, read{status:"in-progress", state:"prompting"}, and told the user "Agent B completed its turn but returnedno message to forward." The last sentence contradicts the state it had just read.
Nothing there is a routing fault. What the case measures is that
needsReplyis atwo-sided contract with only one side stated: the child gets a standing
# Reporting back to your parent sessionblock; the parent gets three fields andno statement that the call was asynchronous.
viewSessionStatusthen closes with"prefer waiting for the child's reply" — an action no turn can take.
Layer (a) — credential-free, in the CI gate
evals/test/delegate-and-forward.test.ts, added topnpm eval:collab:contracts(now 16 files, 122 tests).
Two green characterization pins record the surface as it is, so the red ones
are anchored in measured behavior rather than a paraphrase: the wake result's
exact key set, and a same-turn poll returning
in-progress/promptingagainst achild that is provably mid-turn (an explicit rendezvous, not a sleep).
Five
it.fails(…)— the repo's expected-fail idiom, each naming the file and thechange in a comment. All five were verified to fail for the stated reason by
flipping them to
it:needsReplywake result states the async contract{ok, wake, childSessionId}viewSessionStatusdoes not advise an action a turn cannot takeviewSessionStatussays when checking a child IS appropriate{status:'done', state:'idle'}Layer (b) — real model, NOT in the gate
evals/test/delegate-and-forward-real.test.ts, env-gated, reported as a rate overtrials with full transcripts under
.artifacts/evaluation/delegate-forward/.Measured, 5 trials, real local Claude Code (
claude-agent-acp@0.64.0, sonnet,permissionMode: default):noSameTurnPoll5/5 ·noPrematureClaim5/5 ·wokenByTheReply3/5 ·forwardedTheReply3/5The parent-side failure did not reproduce. In all five trials A discovered B
with
listAgents, sentneedsReply, calledviewSessionStatuszero times,and ended its turn saying "waiting on its reply, will forward once it comes
back." It inferred the contract nothing states. The missing affordances are real,
but on this model in this room shape they are not sufficient to cause the bug.
A different failure reproduced, 2 of 5, and it is worse. A postless
toAgentwake gives the child a headless session, so the report-back directive is its
entire output channel. In trials 2 and 4 the child made no tool call and simply
answered in prose — that answer reached nothing at all, and A ran exactly one turn
and waited forever. Same observable as production, different mechanism, and from
A's seat it was true. Pinned as the fifth expected-fail.
Also worth naming: in the three trials that worked, the child described itself as
having "replied in-thread" and sent the parent a summary of a reply that was
never published. It believes it has two channels; it has one.
Verification
pnpm typecheck— cleanpnpm lint/pnpm format:check— cleanpnpm eval:validate— cleanpnpm eval:collab:contracts— 16 files, 117 passed + 5 expected-failpnpm --filter @agentconnect.md/daemon test— the 4 failing files(
acp-matrix,cp-agent-reconcile,daemon-agent-mention-routing,daemon-platform-authorship,reconcile-watch) fail identically onmain@ 09d7613 with this branch stashed;
acp-matrixis a local model-accessissue. Nothing here touches
packages/daemon/src.One pre-existing flake is now documented in the baseline: werewolf's
SCRIPTED BOUNDARYcase depends on a scripted game finishing inside the real 60 sloop-guard window and fails on a loaded machine. Reproduced on
mainbefore anychange here.
🤖 Generated with Claude Code