openclaw - 💡(How to fix) Fix Silent send miss: incomplete-turn classifier discards stopReason=stop final after update_plan [1 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When an agent run's last tool call is update_plan (or any tool whose toolResult is empty/no-op), and the model then produces a subsequent plain-text final turn with stopReason="stop", the embedded runtime's incomplete-turn classifier still reads lastAssistant.stopReason === "toolUse" from the previous tool-use snapshot.

That misclassification cascades through three sequential bugs and ends in a silent send miss:

  1. All collected attempt.assistantTexts (including the user's real final answer) are discarded.
  2. They are replaced with a generic { text: "⚠️ Agent couldn't generate a response…", isError: true } payload.
  3. That payload then fails isDeliverablePayload() (text-only, no media/interactive/channelData), so pickDeliverablePayloads returns [].
  4. The delivery loop iterates zero times → WhatsApp/channel send is never invoked.
  5. trace.artifacts.finalStatus = "success" but didSendViaMessagingTool = false and messagingToolSentTexts = []. The user receives nothing. Dashboards report green.

Error Message

const realPayloads = (attempt.assistantTexts ?? []) .filter(t => typeof t === "string" && t.trim().length > 0) .map(text => ({ text, isError: false })); return { payloads: [...realPayloads, { text: incompleteTurnText, isError: true }], ... };

Root Cause

Root Cause — Three bugs in sequence

Fix Action

Fixed

Code Example

[agent/embedded] incomplete turn detected: runId=… stopReason=toolUse payloads=N — surfacing error to user

---

{
  "finalStatus": "success",
  "didSendViaMessagingTool": false,
  "messagingToolSentTexts": [],
  "aborted": false,
  "timedOut": false,
  "compactionCount": 0,
  "usage": { "output": 9652, "cacheRead": 732544 }
}

---

[0] "Her iki dosyayı da okudum. Ciddi mükerrerlik var..."           ( 447 chars)
[1] "AGENTS.md sıkıştırıldı (12KB → 5.8KB). Şimdi MEMORY.md..."     (  73 chars)
[2] "Her iki dosya da sıkıştırıldı. Doğrulama yapıyorum."           (  51 chars)
[3] "Sıkıştırma tamam, doğrulandı. İşte özet: ✅ MEMORY.md..."     (1266 chars) ← the user's real answer

---

{
  "role": "assistant",
  "message": {
    "stopReason": "stop",          // ← clean completion, NOT "toolUse"
    "content": [
      { "type": "thinking", "thinking": "update_plan returned ..." },
      { "type": "text", "text": "Sıkıştırma tamam, doğrulandı..." }   // 1257 chars
    ]
  },
  "usage": { "output": 524 }
}

---

[agent/embedded] incomplete turn detected:
  runId=1c5d5507-ff5f-4120-a042-a1d859d3d39e
  sessionId=f1328505-7fc4-4afd-b7d2-2e986cba7aa8
  stopReason=toolUse payloads=4 — surfacing error to user

---

const realPayloads = (attempt.assistantTexts ?? [])
     .filter(t => typeof t === "string" && t.trim().length > 0)
     .map(text => ({ text, isError: false }));
   return {
     payloads: [...realPayloads, { text: incompleteTurnText, isError: true }],
     ...
   };

---

const hasMedia    = !!payload.mediaUrl || (payload.mediaUrls?.length);
   const hasInteract = (payload.interactive?.blocks?.length);
   const hasChannel  = payload.channelData && Object.keys(payload.channelData).length;
   const hasText     = typeof payload.text === "string" && payload.text.trim().length > 0;
   return hasMedia || hasInteract || hasChannel || hasText;
RAW_BUFFERClick to expand / collapse

Summary

When an agent run's last tool call is update_plan (or any tool whose toolResult is empty/no-op), and the model then produces a subsequent plain-text final turn with stopReason="stop", the embedded runtime's incomplete-turn classifier still reads lastAssistant.stopReason === "toolUse" from the previous tool-use snapshot.

That misclassification cascades through three sequential bugs and ends in a silent send miss:

  1. All collected attempt.assistantTexts (including the user's real final answer) are discarded.
  2. They are replaced with a generic { text: "⚠️ Agent couldn't generate a response…", isError: true } payload.
  3. That payload then fails isDeliverablePayload() (text-only, no media/interactive/channelData), so pickDeliverablePayloads returns [].
  4. The delivery loop iterates zero times → WhatsApp/channel send is never invoked.
  5. trace.artifacts.finalStatus = "success" but didSendViaMessagingTool = false and messagingToolSentTexts = []. The user receives nothing. Dashboards report green.

Environment

  • OpenClaw 2026.5.7 (npm), gitSha 2ba23ce
  • Node 24.14.1, OS Ubuntu 24.04 LTS
  • Channel WhatsApp Cloud API account
  • Provider/Model openrouter/deepseek/deepseek-v4-pro, thinkLevel xhigh

Reproduction (reliable)

  1. Give the agent a multi-step task that ends with a checklist closure (e.g. "compress these two files and verify").
  2. Agent processes: read → read → write → write → verify(via Bash) → update_plan(close checklist).
  3. After update_plan toolResult returns { status: "updated", isError: false, content: [] }, the model is invoked once more and produces a single assistant turn with thinking + visible text and stopReason = "stop" (no further tool calls).
  4. Gateway logs:
    [agent/embedded] incomplete turn detected: runId=… stopReason=toolUse payloads=N — surfacing error to user
  5. No Sending message … log line follows. No entry is appended to delivery-queue/ or delivery-queue/failed/. User receives nothing.

This pattern recurs reliably for any agent that follows checklist discipline (closing every task with update_plan) — i.e. it's the default behavior pattern, not an edge case.

Root Cause — Three bugs in sequence

Bug 1 — Stale lastAssistant snapshot

File: dist/selection-BeP8qtCb.js:1634 (the isIncompleteTerminalAssistantTurn check inside resolveIncompleteTurnPayloadText)

The classifier reads attempt.lastAssistant?.stopReason === "toolUse" but the runtime's state-machine does not synchronously update lastAssistant on the most recent model.completed event. The update_plan turn (stopReason=toolUse) therefore remains as lastAssistant even after the final stopReason=stop turn completes.

Bug 2 — assistantTexts discarded in incomplete branch

File: dist/pi-embedded-Bcz04p2i.js:3275-3318

The incomplete branch returns only [{ text: incompleteTurnText, isError: true }], dropping every entry in attempt.assistantTexts. This is the actual data-loss point — the user's real summary (the latest assistantTexts entry) is thrown away.

Bug 3 — Text-only payloads fail deliverability filter

File: dist/helpers-BalIC4F-.js:102 (isDeliverablePayload) and :119 (pickDeliverablePayloads)

isDeliverablePayload currently requires mediaUrl || mediaUrls.length || interactive.blocks.length || channelData keys. Plain text — including the error fallback inserted by Bug 2 — does not satisfy any of these. pickDeliverablePayloads then returns [], and the deliver loop iterates zero times.

Concrete evidence from one reproduction

trace.artifacts (line 147 of trajectory.jsonl):

{
  "finalStatus": "success",
  "didSendViaMessagingTool": false,
  "messagingToolSentTexts": [],
  "aborted": false,
  "timedOut": false,
  "compactionCount": 0,
  "usage": { "output": 9652, "cacheRead": 732544 }
}

data.assistantTexts (in model.completed, line 146) — 4 entries collected, all discarded:

[0] "Her iki dosyayı da okudum. Ciddi mükerrerlik var..."           ( 447 chars)
[1] "AGENTS.md sıkıştırıldı (12KB → 5.8KB). Şimdi MEMORY.md..."     (  73 chars)
[2] "Her iki dosya da sıkıştırıldı. Doğrulama yapıyorum."           (  51 chars)
[3] "Sıkıştırma tamam, doğrulandı. İşte özet: ✅ MEMORY.md..."     (1266 chars) ← the user's real answer

Session file's actual final assistant message (UTC 05:36:07.020Z):

{
  "role": "assistant",
  "message": {
    "stopReason": "stop",          // ← clean completion, NOT "toolUse"
    "content": [
      { "type": "thinking", "thinking": "update_plan returned ..." },
      { "type": "text", "text": "Sıkıştırma tamam, doğrulandı..." }   // 1257 chars
    ]
  },
  "usage": { "output": 524 }
}

Gateway log line that fires the misclassification:

[agent/embedded] incomplete turn detected:
  runId=1c5d5507-ff5f-4120-a042-a1d859d3d39e
  sessionId=f1328505-7fc4-4afd-b7d2-2e986cba7aa8
  stopReason=toolUse payloads=4 — surfacing error to user

Suggested fixes

P0 — Critical (silent data loss)

  1. Update lastAssistant synchronously on every model.completed so the classifier always sees the actual terminal turn. Alternatively, in isIncompleteTerminalAssistantTurn, walk messagesSnapshot backwards and base the verdict on the most recent assistant message, not on a possibly stale snapshot field.

  2. Fall back to assistantTexts in the incomplete-turn branch (pi-embedded-Bcz04p2i.js:3275). Even if classification is wrong, the user's real output should still be delivered:

    const realPayloads = (attempt.assistantTexts ?? [])
      .filter(t => typeof t === "string" && t.trim().length > 0)
      .map(text => ({ text, isError: false }));
    return {
      payloads: [...realPayloads, { text: incompleteTurnText, isError: true }],
      ...
    };
  3. Allow text-only payloads to be deliverable (helpers-BalIC4F-.js:102):

    const hasMedia    = !!payload.mediaUrl || (payload.mediaUrls?.length);
    const hasInteract = (payload.interactive?.blocks?.length);
    const hasChannel  = payload.channelData && Object.keys(payload.channelData).length;
    const hasText     = typeof payload.text === "string" && payload.text.trim().length > 0;
    return hasMedia || hasInteract || hasChannel || hasText;

    This makes the deliverability check correct for text-first channels (WhatsApp, SMS, Signal, IRC, etc.).

P1 — Observability

  1. Structured alarm: the combination finalStatus="success" + didSendViaMessagingTool=false + messagingToolSentTexts.length===0 should always trigger a log.warn with runId and reason. Currently this state is fully silent.

  2. delivery-queue/failed/ logging: when pickDeliverablePayloads returns [], write a structured failure record so the miss becomes observable to operators.

  3. openclaw recover --run-id <id> helper: read assistantTexts (or messagesSnapshot last assistant message) for a given runId and re-send through the original channel. After this bug fires, operators currently have no first-class recovery path.

P2 — Prevention

  1. Unit test: the flow assistant(toolUse) → toolResult(empty) → assistant(stopReason=stop, text) MUST NOT trigger incomplete-turn detection.

  2. Mark update_plan and similar checklist-closure tools as terminal=false, so the classifier explicitly waits for a subsequent text turn before classifying.

Impact assessment

HIGH. This bug fires for any agent that follows checklist discipline at the end of long tasks (multi-step compress, batch, ship/deploy, research workflows). The failure is silent and reports as success, masking it from any dashboard that watches finalStatus. We observed it on a real production task; the user's compressed-summary output was lost and only recoverable by reading the trajectory JSONL manually.

Happy to submit a PR for any of P0.1–P0.3 if helpful.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix Silent send miss: incomplete-turn classifier discards stopReason=stop final after update_plan [1 pull requests]