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
- Configure an agent to use a CLI provider (e.g.
google-gemini-cli)
- Open WebChat (
localhost:<port>/control-ui/)
- Send any message
- 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)
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
google-gemini-cli)localhost:<port>/control-ui/)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 (insideif (isCliProvider(...))) emits the response viaemitAgentEvent({stream:'assistant', ...})and thenemitAgentEvent({stream:'lifecycle', data:{phase:'end', startedAt:..., endedAt:...}}), which triggersemitChatFinalincreateAgentEventHandler.emitChatFinalbroadcasts 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.historyafter everystate: finaleventIn
control-ui/assets/index-*.js, functionnO(e, t):up(e)fetcheschat.historyfrom the server and overwriteschatMessageswith 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) →chatMessagesoverwritten → 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.startedAtin the lifecycle end event — the CLI runner always includes it, the embedded runner does not (it useslivenessStateinstead). This is a stable structural discriminator.Add transcript persistence in
emitChatFinalfor CLI-path runs:Where
isCli = evt.data?.startedAt !== undefinedis passed fromfinalizeLifecycleEventat both call sites.All symbols (
loadSessionEntry,resolveAgentIdFromSessionKey,ensureSessionTranscriptFile,SessionManager) are already imported/defined inserver.impl-GQ72oJBa.js.Idempotency:
emitChatFinaldeletes the buffer on first execution, so any duplicate invocation hastext = ""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. ThedispatchInboundMessagecallback chain is the natural place to unify this.Version
openclaw v2026.4.15
Channels affected