diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 9129759..7af8503 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,8 +1,8 @@ @@ -3058,10 +3058,12 @@ unmerged. ## 76. A refused write was listed as touched, so an attempt read as a change -**Status: PARTLY CLOSED 2026-10-19, branch `lotor-lane/touched-outcome`. -Closed for the parser, the since-view, the text receipt and the live view. -Still OPEN for retcon's charter reconciliation (core) and three smaller -readers; see "Still open" below.** The parser +**Status: PARTLY CLOSED 2026-10-19, branch `lotor-lane/touched-outcome`; +narrowed 2026-11-04, branch `lotor-lane/l76-readers`. Closed for the parser, +the since-view, the text receipt, the live view, MCP `query_receipts` and the +chapters view on Claude Code transcripts. Still OPEN for retcon's charter +reconciliation (core), Codex chapters, and a count this work found; see "Still +open" below.** The parser now records an Edit/Write attempt and settles it against the tool_result that answers it. A touched entry from an attempt carries `outcome`: `ok` when the harness answered with a success, `error` when it answered with an error @@ -3087,13 +3089,54 @@ this change carry no `outcome` and render exactly as they did. items whose `outcome` is `'error'`, keeping ok-is-sticky across receipts), and `bin/` is wholesale core, so it waits for a signing sitting. `reconcile.js` itself cannot fix it: it never sees the outcome. -- **MCP `query_receipts` rows** (`src/mcp/server.js`, `summarizeEntry`) - report `touchedCount: p.touched.length`, refused attempts included. -- **The chapters view** (`src/views/chapters.js`) reads Edit/Write paths - straight from the transcript's tool_use and never joins the tool_result. -- **`touchedCount` in the since-view** is still summed per receipt, so with - subsessions "N files touched" overcounts in the same way `touchedRefused` - did. That predates this limit and is left for its own change. +- **Codex chapters.** `chaptersFromCodexRollout` does not settle `apply_patch` + attempts against their output, so a refused patch still reads as a file. + +**What the 2026-11-04 narrowing added.** MCP session rows now carry +`touchedRefused`, omitted when no item on the receipt has an `outcome`, because +then the number is unrecorded rather than zero. `touchedCount` still counts +attempts. In the chapters view a chapter gains a `refused` list when it has a +refused path, settled per chapter: a path refused in one chapter and landed in +a later one is refused in the first and not in the second. The since-view's +`touchedCount` is now distinct paths per session. **What to doubt in this part.** +`refused` is left out of the chapters/1 digest on purpose, because digests +already on the chain were taken without it, so the marks are bound only through +the transcript hash and a reader checking the chapter digest alone learns +nothing about them. Distinct paths in the since-view compare strings as the +receipts wrote them, and a receipt from before limit 75's fix can still list one +file under two spellings. + +**The since-view's other counts, the same day.** `turns`, `toolCalls` and +`failures` were summed across a session's receipts, and the receipts are +cumulative, so a session with three SessionEnd firings reported each of them +about three times over. They are now the largest value across the session's +receipts. Measured on this machine's chain before choosing, 4,110 sessions: 252 +wrote more than one receipt (589 receipts). In none of the 252 did any of the +three counts fall from one receipt to the next, and the largest always equalled +the last. Across the whole chain the sum reported 140,148 tool calls where the +largest reports 124,769, 12.3% over; inside the 252 the median sum was double +the largest and the worst was 5.32 times. Today's traffic barely shows it: in +the seven days measured, 3 of 227 sessions had more than one receipt. +**What to doubt in this part.** The largest value is wrong for a session id +continued in a new transcript: it would under-report, and nothing here detects +that. It was not seen. `session.startedAt` was constant on all 252 except one +placeholder `unknown` id with zero counts, but that is one machine's harness +behaviour, not a promise from it, and a harness that reuses an id would break +this silently. Unexplained and left alone: one session's `touched` list shrank +between receipts (39 paths to 32, 17 gone, none a respelling), so "each receipt +is a superset" held for the counts and not for that one list. The window is +unchanged: a receipt inside the window still reports work done before the +window opened. + +**Amended 2026-11-05, the since view against receipts that disagree.** The view +takes `touched` as a union, so a path a later receipt forgets stays listed; the +shrink above costs that view nothing. It did get one order wrong: a path answered +`ok` by one receipt and `error` by a later one read as refused, against the +parser's rule that one landed write is a write. `ok` now holds across receipts, +and only `ok`: `unknown` then `error` still reads as refused. These orders were +run against synthetic receipts. No real session was found showing landed then +refused, and the 39-to-32 session is still not identified or explained, so +whether this order occurs on a real chain is not known. Found 2026-10-19. Seq 36203 is a gate denial: a session's write to one of its own hook files was refused. Seq 36204 is that session's receipt, and it lists diff --git a/src/mcp/server.js b/src/mcp/server.js index e8aef0e..886ab4f 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -101,6 +101,19 @@ function asWritten(p, key) { return Object.hasOwn(p, key) ? { [key]: p[key] } : {}; } +/** + * KNOWN-LIMITS 76. `touchedCount` counts attempts. This says how many of them + * the harness answered with an error. A receipt written before 2026-10-19 + * carries no `outcome` on any item, and for that receipt the number is + * unrecorded rather than zero, so the field is omitted. Absent is not zero. + */ +function touchedRefusedField(touched) { + if (!Array.isArray(touched)) return {}; + const items = touched.filter(t => t && typeof t === 'object'); + if (!items.some(t => Object.hasOwn(t, 'outcome'))) return {}; + return { touchedRefused: items.filter(t => t.outcome === 'error').length }; +} + /** * The chain holds several kinds of entry and they do not share a shape. * @@ -156,7 +169,8 @@ function summarizeEntry(entry) { ...(typeof c.failures === 'number' ? { failures: c.failures } : {}), ...(typeof c.transcriptEntries === 'number' ? { transcriptEntries: c.transcriptEntries } : {}), - ...(Array.isArray(p.touched) ? { touchedCount: p.touched.length } : {}) + ...(Array.isArray(p.touched) ? { touchedCount: p.touched.length } : {}), + ...touchedRefusedField(p.touched) }; } diff --git a/src/views/chapters.js b/src/views/chapters.js index fdef644..1872698 100644 --- a/src/views/chapters.js +++ b/src/views/chapters.js @@ -144,6 +144,7 @@ function makeBook() { const c = ensure(ts); if (p && !c.touched.includes(p)) c.touched.push(p); touch(ts); + return c; }, fail(ts) { const c = ensure(ts); c.failures++; touch(ts); } }; @@ -197,12 +198,31 @@ const FILE_TOOLS = { Edit: 'file_path', Write: 'file_path', NotebookEdit: 'noteb */ export function chaptersFromClaudeTranscript(jsonlText) { const book = makeBook(); + // Limit 76. A file tool's tool_use is an attempt, and the tool_result that + // answers it says how it went. attempts: tool_use id -> { chapter, path }. + // settled: chapter -> Map(path -> 'ok' | 'error'). `ok` is sticky within a + // chapter, the same rule the parser applies: one landed write is a write. + const attempts = new Map(); + const settled = new Map(); + const settle = (id, isError) => { + const a = attempts.get(id); + if (!a) return; + if (!settled.has(a.chapter)) settled.set(a.chapter, new Map()); + const m = settled.get(a.chapter); + if (!isError) m.set(a.path, 'ok'); + else if (m.get(a.path) !== 'ok') m.set(a.path, 'error'); + }; for (const e of parseLines(jsonlText)) { const msg = e?.message; if (!msg) continue; const ts = CLAUDE_TS(e); if (msg.role === 'user') { + if (Array.isArray(msg.content)) { + for (const item of msg.content) { + if (item?.type === 'tool_result') settle(item.tool_use_id, item.is_error === true); + } + } if (e.isSidechain !== true) { const text = claudeUserText(e); if (text !== null) { book.open(ts, makeTitle(text)); continue; } @@ -223,10 +243,19 @@ export function chaptersFromClaudeTranscript(jsonlText) { book.call(ts, item.name); const key = FILE_TOOLS[item.name]; const p = key ? item.input?.[key] : undefined; - if (typeof p === 'string' && p) book.path(ts, p); + if (typeof p === 'string' && p) { + const chapter = book.path(ts, p); + if (item.id) attempts.set(item.id, { chapter, path: p }); + } } } } + // `refused` appears only on a chapter that has one, so a chapter with no + // refused write keeps exactly the shape it has always had. + for (const [chapter, m] of settled) { + const refused = chapter.touched.filter(p => m.get(p) === 'error'); + if (refused.length) chapter.refused = refused; + } return book.chapters; } @@ -355,10 +384,15 @@ function canonicalize(value) { * title removed, keys sorted at every depth, arrays in order. Titles are * operator words and never enter the chain; everything else in a chapter * is counts, timestamps and paths, which `touched` already puts there. + * + * `refused` is removed too. chapters/1 digests are already on the chain and + * were taken without it, so including it would make every old receipt with a + * refused write read DOES NOT match. The marks are bound only through the + * transcript hash, not through this digest. */ export function chaptersCanonical(chapters) { const rows = (Array.isArray(chapters) ? chapters : []).map(c => { - const { title, ...rest } = c; + const { title, refused, ...rest } = c; return canonicalize(rest); }); return JSON.stringify(rows); @@ -536,6 +570,7 @@ export function chaptersReport(entries, opts = {}) { sessions: out, caveats: [ 'Intent is not recorded. A chapter shows which tools ran and which files were named, never why.', + 'A file count includes attempts. A refused file is one whose every answered write in that chapter came back as an error; Claude Code transcripts only, Codex rollouts are not settled.', 'A chapter title is the operator\'s own words, quoted. It is not a claim about what was done.', 'Chapters come from transcripts on disk, not from the chain. The chain witnesses the session receipt, not this list.', 'Codex sessions are not on the chain at all. Their chapters are unattested.', @@ -597,8 +632,9 @@ export function renderChapters(report) { } else { for (const c of s.chapters) { const files = c.touched.length; + const refused = c.refused?.length ?? 0; const title = c.title === null ? '(no prompt recorded)' : `"${c.title}"`; - const tail = `(${c.toolCalls} tool call${c.toolCalls === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}${c.failures ? `, ${c.failures} failed` : ''})`; + const tail = `(${c.toolCalls} tool call${c.toolCalls === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}${refused ? `, ${refused} refused` : ''}${c.failures ? `, ${c.failures} failed` : ''})`; L.push(` #${String(c.index).padEnd(3)} ${hhmm(c.startedAt)} ${title} ${tail}`); } } diff --git a/src/views/since.js b/src/views/since.js index 4848e02..720f6c4 100644 --- a/src/views/since.js +++ b/src/views/since.js @@ -98,6 +98,10 @@ export function sinceReport(entries, opts = {}) { return sessions.get(id); }; + // sessionId -> paths some receipt answered with outcome 'ok'. Kept out of the + // session record so it does not reach the report. + const landedBySession = new Map(); + const denials = []; const warns = []; const egress = []; @@ -127,13 +131,15 @@ export function sinceReport(entries, opts = {}) { s.receiptSeq = e.seq; s.model = p.session?.model ?? s.model; const c = p.counts || {}; - // A session may write several receipts (subsessions). Accumulate rather - // than overwrite, or the rollup under-reports the busiest sessions. - s.turns = (s.turns ?? 0) + (c.turns ?? 0); - s.toolCalls = (s.toolCalls ?? 0) + (c.toolCalls ?? 0); - s.failures = (s.failures ?? 0) + (c.failures ?? 0); + // A session may write several receipts (subsessions), and each one + // re-parses the whole transcript (src/ingest), so these counts are + // cumulative. A sum counts the same work once per SessionEnd firing. + // Take the largest. KNOWN-LIMITS 76 has the measurement and what this + // would get wrong: a session id continued in a new transcript. + s.turns = Math.max(s.turns ?? 0, c.turns ?? 0); + s.toolCalls = Math.max(s.toolCalls ?? 0, c.toolCalls ?? 0); + s.failures = Math.max(s.failures ?? 0, c.failures ?? 0); if (Array.isArray(p.touched)) { - s.touchedCount = (s.touchedCount ?? 0) + p.touched.length; // Keep the paths, not just the count. A count narrows the search to // a handful of sessions; a path answers the question outright, which // is the difference between "go and look" and "here it is". This is @@ -141,11 +147,19 @@ export function sinceReport(entries, opts = {}) { for (const t of p.touched) { const pth = typeof t === 'string' ? t : t?.path; if (pth && !s.touched.includes(pth)) s.touched.push(pth); + // 'ok' is sticky across receipts, the same rule the parser keeps + // inside one transcript. Only 'ok': an 'unknown' attempt has no + // answer yet, and a later receipt may rightly answer it 'error'. + if (pth && typeof t === 'object' && t?.outcome === 'ok') { + if (!landedBySession.has(id)) landedBySession.set(id, new Set()); + landedBySession.get(id).add(pth); + } // The receipt's own word (limit 76): outcome 'error' means the // harness returned an error for that attempt. Old receipts carry // no outcome key and refuse nothing a reader can see. if (pth && typeof t === 'object' && t?.outcome === 'error') { - if (!s.refusedPaths.includes(pth)) s.refusedPaths.push(pth); + const landed = landedBySession.get(id)?.has(pth) === true; + if (!landed && !s.refusedPaths.includes(pth)) s.refusedPaths.push(pth); } else if (pth && s.refusedPaths.includes(pth)) { // A later receipt answering the same path with a landed write // (any outcome other than 'error', including none) un-refuses @@ -153,6 +167,9 @@ export function sinceReport(entries, opts = {}) { s.refusedPaths.splice(s.refusedPaths.indexOf(pth), 1); } } + // DISTINCT paths for the session, for the same reason as below: a + // per-receipt sum counted one file once per SessionEnd firing. + s.touchedCount = s.touched.length; // DISTINCT refused paths for the session, not a per-receipt sum. // Every subsession receipt re-parses the whole transcript // (src/ingest), so one refused write in a session with three diff --git a/test/known-limits-76-readers.test.js b/test/known-limits-76-readers.test.js new file mode 100644 index 0000000..82ea3e7 --- /dev/null +++ b/test/known-limits-76-readers.test.js @@ -0,0 +1,310 @@ +/** + * KNOWN-LIMITS 76, narrowed 2026-11-04, branch `lotor-lane/l76-readers`: + * three readers still reported a refused write as a plain touched file. + * + * WHAT THIS HOLDS + * Chapters: src/views/chapters.js settles each file attempt against the + * tool_result that answers it, per chapter; a chapter whose every answered + * write to a path came back as an error carries `refused: [paths]`. + * MCP: `query_receipts` session rows carry `touchedRefused`, omitted when + * no item on the receipt has an `outcome` (absent is not zero). + * Since-view: `touchedCount` is distinct paths per session, not a sum. + * Since-view: turns, toolCalls and failures are the largest value across + * a session's receipts, not a sum (s3, s4). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + chaptersFromClaudeTranscript, + chaptersBinding, + renderChapters +} from '../src/views/chapters.js'; +import { sinceReport } from '../src/views/since.js'; +import { createStore } from '../src/store/index.js'; + +// Fixture helpers, copied verbatim from .lane/fixture.mjs (the work order +// forbids importing from .lane: the copy is the durable thing). +const u = (ts, content, extra = {}) => JSON.stringify({ type: 'user', message: { role: 'user', content }, timestamp: ts, ...extra }); +const a = (ts, id, name, input) => JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, timestamp: ts }); +const r = (ts, id, isError) => u(ts, [{ type: 'tool_result', tool_use_id: id, content: isError ? 'denied' : 'ok', is_error: isError }]); +const T = s => `2026-11-04T10:00:${String(s).padStart(2, '0')}.000Z`; +const TRANSCRIPT = [ + u(T(0), 'Fix the hook'), + a(T(1), 'tu1', 'Write', { file_path: '/w/refused.js', content: 'x' }), + r(T(2), 'tu1', true), + a(T(3), 'tu2', 'Write', { file_path: '/w/landed.js', content: 'x' }), + r(T(4), 'tu2', false), + a(T(5), 'tu3', 'Edit', { file_path: '/w/retry.js', old_string: 'a', new_string: 'b' }), + r(T(6), 'tu3', true), + a(T(7), 'tu4', 'Edit', { file_path: '/w/retry.js', old_string: 'a', new_string: 'b' }), + r(T(8), 'tu4', false), + u(T(9), 'Second prompt'), + a(T(10), 'tu5', 'Write', { file_path: '/w/refused.js', content: 'x' }), + r(T(11), 'tu5', false), + a(T(12), 'tu6', 'Write', { file_path: '/w/unanswered.js', content: 'x' }), + a(T(13), 'tu7', 'Write', { file_path: '/w/late.js', content: 'x' }), + u(T(14), 'Third prompt'), + r(T(15), 'tu7', true) +].join('\n') + '\n'; + +const chapters = chaptersFromClaudeTranscript(TRANSCRIPT); + +describe('KNOWN-LIMITS 76 narrowing: the chapters view settles attempts per chapter', () => { + it('c1. chapter 1 lists retry.js as touched but not refused: it failed then landed', () => { + assert.deepEqual(chapters[0].touched, ['/w/refused.js', '/w/landed.js', '/w/retry.js']); + assert.deepEqual(chapters[0].refused, ['/w/refused.js'], + 'retry.js failed once then landed in the same chapter; one landed write is a write'); + }); + + it('c2. chapter 2: late.js is refused there, refused.js landed there, unanswered.js stays out', () => { + assert.deepEqual(chapters[1].refused, ['/w/late.js'], + 'the answer to tu7 arrived after chapter 3 opened, but the attempt was chapter 2\'s'); + assert.ok(!chapters[1].refused.includes('/w/refused.js'), + 'refused.js landed in chapter 2 (tu5 answered ok), so it refuses nothing here'); + assert.ok(!chapters[1].refused.includes('/w/unanswered.js'), + 'an attempt with no answer stays out: unanswered is not refused'); + }); + + it('c3. chapter 3 has no own refused property', () => { + assert.ok(!Object.hasOwn(chapters[2], 'refused'), + '"refused appears only on a chapter that has one"; chapter 3 only received a late answer'); + }); + + it('c4. the chapters/1 digest is unchanged by this narrowing', () => { + // Computed on main (22ae903) before this change, using the same fixture: + // sha256(chaptersCanonical(chaptersFromClaudeTranscript(TRANSCRIPT))) + // It must never move. chapters/1 digests are already on the chain, bound + // at signing sitting against the pre-refused canonical text, so any change to what + // chaptersCanonical includes makes every stored binding read DOES NOT + // match. That is why `refused` is excluded from the canonical form, and + // why this pins it. + assert.strictEqual(chaptersBinding(chapters).digest, + 'b8c53cab506db1ac645937ed57744861a46e90894e013f810fa039462fd24e5c'); + }); + + it('c5. failures are unchanged: [2, 0, 1]', () => { + assert.deepEqual(chapters.map(c => c.failures), [2, 0, 1], + 'the settle pass must not change book.fail behaviour'); + }); + + it('c6. renderChapters shows "1 refused" for chapter 1 and no refused word where there is none', () => { + const report = { + window: { from: 0, to: 0, entryCount: 0 }, + sessions: [{ + runtime: 'claude-code', sessionId: 's', witnessed: false, + chapters, caveats: [] + }], + caveats: [] + }; + const out = renderChapters(report); + const lines = out.split('\n'); + const l1 = lines.find(l => l.trim().startsWith('#1 ')); + const l3 = lines.find(l => l.trim().startsWith('#3 ')); + assert.ok(l1 && l1.includes(', 1 refused,'), + `chapter 1's line must carry the refused count, got: ${l1}`); + assert.ok(l3 && !l3.includes('refused'), + `a chapter without refused must print no refused word, got: ${l3}`); + }); +}); + +// Point the shared home at an isolated temp dir BEFORE importing the server, +// so the server's singleton store lands there and never touches the real +// ~/.lotor. Same approach as test/mcp.test.js; dynamic import because ESM +// static imports are hoisted above this assignment. +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lotor-l76-readers-')); +process.env.LOTOR_HOME = TEST_HOME; + +const { handleQueryReceipts } = await import('../src/mcp/server.js'); + +describe('KNOWN-LIMITS 76 narrowing: query_receipts session rows carry touchedRefused', () => { + it('m1. two touched items, one outcome error: touchedCount 2, touchedRefused 1', () => { + const store = createStore(TEST_HOME); + store.appendReceipt({ + session: { id: 'l76-m1', model: 'test-model' }, + ran: [], failed: [], cost: {}, + counts: { turns: 1, toolCalls: 2, failures: 1 }, + touched: [ + { path: '/a', via: 'write', outcome: 'error' }, + { path: '/b', via: 'write', outcome: 'ok' } + ] + }); + const row = handleQueryReceipts({ sessionId: 'l76-m1' }).receipts[0]; + assert.strictEqual(row.touchedCount, 2); + assert.strictEqual(row.touchedRefused, 1); + }); + + it('m2. all outcomes ok: touchedRefused is present and reads 0', () => { + const store = createStore(TEST_HOME); + store.appendReceipt({ + session: { id: 'l76-m2', model: 'test-model' }, + ran: [], failed: [], cost: {}, + counts: { turns: 1, toolCalls: 2, failures: 0 }, + touched: [ + { path: '/a', via: 'write', outcome: 'ok' }, + { path: '/b', via: 'write', outcome: 'ok' } + ] + }); + const row = handleQueryReceipts({ sessionId: 'l76-m2' }).receipts[0]; + assert.ok(Object.hasOwn(row, 'touchedRefused'), + 'zero refused is a receipt that said so; only no-outcome receipts omit the key'); + assert.strictEqual(row.touchedRefused, 0); + }); + + it('m3. touched with no outcome key anywhere: no touchedRefused key on the row', () => { + const store = createStore(TEST_HOME); + store.appendReceipt({ + session: { id: 'l76-m3', model: 'test-model' }, + ran: [], failed: [], cost: {}, + counts: { turns: 1, toolCalls: 1, failures: 0 }, + touched: [{ path: '/a', via: 'write' }] + }); + const row = handleQueryReceipts({ sessionId: 'l76-m3' }).receipts[0]; + assert.strictEqual(row.touchedCount, 1); + assert.ok(!Object.hasOwn(row, 'touchedRefused'), + 'a receipt written before 2026-10-19 records nothing about outcomes; absent is not zero'); + }); +}); + +describe('KNOWN-LIMITS 76 narrowing: the since-view counts touched paths once per session', () => { + const at = (seq, ts, payload) => ({ seq, timestamp: ts, hash: 'h', payload }); + + const receipt = (id, sub, paths) => ({ + session: { id, model: 'test-model', subsession: sub }, + counts: { turns: 1, toolCalls: 1, failures: 0 }, + touched: paths.map(p => ({ path: p, via: 'write', outcome: 'ok' })) + }); + + const open = id => at(0, 1000, { type: 'session-open', sessionId: id, source: 'startup', cwd: '/w' }); + + it('s1. two receipts listing the same two paths count 2, not 4', () => { + const entries = [ + open('l76-s1'), + at(1, 2000, receipt('l76-s1', 0, ['/a.js', '/b.js'])), + at(2, 3000, receipt('l76-s1', 1, ['/a.js', '/b.js'])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(s.touchedCount, 2, + 'every subsession receipt repeats the whole transcript\'s touched list; the count is distinct paths'); + }); + + it('s2. a second receipt adding one new path counts 3', () => { + const entries = [ + open('l76-s2'), + at(1, 2000, receipt('l76-s2', 0, ['/a.js', '/b.js'])), + at(2, 3000, receipt('l76-s2', 1, ['/a.js', '/b.js', '/c.js'])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(s.touchedCount, 3); + }); + + it('s3. turns, toolCalls and failures are not summed across cumulative receipts (limit 76)', () => { + // Every subsession receipt re-parses the whole transcript (src/ingest), + // so two receipts with the same counts are one stretch of work. + const entries = [ + open('l76-s3'), + at(1, 2000, { + session: { id: 'l76-s3', model: 'test-model', subsession: 0 }, + counts: { turns: 5, toolCalls: 4, failures: 1 }, + touched: [{ path: '/a.js', via: 'write', outcome: 'ok' }] + }), + at(2, 3000, { + session: { id: 'l76-s3', model: 'test-model', subsession: 1 }, + counts: { turns: 5, toolCalls: 4, failures: 1 }, + touched: [{ path: '/a.js', via: 'write', outcome: 'ok' }] + }) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(s.turns, 5); + assert.strictEqual(s.toolCalls, 4); + assert.strictEqual(s.failures, 1); + }); + + it('s4. a growing session reads as its largest receipt, and a counts-free receipt does not lower it', () => { + const entries = [ + open('l76-s4'), + at(1, 2000, { + session: { id: 'l76-s4', model: 'test-model', subsession: 0 }, + counts: { turns: 3, toolCalls: 2, failures: 0 }, + touched: [] + }), + at(2, 3000, { + session: { id: 'l76-s4', model: 'test-model', subsession: 1 }, + counts: { turns: 7, toolCalls: 6, failures: 2 }, + touched: [] + }), + at(3, 4000, { + session: { id: 'l76-s4', model: 'test-model', subsession: 2 }, + touched: [] + }) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(s.turns, 7); + assert.strictEqual(s.toolCalls, 6); + assert.strictEqual(s.failures, 2); + }); + + // Receipts that disagree about a path. Inside one transcript the parser keeps + // 'ok' sticky; these pin the same answer across receipts, whatever the order. + const said = (id, sub, items) => ({ + session: { id, model: 'test-model', subsession: sub }, + counts: { turns: 1, toolCalls: 1, failures: 0 }, + touched: items.map(([p, outcome]) => ({ path: p, via: 'write', outcome })) + }); + + it('s5. a path a later receipt forgets stays touched: the view is a union', () => { + const entries = [ + open('l76-s5'), + at(1, 2000, said('l76-s5', 0, [['/a.js', 'ok'], ['/b.js', 'ok']])), + at(2, 3000, said('l76-s5', 1, [['/a.js', 'ok']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.touched, ['/a.js', '/b.js']); + assert.strictEqual(s.touchedCount, 2); + }); + + it('s6. landed then refused reads as landed: one landed write is a write', () => { + const entries = [ + open('l76-s6'), + at(1, 2000, said('l76-s6', 0, [['/a.js', 'ok']])), + at(2, 3000, said('l76-s6', 1, [['/a.js', 'error']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.refusedPaths, []); + assert.strictEqual(s.touchedRefused, 0); + }); + + it('s7. refused then landed reads as landed', () => { + const entries = [ + open('l76-s7'), + at(1, 2000, said('l76-s7', 0, [['/a.js', 'error']])), + at(2, 3000, said('l76-s7', 1, [['/a.js', 'ok']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.refusedPaths, []); + assert.strictEqual(s.touchedRefused, 0); + }); + + it('s8. unknown then refused stays refused: an unanswered attempt never landed', () => { + const entries = [ + open('l76-s8'), + at(1, 2000, said('l76-s8', 0, [['/a.js', 'unknown']])), + at(2, 3000, said('l76-s8', 1, [['/a.js', 'error']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.refusedPaths, ['/a.js']); + assert.strictEqual(s.touchedRefused, 1); + }); + + it('s9. the landed bookkeeping does not reach the session record', () => { + const entries = [ + open('l76-s9'), + at(1, 2000, said('l76-s9', 0, [['/a.js', 'ok']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(Object.keys(s).some(k => /landed/i.test(k)), false); + }); +}); \ No newline at end of file diff --git a/test/mcp.test.js b/test/mcp.test.js index edd12ac..6834dcc 100644 --- a/test/mcp.test.js +++ b/test/mcp.test.js @@ -151,7 +151,7 @@ describe('MCP tool handlers', () => { 'seq', 'timestamp', 'hash', 'type', // session receipts 'sessionId', 'model', 'subsession', - 'turns', 'toolCalls', 'failures', 'transcriptEntries', 'touchedCount', + 'turns', 'toolCalls', 'failures', 'transcriptEntries', 'touchedCount', 'touchedRefused', // session-open 'source', 'cwd', // gated-action diff --git a/test/since-view.test.js b/test/since-view.test.js index dab7980..f240eb1 100644 --- a/test/since-view.test.js +++ b/test/since-view.test.js @@ -101,15 +101,16 @@ test('the render always states what it cannot tell you', () => { assert.match(out, /no session id/); }); -test('subsession receipts accumulate rather than overwrite', () => { - // A session can write several receipts. Taking the last one under-reports - // exactly the busiest sessions, which are the ones worth finding. +test('subsession receipts: counts are cumulative, touched paths accumulate', () => { + // A session can write several receipts, each carrying the whole session so + // far. Summing their counts reports the same work once per receipt. const entries = [ at(1000, { type: 'session-open', sessionId: 'A' }, 1), at(1100, { session: { id: 'A' }, counts: { toolCalls: 5, turns: 5 }, touched: [{ path: '/a' }] }, 2), at(1200, { session: { id: 'A' }, counts: { toolCalls: 9, turns: 9 }, touched: [{ path: '/b' }] }, 3) ]; const r = sinceReport(entries); - assert.equal(r.sessions[0].toolCalls, 14); + assert.equal(r.sessions[0].toolCalls, 9); + assert.equal(r.sessions[0].turns, 9); assert.deepEqual(r.sessions[0].touched, ['/a', '/b']); });