Summary
Webchat (and other embedded-runtime) sessions silently drop assistant turns from the session JSONL while user turns persist normally. The UI streams the full assistant response, but on the next turn the agent loads the JSONL, sees only user messages, and produces a fresh "first attempt" response — manifesting as confused looping where the agent appears to forget its own answers.
Root cause is a runner-tag gate in agent-command.ts that only persists assistant turns when the executionTrace runner is "cli". Embedded-runtime turns (runner === "embedded") skip the persistence call entirely, and there is no equivalent embedded-path persistence for direct webchat replies.
Affected versions
Reproduced on OpenClaw 2026.5.2 GA (live op014-source as of 2026-05-04). Original incident report 2026-04-25 was on a prior version; gate is still present in current source.
Symptom
sessions.json updates per turn (metadata).
<sessionId>.jsonl writes user turns but skips assistant turns.
.trajectory.jsonl may also stop writing after the regression triggers.
- No error logged; no compaction event; no truncation marker.
- UI renders full assistant responses to each user turn (streamed live).
- Reload the chat in the Control UI: assistant turns missing from history.
- Agent loses its own context across turns; produces fresh first-attempt responses repeatedly.
Concrete instance — 2026-05-04
Session: agent:lt-instructeur, sessionId 4e6424b6-aa18-4256-bb9b-5b40a0f96d98. Window 11:17–11:34 UTC.
- 49 entries total in JSONL.
- Indices 31, 33, 35, 37, 39, 41 are all
role: "user", consecutive. Zero assistant entries between them.
- Webchat UI showed full responses for each.
- No compaction event in window; no model override in this session.
Earlier instance (2026-04-25) was triggered by mid-session model override, but the 2026-05-04 case had no model override, confirming this is not model-override-specific.
Root cause
File: src/agents/agent-command.ts:1166
if (result.meta.executionTrace?.runner === "cli") {
try {
sessionEntry = await attemptExecutionRuntime.persistCliTurnTranscript({...});
sessionEntry = await (
await loadCliCompactionRuntime()
).runCliTurnCompactionLifecycle({...});
...
}
}
persistCliTurnTranscript (src/agents/command/attempt-execution.ts:275) is the path that appends both user and assistant messages to the session JSONL via sessionManager.appendMessage (lines 211 + 219). This is gated on runner === "cli".
When the runner is embedded, the entire block is skipped. Embedded-runtime sets runner: "embedded" on the executionTrace, so this gate fires for any embedded-path turn including direct webchat replies.
The other embedded persistence helper, appendAssistantMessageToSessionTranscript (src/config/sessions/transcript.ts:157), is only called from outbound delivery paths:
src/infra/outbound/deliver.ts:1296
src/infra/outbound/outbound-send-service.ts:156
These fire for mirror deliveries (when the assistant message is being mirrored to an outbound channel like Feishu/Slack), NOT for direct webchat replies. So embedded-runtime webchat sessions have no persistence path for assistant turns.
User turns escape this regression because they are appended via a different upstream path during turn intake, before the runner-gated post-turn persistence block.
Hypothesis: when does runner=embedded vs runner=cli happen?
The original 2026-04-25 instance was triggered by mid-session model override pushing the cli-backend through the embedded wrapper (changing the runner tag from cli to embedded mid-session). The 2026-05-04 lt-instructeur instance had no model override, suggesting the embedded runner can be selected for webchat sessions under other conditions too — possibly any session whose model resolves to a non-cli provider, or any session with active-memory in the prompt-build chain. Confirming this would help identify which sessions are at risk.
Proposed fix paths
Option 1 — Mirror cli persistence in the embedded path (preferred)
Add an else if (runner === "embedded") branch (or refactor to a single call site that picks the right persistence helper) that calls an embedded-equivalent of persistCliTurnTranscript with the right transcript-body shape. Symmetric persistence under both runners.
This is the structurally correct fix. Runner tag carries real semantic meaning (different lifecycle, different compaction path), so the persistence path needs to handle both rather than treat one as the exclusive case.
Option 2 — Fix the runner-tag dispatch
If the persistence semantics are equivalent across runners for webchat-targeted sessions, route those sessions to report runner: "cli" even when running through the embedded wrapper. Less invasive but blurs the runner distinction and may have downstream consequences for compaction / lifecycle.
Workaround in flight
Sergent-Dashboard (an OpenClaw operator-side agent) deployed an auto-repair mechanism on 2026-04-26 that restores lost sessions on UI reload — papers over the symptom but does not fix the underlying drop.
Reproduction
A clean minimal repro is not yet built. Two real-world incidents documented:
- 2026-04-25, sapeur webchat session
e746bd91-..., triggered by mid-session model override (provider=zai, model=glm-5.1 → claude-cli/claude-opus-4-7).
- 2026-05-04, lt-instructeur webchat session
4e6424b6-..., no model override. 6 consecutive user-only JSONL entries, full UI responses for each.
If a minimal repro would be helpful for confirming the fix, I can produce one — let me know.
Related
Summary
Webchat (and other embedded-runtime) sessions silently drop assistant turns from the session JSONL while user turns persist normally. The UI streams the full assistant response, but on the next turn the agent loads the JSONL, sees only user messages, and produces a fresh "first attempt" response — manifesting as confused looping where the agent appears to forget its own answers.
Root cause is a runner-tag gate in
agent-command.tsthat only persists assistant turns when the executionTrace runner is"cli". Embedded-runtime turns (runner === "embedded") skip the persistence call entirely, and there is no equivalent embedded-path persistence for direct webchat replies.Affected versions
Reproduced on OpenClaw 2026.5.2 GA (live op014-source as of 2026-05-04). Original incident report 2026-04-25 was on a prior version; gate is still present in current source.
Symptom
sessions.jsonupdates per turn (metadata).<sessionId>.jsonlwrites user turns but skips assistant turns..trajectory.jsonlmay also stop writing after the regression triggers.Concrete instance — 2026-05-04
Session:
agent:lt-instructeur, sessionId4e6424b6-aa18-4256-bb9b-5b40a0f96d98. Window 11:17–11:34 UTC.role: "user", consecutive. Zero assistant entries between them.Earlier instance (2026-04-25) was triggered by mid-session model override, but the 2026-05-04 case had no model override, confirming this is not model-override-specific.
Root cause
File:
src/agents/agent-command.ts:1166persistCliTurnTranscript(src/agents/command/attempt-execution.ts:275) is the path that appends both user and assistant messages to the session JSONL viasessionManager.appendMessage(lines 211 + 219). This is gated onrunner === "cli".When the runner is
embedded, the entire block is skipped. Embedded-runtime setsrunner: "embedded"on the executionTrace, so this gate fires for any embedded-path turn including direct webchat replies.The other embedded persistence helper,
appendAssistantMessageToSessionTranscript(src/config/sessions/transcript.ts:157), is only called from outbound delivery paths:src/infra/outbound/deliver.ts:1296src/infra/outbound/outbound-send-service.ts:156These fire for mirror deliveries (when the assistant message is being mirrored to an outbound channel like Feishu/Slack), NOT for direct webchat replies. So embedded-runtime webchat sessions have no persistence path for assistant turns.
User turns escape this regression because they are appended via a different upstream path during turn intake, before the runner-gated post-turn persistence block.
Hypothesis: when does runner=embedded vs runner=cli happen?
The original 2026-04-25 instance was triggered by mid-session model override pushing the cli-backend through the embedded wrapper (changing the runner tag from cli to embedded mid-session). The 2026-05-04 lt-instructeur instance had no model override, suggesting the embedded runner can be selected for webchat sessions under other conditions too — possibly any session whose model resolves to a non-cli provider, or any session with active-memory in the prompt-build chain. Confirming this would help identify which sessions are at risk.
Proposed fix paths
Option 1 — Mirror cli persistence in the embedded path (preferred)
Add an
else if (runner === "embedded")branch (or refactor to a single call site that picks the right persistence helper) that calls an embedded-equivalent ofpersistCliTurnTranscriptwith the right transcript-body shape. Symmetric persistence under both runners.This is the structurally correct fix. Runner tag carries real semantic meaning (different lifecycle, different compaction path), so the persistence path needs to handle both rather than treat one as the exclusive case.
Option 2 — Fix the runner-tag dispatch
If the persistence semantics are equivalent across runners for webchat-targeted sessions, route those sessions to report
runner: "cli"even when running through the embedded wrapper. Less invasive but blurs the runner distinction and may have downstream consequences for compaction / lifecycle.Workaround in flight
Sergent-Dashboard(an OpenClaw operator-side agent) deployed an auto-repair mechanism on 2026-04-26 that restores lost sessions on UI reload — papers over the symptom but does not fix the underlying drop.Reproduction
A clean minimal repro is not yet built. Two real-world incidents documented:
e746bd91-..., triggered by mid-session model override (provider=zai, model=glm-5.1→claude-cli/claude-opus-4-7).4e6424b6-..., no model override. 6 consecutive user-only JSONL entries, full UI responses for each.If a minimal repro would be helpful for confirming the fix, I can produce one — let me know.
Related
project_webchat_persistence_regression(Sapeur agent)mor4wzpftzxzj(OpenClaw Command Post)attempt.tscomment block — different bug, similar shape)