Skip to content

createTurn cancels the in-flight turn before validating the new request — a duplicate approval submission can truncate the approved turn mid-stream #508

Open

Description

@MachineLearning-Nerd

Version: @truefoundry/trueforge 0.1.4 (macOS, Node 24, local server via npx @truefoundry/trueforge@0.1.4 --port 8891)

Summary

SessionHandle.createTurn cancels the in-flight previous turn (cancelled-for-next-turn) before the new turn request is validated or persisted, with no rollback. Any turn-create request that subsequently fails therefore kills the running turn and leaves no successor. Combined with the approval flow — where a human approval decision is itself delivered as a new turn (user.tool_approval in TurnInputItem) — a duplicate Approve submission from the UI can cancel the very turn that is executing the approved tool, truncating it mid-stream.

What we observed

Session with an approval-gated MCP tool (require_approval_for_tools). Timeline from the persisted events:

  1. Turn N ends paused: tool.approval_required at 13:35:29.327.
  2. Turn N+1 (the approval turn) is created at 13:35:29.337; the approved tool executes and its tool.response is persisted.
  3. ~2.8 s later, turn N+1 is frozen:
{ "status": "cancelled", "reason": "cancelled-for-next-turn", "completed_at": "2026-08-30T13:35:32.154Z" }
  1. No next turn exists in the session — GET /turns still returns only the turns up to N+1.

So the assistant was cut off after the tool response but before producing any follow-up output (in our case: the message that relays the tool's result, a second tool call, and a sandbox step). The approved side effect had already executed; the evidence trail for it was never produced.

Root cause (from the published dist)

trueforge-core/dist/agent-session/SessionHandle.mjs, createTurn:

async createTurn(input) {
  const previousTurnId = resolvePreviousTurnId(input.previous_turn_id, this.session.last_turn_id);
  const previous = previousTurnId ? await this.freezeTurn({
    turn_id: previousTurnId,
    reason: CancellationReason.CancelledForNextTurn
  }) : void 0;
  // ... everything that can fail (agent spec resolution, sandbox resolution,
  //     turn persistence) happens after the freeze, inside the try block

The freeze is unconditional and happens first. If the rest of createTurn throws or the request is aborted, the previous turn is already cancelled and nothing replaces it.

Contributing UI factor (@truefoundry/trueforge-ui): the stock ToolApprovalBar only disables its buttons via disabled && !isDecided, and isDecided flips when the resolved status comes back over the stream. While the approval turn is executing (seconds, for a slow tool), the Approve button remains clickable; a second click submits a second user.tool_approval turn-create, which — because of the server behavior above — cancels the in-flight approval turn and then dies (duplicate/stale approval), matching the "cancelled with no successor" state we recorded.

Repro

  1. Register an agent with an MCP tool in require_approval_for_tools where the tool + follow-up output takes a few seconds.
  2. Run a mission until the turn pauses on approval.
  3. Click Approve twice ~1–3 s apart (or send any second turn-create that will fail validation while the approval turn is streaming).
  4. Fetch the session's turns: the approval turn is cancelled / cancelled-for-next-turn, the approved tool's side effect has executed, and no next turn exists.

Expected

  • A turn-create request should not destroy the in-flight turn until the new turn is validated and guaranteed to exist (validate → persist → then freeze predecessor, or freeze with rollback).
  • A duplicate approval for an already-resolved approval id should be rejected idempotently without side effects on the running turn.
  • (UI) The approval bar could lock optimistically on first click.

Impact

Any approval-gated action can end up executed with its confirmation/output truncated — awkward for audit-style flows where the post-approval output is the evidence that the action happened. Related but distinct: #397 (sandbox lifecycle on cancel).

Happy to provide the full persisted event dumps for the affected session if useful.

Activity

  1. MachineLearning-Nerd commented on Aug 30, 2026

    @MachineLearning-Nerd
    Author

    Reproduced this end to end against a fresh local 0.1.4 server, with a different model provider than the original report, so it is not model specific.

    Sequence:

    1. Run a mission until the turn pauses on a tool listed in require_approval_for_tools.
    2. Send the approval as a new turn (user.tool_approval input). The approval turn starts, the gated tool executes, and its tool.response streams back.
    3. ~30ms after the tool response, send the exact same approval input again, simulating a double click on Approve.

    Result:

    • The duplicate request is correctly rejected with 422 and no new turn is created (the session still has exactly 2 turns):

      Status code: 422
      {"error":{"message":"thread main: messages[0] no pending approval for tool_call_id 'call_00_YVVj2FqshNnqrLTIzfHo2622'"}}
      
    • The running approval turn is cancelled anyway, timestamped 2ms after the duplicate arrived, before the model produced any post-tool output:

      {"status":"cancelled","reason":"cancelled-for-next-turn","completedAt":"2026-08-30T17:04:20.509Z"}
      

    So a request the server itself rejects as invalid still destroys the in-flight turn. The gated tool's side effect had already executed by then; everything after it (the model's message relaying the result, any follow-up tool calls) is lost.

    repro script (Node, uses @truefoundry/trueforge-sdk)
    import { TrueForge } from "@truefoundry/trueforge-sdk";
    
    const baseUrl = "http://localhost:8891";
    const client = new TrueForge({ baseUrl, timeoutInSeconds: 300 });
    
    // any registered agent with one tool in require_approval_for_tools
    const session = (await client.sessions.create({ agent: { name: "my-agent" } })).data;
    
    let approvalCall = null;
    const stream1 = await client.sessions.createTurnStream(session.id, {
      input: [{ type: "user.message", content: "<mission that ends in the approval-gated tool>" }],
    });
    for await (const item of stream1.withMetadata()) {
      const ev = item.data;
      if (ev.type === "tool.approval_required") {
        approvalCall = { threadId: ev.threadId, toolCallId: ev.toolCalls[0].id };
      }
    }
    
    const approvalInput = [{
      type: "user.tool_approval",
      threadId: approvalCall.threadId,
      toolCallId: approvalCall.toolCallId,
      approval: { status: "allow" },
    }];
    
    let fired = false;
    const stream2 = await client.sessions.createTurnStream(session.id, { input: approvalInput });
    for await (const item of stream2.withMetadata()) {
      const ev = item.data;
      if (ev.type === "tool.response" && !fired) {
        fired = true;
        // duplicate approval while the approval turn is still streaming
        client.sessions.createTurnStream(session.id, { input: approvalInput })
          .then(async (s) => { for await (const it of s.withMetadata()) {} })
          .catch((e) => console.log("duplicate rejected:", e?.message));
      }
      if (ev.type === "turn.done") console.log("approval turn state:", JSON.stringify(ev.state));
    }
    
    const turns = (await client.sessions.listTurns(session.id)).data;
    console.log("turn count:", turns.length);

    The cause matches what the issue describes: SessionHandle.createTurn freezes the previous turn with cancelled-for-next-turn before the new request's input is validated, and there is no rollback when validation then fails.

  2. debajyoti-truefoundry commented on Aug 31, 2026

    @debajyoti-truefoundry
    Contributor

    @MachineLearning-Nerd

    ~30ms after the tool response, send the exact same approval input again

    Is this something that is possible from the TrueForge UI today?

  3. MachineLearning-Nerd commented on Aug 31, 2026

    @MachineLearning-Nerd
    Author

    Yes. This can happen from the bundled TrueForge UI today, although it requires a timing race such as clicking Approve twice. In @truefoundry/trueforge-ui@0.2.4, the approval controls remain enabled while the approval turn is still running. A second click can submit the same user.tool_approval input. The server returns HTTP 422 and creates no new turn for the duplicate, but the request can still cancel the in-flight approval turn, as demonstrated in this issue. The UI should disable the controls immediately after the first click. The server should also validate and persist the new turn before freezing the previous one.

  4. dajiaohuang commented on Sep 4, 2026

    @dajiaohuang

    I verified that current main still freezes the previous turn before validating a new approval/tool-response resume. I prepared a tested fork-only Draft PR at dajiaohuang#1. It adds a read-only running-turn guard, preserves user-message barge-in behavior, and includes regression coverage.

    Validation passed for the focused session suite (12 tests), core typecheck, Prettier, and ESLint. The full core suite had 403 passing tests and one unrelated pre-existing Windows path-separator assertion failure in tests/core/sandbox/Sandbox.paths.test.ts.

    Because the contribution policy requires maintainer approval before community code contributions are submitted upstream, I did not open an upstream PR. If this direction fits the project, please grant the required invitation/approval for an upstream contribution.

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