diff options
Diffstat (limited to 'packages/session-orchestrator/src/orchestrator.test.ts')
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 678 |
1 files changed, 675 insertions, 3 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 400ca0b..c4be03c 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -19,11 +19,14 @@ import type { TurnMetrics, } from "@dispatch/kernel"; import { createLogger, runTurn } from "@dispatch/kernel"; +import { createMessageQueueService } from "@dispatch/message-queue"; import type { SystemPromptService } from "@dispatch/system-prompt"; import { describe, expect, it } from "vitest"; import { + type ConversationCompactedPayload, type ConversationOpenedPayload, type ConversationStatusChangedPayload, + conversationCompacted, createCompactionService, createSessionOrchestrator, createWarmService, @@ -49,6 +52,7 @@ function createInMemoryStore(): ConversationStore & { const effortData = new Map<string, ReasoningEffort>(); const modelData = new Map<string, string>(); const workspaceIdData = new Map<string, string>(); + const compactPercentData = new Map<string, number>(); // Track conversations that have a meta row. In the real store, append, // setWorkspaceId, setConversationStatus, setConversationTitle, and // setCompactedFrom all create a minimal meta row on first contact. @@ -158,10 +162,12 @@ function createInMemoryStore(): ConversationStore & { knownConversations.add(conversationId); data.set(conversationId, [...messages]); }, - async getCompactPercent() { - return null; + async getCompactPercent(conversationId) { + return compactPercentData.get(conversationId) ?? null; + }, + async setCompactPercent(conversationId, percent) { + compactPercentData.set(conversationId, percent); }, - async setCompactPercent() {}, async forkHistory(_sourceId, targetId) { knownConversations.add(targetId); }, @@ -3873,6 +3879,194 @@ describe("system prompt: regular turn flow", () => { }); }); +describe("title (summon-title): deferred until after workspace initialization", () => { + // Regression: an earlier implementation set the title in the HTTP /chat + // route BEFORE the turn started, which pre-created the conversation meta + // and made the orchestrator's `meta === null` newness check falsely report + // an EXISTING conversation — so ensureWorkspace / setWorkspaceId / the + // first-turn system-prompt construct were ALL skipped. The fix defers the + // title set into workspaceSetupPromise, AFTER the newness check + workspace + // assignment, so a titled new conversation is still initialized correctly. + + /** Wrap the in-memory store to record the ORDER of init-relevant calls. */ + function createCallRecordingStore() { + const base = createInMemoryStore(); + const calls: string[] = []; + const titleCalls: { conversationId: string; title: string }[] = []; + return { + store: { + ...base, + async getConversationMeta(conversationId: string) { + calls.push(`getMeta:${conversationId}`); + return base.getConversationMeta(conversationId); + }, + async ensureWorkspace(id: string) { + calls.push(`ensureWorkspace:${id}`); + return base.ensureWorkspace(id); + }, + async setWorkspaceId(conversationId: string, workspaceId: string) { + calls.push(`setWorkspaceId:${workspaceId}`); + await base.setWorkspaceId(conversationId, workspaceId); + }, + async setConversationTitle(conversationId: string, title: string) { + calls.push(`setTitle:${title}`); + titleCalls.push({ conversationId, title }); + await base.setConversationTitle(conversationId, title); + }, + } as ConversationStore, + calls, + titleCalls, + }; + } + + it("titled new conversation: workspace assigned, system prompt constructed, title set", async () => { + const { store, calls, titleCalls } = createCallRecordingStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + const constructCalls: string[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService(async (conversationId) => { + constructCalls.push(conversationId); + return "CONSTRUCTED_PROMPT"; + }), + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-new", + text: "hi", + onEvent: () => {}, + title: "My Task", + workspaceId: "my-workspace", + }); + + // The bug: workspace init was skipped. It must NOT be. + expect(calls).toContain("ensureWorkspace:my-workspace"); + expect(calls).toContain("setWorkspaceId:my-workspace"); + // First-turn system prompt construct runs (proves isNewConversation was + // true — the newness check was not fooled by a pre-created meta). + expect(constructCalls).toEqual(["conv-title-new"]); + // The title is persisted. + expect(titleCalls).toEqual([{ conversationId: "conv-title-new", title: "My Task" }]); + }); + + it("title is set AFTER the newness check + workspace assignment (order)", async () => { + const { store, calls } = createCallRecordingStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-order", + text: "hi", + onEvent: () => {}, + title: "Ordered", + }); + + const getMetaIdx = calls.findIndex((c) => c.startsWith("getMeta:")); + const ensureIdx = calls.findIndex((c) => c.startsWith("ensureWorkspace:")); + const setWsIdx = calls.findIndex((c) => c.startsWith("setWorkspaceId:")); + const setTitleIdx = calls.findIndex((c) => c.startsWith("setTitle:")); + expect(getMetaIdx).toBeGreaterThanOrEqual(0); + expect(ensureIdx).toBeGreaterThan(getMetaIdx); + expect(setWsIdx).toBeGreaterThan(ensureIdx); + expect(setTitleIdx).toBeGreaterThan(setWsIdx); + }); + + it("no title: setConversationTitle is not called", async () => { + const { store, calls } = createCallRecordingStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-title", + text: "hi", + onEvent: () => {}, + }); + + expect(calls.some((c) => c.startsWith("setTitle:"))).toBe(false); + }); + + it("existing conversation with a title: workspace NOT re-assigned, title still set", async () => { + const { store, calls, titleCalls } = createCallRecordingStore(); + // Seed an existing conversation (meta non-null, workspace already set). + await store.setWorkspaceId("conv-title-existing", "prior-workspace"); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-existing", + text: "hi", + onEvent: () => {}, + title: "Renamed", + }); + + // Existing conversation: workspace init must not run again. + expect(calls.some((c) => c.startsWith("ensureWorkspace:"))).toBe(false); + // But the title is still applied (rename on an existing conversation). + expect(titleCalls).toEqual([{ conversationId: "conv-title-existing", title: "Renamed" }]); + }); + + it("turn still completes if setConversationTitle throws", async () => { + const base = createInMemoryStore(); + const store: ConversationStore = { + ...base, + async setConversationTitle() { + throw new Error("title store unavailable"); + }, + }; + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-title-throws", + text: "hi", + onEvent: () => {}, + title: "Resilient", + }); + + // The turn ran despite the title-set failure. + expect(captured).toHaveLength(1); + }); +}); + describe("system prompt: compaction flow", () => { function seedHistory( store: ReturnType<typeof createInMemoryStore>, @@ -3985,6 +4179,484 @@ describe("system prompt: compaction flow", () => { }); }); +describe("in-flight compaction", () => { + // Seeds a conversation with `count` alternating user/assistant text messages + // so the history is long enough to compact (> DEFAULT_KEEP_LAST_N = 10). + function seedHistory( + store: ReturnType<typeof createInMemoryStore>, + conversationId: string, + count: number, + ): void { + const messages: ChatMessage[] = []; + for (let i = 0; i < count; i++) { + messages.push({ + role: i % 2 === 0 ? "user" : "assistant", + chunks: [{ type: "text", text: `seed message ${i}` }], + }); + } + store.data.set(conversationId, messages); + } + + // A provider whose `stream` serves a SCRIPT of per-call event lists, in + // order. Captures the messages passed to each call so a test can assert what + // the model saw at each step (incl. after in-flight compaction replaced it). + function createScriptedCapturingProvider(script: ProviderEvent[][]): { + provider: ProviderContract; + capturedMessages: ChatMessage[][]; + } { + const capturedMessages: ChatMessage[][] = []; + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream(messages) { + capturedMessages.push([...messages]); + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) { + yield event; + } + })(); + }, + }; + return { provider, capturedMessages }; + } + + function echoTool(): ToolContract { + return { + name: "echo", + description: "echo", + parameters: { type: "object" }, + execute: async () => ({ content: "echoed" }), + }; + } + + it("triggers when a step's usage exceeds the threshold: history is compacted mid-turn and the prompt continues with the summary", async () => { + const store = createInMemoryStore(); + seedHistory(store, "conv-inflight", 15); // > keepLastN(10) → compactable + + // contextWindow 1000, default percent 85 → threshold 850. + // Step 0 emits a tool call + usage(inputTokens 900) → 910 > 850 → trigger. + // Then the compaction summary call, then step 1 ends the turn. + const { provider, capturedMessages } = createScriptedCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } }, + { type: "finish", reason: "tool-calls" }, + ], + // Compaction summary call (performCompaction): + [ + { type: "text-delta", delta: "COMPACTED SUMMARY" }, + { type: "finish", reason: "stop" }, + ], + // Step 1 (post-compaction) — the turn CONTINUES: + [ + { type: "text-delta", delta: "all done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const compactedEvents: ConversationCompactedPayload[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [echoTool()], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => ({ provider, model: "model" }), + resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }), + runTurn, + emit: (hook, payload) => { + if (hook === conversationCompacted) { + compactedEvents.push(payload as ConversationCompactedPayload); + } + }, + }); + + const { events, onEvent } = collectEvents(); + + await orchestrator.handleMessage({ + conversationId: "conv-inflight", + text: "keep working overnight", + onEvent, + modelName: "test/model", + }); + + // 1) The conversationCompacted event fired mid-turn. + expect(compactedEvents).toHaveLength(1); + expect(compactedEvents[0]?.conversationId).toBe("conv-inflight"); + expect(compactedEvents[0]?.messagesSummarized).toBeGreaterThan(0); + expect(compactedEvents[0]?.messagesKept).toBe(10); + + // 2) The store history was replaced: it now begins with the system summary + // message, and the OLDEST seed messages are gone (summarized). The most + // recent messages are retained (keepLastN = 10), so some later seed + // messages may survive — that is correct. + const stored = store.data.get("conv-inflight") ?? []; + expect(stored.length).toBeGreaterThan(0); + expect(stored[0]?.role).toBe("system"); + expect(stored[0]?.chunks[0]).toMatchObject({ type: "text" }); + const firstText = (stored[0]?.chunks[0] as { text: string } | undefined)?.text ?? ""; + expect(firstText).toContain("COMPACTED SUMMARY"); + // The earliest seed messages were summarized away (not retained). + expect( + stored.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "seed message 0")), + ).toBe(false); + + // 3) The turn CONTINUED after compaction: 3 provider calls happened + // (step 0, compaction summary, step 1) and the final assistant text + // was produced + persisted. + expect(capturedMessages).toHaveLength(3); + const step1Messages = capturedMessages[2] ?? []; + // Step 1 saw the COMPACTED history: it must start with the summary + // system message, NOT the original seed/user prefix. + expect(step1Messages[0]?.role).toBe("system"); + + const turnSealed = events.some((e) => e.type === "turn-sealed"); + expect(turnSealed).toBe(true); + }); + + it("does NOT trigger when the step usage is below the threshold (history unchanged, no event)", async () => { + const store = createInMemoryStore(); + seedHistory(store, "conv-below", 15); + + // contextWindow 1000 → threshold 850. Step usage 100 < 850 → no trigger. + const { provider, capturedMessages } = createScriptedCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 100, outputTokens: 5 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const compactedEvents: ConversationCompactedPayload[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [echoTool()], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => ({ provider, model: "model" }), + resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }), + runTurn, + emit: (hook, payload) => { + if (hook === conversationCompacted) { + compactedEvents.push(payload as ConversationCompactedPayload); + } + }, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-below", + text: "hi", + onEvent: () => {}, + modelName: "test/model", + }); + + // No compaction event, and only 2 provider calls (no summary call). + expect(compactedEvents).toHaveLength(0); + expect(capturedMessages).toHaveLength(2); + // The seed messages are still the start of history (uncompacted). + const stored = store.data.get("conv-below") ?? []; + expect(stored[0]?.chunks[0]).toMatchObject({ type: "text", text: "seed message 0" }); + }); + + it("does NOT trigger when auto-compact is disabled (compact percent = 0)", async () => { + const store = createInMemoryStore(); + seedHistory(store, "conv-disabled", 15); + await store.setCompactPercent("conv-disabled", 0); + + // Usage would exceed the default threshold, but percent=0 disables it. + const { provider, capturedMessages } = createScriptedCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 950, outputTokens: 10 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const compactedEvents: ConversationCompactedPayload[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [echoTool()], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => ({ provider, model: "model" }), + resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }), + runTurn, + emit: (hook, payload) => { + if (hook === conversationCompacted) { + compactedEvents.push(payload as ConversationCompactedPayload); + } + }, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-disabled", + text: "hi", + onEvent: () => {}, + modelName: "test/model", + }); + + expect(compactedEvents).toHaveLength(0); + expect(capturedMessages).toHaveLength(2); + }); + + it("does NOT trigger on a text-only turn (no tool calls → no next step → no boundary)", async () => { + const store = createInMemoryStore(); + seedHistory(store, "conv-textonly", 15); + + // Single text-only step with high usage — but no tool calls → the turn + // ends → there is no step boundary to compact at (post-seal handles it). + const { provider, capturedMessages } = createScriptedCapturingProvider([ + [ + { type: "text-delta", delta: "final answer" }, + { type: "usage", usage: { inputTokens: 950, outputTokens: 10 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const compactedEvents: ConversationCompactedPayload[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => ({ provider, model: "model" }), + resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }), + runTurn, + emit: (hook, payload) => { + if (hook === conversationCompacted) { + compactedEvents.push(payload as ConversationCompactedPayload); + } + }, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-textonly", + text: "hi", + onEvent: () => {}, + modelName: "test/model", + }); + + // No in-flight compaction (only 1 provider call — the turn ended). + expect(compactedEvents).toHaveLength(0); + expect(capturedMessages).toHaveLength(1); + }); + + it("the manual compaction SERVICE still refuses while a conversation is generating, but in-flight compaction runs anyway", async () => { + // This documents the two-path design: compact() (the service) guards on + // activeConversations and refuses mid-turn; the in-flight path bypasses + // that guard (it IS the mid-turn path) using performCompaction directly. + const store = createInMemoryStore(); + seedHistory(store, "conv-twopath", 15); + + const { provider } = createScriptedCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "SUMMARY" }, + { type: "finish", reason: "stop" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const compactedEvents: ConversationCompactedPayload[] = []; + const activeConversations = new Set<string>(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [echoTool()], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => ({ provider, model: "model" }), + resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }), + runTurn, + emit: (hook, payload) => { + if (hook === conversationCompacted) { + compactedEvents.push(payload as ConversationCompactedPayload); + } + }, + }); + + // The compaction SERVICE shares the orchestrator's activeConversations set; + // build it against the SAME set so the guard reflects reality. + const compactionService = createCompactionService( + { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + }, + activeConversations, + ); + + // Drive a turn that triggers in-flight compaction. We can't easily inspect + // activeConversations mid-turn, so we assert the observable contract: + // in-flight compaction produced an event (it ran WHILE active), and the + // store was compacted. + await orchestrator.handleMessage({ + conversationId: "conv-twopath", + text: "hi", + onEvent: () => {}, + modelName: "test/model", + }); + + expect(compactedEvents).toHaveLength(1); + const stored = store.data.get("conv-twopath") ?? []; + expect(stored[0]?.role).toBe("system"); + + // After the turn settles (idle), the manual service CAN compact (no longer + // active) — and it succeeds (history is compactable again only if long + // enough; here it is short post-compaction, so it reports too-short, which + // proves the service path is reachable and its guard is the ONLY reason it + // would have refused mid-turn). + const manual = await compactionService.compact("conv-twopath"); + // Post-compaction the history is short (summary + ~10 + turn tail) → the + // service reports an error (too short / threshold), NOT "generating". + expect("error" in manual).toBe(true); + if ("error" in manual) { + expect(manual.error).not.toBe("conversation is generating"); + } + }); + + it("steering messages are persisted and survive in-flight compaction — the store and the LLM's context stay aligned (Bug A + B)", async () => { + // Regression test for the two critical bugs: + // A) drainSteering injected steering into the kernel's in-memory messages + // but never persisted it → the user could never see it, and + // compaction (loading the store) scrubbed it. + // B) compaction sliced the store and the kernel's messages independently; + // the unpersisted steering offset the slices → DB and LLM dropped + // DIFFERENT messages (structural divergence). + // Fix: drainSteering persists (awaited); compaction uses the kernel's LIVE + // messages array, so the store write and the kernel's replacement use the + // SAME recent slice → aligned, and the steering is retained. + const store = createInMemoryStore(); + seedHistory(store, "conv-align", 15); // > keepLastN(10) → compactable + const queue = createMessageQueueService({ + id: () => `q-${Math.random().toString(36).slice(2, 8)}`, + now: () => 1000, + notify: () => {}, + }); + queue.enqueue("conv-align", "STEER MID-TURN"); // drained at step 0's boundary + + // contextWindow 1000, percent 85 → threshold 850. Step 0 usage 900 → fire. + const { provider, capturedMessages } = createScriptedCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } }, + { type: "finish", reason: "tool-calls" }, + ], + // Compaction summary call: + [ + { type: "text-delta", delta: "ALIGN SUMMARY" }, + { type: "finish", reason: "stop" }, + ], + // Step 1 (post-compaction) — the turn continues: + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const compactedEvents: ConversationCompactedPayload[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [echoTool()], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => ({ provider, model: "model" }), + resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }), + resolveQueue: () => queue, + runTurn, + emit: (hook, payload) => { + if (hook === conversationCompacted) { + compactedEvents.push(payload as ConversationCompactedPayload); + } + }, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-align", + text: "keep working overnight", + onEvent: () => {}, + modelName: "test/model", + }); + + // Bug A: the steering message was PERSISTED to the store (the user CAN see + // it). It is either retained in the kept recent slice or captured in the + // summary; either way it must be present in the store, not lost. + expect(compactedEvents).toHaveLength(1); + const stored = store.data.get("conv-align") ?? []; + // The compacted history begins with the summary system message. + expect(stored[0]?.role).toBe("system"); + expect((stored[0]?.chunks[0] as { text?: string } | undefined)?.text).toContain( + "ALIGN SUMMARY", + ); + // The steering message survived in the kept recent slice (it was the most + // recent user message before compaction, so it is within keepLastN=10). + const storedSteering = stored.find( + (m) => + m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"), + ); + expect(storedSteering).toBeDefined(); + + // Bug B: the store and the LLM's context are ALIGNED. The provider's + // post-compaction call (captured[2]) is the kernel's working history AFTER + // compaction replaced it. The store was written with the SAME compacted + // history, then step 1's assistant output was appended on top. So the + // kernel's view (captured[2]) must be an exact PREFIX of the store — same + // summary, same recent slice, same steering at the same index. (Before the + // fix, the store dropped the steering while the kernel kept it, so the two + // diverged structurally.) + const step1Messages = capturedMessages[2] ?? []; + expect(step1Messages[0]?.role).toBe("system"); // summary heads both + const kernelSteering = step1Messages.find( + (m) => + m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"), + ); + expect(kernelSteering).toBeDefined(); + // The kernel's post-compaction history is an exact PREFIX of the store + // (the store then has step 1's appended assistant output after it). Same + // length, same roles in order, same steering index => structural alignment. + expect(stored.length).toBeGreaterThanOrEqual(step1Messages.length); + const storePrefix = stored.slice(0, step1Messages.length); + expect(storePrefix).toHaveLength(step1Messages.length); + for (let i = 0; i < step1Messages.length; i++) { + expect(storePrefix[i]?.role).toBe(step1Messages[i]?.role); + } + const storeSteerIdx = storePrefix.findIndex( + (m) => + m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"), + ); + const kernelSteerIdx = step1Messages.findIndex( + (m) => + m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"), + ); + expect(kernelSteerIdx).toBe(storeSteerIdx); + expect(kernelSteerIdx).toBeGreaterThanOrEqual(0); + }); +}); + describe("per-turn memory telemetry", () => { function capturingLogger(): { logger: Logger; records: LogRecord[] } { let id = 0; |
