Summary
runTurn({ mode: "wait" }) reports the finished turn's assistant message by reading the session's newest message after the turn queue has released its slot. When a second turn is already queued on the same agent, that queued turn can append its user message first, so the completed turn resolves with status: "completed" and message: undefined.
@cloudflare/think@0.17.0, dist/think.js 3758–3768:
async _enrichTurnResult(result, continuation) {
let message;
if (result.status === "completed") {
const leaf = await this.session.getLatestLeaf();
if (leaf?.role === "assistant") message = leaf;
}
return { ...result, continuation, message };
}
_runTurnWait awaits _runProgrammaticMessagesTurn (which holds the TurnQueue slot) and only then calls _enrichTurnResult, so the lookup happens outside the serialized region:
const result = await this._runProgrammaticMessagesTurn(crypto.randomUUID(), input, { ... });
return this._enrichTurnResult(result, false);
TurnQueue.enqueue (agents/dist/chat/index.js 164–189) releases in a finally before the caller's continuation runs, so the next turn's execute — which begins by appending its user message — is free to interleave.
The turn itself is fine: both turns run, both messages persist, and the session is correct afterwards. Only the reported message is wrong, which makes a correct turn look like a failure to the caller.
Environment
@cloudflare/think@0.17.0, agents@0.22.0 (current npm latest)
- Cloudflare Workers / workerd,
Think subclass in a Durable Object
- Same code on
main: packages/think/src/think.ts _enrichTurnResult
Reproduction
Two RPC calls on one agent instance, each calling runTurn({ mode: "wait" }) with a function input:
await Promise.all([
stub.appendTurn({ q: "any 2 bedrooms?" }),
stub.appendTurn({ q: "book a tour" }),
]);
Probe inside the first call's continuation:
{"status":"completed","leafRole":"user"}
Expected leafRole: "assistant" for the turn that just completed; the leaf is the other turn's question. Reproduces reliably on a cold install and intermittently otherwise — it is a timing race, so it surfaces as a flaky test or an occasional 5xx in production rather than a hard failure.
Suggested fix
Resolve the assistant message from the run that produced it rather than from session-global state — either capture the leaf inside the admitted turn body (still holding the queue slot) and pass it out with the result, or look it up by the turn's requestId. Reading getLatestLeaf() after the slot is released is only correct when exactly one turn ever exists.
Workaround
Callers can record the id of the user message they submit and take the first assistant message after it in getMessages(), which is stable regardless of what else is queued.
Summary
runTurn({ mode: "wait" })reports the finished turn's assistant message by reading the session's newest message after the turn queue has released its slot. When a second turn is already queued on the same agent, that queued turn can append its user message first, so the completed turn resolves withstatus: "completed"andmessage: undefined.@cloudflare/think@0.17.0,dist/think.js3758–3768:_runTurnWaitawaits_runProgrammaticMessagesTurn(which holds theTurnQueueslot) and only then calls_enrichTurnResult, so the lookup happens outside the serialized region:TurnQueue.enqueue(agents/dist/chat/index.js164–189) releases in afinallybefore the caller's continuation runs, so the next turn'sexecute— which begins by appending its user message — is free to interleave.The turn itself is fine: both turns run, both messages persist, and the session is correct afterwards. Only the reported
messageis wrong, which makes a correct turn look like a failure to the caller.Environment
@cloudflare/think@0.17.0,agents@0.22.0(current npm latest)Thinksubclass in a Durable Objectmain:packages/think/src/think.ts_enrichTurnResultReproduction
Two RPC calls on one agent instance, each calling
runTurn({ mode: "wait" })with a function input:Probe inside the first call's continuation:
Expected
leafRole: "assistant"for the turn that just completed; the leaf is the other turn's question. Reproduces reliably on a cold install and intermittently otherwise — it is a timing race, so it surfaces as a flaky test or an occasional 5xx in production rather than a hard failure.Suggested fix
Resolve the assistant message from the run that produced it rather than from session-global state — either capture the leaf inside the admitted turn body (still holding the queue slot) and pass it out with the result, or look it up by the turn's
requestId. ReadinggetLatestLeaf()after the slot is released is only correct when exactly one turn ever exists.Workaround
Callers can record the id of the user message they submit and take the first assistant message after it in
getMessages(), which is stable regardless of what else is queued.