cloudflare / cloudflare/agents
think: runTurn({ mode: "wait" }) resolves with message: undefined when a second turn is queued on the same agent
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
## 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:
```js
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:
```js
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:
```ts
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.
Contributor guide
Research direction
Start in packages/think/src/think.ts at _enrichTurnResult and trace _runTurnWait, then inspect TurnQueue.enqueue in agents/dist/chat/index.js. Reproduce with two concurrent appendTurn calls using runTurn({ mode: "wait" }). Done means each completed turn reports its own assistant message rather than message: undefined or the other turn's user message, with both turns still persisting correctly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100