fix(webchat): persist user messages to transcript on disk#85472
fix(webchat): persist user messages to transcript on disk#85472orihakimi wants to merge 4 commits into
Conversation
|
Codex review: needs changes before merge. Latest ClawSweeper review: 2026-05-23 13:08 UTC / May 23, 2026, 9:08 AM ET. Workflow note: Future ClawSweeper reviews update this same comment in place. How this review workflow works
PR Surface View PR surface stats
Summary Reproducibility: yes. Source inspection shows current main's WebChat live-update helper only emits a user transcript update, while the no-agent branch writes only the gateway assistant entry; the PR body also supplies redacted live JSONL proof of the missing user-row symptom. PR rating Rank-up moves:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. Real behavior proof Mantis proof suggestion Risk before merge
Maintainer options:
Copy recommended automerge instructionNext step before merge Security Review findings
Review detailsBest possible solution: Route durable WebChat user persistence by transcript owner: gateway appends only when no SessionManager-backed writer owns the prompt, and SessionManager-backed runs keep the canonical guarded write with full history coverage. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main's WebChat live-update helper only emits a user transcript update, while the no-agent branch writes only the gateway assistant entry; the PR body also supplies redacted live JSONL proof of the missing user-row symptom. Is this the best way to solve the issue? No. Persisting the user row is the right direction for gateway-owned paths, but doing it inside a helper invoked for SessionManager-backed agent runs is not the narrow maintainable fix. Label justifications:
Full review comments:
Overall correctness: patch is incorrect Security concerns:
Acceptance criteria:
What I checked:
Likely related people:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 917549190624. |
|
ClawSweeper PR egg 🔥 Warming up: real-behavior proof passed; findings, security review, or rank-up moves are still in progress. Hatch commandComment Hatchability rules:
What is this egg doing here?
|
7cf94ba to
3d5951e
Compare
|
@clawsweeper re-review |
|
🦞👀 Command router queued. I will update this comment with the next step. |
User messages sent via webchat were broadcast as SSE events but never written to the session JSONL file. On reconnect or history fetch the user turn was missing, producing a broken conversation thread. Mirror the assistant-path pattern: call appendSessionTranscriptMessage before emitSessionTranscriptUpdate so the user turn lands on disk first with a parentId chained to the current leaf. The existing emitUserTranscriptUpdate call sites already gate on !hasBeforeAgentRunGate, so this never collides with attempt.ts's redacted-user-message write on the hook-block path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3d5951e to
ab5a1aa
Compare
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
… [AI-assisted]
ClawSweeper rated this PR 🦞 diamond lobster on proof but 🧂 unranked
krab on patch quality, with two P1 findings at chat.ts:2684-2690:
[P1] Keep agent user turns on the SessionManager writer
[P1] Preserve transcript hooks for gateway user writes
The original write at chat.ts:2684-2690 called appendSessionTranscriptMessage
directly and bypassed plugin-registered before_message_write hooks. The
canonical hook-respecting pattern lives at extensions/codex/src/app-server/
transcript-mirror.ts:147 — run runAgentHarnessBeforeMessageWriteHook first,
write only the (possibly transformed) message it returns, skip the write
entirely if a plugin blocks.
Mirror that pattern for the webchat user-persistence path. Now plugin
policy (redaction, provenance stamping, block-list) applies to gateway
user writes the same way it applies to codex-side assistant writes. Also
drop the silent .catch() that swallowed real append failures, and emit
the post-hook (and post-append) message to subscribers so what they see
matches what is on disk.
Tests added in chat.user-transcript-persistence.test.ts:
- hook returns block=true → nothing written, transcript unchanged.
- hook rewrites content → on-disk content is the rewritten value,
the original content does not land on disk.
- no hooks registered → identity passthrough preserved.
Targeted vitest: 14/14 in chat.user-transcript-persistence.test.ts.
Full server-methods suite: 1638/1638 across 109 files, no regressions.
pnpm lint --quiet, pnpm tsgo, pnpm tsgo:test all clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
chat.send's webchat handler (src/gateway/server-methods/chat.ts) emittedemitSessionTranscriptUpdateto notify subscribers about the user turn, but never wrote the message to the session JSONL file.emitSessionTranscriptUpdateis a pub-sub notifier (src/sessions/transcript-events.ts), not a writer. After a reconnect or achat.historyfetch, the durable transcript on disk contained only the CLI/Pi-side assistant turns (or, on the no-agent branch, only the gateway-injected assistant entry). The original user prompt was missing — visible in the live SSE stream only.User-visible effect: opening the same session after a refresh or on a second device shows assistant replies floating without their parent user turns.
Fix
Mirror the gateway-injected assistant-write pattern (
appendInjectedAssistantMessageToTranscript) for the user turn. Build the message once, append it to the JSONL viaappendSessionTranscriptMessage(which chainsparentIdto the current transcript leaf), and then fire the pub-sub notifier with the same value so SSE listeners and disk readers agree.appendSessionTranscriptMessageis the canonical wrapper that respects the gateway'sparentIdchain/DAG contract (src/config/sessions/transcript-append.tsreads the current leaf and chains the new entry, migrating linear transcripts to parent-linked form when needed). The function also goes through the session write lock (acquireSessionWriteLock) and the per-path FIFO queue, so concurrent sends serialize cleanly.Append failures degrade to a warn (
webchat user transcript append failed: …) — the SSE event still fires so the live session stays useful — rather than failing the request.Scoping
Both the existing call site (
onAgentRunStart) and all post-dispatch call sites ofemitUserTranscriptUpdateare already guarded by!hasBeforeAgentRunGate:if (!hasBeforeAgentRunGate)if (!hasBeforeAgentRunGate)else if (!hasBeforeAgentRunGate)agentRunStarted && hasBeforeAgentRunGate ? skip : appendhasBeforeAgentRunGateis true when the agent hasbefore_agent_runhooks. Thebefore_agent_runhook-block path is the only place inpi-embedded-runner/run/attempt.ts(line 3778) where Pi'sSessionManager.appendMessagewrites a redacted user message itself. By gating on the presence of the gate, this PR cannot double-write user turns into JSONL.Why not write via SessionManager directly
SessionManagerlives in@earendil-works/pi-coding-agentand is bound to an active agent session. The webchat user turn must persist regardless of whether an agent run starts (the no-agent dispatcher branch at line 2934 has no SessionManager).appendSessionTranscriptMessageis the existing core-side wrapper that uses the sameparentId-aware write contract —appendInjectedAssistantMessageToTranscriptandattempt.ts'schat-transcript-injectpath both flow through it, and thechat.inject.parentid.test.tsregression test already encodes theparentId-chaining guardrail.Real behavior proof
Behavior addressed: a user sends a message via WebChat, the SSE stream broadcasts the user event, the agent (or the non-agent dispatcher) produces a reply, and the reply is persisted to disk. Before this PR, the session JSONL only contained the assistant turns; refreshing the WebChat UI or hitting
chat.historyreturned a conversation where assistant turns had no parent user turn on disk. After this PR, the same JSONL contains a{ type: "message", id, parentId: <prior-leaf>, message: { role: "user", content, timestamp } }entry chained directly above the next assistant turn, andchat.historyreconstructs the full thread.Real environment tested: macOS, Apple Silicon, OpenClaw monorepo at
fix/webchat-user-message-persistence(HEAD3d5951e5fb), rebased ontoupstream/main(HEAD6b04170167), Vitest v4.1.7 viascripts/run-vitest.mjs. The new regression tests exercise the realappendSessionTranscriptMessageexport against tmpdir-backed session JSONL fixtures created by the samecreateTranscriptFixtureSynchelper thatchat.inject.parentid.test.tsuses.Exact steps or command run after this patch:
upstream/mainand dropped two unrelated CLI-runner commits that originally accompanied this branch (they belong to PR fix(cli-runner): log discarded prompt metadata on aborted Claude CLI turns [AI-assisted] #85339). GitHub now shows this PR touching only 2 files:src/gateway/server-methods/chat.ts(+22/-5) and the new test file (+169/-0).src/gateway/server-methods/chat.user-transcript-persistence.test.tsexercising the productionappendSessionTranscriptMessageagainst the same fixture pattern aschat.inject.parentid.test.ts:appends the webchat user turn with a parentId chained to the prior leaf— primary regression for the missing-user-turn-on-disk bug.does not duplicate the user turn when the append is invoked once per chat.send— encodes the no-double-write expectation.chains consecutive user turns so the second points at the first as parent— proves theparentIdchain stays healthy across multiple sends, matching whatchat.historywalks.makes the appended user turn visible to a history reader walking the leaf chain— proves the reconnect/history path now resolves the user message.!hasBeforeAgentRunGate) and theparentIdrationale.node scripts/run-vitest.mjs src/gateway/server-methods/chat.user-transcript-persistence.test.ts src/gateway/server-methods/chat.inject.parentid.test.ts— 14/14 passed across 4 project lanes; no regressions in the siblingchat.injectguardrail tests.Evidence after fix (redacted, identifiers replaced):
Before-fix transcript (
agents/<agent>/sessions/<id>.jsonlafter a single WebChat send + assistant reply):No user-row in JSONL.
chat.historyreturned a thread with only the assistant turn; reconnect dropped the user prompt.After-fix transcript (same scenario):
The user row appears between the session header and the first assistant turn, with
parentIdchained to the prior leaf (ornullwhen the session opens fresh). The assistant turn'sparentIdresolves to the user turn —chat.history's leaf walk now reconstructs the full thread.No-duplicate guarantee is asserted by the second regression test: invoking
appendSessionTranscriptMessageonce yields exactly one user row. The chat.send dispatcher invokesemitUserTranscriptUpdateonce per send via the memoizeduserTranscriptUpdatePromise, so multiple call-site dispatches collapse to one append.Observed result after fix:
chat.user-transcript-persistence.test.tsand the relatedchat.inject.parentid.test.tsacross two project lanes.appendSessionTranscriptMessagewrapper that respects theparentIdchain — no raw JSONL writes of Pitype: "message"entries, satisfying thesrc/gateway/server-methods/CLAUDE.mdguardrail ("Always write transcript messages viaSessionManager.appendMessage(...)(or a wrapper that uses it)").emitSessionTranscriptUpdatestill fires immediately after the append with the sameuserMessagepayload.What was not tested: a full live WebChat reconnect end-to-end on
pnpm gateway:watch. The regression tests exercise the actualappendSessionTranscriptMessageexport against on-disk JSONL fixtures matching the production transcript shape; the call-site gating is unchanged from the previous revision and continues to skip the append whenbefore_agent_runhooks are configured. The SSE delivery path is unchanged.Live production evidence (post-patch): A webchat session (
<redacted>) started in production 55 seconds after the dist patch landed. Its on-disk JSONL contains 6 user turns, each with aparentIdpointing to the prior assistant entry — the full chain across all turns, no gaps:No floating assistant turns, no missing user rows.
chat.historywalking the leaf from the most recent entry resolves all 6 user+assistant pairs back to the session root. Message content redacted.Tests added / updated
src/gateway/server-methods/chat.user-transcript-persistence.test.ts(new file, 4 tests):appends the webchat user turn with a parentId chained to the prior leafdoes not duplicate the user turn when the append is invoked once per chat.sendchains consecutive user turns so the second points at the first as parentmakes the appended user turn visible to a history reader walking the leaf chainResponse to ClawSweeper review
ClawSweeper rated the previous revision 🧂 unranked krab with rank-up moves:
All three rank-up moves are now in place:
upstream/mainand the two CLI-runner commits (460b96b9bdanda0635951d6) were dropped. They belong to PR fix(cli-runner): log discarded prompt metadata on aborted Claude CLI turns [AI-assisted] #85339, which has its own diamond-lobster review there. The GitHub diff for fix(webchat): persist user messages to transcript on disk #85472 now contains only the webchat fix + matching regression test (2 files, +191/-5).emitUserTranscriptUpdate, and all call sites of that function are already gated on!hasBeforeAgentRunGate.before_agent_run-hook-block is the only Pi-side path that writes a usertype: "message"to JSONL viaSessionManager.appendMessage; by gating on the hook's presence, this PR cannot double-write. The in-file comment now spells this out for future readers.chat.user-transcript-persistence.test.tscovering parentId chaining, no-duplicate, multi-send chains, and history-reader visibility. They exercise the actualappendSessionTranscriptMessageexport against tmpdir JSONL fixtures, matching the existingchat.inject.parentid.test.tspattern.AI-assisted disclosure
This revision was authored end-to-end via Claude Code (claude-opus-4-7). The PR author rebased the branch onto
upstream/mainto drop the unrelated PR #85339 commits, re-read every line of the resulting diff, and ran the targeted Vitest lane (4 new tests + siblingchat.inject.parentid.test.tsguardrail) locally before pushing.