fix: exactly-once entry delivery, repaired EVM examples, README that compiles - #42
fix: exactly-once entry delivery, repaired EVM examples, README that compiles#42LauJoeYing wants to merge 4 commits into
Conversation
Three issues reported from the field, all reproduced against the code.
1. `provider` vs `evmProvider`. `CreateAcpClientInput` accepts `evmProvider` /
`solanaProvider`; there is no `provider` key. All 8 EVM examples and 3 README
snippets used `provider:`, so they failed to compile and threw "At least one
provider ... must be provided" at runtime. Root cause: tsconfig.json excludes
`src/examples*` (correctly -- they must not ship in dist/), so nothing ever
type-checked them. Adds tsconfig.examples.json + `npm run typecheck:examples`
so this can't regress, and makes the runtime error name the right key for
plain-JS consumers who get no compile error at all.
Also documents that `walletAddress` is viem's `Address`, so an env-sourced
address needs an `as \`0x${string}\`` cast -- every example already did this,
the README never mentioned it.
2. Raw job creation carries no requirement. Only createJobFromOffering /
createJobByOfferingName post the "requirement" entry; createJob and the three
hook variants send nothing but `description`. An evaluator on that path gets a
deliverable with no stated ask. Adds TSDoc on all four creators, a README
section, and a standalone evaluator quick start -- neither existing quick
start covered the evaluator role.
3. Duplicate event delivery on restart. hydrateSessions() fires the handler with
the latest entry of every active job on every start(), by design, so restarts
replay whatever the job was waiting on. There is no dedup: the check in
dispatch() is object identity, and JobRoomEntry has no stable id. Documents
the replay contract and the persistent-dedup pattern it requires, and notes
that the contract rejecting a redundant complete/reject is a failure path,
not a deduplication mechanism.
Also fixes the llm/ examples, which passed model: "gemini-3.1-flash-lite-preview"
to a bare `new Anthropic()` client -- a Google model id on the Anthropic API.
Verified: `npm run typecheck` and `npm run typecheck:examples` both clean (the
latter reported 15 errors before this change); createAcpClients error paths and
the evmProvider happy path exercised directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`start()` registers the live entry handler and awaits `transport.connect()`
before `hydrateSessions()`. The stream has to be live first -- otherwise
entries occurring during catch-up are lost outright -- but dispatching them
immediately is worse, because hydration has not built the session yet. Two
deterministic failures came out of that window, both reproducible in a single
process with no restart involved:
- provider role: the handler ran *twice* for one `job.funded`. The live entry
dispatched against a session built from no history, then hydration
re-delivered the same entry as the job's latest. Two submits for one
funding; for an evaluator, two rulings for one submission.
- evaluator role: the handler ran *zero* times. A first sighting that isn't
`job.created` has no role information, so `inferRoles([])` fell back to
["provider"] and `shouldRespond` dropped the `job.submitted`. A ruling
silently never happened. The session also kept 1 of 2 history entries, so
`toContext()` and `status` were computed on partial history.
Entry identity was the underlying problem: `dispatch` tested membership with
`session.entries.includes(entry)`, a reference compare, and the same logical
entry arrives as a different object on every path that produces it -- an SSE
frame and a `getHistory()` response each parse their own copy.
Fixes:
- `entryKey()` (new, exported): stable content key for a `JobRoomEntry`,
since the type carries no server-assigned id.
- `AcpAgent.start()`: queue live entries while `hydrating` is set, drain after
hydration in a `finally` so a hydration failure can't strand them.
- `JobSession.appendEntry()` is idempotent and returns whether the entry was
new; `hasEntry()` and `mergeEntries()` added. `mergeEntries` sorts by
timestamp because `status` scans backwards for the newest system event, so
folding older history in after a newer live entry would walk status
backwards.
- `dispatch()`: a first sighting that isn't `job.created` fetches history so
roles resolve; failure to fetch is logged rather than swallowed and the
entry is still delivered.
- `getOrCreateSession()` merges supplied history into an existing session
instead of discarding it.
Replay across process restarts is unchanged and still deliberate: it is what
lets an agent killed mid-flow pick the job back up. Integrators still need
persistent dedup for that, and the key set here dies with the process by
design.
Adds tests/entryDelivery.test.ts -- 7 cases over fake transports, no framework,
no network. Run via `npm test` (previously a stub that exited 1), type-checked
via `npm run typecheck:tests` with tsconfig.tests.json. Against the previous
commit the suite fails 5/7; the 2 that pass before and after are the ones
asserting preserved behavior (restart replay, the `job.created` path), so the
change is provably narrow. tests/ stays out of dist/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every README code block was extracted and compiled against the packed
tarball's `.d.ts`, the way a reader would use it. Three problems:
- The evaluator quick start didn't compile. `session.entries.find((e) =>
e.kind === "message" && ...)` returns `JobRoomEntry`, so `requirement
?.content` is a type error. Narrowed with an `e is AgentMessage` predicate
and added the import block so the snippet stands alone.
- The Agent Discovery snippet indexed `agents[0].offerings[0]` unguarded.
That's a compile error under `noUncheckedIndexedAccess` (which this repo's
own tsconfig sets and `typecheck:examples` enforces), and an empty
`browseAgents` result is a real runtime case -- src/examples/basic/buyer.ts
already guards it. Guarded both index reads; the local is now `seller` so it
no longer collides with the `provider` declared later in the same block.
- `reason` on `job.completed` / `job.rejected` is typed `string`, but the
value delivered is the on-chain `bytes32`: the string hex-encoded and
right-padded, e.g. `"rejected"` arrives as `0x72656a6563746564...0000`.
Nothing decoded it and nothing said so. Documented the actual behavior, the
32-byte truncation limit, and a `hexToString(..., { size: 32 })` decode
(viem is already a dependency). Behavior itself is untouched -- decoding it
in the SDK would change event payloads for existing consumers.
Also corrects the restart & replay section, which claimed the SDK "does not
dedupe for you" and "deliberately can't". Since the previous commit it does,
within a process, by content key. The cross-restart replay it can't dedupe is
still the integrator's job, so the section now says exactly where that line
falls and why an in-memory set was never the answer for it.
13 of 14 typescript blocks now compile standalone; the exception is a
deliberate one-line property fragment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dispatch` gated the handler on `appendEntry`'s return value:
const entryIsNew = session.appendEntry(entry);
if (!entryIsNew && !sessionIsNew) return;
...
await session.fetchJob();
this.fireHandler(session, entry);
The entry was recorded in the transcript before `fetchJob()` and
`fireHandler()` ran, so a transient `getJob()` failure -- routine while the
observer lags behind chain state -- left it permanently marked "seen". Every
later arrival of the same logical entry hit `entryIsNew === false` and
returned: the live entry drained after a failed hydrate, a reconnect replay,
any of it. The in-process exactly-once path delivered zero times, silently.
`hydrateSessions()` compounded it. Its `await session.fetchJob()` had no
try/catch, so one lagging job threw out of the whole loop and every job after
it in `getActiveJobs()` order lost its hydration replay too.
Being in `entries` is a fact about history, not about the handler. The two are
now tracked separately:
- `JobSession` gains `deliveredEntryKeys`, `tryClaimDelivery()` and
`unclaimDelivery()`. The claim is synchronous, so two paths racing the same
entry can't both win it.
- `dispatch()` and `hydrateSessions()` claim before the fetch and release on
failure, leaving a later dispatch or replay free to retry. Hydration's
failure path `continue`s to the next job instead of aborting the loop.
- The `job.created` role-swap branch hands the claim from the superseded
session to its replacement.
- `appendEntry()` stays idempotent but no longer carries delivery meaning.
Verified against the parent commit by running the two new cases on its source
in a detached worktree: 7/9, with the live-dispatch case asserting 0 !== 1
(handler fired zero times) and the hydration case throwing out of
`hydrateSessions`. The 7 pre-existing cases pass before and after, so the
change is narrow. 9/9 here, `typecheck` and `typecheck:tests` clean.
One narrower hole is left open deliberately. Live dispatch is fire-and-forget,
so if two copies of an entry arrive concurrently *and* the first one's
`fetchJob()` fails, the second returns on the still-held claim before the first
releases it -- zero deliveries, no pending replay. Closing it needs an
in-flight-promise map keyed by entry to serialize duplicate dispatches, which
is a separate change; both triggers fixed here are sequential.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 975145f. Configure here.
| ); | ||
| continue; | ||
| } | ||
| this.fireHandler(session, latest); |
There was a problem hiding this comment.
Hydration omits historical delivery claims
High Severity
tryClaimDelivery replaced transcript membership as the in-process dedupe, but hydration only claims the latest history entry. dispatch then treats any other already-known entry as new. Live frames queued during getHistory (or a later job.created session rebuild, which does not copy deliveredEntryKeys) can fire the handler again after the latest event was already replayed.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 975145f. Configure here.


Three issues were reported from the field against v0.1.12. All three reproduce.
Two were documentation drift; one is a real concurrency bug in
AcpAgent.start()that is worse than the docs described.
1. Docs didn't match the installed code — fixed, plus a second instance of the same bug
CreateAcpClientInputacceptsevmProvider/solanaProvider; there is noproviderkey. All 8 EVM examples and 3 README snippets usedprovider:, sothey failed to compile and threw "At least one provider must be provided" at
runtime. Root cause:
tsconfig.jsonexcludessrc/examples*— correctly, theymust not ship in
dist/— so nothing ever type-checked them.Fixed in 7e14667:
tsconfig.examples.json+npm run typecheck:examples(15errors before, clean after), and the runtime error now names the right key for
plain-JS consumers who get no compile error at all.
walletAddressbeing viem'sAddress— so an env-sourced address needsas \0x${string}`` — is nowdocumented.
Nothing guarded the README, though, and the fix shipped a fresh instance of the
same class of bug. Every fenced
typescriptblock was extracted and compiledagainst the packed tarball's
.d.ts, the way a reader would use it:session.entries.find((e) => e.kind === "message" && ...)returnsJobRoomEntry, sorequirement?.contentis a type error. Narrowed with ane is AgentMessagepredicate, plus an import block so the snippet isself-contained.
agents[0].offerings[0]unguarded — acompile error under
noUncheckedIndexedAccess(which our own tsconfig sets andtypecheck:examplesenforces), and an emptybrowseAgentsresult is a realruntime case that
src/examples/basic/buyer.tsalready guards.13 of 14 blocks now compile standalone; the exception is a deliberate one-line
property fragment. A negative control confirms the harness catches the original
provider:+ un-cast address mistake.2. Raw
createJobcarries no requirement — resolved as documentationOnly
createJobFromOffering/createJobByOfferingNamepost the"requirement"entry.
createJoband the three hook variants put a job on-chain and stop, so anevaluator on that path receives a deliverable with no stated ask. Added TSDoc on
all four creators, a README section, and a standalone evaluator quick start —
neither existing quick start covered the evaluator role. Confirmed live: the
requirement message posts on the offering path and the seller reads it.
3. Duplicate delivery — a real bug, not just restart replay
The docs framed duplicates as a cross-restart replay contract. They also happen
inside a single process with no restart.
start()registers the live handlerand awaits
connect()beforehydrateSessions()— the stream must be live firstor entries occurring during catch-up are lost, but dispatching them immediately
is worse, because hydration hasn't built the session yet. Two deterministic
reproductions:
job.funded— two submits for one fundingjob.createdhas no role info, soinferRoles([])fell back to["provider"]andshouldResponddropped thejob.submitted. The session also kept 1 of 2 history entries, sotoContext()andstatuswere built on partial historyEntry identity was the underlying problem:
dispatchtested membership withsession.entries.includes(entry), a reference compare, and the same logicalentry arrives as a different object on every path that produces it — an SSE frame
and a
getHistory()response each parse their own copy.Fixed at the root:
entryKey()(src/events/entryKey.ts, exported) — stable content key,since
JobRoomEntrycarries no server-assigned id.AcpAgent.start()— live entries are queued whilehydratingis set anddrained afterwards in a
finally, so a hydration failure can't strand them.JobSession.appendEntry()is idempotent and returns whether the entry wasnew;
hasEntry()andmergeEntries()added.mergeEntriesfolds history intimestamp order because
statusscans backwards for the newest system event —appending older history after a newer live entry would walk status backwards.
dispatch()— a first sighting that isn'tjob.createdfetches history soroles resolve; a failed fetch is logged rather than swallowed and the entry is
still delivered.
getOrCreateSession()merges supplied history into an existing sessioninstead of discarding it.
Cross-restart replay is unchanged and still deliberate — it's what lets an agent
killed mid-flow pick the job back up. Integrators still need persistent dedup for
that, and the in-process key set dies with the process by design. The README's
restart section previously claimed the SDK "does not dedupe for you" and
"deliberately can't"; it now states exactly where that line falls.
Left untouched — needs a decision
reasondoesn't round-trip.reject("deliverable rejected")reacheshandlers as
0x64656c69...0000: right-paddedbytes32, nothing decodes it,and the type says
string. Decoding it in the SDK would change event payloadsfor existing consumers, so this PR only documents the behavior, the 32-byte
truncation limit, and a
hexToString(..., { size: 32 })decode. Whether todecode it properly is your call.
fetchJob()swallows the cause (src/jobSession.ts:208-212) — everyfailure becomes
"Failed to fetch job N". ABigInt("0.01")error surfaced asthat generic message and cost a debug cycle.
src/examples/basic/buyer.tserrors out of the box against our own seller:the placeholder
{ description: ... }doesn't satisfy thefactCheckschema,which requires
claim. Schema validation working as intended, but the firstthing a new user runs fails.
both README bugs works and can land as
npm run typecheck:readmeif wanted.Verification
npm run typecheck,typecheck:examples,typecheck:tests,npm test, andnpm run buildall clean.dist/still excludes tests and examples.tests/entryDelivery.test.ts— 7 cases over fake transports, no framework, nonetwork.
npm testwas previously a stub that exited 1. Against the parentcommit the suite fails 5/7; the 2 that pass before and after are the ones
asserting preserved behavior (restart replay, the
job.createdpath), so thefix is provably narrow.
Live on Base mainnet, both evaluator directions, before and after the fix:
budget.set, restartedbudget.set→ restarted, patched SDKEvery event fired exactly once in every run. No jobs left in flight.
Review focus
The delivery-semantics change in
acpAgent.ts/jobSession.ts/entryKey.tsis the part that warrants scrutiny — it changes what happens inside
start().Then the four untouched items above, particularly
reason.🤖 Generated with Claude Code
Note
Medium Risk
Changes
start()/dispatch()delivery semantics for all agents—behavioral but covered by new tests; on-chain side effects still need integrator idempotency across restarts.Overview
Core fix:
AcpAgent.start()no longer double-fires or silently dropsentryevents when the stream connects before hydration finishes. Live events are queued during hydration and drained afterward;entryKey()compares entries by content (not object reference);JobSessionadds idempotentappendEntry, timestamp-orderedmergeEntries, andtryClaimDelivery/unclaimDeliveryso handler delivery is separate from the transcript.dispatch()now fetches history on first sighting when the entry isn’tjob.created, so evaluator roles resolve instead of defaulting to provider.Integrator docs: README gains evaluator quick start, restart/replay semantics (in-process dedup vs cross-restart persistence), requirement-message behavior (only offering-based creators post
"requirement"),bytes32reasondecoding notes, and provider adapter keys. TSDoc on raw job creators matches.createAcpClientsthrows a clear error if callers pass deprecatedprovider.Tooling / examples: All EVM examples use
evmProvider;npm testrunstests/entryDelivery.test.ts(9 delivery scenarios);typecheck,typecheck:examples,typecheck:testsadded. LLM examples switch toclaude-opus-5;@anthropic-ai/sdkadded as a devDependency.Reviewed by Cursor Bugbot for commit 975145f. Bugbot is set up for automated code reviews on this repo. Configure here.