Skip to content

Webchat persistence regression: embedded-runtime turns skip persistCliTurnTranscript causing silent assistant message drops #77518

Description

@aderius

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:

  1. 2026-04-25, sapeur webchat session e746bd91-..., triggered by mid-session model override (provider=zai, model=glm-5.1claude-cli/claude-opus-4-7).
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions