Download src/agents/harness/context-engine-lifecycle.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 31.1 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/harness/context-engine-lifecycle.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/harness/context-engine-lifecycle.test.ts
-
curl -L -o context-engine-lifecycle.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/harness/context-engine-lifecycle.test.ts
31.1 kB
| // Covers context-engine message filtering, assemble validation, and turn finalization. | |
| import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; | |
| import { describe, expect, it, vi } from "vitest"; | |
| import { buildMemorySystemPromptAddition } from "../../context-engine/delegate.js"; | |
| import { | |
| CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, | |
| OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, | |
| } from "../../context-engine/host-compat.js"; | |
| import { | |
| registerContextEngineForOwner, | |
| resolveContextEngine, | |
| } from "../../context-engine/registry.js"; | |
| import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js"; | |
| import type { | |
| ContextEngine, | |
| ContextEngineRuntimeContext, | |
| ContextEngineRuntimeSettings, | |
| } from "../../context-engine/types.js"; | |
| import { | |
| clearMemoryPluginState, | |
| registerMemoryPromptPreparation, | |
| registerTestMemoryPromptBuilder, | |
| type MemoryPromptSectionParams, | |
| } from "../../plugins/memory-state.test-fixtures.js"; | |
| import { compactContextEngineWithSafetyTimeout } from "../embedded-agent-runner/compaction-safety-timeout.js"; | |
| import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../internal-runtime-context.js"; | |
| import { | |
| assembleHarnessContextEngine, | |
| bootstrapHarnessContextEngine, | |
| finalizeHarnessContextEngineTurn, | |
| } from "./context-engine-lifecycle.js"; | |
| function registerTestContextEngine( | |
| id: string, | |
| factory: Parameters<typeof registerContextEngineForOwner>[1], | |
| ) { | |
| return registerContextEngineForOwner(id, factory, `test:${id}`, { | |
| allowSameOwnerRefresh: true, | |
| }); | |
| } | |
| function textMessage(role: "user" | "assistant", text: string, timestamp: number): AgentMessage { | |
| return { | |
| role, | |
| content: [{ type: "text", text }], | |
| timestamp, | |
| } as AgentMessage; | |
| } | |
| function runtimeContextMessage(content: string, timestamp: number): AgentMessage { | |
| // Runtime context is hidden harness metadata. Context engines should see | |
| // user/assistant transcript messages, not this internal custom channel. | |
| return { | |
| role: "custom", | |
| customType: OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE, | |
| content, | |
| display: false, | |
| details: { source: "openclaw-runtime-context" }, | |
| timestamp, | |
| } as AgentMessage; | |
| } | |
| function createContextEngine(overrides: Partial<ContextEngine> = {}): ContextEngine { | |
| return { | |
| info: { id: "test", name: "Test context engine" }, | |
| ingest: vi.fn(async () => ({ ingested: true })), | |
| assemble: vi.fn(async (params) => ({ | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| })), | |
| compact: vi.fn(async () => ({ ok: true, compacted: false })), | |
| ...overrides, | |
| }; | |
| } | |
| const sessionParams = { | |
| sessionIdUsed: "session-1", | |
| sessionId: "session-1", | |
| sessionKey: "agent:main:main", | |
| sessionFile: "sessions/main.jsonl", | |
| }; | |
| let configuredProofEngineIdCounter = 0; | |
| function uniqueConfiguredProofEngineId() { | |
| configuredProofEngineIdCounter += 1; | |
| return `configured-runtime-settings-proof-${configuredProofEngineIdCounter}`; | |
| } | |
| describe("harness context engine lifecycle", () => { | |
| it("forwards session keys across bootstrap, assemble, and afterTurn hooks", async () => { | |
| const bootstrap = vi.fn(async () => ({ bootstrapped: true })); | |
| const assemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => ({ | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| })); | |
| const afterTurn = vi.fn(async () => {}); | |
| const contextEngine = createContextEngine({ bootstrap, assemble, afterTurn }); | |
| await bootstrapHarnessContextEngine({ | |
| hadSessionFile: true, | |
| contextEngine, | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| runMaintenance: async () => undefined, | |
| warn: () => {}, | |
| }); | |
| await assembleHarnessContextEngine({ | |
| contextEngine, | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages: [textMessage("user", "ask", 1)], | |
| modelId: "gpt-test", | |
| }); | |
| await finalizeHarnessContextEngineTurn({ | |
| contextEngine, | |
| promptError: false, | |
| aborted: false, | |
| yieldAborted: false, | |
| sessionIdUsed: sessionParams.sessionIdUsed, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| messagesSnapshot: [textMessage("assistant", "done", 2)], | |
| prePromptMessageCount: 0, | |
| runMaintenance: async () => undefined, | |
| warn: () => {}, | |
| }); | |
| for (const hook of [bootstrap, assemble, afterTurn]) { | |
| expect(hook).toHaveBeenCalledWith( | |
| expect.objectContaining({ sessionKey: sessionParams.sessionKey }), | |
| ); | |
| } | |
| }); | |
| it.each([false, true])( | |
| "isolates prepared memory tools across non-legacy turns with sandboxed=%s", | |
| async (sandboxed) => { | |
| const prepare = vi.fn(async (params: MemoryPromptSectionParams) => { | |
| const tools = [...params.availableTools].join(","); | |
| params.availableTools.add("preparer-only"); | |
| await Promise.resolve(); | |
| return ["## Prepared Memory", `sandboxed=${params.sandboxed}; tools=${tools}`, ""]; | |
| }); | |
| registerTestMemoryPromptBuilder(({ availableTools }) => { | |
| availableTools.add("builder-only"); | |
| return ["## Memory Recall", ""]; | |
| }); | |
| registerMemoryPromptPreparation("memory-wiki", prepare); | |
| const assemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => ({ | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| systemPromptAddition: buildMemorySystemPromptAddition({ | |
| availableTools: params.availableTools ?? new Set(), | |
| citationsMode: params.citationsMode, | |
| }), | |
| })); | |
| try { | |
| for (const toolNames of [undefined, [], ["wiki_search"], ["memory_search"]]) { | |
| const availableTools = toolNames ? new Set(toolNames) : undefined; | |
| const result = await assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ assemble }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: "global", | |
| agentId: "support", | |
| messages: [textMessage("user", "visible ask", 1)], | |
| availableTools, | |
| citationsMode: "on", | |
| sandboxed, | |
| modelId: "gpt-test", | |
| }); | |
| expect(result?.systemPromptAddition).toBe( | |
| `## Memory Recall\n\n## Prepared Memory\nsandboxed=${sandboxed}; tools=${(toolNames ?? []).join(",")}`, | |
| ); | |
| expect([...(availableTools ?? [])]).toEqual(toolNames ?? []); | |
| expect(prepare).toHaveBeenLastCalledWith( | |
| expect.objectContaining({ | |
| agentId: "support", | |
| agentSessionKey: "global", | |
| sandboxed, | |
| }), | |
| ); | |
| } | |
| expect(buildMemorySystemPromptAddition({ availableTools: new Set() })).toBe( | |
| "## Memory Recall", | |
| ); | |
| } finally { | |
| clearMemoryPluginState(); | |
| } | |
| }, | |
| ); | |
| it("keeps hidden runtime-context custom messages out of assemble hooks", async () => { | |
| const visibleUser = textMessage("user", "visible ask", 1); | |
| const hiddenRuntimeContext = runtimeContextMessage("hidden runtime context", 2); | |
| const visibleAssistant = textMessage("assistant", "visible answer", 3); | |
| const assemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => ({ | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| })); | |
| await assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ assemble }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages: [visibleUser, hiddenRuntimeContext, visibleAssistant], | |
| modelId: "gpt-test", | |
| }); | |
| const assembleParams = assemble.mock.calls.at(0)?.[0]; | |
| expect(assembleParams?.messages).toEqual([visibleUser, visibleAssistant]); | |
| }); | |
| it("keeps persisted runtime-context carriers in place for append-only replay policies", async () => { | |
| // Prefix-bound thinking (Anthropic family) is signed over every earlier message, | |
| // including the carrier that followed its user turn; assembly must not drop it. | |
| const visibleUser = textMessage("user", "visible ask", 1); | |
| const persistedCarrier = runtimeContextMessage("persisted runtime context", 2); | |
| const visibleAssistant = textMessage("assistant", "visible answer", 3); | |
| const assemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => ({ | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| })); | |
| await assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ assemble }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| appendOnlyRuntimeContext: true, | |
| messages: [visibleUser, persistedCarrier, visibleAssistant], | |
| modelId: "claude-test", | |
| }); | |
| const assembleParams = assemble.mock.calls.at(0)?.[0]; | |
| expect(assembleParams?.messages).toEqual([visibleUser, persistedCarrier, visibleAssistant]); | |
| }); | |
| it("isolates the authoritative history array while preserving in-place assembly", async () => { | |
| const first = textMessage("user", "first", 1); | |
| const second = textMessage("assistant", "second", 2); | |
| const messages = [first, second]; | |
| const assemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => { | |
| expect(params.messages).not.toBe(messages); | |
| expect(params.messages).toEqual(messages); | |
| expect(params.messages[0]).toBe(first); | |
| expect(params.messages[1]).toBe(second); | |
| params.messages.reverse(); | |
| return { messages: params.messages, estimatedTokens: 0 }; | |
| }); | |
| const assembled = await assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ assemble }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages, | |
| modelId: "gpt-test", | |
| }); | |
| expect(messages).toEqual([first, second]); | |
| expect(assembled?.messages).toEqual([second, first]); | |
| expect(assembled?.messages[0]).toBe(second); | |
| expect(assembled?.messages[1]).toBe(first); | |
| }); | |
| it("preserves the authoritative history when assembly mutates then throws", async () => { | |
| const first = textMessage("user", "first", 1); | |
| const second = textMessage("assistant", "second", 2); | |
| const messages = [first, second]; | |
| const contextEngine = createContextEngine({ | |
| assemble: vi.fn(async ({ messages: workingMessages }) => { | |
| workingMessages.reverse(); | |
| workingMessages.pop(); | |
| throw new Error("assembly failed after windowing"); | |
| }), | |
| }); | |
| await expect( | |
| assembleHarnessContextEngine({ | |
| contextEngine, | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages, | |
| modelId: "gpt-test", | |
| }), | |
| ).rejects.toThrow("assembly failed after windowing"); | |
| expect(messages).toEqual([first, second]); | |
| expect(messages[0]).toBe(first); | |
| expect(messages[1]).toBe(second); | |
| }); | |
| it("passes declared runtime settings into assemble hooks", async () => { | |
| const visibleUser = textMessage("user", "visible ask", 1); | |
| const assemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => ({ | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| })); | |
| await assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ assemble }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages: [visibleUser], | |
| tokenBudget: 4096, | |
| modelId: "gpt-5.5", | |
| providerId: "openai", | |
| contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, | |
| }); | |
| const assembleParams = assemble.mock.calls.at(0)?.[0]; | |
| expect(assembleParams?.runtimeSettings).toMatchObject({ | |
| schemaVersion: 1, | |
| runtime: { | |
| host: "openclaw", | |
| mode: "normal", | |
| }, | |
| model: { | |
| resolved: "gpt-5.5", | |
| provider: "openai", | |
| }, | |
| contextEngineSelection: { | |
| selectedId: expect.any(String), | |
| source: "configured", | |
| }, | |
| executionHost: { | |
| id: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST.id, | |
| }, | |
| limits: { | |
| promptTokenBudget: 4096, | |
| }, | |
| }); | |
| }); | |
| it("passes runtime settings through a configured context engine across lifecycle hooks", async () => { | |
| const engineId = uniqueConfiguredProofEngineId(); | |
| const captured: Array<{ | |
| hook: "bootstrap" | "assemble" | "afterTurn" | "maintain" | "compact"; | |
| runtimeContext?: ContextEngineRuntimeContext; | |
| runtimeSettings?: ContextEngineRuntimeSettings; | |
| sessionTarget?: ContextEngineRuntimeContext["sessionTarget"]; | |
| }> = []; | |
| const sessionTarget = { | |
| agentId: "main", | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| storePath: "/tmp/state/openclaw.sqlite", | |
| }; | |
| const bootstrapRuntimeContext = { | |
| transcriptStorage: { kind: "sqlite" as const }, | |
| sessionTarget, | |
| promptCache: { | |
| observation: { | |
| broke: true, | |
| previousCacheRead: 5000, | |
| cacheRead: 2000, | |
| changes: [{ code: "systemPrompt", detail: "system prompt digest changed" }], | |
| }, | |
| }, | |
| } satisfies ContextEngineRuntimeContext; | |
| const engine = createContextEngine({ | |
| info: { | |
| id: engineId, | |
| name: "Configured runtime settings proof engine", | |
| acceptedHostParams: ["runtimeSettings", "runtimeContext", "sessionTarget"], | |
| }, | |
| bootstrap: vi.fn(async (params) => { | |
| captured.push({ | |
| hook: "bootstrap", | |
| runtimeContext: params.runtimeContext, | |
| runtimeSettings: params.runtimeSettings, | |
| sessionTarget: params.sessionTarget, | |
| }); | |
| return { bootstrapped: true }; | |
| }), | |
| assemble: vi.fn(async (params) => { | |
| captured.push({ hook: "assemble", runtimeSettings: params.runtimeSettings }); | |
| return { | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| }; | |
| }), | |
| afterTurn: vi.fn(async (params) => { | |
| captured.push({ | |
| hook: "afterTurn", | |
| runtimeContext: params.runtimeContext, | |
| runtimeSettings: params.runtimeSettings, | |
| sessionTarget: params.sessionTarget, | |
| }); | |
| }), | |
| maintain: vi.fn(async (params) => { | |
| captured.push({ | |
| hook: "maintain", | |
| runtimeContext: params.runtimeContext, | |
| runtimeSettings: params.runtimeSettings, | |
| sessionTarget: params.sessionTarget, | |
| }); | |
| return { changed: false, bytesFreed: 0, rewrittenEntries: 0 }; | |
| }), | |
| compact: vi.fn(async (params) => { | |
| captured.push({ hook: "compact", runtimeSettings: params.runtimeSettings }); | |
| return { ok: true, compacted: false }; | |
| }), | |
| }); | |
| registerTestContextEngine(engineId, () => engine); | |
| const configuredEngine = await resolveContextEngine({ | |
| plugins: { slots: { contextEngine: engineId } }, | |
| }); | |
| await bootstrapHarnessContextEngine({ | |
| hadSessionFile: true, | |
| contextEngine: configuredEngine, | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionTarget, | |
| sessionFile: sessionParams.sessionFile, | |
| providerId: "openai", | |
| requestedModelId: "openai/gpt-5.5", | |
| modelId: "anthropic/claude-sonnet-4-6", | |
| fallbackReason: "primary_provider_5xx", | |
| runtimeContext: bootstrapRuntimeContext, | |
| warn: () => {}, | |
| }); | |
| await assembleHarnessContextEngine({ | |
| contextEngine: configuredEngine, | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages: [textMessage("user", "visible ask", 1)], | |
| tokenBudget: 2048, | |
| runtimeContext: bootstrapRuntimeContext, | |
| providerId: "openai", | |
| requestedModelId: "openai/gpt-5.5", | |
| modelId: "anthropic/claude-sonnet-4-6", | |
| fallbackReason: "primary_provider_5xx", | |
| }); | |
| await finalizeHarnessContextEngineTurn({ | |
| contextEngine: configuredEngine, | |
| promptError: false, | |
| aborted: false, | |
| yieldAborted: false, | |
| sessionIdUsed: sessionParams.sessionIdUsed, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionTarget, | |
| sessionFile: sessionParams.sessionFile, | |
| messagesSnapshot: [ | |
| textMessage("user", "old ask", 1), | |
| textMessage("assistant", "old answer", 2), | |
| textMessage("user", "new ask", 3), | |
| textMessage("assistant", "new answer", 4), | |
| ], | |
| prePromptMessageCount: 2, | |
| tokenBudget: 2048, | |
| runtimeContext: bootstrapRuntimeContext, | |
| providerId: "openai", | |
| requestedModelId: "openai/gpt-5.5", | |
| modelId: "anthropic/claude-sonnet-4-6", | |
| fallbackReason: "primary_provider_5xx", | |
| warn: () => {}, | |
| }); | |
| const compactRuntimeSettings = buildContextEngineRuntimeSettings({ | |
| contextEngineHost: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, | |
| provider: "openai", | |
| requestedModel: "openai/gpt-5.5", | |
| resolvedModel: "anthropic/claude-sonnet-4-6", | |
| selectedContextEngineId: engineId, | |
| contextEngineSelectionSource: "configured", | |
| promptTokenBudget: 2048, | |
| fallbackReason: "primary_provider_5xx", | |
| }); | |
| await compactContextEngineWithSafetyTimeout( | |
| configuredEngine, | |
| { | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionTarget: { | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| }, | |
| tokenBudget: 2048, | |
| runtimeSettings: compactRuntimeSettings, | |
| }, | |
| 100, | |
| ); | |
| expect(new Set(captured.map((entry) => entry.hook))).toEqual( | |
| new Set(["bootstrap", "assemble", "afterTurn", "maintain", "compact"]), | |
| ); | |
| expect(captured.find((entry) => entry.hook === "bootstrap")?.runtimeContext).toEqual( | |
| bootstrapRuntimeContext, | |
| ); | |
| expect(captured.find((entry) => entry.hook === "bootstrap")?.sessionTarget).toEqual( | |
| sessionTarget, | |
| ); | |
| expect(captured.find((entry) => entry.hook === "afterTurn")?.sessionTarget).toEqual( | |
| sessionTarget, | |
| ); | |
| expect(captured.find((entry) => entry.hook === "afterTurn")?.runtimeContext).toEqual( | |
| bootstrapRuntimeContext, | |
| ); | |
| expect(captured.find((entry) => entry.hook === "maintain")?.sessionTarget).toEqual( | |
| sessionTarget, | |
| ); | |
| expect( | |
| captured.find((entry) => entry.hook === "maintain")?.runtimeContext?.sessionTarget, | |
| ).toEqual(sessionTarget); | |
| for (const entry of captured) { | |
| expect(entry.runtimeSettings).toMatchObject({ | |
| schemaVersion: 1, | |
| runtime: { mode: "fallback" }, | |
| model: { | |
| requested: "openai/gpt-5.5", | |
| resolved: "anthropic/claude-sonnet-4-6", | |
| provider: "openai", | |
| family: null, | |
| }, | |
| contextEngineSelection: { | |
| selectedId: engineId, | |
| source: "configured", | |
| }, | |
| diagnostics: { | |
| fallbackReason: "provider_unavailable", | |
| }, | |
| }); | |
| } | |
| }); | |
| it("never derives model.family from the model id (defaults to null)", async () => { | |
| const assemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => ({ | |
| messages: params.messages, | |
| estimatedTokens: 0, | |
| })); | |
| await assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ assemble }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages: [textMessage("user", "ask", 1)], | |
| modelId: "anthropic/claude-opus-4-8", | |
| providerId: "anthropic", | |
| }); | |
| const noFamily = assemble.mock.calls.at(0)?.[0]?.runtimeSettings?.model; | |
| // Regression: model.family must not mirror the model id. | |
| expect(noFamily?.resolved).toBe("anthropic/claude-opus-4-8"); | |
| expect(noFamily?.family).toBeNull(); | |
| await assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ assemble }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages: [textMessage("user", "ask", 1)], | |
| modelId: "anthropic/claude-opus-4-8", | |
| providerId: "anthropic", | |
| modelFamily: "claude", | |
| }); | |
| const withFamily = assemble.mock.calls.at(1)?.[0]?.runtimeSettings?.model; | |
| // When a real family is supplied, it is carried through verbatim. | |
| expect(withFamily?.family).toBe("claude"); | |
| }); | |
| it("keeps hidden runtime-context custom messages out of afterTurn hooks", async () => { | |
| const beforePromptUser = textMessage("user", "old ask", 1); | |
| const beforePromptRuntimeContext = runtimeContextMessage("old hidden context", 2); | |
| const beforePromptAssistant = textMessage("assistant", "old answer", 3); | |
| const turnUser = textMessage("user", "new ask", 4); | |
| const turnRuntimeContext = runtimeContextMessage("new hidden context", 5); | |
| const turnAssistant = textMessage("assistant", "new answer", 6); | |
| const afterTurn = vi.fn(async () => {}); | |
| await finalizeHarnessContextEngineTurn({ | |
| contextEngine: createContextEngine({ afterTurn }), | |
| promptError: false, | |
| aborted: false, | |
| yieldAborted: false, | |
| sessionIdUsed: sessionParams.sessionIdUsed, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| messagesSnapshot: [ | |
| beforePromptUser, | |
| beforePromptRuntimeContext, | |
| beforePromptAssistant, | |
| turnUser, | |
| turnRuntimeContext, | |
| turnAssistant, | |
| ], | |
| prePromptMessageCount: 3, | |
| tokenBudget: 2048, | |
| runtimeContext: {}, | |
| runMaintenance: async () => undefined, | |
| warn: () => {}, | |
| }); | |
| const afterTurnCalls = (afterTurn as unknown as { mock: { calls: unknown[][] } }).mock.calls; | |
| const afterTurnParams = afterTurnCalls[0]?.[0] as | |
| | { messages?: AgentMessage[]; prePromptMessageCount?: number } | |
| | undefined; | |
| expect(afterTurnParams?.messages).toEqual([ | |
| beforePromptUser, | |
| beforePromptAssistant, | |
| turnUser, | |
| turnAssistant, | |
| ]); | |
| expect(afterTurnParams?.prePromptMessageCount).toBe(2); | |
| }); | |
| describe("assembleHarnessContextEngine result validation", () => { | |
| // Regression for #75541: plugins that return a malformed assemble result | |
| // previously poisoned activeSession.messages with `undefined`, which then | |
| // crashed downstream with "Cannot read properties of undefined (reading | |
| // 'length')". The harness wrapper now throws a descriptive error so the | |
| // runner's existing assemble try/catch can log the engine id and fall | |
| // back to the pipeline messages instead of corrupting session state. | |
| const visibleUser = textMessage("user", "ping", 1); | |
| async function runAssembleWithEngineResult(result: unknown) { | |
| return assembleHarnessContextEngine({ | |
| contextEngine: createContextEngine({ | |
| info: { id: "broken-engine", name: "Broken engine" }, | |
| assemble: vi.fn(async () => result as never), | |
| }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| messages: [visibleUser], | |
| modelId: "gpt-test", | |
| }); | |
| } | |
| it("passes through a well-formed AssembleResult unchanged", async () => { | |
| const wellFormed = { messages: [visibleUser], estimatedTokens: 0 }; | |
| await expect(runAssembleWithEngineResult(wellFormed)).resolves.toBe(wellFormed); | |
| }); | |
| it("rejects an undefined assemble result with the engine id", async () => { | |
| await expect(runAssembleWithEngineResult(undefined)).rejects.toThrow( | |
| /context engine "broken-engine"[\s\S]*messages/, | |
| ); | |
| }); | |
| it("rejects a null assemble result with the engine id", async () => { | |
| await expect(runAssembleWithEngineResult(null)).rejects.toThrow( | |
| /context engine "broken-engine"[\s\S]*messages/, | |
| ); | |
| }); | |
| it("rejects an assemble result missing the messages array (lossless-claw shape)", async () => { | |
| // Mirrors the malformed shape reported in #75541, where the plugin | |
| // returned an object that satisfied the truthy `if (!assembled)` guard | |
| // but omitted the required `messages` field. | |
| await expect(runAssembleWithEngineResult({ estimatedTokens: 0 })).rejects.toThrow( | |
| /assemble\(\) returned an invalid result[\s\S]*messages of type undefined/, | |
| ); | |
| }); | |
| it("rejects an assemble result whose messages field is not an array", async () => { | |
| await expect( | |
| runAssembleWithEngineResult({ messages: "all of them", estimatedTokens: 0 }), | |
| ).rejects.toThrow(/messages of type string/); | |
| }); | |
| it("rejects an assemble result whose messages field is null", async () => { | |
| await expect( | |
| runAssembleWithEngineResult({ messages: null, estimatedTokens: 0 }), | |
| ).rejects.toThrow(/messages of type null/); | |
| }); | |
| }); | |
| it("keeps hidden runtime-context custom messages out of ingestBatch fallbacks", async () => { | |
| const beforePromptUser = textMessage("user", "old ask", 1); | |
| const beforePromptRuntimeContext = runtimeContextMessage("old hidden context", 2); | |
| const beforePromptAssistant = textMessage("assistant", "old answer", 3); | |
| const turnUser = textMessage("user", "new ask", 4); | |
| const turnRuntimeContext = runtimeContextMessage("new hidden context", 5); | |
| const turnAssistant = textMessage("assistant", "new answer", 6); | |
| const ingestBatch = vi.fn(async () => ({ ingestedCount: 2 })); | |
| // The ingestBatch fallback receives only the current visible turn, with | |
| // hidden runtime context filtered and the pre-prompt history excluded. | |
| await finalizeHarnessContextEngineTurn({ | |
| contextEngine: createContextEngine({ ingestBatch }), | |
| promptError: false, | |
| aborted: false, | |
| yieldAborted: false, | |
| sessionIdUsed: sessionParams.sessionIdUsed, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| messagesSnapshot: [ | |
| beforePromptUser, | |
| beforePromptRuntimeContext, | |
| beforePromptAssistant, | |
| turnUser, | |
| turnRuntimeContext, | |
| turnAssistant, | |
| ], | |
| prePromptMessageCount: 3, | |
| tokenBudget: 2048, | |
| runtimeContext: {}, | |
| runMaintenance: async () => undefined, | |
| warn: () => {}, | |
| isHeartbeat: true, | |
| }); | |
| const ingestBatchCalls = (ingestBatch as unknown as { mock: { calls: unknown[][] } }).mock | |
| .calls; | |
| const ingestBatchParams = ingestBatchCalls[0]?.[0] as | |
| | { isHeartbeat?: boolean; messages?: AgentMessage[]; sessionKey?: string } | |
| | undefined; | |
| expect(ingestBatchParams?.messages).toEqual([turnUser, turnAssistant]); | |
| expect(ingestBatchParams?.isHeartbeat).toBe(true); | |
| expect(ingestBatchParams?.sessionKey).toBe(sessionParams.sessionKey); | |
| }); | |
| it("forwards heartbeat state to per-message ingest fallbacks", async () => { | |
| const turnUser = textMessage("user", "new ask", 4); | |
| const turnAssistant = textMessage("assistant", "new answer", 6); | |
| const ingest = vi.fn(async () => ({ ingested: true })); | |
| await finalizeHarnessContextEngineTurn({ | |
| contextEngine: createContextEngine({ ingest }), | |
| promptError: false, | |
| aborted: false, | |
| yieldAborted: false, | |
| sessionIdUsed: sessionParams.sessionIdUsed, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| messagesSnapshot: [turnUser, turnAssistant], | |
| prePromptMessageCount: 0, | |
| tokenBudget: 2048, | |
| runtimeContext: {}, | |
| runMaintenance: async () => undefined, | |
| warn: () => {}, | |
| isHeartbeat: true, | |
| }); | |
| const ingestCalls = (ingest as unknown as { mock: { calls: unknown[][] } }).mock.calls; | |
| expect(ingestCalls).toHaveLength(2); | |
| for (const call of ingestCalls) { | |
| const ingestParams = call[0] as { isHeartbeat?: boolean; sessionKey?: string }; | |
| expect(ingestParams.isHeartbeat).toBe(true); | |
| expect(ingestParams.sessionKey).toBe(sessionParams.sessionKey); | |
| } | |
| }); | |
| it.each(["afterTurn", "ingestBatch"] as const)( | |
| "skips turn maintenance when %s fails", | |
| async (failingHook) => { | |
| const runMaintenance = vi.fn(async () => undefined); | |
| const contextEngine = createContextEngine({ | |
| afterTurn: | |
| failingHook === "afterTurn" | |
| ? vi.fn(async () => { | |
| throw new Error("afterTurn failed"); | |
| }) | |
| : undefined, | |
| ingestBatch: | |
| failingHook === "ingestBatch" | |
| ? vi.fn(async () => { | |
| throw new Error("ingestBatch failed"); | |
| }) | |
| : undefined, | |
| }); | |
| await finalizeHarnessContextEngineTurn({ | |
| contextEngine, | |
| promptError: false, | |
| aborted: false, | |
| yieldAborted: false, | |
| sessionIdUsed: sessionParams.sessionIdUsed, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| messagesSnapshot: [textMessage("assistant", "done", 1)], | |
| prePromptMessageCount: 0, | |
| runMaintenance, | |
| warn: () => {}, | |
| }); | |
| expect(runMaintenance).not.toHaveBeenCalled(); | |
| }, | |
| ); | |
| it("runs bootstrap maintenance for existing sessions without bootstrap()", async () => { | |
| const runMaintenance = vi.fn(async () => undefined); | |
| await bootstrapHarnessContextEngine({ | |
| hadSessionFile: true, | |
| contextEngine: createContextEngine({ | |
| bootstrap: undefined, | |
| maintain: vi.fn(async () => ({ | |
| changed: false, | |
| bytesFreed: 0, | |
| rewrittenEntries: 0, | |
| })), | |
| }), | |
| sessionId: sessionParams.sessionId, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| runMaintenance, | |
| warn: () => {}, | |
| }); | |
| expect(runMaintenance).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| reason: "bootstrap", | |
| sessionKey: sessionParams.sessionKey, | |
| }), | |
| ); | |
| }); | |
| it.each([ | |
| { promptError: true, aborted: false, yieldAborted: false }, | |
| { promptError: false, aborted: true, yieldAborted: false }, | |
| { promptError: false, aborted: false, yieldAborted: true }, | |
| ])("does not advance context ingestion for unsuccessful turns: %o", async (terminal) => { | |
| const afterTurn = vi.fn(async () => {}); | |
| const ingest = vi.fn(async () => ({ ingested: true })); | |
| const ingestBatch = vi.fn(async () => ({ ingestedCount: 0 })); | |
| const maintain = vi.fn(async () => ({ | |
| changed: false, | |
| bytesFreed: 0, | |
| rewrittenEntries: 0, | |
| })); | |
| await finalizeHarnessContextEngineTurn({ | |
| contextEngine: createContextEngine({ afterTurn, ingest, ingestBatch, maintain }), | |
| ...terminal, | |
| sessionIdUsed: sessionParams.sessionIdUsed, | |
| sessionKey: sessionParams.sessionKey, | |
| sessionFile: sessionParams.sessionFile, | |
| messagesSnapshot: [textMessage("user", "failed", 1)], | |
| prePromptMessageCount: 0, | |
| warn: () => {}, | |
| }); | |
| expect(afterTurn).not.toHaveBeenCalled(); | |
| expect(ingest).not.toHaveBeenCalled(); | |
| expect(ingestBatch).not.toHaveBeenCalled(); | |
| expect(maintain).not.toHaveBeenCalled(); | |
| }); | |
| }); | |