From 51a20cefa7c4c75bfc2d4807aece86f4267ba08b Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Sun, 7 Jun 2026 21:03:10 +0800 Subject: [PATCH] feat(intercept): reflect edited requests in the dashboard + forensic original + edited badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/messages.js | 24 +++- public/style.css | 8 ++ server/delta-helpers.js | 22 ++- server/entry.js | 1 + server/forward.js | 173 +++++++++++++++++++++--- server/index.js | 14 +- server/restore.js | 24 +++- test/intercept-edit-reflect.test.js | 199 ++++++++++++++++++++++++++++ test/intercept-mod-summary.test.js | 62 +++++++++ test/messages-ui.test.js | 47 +++++++ 10 files changed, 549 insertions(+), 25 deletions(-) create mode 100644 test/intercept-edit-reflect.test.js create mode 100644 test/intercept-mod-summary.test.js diff --git a/public/messages.js b/public/messages.js index d2d71d6..3e7b1af 100644 --- a/public/messages.js +++ b/public/messages.js @@ -549,6 +549,28 @@ function getActiveStepKey() { } // Render step detail content as HTML +// Server-authoritative "edited via intercept" banner. The proxy persists the +// as-sent request as canonical and exposes req.edited, req.editSummary (the diff +// lines), and req.original (the pre-edit body from the forensic received +// sidecar). Dumb renderer — no client-side diffing. +function renderEditedBanner(req, msgIdx) { + if (!req || !req.edited) return ''; + const summary = (req.editSummary || []) + .map(l => '
' + escapeHtml(l) + '
').join(''); + // Show the "original before edit" toggle only when THIS message actually + // changed — otherwise an unedited message would show an identical "original". + const origMsg = req.original && Array.isArray(req.original.messages) ? req.original.messages[msgIdx] : null; + const curMsg = req.messages ? req.messages[msgIdx] : null; + const changed = origMsg && curMsg && JSON.stringify(origMsg) !== JSON.stringify(curMsg); + const original = changed + ? '
Original before edit' + + renderSingleMessage(origMsg, null, msgIdx) + '
' + : ''; + return '
✎ EDITED' + + (summary ? '
' + summary + '
' : '') + + original + '
'; +} + function renderStepDetailHtml(req, tok) { if (selectedMessageIdx < 0) return ''; const selection = typeof getSelectedStepSelection === 'function' ? getSelectedStepSelection() : null; @@ -561,7 +583,7 @@ function renderStepDetailHtml(req, tok) { if (step.type === 'human') { const msgIdx = step.msgIndices[0]; const msg = req?.messages?.[msgIdx]; - return msg ? '
' + renderSingleMessage(msg, tok?.perMessage?.[msgIdx], msgIdx) + '
' : '
No message
'; + return msg ? '
' + renderEditedBanner(req, msgIdx) + renderSingleMessage(msg, tok?.perMessage?.[msgIdx], msgIdx) + '
' : '
No message
'; } else if (step.type === 'assistant-text') { return '
' + highlightCredentials(step.text || '') + '
'; } else if (step.type === 'tool-group') { diff --git a/public/style.css b/public/style.css index 7438f8e..b3ace45 100644 --- a/public/style.css +++ b/public/style.css @@ -643,3 +643,11 @@ .detail-copy-btn:hover { color: var(--text); background: var(--surface); } .detail-input-details summary { cursor: pointer; font-size: 11px; color: var(--yellow); margin-bottom: 2px; } .detail-input-details { margin-bottom: 6px; padding: 6px 8px; background: var(--bg); border-radius: 4px; } + + /* Intercept-edited request banner (server-authoritative; see renderEditedBanner) */ + .edit-banner { margin-bottom: 8px; padding: 6px 8px; background: var(--bg); border: 1px solid var(--border); border-left: 3px solid var(--yellow); border-radius: 4px; } + .edit-badge { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: 0.04em; color: var(--bg); background: var(--yellow); padding: 1px 6px; border-radius: 3px; } + .edit-summary { margin-top: 4px; } + .edit-summary-line { font-size: 11px; color: var(--dim); white-space: pre-wrap; word-break: break-word; } + .edit-original { margin-top: 6px; } + .edit-original summary { cursor: pointer; font-size: 11px; color: var(--yellow); } diff --git a/server/delta-helpers.js b/server/delta-helpers.js index 5529ab7..bcac210 100644 --- a/server/delta-helpers.js +++ b/server/delta-helpers.js @@ -47,4 +47,24 @@ function findSharedPrefixFromLast(prevLastMsg, prevCount, currMsgs) { return prevCount; } -module.exports = { msgNorm, findSharedPrefix, findSharedPrefixFromLast }; +// Build a FULL-format _req.json record from an edited (intercept-approved) body. +// The edited turn is always persisted full — never delta — because the edit may +// touch a message inside what would have been the shared prefix, and a delta +// stores only the suffix, so a delta record could silently serve original +// content for an edited prefix index on the read path. Writing full sidesteps +// that and makes the edited turn a fresh chain anchor. system/tools stay +// content-addressed via sysHash/toolsHash (the caller writes the shared files +// when the hashes changed). +function buildEditedReqRecord(parsedBody, { sysHash = null, toolsHash = null, sessionId = null } = {}) { + const messages = Array.isArray(parsedBody && parsedBody.messages) ? parsedBody.messages : []; + return { + model: parsedBody ? parsedBody.model : undefined, + max_tokens: parsedBody ? parsedBody.max_tokens : undefined, + messages, + sysHash, + toolsHash, + ...(sessionId ? { metadata: { session_id: sessionId } } : {}), + }; +} + +module.exports = { msgNorm, findSharedPrefix, findSharedPrefixFromLast, buildEditedReqRecord }; diff --git a/server/entry.js b/server/entry.js index 5c38f1e..76ab0cc 100644 --- a/server/entry.js +++ b/server/entry.js @@ -5,6 +5,7 @@ const INDEX_FIELDS = [ 'isSubagent','sessionInferred','cwd','isSSE','usage','cost','maxContext','responseMetadata', 'stopReason','title','thinkingDuration','toolFail','elapsed','status','receivedAt', 'sysHash','toolsHash','coreHash','thinkingStripped','hasCredential','toolSources', + 'edited','editSummary', ]; function buildIndexLine(entry) { diff --git a/server/forward.js b/server/forward.js index 342675b..29a0562 100644 --- a/server/forward.js +++ b/server/forward.js @@ -3,8 +3,10 @@ const https = require('https'); const http = require('http'); const tls = require('tls'); +const crypto = require('crypto'); const config = require('./config'); const store = require('./store'); +const { buildEditedReqRecord } = require('./delta-helpers'); const { calculateCost } = require('./pricing'); const helpers = require('./helpers'); const { broadcast, broadcastSessionStatus, broadcastSessionTitleUpdate } = require('./sse-broadcast'); @@ -242,6 +244,130 @@ function stripInjectedStats(parsedBody) { return modified; } +// Build the human-readable diff lines injected into the response stream when a +// request was edited via dashboard intercept. Pure function so it can be tested +// independently of the SSE plumbing. Returns an array of one-line strings. +function buildEditSummary(orig, mod, opts) { + const MAX_SHOWN = (opts && opts.maxShown) || 5; + const MAX_LEN = (opts && opts.maxLen) || 60; + const diffs = []; + if (!orig || !mod) return diffs; + + const snippet = (c) => { + if (c == null) return ''; + const s = typeof c === 'string' ? c : JSON.stringify(c); + const flat = s.replace(/\s+/g, ' ').trim(); + return flat.length > MAX_LEN ? flat.slice(0, MAX_LEN) + '…' : flat; + }; + + if (orig.model !== mod.model) diffs.push('Model: ' + orig.model + ' → ' + mod.model); + + const origMsgs = orig.messages || []; + const modMsgs = mod.messages || []; + if (origMsgs.length !== modMsgs.length) { + diffs.push('Messages: ' + origMsgs.length + ' → ' + modMsgs.length); + } + + // Per-message edits: show old → new snippet, not just a count, so the CLI + // notice tells the user what actually changed. + const edited = []; + for (let i = 0; i < modMsgs.length; i++) { + const o = origMsgs[i]; + if (!o) continue; + const oStr = typeof o.content === 'string' ? o.content : JSON.stringify(o.content); + const mStr = typeof modMsgs[i].content === 'string' ? modMsgs[i].content : JSON.stringify(modMsgs[i].content); + if (oStr !== mStr) { + edited.push((modMsgs[i].role || 'msg') + '[' + i + ']: "' + snippet(o.content) + '" → "' + snippet(modMsgs[i].content) + '"'); + } + } + for (const line of edited.slice(0, MAX_SHOWN)) diffs.push(line); + if (edited.length > MAX_SHOWN) diffs.push('…and ' + (edited.length - MAX_SHOWN) + ' more message(s) edited'); + + const origToolLen = (orig.tools || []).length; + const modToolLen = (mod.tools || []).length; + if (origToolLen !== modToolLen) { + diffs.push('Tools: ' + origToolLen + ' → ' + modToolLen + ' (' + (modToolLen - origToolLen >= 0 ? '+' : '') + (modToolLen - origToolLen) + ')'); + } + + const origSys = typeof orig.system === 'string' ? orig.system : JSON.stringify(orig.system); + const modSys = typeof mod.system === 'string' ? mod.system : JSON.stringify(mod.system); + if (origSys !== modSys) diffs.push('System prompt: "' + snippet(orig.system) + '" → "' + snippet(mod.system) + '"'); + + return diffs; +} + +// ── Intercept-edit persistence ─────────────────────────────────────── +// sessionLastReq (the per-session delta anchor) lives privately in index.js. +// It injects a narrow recorder so this module can re-anchor the chain after an +// edited rewrite (messages = clone of edited) or CLEAR it on failure (messages +// = null → next turn re-anchors full). Keeps mutable session state encapsulated. +let sessionAnchorRecorder = null; +function setSessionAnchorRecorder(fn) { sessionAnchorRecorder = fn; } + +function sha12(value) { + return value == null ? null + : crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 12); +} + +// Persist an intercept-edited request. _req.json was written at receipt time +// (index.js) from the ORIGINAL body; this rewrites it as-sent so the dashboard +// shows what actually went upstream, while preserving the original in a +// non-authoritative `_req.received.json` sidecar (forensics + the "original +// before edit" view). Order matters: write the received sidecar and edited +// shared sys/tools FIRST, then overwrite the canonical _req.json, so a lazy load +// never observes a half-applied edit. On full success re-anchor the delta chain +// from a CLONE of the edited messages; on ANY failure clear the anchor so the +// next turn re-anchors full (no split-brain between disk and the in-memory anchor). +// Returns a promise; the caller folds it into ctx.reqWritePromise. +async function persistEditedRequest(ctx) { + const { id, parsedBody, originalBody, reqSessionId } = ctx; + if (!parsedBody) return; + + ctx.edited = true; + ctx.editSummary = ctx.editSummary || buildEditSummary(originalBody, parsedBody); + + try { + // 1. Forensic original, preserved before _req.json is overwritten. + if (originalBody) { + await config.storage.write(id, '_req.received.json', JSON.stringify(originalBody)); + } + + // 2. Edited system/tools, content-addressed. Write the shared file only when + // the edit actually changed it (compare against the ORIGINAL hash, not + // ctx.sysHash, which the forward gate may have already set to the edited + // value). Update ctx so entry/index metadata point at the edited content. + const editedSysHash = parsedBody.system ? sha12(parsedBody.system) : null; + const editedToolsHash = parsedBody.tools ? sha12(parsedBody.tools) : null; + const origSysHash = originalBody && originalBody.system ? sha12(originalBody.system) : null; + const origToolsHash = originalBody && originalBody.tools ? sha12(originalBody.tools) : null; + if (editedSysHash && editedSysHash !== origSysHash) { + await config.storage.writeSharedIfAbsent(`sys_${editedSysHash}.json`, JSON.stringify(parsedBody.system)); + } + if (editedToolsHash && editedToolsHash !== origToolsHash) { + await config.storage.writeSharedIfAbsent(`tools_${editedToolsHash}.json`, JSON.stringify(parsedBody.tools)); + } + ctx.sysHash = editedSysHash; + ctx.toolsHash = editedToolsHash; + + // 3. Canonical _req.json, as-sent, full format (no prevId/msgOffset). + const record = buildEditedReqRecord(parsedBody, { + sysHash: editedSysHash, toolsHash: editedToolsHash, sessionId: reqSessionId, + }); + await config.storage.write(id, '_req.json', JSON.stringify(record)); + + // 4. Re-anchor the delta chain from a CLONE (never the live parsedBody array), + // only for delta-eligible turns (explicit session + delta-capable storage). + if (reqSessionId && config.storage.supportsDelta && sessionAnchorRecorder) { + sessionAnchorRecorder(reqSessionId, id, JSON.parse(JSON.stringify(record.messages))); + } + } catch (e) { + console.error('Edited request persistence failed:', e.message); + // Split-brain mitigation: disk is uncertain → clear the anchor so the next + // turn writes FULL rather than a delta against an unrecoverable base. + if (reqSessionId && sessionAnchorRecorder) sessionAnchorRecorder(reqSessionId, id, null); + } +} + // ── Forward request to Anthropic ───────────────────────────────────── function forwardRequest(ctx) { const { id, ts, startTime, parsedBody, rawBody, clientReq, clientRes, fwdHeaders, reqSessionId } = ctx; @@ -291,6 +417,26 @@ function forwardRequest(ctx) { const modelPrefixed = applyModelPrefix(parsedBody, config.REWRITE_MODEL_PREFIX); const bodyToSend = (ctx.bodyModified || statsStripped || modelPrefixed) ? Buffer.from(JSON.stringify(parsedBody)) : rawBody; + // Intercept-edited body: persist it as-sent (rewrite _req.json) + a forensic + // _req.received.json. This runs AFTER stripInjectedStats/applyModelPrefix so + // the persisted record matches the bytes actually sent. Chain after the + // original receipt-time write (never Promise.all — the original must not land + // last and restore stale bytes); loadEntryReqRes awaits entry._writePromise, + // which bundles ctx.reqWritePromise, so the read path observes the rewrite. + if (ctx.bodyModified && provider === 'anthropic' && !ctx.skipEntry) { + // Set edited state + as-sent hashes synchronously so the entry built in the + // response handler (and its index line) reference the edited content + // regardless of when the async persist runs. persist decides shared-file + // writes by comparing against the ORIGINAL hashes (from originalBody), so + // mutating ctx.sysHash/toolsHash here does not affect that decision. + ctx.edited = true; + ctx.editSummary = buildEditSummary(ctx.originalBody, parsedBody); + ctx.sysHash = parsedBody.system ? sha12(parsedBody.system) : null; + ctx.toolsHash = parsedBody.tools ? sha12(parsedBody.tools) : null; + const prior = ctx.reqWritePromise || Promise.resolve(); + ctx.reqWritePromise = prior.then(() => persistEditedRequest(ctx), () => persistEditedRequest(ctx)); + } + const transport = upstream.protocol === 'http' ? http : https; const tunnelAgent = getTunnelAgent(upstream); const proxyReq = transport.request({ @@ -471,27 +617,7 @@ function handleSSEResponse(ctx, proxyRes, clientRes) { // Inject intercept modification summary if (ctx.bodyModified && ctx.originalBody) { - const orig = ctx.originalBody; - const mod = parsedBody; - const diffs = []; - if (orig.model !== mod.model) diffs.push('Model: ' + orig.model + ' → ' + mod.model); - const origMsgLen = (orig.messages || []).length; - const modMsgLen = (mod.messages || []).length; - if (origMsgLen !== modMsgLen) diffs.push('Messages: ' + origMsgLen + ' → ' + modMsgLen); - const msgEdits = (mod.messages || []).reduce((cnt, m, i) => { - const o = (orig.messages || [])[i]; - if (!o) return cnt; - const oStr = typeof o.content === 'string' ? o.content : JSON.stringify(o.content); - const mStr = typeof m.content === 'string' ? m.content : JSON.stringify(m.content); - return oStr !== mStr ? cnt + 1 : cnt; - }, 0); - if (msgEdits > 0) diffs.push(msgEdits + ' message(s) edited'); - const origToolLen = (orig.tools || []).length; - const modToolLen = (mod.tools || []).length; - if (origToolLen !== modToolLen) diffs.push('Tools: ' + origToolLen + ' → ' + modToolLen + ' (' + (modToolLen - origToolLen) + ')'); - const origSys = typeof orig.system === 'string' ? orig.system : JSON.stringify(orig.system); - const modSys = typeof mod.system === 'string' ? mod.system : JSON.stringify(mod.system); - if (origSys !== modSys) diffs.push('System prompt: modified'); + const diffs = buildEditSummary(ctx.originalBody, parsedBody); if (diffs.length > 0) { const interceptIdx = maxBlockIndex + (usage && totalCtx && stopReason !== 'tool_use' ? 2 : 1); const iText = '\n\n---\n🔀 Request was modified by dashboard intercept:\n ' + diffs.join('\n '); @@ -532,6 +658,7 @@ function handleSSEResponse(ctx, proxyRes, clientRes) { req: parsedBody, res: events, elapsed, status: proxyRes.statusCode, isSSE: true, receivedAt: startTime, + edited: ctx.edited, editSummary: ctx.editSummary, tokens: helpers.tokenizeRequest(parsedBody), duplicateToolCalls: helpers.extractDuplicateToolCalls(parsedBody?.messages), ...getParser('anthropic').buildEntryFields({ @@ -750,6 +877,7 @@ function handleNonSSEResponse(ctx, proxyRes, clientRes) { req: parsedBody, res: resData, elapsed, status: proxyRes.statusCode, isSSE: false, receivedAt: startTime, + edited: ctx.edited, editSummary: ctx.editSummary, tokens: helpers.tokenizeRequest(parsedBody), duplicateToolCalls: helpers.extractDuplicateToolCalls(parsedBody?.messages), ...getParser('anthropic').buildEntryFields({ @@ -797,9 +925,12 @@ function handleNonSSEResponse(ctx, proxyRes, clientRes) { module.exports = { forwardRequest, + persistEditedRequest, + setSessionAnchorRecorder, resolveProxyAgent, applyModelPrefix, stripInjectedStats, + buildEditSummary, setStatusLineEnabled, getStatusLineEnabled, parseSSEFrame, diff --git a/server/index.js b/server/index.js index b40da54..fb95415 100755 --- a/server/index.js +++ b/server/index.js @@ -11,7 +11,7 @@ const helpers = require('./helpers'); const { fetchPricing } = require('./pricing'); const { restoreFromLogs, pruneLogs } = require('./restore'); const { warmUp: warmUpCosts } = require('./cost-budget'); -const { forwardRequest, setStatusLineEnabled, getStatusLineEnabled } = require('./forward'); +const { forwardRequest, setStatusLineEnabled, getStatusLineEnabled, setSessionAnchorRecorder } = require('./forward'); const { readSettings } = require('./settings'); const { broadcastSessionStatus, broadcastPendingRequest } = require('./sse-broadcast'); const { dispatch, mintAutoOpenUrl, formatAutoOpenUrl } = require('./auth'); @@ -86,6 +86,18 @@ if (agentMode || hubMode) console.log = () => {}; // Only populated for sessions with explicit session_id (main orchestrator turns). const sessionLastReq = new Map(); // sessionId → { id, messages, deltaCount } +// Narrow recorder injected into forward.js so the intercept-edit rewrite can +// re-anchor this private map without owning it. messages == null clears the +// anchor (delete) so the next turn re-anchors FULL instead of writing a delta +// against an unrecoverable base (split-brain mitigation on rewrite failure); +// otherwise it sets the edited turn as a fresh full anchor (deltaCount 0). +function recordSessionAnchor(sessionId, anchorId, messages) { + if (!sessionId) return; + if (messages == null) { sessionLastReq.delete(sessionId); return; } + sessionLastReq.set(sessionId, { id: anchorId, messages, deltaCount: 0 }); +} +setSessionAnchorRecorder(recordSessionAnchor); + // Route handlers const { handleSSERoute } = require('./routes/sse'); const { handleApiRoutes } = require('./routes/api'); diff --git a/server/restore.js b/server/restore.js index 041d70e..32f62ad 100644 --- a/server/restore.js +++ b/server/restore.js @@ -60,6 +60,28 @@ function loadEntryReqRes(entry) { delete entry.req.prevId; delete entry.req.msgOffset; } + + // Intercept-edited turn: surface the edited flag, the server-authoritative + // edit summary (for the badge), and the forensic original from the + // _req.received.json sidecar (the "original before edit" view). Display-only: + // the sidecar is NEVER spliced for delta reconstruction (line ~52 always + // uses the canonical as-sent _req.json messages). + if (entry.edited && entry.req) { + entry.req.edited = true; + entry.req.editSummary = Array.isArray(entry.editSummary) ? entry.editSummary : []; + try { + const original = JSON.parse(await config.storage.read(entry.id, '_req.received.json')); + const oSys = original.sysHash + ? await config.storage.readShared(`sys_${original.sysHash}.json`).then(JSON.parse).catch(() => null) + : (original.system != null ? original.system : null); + const oTools = original.toolsHash + ? await config.storage.readShared(`tools_${original.toolsHash}.json`).then(JSON.parse).catch(() => null) + : (original.tools != null ? original.tools : null); + entry.req.original = { ...original, system: oSys, tools: oTools }; + delete entry.req.original.sysHash; + delete entry.req.original.toolsHash; + } catch { /* sidecar missing → degrade: edited flag + summary still shown */ } + } } catch { entry.req = null; } try { const raw = await config.storage.read(entry.id, '_res.json'); @@ -312,7 +334,7 @@ async function pruneLogs() { let deleted = 0, kept = 0; for (const filename of files) { - const m = filename.match(/^(\d{4}-\d{2}-\d{2}T.*?)_(req|res)\.json$/); + const m = filename.match(/^(\d{4}-\d{2}-\d{2}T.*?)_(req\.received|req|res)\.json$/); if (!m) continue; if (filename.slice(0, 10) >= cutoffStr) continue; if (protectedIds.has(m[1])) { kept++; continue; } diff --git a/test/intercept-edit-reflect.test.js b/test/intercept-edit-reflect.test.js new file mode 100644 index 0000000..b2685e2 --- /dev/null +++ b/test/intercept-edit-reflect.test.js @@ -0,0 +1,199 @@ +'use strict'; + +// Differential tests for: "an intercept-edited request is reflected in the +// dashboard, with a forensic received-sidecar and an edited badge". +// +// Per the verification doctrine: each check below FAILS on the pre-fix code and +// PASSES on the fixed code (fail-on-old / pass-on-new), and asserts an OBSERVABLE +// result (what loadEntryReqRes — the function the dashboard endpoint serves — +// returns, and what lands on disk), not an implementation detail. +// +// T-READ : same-check on the read contract (runs on old + new; old returns +// no edited/original/editSummary → red). +// T-WRITE : the write seam persistEditedRequest persists as-sent _req.json + +// forensic _req.received.json + sets ctx.edited/editSummary + +// re-anchors the delta chain (absent on old → red). +// T-DELTA : a later delta whose prevId points at an edited turn splices the +// canonical EDITED messages, never the received sidecar. +// T-SPLIT : if the _req.json rewrite fails, the anchor is CLEARED (recorder +// called with null) so the next turn re-anchors full (no split-brain). + +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const config = require('../server/config'); +const store = require('../server/store'); +const { createLocalStorage } = require('../server/storage/local'); +const { loadEntryReqRes } = require('../server/restore'); +const forward = require('../server/forward'); + +const ORIG = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + { role: 'user', content: 'say X' }, +]; +const EDITED = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + { role: 'user', content: 'say BANANA' }, +]; + +describe('intercept edit reflected in dashboard (+ received sidecar + badge)', () => { + const tmpDir = path.join(os.tmpdir(), 'ccxray-edit-reflect-' + process.pid); + let realStorage; + + before(async () => { + realStorage = config.storage; + const tmpStorage = createLocalStorage(tmpDir); + await tmpStorage.init(); + config.storage = tmpStorage; + }); + + after(() => { + config.storage = realStorage; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + store.entries.length = 0; + for (const sid of Object.keys(store.sessionMeta)) delete store.sessionMeta[sid]; + }); + + function pushEntry(fields) { + const entry = { req: null, res: null, _loaded: false, ...fields }; + store.entries.push(entry); + return entry; + } + + // ── T-READ: the read contract surfaces edited flag + original + summary ── + it('T-READ: loadEntryReqRes exposes edited flag, original body, and editSummary', async () => { + const id = 'edited-001'; + // Canonical _req.json is the AS-SENT (edited) body; received sidecar holds original. + await config.storage.write(id, '_req.json', JSON.stringify({ model: 'm', max_tokens: 10, messages: EDITED })); + await config.storage.write(id, '_req.received.json', JSON.stringify({ model: 'm', max_tokens: 10, messages: ORIG })); + const summary = ['user[2]: "say X" → "say BANANA"']; + const entry = pushEntry({ id, edited: true, editSummary: summary }); + + await loadEntryReqRes(entry); + + // Canonical content is the edited body (both old and new agree here). + assert.deepEqual(entry.req.messages, EDITED); + // The differential part — absent on pre-fix code: + assert.equal(entry.req.edited, true, 'entry.req.edited must be true for an edited turn'); + assert.ok(entry.req.original, 'entry.req.original must be attached from the received sidecar'); + assert.deepEqual(entry.req.original.messages, ORIG, 'original must hold the pre-edit body'); + assert.deepEqual(entry.req.editSummary, summary, 'editSummary must be surfaced for the badge'); + }); + + it('T-READ: a non-edited turn has no edited/original (no sidecar probe regression)', async () => { + const id = 'plain-001'; + await config.storage.write(id, '_req.json', JSON.stringify({ model: 'm', max_tokens: 10, messages: ORIG })); + const entry = pushEntry({ id }); // no edited flag + await loadEntryReqRes(entry); + assert.deepEqual(entry.req.messages, ORIG); + assert.ok(!entry.req.edited, 'plain turn must not be flagged edited'); + assert.ok(!entry.req.original, 'plain turn must not carry an original'); + }); + + // ── T-WRITE: the write seam persists as-sent + received + re-anchors ── + it('T-WRITE: persistEditedRequest writes as-sent _req.json, received sidecar, sets ctx.edited, re-anchors', async () => { + const id = 'edited-write-001'; + const sid = 'sess-write-1'; + // Receipt-time write (what index.js does) — the ORIGINAL body. + await config.storage.write(id, '_req.json', JSON.stringify({ model: 'm', max_tokens: 10, messages: ORIG, metadata: { session_id: sid } })); + + const anchorCalls = []; + forward.setSessionAnchorRecorder((sessionId, anchorId, messages) => anchorCalls.push({ sessionId, anchorId, messages })); + + const ctx = { + id, reqSessionId: sid, + parsedBody: { model: 'm', max_tokens: 10, messages: EDITED, metadata: { session_id: sid } }, + originalBody: { model: 'm', max_tokens: 10, messages: ORIG, metadata: { session_id: sid } }, + sysHash: null, toolsHash: null, + }; + + await forward.persistEditedRequest(ctx); + + // _req.json now holds the AS-SENT (edited) body, full format (no delta fields). + const onDisk = JSON.parse(await config.storage.read(id, '_req.json')); + assert.deepEqual(onDisk.messages, EDITED, '_req.json must be rewritten to the edited body'); + assert.equal(onDisk.prevId, undefined, 'edited turn must be full format (no prevId)'); + assert.equal(onDisk.msgOffset, undefined, 'edited turn must be full format (no msgOffset)'); + + // Forensic received sidecar holds the original. + const received = JSON.parse(await config.storage.read(id, '_req.received.json')); + assert.deepEqual(received.messages, ORIG, '_req.received.json must preserve the original body'); + + // ctx surfaces edited state for the entry/index. + assert.equal(ctx.edited, true); + assert.ok(Array.isArray(ctx.editSummary) && ctx.editSummary.length > 0, 'ctx.editSummary must describe the change'); + assert.ok(ctx.editSummary.some(l => l.includes('say X') && l.includes('say BANANA'))); + + // Anchor reset to a CLONE of the edited messages (not by-ref to parsedBody). + const reset = anchorCalls.find(c => c.sessionId === sid && c.messages != null); + assert.ok(reset, 'anchor must be reset on success'); + assert.deepEqual(reset.messages, EDITED); + assert.notEqual(reset.messages, ctx.parsedBody.messages, 'anchor messages must be a clone, not the live parsedBody array'); + + // Round-trips through the dashboard read path as edited + original. + const entry = pushEntry({ id, edited: ctx.edited, editSummary: ctx.editSummary }); + await loadEntryReqRes(entry); + assert.deepEqual(entry.req.messages, EDITED); + assert.deepEqual(entry.req.original.messages, ORIG); + }); + + // ── T-DELTA: later delta splices canonical edited, never the received sidecar ── + it('T-DELTA: a delta whose prevId is an edited turn reconstructs from EDITED canonical, not the sidecar', async () => { + const anchorId = 'edited-anchor-001'; + await config.storage.write(anchorId, '_req.json', JSON.stringify({ model: 'm', max_tokens: 10, messages: EDITED })); + await config.storage.write(anchorId, '_req.received.json', JSON.stringify({ model: 'm', max_tokens: 10, messages: ORIG })); + pushEntry({ id: anchorId, edited: true, editSummary: ['user[2]: "say X" → "say BANANA"'] }); + + const childId = 'delta-child-001'; + const childNew = [{ role: 'assistant', content: 'BANANA' }, { role: 'user', content: 'again' }]; + await config.storage.write(childId, '_req.json', JSON.stringify({ model: 'm', max_tokens: 10, prevId: anchorId, msgOffset: 3, messages: childNew })); + const child = pushEntry({ id: childId }); + + await loadEntryReqRes(child); + // Spliced prefix must be the EDITED messages from the canonical _req.json, + // never the original "say X" from the received sidecar. + assert.deepEqual(child.req.messages, [...EDITED, ...childNew]); + assert.ok(!JSON.stringify(child.req.messages).includes('say X'), 'must not leak original content via the sidecar'); + }); + + // ── T-SPLIT: rewrite failure clears the anchor (no split-brain) ── + it('T-SPLIT: when the _req.json rewrite fails, the anchor is cleared (recorder called with null)', async () => { + const id = 'edited-fail-001'; + const sid = 'sess-fail-1'; + await config.storage.write(id, '_req.json', JSON.stringify({ model: 'm', max_tokens: 10, messages: ORIG })); + + const anchorCalls = []; + forward.setSessionAnchorRecorder((sessionId, anchorId, messages) => anchorCalls.push({ sessionId, messages })); + + // Make the canonical _req.json rewrite fail, leaving disk + anchor inconsistent + // unless the fix clears the anchor. + const realWrite = config.storage.write.bind(config.storage); + config.storage.write = (entryId, suffix, data) => + suffix === '_req.json' && entryId === id + ? Promise.reject(new Error('disk full')) + : realWrite(entryId, suffix, data); + + try { + const ctx = { + id, reqSessionId: sid, + parsedBody: { model: 'm', max_tokens: 10, messages: EDITED, metadata: { session_id: sid } }, + originalBody: { model: 'm', max_tokens: 10, messages: ORIG }, + sysHash: null, toolsHash: null, + }; + await forward.persistEditedRequest(ctx).catch(() => {}); + } finally { + config.storage.write = realWrite; + } + + const cleared = anchorCalls.find(c => c.sessionId === sid && c.messages == null); + assert.ok(cleared, 'on rewrite failure the anchor must be cleared (recorder called with null) to force the next turn full'); + }); +}); diff --git a/test/intercept-mod-summary.test.js b/test/intercept-mod-summary.test.js new file mode 100644 index 0000000..dee6c46 --- /dev/null +++ b/test/intercept-mod-summary.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildEditSummary } = require('../server/forward'); + +describe('buildEditSummary', () => { + it('returns no diffs when nothing changed', () => { + const body = { model: 'claude', messages: [{ role: 'user', content: 'hi' }] }; + assert.deepEqual(buildEditSummary(body, JSON.parse(JSON.stringify(body))), []); + }); + + it('shows old → new content for an edited message (not just a count)', () => { + const orig = { model: 'claude', messages: [{ role: 'user', content: 'say X' }] }; + const mod = { model: 'claude', messages: [{ role: 'user', content: 'say BANANA' }] }; + const diffs = buildEditSummary(orig, mod); + // The whole point of the enhancement: the actual before/after text is visible. + assert.ok(diffs.some(l => l.includes('"say X"') && l.includes('"say BANANA"')), + 'expected a diff line containing both old and new text, got: ' + JSON.stringify(diffs)); + // And it is attributed to the right message index/role. + assert.ok(diffs.some(l => l.startsWith('user[0]:'))); + // Regression guard: it must NOT fall back to the old count-only wording. + assert.ok(!diffs.some(l => /message\(s\) edited$/.test(l) && !l.includes('more')), + 'should not emit the old count-only "N message(s) edited" line'); + }); + + it('flattens newlines and truncates long content to one line', () => { + const longNew = 'A'.repeat(200); + const orig = { messages: [{ role: 'user', content: 'a\nb\nc' }] }; + const mod = { messages: [{ role: 'user', content: longNew }] }; + const diffs = buildEditSummary(orig, mod); + const line = diffs.find(l => l.startsWith('user[0]:')); + assert.ok(line); + assert.ok(!line.includes('\n'), 'diff line must be single-line'); + assert.ok(line.includes('…'), 'long content should be truncated with an ellipsis'); + }); + + it('caps the number of shown message edits and summarizes the rest', () => { + const mk = (txt) => ({ role: 'user', content: txt }); + const orig = { messages: Array.from({ length: 8 }, (_, i) => mk('old' + i)) }; + const mod = { messages: Array.from({ length: 8 }, (_, i) => mk('new' + i)) }; + const diffs = buildEditSummary(orig, mod, { maxShown: 5 }); + const shown = diffs.filter(l => /^user\[\d+\]:/.test(l)); + assert.equal(shown.length, 5); + assert.ok(diffs.some(l => l === '…and 3 more message(s) edited')); + }); + + it('reports model, message-count, tools and system-prompt changes', () => { + const orig = { model: 'a', messages: [{ role: 'user', content: 'hi' }], tools: [{}], system: 'old sys' }; + const mod = { model: 'b', messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'x' }], tools: [{}, {}, {}], system: 'new sys' }; + const diffs = buildEditSummary(orig, mod); + assert.ok(diffs.some(l => l === 'Model: a → b')); + assert.ok(diffs.some(l => l === 'Messages: 1 → 2')); + assert.ok(diffs.some(l => l.startsWith('Tools: 1 → 3'))); + assert.ok(diffs.some(l => l.includes('System prompt:') && l.includes('old sys') && l.includes('new sys'))); + }); + + it('returns empty for missing inputs', () => { + assert.deepEqual(buildEditSummary(null, {}), []); + assert.deepEqual(buildEditSummary({}, null), []); + }); +}); diff --git a/test/messages-ui.test.js b/test/messages-ui.test.js index eb960f4..8c7b6f2 100644 --- a/test/messages-ui.test.js +++ b/test/messages-ui.test.js @@ -80,3 +80,50 @@ describe('dashboard timeline rendering helpers', () => { assert.equal(steps[0].calls[0].pending, true); }); }); + +describe('renderEditedBanner — intercept-edited badge (client render)', () => { + function ctxWithStubs() { + const context = loadMessagesContext(); + // escapeHtml lives in miller-columns.js (not loaded here); renderSingleMessage + // is covered by its own tests. Stub both so this isolates renderEditedBanner's + // own logic (badge + summary + original toggle). + context.escapeHtml = (s) => String(s); + context.renderSingleMessage = (m) => '' + (m && m.content) + ''; + return context; + } + + it('renders nothing when the request was not edited', () => { + const ctx = ctxWithStubs(); + assert.equal(ctx.renderEditedBanner({ edited: false }, 0), ''); + assert.equal(ctx.renderEditedBanner(null, 0), ''); + assert.equal(ctx.renderEditedBanner(undefined, 0), ''); + }); + + it('renders the EDITED badge and the server-authoritative summary', () => { + const ctx = ctxWithStubs(); + const html = ctx.renderEditedBanner({ edited: true, editSummary: ['user[2]: "say X" → "say BANANA"'] }, 2); + assert.ok(html.includes('EDITED'), 'badge must be present'); + assert.ok(html.includes('say X') && html.includes('say BANANA'), 'summary line must be rendered'); + // No original supplied → no collapsible "Original before edit". + assert.ok(!html.includes('Original before edit')); + }); + + it('shows the original-before-edit toggle only on the message that actually changed', () => { + const ctx = ctxWithStubs(); + const req = { + edited: true, + editSummary: ['user[2]: "say X" → "say BANANA"'], + messages: [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }, { role: 'user', content: 'say BANANA' }], + original: { messages: [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }, { role: 'user', content: 'say X' }] }, + }; + // Viewing the changed message (index 2): original toggle present. + const changed = ctx.renderEditedBanner(req, 2); + assert.ok(changed.includes('Original before edit'), 'changed message must offer the original'); + assert.ok(changed.includes('say X'), 'original content must render via renderSingleMessage'); + // Viewing an unchanged message (index 0): badge + summary, but NO misleading + // "original" toggle (the content is identical). + const unchanged = ctx.renderEditedBanner(req, 0); + assert.ok(unchanged.includes('EDITED'), 'turn-level badge still shown on unchanged messages'); + assert.ok(!unchanged.includes('Original before edit'), 'unchanged message must not offer an identical original'); + }); +});