Skip to content

test(evals): encode the delegate-and-forward async-contract case in the arena - #737

Open
Poytr1 wants to merge 1 commit into
mainfrom
feat/arena-delegate-forward
Open

test(evals): encode the delegate-and-forward async-contract case in the arena#737
Poytr1 wants to merge 1 commit into
mainfrom
feat/arena-delegate-forward

Conversation

@Poytr1

@Poytr1 Poytr1 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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 viewSessionStatus on that child, read {status:"in-progress", state:"prompting"}, and told the user "Agent B completed its turn but returned
no 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 needsReply is a
two-sided contract with only one side stated: the child gets a standing
# Reporting back to your parent session block; the parent gets three fields and
no statement that the call was asynchronous. viewSessionStatus then 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 to pnpm 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/prompting against a
child 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 the
change in a comment. All five were verified to fail for the stated reason by
flipping them to it:

Assertion Today
The needsReply wake result states the async contract Red — it is exactly {ok, wake, childSessionId}
viewSessionStatus does not advise an action a turn cannot take Red — "prefer waiting for the child's reply"
viewSessionStatus says when checking a child IS appropriate Red
The status result separates "turn ended" from "reported back" Red — both run end-to-end and, minus ids and clock, return identical {status:'done', state:'idle'}
A headless child's answer is not silently dropped Red — reaches no world effect and no parent turn

Layer (b) — real model, NOT in the gate

evals/test/delegate-and-forward-real.test.ts, env-gated, reported as a rate over
trials with full transcripts under .artifacts/evaluation/delegate-forward/.

Measured, 5 trials, real local Claude Code (claude-agent-acp@0.64.0, sonnet,
permissionMode: default):

noSameTurnPoll 5/5 · noPrematureClaim 5/5 · wokenByTheReply 3/5 ·
forwardedTheReply 3/5

The parent-side failure did not reproduce. In all five trials A discovered B
with listAgents, sent needsReply, called viewSessionStatus zero 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 toAgent
wake 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 — clean
  • pnpm lint / pnpm format:check — clean
  • pnpm eval:validate — clean
  • pnpm eval:collab:contracts — 16 files, 117 passed + 5 expected-fail
  • pnpm --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 on main
    @ 09d7613 with this branch stashed; acp-matrix is a local model-access
    issue. Nothing here touches packages/daemon/src.

One pre-existing flake is now documented in the baseline: werewolf's
SCRIPTED BOUNDARY case depends on a scripted game finishing inside the real 60 s
loop-guard window and fails on a loaded machine. Reproduced on main before any
change here.

🤖 Generated with Claude Code

…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>

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking documentation typo: the gate is updated above to 122 tests (117 passing + 5 expected-fail), so this remaining 115 should be 122.

@Poytr1

Poytr1 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant