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 => '
' + renderEditedBanner(req, msgIdx) + renderSingleMessage(msg, tok?.perMessage?.[msgIdx], msgIdx) + '
' : '' + 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) => '