summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src/orchestrator.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/session-orchestrator/src/orchestrator.test.ts')
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts678
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;