Skip to content

[Bug]: WebChat responses invisible when agent uses CLI provider — transcript not persisted before history reload #68582

@martinlegend-ctrl

Description

@martinlegend-ctrl

Bug type

Behavior bug (incorrect output/state without crash)

Beta release blocker

No

Summary

When an agent runs via a CLI provider (e.g. google-gemini-cli, openai-codex), responses are generated and broadcast to the WebChat UI correctly, but never actually appear — every reply disappears after a brief flash or is never shown at all. All other channels (TUI, Matrix/Element, Telegram, Signal) work correctly. Only WebChat is affected.

Steps to reproduce

  1. Configure an agent to use a CLI provider (e.g. google-gemini-cli)
  2. Open WebChat (localhost:<port>/control-ui/)
  3. Send any message
  4. Observe: CLI runs (visible in logs), response is generated, but WebChat shows nothing

Expected behavior

WebChat displays the response, consistent with TUI/Matrix behavior.

Actual behavior

WebChat shows no new message. Old messages remain visible. Every new reply disappears.

Root cause (confirmed via source code)

Two behaviors combine to produce the bug:

1. CLI path never persists the assistant response to the session JSONL transcript

In agent-runner.runtime-*.js, the CLI branch (inside if (isCliProvider(...))) emits the response via emitAgentEvent({stream:'assistant', ...}) and then emitAgentEvent({stream:'lifecycle', data:{phase:'end', startedAt:..., endedAt:...}}), which triggers emitChatFinal in createAgentEventHandler.

emitChatFinal broadcasts the response but never writes it to the session JSONL file.

The embedded runner (pi-coding-agent) DOES persist internally (before emitting the lifecycle end event), which is why all non-CLI paths work correctly.

2. WebChat client unconditionally calls chat.history after every state: final event

In control-ui/assets/index-*.js, function nO(e, t):

if (r === 'final' && !i && jD(t)) { up(e); return; }

up(e) fetches chat.history from the server and overwrites chatMessages with the result. This was presumably designed to work with the embedded runner path (where the transcript IS saved before broadcast), but breaks for the CLI path.

Combined effect: Broadcast arrives → bp() adds message to UI → nO() reloads history → server returns old JSONL (no CLI response saved) → chatMessages overwritten → message gone.

Proposed fix

File: dist/server.impl-GQ72oJBa.js (or equivalent in source)

The CLI path can be identified by the presence of evt.data.startedAt in the lifecycle end event — the CLI runner always includes it, the embedded runner does not (it uses livenessState instead). This is a stable structural discriminator.

Add transcript persistence in emitChatFinal for CLI-path runs:

// Before the broadcast("chat", payload) call in the jobState === "done" branch:
if (isCli && text && !shouldSuppressSilent) {
    try {
        const { storePath: _sp, entry: _e } = loadSessionEntry(sessionKey);
        const _aid = resolveAgentIdFromSessionKey(sessionKey) ?? 'main';
        const _ensured = ensureSessionTranscriptFile({
            sessionId: _e?.sessionId ?? clientRunId,
            sessionFile: _e?.sessionFile,
            storePath: _sp,
            agentId: _aid
        });
        if (_ensured.ok)
            SessionManager.open(_ensured.transcriptPath).appendMessage({
                role: 'assistant',
                content: [{ type: 'text', text }],
                timestamp: Date.now(),
                stopReason: 'stop',
                usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
                         cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
            });
    } catch (err) {
        console.warn(`[cli-transcript] save failed: sessionKey=${sessionKey} err=${String(err)}`);
    }
}

Where isCli = evt.data?.startedAt !== undefined is passed from finalizeLifecycleEvent at both call sites.

All symbols (loadSessionEntry, resolveAgentIdFromSessionKey, ensureSessionTranscriptFile, SessionManager) are already imported/defined in server.impl-GQ72oJBa.js.

Idempotency: emitChatFinal deletes the buffer on first execution, so any duplicate invocation has text = "" and the guard prevents double-writes.

Longer-term recommendation

The root issue is that the CLI and embedded paths have asymmetric transcript persistence. The correct long-term fix is to make ALL execution paths (CLI and embedded) persist the assistant response to JSONL before broadcasting state: final, regardless of which runner was used. The dispatchInboundMessage callback chain is the natural place to unify this.

Version

openclaw v2026.4.15

Channels affected

  • ❌ WebChat
  • ✅ TUI (unaffected)
  • ✅ Matrix/Element (unaffected)
  • ✅ Telegram (unaffected)
  • ✅ Signal (unaffected)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions