From a6bb9969d04edad550f50356a28599b194ba60c3 Mon Sep 17 00:00:00 2001 From: Jerome-2708 Date: Mon, 19 Oct 2026 17:03:53 -0400 Subject: [PATCH 1/2] test(provenance): pin sequenceOf's other paths: a view filter, an auxiliary slot, a sibling's writes, an uncached listing The compile test counts the point reads under get() and sequenceOf() for a render with no view filter, where a run starts at every other message. Four more tests pin the paths it doesn't reach: - Under a view filter, after a write: hiding every other message starts a run at each shown one, attribution reads the merged view before the filter, and a resident writes before each compile. The compile reads no stored message one at a time. - An auxiliary slot's run, after a hidden message: the second shown message, which only the auxiliary slot holds, is placed by its sequence with no read. - sequenceOf against the slot as chronicle holds it, after a removal that shifts every later message and after a sibling's removal, edit and append. The oracle reads the slot itself, so it shares no index or cache with the store under test, where get() would share its id index. - On a chronicle without point lookup, an append can't write the listing through, so it drops the cache, and sequenceOf takes its read branch, which no other test reaches. Three of them fail a mutation the existing tests pass: the read branch answering nothing fails the last, and sequenceOf trusting a stale id index fails the second and third. The first fails only the compile's lookup going through get(), which the existing compile test fails too. It pins the filtered path, which that test doesn't run. Co-Authored-By: Claude Opus 5.5 --- test/compile-provenance.test.ts | 78 +++++++++++++++++++++++++++++ test/message-store-sequence.test.ts | 64 +++++++++++++++++++++-- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/test/compile-provenance.test.ts b/test/compile-provenance.test.ts index 4ce9e50a..4b2b1243 100644 --- a/test/compile-provenance.test.ts +++ b/test/compile-provenance.test.ts @@ -455,6 +455,84 @@ describe('attribution reads what the raw entries name, not the stored history', rmSync(path, { recursive: true, force: true }); } }); + + it('under a view filter, after a write, a compile reads no stored message to place the runs the filter breaks (#273)', async () => { + const { ContextManager, PassthroughStrategy, MessageStore } = await import('../src/index.js'); + const { rmSync } = await import('node:fs'); + // Hiding every other message starts a run at each shown one. Attribution + // then reads the merged view before the filter, and a resident writes + // before each compile. + const path = './test-attribution-lookups-filtered'; + rmSync(path, { recursive: true, force: true }); + const proto = MessageStore.prototype as unknown as { getInternal(index: number): unknown }; + const getInternal = proto.getInternal; + let reads = 0; + try { + const cm = await ContextManager.open({ path, strategy: new PassthroughStrategy(), viewFilter: (m) => !m.metadata?.tuneOut }); + const turn = (i: number) => { + cm.addMessage('bob', [text(`hidden ${i}`)], { tuneOut: { epochId: `e${i}` } }); + return cm.addMessage('alice', [text(`shown ${i}`)]); + }; + const shown = Array.from({ length: 20 }, (_, i) => turn(i)); + await cm.compile(); + shown.push(turn(20)); + proto.getInternal = function (this: unknown, index: number) { + reads++; + return getInternal.call(this, index); + }; + const result = await cm.compile(); + proto.getInternal = getInternal; + const raw = result.provenance!.messages.map((m) => (m.kind === 'raw' ? m.bodies.map((b) => [b.messageId, b.complete]) : null)); + assert.deepEqual(raw, shown.map((id) => [[id, true]]), 'each shown message is its own complete body'); + assert.equal(reads, 0, `compile read ${reads} stored messages one at a time`); + cm.close(); + } finally { + proto.getInternal = getInternal; + rmSync(path, { recursive: true, force: true }); + } + }); + + it('a raw run from an auxiliary slot is placed without reading its message (#273)', async () => { + const { ContextManager, PassthroughStrategy, MessageStore } = await import('../src/index.js'); + const { rmSync } = await import('node:fs'); + // The hidden message breaks the run, so the second shown one, which only + // the auxiliary slot holds, is placed by its sequence. + const path = './test-attribution-lookups-auxiliary'; + rmSync(path, { recursive: true, force: true }); + const proto = MessageStore.prototype as unknown as { getInternal(index: number): unknown }; + const getInternal = proto.getInternal; + let reads = 0; + try { + const main = await ContextManager.open({ path, strategy: new PassthroughStrategy() }); + const side = await ContextManager.open({ + store: main.getStore(), + namespace: 'subconscious/reader', + isolate: true, + strategy: new PassthroughStrategy(), + auxiliaryMessageViews: [{}], + viewFilter: (m) => !m.metadata?.tuneOut, + }); + const first = main.addMessage('alice', [text('first')]); + main.addMessage('bob', [text('hidden')], { tuneOut: { epochId: 'e1' } }); + const second = main.addMessage('alice', [text('second')]); + // The first compile reads the writer's appends into the reader's listing. + await side.compile(); + proto.getInternal = function (this: unknown, index: number) { + reads++; + return getInternal.call(this, index); + }; + const result = await side.compile(); + proto.getInternal = getInternal; + const raw = result.provenance!.messages.map((m) => (m.kind === 'raw' ? m.bodies.map((b) => [b.messageId, b.complete]) : null)); + assert.deepEqual(raw, [[[first, true]], [[second, true]]]); + assert.equal(reads, 0, `compile read ${reads} stored messages one at a time`); + side.close(); + main.close(); + } finally { + proto.getInternal = getInternal; + rmSync(path, { recursive: true, force: true }); + } + }); }); describe('a tool result moved by repair is still carried', () => { diff --git a/test/message-store-sequence.test.ts b/test/message-store-sequence.test.ts index b89989e7..545aaefd 100644 --- a/test/message-store-sequence.test.ts +++ b/test/message-store-sequence.test.ts @@ -24,9 +24,11 @@ import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert'; -import { rmSync, existsSync } from 'node:fs'; -import { ContextManager, AutobiographicalStrategy } from '../src/index.js'; -import type { MessageStore } from '../src/index.js'; +import { rmSync, existsSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { ContextManager, AutobiographicalStrategy, MessageStore } from '../src/index.js'; import type { ContentBlock } from '@animalabs/membrane'; const TEST_STORE_PATH = './test-message-store-sequence'; @@ -250,3 +252,59 @@ describe('MessageStore — sequence semantics', () => { manager.close(); }); }); + +describe('MessageStore.sequenceOf against the slot itself (#273)', () => { + function openSlot(t: { after(fn: () => void): void }) { + const dir = mkdtempSync(join(tmpdir(), 'cm-sequence-of-')); + const js = JsStore.openOrCreate({ path: join(dir, 'store') }); + t.after(() => { + js.close(); + rmSync(dir, { recursive: true, force: true }); + }); + MessageStore.register(js); + // The oracle reads the slot as chronicle holds it, so it shares no index + // or cache with the store under test. + const slotSequence = (id: string) => + (js.getStateJson('messages') as Array<{ id: string; sequence: number }>).find((m) => m.id === id)?.sequence; + return { js, slotSequence }; + } + + it("follows a removal that shifts every later message, and a sibling's removal, edit and append", (t) => { + const { js, slotSequence } = openSlot(t); + const reader = new MessageStore(js); + const writer = new MessageStore(js); + const ids = ['a', 'b', 'c', 'd', 'e'].map((x) => reader.append('user', textBlock(x)).id); + const agrees = (when: string, known: string[]) => { + for (const id of known) assert.equal(reader.sequenceOf(id), slotSequence(id), `${id} ${when}`); + }; + agrees('after the appends', ids); + + reader.remove(ids[0]!); + assert.equal(reader.sequenceOf(ids[0]!), undefined, 'the removed message'); + agrees('after a removal that shifts the rest', ids.slice(1)); + + writer.remove(ids[2]!); + assert.equal(reader.sequenceOf(ids[2]!), undefined, "the sibling's removed message"); + agrees("after a sibling's removal", [ids[1]!, ids[3]!, ids[4]!]); + + writer.edit(ids[3]!, textBlock('d, edited by a sibling')); + const appended = writer.append('user', textBlock('f')).id; + agrees("after a sibling's edit and append", [ids[1]!, ids[3]!, ids[4]!, appended]); + }); + + it("reads the item while the listing isn't cached, as on a chronicle without point lookup", (t) => { + const { js, slotSequence } = openSlot(t); + // Without point lookup an append can't write the listing through, so it + // drops the cache, and the next lookup reads the item. + const legacy = new Proxy(js, { + get(target, prop, receiver) { + if (prop === 'getStateItemJson') return undefined; + const v = Reflect.get(target, prop, receiver); + return typeof v === 'function' ? v.bind(target) : v; + }, + }) as JsStore; + const messages = new MessageStore(legacy); + const ids = ['a', 'b'].map((x) => messages.append('user', textBlock(x)).id); + for (const id of ids) assert.equal(messages.sequenceOf(id), slotSequence(id), id); + }); +}); From 36f214f92286e19ef63edee86ce5ecde0d2a2c6a Mon Sep 17 00:00:00 2001 From: Jerome-2708 Date: Mon, 19 Oct 2026 17:03:53 -0400 Subject: [PATCH 2/2] docs(view): say when sequenceOf answers without reading the message MessageStore answers from its listing while that is cached, as it is during a compile, and reads the item otherwise, as after an append that couldn't write the listing through. A merged view asks each source's sequenceOf, and the source's get() where it has none. Co-Authored-By: Claude Opus 5.5 --- src/types/strategy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/types/strategy.ts b/src/types/strategy.ts index ab25e60f..c8128d85 100644 --- a/src/types/strategy.ts +++ b/src/types/strategy.ts @@ -19,8 +19,10 @@ export interface MessageStoreView { get(id: MessageId): StoredMessage | null; /** * A message's sequence, as `get(id)?.sequence` answers it, without reading - * the message (optional — MessageStore provides it; a reader without it - * falls back to `get`). + * the message where the view can (optional — a reader without it falls + * back to `get`). MessageStore answers from its listing while that is + * cached, as it is during a compile, and reads the item otherwise; a merged + * view asks each source's, and the source's `get` where it has none. */ sequenceOf?(id: MessageId): number | undefined; /** Get messages from a specific index */