Skip to content

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

Description

@MatthewHager

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions