cloudflare / cloudflare/agents

think: runTurn({ mode: "wait" }) resolves with message: undefined when a second turn is queued on the same agent

Open
#2,243 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.