Skip to content

feat(intercept): reflect edited requests in the dashboard (+ forensic original, edited badge)#55

Merged
lis186 merged 1 commit into
mainfrom
feat/intercept-edit-reflected
Jun 7, 2026
Merged

feat(intercept): reflect edited requests in the dashboard (+ forensic original, edited badge)#55
lis186 merged 1 commit into
mainfrom
feat/intercept-edit-reflected

Conversation

@lis186

@lis186 lis186 commented Jun 7, 2026

Copy link
Copy Markdown
Owner

繁中摘要

修掉一個 dashboard 顯示 bug,並補上鑑識用原文與「已編輯」標記。

  • Bug:經 intercept 編輯並 approve 後,turn detail 仍顯示「編輯前」的 prompt。實際送上游的是編輯後的內容(已實測:改成 say BANANA → Claude 回 BANANA),但 _req.json 在收到當下就寫死、之後從不更新,所以 dashboard(及任何直接讀 _req.json 的消費者)都看到舊的。
  • 解法:forward 在送出後把 _req.json 以 as-sent(編輯後、full 格式)重寫,鏈接在原始寫入之後(不用 Promise.all,避免原始寫入後落地蓋回舊值)。原文保留在非權威_req.received.json sidecar,絕不參與 delta 重建
  • edit summary:server 權威的 buildEditSummary(與既有 SSE 注入提示共用同一份),持久化進 index 並呈現。dashboard 顯示 ✎ EDITED 標記,「原文對照」只在真的有改的那則訊息出現。
  • delta 鏈安全:成功時用編輯後 messages 的 clone 重設錨點;任一持久化失敗則清錨點 → 下一輪改寫 full,杜絕「記憶體錨點 vs 磁碟」裂腦。sessionLastReq 維持 index.js private,經注入 recorder 操作。
  • 範圍僅 AnthropicCodex(OpenAI Responses API)的 intercept 編輯尚未開發,不在此 PR 範圍——屬 Discuss provider-agnostic abstraction to support codex / gemini..etc #20 多 provider 方向下的後續 parity 工作。
  • 驗證:紅先行差異測試(舊碼紅 → 新碼綠)、792 測試全綠、真實 server 端點 smoke、真實 Chrome 完整 DOM/lazy-load 實測。

Problem

The dashboard turn detail showed the pre-edit prompt after an intercept edit. _req.json is written at receipt time (server/index.js) — before the intercept hold and the user's edit — and forwardRequest only ever rewrites _res.json, never _req.json. The edited body was sent upstream correctly (confirmed live: editing the last message to say BANANA produced BANANA), but the persisted request, and therefore loadEntryReqRes and the dashboard it serves, stayed stale. The Claude Code CLI also shows the original because it renders its own local input, independent of the proxy rewrite.

Solution

Persist the request as-sent, keep the original for forensics, and surface the edit:

  1. Canonical _req.json = as-sent. After stripInjectedStats / applyModelPrefix mutate parsedBody, persistEditedRequest(ctx) rewrites _req.json full-format from the edited body (via the pure buildEditedReqRecord). The rewrite is chained onto ctx.reqWritePromise (prior.then(rewrite, rewrite)), never Promise.all, so the receipt-time write cannot land last and restore stale bytes. loadEntryReqRes already awaits entry._writePromise, so the read path observes the rewrite.
  2. Forensic _req.received.json sidecar. The pre-edit body is written before _req.json is overwritten. It is non-authoritative: never a delta anchor, never a prevId splice source, never a token/cost source — purely the "original before edit" view.
  3. Server-authoritative edit summary. buildInterceptModSummary is renamed buildEditSummary and reused for both the existing SSE injection notice and the persisted entry.editSummary. The client is a dumb renderer — no client-side diffing, so restored historical entries show the same summary they had when written.
  4. Delta-chain safety. On success the session anchor is reset to a clone of the edited messages (deltaCount: 0); on any persistence failure the anchor is cleared so the next turn re-anchors full instead of splicing against an unrecoverable base (split-brain mitigation). sessionLastReq stays private to index.js; forward.js mutates it only through an injected setSessionAnchorRecorder.
  5. Hash fidelity. Edited sysHash/toolsHash are recomputed and ctx is updated synchronously at the forward gate so the entry/index reference the as-sent content; the shared sys_*/tools_* files are written (compared against the original hash) inside the same chained promise.

Architecture

receive → index.js writes _req.json (original)          ← unchanged
intercept hold → edit → approve (bodyModified)
forward: stripInjectedStats → applyModelPrefix → bodyToSend  (sent upstream)
   └─ ctx.reqWritePromise = prior.then(persistEditedRequest, persistEditedRequest)
        ① write _req.received.json (original)
        ② edited shared sys/tools (only if changed)
        ③ rewrite _req.json (as-sent, full, no prevId/msgOffset)
        ④ buildEditSummary → ctx.edited / ctx.editSummary
        ⑤ success → recorder(sessionId, id, clone(editedMessages))
           failure → recorder(sessionId, id, null)   // clear → next turn full
read: loadEntryReqRes reconstructs canonical _req.json via the delta chain,
      then (leaf only, edited turns) attaches req.edited / req.editSummary /
      req.original from the sidecar. Delta splices ALWAYS use the canonical
      as-sent messages, never the sidecar.

UI

  • An ✎ EDITED badge plus the server summary render in the request detail (turn-level signal).
  • A collapsible Original before edit appears only on the message that actually changed (an unchanged message no longer shows an identical, misleading "original").
  • No client change beyond public/messages.js (renderEditedBanner) + a few compact styles; the dashboard consumes the server-provided fields.

Verification

  • Differential test (test/intercept-edit-reflect.test.js): the read-contract check is a same-check that fails on the pre-fix code and passes on the fix (the edited flag / original / summary are absent before). Write-seam and split-brain tests cover persistEditedRequest and the anchor-clear path.
  • Full suite: 792 passing, 0 failing.
  • Real running-server smoke: an isolated instance on :5602 returns req.edited, the as-sent body, req.original, and req.editSummary through the real restore → loadEntryReqRes → /_api/entry/:id path.
  • Full browser pipeline: verified in real Chrome that the badge, summary, and per-message original toggle render in the live DOM (lazy-load → buildMergedStepsrenderStepDetailHtmlrenderEditedBanner).

Scope & related

Trade-offs

  • An edited turn writes three files (original _req.json, received sidecar, rewritten _req.json) and is always persisted full-format — extra I/O and lost delta compression for that single turn, accepted because edits are rare and an edit resets the chain anchor anyway.
  • On persistence failure the next turn degrades to a full write — extra space in exchange for never leaving a split-brain anchor.
  • The badge/summary are turn-level (shown on each human step); only the original toggle is per-message.
  • buildInterceptModSummarybuildEditSummary rename (existing test updated).

…original + edited badge

The dashboard turn detail showed the pre-edit prompt because _req.json is
written at receipt time (server/index.js) and never rewritten after an
intercept edit. The edited body was correctly sent upstream (verified live:
editing to "say BANANA" produced "BANANA"), but the persisted record — and
thus the dashboard and any consumer that reads _req.json directly — stayed
stale.

This persists the request as-sent: after the forward-time body mutations,
_req.json is rewritten full-format from the edited body, chained after the
original write (never Promise.all, so the original cannot land last and
restore stale bytes). The pre-edit body is preserved in a non-authoritative
_req.received.json sidecar that never participates in delta reconstruction. A
server-authoritative edit summary (buildEditSummary, reused for the existing
SSE injection notice) is persisted and surfaced. The delta chain is re-anchored
from a clone of the edited messages on success and cleared on any failure, so
the next turn re-anchors full rather than splicing against an unrecoverable
base (split-brain mitigation). sessionLastReq stays private to index.js via an
injected recorder.

The dashboard renders an EDITED badge plus the server summary, with a
collapsible "original before edit" shown only on the message that actually
changed.

Anthropic-only. Codex (OpenAI Responses API) intercept editing is not yet
developed and is out of scope here.

Verified: a differential read-contract test (fail-on-old -> pass-on-new),
792/792 unit+integration tests, a real running-server endpoint smoke, and a
full real-Chrome DOM/lazy-load check of the badge and per-message toggle.
@lis186 lis186 merged commit 167475e into main Jun 7, 2026
2 checks passed
@lis186 lis186 deleted the feat/intercept-edit-reflected branch June 7, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant