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:
- All collected
attempt.assistantTexts (including the user's real final answer) are discarded.
- They are replaced with a generic
{ text: "⚠️ Agent couldn't generate a response…", isError: true } payload.
- That payload then fails
isDeliverablePayload() (text-only, no media/interactive/channelData), so pickDeliverablePayloads returns [].
- The delivery loop iterates zero times → WhatsApp/channel send is never invoked.
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)
- Give the agent a multi-step task that ends with a checklist closure (e.g. "compress these two files and verify").
- Agent processes:
read → read → write → write → verify(via Bash) → update_plan(close checklist).
- 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).
- Gateway logs:
[agent/embedded] incomplete turn detected: runId=… stopReason=toolUse payloads=N — surfacing error to user
- 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):
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):
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)
-
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.
-
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 }],
...
};
-
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
-
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.
-
delivery-queue/failed/ logging: when pickDeliverablePayloads returns [], write a structured failure record so the miss becomes observable to operators.
-
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
-
Unit test: the flow assistant(toolUse) → toolResult(empty) → assistant(stopReason=stop, text) MUST NOT trigger incomplete-turn detection.
-
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.
Summary
When an agent run's last tool call is
update_plan(or any tool whosetoolResultis empty/no-op), and the model then produces a subsequent plain-text final turn withstopReason="stop", the embedded runtime's incomplete-turn classifier still readslastAssistant.stopReason === "toolUse"from the previous tool-use snapshot.That misclassification cascades through three sequential bugs and ends in a silent send miss:
attempt.assistantTexts(including the user's real final answer) are discarded.{ text: "⚠️ Agent couldn't generate a response…", isError: true }payload.isDeliverablePayload()(text-only, no media/interactive/channelData), sopickDeliverablePayloadsreturns[].trace.artifacts.finalStatus = "success"butdidSendViaMessagingTool = falseandmessagingToolSentTexts = []. The user receives nothing. Dashboards report green.Environment
2026.5.7(npm), gitSha2ba23ce24.14.1, OS Ubuntu 24.04 LTSopenrouter/deepseek/deepseek-v4-pro, thinkLevelxhighReproduction (reliable)
read → read → write → write → verify(via Bash) → update_plan(close checklist).update_plantoolResult returns{ status: "updated", isError: false, content: [] }, the model is invoked once more and produces a single assistant turn with thinking + visible text andstopReason = "stop"(no further tool calls).Sending message …log line follows. No entry is appended todelivery-queue/ordelivery-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
lastAssistantsnapshotFile:
dist/selection-BeP8qtCb.js:1634(theisIncompleteTerminalAssistantTurncheck insideresolveIncompleteTurnPayloadText)The classifier reads
attempt.lastAssistant?.stopReason === "toolUse"but the runtime's state-machine does not synchronously updatelastAssistanton the most recentmodel.completedevent. Theupdate_planturn (stopReason=toolUse) therefore remains aslastAssistanteven after the finalstopReason=stopturn completes.Bug 2 —
assistantTextsdiscarded in incomplete branchFile:
dist/pi-embedded-Bcz04p2i.js:3275-3318The incomplete branch returns only
[{ text: incompleteTurnText, isError: true }], dropping every entry inattempt.assistantTexts. This is the actual data-loss point — the user's real summary (the latestassistantTextsentry) is thrown away.Bug 3 — Text-only payloads fail deliverability filter
File:
dist/helpers-BalIC4F-.js:102(isDeliverablePayload) and:119(pickDeliverablePayloads)isDeliverablePayloadcurrently requiresmediaUrl || mediaUrls.length || interactive.blocks.length || channelData keys. Plain text — including the error fallback inserted by Bug 2 — does not satisfy any of these.pickDeliverablePayloadsthen 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(inmodel.completed, line 146) — 4 entries collected, all discarded: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:
Suggested fixes
P0 — Critical (silent data loss)
Update
lastAssistantsynchronously on everymodel.completedso the classifier always sees the actual terminal turn. Alternatively, inisIncompleteTerminalAssistantTurn, walkmessagesSnapshotbackwards and base the verdict on the most recent assistant message, not on a possibly stale snapshot field.Fall back to
assistantTextsin the incomplete-turn branch (pi-embedded-Bcz04p2i.js:3275). Even if classification is wrong, the user's real output should still be delivered:Allow text-only payloads to be deliverable (
helpers-BalIC4F-.js:102):This makes the deliverability check correct for text-first channels (WhatsApp, SMS, Signal, IRC, etc.).
P1 — Observability
Structured alarm: the combination
finalStatus="success"+didSendViaMessagingTool=false+messagingToolSentTexts.length===0should always trigger alog.warnwithrunIdand reason. Currently this state is fully silent.delivery-queue/failed/logging: whenpickDeliverablePayloadsreturns[], write a structured failure record so the miss becomes observable to operators.openclaw recover --run-id <id>helper: readassistantTexts(ormessagesSnapshotlast 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
Unit test: the flow
assistant(toolUse) → toolResult(empty) → assistant(stopReason=stop, text)MUST NOT trigger incomplete-turn detection.Mark
update_planand similar checklist-closure tools asterminal=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.