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 */ 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); + }); +});