diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index d90597e7cfb3..501ab74b28a2 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -183,7 +183,6 @@ src/agents/embedded-agent-runner/compact.hooks.test.ts src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts src/agents/embedded-agent-runner/runs.ts -src/agents/embedded-agent-runner/tool-result-truncation.test.ts src/agents/embedded-agent-runner/tool-result-truncation.ts src/agents/embedded-agent-subscribe.handlers.tools.test.ts src/agents/harness/native-hook-relay.test.ts diff --git a/config/test-timeout-race-baseline.txt b/config/test-timeout-race-baseline.txt index c8ca9c0d0a73..4849af80df8b 100644 --- a/config/test-timeout-race-baseline.txt +++ b/config/test-timeout-race-baseline.txt @@ -83,7 +83,7 @@ src/infra/push-apns.test.ts 1 src/infra/session-cost-usage-cache.worker.test.ts 5 src/infra/state-migrations.workspace-setup-sandbox.test.ts 1 src/meeting-bot/realtime-node-audio-transport.test.ts 1 -src/plugins/openai-compatible-embedding-provider.test.ts 3 +src/plugins/openai-compatible-embedding-provider.test.ts 2 src/plugins/plugin-metadata-lifecycle.test.ts 1 src/plugins/provider-self-hosted-discovery.test.ts 11 src/plugins/runtime/runtime-agent.ingress.integration.test.ts 1 diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 2bc4bfa37551..05313bb279a2 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -1,6 +1,5 @@ // Hook integration coverage for direct and queued embedded compaction. -import { mkdtemp, realpath } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { realpath } from "node:fs/promises"; import { join } from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import type { AgentMessage, StreamFn } from "openclaw/plugin-sdk/agent-core"; @@ -19,7 +18,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { delegateCompactionToRuntime } from "../../context-engine/delegate.js"; import { racePromiseWithAbortSignal } from "../../infra/abort-signal.js"; import { createPluginMetadataSnapshotFixture } from "../../plugins/plugin-metadata.test-support.js"; -import { getModelProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js"; import type { CommandQueueEnqueueOptions } from "../../process/command-queue.types.js"; import type { SessionTurnAdmission } from "../../sessions/session-controller.admission.js"; import type { ReplyOperation } from "../../sessions/session-controller.contracts.js"; @@ -28,7 +26,6 @@ import { waitForSessionRunEnd } from "../../sessions/session-controller.native-r import { isSessionRunActive } from "../../sessions/session-controller.queries.js"; import { createProcessSessionFixture } from "../bash-process-registry.test-helpers.js"; import { recordModelFallbackStop } from "../model-fallback-stop.js"; -import { getModelProviderLocalServiceReconciler } from "../provider-local-service-reconcile.js"; import { createSessionMaintenanceOwner } from "../session-maintenance/coordinator.js"; import { agentSessionAutomaticCompaction } from "../sessions/agent-session-compaction.js"; import { @@ -66,15 +63,12 @@ import { getApiKeyForModelMock, getMemorySearchManagerMock, resolveMemorySearchConfigMock, - resolveSessionAgentIdMock, getHistoryLimitFromSessionKeyMock, hookRunner, limitHistoryTurnsMock, - listRegisteredPluginAgentPromptGuidanceMock, loadCompactHooksHarness, maybeCompactAgentHarnessSessionMock, resolveAgentHarnessPolicyMock, - registerProviderStreamForModelMock, resolveProviderEntryApiKeyProfileReferenceMock, resolveContextWindowInfoMock, resolveCliBackendConfigMock, @@ -125,17 +119,13 @@ const compactionFixture = useCompactHooksSessionFixture(TEST_SESSION_KEY); let TEST_SESSION_FILE: string; let TEST_WORKSPACE_DIR: string; const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions"; -function plannedCompactionPluginSelections( - config: OpenClawConfig, - metadataSnapshot = createPluginMetadataSnapshotFixture({ plugins: [] }), -) { +function plannedCompactionPluginSelections(config: OpenClawConfig) { const derive = expectDefined( acquireAgentRunPreparedModelRuntimeMock.mock.calls[0]?.[1]?.deriveRuntimePluginSelections, "admitted compaction selection recipe", ); - return derive({ config, metadataSnapshot }); + return derive({ config, metadataSnapshot: createPluginMetadataSnapshotFixture({ plugins: [] }) }); } - function mockResolvedModel(supportsTools?: boolean, queued = false) { resolveModelMock.mockReset(); resolveModelMock.mockImplementation( @@ -181,17 +171,21 @@ function directCompactionArgs() { }; } +function defaultSessionTarget() { + return { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath: TEST_STORE_PATH, + }; +} + function wrappedCompactionArgs(overrides: Record = {}) { return { sessionId: TEST_SESSION_ID, sessionKey: TEST_SESSION_KEY, sessionFile: TEST_SESSION_KEY, - sessionTarget: { - agentId: "main", - sessionId: TEST_SESSION_ID, - sessionKey: TEST_SESSION_KEY, - storePath: TEST_STORE_PATH, - }, + sessionTarget: defaultSessionTarget(), workspaceDir: TEST_WORKSPACE_DIR, customInstructions: TEST_CUSTOM_INSTRUCTIONS, ...overrides, @@ -222,6 +216,34 @@ function expectNoCalls(...mocks: Mock[]) { } const abortedCompaction = { ok: false, compacted: false, reason: "compaction aborted" }; +const compacted = { ok: true, compacted: true }; +const notCompacted = { ok: false, compacted: false }; +const unchangedMaintenance = { changed: false, bytesFreed: 0, rewrittenEntries: 0 }; + +function lockedFailure(reason = "model_selection_locked") { + return { ...notCompacted, failure: { reason } }; +} + +function assertActiveWhile(active: () => boolean) { + return () => { + if (!active()) { + throw new Error("queued compaction host authority expired"); + } + }; +} + +function subscriptionModel(provider = "openai", modelId = "fake") { + return { + ...createCompactHooksResolvedModel(provider, modelId), + model: { + provider: "openai", + id: "fake", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + input: [], + }, + }; +} function useCodexPolicy() { const policy = { runtime: "codex", runtimeSource: "model" }; @@ -419,9 +441,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { customInstructions: "preserve the committed state", }); expect(contextEngineCompactMock).not.toHaveBeenCalled(); - expect(createAgentSessionMock).toHaveBeenCalledOnce(); expect(sessionCompactImpl).toHaveBeenCalledOnce(); - expect(result).toMatchObject({ ok: false, compacted: false }); + expect(result).toMatchObject(notCompacted); }); it("restricts endpoint tools and excludes private skills", async () => { @@ -516,7 +537,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }, }, }); - expect(attemptServerEndpointCompactionMock).toHaveBeenCalledOnce(); expectCall(attemptServerEndpointCompactionMock, { context: expect.objectContaining({ messages: [ @@ -568,8 +588,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactDirect({ compactionTimeoutReset }); - expect(result).toMatchObject({ ok: true, compacted: true }); - expect(hookRunner.runAfterCompaction).toHaveBeenCalledOnce(); + expect(result).toMatchObject(compacted); }); it("refreshes the delegated watchdog before delayed fallback setup", async () => { @@ -599,8 +618,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await pending; } - await expect(pending).resolves.toMatchObject({ ok: true, compacted: true }); - expect(createAgentSessionMock).toHaveBeenCalledTimes(2); + await expect(pending).resolves.toMatchObject(compacted); expect(compactionTimeoutReset).toHaveBeenCalledTimes(6); }); @@ -649,11 +667,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionEntry: { sessionId: TEST_SESSION_ID, updatedAt: 1, pluginOwnerId: "stale-owner" }, }); - expect(result).toMatchObject({ - ok: false, - compacted: false, - failure: { reason: "model_selection_locked" }, - }); + expect(result).toMatchObject(lockedFailure()); expectNoCalls(resolveModelMock, sessionCompactImpl); }); @@ -745,12 +759,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { ([params]) => (params as { profileId?: string }).profileId, ), ).toEqual(["openai:missing", "openai:backup"]); - expect(resolveModelAsyncMock.mock.calls.map((call) => call[4])).toEqual( - expect.arrayContaining([ - expect.objectContaining({ authProfileId: "openai:backup" }), - expect.objectContaining({ authProfileId: "openai:missing" }), - ]), - ); expectCall(resolveEmbeddedAgentStreamMock, { authProfileId: "openai:backup" }); expectCall(buildAgentRuntimePlanMock, { sessionAuthProfileId: "openai:backup" }); }); @@ -788,10 +796,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }, }); - expectCall(resolveSandboxContextMock, { - sessionKey: other.scopeKey, - workspaceDir: join(TEST_WORKSPACE_DIR, "workspace"), - }); expect(sandboxQueries).toEqual([]); expectCall(buildConfiguredAgentSystemPromptMock, { runtimeInfo: expect.objectContaining({ @@ -804,27 +808,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { } }); - it.each([ - ["agent:main:subagent:worker", "subagent", "minimal", "Subagent compact command guidance."], - ["agent:codex:acp:worker", "acp_backend", "full", "ACP compact command guidance."], - ] as const)( - "rebuilds the %s prompt with its guidance", - async (sessionKey, promptSurface, promptMode, guidance) => { - const runtimeCwd = join(TEST_WORKSPACE_DIR, "task-repo"); - await compactWorkspace({ sessionKey, cwd: runtimeCwd }); - expect(listRegisteredPluginAgentPromptGuidanceMock).toHaveBeenCalledWith({ - surface: promptSurface, - }); - expectCall(buildConfiguredAgentSystemPromptMock, { - promptMode, - promptSurface, - runtimeCwd, - workspaceDir: join(TEST_WORKSPACE_DIR, "workspace"), - nativeCommandGuidanceLines: [guidance], - }); - }, - ); - it.each([false, true])( "renders the compaction bootstrap notice only for omitted files (%s)", async (omitted) => { @@ -856,7 +839,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const prompt = created.session.agent.state.systemPrompt ?? ""; expect(prompt.includes("## Bootstrap Context Notice")).toBe(omitted); expect(prompt.includes("Treat Project Context as partial")).toBe(omitted); - expect(created.session.setBaseSystemPrompt).toHaveBeenCalledWith(prompt); }, ); @@ -935,11 +917,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactWorkspace({ config: { tools: { profile: "coding" } } }); expect(result.ok).toBe(true); - const toolOptions = mockCallArg(createOpenClawCodingToolsMock) as { - preparedModelRuntime: { metadataSnapshot: unknown }; - }; - expect(toolOptions.preparedModelRuntime.metadataSnapshot).toBe(metadataSnapshot); - expect(toolOptions).toMatchObject({ + expect(mockCallArg(createOpenClawCodingToolsMock)).toMatchObject({ conversationCapabilityProfile: { policy: { explicitToolAllowlist: expect.arrayContaining([toolName]) }, }, @@ -1192,8 +1170,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const run = await compactWhileStreaming(10, "done"); await vi.advanceTimersByTimeAsync(11 * deltaEveryMs); - await expect(run.pending).resolves.toMatchObject({ ok: true, compacted: true }); - expect(run.stream).toHaveBeenCalledOnce(); + await expect(run.pending).resolves.toMatchObject(compacted); }); it.each([ @@ -1216,7 +1193,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { compacted: false, reason: expect.stringContaining("timed out"), }); - expect(run.stream).toHaveBeenCalledOnce(); }); // #164246: automatic summary timeouts still commit deterministic reduction. @@ -1228,11 +1204,10 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); await vi.advanceTimersByTimeAsync(summaryCutoffMs - prepMs); - await expect(run.pending).resolves.toMatchObject({ ok: true, compacted: true }); + await expect(run.pending).resolves.toMatchObject(compacted); expect( run.sessionManager.getBranch().findLast((entry) => entry.type === "compaction"), ).toMatchObject({ summary: expect.stringContaining("removed without a summary") }); - expect(run.stream).toHaveBeenCalledOnce(); }); }); @@ -1248,23 +1223,16 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { config, }); expect(result.ok, JSON.stringify(result)).toBe(true); + const expectedSelection = { provider: "anthropic", runtime: "codex" }; expect(plannedCompactionPluginSelections(config)).toContainEqual( - expect.objectContaining({ - provider: "anthropic", - modelId: "claude-fallback", - runtime: "codex", - }), + expect.objectContaining({ ...expectedSelection, modelId: "claude-fallback" }), ); const admittedConfig = defaultsConfig({ ...config.agents?.defaults, compaction: { model: "anthropic/reloaded-summary" }, }); expect(plannedCompactionPluginSelections(admittedConfig)).toContainEqual( - expect.objectContaining({ - provider: "anthropic", - modelId: "reloaded-summary", - runtime: "codex", - }), + expect.objectContaining({ ...expectedSelection, modelId: "reloaded-summary" }), ); }); @@ -1333,14 +1301,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); expect(result).toMatchObject({ ok: true, result: { summary: "oauth fallback summary" } }); - findMockCall( - resolveModelMock, - ([provider, modelId]) => provider === "openai" && modelId === "gpt-5.5", - ); - findMockCall( - resolveModelMock, - ([provider, modelId]) => provider === "openai" && modelId === "gpt-5.4-mini", - ); expectRecordFields(mockCallArg(resolveEmbeddedAgentStreamMock, 1), { authProfileId: "openai:default", }); @@ -1393,15 +1353,9 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("skips sync in await mode when postCompactionForce is false", async () => { - const sync = vi.fn(async () => {}); - getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); resolveMemorySearchConfigMock.mockReturnValue({ sources: ["sessions"], - sync: { - sessions: { - postCompactionForce: false, - }, - }, + sync: { sessions: { postCompactionForce: false } }, }); await compactTesting.runPostCompactionSideEffects({ @@ -1410,14 +1364,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionFile: TEST_SESSION_FILE, }); - const resolveAgentArg = mockCallArg(resolveSessionAgentIdMock); - if (typeof resolveAgentArg !== "object" || resolveAgentArg === null) { - throw new Error("Expected session agent arguments"); - } - expect(resolveAgentArg).toMatchObject({ sessionKey: TEST_SESSION_KEY }); - expect(Reflect.get(resolveAgentArg, "config")).toBeTypeOf("object"); expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); - expect(sync).not.toHaveBeenCalled(); }); registerDirectProviderRefreshTests({ @@ -1426,27 +1373,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionKey: TEST_SESSION_KEY, sessionFile: () => TEST_SESSION_FILE, }); - it("carries the prepared provider reconciler into direct compaction", async () => { - const reconcile = vi.fn(async () => undefined); - const { resolvePreparedProviderRuntimeHandle } = await import("../runtime-plan/build.js"); - vi.mocked(resolvePreparedProviderRuntimeHandle).mockImplementationOnce((params) => ({ - provider: params.provider, - modelId: params.modelId, - workspaceDir: params.workspaceDir, - prepared: true, - plugin: { id: params.provider, label: "Fixture", auth: [], reconcileLocalService: reconcile }, - })); - - await expect(compactDirect()).resolves.toMatchObject({ ok: true }); - - const streamRegistration = mockCallArg(registerProviderStreamForModelMock) as { - model: object; - }; - expect(getModelProviderLocalServiceReconciler(streamRegistration.model)).toBe(reconcile); - expect(getModelProviderRuntimePluginHandle(streamRegistration.model)).toBe( - buildAgentRuntimePlanMock.mock.calls[0]?.[0].providerRuntimeHandle, - ); - }); it("compacts an overflow transcript anchored by a compaction summary", async () => { sessionMessages.splice( 0, @@ -1466,8 +1392,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactDirect({ trigger: "overflow" }); - expect(result).toMatchObject({ ok: true, compacted: true }); - expect(sessionCompactImpl).toHaveBeenCalledOnce(); + expect(result).toMatchObject(compacted); }); it("never falls back from an explicit compaction.model", async () => { @@ -1716,29 +1641,11 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { it("cancels only the target during runtime admission", async () => { const sourceController = new AbortController(); const admissionStarted = createDeferred(); - acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce((async ( - _input: Record, - options?: { abortSignal?: AbortSignal }, - ): Promise => { - const signal = options?.abortSignal; + acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce(async (_input, options) => { + const signal = expectDefined(options?.abortSignal, "runtime admission abort signal"); admissionStarted.resolve(signal); - if (!signal) { - throw new Error("prepared runtime lease admission did not receive the caller signal"); - } - return await new Promise((_resolve, reject) => { - signal.addEventListener( - "abort", - () => { - const error = new Error("Prepared model runtime lease admission aborted", { - cause: signal.reason, - }); - error.name = "AbortError"; - reject(error); - }, - { once: true }, - ); - }); - }) as never); + return await racePromiseWithAbortSignal(createDeferred().promise, signal); + }); const pending = compactQueued({ abortSignal: sourceController.signal, trigger: "manual" }); const admittedSignal = expectDefined( @@ -1754,57 +1661,18 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(resolveContextEngineMock).not.toHaveBeenCalled(); }); - it("releases the prepared runtime lease when host authority expires after admission", async () => { - const { AsyncWorkScope } = await import("../../shared/async-work-scope.js"); - const parent = new AsyncWorkScope(); - const admissionStarted = createDeferred(); - const releaseAdmission = createDeferred(); - const releaseLease = vi.fn(async () => {}); - const defaultAcquire = expectDefined( - acquireAgentRunPreparedModelRuntimeMock.getMockImplementation(), - "default prepared runtime acquisition mock", - ); - let hostActive = true; - acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce((async ( - input: Parameters[0], - ) => { - admissionStarted.resolve(undefined); - await releaseAdmission.promise; - const lease = await defaultAcquire(input); - return { ...lease, [Symbol.asyncDispose]: releaseLease }; - }) as never); - - const pending = parent.run(() => - compactQueued( - {}, - { - assertActive: () => { - if (!hostActive) { - throw new Error("queued compaction host authority expired"); - } - }, - }, - ), - ); - await admissionStarted.promise; - hostActive = false; - releaseAdmission.resolve(undefined); - - try { - await expect(pending).rejects.toThrow("queued compaction host authority expired"); - } finally { - await AsyncWorkScope.runWhenAllIdle( - () => [parent], - () => parent.drain(), - ); - } - expect(releaseLease).toHaveBeenCalledTimes(1); - expect(resolveContextEngineMock).not.toHaveBeenCalled(); - }); - - it.each(["context-engine", "model"] as const)( - "stops preparation when host authority expires during %s resolution", - async (stage) => { + it.each([ + ["admission", false], + ["context-engine", false], + ["model", false], + ["context-engine", true], + ] as const)( + "stops preparation at %s after authority expires (budget cancellation: %s)", + async (stage, cancel) => { + const { AsyncWorkScope } = await import("../../shared/async-work-scope.js"); + const parent = new AsyncWorkScope(); + const controller = new AbortController(); + const releaseLease = vi.fn(async () => {}); const resolutionStarted = createDeferred(); const releaseResolution = createDeferred(); let hostActive = true; @@ -1812,98 +1680,79 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { resolutionStarted.resolve(undefined); await releaseResolution.promise; }; - if (stage === "model") { + if (stage === "admission") { + const acquire = expectDefined( + acquireAgentRunPreparedModelRuntimeMock.getMockImplementation(), + "prepared runtime acquisition", + ); + acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce(async (input) => { + await waitForResolution(); + return { ...(await acquire(input)), [Symbol.asyncDispose]: releaseLease }; + }); + } else if (stage === "model") { resolveModelAsyncMock.mockImplementationOnce(async (provider, modelId) => { await waitForResolution(); - return { - ...createCompactHooksResolvedModel(provider, modelId), - model: { - provider: "openai", - id: "fake", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - input: [], - }, - }; + return subscriptionModel(provider, modelId); }); } else { resolveContextEngineMock.mockImplementationOnce(async () => { await waitForResolution(); - return { - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - }; + return { info: { ownsCompaction: true }, compact: contextEngineCompactMock }; }); } - const pending = compactQueued( - stage === "model" - ? { - provider: "openai", - model: "fake", - runtimeAuthPlan: { - providerForAuth: "openai", - authProfileProviderForAuth: "openai", - selectedAuthMode: "api-key", - modelRoute: openaiRoute(undefined, "fake"), - }, - } - : {}, - { - assertActive: () => { - if (!hostActive) { - throw new Error("queued compaction host authority expired"); - } - }, - }, + const pending = parent.run(() => + compactQueued( + stage === "model" + ? { + provider: "openai", + model: "fake", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "api-key", + modelRoute: openaiRoute(undefined, "fake"), + }, + } + : cancel + ? { abortSignal: controller.signal, trigger: "budget" } + : {}, + { assertActive: assertActiveWhile(() => hostActive) }, + ), ); await resolutionStarted.promise; - hostActive = false; + if (cancel) { + controller.abort(new Error("request timed out")); + } else { + hostActive = false; + } releaseResolution.resolve(undefined); - - await expect(pending).rejects.toThrow("queued compaction host authority expired"); + try { + if (cancel) { + await expect(pending).resolves.toEqual(abortedCompaction); + } else { + await expect(pending).rejects.toThrow("queued compaction host authority expired"); + } + } finally { + await AsyncWorkScope.runWhenAllIdle( + () => [parent], + () => parent.drain(), + ); + } expect(resolveModelAsyncMock).toHaveBeenCalledTimes(stage === "model" ? 1 : 0); expectNoCalls(selectAgentHarnessForPreparedModelProvidersMock, contextEngineCompactMock); - if (stage === "model") { + if (stage === "admission") { + expect(releaseLease).toHaveBeenCalledOnce(); + expect(resolveContextEngineMock).not.toHaveBeenCalled(); + } + if (stage === "model" || cancel) { expectNoCalls(maybeCompactAgentHarnessSessionMock, enqueueCommandInLaneMock); } }, ); - it("reports budget compaction cancellation during context-engine resolution", async () => { - const sourceController = new AbortController(); - const resolutionStarted = createDeferred(); - const releaseResolution = createDeferred(); - resolveContextEngineMock.mockImplementationOnce(async () => { - resolutionStarted.resolve(undefined); - await releaseResolution.promise; - return { - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - }; - }); - - const pending = compactQueued({ - abortSignal: sourceController.signal, - trigger: "budget", - }); - await resolutionStarted.promise; - sourceController.abort(new Error("request timed out")); - releaseResolution.resolve(undefined); - - await expect(pending).resolves.toEqual({ - ok: false, - compacted: false, - reason: "compaction aborted", - }); - expect(resolveModelAsyncMock).not.toHaveBeenCalled(); - expect(selectAgentHarnessForPreparedModelProvidersMock).not.toHaveBeenCalled(); - expect(contextEngineCompactMock).not.toHaveBeenCalled(); - expect(enqueueCommandInLaneMock).not.toHaveBeenCalled(); - }); - it("resolves the durable session key before invoking an owning context engine", async () => { - const dir = await realpath(await mkdtemp(join(tmpdir(), "openclaw-compaction-session-key-"))); + const dir = await realpath(compactionFixture.makeTempDir("openclaw-compaction-session-key-")); const storePath = join(dir, "sessions.json"); const sessionId = "9d6c8436-7cb2-4bd5-a302-e33305bfc8c4"; const sessionKey = "agent:main:telegram:direct:reporter"; @@ -1966,42 +1815,20 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }, sessionKey: "agent:marie-clawndo:dashboard:session-1", sessionTarget: { + ...defaultSessionTarget(), agentId: "marie-clawndo", - sessionId: TEST_SESSION_ID, sessionKey: "agent:marie-clawndo:dashboard:session-1", - storePath: TEST_STORE_PATH, }, }); expect(result.ok).toBe(true); - expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( - expect.objectContaining({ agentId: "marie-clawndo" }), - expect.objectContaining({ abortSignal: expect.any(AbortSignal) }), - ); }); it("disposes the context engine once when route materialization rejects", async () => { const dispose = vi.fn(async () => {}); - const { authStorage } = createCompactHooksResolvedModel(); - resolveContextEngineMock.mockResolvedValue({ - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - dispose, - } as never); + mockContextEngine(true, false, { dispose }); resolveModelAsyncMock - .mockImplementationOnce(async (provider, modelId) => ({ - logicalRef: { provider, model: modelId }, - model: { - provider: "openai", - id: "fake", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - input: [], - }, - error: null, - authStorage, - modelRegistry: {}, - })) + .mockImplementationOnce(async (provider, modelId) => subscriptionModel(provider, modelId)) .mockRejectedValueOnce(new Error("route materialization failed")); await expect( @@ -2012,14 +1839,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { providerForAuth: "openai", authProfileProviderForAuth: "openai", selectedAuthMode: "api-key", - modelRoute: { - provider: "openai", - modelId: "fake", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - authRequirement: "api-key", - requestTransportOverrides: "none", - }, + modelRoute: openaiRoute(undefined, "fake"), }, }), ).rejects.toThrow("route materialization failed"); @@ -2250,25 +2070,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { ); expect(result.ok).toBe(true); if (subscription) { - expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toEqual( - expect.objectContaining({ authProfileId: "openai:token" }), - ); - expect(resolveModelAsyncMock).toHaveBeenLastCalledWith( - "openai", - "gpt-5.5", - expect.any(String), - expect.objectContaining( - providerConfig("openai", expect.objectContaining({ api, baseUrl })), - ), - expect.objectContaining({ authProfileMode: "token" }), - ); expectCall(contextEngineCompactMock, { tokenBudget: 272_000 }); - } else { - expect(mockCallArg(resolveModelMock)).toBe("openai"); - expectRecordFields(mockCallArg(resolveContextWindowInfoMock), { - provider: "openai", - modelId: "gpt-5.5", - }); } expectCall( maybeCompactAgentHarnessSessionMock, @@ -2292,24 +2094,12 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(contextEngineCompactMock.mock.invocationCallOrder[0]).toBeLessThan( maybeCompactAgentHarnessSessionMock.mock.invocationCallOrder[0]!, ); - const compactArg = mockCallArg(contextEngineCompactMock) as { - runtimeContext?: Record; - }; - expectRecordFields(compactArg.runtimeContext, { - provider: "openai", - runtimeProvider: undefined, - model: "gpt-5.5", - }); }, ); it("reports deferred compaction scheduling failures", async () => { const dispose = vi.fn(async () => {}); - const maintain = vi.fn(async () => ({ - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - })); + const maintain = vi.fn(async () => unchangedMaintenance); resolveContextEngineMock.mockResolvedValue({ info: { ownsCompaction: true, turnMaintenanceMode: "background" }, compact: contextEngineCompactMock, @@ -2391,15 +2181,9 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { failure: { reason: failureReason }, }, ); - expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(1); expectCall( maybeCompactAgentHarnessSessionMock, - { - provider: "openai", - model: "gpt-5.5", - agentHarnessId, - preflightRequired: true, - }, + { preflightRequired: true }, expect.objectContaining({ nativeCompactionRequest: "required_preflight" }), ); expect(contextEngineCompactMock).toHaveBeenCalledTimes(nativeCapabilityUsed ? 1 : 0); @@ -2421,11 +2205,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { agentHarnessId, currentTokenCount: 333, }); - expect(result).toMatchObject({ - ok: false, - compacted: false, - failure: { reason: "model_selection_locked" }, - }); + expect(result).toMatchObject(lockedFailure()); expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(nativeCalls); expect(contextEngineCompactMock).not.toHaveBeenCalled(); }, @@ -2433,7 +2213,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { it("runs native manual compaction before generic auth", async () => { const result = await compactNative({ ...nativeCliArgs(), agentHarnessId: "claude-cli" }); - expect(result).toMatchObject({ ok: true, compacted: true }); + expect(result).toMatchObject(compacted); expectCall(runCliAgentMock, { cliSessionId: "native-session", controlOperation: "compact" }); expectNoCalls(acquireAgentRunPreparedModelRuntimeMock, contextEngineCompactMock); }); @@ -2471,7 +2251,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(settled).toBe(false); nativeTerminal.resolve(nativeResult); - await expect(resultPromise).resolves.toMatchObject({ ok: true, compacted: true }); + await expect(resultPromise).resolves.toMatchObject(compacted); } finally { nativeTerminal.resolve(nativeResult); await resultPromise.catch(() => undefined); @@ -2586,7 +2366,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(pending.signal?.aborted).toBe(true); expect(pending.signal?.reason).toBe(replyOperation.abortSignal.reason); pending.release.resolve(undefined); - await expect(resultPromise).resolves.toMatchObject({ ok: false, compacted: false }); + await expect(resultPromise).resolves.toMatchObject(notCompacted); expect(isEmbeddedAgentRunHandleActive(TEST_SESSION_ID)).toBe(false); expect(isSessionRunActive(TEST_SESSION_ID)).toBe(true); } finally { @@ -2642,11 +2422,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { await entered.promise; try { const result = await compactQueued({ trigger: "manual" }); - expect(result).toMatchObject( - active.foreignStore - ? { ok: true, compacted: true } - : { ok: false, compacted: false, failure: { reason: "active_run" } }, - ); + expect(result).toMatchObject(active.foreignStore ? compacted : lockedFailure("active_run")); expect(contextEngineCompactMock).toHaveBeenCalledTimes(active.foreignStore ? 1 : 0); expect(maybeCompactAgentHarnessSessionMock).not.toHaveBeenCalled(); expect(existingHandle.abort).not.toHaveBeenCalled(); @@ -2662,7 +2438,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { async (format) => { const dir = format === "session-key" - ? await realpath(await mkdtemp(join(tmpdir(), "openclaw-compaction-successor-mismatch-"))) + ? await realpath(compactionFixture.makeTempDir("openclaw-compaction-successor-mismatch-")) : undefined; const storePath = dir ? join(dir, "sessions.json") : TEST_STORE_PATH; const activeTarget = { ...wrappedCompactionArgs().sessionTarget, storePath }; @@ -2708,7 +2484,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { const result = await compactQueued(); - expect(result).toMatchObject({ ok: true, compacted: true }); + expect(result).toMatchObject(compacted); expect(contextEngineCompactMock).toHaveBeenCalledTimes(1); }); it("prepares native harness auth without a host profile", async () => { @@ -2731,18 +2507,14 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expectCall( maybeCompactAgentHarnessSessionMock, { runtimeAuthPlan: undefined }, - expectedNativeCompactionOptions("after_context_engine"), + expect.objectContaining({ nativeCompactionRequest: "after_context_engine" }), ); }); it.each(["SQLite marker", "partial structured target"] as const)( "keeps a %s successor in the active store for legacy maintenance", async (format) => { - const maintain = vi.fn(async (_params?: unknown) => ({ - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - })); + const maintain = vi.fn(async (_params?: unknown) => unchangedMaintenance); const delegatedSessionId = format === "SQLite marker" ? "delegated-marker-session" : "delegated-session"; const storePath = diff --git a/src/agents/embedded-agent-runner/thinking.test.ts b/src/agents/embedded-agent-runner/thinking.test.ts index 081c913171d3..aa46a697ccef 100644 --- a/src/agents/embedded-agent-runner/thinking.test.ts +++ b/src/agents/embedded-agent-runner/thinking.test.ts @@ -1,17 +1,15 @@ // Thinking sanitization tests cover reasoning-block retention, stripping, and // recovery behavior for provider transcripts and active assistant turns. -import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { AgentMessage, StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; import { castAgentMessage, - castAgentMessages, makeAgentAssistantMessage, } from "../test-helpers/agent-message-fixtures.js"; import { createQueuedAssistantStream } from "../test-helpers/assistant-event-stream.test-support.js"; -import { sparseAssistant, textAssistant } from "../test-helpers/sparse-transcript.test-support.js"; -import { createZeroUsageFixture } from "../test-helpers/usage-fixtures.js"; +import { sparseAssistant } from "../test-helpers/sparse-transcript.test-support.js"; import { stripStaleThinkingSignaturesForCompactionReplay } from "../thinking-signatures.js"; import { assessLastAssistantMessage, @@ -22,873 +20,419 @@ import { } from "./thinking.js"; type AssistantMessage = Extract; +type ContentCase = [name: string, before: unknown[], after: unknown[]]; const OMITTED_ASSISTANT_REASONING_TEXT = "[assistant reasoning omitted]"; -const noThinkingReferenceCases = [ - { name: "dropThinkingBlocks", drop: dropThinkingBlocks }, - { name: "dropReasoningFromHistory", drop: dropReasoningFromHistory }, -]; - -function createNoThinkingMessages(): AgentMessage[] { - // No-thinking fixtures should keep reference identity to avoid unnecessary - // transcript rewrites in the common path. - return [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "world" }] }), - ]; +function assistant(content: unknown[], timestamp?: number) { + return castAgentMessage({ + ...sparseAssistant(content), + ...(timestamp === undefined ? {} : { timestamp }), + }); +} +const user = (content: string) => castAgentMessage({ role: "user", content }); +const text = (value: string) => ({ type: "text", text: value }); +const thinking = (value: string, thinkingSignature?: string) => ({ + type: "thinking", + thinking: value, + ...(thinkingSignature === undefined ? {} : { thinkingSignature }), +}); +const omitted = [text(OMITTED_ASSISTANT_REASONING_TEXT)]; +function expectContent(messages: AgentMessage[], index: number, content: unknown[]) { + expect(messages[index]).toHaveProperty("content", content); } describe("thinking-free history contract", () => { - it.each(noThinkingReferenceCases)( - "$name returns the original reference when no thinking blocks are present", - ({ drop }) => { - const messages = createNoThinkingMessages(); - - const result = drop(messages); - expect(result).toBe(messages); - }, - ); + it.each([ + { name: "dropThinkingBlocks", drop: dropThinkingBlocks }, + { name: "dropReasoningFromHistory", drop: dropReasoningFromHistory }, + ])("$name returns the original reference when no thinking blocks are present", ({ drop }) => { + const messages = [user("hello"), assistant([text("world")])]; + expect(drop(messages)).toBe(messages); + }); }); describe("dropThinkingBlocks", () => { it("preserves a latest assistant turn even when all content blocks are thinking", () => { - const messages = castAgentMessages([ - { role: "assistant", content: [{ type: "thinking", thinking: "internal-only" }] }, - ]); + const messages = [assistant([thinking("internal-only")])]; expect(dropThinkingBlocks(messages)).toBe(messages); }); - it("preserves thinking blocks in the latest assistant message", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "old" }, - { type: "text", text: "old text" }, - ]), - ), - castAgentMessage({ role: "user", content: "second" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "latest", thinkingSignature: "sig_latest" }, - { type: "text", text: "latest text" }, - ]), - ), + it.each([ + ["mixed", [thinking("old"), text("old text")], [text("old text")]], + ["thinking-only", [thinking("old", "sig_old")], omitted], + ["redacted-only", [{ type: "redacted_thinking", data: "opaque" }], omitted], + ])("strips an older %s turn while preserving the latest thinking", (_name, prior, expected) => { + const messages = [ + user("first"), + assistant(prior), + user("second"), + assistant([thinking("latest", "sig_latest"), text("latest text")]), ]; - const result = dropThinkingBlocks(messages); - const firstAssistant = result[1] as Extract; - const latestAssistant = result[3] as Extract; - - expect(firstAssistant.content).toEqual([{ type: "text", text: "old text" }]); - expect(latestAssistant.content).toEqual([ - { type: "thinking", thinking: "latest", thinkingSignature: "sig_latest" }, - { type: "text", text: "latest text" }, - ]); - }); - - it("uses non-empty omitted-reasoning text when an older assistant turn is thinking-only", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage( - sparseAssistant([{ type: "thinking", thinking: "old", thinkingSignature: "sig_old" }]), - ), - castAgentMessage({ role: "user", content: "second" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "latest", thinkingSignature: "sig_latest" }, - { type: "text", text: "latest text" }, - ]), - ), - ]; - - const result = dropThinkingBlocks(messages); - const oldAssistant = result[1] as Extract; - const latestAssistant = result[3] as Extract; - const originalLatestAssistant = messages[3] as Extract; - - expect(oldAssistant.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, - ]); - expect(latestAssistant.content).toEqual(originalLatestAssistant.content); - }); - - it("uses non-empty omitted-reasoning text when an older assistant turn is redacted-thinking-only", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage(sparseAssistant([{ type: "redacted_thinking", data: "opaque" }])), - castAgentMessage({ role: "user", content: "second" }), - castAgentMessage(textAssistant("latest text")), - ]; - - const result = dropThinkingBlocks(messages); - const oldAssistant = result[1] as Extract; - - expect(oldAssistant.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, - ]); + expectContent(result, 1, expected); + expectContent(result, 3, [thinking("latest", "sig_latest"), text("latest text")]); }); }); +const toolCall = { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }; +function toolTurn() { + return [ + user("look up the answer"), + assistant([thinking("call the tool"), toolCall]), + castAgentMessage({ + role: "toolResult", + toolCallId: "call123456", + toolName: "lookup", + content: "42", + }), + ]; +} + describe("dropReasoningFromHistory", () => { it("uses omitted-reasoning text when a completed assistant turn is reasoning-only", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage(sparseAssistant([{ type: "thinking", thinking: "private" }])), - castAgentMessage({ role: "user", content: "second" }), - ]; - - const result = dropReasoningFromHistory(messages); - const assistant = result[1] as AssistantMessage; - - expect(assistant.content).toEqual([{ type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }]); + expectContent( + dropReasoningFromHistory([user("first"), assistant([thinking("private")]), user("second")]), + 1, + omitted, + ); }); - it("preserves reasoning for the active tool-call continuation after the latest user turn", () => { - // Active tool-call turns may need reasoning signatures for provider - // continuation, so only completed prior turns are stripped. - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "look up the answer" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "call the tool" }, - { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }, - ]), - ), - castAgentMessage({ - role: "toolResult", - toolCallId: "call123456", - toolName: "lookup", - content: "42", - }), - ]; - - const result = dropReasoningFromHistory(messages); - - expect(result).toBe(messages); + const messages = toolTurn(); + expect(dropReasoningFromHistory(messages)).toBe(messages); }); - it("strips reasoning from old tool-call turns once a later user turn starts", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "look up the answer" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "call the tool" }, - { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }, - ]), - ), - castAgentMessage({ - role: "toolResult", - toolCallId: "call123456", - toolName: "lookup", - content: "42", - }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "42" }] }), - castAgentMessage({ role: "user", content: "thanks" }), - ]; - - const result = dropReasoningFromHistory(messages); - const assistant = result[1] as AssistantMessage; - - expect(assistant.content).toEqual([ - { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }, - ]); + const messages = [...toolTurn(), assistant([text("42")]), user("thanks")]; + expectContent(dropReasoningFromHistory(messages), 1, [toolCall]); }); }); +const invalidThinking = () => [ + thinking("missing"), + thinking("empty", ""), + thinking("blank", " "), +]; +const signedAnswer = () => [thinking("signed", "sig"), text("answer")]; +function olderTurn(content: unknown[]) { + return [assistant(content), user("follow up"), assistant([text("latest")])]; +} + describe("stripInvalidThinkingSignatures", () => { it("returns the original reference when no invalid thinking signatures are present", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "internal", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - ]; - - const result = stripInvalidThinkingSignatures(messages); - - expect(result).toBe(messages); + const messages = [user("hello"), assistant([thinking("internal", "sig"), text("answer")])]; + expect(stripInvalidThinkingSignatures(messages)).toBe(messages); }); - it("preserves invalid thinking signatures on the latest assistant message", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "empty", thinkingSignature: "" }, - { type: "thinking", thinking: "blank", thinkingSignature: " " }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - ]; - + const content = [...invalidThinking(), ...signedAnswer()]; + const messages = [user("hello"), assistant(content)]; const result = stripInvalidThinkingSignatures(messages); - const assistant = result[1] as Extract; - expect(result).toBe(messages); - expect(assistant.content).toEqual([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "empty", thinkingSignature: "" }, - { type: "thinking", thinking: "blank", thinkingSignature: " " }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]); + expectContent(result, 1, [...invalidThinking(), ...signedAnswer()]); }); - it("can strip invalid thinking signatures from the latest assistant message", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - ]; - + const messages = [user("hello"), assistant([thinking("missing"), ...signedAnswer()])]; const result = stripInvalidThinkingSignatures(messages, { preserveLatestAssistant: false }); - const assistant = result[1] as Extract; - expect(result).not.toBe(messages); - expect(assistant.content).toEqual([ - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]); + expectContent(result, 1, signedAnswer()); }); - - it("strips thinking blocks with missing, empty, or blank signatures from older assistant messages", () => { - const messages: AgentMessage[] = [ - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "empty", thinkingSignature: "" }, - { type: "thinking", thinking: "blank", thinkingSignature: " " }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - castAgentMessage({ role: "user", content: "follow up" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "latest" }] }), - ]; - + it.each([ + [ + "missing, empty, and blank signatures", + [...invalidThinking(), ...signedAnswer()], + signedAnswer(), + ], + ["only invalid signatures", [thinking("reasoning", "")], omitted], + [ + "invalid opaque signatures", + [ + { type: "redacted_thinking", data: "" }, + { type: "redacted_thinking", signature: " " }, + { type: "redacted_thinking", data: "opaque" }, + text("answer"), + ], + [{ type: "redacted_thinking", data: "opaque" }, text("answer")], + ], + ])("strips %s from older assistant messages", (_name, content, expected) => { + const messages = olderTurn(content); const result = stripInvalidThinkingSignatures(messages); - const assistant = result[0] as Extract; - expect(result).not.toBe(messages); - expect(assistant.content).toEqual([ - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]); - }); - - it("uses non-empty omitted-reasoning text when all thinking signatures are invalid", () => { - const messages: AgentMessage[] = [ - castAgentMessage( - sparseAssistant([{ type: "thinking", thinking: "reasoning", thinkingSignature: "" }]), - ), - castAgentMessage({ role: "user", content: "follow up" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "latest" }] }), - ]; - - const result = stripInvalidThinkingSignatures(messages); - const assistant = result[0] as Extract; - - expect(assistant.content).toEqual([{ type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }]); - }); - - it("strips redacted thinking blocks with invalid opaque signatures from older assistant messages", () => { - const messages: AgentMessage[] = [ - castAgentMessage( - sparseAssistant([ - { type: "redacted_thinking", data: "" }, - { type: "redacted_thinking", signature: " " }, - { type: "redacted_thinking", data: "opaque" }, - { type: "text", text: "answer" }, - ]), - ), - castAgentMessage({ role: "user", content: "follow up" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "latest" }] }), - ]; - - const result = stripInvalidThinkingSignatures(messages); - const assistant = result[0] as Extract; - - expect(assistant.content).toEqual([ - { type: "redacted_thinking", data: "opaque" }, - { type: "text", text: "answer" }, - ]); + expectContent(result, 0, expected); }); }); describe("assessLastAssistantMessage", () => { - it("marks signed thinking with an empty text block as incomplete text", () => { - const message = castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "complete", thinkingSignature: "sig" }, - { type: "text", text: "" }, - ]), - ); - - expect(assessLastAssistantMessage(message)).toBe("incomplete-text"); - }); - - it("treats partial text after signed thinking as valid", () => { - const message = castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "complete", thinkingSignature: "sig" }, - { type: "text", text: "Here is my answ" }, - ]), - ); - - expect(assessLastAssistantMessage(message)).toBe("valid"); - }); - - it("treats non-string text blocks as incomplete text when thinking is signed", () => { - const message = castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "complete", thinkingSignature: "sig" }, - { type: "text", text: { bad: true } }, - ]), - ); - - expect(assessLastAssistantMessage(message)).toBe("incomplete-text"); + it.each([ + { text: "", assessment: "incomplete-text" }, + { text: "Here is my answ", assessment: "valid" }, + { text: { bad: true }, assessment: "incomplete-text" }, + ])("assesses signed thinking followed by $text as $assessment", ({ text: value, assessment }) => { + expect( + assessLastAssistantMessage( + assistant([thinking("complete", "sig"), { type: "text", text: value }]), + ), + ).toBe(assessment); }); }); +function invoke(wrapped: StreamFn, messages: AgentMessage[] = []) { + return wrapped({} as never, { messages } as never, {} as never); +} +function invokeSync(wrapped: StreamFn) { + const stream = invoke(wrapped); + if (stream instanceof Promise) { + throw new Error("Expected a synchronous event stream"); + } + return stream; +} +async function collect(stream: AsyncIterable) { + const events: unknown[] = []; + for await (const event of stream) { + events.push(event); + } + return events; +} + describe("wrapAnthropicStreamWithRecovery", () => { const anthropicThinkingError = new Error( "thinking or redacted_thinking blocks in the latest assistant message cannot be modified", ); - const genericizedProviderError = - "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI."; const terminalThinkingSignatureError = "ValidationException: invalid signature on thinking block in message history"; - - function createTestAssistantMessage( - overrides: Partial & Pick, - ): AssistantMessage { + const genericizedProviderError = + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI."; + const history = () => [assistant([thinking("secret", "sig")])]; + function message(value = "recovered", errorMessage?: string) { return makeAgentAssistantMessage({ api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-6", - ...overrides, + content: [{ type: "text", text: value }], + stopReason: errorMessage ? "error" : "stop", + ...(errorMessage ? { errorMessage } : {}), }); } - - function thinkingHistory(): AgentMessage[] { - return castAgentMessages([ - sparseAssistant([{ type: "thinking", thinking: "secret", thinkingSignature: "sig" }]), - ]); - } - - function createTestStreamErrorMessage(errorMessage: string): AssistantMessage { - return createTestAssistantMessage({ - content: [{ type: "text", text: "stream failed" }], - stopReason: "error", - errorMessage, + type Event = Parameters["push"]>[0]; + function stream(...events: Event[]) { + return createQueuedAssistantStream((push) => { + for (const event of events) { + push({ ...event }); + } }); } - - it("retries once with omitted-reasoning text when the request is rejected before streaming", async () => { - let callCount = 0; - const contexts: Array<{ messages?: AgentMessage[] }> = []; - const wrapped = wrapAnthropicStreamWithRecovery( - ((_model, context) => { - callCount += 1; - contexts.push(context as { messages?: AgentMessage[] }); - return Promise.reject(anthropicThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect( - wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - ), - ).rejects.toBe(anthropicThinkingError); - expect(callCount).toBe(2); - const retryMessage = contexts[1]?.messages?.[0]; - if (!retryMessage || retryMessage.role !== "assistant") { - throw new Error("Expected Anthropic recovery retry to start with an assistant message"); + function done(result: AssistantMessage) { + return stream({ type: "done", reason: "stop", message: result }); + } + function failed(result: AssistantMessage) { + return stream({ type: "error", reason: "error", error: result }); + } + function harness(produce: (attempt: number) => ReturnType, notify = false) { + const inner = vi.fn(); + inner.mockImplementation(() => produce(inner.mock.calls.length)); + const recovered = vi.fn(); + const wrapped = wrapAnthropicStreamWithRecovery(inner, { + id: "test-session", + ...(notify ? { onRecoveredAnthropicThinking: recovered } : {}), + }); + return { inner, recovered, wrapped }; + } + async function expectResult( + response: Awaited>, + result: AssistantMessage, + events?: Event[], + ) { + const actual = await collect(response); + if (events) { + expect(actual).toEqual(events); } - expect(retryMessage.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, - ]); - }); - - it("retries with visible assistant text when stripping thinking leaves content", async () => { - const contexts: Array<{ messages?: AgentMessage[] }> = []; - const wrapped = wrapAnthropicStreamWithRecovery( - ((_model, context) => { - contexts.push(context as { messages?: AgentMessage[] }); - return Promise.reject(anthropicThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); + await expect(response.result()).resolves.toEqual(result); + } + function expectRetryContent(inner: ReturnType["inner"], content: unknown[]) { + const retry = inner.mock.calls[1]?.[1].messages[0]; + expect(retry).toMatchObject({ role: "assistant" }); + expect(retry).toHaveProperty("content", content); + } + it.each([ + { name: "omitted reasoning", content: [], expected: omitted }, + { name: "visible text", content: [text("visible answer")], expected: [text("visible answer")] }, + ])("retries a rejected request once with $name", async ({ content, expected }) => { + const { wrapped, inner } = harness(() => Promise.reject(anthropicThinkingError)); await expect( - wrapped( - {} as never, - { - messages: castAgentMessages([ - sparseAssistant([ - { type: "thinking", thinking: "secret", thinkingSignature: "sig" }, - { type: "text", text: "visible answer" }, - ]), - ]), - } as never, - {} as never, - ), + invoke(wrapped, [assistant([thinking("secret", "sig"), ...content])]), ).rejects.toBe(anthropicThinkingError); - - const retryMessage = contexts[1]?.messages?.[0]; - if (!retryMessage || retryMessage.role !== "assistant") { - throw new Error("Expected Anthropic recovery retry to start with an assistant message"); - } - expect(retryMessage.content).toEqual([{ type: "text", text: "visible answer" }]); + expect(inner).toHaveBeenCalledTimes(2); + expectRetryContent(inner, expected); }); it("notifies recovery only after a rejected request retry succeeds", async () => { - let callCount = 0; - const recovered = vi.fn(); - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "recovered" }], - stopReason: "stop", - }); - const originalMessages = castAgentMessages([ - sparseAssistant([ - { type: "thinking", thinking: "secret", thinkingSignature: "sig" }, - { type: "text", text: "visible answer" }, - ]), - ]); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - if (callCount === 1) { - return Promise.reject(anthropicThinkingError); - } - return createQueuedAssistantStream((push) => { - push({ type: "done", reason: "stop", message: finalMessage }); - }); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const result = message(); + const { wrapped, inner, recovered } = harness( + (attempt) => (attempt === 1 ? Promise.reject(anthropicThinkingError) : done(result)), + true, ); - - const response = (await wrapped( - {} as never, - { - messages: originalMessages, - } as never, - {} as never, - )) as { result: () => Promise } & AsyncIterable; - for await (const event of response) { - void event; - // Drain the retry stream before reading result(). - } - - await expect(response.result()).resolves.toEqual(finalMessage); - expect(callCount).toBe(2); + await expectResult( + await invoke(wrapped, [assistant([thinking("secret", "sig"), text("visible answer")])]), + result, + ); + expect(inner).toHaveBeenCalledTimes(2); expect(recovered).toHaveBeenCalledTimes(1); }); it("does not notify recovery when the stripped-thinking retry also fails", async () => { - const recovered = vi.fn(); - let callCount = 0; const retryError = new Error("retry failed"); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(callCount === 1 ? anthropicThinkingError : retryError); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const { wrapped, recovered } = harness( + (attempt) => Promise.reject(attempt === 1 ? anthropicThinkingError : retryError), + true, ); - - await expect( - wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - ), - ).rejects.toBe(retryError); + await expect(invoke(wrapped, history())).rejects.toBe(retryError); expect(recovered).not.toHaveBeenCalled(); }); it("does not notify recovery when the stripped-thinking retry resolves to a stream error", async () => { - const recovered = vi.fn(); - let callCount = 0; - const errorMessage = createTestStreamErrorMessage("retry stream failed"); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - if (callCount === 1) { - return Promise.reject(anthropicThinkingError); - } - return createQueuedAssistantStream((push) => { - push({ - type: "error", - reason: "error", - error: errorMessage, - }); - }); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const result = message("stream failed", "retry stream failed"); + const { wrapped, inner, recovered } = harness( + (attempt) => (attempt === 1 ? Promise.reject(anthropicThinkingError) : failed(result)), + true, ); - - const response = (await wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - )) as { result: () => Promise } & AsyncIterable; - for await (const event of response) { - void event; - // Drain the retry stream before reading result(). - } - - await expect(response.result()).resolves.toEqual(errorMessage); - expect(callCount).toBe(2); + await expectResult(await invoke(wrapped, history()), result); + expect(inner).toHaveBeenCalledTimes(2); expect(recovered).not.toHaveBeenCalled(); }); it("retries Bedrock-style invalid thinking signature errors", async () => { - let callCount = 0; - const bedrockThinkingError = new Error( - "ValidationException: invalid signature on thinking block in message history", - ); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(bedrockThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect( - wrapped( - {} as never, - { - messages: castAgentMessages([ - sparseAssistant([{ type: "thinking", thinking: "secret", thinkingSignature: "" }]), - ]), - } as never, - {} as never, - ), - ).rejects.toBe(bedrockThinkingError); - expect(callCount).toBe(2); + const error = new Error(terminalThinkingSignatureError); + const { wrapped, inner } = harness(() => Promise.reject(error)); + await expect(invoke(wrapped, [assistant([thinking("secret", "")])])).rejects.toBe(error); + expect(inner).toHaveBeenCalledTimes(2); }); it.each([ - { - name: "failover rawError", - createError: () => - Object.assign(new Error(genericizedProviderError), { - rawError: terminalThinkingSignatureError, + ["failover rawError", () => ({ rawError: terminalThinkingSignatureError })], + [ + "Anthropic SDK error body", + () => ({ error: { error: { message: terminalThinkingSignatureError } } }), + ], + ["direct errorMessage", () => ({ errorMessage: terminalThinkingSignatureError })], + [ + "ProviderHttpError errorBody", + () => ({ + errorBody: JSON.stringify({ + error: { message: terminalThinkingSignatureError, type: "invalid_request_error" }, }), - }, - { - name: "Anthropic SDK error body", - createError: () => - Object.assign(new Error(genericizedProviderError), { - error: { error: { message: terminalThinkingSignatureError } }, - }), - }, - { - name: "direct errorMessage", - createError: () => - Object.assign(new Error(genericizedProviderError), { - errorMessage: terminalThinkingSignatureError, - }), - }, - { - name: "ProviderHttpError errorBody", - createError: () => - Object.assign(new Error(genericizedProviderError), { - errorBody: JSON.stringify({ - error: { - message: terminalThinkingSignatureError, - type: "invalid_request_error", - }, - }), - }), - }, - { - name: "cyclic cause graph", - createError: () => { - const root = new Error(genericizedProviderError) as Error & { cause?: unknown }; - const nested = { cause: root, message: terminalThinkingSignatureError }; - root.cause = nested; - return root; + }), + ], + [ + "cyclic cause graph", + () => { + const cause = { + cause: new Error(genericizedProviderError), + message: terminalThinkingSignatureError, + }; + const error = Object.assign(new Error(genericizedProviderError), { cause }); + cause.cause = error; + return error; }, - }, - ])( - "retries genericized request errors carrying provider detail in $name", - async ({ createError }) => { - const providerError = createError(); - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(providerError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect(wrapped({} as never, { messages: [] } as never, {} as never)).rejects.toBe( - providerError, - ); - expect(callCount).toBe(2); + ], + ] as const)( + "retries genericized request errors carrying provider detail in %s", + async (_name, detail) => { + const details = detail(); + const error = + details instanceof Error + ? details + : Object.assign(new Error(genericizedProviderError), details); + const { wrapped, inner } = harness(() => Promise.reject(error)); + await expect(invoke(wrapped)).rejects.toBe(error); + expect(inner).toHaveBeenCalledTimes(2); }, ); it.each([false, true])("recovers terminal stream errors (promised=%s)", async (promised) => { - let callCount = 0; - const contexts: Array<{ messages?: AgentMessage[] }> = []; - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "recovered" }], - stopReason: "stop", + const result = message(); + const { wrapped, inner } = harness((attempt) => { + const response = + attempt === 1 + ? failed(message("stream failed", terminalThinkingSignatureError)) + : done(result); + return promised ? Promise.resolve(response) : response; }); - const wrapped = wrapAnthropicStreamWithRecovery( - ((_model, context) => { - callCount += 1; - const attempt = callCount; - contexts.push(context as { messages?: AgentMessage[] }); - const stream = createQueuedAssistantStream((push) => { - if (attempt === 1) { - push({ - type: "error", - reason: "error", - error: createTestStreamErrorMessage(terminalThinkingSignatureError), - }); - } else { - push({ type: "done", reason: "stop", message: finalMessage }); - } - }); - return promised ? Promise.resolve(stream) : stream; - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = (await wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - )) as { result: () => Promise } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - expect(events).toEqual([{ type: "done", reason: "stop", message: finalMessage }]); - await expect(response.result()).resolves.toEqual(finalMessage); - expect(callCount).toBe(2); - const retryMessage = contexts[1]?.messages?.[0]; - if (!retryMessage || retryMessage.role !== "assistant") { - throw new Error("Expected Anthropic recovery retry to start with an assistant message"); - } - expect(retryMessage.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, + await expectResult(await invoke(wrapped, history()), result, [ + { type: "done", reason: "stop", message: result }, ]); + expect(inner).toHaveBeenCalledTimes(2); + expectRetryContent(inner, omitted); }); it("does not retry non-thinking terminal stream-error events", async () => { - let callCount = 0; - const errorMessage = createTestAssistantMessage({ - content: [{ type: "text", text: terminalThinkingSignatureError }], - stopReason: "error", - errorMessage: "rate limit exceeded", - }); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return createQueuedAssistantStream((push) => { - push({ type: "error", reason: "error", error: errorMessage }); - }); - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = wrapped({} as never, { messages: [] } as never, {} as never) as { - result: () => Promise; - } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - expect(events).toEqual([{ type: "error", reason: "error", error: errorMessage }]); - await expect(response.result()).resolves.toEqual(errorMessage); - expect(callCount).toBe(1); + const result = message(terminalThinkingSignatureError, "rate limit exceeded"); + const { wrapped, inner } = harness(() => failed(result)); + await expectResult(invokeSync(wrapped), result, [ + { type: "error", reason: "error", error: result }, + ]); + expect(inner).toHaveBeenCalledTimes(1); }); it.each([false, true])("does not retry after output (promised=%s)", async (promised) => { - let callCount = 0; - const partialMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "" }], - stopReason: "stop", + const partial = message(""); + const result = message("stream failed", terminalThinkingSignatureError); + const events: Event[] = [ + { type: "start", partial }, + { type: "error", reason: "error", error: result }, + ]; + const { wrapped, inner } = harness(() => { + const response = stream(...events); + return promised ? Promise.resolve(response) : response; }); - const errorMessage = createTestStreamErrorMessage(terminalThinkingSignatureError); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - const stream = createQueuedAssistantStream((push) => { - push({ type: "start", partial: partialMessage }); - push({ type: "error", reason: "error", error: errorMessage }); - }); - return promised ? Promise.resolve(stream) : stream; - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = (await wrapped({} as never, { messages: [] } as never, {} as never)) as { - result: () => Promise; - } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - expect(events).toEqual([ - { type: "start", partial: partialMessage }, - { type: "error", reason: "error", error: errorMessage }, - ]); - await expect(response.result()).resolves.toEqual(errorMessage); - expect(callCount).toBe(1); + await expectResult(await invoke(wrapped), result, events); + expect(inner).toHaveBeenCalledTimes(1); }); it("does not retry when the stream fails after yielding a chunk", async () => { - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return (async function* failingStream() { + const { wrapped, inner } = harness( + () => + (async function* () { yield "chunk"; throw anthropicThinkingError; - })(); - }) as unknown as Parameters[0], - { id: "test-session" }, + })() as unknown as ReturnType, ); - - const chunks: unknown[] = []; - const response = wrapped({} as never, { messages: [] } as never, {} as never) as { - result: () => Promise; - } & AsyncIterable; - for await (const chunk of response) { - chunks.push(chunk); - } - - expect(chunks).toEqual(["chunk"]); + const response = invokeSync(wrapped); + expect(await collect(response)).toEqual(["chunk"]); await expect(response.result()).rejects.toBe(anthropicThinkingError); - expect(callCount).toBe(1); + expect(inner).toHaveBeenCalledTimes(1); }); it("does not retry non-Anthropic-thinking errors", async () => { - const rateLimitError = new Error("rate limit exceeded"); - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(rateLimitError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect(wrapped({} as never, { messages: [] } as never, {} as never)).rejects.toBe( - rateLimitError, - ); - expect(callCount).toBe(1); + const error = new Error("rate limit exceeded"); + const { wrapped, inner } = harness(() => Promise.reject(error)); + await expect(invoke(wrapped)).rejects.toBe(error); + expect(inner).toHaveBeenCalledTimes(1); }); it("allows each provider call to recover once", async () => { - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(anthropicThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); - const context = { - messages: thinkingHistory(), - }; - - await expect(wrapped({} as never, context as never, {} as never)).rejects.toBe( - anthropicThinkingError, - ); - await expect(wrapped({} as never, context as never, {} as never)).rejects.toBe( - anthropicThinkingError, - ); - - expect(callCount).toBe(4); + const { wrapped, inner } = harness(() => Promise.reject(anthropicThinkingError)); + const messages = history(); + await expect(invoke(wrapped, messages)).rejects.toBe(anthropicThinkingError); + await expect(invoke(wrapped, messages)).rejects.toBe(anthropicThinkingError); + expect(inner).toHaveBeenCalledTimes(4); }); it("preserves result() for synchronous event streams", async () => { - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "done" }], - usage: createZeroUsageFixture(), - stopReason: "stop", - timestamp: Date.now(), - }); - - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - return createQueuedAssistantStream((push) => { - push({ type: "start", partial: finalMessage }); - push({ type: "done", reason: "stop", message: finalMessage }); - }); - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = wrapped({} as never, { messages: [] } as never, {} as never) as { - result: () => Promise; - } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - await expect(response.result()).resolves.toEqual(finalMessage); - expect(events).toHaveLength(2); + const result = message("done"); + const events: Event[] = [ + { type: "start", partial: result }, + { type: "done", reason: "stop", message: result }, + ]; + const { wrapped } = harness(() => stream(...events)); + await expectResult(invokeSync(wrapped), result, events); }); it("recovers an error event from a Promise-resolved stream without changing Promise timing", async () => { - const recovered = vi.fn(); - let callCount = 0; - const { promise: firstStreamPromise, resolve: resolveFirstStream } = - createDeferred>(); - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "recovered answer" }], - stopReason: "stop", - }); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - const attempt = ++callCount; - if (attempt === 1) { - return firstStreamPromise; - } - return createQueuedAssistantStream((push) => { - push({ type: "done", reason: "stop", message: finalMessage }); - }); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const first = createDeferred>(); + const result = message("recovered answer"); + const { wrapped, inner, recovered } = harness( + (attempt) => (attempt === 1 ? first.promise : done(result)), + true, ); - - const responsePromise = wrapped({} as never, { messages: [] } as never, {} as never); + const responsePromise = invoke(wrapped); expect(responsePromise).toBeInstanceOf(Promise); let resolved = false; void Promise.resolve(responsePromise).then(() => { @@ -896,201 +440,61 @@ describe("wrapAnthropicStreamWithRecovery", () => { }); await Promise.resolve(); expect(resolved).toBe(false); - const firstStream = createAssistantMessageEventStream(); - resolveFirstStream(firstStream); + first.resolve(firstStream); const response = await responsePromise; queueMicrotask(() => { firstStream.push({ type: "error", reason: "error", - error: createTestStreamErrorMessage(terminalThinkingSignatureError), + error: message("stream failed", terminalThinkingSignatureError), }); firstStream.end(); }); - for await (const event of response) { - void event; - } - - await expect(response.result()).resolves.toEqual(finalMessage); - expect(callCount).toBe(2); + await expectResult(response, result); + expect(inner).toHaveBeenCalledTimes(2); expect(recovered).toHaveBeenCalledTimes(1); }); }); +function summary(timestamp: number | string = 2000) { + return castAgentMessage({ role: "compactionSummary", summary: "s", tokensBefore: 0, timestamp }); +} + describe("stripStaleThinkingSignaturesForCompactionReplay", () => { - it("returns the original reference when no compaction summary is present", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "think", thinkingSignature: "sig" }], - timestamp: 1000, - }), - ]; + it.each([ + ["without a summary", () => [user("hello"), assistant([thinking("think", "sig")], 1000)]], + ["without an assistant timestamp", () => [summary(), assistant([thinking("think", "sig")])]], + [ + "at the summary timestamp", + () => [summary(), assistant([thinking("exact", "exact_sig")], 2000)], + ], + ] as const)("preserves signatures %s", (_name, makeMessages) => { + const messages = makeMessages(); expect(stripStaleThinkingSignaturesForCompactionReplay(messages)).toBe(messages); }); - - it("strips thinking signatures from assistant messages before the compaction timestamp", () => { - const compactionSummary = castAgentMessage({ - role: "compactionSummary", - summary: "summary", - tokensBefore: 100, - timestamp: 2000, - }); - const preCompaction = castAgentMessage({ - role: "assistant", - content: [ - { type: "thinking", thinking: "old think", thinkingSignature: "stale_sig" }, - { type: "text", text: "old answer" }, - ], - timestamp: 1000, - }); - const postCompaction = castAgentMessage({ - role: "assistant", - content: [ - { type: "thinking", thinking: "new think", thinkingSignature: "fresh_sig" }, - { type: "text", text: "new answer" }, - ], - timestamp: 3000, - }); - const messages: AgentMessage[] = [ - compactionSummary, - preCompaction, - castAgentMessage({ role: "user", content: "q" }), - postCompaction, + it("strips stale signatures before the newest summary, retaining later signatures", () => { + const messages = [ + summary(), + assistant( + [ + thinking("old", "stale_sig"), + { type: "redacted_thinking", data: "opaque_sig" }, + text("old answer"), + ], + 1500, + ), + user("q"), + summary(1000), + assistant([thinking("new", "fresh_sig"), text("new answer")], 3000), ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); expect(result).not.toBe(messages); - - const pre = result[1] as AssistantMessage; - expect(pre.content).toEqual([ - { type: "thinking", thinking: "old think" }, - { type: "text", text: "old answer" }, - ]); - - const post = result[3] as AssistantMessage; - expect(post.content).toEqual([ - { type: "thinking", thinking: "new think", thinkingSignature: "fresh_sig" }, - { type: "text", text: "new answer" }, - ]); + expectContent(result, 1, [thinking("old"), { type: "redacted_thinking" }, text("old answer")]); + expectContent(result, 4, [thinking("new", "fresh_sig"), text("new answer")]); }); - - it("strips redacted_thinking data from pre-compaction messages", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage({ - role: "assistant", - content: [ - { type: "redacted_thinking", data: "opaque_sig" }, - { type: "text", text: "visible" }, - ], - timestamp: 1500, - }), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - const assistant = result[1] as AssistantMessage; - expect(assistant.content).toEqual([ - { type: "redacted_thinking" }, - { type: "text", text: "visible" }, - ]); - }); - - it("skips assistant messages with no parseable timestamp", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage( - sparseAssistant([{ type: "thinking", thinking: "think", thinkingSignature: "sig" }]), - ), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - expect(result).toBe(messages); - }); - - it("uses the maximum compaction timestamp even when summaries are out of order", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "latest", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "mid", thinkingSignature: "sig_mid" }], - timestamp: 1500, - }), - castAgentMessage({ - role: "compactionSummary", - summary: "older", - tokensBefore: 0, - timestamp: 1000, - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "after", thinkingSignature: "sig_after" }], - timestamp: 3000, - }), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - // mid (timestamp 1500 < 2000): signature stripped - const mid = result[1] as AssistantMessage; - expect(mid.content).toEqual([{ type: "thinking", thinking: "mid" }]); - // after (timestamp 3000 > 2000): signature kept - const after = result[3] as AssistantMessage; - expect((after.content[0] as unknown as Record).thinkingSignature).toBe( - "sig_after", - ); - }); - - it("preserves signatures on assistant messages at exactly the compaction timestamp", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "exact", thinkingSignature: "exact_sig" }], - timestamp: 2000, - }), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - // Same millisecond as compaction: treated as post-compaction; signature preserved - expect(result).toBe(messages); - }); - it("parses numeric-looking compaction strings as dates before numeric message timestamps", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: "2026", - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "old", thinkingSignature: "stale_sig" }], - timestamp: 1_000_000, - }), - ]; - - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - expect((result[1] as AssistantMessage).content).toEqual([ - { type: "thinking", thinking: "old" }, - ]); + const messages = [summary("2026"), assistant([thinking("old", "stale_sig")], 1_000_000)]; + expectContent(stripStaleThinkingSignaturesForCompactionReplay(messages), 1, [thinking("old")]); }); }); diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts index 40b172ccfd91..a5d486304ddb 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts @@ -1,5 +1,3 @@ -// Tool-result truncation tests cover live and persisted shrinking of oversized -// tool outputs while preserving transcript shape and update notifications. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -21,7 +19,6 @@ import { onInternalSessionTranscriptUpdate } from "../../sessions/transcript-eve import { useSessionStoreTempDirs } from "../../test-utils/session-state-cleanup.js"; import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; -import { resolveAutoLiveToolResultMaxChars } from "../tool-result-limits.js"; import { prepareEmbeddedAttemptPromptContext } from "./run/attempt-prompt-build.js"; import { buildRuntimeContextCustomMessage } from "./run/runtime-context-prompt.js"; import { @@ -64,8 +61,6 @@ afterEach(async () => { }); function toolResult(text: string, toolCallId = "call_1", details?: unknown): ToolResultMessage { - // Tool-result fixtures use increasing timestamps so persisted branch rewrites - // can preserve ordering while changing content. return { role: "toolResult", toolCallId, @@ -77,54 +72,75 @@ function toolResult(text: string, toolCallId = "call_1", details?: unknown): Too }; } -async function prepareProjection(params: { - sessionId: string; - messages: AgentMessage[]; - state: ToolResultPromptProjectionState; - raw?: boolean; -}) { - const prompt = params.raw ? "raw probe" : "continue"; +async function prepareProjection( + messages: AgentMessage[], + state: ToolResultPromptProjectionState, + raw = false, + sessionId = "projection", +) { + const prompt = raw ? "raw probe" : "continue"; await prepareEmbeddedAttemptPromptContext({ capabilityToolNames: new Set(), attempt: { config: {}, contextTokenBudget: 128_000, - sessionId: params.sessionId, - sessionKey: `agent:main:${params.sessionId}`, + sessionId, + sessionKey: `agent:main:${sessionId}`, suppressNextUserMessagePersistence: false, }, includeBoundaryTimestamp: false, - isRawModelRun: params.raw ?? false, - messages: params.messages, + isRawModelRun: raw, + messages, prompt: { effectivePrompt: prompt, effectiveTranscriptPrompt: prompt, }, replaceSessionMessages: () => {}, sessionAgentId: "main", - systemPromptText: params.raw ? "" : "system", - toolResultPromptProjectionState: params.state, + systemPromptText: raw ? "" : "system", + toolResultPromptProjectionState: state, }); } -describe("tool-result warning dedupe", () => { - const warningDedupeLimit = 1_024; +function toolResults(texts: string[], prefix = "call") { + return texts.map((text, index) => toolResult(text, `${prefix}_${index}`)); +} - it("bounds and evicts the oldest session recovery warning keys", () => { - const cache = toolResultWarningDedupe.sessionRecovery; +function totalText(messages: AgentMessage[]) { + return messages.reduce((sum, message) => sum + textLength(message), 0); +} - for (let index = 0; index <= warningDedupeLimit; index += 1) { - expect(cache.check(`session-${index}`)).toBe(false); - } +function findTool(messages: AgentMessage[], id: string) { + return messages.find((message) => message.role === "toolResult" && message.toolCallId === id); +} - expect(cache.size()).toBe(warningDedupeLimit); - expect(cache.peek("session-0")).toBe(false); - expect(cache.peek("session-1")).toBe(true); - expect(cache.peek(`session-${warningDedupeLimit}`)).toBe(true); - expect(cache.check("session-0")).toBe(false); - expect(cache.check(`session-${warningDedupeLimit}`)).toBe(true); - }); -}); +function projection(maxChars = 5_000, aggregate = 20_000, context = 128_000) { + const state = createToolResultPromptProjectionState(); + return { + state, + project: (messages: AgentMessage[], cap = maxChars, total = aggregate) => + truncate(messages, context, cap, total, state), + }; +} + +function expectProjectionSize( + state: ToolResultPromptProjectionState, + size: number, + replacements = size, +) { + expect([state.sourceHashByKey.size, state.frozen.size, state.replacements.size]).toEqual([ + size, + size, + replacements, + ]); +} + +function byToolPrefix(messages: AgentMessage[], prefix: string) { + return messages.filter( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolCallId.startsWith(prefix), + ); +} function textWithFullOutputFooter(text: string, fullOutputPath: string): string { return `${text}\n\n[Showing truncated output. ${formatFullOutputFooter(fullOutputPath)}]`; @@ -205,18 +221,12 @@ describe("truncateToolResultText", () => { }); it.each([ - { text: "aaa😀z", maxChars: 5, suffix: () => "!", minKeepChars: 0, expected: "aaa!" }, - { text: "abcdef", maxChars: 1, suffix: () => "😀", minKeepChars: 0, expected: "" }, - { - text: "x".repeat(100), - maxChars: 4, - suffix: (truncatedChars: number) => `[${truncatedChars}]`, - minKeepChars: 1, - expected: "[100", - }, - ])( - "bounds suffix-only and code-point cuts at $maxChars chars", - ({ text, maxChars, suffix, minKeepChars, expected }) => { + ["aaa😀z", 5, () => "!", 0, "aaa!"], + ["abcdef", 1, () => "😀", 0, ""], + ["x".repeat(100), 4, (n: number) => `[${n}]`, 1, "[100"], + ] as const)( + "bounds suffix-only and code-point cuts for %s", + (text, maxChars, suffix, minKeepChars, expected) => { expect(truncateToolResultText(text, maxChars, { suffix, minKeepChars })).toBe(expected); }, ); @@ -239,17 +249,11 @@ describe("truncateToolResultText", () => { describe("truncateToolResultMessage", () => { it("truncates Codex protocol toolResult content blocks and mirrored content", () => { const oversized = "x".repeat(50_000); + const block = { type: "toolResult", toolUseId: "call_1", text: oversized, content: oversized }; const msg = { ...toolResult(""), toolName: "exec", - content: [ - { - type: "toolResult", - toolUseId: "call_1", - text: oversized, - content: oversized, - }, - ], + content: [block], } as unknown as ToolResultMessage; const result = truncateToolResultMessage(msg, 10_000, { @@ -264,42 +268,21 @@ describe("truncateToolResultMessage", () => { expect(firstBlock.content).toBe(firstBlock.text); }); - it("truncates dense CJK by weighted budget while leaving same-size ASCII unchanged", () => { - const maxChars = 16_000; - const asciiMessage = toolResult("a".repeat(maxChars)); - expect(truncateToolResultMessage(asciiMessage, maxChars)).toBe(asciiMessage); - - const cjk = "你".repeat(maxChars); - const cjkMessage = toolResult(cjk); - const result = truncateToolResultMessage(cjkMessage, maxChars); - const resultText = toolText(result); - - expect(result).not.toBe(cjkMessage); - expect(resultText.length).toBeLessThan(cjk.length); - expect(estimateStringChars(resultText)).toBeLessThanOrEqual(maxChars); - expect(resultText).toContain("truncated"); - }); - it.each([ - { - kind: "mixed CJK/ASCII", - texts: ["你".repeat(1_000), "a".repeat(4_000)], - maxChars: 4_000, - options: undefined, - }, - { - kind: "small blocks", - texts: ["a".repeat(50), "b".repeat(50), "c".repeat(500)], - maxChars: 100, - options: { suffix: () => "!", minKeepChars: 99 }, - }, - { - kind: "empty blocks", - texts: [...Array(150).fill(""), "x".repeat(500)], - maxChars: 100, - options: { suffix: () => "!", minKeepChars: 0 }, - }, - ])("reserves the weighted notice budget for $kind", ({ kind, texts, maxChars, options }) => { + ["mixed CJK/ASCII", ["你".repeat(1_000), "a".repeat(4_000)], 4_000, undefined], + [ + "small blocks", + ["a".repeat(50), "b".repeat(50), "c".repeat(500)], + 100, + { suffix: () => "!", minKeepChars: 99 }, + ], + [ + "empty blocks", + [...Array(150).fill(""), "x".repeat(500)], + 100, + { suffix: () => "!", minKeepChars: 0 }, + ], + ] as const)("reserves the weighted notice budget for %s", (kind, texts, maxChars, options) => { const msg = { ...toolResult("unused"), content: texts.map((text) => ({ type: "text" as const, text })), @@ -330,15 +313,10 @@ describe("perResultLimit", () => { [20_000, 16_000, 64_000], [128_000, 32_000, 256_000], [200_000, 64_000, 400_000], - [1_000_000, 64_000, 2_000_000], ])( "resolves aggregate live cap for %s token windows", (contextWindowTokens, perResultMaxChars, aggregateMaxChars) => { expect(perResultLimit(contextWindowTokens)).toBe(perResultMaxChars); - if (contextWindowTokens === 200_000) { - expect(resolveAutoLiveToolResultMaxChars(contextWindowTokens)).toBe(64_000); - expect(perResultMaxChars).toBeGreaterThan(perResultLimit(8_000)); - } expect( aggregateLimit({ contextWindowTokens, @@ -349,53 +327,24 @@ describe("perResultLimit", () => { ); }); -describe("sessionLikelyHasOversizedToolResults", () => { - it.each([ - { kind: "CJK", text: "你".repeat(5_000), count: 1, contextWindowTokens: 50_000 }, - { kind: "oversized", text: "x".repeat(500_000), count: 1, contextWindowTokens: 128_000 }, - { - kind: "aggregate", - text: "alpha beta gamma delta epsilon ".repeat(500), - count: 6, - contextWindowTokens: 20_000, - }, - ])("detects $kind pressure", ({ text, count, contextWindowTokens }) => { - const messages = Array.from({ length: count }, (_, index) => toolResult(text, `call_${index}`)); - expect(sessionLikelyHasOversizedToolResults({ messages, contextWindowTokens })).toBe(true); - }); -}); - describe("truncateOversizedToolResultsInMessages", () => { it("returns unchanged messages when nothing is oversized", () => { const messages = [user("hello"), assistant("using tool"), toolResult("small result")]; + expect(sessionLikelyHasOversizedToolResults({ messages, contextWindowTokens: 200_000 })).toBe( + false, + ); const { messages: result, truncatedCount } = truncate(messages, 200_000); expect(truncatedCount).toBe(0); expect(result).toEqual(messages); }); - it("truncates multiple oversized tool results", () => { - const messages: AgentMessage[] = [ - user("hello"), - assistant("reading files"), - toolResult("x".repeat(500_000), "call_1"), - toolResult("y".repeat(500_000), "call_2"), - ]; - const { messages: result, truncatedCount } = truncate(messages, 128_000); - expect(truncatedCount).toBe(2); - for (const msg of result.slice(2)) { - expect(msg.role).toBe("toolResult"); - const text = toolText(msg); - expect(text.length).toBeLessThan(500_000); - expect(text).toContain("truncated"); - } - }); - it("applies the aggregate cap to CJK-weighted prompt history", () => { - const messages = Array.from({ length: 6 }, (_, index) => - toolResult("你".repeat(3_000), `call_${index}`), - ); + const messages = toolResults(Array(6).fill("你".repeat(3_000))); const result = truncate(messages, 20_000); + expect(sessionLikelyHasOversizedToolResults({ messages, contextWindowTokens: 20_000 })).toBe( + true, + ); const estimated = result.messages.reduce( (sum, message) => sum + (message.role === "toolResult" ? estimateStringChars(toolText(message)) : 0), @@ -407,50 +356,27 @@ describe("truncateOversizedToolResultsInMessages", () => { }); it("bounds aggregate tool-result text in prompt history without rewriting callers", () => { - // Live replay truncates cloned tool-result messages; the source array keeps - // full content for UI and transcript persistence. const medium = "alpha beta gamma delta epsilon ".repeat(800); const messages: AgentMessage[] = [ user("hello"), assistant("calling tools"), - toolResult(medium, "call_1"), - toolResult(medium, "call_2"), - toolResult(medium, "call_3"), + ...toolResults(Array(3).fill(medium)), ]; const { messages: result, truncatedCount } = truncate(messages, 128_000, 12_000, 12_000); + expect( + sessionLikelyHasOversizedToolResults({ + messages, + contextWindowTokens: 128_000, + maxCharsOverride: 12_000, + }), + ).toBe(true); - const totalChars = result.reduce( - (sum, message) => sum + (message.role === "toolResult" ? textLength(message) : 0), - 0, - ); expect(truncatedCount).toBeGreaterThan(0); - expect(totalChars).toBeLessThanOrEqual(12_000); + expect(totalText(result)).toBeLessThanOrEqual(12_000); expect(result[0]).toBe(messages[0]); expect(result[1]).toBe(messages[1]); - expect(messages.reduce((sum, message) => sum + textLength(message), 0)).toBe(medium.length * 3); - }); - - it("keeps #99495 historical bytes stable across attempts sharing session state", () => { - using firstLease = retainEmbeddedSessionPromptState("session-99495"); - const state = firstLease.state.toolResults; - const history = [ - toolResult("a".repeat(4_000), "history_1"), - toolResult("b".repeat(4_000), "history_2"), - ]; - const first = truncate(history, 128_000, 5_000, 20_000, state); - using secondLease = retainEmbeddedSessionPromptState("session-99495"); - const secondAttemptState = secondLease.state.toolResults; - const second = truncate( - [...history, toolResult("c".repeat(12_000), "current")], - 128_000, - 5_000, - 20_000, - secondAttemptState, - ); - - expect(secondAttemptState).toBe(state); - expect(second.messages.slice(0, history.length)).toEqual(first.messages); + expect(totalText(messages)).toBe(medium.length * 3); }); it("reclaims #99495 state from canonical compaction, not filtered projections", async () => { @@ -465,67 +391,46 @@ describe("truncateOversizedToolResultsInMessages", () => { truncate([retained], 128_000, 5_000, 20_000, state); expect(state.sourceHashByKey.size).toBe(2); - await prepareProjection({ sessionId, messages: [], state, raw: true }); + await prepareProjection([], state, true, sessionId); expect(state.sourceHashByKey.size).toBe(2); - await prepareProjection({ sessionId, messages: [retained], state }); + await prepareProjection([retained], state, false, sessionId); - expect(state).toMatchObject({ - sourceHashByKey: { size: 1 }, - frozen: { size: 1 }, - replacements: { size: 1 }, - }); + expectProjectionSize(state, 1); expect(truncate([retained], 128_000, 5_000, 20_000, state).messages).toEqual( projected.messages.slice(1), ); }); - it("keeps frozen aggregate projections byte-identical when a later turn exceeds the budget", () => { - const state = createToolResultPromptProjectionState(); + it("allows aggregate overflow without rewriting untruncated frozen history", () => { + const { project } = projection(8_000, 10_000, 1_000_000); const history = [ - toolResult("a".repeat(4_000), "history_1"), - toolResult("b".repeat(4_000), "history_2"), - user("continue"), + ...toolResults(["a".repeat(4_000), "b".repeat(4_000)], "history"), + user("establish a frozen projection baseline"), ]; - const first = truncate(history, 128_000, 8_000, 12_000, state); - const frozenBytes = first.messages.map((message) => JSON.stringify(message)); - const runtimeContextMessage = buildRuntimeContextCustomMessage("runtime context refresh"); - if (!runtimeContextMessage) { + const first = project(history); + expect(first.aggregatePressureEngaged).toBe(false); + const carrier = buildRuntimeContextCustomMessage("hard-cap runtime context"); + if (!carrier) { throw new Error("expected runtime context message"); } - - const second = truncate( - [ - ...history, - assistant("running exec"), - toolResult("c".repeat(6_000), "current"), - runtimeContextMessage, - ], - 128_000, - 8_000, - 12_000, - state, + const freshOutput = "OC99241_HARD_CAP_SENTINEL_".padEnd(4_000, "f"); + const messages = convertToLlm([...history, toolResult(freshOutput, "fresh"), carrier]); + expect(messages.at(-1)).toMatchObject({ role: "user", runtimeContext: {} }); + const second = project(messages, 8_000, 100); + expect(byToolPrefix(second.messages, "history_")).toEqual( + byToolPrefix(first.messages, "history_"), ); - - expect(first.aggregateTruncatedCount).toBe(0); + expect(toolText(findTool(second.messages, "fresh")!)).toBe(freshOutput); expect(second.aggregateTruncatedCount).toBe(0); - expect( - second.messages.slice(0, history.length).map((message) => JSON.stringify(message)), - ).toEqual(frozenBytes); - const current = second.messages.find( - (message): message is ToolResultMessage => - message.role === "toolResult" && message.toolCallId === "current", - ); - expect(current && toolText(current)).toBe("c".repeat(6_000)); + expect(second.aggregatePressureEngaged).toBe(true); + expect(totalText(second.messages)).toBeGreaterThan(100); }); it("shrinks #99495 frozen bytes monotonically only under a tighter hard cap", () => { using promptStateLease = retainEmbeddedSessionPromptState("session-99495-shrink"); const state = promptStateLease.state.toolResults; - const history = [ - toolResult("a".repeat(8_000), "history_1"), - toolResult("b".repeat(8_000), "history_2"), - ]; + const history = toolResults(["a".repeat(8_000), "b".repeat(8_000)], "history"); const first = truncate(history, 128_000, 6_000, 20_000, state); const shrunk = truncate(history, 128_000, 3_000, 20_000, state); const relaxed = truncate(history, 128_000, 7_000, 20_000, state); @@ -538,31 +443,24 @@ describe("truncateOversizedToolResultsInMessages", () => { }); it.each([ - { kind: "tiny", outputs: ["ABC"], totalCap: 32_100 }, - { - kind: "runtime carrier", - outputs: ["OC99756_EXEC_MARKER_".padEnd(4_000, "x")], - totalCap: 36_000, - }, - { - kind: "queued steering", - outputs: [ - "OC99241_SHORT_SENTINEL_".padEnd(234, "s"), - "OC99241_LONG_SENTINEL_".padEnd(4_000, "l"), - ], - totalCap: 36_234, - }, - { kind: "oversized", outputs: ["z".repeat(20_000)], totalCap: 40_000 }, - ])( - "preserves fresh $kind output without rewriting frozen history", - ({ kind, outputs, totalCap }) => { - const state = createToolResultPromptProjectionState(); + ["tiny", ["ABC"], 32_100], + ["runtime carrier", ["OC99756_EXEC_MARKER_".padEnd(4_000, "x")], 36_000], + [ + "queued steering", + ["OC99241_SHORT_SENTINEL_".padEnd(234, "s"), "OC99241_LONG_SENTINEL_".padEnd(4_000, "l")], + 36_234, + ], + ["oversized", ["z".repeat(20_000)], 40_000], + ] as const)( + "preserves fresh %s output without rewriting frozen history", + (kind, outputs, totalCap) => { + const { project } = projection(8_000, 32_000, 1_000_000); const history: AgentMessage[] = []; for (let index = 0; index < 50; index++) { history.push(assistant(`call ${index}`), toolResult("x".repeat(4_000), `history_${index}`)); } history.push(user("run commands")); - const first = truncate(history, 1_000_000, 8_000, 32_000, state); + const first = project(history); expect(first.truncatedCount).toBeGreaterThan(0); const carrier = kind === "queued steering" @@ -581,17 +479,13 @@ describe("truncateOversizedToolResultsInMessages", () => { if (kind === "runtime carrier") { expect(providerMessages.at(-1)).toMatchObject({ role: "user", runtimeContext: {} }); } - const second = truncate(providerMessages, 1_000_000, 8_000, 32_000, state); - const freshResults = second.messages.filter( - (message): message is ToolResultMessage => - message.role === "toolResult" && message.toolCallId.startsWith("fresh_"), - ); + const budget = kind === "tiny" ? 100 : 32_000; + const second = project(providerMessages, 8_000, budget); + const freshResults = byToolPrefix(second.messages, "fresh_"); if (kind === "runtime carrier") { - const historicalResults = (entries: AgentMessage[]) => - entries.filter( - (message) => message.role === "toolResult" && message.toolCallId.startsWith("history_"), - ); - expect(historicalResults(second.messages)).toEqual(historicalResults(first.messages)); + expect(byToolPrefix(second.messages, "history_")).toEqual( + byToolPrefix(first.messages, "history_"), + ); } else { expect(second.messages.slice(0, history.length)).toEqual(first.messages); } @@ -610,55 +504,11 @@ describe("truncateOversizedToolResultsInMessages", () => { expect(second.aggregateTruncatedCount).toBe(0); expect(second.aggregatePressureEngaged).toBe(true); } - expect( - second.messages.reduce((sum, message) => sum + textLength(message), 0), - ).toBeLessThanOrEqual(totalCap); + expect(totalText(second.messages)).toBeLessThanOrEqual(totalCap); + expect(totalText(second.messages)).toBeGreaterThan(budget); }, ); - it("allows aggregate overflow rather than rewriting frozen history", () => { - const state = createToolResultPromptProjectionState(); - const history: AgentMessage[] = [ - toolResult("a".repeat(4_000), "history_a"), - toolResult("b".repeat(4_000), "history_b"), - user("establish a frozen projection baseline"), - ]; - const first = truncate(history, 1_000_000, 8_000, 10_000, state); - expect(first.aggregatePressureEngaged).toBe(false); - - const freshOutput = "OC99241_HARD_CAP_SENTINEL_".padEnd(4_000, "f"); - const runtimeContextMessage = buildRuntimeContextCustomMessage("hard-cap runtime context"); - if (!runtimeContextMessage) { - throw new Error("expected runtime context message"); - } - const providerMessages = convertToLlm([ - ...history, - toolResult(freshOutput, "fresh_hard_cap"), - runtimeContextMessage, - ] as AgentMessage[]); - expect(providerMessages.at(-1)).toMatchObject({ role: "user", runtimeContext: {} }); - const second = truncate(providerMessages, 1_000_000, 8_000, 100, state); - const freshResult = second.messages.find( - (message): message is ToolResultMessage => - message.role === "toolResult" && message.toolCallId === "fresh_hard_cap", - ); - const freshText = freshResult ? toolText(freshResult) : ""; - const totalChars = second.messages.reduce( - (sum, message) => sum + (message.role === "toolResult" ? textLength(message) : 0), - 0, - ); - - expect( - second.messages.filter( - (message) => message.role === "toolResult" && message.toolCallId.startsWith("history_"), - ), - ).toEqual(first.messages.filter((message) => message.role === "toolResult")); - expect(freshText).toBe(freshOutput); - expect(second.aggregateTruncatedCount).toBe(0); - expect(second.aggregatePressureEngaged).toBe(true); - expect(totalChars).toBeGreaterThan(100); - }); - it("leaves fresh trailing batches intact when only they exceed the aggregate budget", () => { const state = createToolResultPromptProjectionState(); const messages: AgentMessage[] = [user("run several tools")]; @@ -667,13 +517,12 @@ describe("truncateOversizedToolResultsInMessages", () => { } const result = truncate(messages, 1_000_000, 8_000, 32_000, state); - const toolResults = result.messages.filter((message) => message.role === "toolResult"); - const totalChars = toolResults.reduce((sum, message) => sum + textLength(message), 0); + const results = result.messages.filter((message) => message.role === "toolResult"); expect(result.truncatedCount).toBe(0); expect(result.aggregatePressureEngaged).toBe(true); - expect(totalChars).toBeGreaterThan(32_000); - expect(toolResults.every((message) => toolText(message).length > 0)).toBe(true); + expect(totalText(results)).toBeGreaterThan(32_000); + expect(results.every((message) => toolText(message).length > 0)).toBe(true); }); it.each([ @@ -688,9 +537,7 @@ describe("truncateOversizedToolResultsInMessages", () => { const texts = result.messages.map(toolText); if (kind === "tiny") { expect(result.truncatedCount).toBeGreaterThan(0); - expect( - result.messages.reduce((sum, message) => sum + textLength(message), 0), - ).toBeLessThanOrEqual(8); + expect(totalText(result.messages)).toBeLessThanOrEqual(8); } else if (kind === "plain") { expect(texts[0]).toContain("[tool result elided"); expect(texts[0]).not.toContain("full output preserved at"); @@ -741,41 +588,31 @@ describe("truncateOversizedToolResultsInMessages", () => { ]; const result = truncate(messages, 128_000, perResult, budget); const text = toolText(result.messages[0] ?? toolResult("")); - switch (kind) { - case "live": - case "escaped": - expect(text).toContain("read"); - expect(text).toContain(spillPath); - break; - case "partial": - expect(text).toContain("partial"); - expect(text).toContain(spillPath); - expect(text).not.toContain("full output preserved"); - break; - case "deleted": - case "private": - expect(text).toContain("[tool result elided"); - expect(text).not.toContain(spillPath); - break; - case "compact": - expect(text).toBe(`[read ${spillPath}]`); - break; - case "suffix": - expect(text).toContain(`full output at ${spillPath}`); - expect(text).not.toContain("narrow args"); - break; + expect(text.includes(spillPath)).toBe(kind !== "deleted" && kind !== "private"); + if (kind === "live" || kind === "escaped") { + expect(text).toContain("read"); + } else if (kind === "partial") { + expect(text).toContain("partial"); + expect(text).not.toContain("full output preserved"); + } else if (kind === "deleted" || kind === "private") { + expect(text).toContain("[tool result elided"); + } else if (kind === "compact") { + expect(text).toBe(`[read ${spillPath}]`); + } else { + expect(text).toContain(`full output at ${spillPath}`); + expect(text).not.toContain("narrow args"); } }, ); it("does not restore filtered image blocks when reusing a projection", () => { - const state = createToolResultPromptProjectionState(); + const { project } = projection(12_000, 12_000); const source = toolResult("x".repeat(15_000), "image_call"); source.content = [ { type: "image", data: "filtered-after-conversion" }, { type: "text", text: "x".repeat(15_000) }, ] as never; - truncate([source], 128_000, 12_000, 12_000, state); + project([source]); const providerMessage: ToolResultMessage = { ...source, @@ -784,9 +621,7 @@ describe("truncateOversizedToolResultsInMessages", () => { { type: "text" as const, text: "x".repeat(15_000) }, ], }; - const result = truncate([providerMessage], 128_000, 12_000, 12_000, state).messages[0] as - | ToolResultMessage - | undefined; + const result = project([providerMessage]).messages[0] as ToolResultMessage | undefined; expect(result?.content?.[0]).toEqual({ type: "text", @@ -798,13 +633,13 @@ describe("truncateOversizedToolResultsInMessages", () => { }); it("retains bounded projection data while replaying current canonical metadata", () => { - const state = createToolResultPromptProjectionState(); + const { state, project } = projection(); const text = "x".repeat(100_000); const source = toolResult(text, "retained-read", { content: text }); source.content.push({ type: "image", data: "a".repeat(100_000), mimeType: "image/png" }); // SAFETY: exercise malformed plugin content that the projection owner preserves. source.content.push(null as never); - const first = truncate([source], 128_000, 5_000, 20_000, state); + const first = project([source]); const retained = JSON.stringify(state, (_key, value) => value instanceof Map || value instanceof Set ? [...value.values()] : value, ); @@ -812,8 +647,7 @@ describe("truncateOversizedToolResultsInMessages", () => { expect(first.messages[0]).toMatchObject({ details: source.details }); const current = { ...source, details: { revision: "current" } }; - const replay = truncate([current], 128_000, 5_000, 20_000, state) - .messages[0] as ToolResultMessage; + const replay = project([current]).messages[0] as ToolResultMessage; expect(replay.details).toBe(current.details); expect(replay.content).toEqual((first.messages[0] as ToolResultMessage).content); expect(source.content[0]).toEqual({ type: "text", text }); @@ -826,87 +660,53 @@ describe("truncateOversizedToolResultsInMessages", () => { ], [["\ud800"], ["\ud801"]], ])("invalidates rewritten canonical text with preserved framing: %j", async (before, after) => { - const state = createToolResultPromptProjectionState(); + const { state, project } = projection(); const source = toolResult("", "rewritten-source"); const blocks = (parts: string[]) => parts.map((text) => ({ type: "text" as const, text })); source.content = blocks(["x".repeat(15_000), ...before]); - truncate([source], 128_000, 5_000, 20_000, state); + project([source]); const rewritten = { ...source, content: blocks(["x".repeat(15_000), ...after]) }; - await prepareProjection({ - sessionId: "rewritten-source", - messages: [rewritten], - state, - }); + await prepareProjection([rewritten], state, false, "rewritten-source"); expect(state.replacements.size).toBe(0); expect(state.frozen.size).toBe(0); }); it("freezes #99495 ambiguous-key projections across filtered history", async () => { - const state = createToolResultPromptProjectionState(); - const duplicate = (text: string) => ({ - role: "toolResult" as const, - toolCallId: "duplicate-call", - toolName: "duplicate", - isError: false, - timestamp: 1, - content: [{ type: "text" as const, text }], - }); - const first = truncate( - [duplicate("a".repeat(100)), duplicate("b".repeat(100))], - 128_000, - 100, - 100, - state, - ); - const filtered = truncate([duplicate("b".repeat(100))], 128_000, 100, 100, state); + const { state, project } = projection(100, 100); + const duplicate = (text: string) => ({ ...toolResult(text, "duplicate-call"), timestamp: 1 }); + const first = project([duplicate("a".repeat(100)), duplicate("b".repeat(100))]); + const filtered = project([duplicate("b".repeat(100))]); expect(first.messages[0]).not.toEqual(first.messages[1]); expect(filtered.messages[0]).toEqual(first.messages[1]); - await prepareProjection({ - sessionId: "ambiguous-filtered-history", - messages: [duplicate("b".repeat(100))], + await prepareProjection( + [duplicate("b".repeat(100))], state, - }); - expect(state.sourceHashByKey.size).toBe(1); - expect(state.frozen.size).toBe(1); - expect(state.replacements.size).toBe(0); - expect(state.ambiguousBaseKeys.size).toBe(1); - expect(truncate([duplicate("b".repeat(100))], 128_000, 100, 100, state).messages[0]).toEqual( - first.messages[1], + false, + "ambiguous-filtered-history", ); - await prepareProjection({ - sessionId: "ambiguous-removed-history", - messages: [], - state, - }); + expectProjectionSize(state, 1, 0); + expect(state.ambiguousBaseKeys.size).toBe(1); + expect(project([duplicate("b".repeat(100))]).messages[0]).toEqual(first.messages[1]); + await prepareProjection([], state, false, "ambiguous-removed-history"); expect(state.ambiguousBaseKeys.size).toBe(0); }); it("drops an unselected identical-occurrence key without changing projected bytes", async () => { - const state = createToolResultPromptProjectionState(); - const duplicate = (): ToolResultMessage => ({ - role: "toolResult", - toolCallId: "identical-call", - toolName: "duplicate", - isError: false, - content: [{ type: "text", text: "x".repeat(100) }], + const { state, project } = projection(100, 100); + const duplicate = () => ({ + ...toolResult("x".repeat(100), "identical-call"), timestamp: 1_000, }); const history = [duplicate(), assistant("separator"), duplicate()]; - const first = truncate(history, 128_000, 100, 100, state); + const first = project(history); const stateWithStaleOccurrence = createToolResultPromptProjectionState(state); expect(stateWithStaleOccurrence.frozen.size).toBe(2); - await prepareProjection({ - sessionId: "identical-occurrence-compaction", - messages: [duplicate()], - state, - }); + await prepareProjection([duplicate()], state, false, "identical-occurrence-compaction"); const retainedWithStale = truncate([duplicate()], 128_000, 100, 100, stateWithStaleOccurrence); - const retainedAfterPrune = truncate([duplicate()], 128_000, 100, 100, state); - // After the first identical occurrence disappears, both states select :0; - // retaining the unreachable :1 entry cannot preserve or change provider bytes. + const retainedAfterPrune = project([duplicate()]); expect(retainedAfterPrune.messages).toEqual(retainedWithStale.messages); expect(retainedAfterPrune.messages[0]).toEqual(first.messages[0]); expect(state.frozen.size).toBe(1); @@ -914,7 +714,7 @@ describe("truncateOversizedToolResultsInMessages", () => { }); }); -async function createSession(sessionId: string, sessionKey: string) { +async function createSession(sessionId: string, sessionKey = `agent:main:${sessionId}`) { const storePath = path.join(sessionDirs.make(), "sessions.json"); const scope = { agentId: "main", sessionId, sessionKey, storePath }; const sessionFile = formatSqliteSessionFileMarker(scope); @@ -926,6 +726,24 @@ async function createSession(sessionId: string, sessionKey: string) { return scope; } +type SessionScope = Awaited>; + +function recover( + scope: SessionScope, + overrides: Partial[0]> = {}, +) { + return truncateOversizedToolResultsInSessionManager({ + sessionManager: SessionManager.open(scope), + ...scope, + contextWindowTokens: 100, + ...overrides, + }); +} + +function append(scope: SessionScope, message: AgentMessage) { + return appendTranscriptMessage(scope, { message }); +} + function activeMessages(scope: Awaited>) { return SessionManager.open(scope) .getBranch() @@ -948,9 +766,7 @@ async function storedMessages(scope: Awaited>) describe("truncateOversizedToolResultsInSession", () => { it("truncates SQLite runtime transcripts without treating the marker as a file", async () => { const scope = await createSession("runtime-sqlite-tool-truncation", "agent:main:test"); - await appendTranscriptMessage(scope, { - message: user("run tools"), - }); + await append(scope, user("run tools")); const staleCheckpointReplay = { v: 1, type: "openai-responses-compaction", @@ -960,22 +776,15 @@ describe("truncateOversizedToolResultsInSession", () => { model: "gpt-5.2", baseUrlHash: "ozhevd1smnk8s", } satisfies NonNullable; - const preBoundaryCheckpointOwner = assistant("pre-boundary checkpoint owner"); - preBoundaryCheckpointOwner.providerReplay = staleCheckpointReplay; - await appendTranscriptMessage(scope, { message: preBoundaryCheckpointOwner }); + const checkpoint = (text: string, providerReplay: AssistantMessage["providerReplay"]) => + append(scope, { ...assistant(text), providerReplay }); + await checkpoint("pre-boundary checkpoint owner", staleCheckpointReplay); const medium = "alpha beta gamma delta epsilon ".repeat(600); - const firstToolResult = await appendTranscriptMessage(scope, { - message: toolResult(medium, "call_1"), - }); - const secondToolResult = await appendTranscriptMessage(scope, { - message: toolResult(medium, "call_2"), - }); - const thirdToolResult = await appendTranscriptMessage(scope, { - message: toolResult(medium, "call_3"), - }); - const staleCheckpointOwner = assistant("stale checkpoint owner"); - staleCheckpointOwner.providerReplay = staleCheckpointReplay; - await appendTranscriptMessage(scope, { message: staleCheckpointOwner }); + const originalToolResultIds = new Set(); + for (const message of toolResults(Array(3).fill(medium))) { + originalToolResultIds.add((await append(scope, message)).messageId); + } + await checkpoint("stale checkpoint owner", staleCheckpointReplay); const staleAnthropicCheckpointReplay = { ...staleCheckpointReplay, type: "anthropic-compaction", @@ -983,25 +792,17 @@ describe("truncateOversizedToolResultsInSession", () => { api: "anthropic-messages", model: "claude-sonnet-4-6", } satisfies NonNullable; - const staleAnthropicCheckpointOwner = assistant("stale Anthropic checkpoint owner"); - staleAnthropicCheckpointOwner.providerReplay = staleAnthropicCheckpointReplay; - await appendTranscriptMessage(scope, { message: staleAnthropicCheckpointOwner }); + await checkpoint("stale Anthropic checkpoint owner", staleAnthropicCheckpointReplay); const suppressionReplay = { ...staleCheckpointReplay, type: "openai-responses-compaction-suppression", data: "rejected", } satisfies NonNullable; - const suppressionOwner = assistant("suppression owner"); - suppressionOwner.providerReplay = suppressionReplay; - await appendTranscriptMessage(scope, { message: suppressionOwner }); + await checkpoint("suppression owner", suppressionReplay); const listener = vi.fn(); const cleanup = onInternalSessionTranscriptUpdate(listener); - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 100, - }); + const result = await recover(scope); cleanup(); expect(result.truncated).toBe(true); @@ -1013,11 +814,6 @@ describe("truncateOversizedToolResultsInSession", () => { target: scope, }); - const originalToolResultIds = new Set([ - firstToolResult.messageId, - secondToolResult.messageId, - thirdToolResult.messageId, - ]); expect( (await storedMessages(scope)) .filter((entry) => originalToolResultIds.has(entry.id)) @@ -1027,9 +823,9 @@ describe("truncateOversizedToolResultsInSession", () => { ).toEqual([medium, medium, medium]); const branchMessages = activeMessages(scope); - const toolResultTexts = branchMessages.flatMap((message) => - message.role === "toolResult" ? [toolText(message as ToolResultMessage)] : [], - ); + const toolResultTexts = branchMessages + .filter((message) => message.role === "toolResult") + .map(toolText); const findAssistant = (text: string) => branchMessages.find( (message): message is AssistantMessage => @@ -1047,90 +843,74 @@ describe("truncateOversizedToolResultsInSession", () => { expect(findAssistant("suppression owner")?.providerReplay).toEqual(suppressionReplay); }); - it("reuses frozen provider projection bytes on the recovery branch", async () => { - const scope = await createSession( - "runtime-sqlite-frozen-projection", - "agent:main:frozen-projection", - ); - await appendTranscriptMessage(scope, { message: user("run tool") }); - const original = toolResult("frozen output ".repeat(2_000), "frozen_call"); - const persisted = await appendTranscriptMessage(scope, { message: original }); - const state = createToolResultPromptProjectionState(); - const projected = truncate([original], 128_000, 12_000, 48_000, state).messages[0]; - const staleProjectionState = createToolResultPromptProjectionState(state); - - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 128_000, - maxCharsOverride: 12_000, - aggregateMaxCharsOverride: 48_000, - projectionState: state, - }); - - expect(result.truncated).toBe(true); - expect(state).toMatchObject({ - sourceHashByKey: { size: 0 }, - replacements: { size: 0 }, - frozen: { size: 0 }, - }); - await prepareProjection({ - sessionId: scope.sessionId, - messages: SessionManager.open(scope).buildSessionContext().messages, - state: staleProjectionState, - }); - expect(staleProjectionState.sourceHashByKey.size).toBe(0); - expect(staleProjectionState.replacements.size).toBe(0); - expect(staleProjectionState.frozen.size).toBe(0); - expect(activeMessages(scope).find((message) => message.role === "toolResult")).toEqual( - projected, - ); - const originalEvent = (await storedMessages(scope)).find( - (entry) => entry.id === persisted.messageId, - ); - expect(originalEvent?.message).toEqual(original); - }); - - it("reduces aggregate-only frozen history on the recovery branch", async () => { - const scope = await createSession( - "runtime-sqlite-frozen-aggregate-recovery", - "agent:main:frozen-aggregate-recovery", - ); - await appendTranscriptMessage(scope, { message: user("run tools") }); - // Each result stays under the per-result cap; only the aggregate is over. - const frozenResults = [ - toolResult("a".repeat(6_000), "frozen_agg_1"), - toolResult("b".repeat(6_000), "frozen_agg_2"), - toolResult("c".repeat(6_000), "frozen_agg_3"), - ]; - for (const message of frozenResults) { - await appendTranscriptMessage(scope, { message }); - } - const state = createToolResultPromptProjectionState(); - // Dispatch under a loose budget freezes every result at full text. - truncate(frozenResults, 128_000, 8_000, 48_000, state); - expect(state.frozen.size).toBe(3); - - // A provider context failure then demands recovery under a tighter budget: - // frozen history is the only reducible mass and must still shrink. - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 128_000, - maxCharsOverride: 8_000, - aggregateMaxCharsOverride: 6_000, - projectionState: state, - }); - - expect(result.truncated).toBe(true); - const totalChars = activeMessages(scope).reduce((sum, message) => sum + textLength(message), 0); - expect(totalChars).toBeLessThan(18_000); - }); + it.each([ + ["per-result", 12_000, 48_000], + ["aggregate-only", 8_000, 6_000], + ] as const)( + "recovers frozen %s history without rewriting original events", + async (kind, cap, budget) => { + const scope = await createSession(`runtime-sqlite-frozen-${kind}`); + await append(scope, user("run tools")); + const originals = + kind === "per-result" + ? [toolResult("frozen output ".repeat(2_000), "frozen_call")] + : toolResults( + ["a", "b", "c"].map((char) => char.repeat(6_000)), + "frozen_agg", + ); + const persisted: Array>> = []; + for (const message of originals) { + persisted.push(await append(scope, message)); + } + const { state, project } = projection(cap, 48_000); + const projected = project(originals).messages; + const stale = createToolResultPromptProjectionState(state); + expect(state.frozen.size).toBe(originals.length); + const result = await recover(scope, { + contextWindowTokens: 128_000, + maxCharsOverride: cap, + aggregateMaxCharsOverride: budget, + projectionState: state, + }); + expect(result.truncated).toBe(true); + const events = await storedMessages(scope); + expect( + persisted.map(({ messageId }) => events.find(({ id }) => id === messageId)?.message), + ).toEqual(originals); + if (kind === "aggregate-only") { + expect(totalText(activeMessages(scope))).toBeLessThan(18_000); + } else { + expectProjectionSize(state, 0); + await prepareProjection( + SessionManager.open(scope).buildSessionContext().messages, + stale, + false, + scope.sessionId, + ); + expectProjectionSize(stale, 0); + expect(activeMessages(scope).find((message) => message.role === "toolResult")).toEqual( + projected[0], + ); + } + }, + ); it("honors SQLite leaf controls when truncating runtime transcripts", async () => { const scope = await createSession("runtime-sqlite-leaf-tool-truncation", "agent:main:test"); const activeLarge = "selected branch tool output ".repeat(700); const inactiveLarge = "inactive branch tool output ".repeat(700); + const messageEntry = ( + id: string, + parentId: string | null, + message: AgentMessage, + second: number, + ) => ({ + type: "message" as const, + id, + parentId, + message, + timestamp: `2026-01-01T00:00:0${second}.000Z`, + }); await replaceTranscriptEvents(scope, [ { type: "session", @@ -1139,27 +919,9 @@ describe("truncateOversizedToolResultsInSession", () => { timestamp: "2026-01-01T00:00:00.000Z", cwd: path.dirname(scope.storePath), }, - { - type: "message", - id: "root-user", - parentId: null, - timestamp: "2026-01-01T00:00:01.000Z", - message: user("run tools"), - }, - { - type: "message", - id: "selected-tool", - parentId: "root-user", - timestamp: "2026-01-01T00:00:02.000Z", - message: toolResult(activeLarge, "call_selected"), - }, - { - type: "message", - id: "inactive-tool", - parentId: "root-user", - timestamp: "2026-01-01T00:00:03.000Z", - message: toolResult(inactiveLarge, "call_inactive"), - }, + messageEntry("root-user", null, user("run tools"), 1), + messageEntry("selected-tool", "root-user", toolResult(activeLarge, "call_selected"), 2), + messageEntry("inactive-tool", "root-user", toolResult(inactiveLarge, "call_inactive"), 3), { type: "leaf", id: "selected-leaf", @@ -1169,16 +931,10 @@ describe("truncateOversizedToolResultsInSession", () => { }, ]); - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 100, - }); + const result = await recover(scope); expect(result.truncated).toBe(true); const stored = (await storedMessages(scope)).map((entry) => entry.message); - const findTool = (messages: AgentMessage[], id: string) => - messages.find((message) => message.role === "toolResult" && message.toolCallId === id); const originalSelectedTool = findTool(stored, "call_selected"); const inactiveTool = findTool(stored, "call_inactive"); const selectedTool = findTool(activeMessages(scope), "call_selected"); @@ -1242,4 +998,3 @@ describe("truncateToolResultText head+tail strategy", () => { } }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/sessions/auth-storage.test.ts b/src/agents/sessions/auth-storage.test.ts index cd5184997a19..36a496619fd5 100644 --- a/src/agents/sessions/auth-storage.test.ts +++ b/src/agents/sessions/auth-storage.test.ts @@ -67,14 +67,7 @@ const { ensureAuthProfileStoreWithoutExternalProfiles, saveAuthProfileStore } = authProfileStoreRuntime; function providerBCredential(overrides: Partial = {}) { - return createCredential({ - provider: "provider-b", - access: "provider-b-access", - refresh: "provider-b-refresh", - expires: Date.now() + 600_000, - accountId: "acct-b", - ...overrides, - }); + return createCredential({ provider: "provider-b", accountId: "acct-b", ...overrides }); } function saveCredential(agentDir: string, profileId: string, credential: OAuthCredential) { @@ -83,6 +76,37 @@ function saveCredential(agentDir: string, profileId: string, credential: OAuthCr }); } +function registerRefresh(storage: AuthStorage, providerId: string, credential: OAuthCredential) { + const refresh = vi.fn(async () => credential); + getAuthStorageOAuthProviderRegistry(storage).register(oauthProvider(providerId, refresh)); + return refresh; +} + +function expectPublished( + storage: AuthStorage, + agentDir: string, + providerId: string, + credential: OAuthCredential | undefined, +) { + expect(storage.get(providerId)).toEqual(credential); + expect(readProfile(agentDir, `${providerId}:default`)).toEqual(credential); +} + +function changeCredential( + storage: AuthStorage, + providerId: string, + change: string, + replacement: OAuthCredential, +) { + if (change === "logout") { + storage.logout(providerId); + } else if (change === "replace") { + storage.set(providerId, replacement); + } else if (change === "unrelated") { + storage.set("other", { type: "api_key", key: "synthetic-other-new" }); + } +} + afterEach(() => { vi.restoreAllMocks(); clearAuthProfileMigrationDiagnostics(); @@ -94,7 +118,6 @@ describe("AuthStorage OAuth refresh ownership", () => { it("refreshes local OAuth credentials while the shared owner requires migration", () => withAgent(async (agentDir, state) => { const providerId = "test-oauth"; - const credential = createCredential({ provider: providerId, expires: 1 }); const sharedStore = { version: 1, profiles: {} }; await state.writeJson("agents/main/agent/auth-profiles.json", { version: 1, @@ -103,25 +126,18 @@ describe("AuthStorage OAuth refresh ownership", () => { }, }); writePersistedAuthProfileStoreRaw(sharedStore); - writePersistedAuthProfileStoreRaw(profileStore(credential), agentDir); - const storage = AuthStorage.forAgent(agentDir, {}); - const refreshed = createCredential({ - provider: providerId, - access: "synthetic-refreshed-access", - refresh: "synthetic-refreshed-refresh", - }); - const refreshToken = vi.fn(async () => refreshed); - getAuthStorageOAuthProviderRegistry(storage).register( - oauthProvider(providerId, refreshToken), + writePersistedAuthProfileStoreRaw( + profileStore(createCredential({ provider: providerId, expires: 1 })), + agentDir, ); + const storage = AuthStorage.forAgent(agentDir, {}); + const refreshed = createCredential({ provider: providerId }); + registerRefresh(storage, providerId, refreshed); const fallback = vi.fn(() => "synthetic-fallback-key"); storage.setFallbackResolver(fallback); await expect(storage.getApiKey(providerId)).resolves.toBe(refreshed.access); - expect(refreshToken).toHaveBeenCalledOnce(); - expect(loadPersistedAuthProfileStore(agentDir)?.profiles[`${providerId}:default`]).toEqual( - refreshed, - ); + expectPublished(storage, agentDir, providerId, refreshed); await expect(storage.getApiKey("anthropic")).rejects.toMatchObject({ code: "AUTH_PROFILE_MIGRATION_REQUIRED", affectedProviders: ["anthropic"], @@ -139,14 +155,15 @@ describe("AuthStorage OAuth refresh ownership", () => { "blocks credential writes and OAuth refresh when the %s destination requires migration", (agentId) => withAgent(async (agentDir, state) => { - const originalStore = profileStore( - createCredential({ provider: "test-oauth", expires: 1 }), + writePersistedAuthProfileStoreRaw( + profileStore(createCredential({ provider: "test-oauth", expires: 1 })), + agentDir, ); - writePersistedAuthProfileStoreRaw(originalStore, agentDir); const storage = AuthStorage.forAgent(agentDir, {}); - const refreshToken = vi.fn(async () => createCredential({ provider: "test-oauth" })); - getAuthStorageOAuthProviderRegistry(storage).register( - oauthProvider("test-oauth", refreshToken), + const refreshToken = registerRefresh( + storage, + "test-oauth", + createCredential({ provider: "test-oauth" }), ); // A populated SQLite owner treats legacy files as leftover bytes. Once // emptied, its matching legacy credential must block further credential use. @@ -159,10 +176,6 @@ describe("AuthStorage OAuth refresh ownership", () => { : profileStore(createCredential({ provider: "test-oauth" })), ); await refreshAuthProfileFileState(); - expect(() => assertAuthProfileMigrationReady(agentDir)).toThrow( - "requires legacy credential migration", - ); - const write = vi.spyOn(authProfileStoreRuntime, "saveAuthProfileStoreWithPreparedOwner"); expect(() => storage.set("litellm", { type: "api_key", key: "synthetic-local-key" }), ).toThrow("requires legacy credential migration"); @@ -170,196 +183,121 @@ describe("AuthStorage OAuth refresh ownership", () => { code: "AUTH_PROFILE_MIGRATION_REQUIRED", }); expect(refreshToken).not.toHaveBeenCalled(); - expect(write).not.toHaveBeenCalled(); expect(readPersistedAuthProfileStoreRaw(agentDir)).toEqual(migrationStore); }, agentId), ); - it.each(["pending", "compare-and-swap", "observer"])( - "rejects a provider change during OAuth manager %s", - async (phase) => { - await withAgent(async (agentDir) => { - const profileId = "openai:oauth"; + describe.each(["manager", "serialized"])("%s provider ownership", (backendKind) => { + it.each(["pending", "compare-and-swap", "observer"])("rejects changes at %s", (phase) => + withAgent(async (agentDir) => { + const profileId = "openai:default"; const attempted = createCredential({ expires: 1, accountId: "acct-a" }); - const providerB = providerBCredential(phase === "pending" ? { expires: 1 } : {}); - const foreignFence = createOAuthRefreshFence({ profileId, credential: providerB }); - saveCredential( - agentDir, - profileId, + const foreign = providerBCredential(phase === "pending" ? { expires: 1 } : {}); + const foreignFence = createOAuthRefreshFence({ profileId, credential: foreign }); + const initial = phase === "compare-and-swap" ? attempted : phase === "pending" ? foreignFence - : createOAuthRefreshFence({ profileId, credential: attempted }), - ); - const buildApiKey = vi.fn( - async (_provider, credential: OAuthCredential) => credential.access, - ); - const refreshCredential = vi.fn(async () => (phase === "observer" ? null : providerB)); - mockOAuthProviderOperations({ - buildApiKey, - refreshCredential, - canRefreshCredential: async () => { - if (phase === "compare-and-swap") { - saveCredential(agentDir, profileId, providerB); - } - return true; - }, - }); - const manager = createOAuthManager(); - - if (phase === "pending") { - const load = authProfileStoreRuntime.loadAuthProfileStoreWithoutExternalProfiles; - let reads = 0; - vi.spyOn( - authProfileStoreRuntime, - "loadAuthProfileStoreWithoutExternalProfiles", - ).mockImplementation((...args) => { - const store = load(...args); - const credential = store.profiles[profileId]; - if ( - credential?.type === "oauth" && - credential.provider === "provider-b" && - isPendingOAuthRefreshFence(credential) && - ++reads > 1 - ) { - throw new Error("provider-B fence must not be observed"); - } - return store; + : createOAuthRefreshFence({ profileId, credential: attempted }); + const refresh = vi.fn(async () => (phase === "observer" ? null : foreign)); + const resolve = vi.fn(async (credential: OAuthCredential) => credential.access); + let reads = 0; + const canRefresh = async (replace: (credential: OAuthCredential) => void) => { + if (phase === "compare-and-swap") { + replace(foreign); + } + return true; + }; + if (backendKind === "manager") { + const replace = (credential: OAuthCredential) => + saveCredential(agentDir, profileId, credential); + replace(initial); + mockOAuthProviderOperations({ + buildApiKey: async (_provider, credential) => resolve(credential), + refreshCredential: refresh, + canRefreshCredential: () => canRefresh(replace), }); - } else if (phase === "observer") { - const load = authProfileStoreRuntime.loadAuthProfileStoreWithoutExternalProfilesAsync; - let ownReads = 0; - let foreignReads = 0; - vi.spyOn( - authProfileStoreRuntime, - "loadAuthProfileStoreWithoutExternalProfilesAsync", - ).mockImplementation(async (...args) => { - const store = await load(...args); - const credential = store.profiles[profileId]; - if (credential?.type === "oauth" && credential.provider === "openai") { - if (++ownReads === 2) { - saveCredential(agentDir, profileId, foreignFence); + if (phase === "observer") { + const load = authProfileStoreRuntime.loadAuthProfileStoreWithoutExternalProfilesAsync; + vi.spyOn( + authProfileStoreRuntime, + "loadAuthProfileStoreWithoutExternalProfilesAsync", + ).mockImplementation(async (...args) => { + const store = await load(...args); + const credential = store.profiles[profileId]; + if ( + credential?.type === "oauth" && + credential.provider === "openai" && + ++reads === 2 + ) { + replace(foreignFence); } - } else if ( - credential?.type === "oauth" && - credential.provider === "provider-b" && - isPendingOAuthRefreshFence(credential) && - ++foreignReads > 1 - ) { - throw new Error("provider-B fence observation continued"); - } - return store; - }); + return store; + }); + } + await expect( + createOAuthManager().resolveOAuthAccess({ + agentDir, + credential: attempted, + profileId, + store: + phase === "compare-and-swap" + ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir) + : profileStore(attempted, profileId), + }), + ).resolves.toBeNull(); + if (phase === "compare-and-swap") { + expect(readProfile(agentDir, profileId)).toEqual(foreign); + } + } else { + const { backend, set } = serializedBackend( + initial, + { + after: () => { + if (++reads === 1 && phase === "observer") { + set(foreignFence); + } + }, + }, + profileId, + ); + await expect( + refreshSerializedOAuthCredential({ + backend, + provider: "openai", + profileId, + label: "test serialized provider ownership", + timeoutMs: 1_000, + parse: (current) => JSON.parse(current ?? "{}") as Record, + serialize: JSON.stringify, + readCredential: (data) => data[profileId], + writeCredential: (data, credential) => ({ ...data, [profileId]: credential }), + canRefresh: () => canRefresh(set), + refresh: async () => { + const credential = await refresh(); + return credential ? { apiKey: credential.access, credential } : null; + }, + resolve: async (credential) => ({ apiKey: await resolve(credential), credential }), + commit: () => {}, + }), + ).resolves.toBeNull(); } - - const store = - phase === "compare-and-swap" - ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir) - : profileStore(attempted, profileId); - await expect( - manager.resolveOAuthAccess({ agentDir, credential: attempted, store, profileId }), - ).resolves.toBeNull(); - expect(buildApiKey).not.toHaveBeenCalled(); - if (phase !== "observer") { - expect(refreshCredential).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + if (backendKind === "serialized" || phase !== "observer") { + expect(refresh).not.toHaveBeenCalled(); } - if (phase === "compare-and-swap") { - expect(readProfile(agentDir, profileId)).toEqual(providerB); - } - }); - }, - ); - it.each(["pending initial fence", "post-canRefresh replacement", "observer fence replacement"])( - "rejects a serialized provider change from a %s", - async (phase) => { - const profileId = "openai:default"; - const foreign = createCredential({ provider: "provider-b", expires: 1 }); - const foreignFence = createOAuthRefreshFence({ profileId, credential: foreign }); - const initial = - phase === "pending initial fence" - ? foreignFence - : phase === "observer fence replacement" - ? createOAuthRefreshFence({ profileId, credential: createCredential({ expires: 1 }) }) - : createCredential({ expires: 1 }); - let calls = 0; - const { backend, set } = serializedBackend( - initial, - { - before: () => { - calls += 1; - if ( - (phase === "pending initial fence" && calls > 1) || - (phase === "observer fence replacement" && calls > 2) - ) { - throw new Error("provider-B fence must not be observed"); - } - }, - after: () => { - if (phase === "observer fence replacement" && calls === 1) { - set(foreignFence); - } - }, - }, - profileId, - ); - const refreshed = providerBCredential(); - const refresh = vi.fn(async () => ({ apiKey: refreshed.access, credential: refreshed })); - const resolve = vi.fn(async (credential: OAuthCredential) => ({ - apiKey: credential.access, - credential, - })); - await expect( - refreshSerializedOAuthCredential({ - backend, - provider: "openai", - profileId, - label: "test serialized provider ownership", - timeoutMs: 1_000, - parse: (current) => JSON.parse(current ?? "{}") as Record, - serialize: JSON.stringify, - readCredential: (data) => data[profileId], - writeCredential: (data, credential) => ({ ...data, [profileId]: credential }), - canRefresh: async () => { - if (phase === "post-canRefresh replacement") { - set(createCredential({ provider: "provider-b", expires: Date.now() + 600_000 })); - } - return true; - }, - refresh, - resolve, - commit: () => {}, - }), - ).resolves.toBeNull(); - expect(refresh).not.toHaveBeenCalled(); - expect(resolve).not.toHaveBeenCalled(); - }, - ); - it("runs provider I/O outside custom backend locks and fences peer retries", async () => { - const providerId = "test-oauth"; - let lockDepth = 0; - const { backend, read } = serializedBackend( - createCredential({ - provider: providerId, - access: "claimed-access", - refresh: "claimed-refresh", - expires: 1, - accountId: "acct-123", }), - { - before: () => { - expect(lockDepth).toBe(0); - lockDepth += 1; - }, - after: () => { - lockDepth -= 1; - }, - }, + ); + }); + it("hides custom-backend refresh claims and shares their settled credential with peers", async () => { + const providerId = "test-oauth"; + const { backend, read } = serializedBackend( + createCredential({ provider: providerId, expires: 1, accountId: "acct-123" }), ); const started = createDeferredCore(); const refresh = createDeferredCore<{ access: string; refresh: string; expires: number }>(); const refreshToken = vi.fn(() => { - expect(lockDepth).toBe(0); started.resolve(); return refresh.promise; }); @@ -371,8 +309,8 @@ describe("AuthStorage OAuth refresh ownership", () => { await started.promise; expect(read()).toContain("openclaw-oauth-refresh-fence:v1:"); - expect(read()).not.toContain("claimed-access"); - expect(read()).not.toContain("claimed-refresh"); + expect(read()).not.toContain("access-token"); + expect(read()).not.toContain("refresh-token"); expect(storage.get(providerId)).toBeUndefined(); expect(storage.has(providerId)).toBe(false); expect(storage.list()).not.toContain(providerId); @@ -401,12 +339,7 @@ describe("AuthStorage OAuth refresh ownership", () => { it("preserves an expired credential when no refresh owner exists", async () => { const providerId = "unowned-oauth"; - const original = createCredential({ - provider: providerId, - access: "unowned-access", - refresh: "unowned-refresh", - expires: 1, - }); + const original = createCredential({ provider: providerId, expires: 1 }); const { backend, read } = serializedBackend(original); const storage = AuthStorage.fromStorage(backend); @@ -417,12 +350,7 @@ describe("AuthStorage OAuth refresh ownership", () => { it("does not replay a failed generation and allows environment fallback after restart", async () => { const providerId = "xai"; const { backend, read } = serializedBackend( - createCredential({ - provider: providerId, - access: "failed-access", - refresh: "failed-refresh", - expires: 1, - }), + createCredential({ provider: providerId, expires: 1 }), ); const refreshToken = vi.fn(async () => { throw new Error("simulated provider rejection"); @@ -439,8 +367,8 @@ describe("AuthStorage OAuth refresh ownership", () => { await expect(restarted.getApiKey(providerId)).resolves.toBe("environment-fallback"); expect(refreshToken).toHaveBeenCalledTimes(1); expect(read()).toContain("openclaw-oauth-refresh-fence:v1:"); - expect(read()).not.toContain("failed-access"); - expect(read()).not.toContain("failed-refresh"); + expect(read()).not.toContain("access-token"); + expect(read()).not.toContain("refresh-token"); } finally { vi.unstubAllEnvs(); } @@ -479,13 +407,7 @@ describe("AuthStorage OAuth publication", () => { ) { queued = true; queueMicrotask(() => { - if (change === "logout") { - mutate.logout(providerId); - } else if (change === "replace") { - mutate.set(providerId, replacement); - } else if (change === "unrelated") { - mutate.set("other", { type: "api_key", key: "synthetic-other-new" }); - } + changeCredential(mutate, providerId, change, replacement); }); } } @@ -497,8 +419,7 @@ describe("AuthStorage OAuth publication", () => { const expected = change === "logout" ? undefined : change === "replace" ? replacement : refreshed; expect(apiKey).toBe(expected?.access); - expect(storage.get(providerId)).toEqual(expected); - expect(durable?.[profileId]).toEqual(expected); + expectPublished(storage, agentDir, providerId, expected); expect(refreshToken).toHaveBeenCalledTimes( phase === "claim" && (change === "logout" || change === "replace") ? 0 : 1, ); @@ -545,11 +466,7 @@ describe("AuthStorage OAuth publication", () => { } else if (!changeQueued && durable.access === refreshed.access) { changeQueued = true; queueMicrotask(() => { - if (change === "logout") { - peer.logout(providerId); - } else if (change === "replace") { - peer.set(providerId, replacement); - } + changeCredential(peer, providerId, change, replacement); }); } } @@ -562,8 +479,7 @@ describe("AuthStorage OAuth publication", () => { const expected = change === "logout" ? undefined : change === "replace" ? replacement : refreshed; expect(apiKey).toBe(expected?.access); - expect(storage.get(providerId)).toEqual(expected); - expect(readProfile(agentDir, profileId)).toEqual(expected); + expectPublished(storage, agentDir, providerId, expected); }), ); @@ -606,8 +522,7 @@ describe("AuthStorage OAuth publication", () => { const durable = readProfile(agentDir, profileId); if (replace) { expect(apiKey).toBe(replacement.access); - expect(storage.get(providerId)).toEqual(replacement); - expect(durable).toEqual(replacement); + expectPublished(storage, agentDir, providerId, replacement); } else { expect(apiKey).toBeUndefined(); expect(storage.get(providerId)).toBeUndefined(); @@ -989,18 +904,12 @@ describe("SQLite auth storage", () => { const right = AuthStorage.forAgent(agentDir); const refreshEntered = createDeferredCore(); const releaseRefresh = createDeferredCore(); - let refreshCalls = 0; - let activeRefreshes = 0; - let maxActiveRefreshes = 0; - const provider = oauthProvider("test-oauth", async () => { - refreshCalls += 1; - activeRefreshes += 1; - maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes); + const refresh = vi.fn(async () => { refreshEntered.resolve(); await releaseRefresh.promise; - activeRefreshes -= 1; return freshOAuth(); }); + const provider = oauthProvider("test-oauth", refresh); getAuthStorageOAuthProviderRegistry(left).register(provider); getAuthStorageOAuthProviderRegistry(right).register(provider); @@ -1036,7 +945,7 @@ describe("SQLite auth storage", () => { } finally { releaseRefresh.resolve(); } - expect([refreshCalls, maxActiveRefreshes]).toEqual([1, 1]); + expect(refresh).toHaveBeenCalledOnce(); await expect(right.getApiKey("test-oauth")).resolves.toBe("fake-fresh-access"); }); diff --git a/src/agents/sessions/model-registry.test.ts b/src/agents/sessions/model-registry.test.ts index c6cc7087f8a4..0e1a78eb1630 100644 --- a/src/agents/sessions/model-registry.test.ts +++ b/src/agents/sessions/model-registry.test.ts @@ -36,6 +36,10 @@ const providerConfig = (models: unknown[], config: Record = {}) function catalog(providers: Record) { return { generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, providers }; } +const capturedCatalog = (pluginId: string, providers: Record) => ({ + pluginId, + contents: JSON.stringify(catalog(providers)), +}); const authResult = (apiKey?: string, headers?: Record) => ({ ok: true, @@ -58,14 +62,7 @@ describe("ModelRegistry", () => { const { writeModelsJson, writeModelsJsonWithPluginCatalog, writeModelsJsonWithPluginCatalogs } = installModelRegistryTestFixtures(); const writeRoot = (providers: Record = {}) => writeModelsJson({ providers }); - const nvidia = { baseUrl: "https://integrate.api.nvidia.com/v1", apiKey: "NVIDIA_API_KEY" }; - - const zai = { - baseUrl: "https://api.z.ai/api/paas/v4", - api: "openai-completions", - apiKey: "ZAI_API_KEY", - models: [{ id: "glm-5.1", name: "GLM 5.1" }], - }; + const zai = providerConfig([{ id: "glm-5.1", name: "GLM 5.1" }]); const custom = providerConfig([{ id: "authored-model", name: "Authored Model" }], { apiKey: "authored-provider-test-key", }); @@ -95,17 +92,6 @@ describe("ModelRegistry", () => { }); const ownerSnapshot = (...ids: string[]) => pluginOwnerSnapshotEntries(ids.map((id) => ({ providerId: id, pluginId: id }))); - async function generatedRegistry( - providers: Record, - auth = AuthStorage.inMemory(), - ) { - return createRegistry( - await writeCatalogs(providers), - { pluginMetadataSnapshot: ownerSnapshot(...Object.keys(providers)) }, - auth, - ); - } - function oauthProviderConfig(name: string, apiKeyPrefix: string): ProviderConfigInput { const credentials = () => ({ access: "test-token-placeholder", @@ -148,18 +134,16 @@ describe("ModelRegistry", () => { }); it("accepts Bedrock AWS SDK auth without apiKey", async () => { - const modelsPath = writeRoot({ - "amazon-bedrock": providerConfig([{ id: "bedrock-model" }], { - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - api: "bedrock-converse-stream", - auth: "aws-sdk", + const registry = createRegistry( + writeRoot({ + "amazon-bedrock": providerConfig([{ id: "bedrock-model" }], { + api: "bedrock-converse-stream", + auth: "aws-sdk", + }), }), - }); - - const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath); + ); const model = registry.find("amazon-bedrock", "bedrock-model"); - expect(registry.getError()).toBeUndefined(); expect(registry.getAvailable()).toEqual([model]); await expect(registry.getApiKeyAndHeaders(model!)).resolves.toEqual(authResult()); expect(registry.getProviderAuthStatus("amazon-bedrock")).toEqual({ @@ -170,12 +154,16 @@ describe("ModelRegistry", () => { }); it("forks a catalog with request-isolated auth and provider mutations", async () => { - const template = createRegistry(exampleCatalog()); + const template = createRegistry(exampleCatalog("test-token-placeholder")); const firstAuth = AuthStorage.inMemory(); const secondAuth = AuthStorage.inMemory(); const first = template.fork(firstAuth); const second = template.fork(secondAuth); + await expect(first.getApiKeyForProvider("custom")).resolves.toBe("test-token-placeholder"); + await expect( + first.getApiKeyAndHeaders(first.find("custom", "example-model")!), + ).resolves.toEqual(authResult("test-token-placeholder")); firstAuth.setRuntimeApiKey("custom", "first-runtime-key"); secondAuth.setRuntimeApiKey("custom", "second-runtime-key"); first.registerProvider("first-only", oauthProviderConfig("First only", "first")); @@ -197,17 +185,6 @@ describe("ModelRegistry", () => { expect(first.find("custom", "example-model")).toBeDefined(); }); - it("preserves models.json provider auth in a catalog fork", async () => { - const template = createRegistry(exampleCatalog("test-token-placeholder")); - const fork = template.fork(AuthStorage.inMemory()); - const model = fork.find("custom", "example-model"); - - await expect(fork.getApiKeyForProvider("custom")).resolves.toBe("test-token-placeholder"); - await expect(fork.getApiKeyAndHeaders(model!)).resolves.toEqual( - authResult("test-token-placeholder"), - ); - }); - it("skips native presence while resolving configured HTTP credentials", async () => { const auth = AuthStorage.inMemory({ custom: { @@ -219,8 +196,6 @@ describe("ModelRegistry", () => { const registry = createRegistry(exampleCatalog("synthetic-configured-api-key"), {}, auth); const model = registry.find("custom", "example-model")!; - expect(auth.hasAuth("custom")).toBe(true); - await expect(auth.getApiKey("custom", { includeFallback: false })).resolves.toBeUndefined(); await expect(registry.getApiKeyForProvider("custom")).resolves.toBe( "synthetic-configured-api-key", ); @@ -250,52 +225,36 @@ describe("ModelRegistry", () => { parameterCount: 4_000_000_000, family: "meta", maxTokens: 128_000, + contextWindow: 1_000_000, + contextTokens: 872_000, + }; + const config: ProviderConfigInput = { + api: "openai-responses", + baseUrl: "https://models.example/v1", + models: [{ ...model, input: [...model.input] }], }; - const windows = [ - ["fixture-primary", 1_000_000, 872_000], - ["fixture-secondary", 1_050_000, 922_000], - ] as const; - const providers: Record = Object.fromEntries( - windows.map(([provider, contextWindow, contextTokens]) => [ - provider, - { - api: "openai-responses", - baseUrl: "https://models.example/v1", - models: [{ ...model, input: [...model.input], contextWindow, contextTokens }], - }, - ]), - ); const registry = source === "persisted" - ? createRegistry(writeRoot(providers)) + ? createRegistry(writeRoot({ fixture: config })) : ModelRegistry.inMemory(AuthStorage.inMemory()); if (source === "registered") { - for (const [provider, config] of Object.entries(providers)) { - registry.registerProvider(provider, config); - } + registry.registerProvider("fixture", config); } expect(registry.getError()).toBeUndefined(); for (const candidate of [registry, registry.fork(AuthStorage.inMemory())]) { - for (const [provider, config] of Object.entries(providers)) { - expect(candidate.find(provider, "shared-model")).toMatchObject(config.models![0]!); - } + expect(candidate.find("fixture", model.id)).toMatchObject(model); } }); it("drops a malformed persisted family without rejecting the catalog", () => { const registry = createRegistry( writeRoot({ - "family-fixture": { - api: "openai-responses", - baseUrl: "https://models.example/v1", - models: [ - { id: "spaced", family: "Open AI" }, - { id: "cased", family: " Meta " }, - ], - }, + "family-fixture": providerConfig([ + { id: "spaced", family: "Open AI" }, + { id: "cased", family: " Meta " }, + ]), }), ); - expect(registry.getError()).toBeUndefined(); expect(registry.find("family-fixture", "spaced")).not.toHaveProperty("family"); expect(registry.find("family-fixture", "cased")?.family).toBe("meta"); }); @@ -314,7 +273,6 @@ describe("ModelRegistry", () => { ), }); const pluginCatalogs = loadPersistedPluginModelCatalogsReadOnly(dirname(modelsPath)); - expect(pluginCatalogs).toHaveLength(1); const stored = JSON.parse(pluginCatalogs[0]!.contents).providers[provider].models; for (const id of ["unknown", "prompt-only"]) { expect(stored.find((row: { id: string }) => row.id === id)).not.toHaveProperty( @@ -327,15 +285,15 @@ describe("ModelRegistry", () => { pluginMetadataSnapshot: pluginOwnerSnapshot(provider, provider), }); for (const candidate of [registry, registry.fork(AuthStorage.inMemory())]) { - for (const [id, expected] of [ - ["unknown", { contextWindow: 128_000 }], - ["prompt-only", { contextWindow: 777_000, contextTokens: 777_000 }], - ] as const) { - expect(candidate.find(provider, id)).toMatchObject({ - ...expected, - contextWindowSource: "synthetic", - }); - } + expect(candidate.find(provider, "unknown")).toMatchObject({ + contextWindow: 128_000, + contextWindowSource: "synthetic", + }); + expect(candidate.find(provider, "prompt-only")).toMatchObject({ + contextWindow: 777_000, + contextTokens: 777_000, + contextWindowSource: "synthetic", + }); for (const [id, window] of [ ["reported", 128_000], ["authored", 64_000], @@ -350,21 +308,14 @@ describe("ModelRegistry", () => { it("loads only lifecycle-captured generated catalogs when the root catalog is absent", () => { const modelsPath = writeRoot(); rmSync(modelsPath); - const capturedCatalog = { - pluginId: "zai", - contents: JSON.stringify(catalog({ zai: { ...zai, apiKey: undefined } })), - }; - const pluginMetadataSnapshot = ownerSnapshot("zai", "other"); const registry = createRegistry(modelsPath, { - includePluginCatalogs: true, modelsJsonContents: null, - pluginCatalogs: [capturedCatalog], + pluginCatalogs: [capturedCatalog("zai", { zai })], pluginMetadataSnapshot, }); const fork = registry.fork(AuthStorage.inMemory()); - expect(registry.getError()).toBeUndefined(); expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1"); expect(registry.find("other", "unrelated-model")).toBeUndefined(); expect(registry.getProviderMetadataOwners()).toBe(pluginMetadataSnapshot.owners); @@ -382,73 +333,38 @@ describe("ModelRegistry", () => { }); it("loads richer generated catalog metadata without widening runtime inputs", async () => { - const registry = await generatedRegistry( - { - minimax: providerConfig([{ id: "video", input: ["text", "image", "video"] }], { - baseUrl: "https://api.minimaxi.com/v1", - apiKey: "MINIMAX_API_KEY", - }), - nvidia: providerConfig( - [ - { id: "audio", input: ["text", "image", "audio"] }, - { id: "audio-only", input: ["audio"] }, - { id: "explicit-empty", input: [] }, - ], - nvidia, - ), - }, - AuthStorage.inMemory({ - minimax: { type: "api_key", key: "sk-minimax" }, - nvidia: { type: "api_key", key: "sk-nvidia" }, - }), + const modelsPath = await writeCatalogs({ + fixture: providerConfig([ + { id: "mixed", input: ["text", "image", "video", "audio"] }, + { id: "unsupported", input: ["audio"] }, + { id: "empty", input: [] }, + ]), + }); + const registry = createRegistry( + modelsPath, + { pluginMetadataSnapshot: ownerSnapshot("fixture") }, + AuthStorage.inMemory({ fixture: { type: "api_key", key: "fixture-key" } }), ); - - expect(registry.getError()).toBeUndefined(); - expect(registry.find("minimax", "video")?.input).toEqual(["text", "image"]); - expect(registry.find("nvidia", "audio")?.input).toEqual(["text", "image"]); - expect(registry.find("nvidia", "audio-only")).toBeUndefined(); - expect(registry.find("nvidia", "explicit-empty")?.input).toEqual([]); - const availableRefs = registry.getAvailable().map((model) => `${model.provider}/${model.id}`); - expect(availableRefs).not.toContain("nvidia/audio-only"); - expect(availableRefs).toContain("nvidia/explicit-empty"); + expect(registry.find("fixture", "mixed")?.input).toEqual(["text", "image"]); + expect(registry.find("fixture", "unsupported")).toBeUndefined(); + expect(registry.find("fixture", "empty")?.input).toEqual([]); + const availableIds = registry.getAvailable().map((model) => model.id); + expect(availableIds).not.toContain("unsupported"); + expect(availableIds).toContain("empty"); }); it("isolates invalid persisted plugin catalogs from valid models", async () => { - const modelsPath = await writeModelsJsonWithPluginCatalogs({ - root: { - providers: { - custom: providerConfig([{ id: "root-model", name: "Root Model" }], { - api: "openai-responses", - apiKey: "CUSTOM_API_KEY", - }), - }, - }, - pluginCatalogs: [ - pluginCatalog("google", { - "google-vertex": { - baseUrl: "https://us-central1-aiplatform.googleapis.com/v1", - api: "google-vertex", - apiKey: "GOOGLE_API_KEY", - models: [{ id: "gemini-3.1-pro-preview", contextWindow: 0 }], - }, - }), - pluginCatalog("zai", { zai }), - ], - }); - - const registry = createRegistry(modelsPath, { - pluginMetadataSnapshot: pluginOwnerSnapshotEntries([ - { providerId: "google-vertex", pluginId: "google" }, - { providerId: "zai", pluginId: "zai" }, - ]), - }); - - expect(registry.getError()).toContain( - "Provider google-vertex, model gemini-3.1-pro-preview: invalid contextWindow", + const modelsPath = await writeCatalogs( + { invalid: providerConfig([{ id: "invalid", contextWindow: 0 }]), zai }, + { providers: { custom } }, ); - expect(registry.find("custom", "root-model")?.name).toBe("Root Model"); + const registry = createRegistry(modelsPath, { + pluginMetadataSnapshot: ownerSnapshot("invalid", "zai"), + }); + expect(registry.getError()).toContain("Provider invalid, model invalid: invalid contextWindow"); + expect(registry.find("custom", "authored-model")?.name).toBe("Authored Model"); expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1"); - expect(registry.find("google-vertex", "gemini-3.1-pro-preview")).toBeUndefined(); + expect(registry.find("invalid", "invalid")).toBeUndefined(); }); it.each([ @@ -610,16 +526,10 @@ describe("ModelRegistry", () => { if (process.getuid?.() === 0) { return; } - const modelsPath = await writeModelsJsonWithPluginCatalog({ - root: { providers: { custom } }, - ...pluginCatalog("anthropic", { - anthropic: providerConfig([{ id: "healthy-model", name: "Healthy Model" }], { - baseUrl: "https://anthropic.example/v1", - api: "anthropic-messages", - apiKey: "healthy-provider-test-key", - }), - }), - }); + const modelsPath = await writeCatalogs( + { anthropic: providerConfig([{ id: "healthy-model", name: "Healthy Model" }]) }, + { providers: { custom } }, + ); const sourcePath = join(dirname(modelsPath), "plugins", "zai", PLUGIN_MODEL_CATALOG_FILE); mkdirSync(dirname(sourcePath), { recursive: true }); writeFileSync( @@ -666,24 +576,19 @@ describe("ModelRegistry source composition", () => { vercelGatewayRouting: { order: ["provider-default"], only: ["shared-route"] }, }; const authoredHeaders = { "X-Authored-Provider": "root", "X-Authored-Model": "root" }; - const authored = { - api: "openai-completions", - baseUrl: rootUrl, - apiKey: "authored-fixture-key", - headers: { "X-Authored-Provider": "root" }, - models: [ + const authored = providerConfig( + [ { id: "shared", name: "Authored", maxTokens: 2048, headers: { "X-Authored-Model": "root" } }, { id: "Shared", name: "Case-distinct authored model" }, ], - }; - const generated = { - api: "openai-completions", - baseUrl: catalogUrl, - apiKey: "cached-fixture-key", - auth: "aws-sdk", - authHeader: true, - headers: { "X-Cached-Provider": "cache" }, - models: [ + { + baseUrl: rootUrl, + apiKey: "authored-fixture-key", + headers: { "X-Authored-Provider": "root" }, + }, + ); + const generated = providerConfig( + [ { id: "shared", name: "Generated", @@ -693,7 +598,14 @@ describe("ModelRegistry source composition", () => { }, { id: "generated-only", maxTokens: 4096 }, ], - }; + { + baseUrl: catalogUrl, + apiKey: "cached-fixture-key", + auth: "aws-sdk", + authHeader: true, + headers: { "X-Cached-Provider": "cache" }, + }, + ); const getAuth = (registry: ModelRegistry, id = "shared", owner = provider) => registry.getApiKeyAndHeaders(registry.find(owner, id)!); @@ -719,12 +631,7 @@ describe("ModelRegistry source composition", () => { root === null ? null : { [options.authoredProvider ?? provider]: root }, AuthStorage.inMemory(options.credentials ?? {}), { - pluginCatalogs: [ - { - pluginId, - contents: JSON.stringify(catalog({ [provider]: cached })), - }, - ], + pluginCatalogs: [capturedCatalog(pluginId, { [provider]: cached })], pluginMetadataSnapshot: pluginOwnerSnapshot(provider, pluginId), }, ); @@ -867,7 +774,7 @@ describe("ModelRegistry source composition", () => { ...authored, api, authHeader: true, - models: [{ ...authored.models[0], baseUrl: catalogUrl }], + models: [{ id: "shared", baseUrl: catalogUrl, headers: { "X-Authored-Model": "root" } }], }, generated: { api: "openai-completions", @@ -930,31 +837,27 @@ describe("ModelRegistry no-auth", () => { params.storedKey ? { custom: { type: "api_key", key: "stored-key" } } : {}, ); const getApiKey = vi.spyOn(authStorage, "getApiKey"); - const modelConfig = { - id: "model", - api: params.modelApi ?? "openai-completions", - headers: { authorization: params.authorization ?? "Bearer model", "X-Model": "model" }, - }; - const provider = { - baseUrl: "https://inference.example.invalid/v1", - api: params.providerApi ?? "openai-completions", - auth: params.auth, - ...(params.inlineKey === false ? {} : { apiKey: "inline-key" }), - authHeader: true, - headers: { - Authorization: params.authorization ?? "Bearer provider", - "X-Provider": "provider", + const provider = providerConfig( + [ + { + id: "model", + api: params.modelApi ?? "openai-completions", + headers: { authorization: params.authorization ?? "Bearer model", "X-Model": "model" }, + }, + ], + { + api: params.providerApi ?? "openai-completions", + auth: params.auth, + ...(params.inlineKey === false ? {} : { apiKey: "inline-key" }), + authHeader: true, + headers: { + Authorization: params.authorization ?? "Bearer provider", + "X-Provider": "provider", + }, }, - models: [modelConfig], - }; - const registry = capturedRegistry({ custom: provider }, authStorage, { - pluginCatalogs: [], - includePluginCatalogs: false, - }); - const model = registry.find("custom", "model"); - if (!model) { - throw new Error(registry.getError() ?? "Expected custom model"); - } + ); + const registry = capturedRegistry({ custom: provider }, authStorage); + const model = registry.find("custom", "model")!; return { registry, model, getApiKey }; } @@ -962,8 +865,8 @@ describe("ModelRegistry no-auth", () => { "keeps registry no-auth terminal with provider adapter %s and an effective Completions route", async (providerApi) => { const { registry, model, getApiKey } = noAuthRegistry({ - providerApi, auth: "none", + providerApi, storedKey: true, }); const requestModel = { diff --git a/src/agents/sessions/sdk.auth-migration.test.ts b/src/agents/sessions/sdk.auth-migration.test.ts index 96a44fc1ae2a..15e6e7a43c19 100644 --- a/src/agents/sessions/sdk.auth-migration.test.ts +++ b/src/agents/sessions/sdk.auth-migration.test.ts @@ -12,10 +12,7 @@ import { replaceSessionEntry, } from "../../config/sessions/session-accessor.js"; import * as contextWorker from "../../config/sessions/session-transcript-read-worker-runtime.js"; -import { - getOwnedSessionTranscriptWriterFence, - SessionTranscriptWriterClaimReboundError, -} from "../../config/sessions/transcript-write-context.js"; +import { SessionTranscriptWriterClaimReboundError } from "../../config/sessions/transcript-write-context.js"; import { snapshotFiles } from "../../infra/state-migrations.caller-mode.test-helpers.js"; import { autoMigrateLegacyState } from "../../infra/state-migrations.doctor.js"; import { inspectLegacyAgentDir } from "../../infra/state-migrations.legacy-sessions.js"; @@ -24,7 +21,6 @@ import { createDeferredCore } from "../../shared/deferred.js"; import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.js"; import { closeOpenClawAgentDatabaseByPathAsync, - inspectOpenClawAgentDatabaseOwner, openOpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; import { runOpenClawAgentWorkerWrite } from "../../state/openclaw-agent-write-admission.js"; @@ -60,7 +56,6 @@ import { createAgentSession } from "./sdk.js"; import { sessionManagerReadInitialContext } from "./session-manager-current-turn.js"; import { SessionMetadataCommittedError } from "./session-manager-metadata-error.js"; import { sessionManagerAppendInitialMetadata } from "./session-manager-metadata.js"; -import type { ModelChangeEntry, ThinkingLevelChangeEntry } from "./session-manager-types.js"; import * as writeAdmission from "./session-manager-write-admission.js"; import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; @@ -101,10 +96,6 @@ describe("SDK credential migration", () => { expect(() => assertAuthProfileMigrationReady(ownerDir)).toThrow(migrationMessage); } - function expectKey(actual: string | undefined, expected: string) { - expect(actual === expected, "selected credential belongs to the expected owner").toBe(true); - } - async function stageLegacy( state: OpenClawTestState, legacy: AuthProfileStore, @@ -156,14 +147,8 @@ describe("SDK credential migration", () => { await writeFile(path.join(legacyDir, "bin", "fd"), "installed SDK tool"); openOpenClawAgentDatabase({ agentId: "main", env: state.env, path: databasePath }); await closeOpenClawAgentDatabaseByPathAsync(databasePath); - // The legacy standalone SDK fixture predates registry enrollment. unregisterOpenClawAgentDatabase({ agentId: "main", env: state.env, path: databasePath }); - expect(inspectOpenClawAgentDatabaseOwner(databasePath)).toEqual({ - status: "owned", - agentId: "main", - }); - // Payload inspection materializes native WAL sidecars before the snapshot. - expect(inspectLegacyAgentDir(legacyDir)).toEqual({ status: "payload" }); + inspectLegacyAgentDir(legacyDir); // Materialize WAL sidecars before taking the snapshot. const before = snapshotFiles(legacyDir); const cfg = { agents: { entries: { worker: {} } }, plugins: { enabled: false } }; await state.writeConfig(cfg); @@ -173,7 +158,6 @@ describe("SDK credential migration", () => { doctorOnlyStateMigrations: true, legacySessionSurfaces: EMPTY_LEGACY_SESSION_SURFACES, }); - expect(result.stepReceipts.find((entry) => entry.outcome === "refused")).toBeUndefined(); const receipt = result.stepReceipts.find((entry) => entry.id === "agent-dir"); expect(receipt).toMatchObject({ outcome: "deferred", @@ -185,9 +169,6 @@ describe("SDK credential migration", () => { path: legacyDir, }, ], - warnings: expect.arrayContaining([ - expect.stringContaining("Keep using the existing store"), - ]), }); expect(snapshotFiles(legacyDir)).toEqual(before); await expect( @@ -215,12 +196,9 @@ describe("SDK credential migration", () => { }); const model = makeProviderModelFixture({ id: "synthetic-model", - name: "Synthetic model", api: "openai-completions", provider: "arcee", baseUrl: modelUrl, - contextWindow: 1000, - maxTokens: 1000, }); const runtimeConfig = getRuntimeConfig(); const manager = SessionManager.inMemory(); @@ -247,16 +225,14 @@ describe("SDK credential migration", () => { const result = Promise.resolve(stream(model, { messages: [] }, {})); if (blocked) { await expect(result).rejects.toThrow(migrationMessage); + expect(providerIo).not.toHaveBeenCalled(); } else { await result; - } - expect(providerIo).toHaveBeenCalledTimes(blocked ? 0 : 1); - if (!blocked) { - const auth = await session.modelRegistry.getApiKeyAndHeaders(model); - expect( - auth.ok && auth.apiKey === credential, - "direct account credential selected", - ).toBe(true); + expect(providerIo).toHaveBeenCalledOnce(); + expect(await session.modelRegistry.getApiKeyAndHeaders(model)).toMatchObject({ + ok: true, + apiKey: credential, + }); } } finally { session.dispose(); @@ -317,7 +293,7 @@ describe("SDK credential migration", () => { "requires the active secrets runtime to materialize SecretRef credentials", ); } else { - expectKey(await resolve(), provider === "arcee" ? localKey : unaffectedKey); + expect(await resolve()).toBe(unaffectedKey); } expect(fallback).not.toHaveBeenCalled(); }, @@ -349,7 +325,7 @@ describe("SDK credential migration", () => { expect(fallback).not.toHaveBeenCalled(); clearAuthProfileMigrationDiagnostics(); storage.reload(); - expectKey(await storage.getApiKey("arcee", { baseUrl }), importedKey); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(importedKey); if (owner === "shared") { const localKey = "synthetic-new-local-key"; storage.set("arcee", { type: "api_key", key: localKey }); @@ -358,7 +334,7 @@ describe("SDK credential migration", () => { await state.writeJson(legacyPath, legacy); await refreshAuthProfileFileState(); expectMigrationRequired(); - expectKey(await storage.getApiKey("arcee", { baseUrl }), localKey); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(localKey); } }); }, @@ -372,7 +348,7 @@ describe("SDK credential migration", () => { await importLegacy(state, legacy); writeStore(legacy, agentDir); const { storage, fallback } = authWithFallback(agentDir); - expectKey(await storage.getApiKey("arcee", { baseUrl }), key); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(key); expect(fallback).not.toHaveBeenCalled(); }, { UNRESOLVED_IMPORTED_ARCEE: undefined }, @@ -422,7 +398,7 @@ describe("SDK credential migration", () => { }); await expect(storage.getApiKey("arcee", { baseUrl })).rejects.toMatchObject(migrationError); expect(reloadedSelectedCredential).toBe(true); - expectKey(await storage.getApiKey("arcee", { baseUrl }), localKey); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(localKey); }); }); @@ -443,7 +419,7 @@ describe("SDK credential migration", () => { if (blocked) { await expect(result).rejects.toMatchObject(migrationError); } else { - expect((await result) === key, "unrelated canonical credential remains usable").toBe(true); + expect(await result).toBe(key); } }); }); @@ -488,16 +464,9 @@ function registryFor(...models: (typeof testModel)[]) { registry.registerProvider(testModel.provider, { api: testModel.api, baseUrl: testModel.baseUrl, - models: models.map(({ id, name, api, reasoning, input, cost, contextWindow, maxTokens }) => ({ - id, - name, - api, - reasoning, - input, - cost, - contextWindow: contextWindow ?? 32_768, - maxTokens, - })), + models: models.map((model) => + Object.assign({}, model, { contextWindow: model.contextWindow ?? 32_768 }), + ), }); return registry; } @@ -573,23 +542,15 @@ it.each(["metadata", "context loading"] as const)( const replacementBefore = await loadTranscriptEvents(replacement); const originalBefore = await loadTranscriptEvents(original); const manager = SessionManager.open(original, state.workspaceDir); - const completed: { - entry?: ModelChangeEntry | ThinkingLevelChangeEntry; - records?: Awaited>; - watermark?: ReturnType; - } = {}; + let committedId: string | undefined; + let committedEntry: ReturnType; + let committedRecords: Awaited> | undefined; const retargetAfterAppend = async (append: () => Promise) => { - const id = await append(); - const entry = manager.getEntry(id); - if (!entry || (entry.type !== "model_change" && entry.type !== "thinking_level_change")) { - throw new Error("Expected the real append's committed metadata entry"); - } - completed.entry = structuredClone(entry); - // Retarget after the real append commits, before the factory exposes its session. - completed.records = loadTranscriptEventsSync(original); - completed.watermark = readSessionTranscriptWatermark(original); + committedId = await append(); + committedEntry = structuredClone(manager.getEntry(committedId)); + committedRecords = loadTranscriptEventsSync(original); manager.setSessionTarget(replacement); - return id; + return committedId; }; const appendInitial = manager[sessionManagerAppendInitialMetadata].bind(manager); const readInitialContext = manager[sessionManagerReadInitialContext].bind(manager); @@ -606,13 +567,7 @@ it.each(["metadata", "context loading"] as const)( return context; }); onTestFinished(() => intercepted.mockRestore()); - const model = { - ...testModel, - id: "sdk-metadata-fixture", - reasoning: true, - }; - expect(getOwnedSessionTranscriptWriterFence()).toBeUndefined(); - + const model = { ...testModel, reasoning: true }; const error = await rejectedSession( createTestSession(manager, state.workspaceDir, { model, @@ -624,24 +579,24 @@ it.each(["metadata", "context loading"] as const)( }), }), ); - expect(intercepted).toHaveBeenCalledOnce(); expect.soft(await loadTranscriptEvents(replacement)).toEqual(replacementBefore); if (after === "context loading") { expect(await loadTranscriptEvents(original)).toEqual(originalBefore); expect(error).toBeInstanceOf(SessionTranscriptWriterClaimReboundError); - expect(error).not.toBeInstanceOf(SessionMetadataCommittedError); expect(hasModelFallbackStop(error)).toBe(false); } else { - expect(completed.entry).toBeDefined(); - expect(completed.records).toHaveLength(3); - expect(await loadTranscriptEvents(original)).toEqual(completed.records); + const records = await loadTranscriptEvents(original); + const watermark = readSessionTranscriptWatermark(original); + expect(records).toHaveLength(3); + expect(records).toEqual(committedRecords); + expect(committedEntry).toMatchObject({ id: committedId, type: "thinking_level_change" }); expect(error).toBeInstanceOf(SessionMetadataCommittedError); expect(error).toMatchObject({ - committedEntry: completed.entry, + committedEntry, committedTarget: original, committedVersion: { - generation: completed.watermark?.generation, - rawSeq: completed.watermark?.maxSeq, + generation: watermark?.generation, + rawSeq: watermark?.maxSeq, }, }); expect(hasModelFallbackStop(error)).toBe(true); @@ -712,11 +667,7 @@ it.each([ const { session } = await pending; const expected = selection === "branch" ? [first] : []; expect(session.messages).toEqual(expected); - const next = { - role: "user" as const, - content: "Continue selected branch", - timestamp: 3, - }; + const next = { role: "user" as const, content: "Continue selected branch", timestamp: 3 }; manager.appendMessage(next); expect(manager.buildSessionContext().messages).toEqual([...expected, next]); expect((await loadTranscriptEvents(target)).slice(0, before.length)).toEqual(before); @@ -771,15 +722,15 @@ it("awaits extension entry, name, and label persistence before publishing their const id = await api.appendEntryAsync("extension-state", { count: 1 }); await api.setSessionNameAsync("Awaited name"); await api.setLabelAsync(id, "bookmark"); - expect(manager.getEntry(id)).toMatchObject({ + expect(changes).toEqual(["Awaited name"]); + const reopened = await SessionManager.openAsync(target, state.workspaceDir); + expect(reopened.getEntry(id)).toMatchObject({ type: "custom", customType: "extension-state", data: { count: 1 }, }); - expect(manager.getLabel(id)).toBe("bookmark"); - expect(changes).toEqual(["Awaited name"]); - const reopened = await SessionManager.openAsync(target, state.workspaceDir); expect(reopened.getEntry(id)).toEqual(manager.getEntry(id)); + expect(manager.getLabel(id)).toBe("bookmark"); expect(reopened.getSessionName()).toBe("Awaited name"); expect(reopened.getLabel(id)).toBe("bookmark"); } finally { @@ -882,7 +833,6 @@ it("does not publish a committed session name into a manager retargeted before c }), ]); await manager.setSessionTargetAsync(replacement); - expect(session.sessionManager).toBe(manager); expect(manager.getSessionName()).toBe("Replacement name"); release.resolve(); await expect @@ -936,12 +886,7 @@ it.each(["SDK initialization", "model transition"] as const)( try { process.chdir(firstDir); const manager = SessionManager.open(relativeTarget, firstDir); - const capturedTarget = manager.getSessionTarget(); - const reasoningModel = { - ...testModel, - id: "cwd-reasoning", - reasoning: true, - }; + const reasoningModel = { ...testModel, id: "cwd-reasoning", reasoning: true }; const plainModel = { ...reasoningModel, id: "cwd-plain", reasoning: false }; const modelRegistry = registryFor(reasoningModel, plainModel); const create = async () => @@ -985,13 +930,8 @@ it.each(["SDK initialization", "model transition"] as const)( await session.setModel(plainModel); } - expect(intercepted).toHaveBeenCalledOnce(); expect(completed.records).toBeDefined(); expect(process.cwd()).toBe(secondDir); - const expectedTarget = { ...first, env: { OPENCLAW_STATE_DIR: state.stateDir } }; - expect.soft(capturedTarget).toEqual(expectedTarget); - expect.soft(manager.getSessionTarget()).toEqual(expectedTarget); - expect(manager.getSessionId()).toBe(relativeTarget.sessionId); const firstAfter = await loadTranscriptEvents(first); expect(firstAfter.slice(0, completed.records?.length)).toEqual(completed.records); const level = operation === "SDK initialization" ? "high" : "off"; diff --git a/src/gateway/server.chat-abort-dispatch-rejection.test.ts b/src/gateway/server.chat-abort-dispatch-rejection.test.ts index 8ccb8a76ac9a..2a714a421e1a 100644 --- a/src/gateway/server.chat-abort-dispatch-rejection.test.ts +++ b/src/gateway/server.chat-abort-dispatch-rejection.test.ts @@ -146,6 +146,14 @@ function textAttachment(bytes: string) { }; } +async function history(socket: GatewaySocket, sessionKey = "main") { + const result = await rpcReq<{ messages: unknown[] }>(socket, "chat.history", { sessionKey }); + expect(result.ok).toBe(true); + return result.payload?.messages; +} + +const containingMessages = (...messages: Record[]) => + expect.arrayContaining(messages.map((message) => expect.objectContaining(message))); describe("gateway WebSocket chat abort ownership", () => { test("preserves committed native input across a profile-bound reconnect", async () => { const scope = await prepareChatSession("native-reconnect", "native-reconnect-bound"); @@ -435,11 +443,8 @@ describe("gateway WebSocket chat abort ownership", () => { const established = await rpcReq(socket, "chat.send", sendParameters); expectRun(established, runId, "error", false); expect(terminalStates).toEqual(yielded ? ["final", "error"] : ["error"]); - const history = await rpcReq<{ messages: unknown[] }>(socket, "chat.history", { - sessionKey: "main", - }); - expect(history.payload?.messages).toContainEqual( - expect.objectContaining({ + expect(await history(socket)).toEqual( + containingMessages({ role: "custom", customType: "run-failed-before-reply", content: expect.stringContaining("dispatch rejected before a late abort"), @@ -450,8 +455,10 @@ describe("gateway WebSocket chat abort ownership", () => { sessionKey: "main", runId, }); - expect(lateAbort.ok).toBe(true); - expect(lateAbort.payload).toMatchObject({ ok: true, aborted: false, runIds: [] }); + expect(lateAbort).toMatchObject({ + ok: true, + payload: { ok: true, aborted: false, runIds: [] }, + }); const replay = await rpcReq(socket, "chat.send", sendParameters); expect(replay.ok).toBe(false); @@ -564,19 +571,15 @@ describe("gateway WebSocket chat abort ownership", () => { }); if (!runtimeTerminal) { const settled = await rpcReq(socket, "agent.wait", { runId, timeoutMs: 1_000 }); - expect(settled.ok).toBe(true); - expect(settled.payload).toMatchObject({ - runId, - status: "error", - stopReason: "aborted", - endedAt: expect.any(Number), + expect(settled).toMatchObject({ + ok: true, + payload: { runId, status: "error", stopReason: "aborted", endedAt: expect.any(Number) }, }); } // The history RPC response follows every previously emitted chat // event on this socket, so it exposes any contradictory late terminal. - const barrier = await rpcReq(socket, "chat.history", { sessionKey: "main" }); - expect(barrier.ok).toBe(true); + await history(socket); expect(terminalStates).toEqual([terminalState]); } finally { dispatchRelease.resolve(); @@ -693,14 +696,8 @@ describe("gateway WebSocket chat abort ownership", () => { await withinTest(failureEntered.promise, signal); await lifecycle; // The same-socket response fences delivered events while the notice commit is held. - const heldHistory = await rpcReq<{ messages: unknown[] }>(socket, "chat.history", { - sessionKey: scope.sessionKey, - }); - expect(heldHistory.ok).toBe(true); - expect(heldHistory.payload?.messages).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ customType: "run-failed-before-reply" }), - ]), + expect(await history(socket, scope.sessionKey)).not.toEqual( + containingMessages({ customType: "run-failed-before-reply" }), ); expect.soft(terminalStates).toEqual([]); failureRelease.resolve(); @@ -708,24 +705,20 @@ describe("gateway WebSocket chat abort ownership", () => { expect.soft(terminalStates).toEqual(["error"]); expect.soft(terminalFrame.payload?.errorMessage).toBe(warning); expect(loadSessionEntry(scope)).toMatchObject({ status: "failed", lastRunId: runId }); - const history = await rpcReq<{ messages: unknown[] }>(reader, "chat.history", { - sessionKey: scope.sessionKey, - }); - expect(history.ok).toBe(true); - expect(history.payload?.messages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ + expect(await history(reader, scope.sessionKey)).toEqual( + containingMessages( + { role: "assistant", content: expect.arrayContaining([ expect.objectContaining({ type: "text", text: "Running it now." }), ]), - }), - expect.objectContaining({ + }, + { role: "custom", customType: "run-failed-before-reply", content: warning, - }), - ]), + }, + ), ); expect(loadTranscriptEventsSync(scope)).toContainEqual( expect.objectContaining({ @@ -978,23 +971,18 @@ describe("gateway WebSocket chat abort ownership", () => { const response = await send; await sendWork; await discardWork; - expect(response.ok).toBe(true); - expect(response.payload).toMatchObject({ + const terminal = { runId, status: "timeout", summary: "aborted", stopReason: "rpc", - }); + }; + expect(response).toMatchObject({ ok: true, payload: terminal }); expect(responses).toEqual([ { filePresent: false, cleanupSettled: true, - payload: expect.objectContaining({ - runId, - status: "timeout", - summary: "aborted", - stopReason: "rpc", - }), + payload: expect.objectContaining(terminal), }, ]); expect(discardSpy).toHaveBeenCalledOnce(); diff --git a/src/gateway/server.sessions.reset-cleanup.test.ts b/src/gateway/server.sessions.reset-cleanup.test.ts index cd4d94c221ed..511bb208300f 100644 --- a/src/gateway/server.sessions.reset-cleanup.test.ts +++ b/src/gateway/server.sessions.reset-cleanup.test.ts @@ -6,18 +6,12 @@ import { createDeferred, withinTest, } from "../../test/helpers/promise.js"; -import { - isSessionEntryDataSql, - observeHostDataSql, -} from "../../test/helpers/sqlite-statement-execution-counter.js"; import { seedCanonicalAcpSessionMeta } from "../acp/runtime/session-meta-fixture.test-support.js"; -import { createAcpMeta } from "../acp/runtime/session-meta-lifecycle.test-support.js"; import { readAcpSessionEntry } from "../acp/runtime/session-meta.js"; import { listRegisteredAgentHarnesses, registerAgentHarness } from "../agents/harness/registry.js"; import { restoreRegisteredAgentHarnesses } from "../agents/harness/registry.test-support.js"; import * as preparedModelRuntime from "../agents/prepared-model-runtime.js"; import { loadSessionEntry, upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; -import { memorySessionActorOwners } from "../config/sessions/session-actor-memory-owner.js"; import { RESET_PARENT_GRANT_FIXTURE } from "../config/sessions/session-lineage.test-support.js"; import type { InternalSessionEntry } from "../config/sessions/types.js"; import { peekSystemEvents } from "../infra/system-events.js"; @@ -36,7 +30,6 @@ import { } from "../sessions/session-controller.mailbox.js"; import { createReplyOperation } from "../sessions/session-controller.operation.js"; import { runExclusiveSessionLifecycle } from "../sessions/session-lifecycle-admission.test-support.js"; -import { resolveIncognitoOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createRequestGate } from "./server-methods.request-gate.test-support.js"; import { disposeSessionReadContexts } from "./server-methods/sessions-read-cache.test-support.js"; @@ -65,8 +58,7 @@ import { } from "./test/server-sessions.test-helpers.js"; const { createSessionStoreDir, seedActiveMainSession } = setupGatewaySessionsHandlerTestHarness(); -const { retireSessionMcpRuntime: retireMcp, disposeSessionMcpRuntime: disposeMcp } = - bundleMcpRuntimeMocks; +const { disposeSessionMcpRuntime: disposeMcp } = bundleMcpRuntimeMocks; afterEach(async () => { await disposeSessionReadContexts(); @@ -199,14 +191,6 @@ function installAcpRuntimeBackendWithFreshSession() { return prepareFreshSession; } -const retirement = (retainAcrossReuse: boolean) => ({ - sessionId: "sess-main", - reason: "gateway-session-cleanup", - preserveActiveLeases: true, - retainAcrossReuse, - onError: expect.any(Function), -}); - function seedAcp( sessionKey: string, overrides: Partial[0]> = {}, @@ -257,37 +241,6 @@ const closedAcpKeys = () => ([request]) => request?.sessionKey, ); -test("sessions.reset retires an unbound memory session without host session SQL", async () => { - await createSessionStoreDir(); - const location = { - agentId: "main", - path: resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main" }), - }; - const sessionKey = "agent:main:dashboard:incognito-reset-source"; - const scope = { agentId: "main", storePath: location.path, sessionKey }; - try { - await upsertSessionEntryCore(scope, { - sessionId: "reset-source", - lifecycleRevision: "reset-source-lifecycle", - updatedAt: 1, - incognito: true, - }); - const sql = observeHostDataSql(); - try { - const result = await directSessionReq<{ deleted: boolean }>("sessions.reset", { - key: sessionKey, - }); - expect(result).toMatchObject({ ok: true, payload: { deleted: true } }); - expect(loadSessionEntry(scope)).toBeUndefined(); - expect(sql.queries.filter(isSessionEntryDataSql)).toEqual([]); - } finally { - sql.restore(); - } - } finally { - memorySessionActorOwners.closeDatabase(location); - } -}); - test("sessions.reset preempts the controller run and clears queues", async () => { const { activeRun, storePath } = await seedWaitingActiveMainSession(); const parentGrant = RESET_PARENT_GRANT_FIXTURE; @@ -335,15 +288,10 @@ test("sessions.reset preempts the controller run and clears queues", async () => expectActiveRunCleanup("agent:main:main", ["main", "agent:main:main", "sess-main"], "main"); expect(peekSystemEvents("agent:main:main")).toStrictEqual([]); expect(peekSystemEvents("agent:main:sess-main")).toStrictEqual([]); - expect(retireMcp).toHaveBeenCalledExactlyOnceWith(retirement(false)); expect(disposeMcp).toHaveBeenCalledWith("sess-main"); - const browserCalls: unknown[][] = - browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mock.calls; - expect(browserCalls).toHaveLength(1); - expect(browserCalls[0]?.[0]).toMatchObject({ - sessionKeys: ["agent:main:main", "agent:main:sess-main"], - onWarn: expect.any(Function), - }); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith( + expect.objectContaining({ sessionKeys: ["agent:main:main", "agent:main:sess-main"] }), + ); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledExactlyOnceWith( { targetSessionKey: "agent:main:main", @@ -475,29 +423,6 @@ test("sessions.reset clears retained native conversations from every execution o } }); -test.each([false, true])( - "sessions.reset interrupts only non-initiating work (initiator: %s)", - async (initiator) => { - const { storePath } = await seedMainSession(); - let interrupted = false; - let releaseAdmission = () => {}; - const admission = await admitMainWork(storePath, () => { - interrupted = true; - if (!initiator) { - releaseAdmission(); - } - }); - releaseAdmission = admission.release; - try { - const reset = await (initiator ? admission.run(resetMainSession) : resetMainSession()); - expect(reset.ok).toBe(true); - expect(interrupted).toBe(!initiator); - } finally { - admission.release(); - } - }, -); - test("sessions.reset rejects an active lifecycle mutation without interrupting admitted work", async () => { const { storePath } = await seedMainSession(); let interrupted = false; @@ -791,25 +716,11 @@ describe("acp-authority", () => { const { closeAcpRuntimeForSession } = await import("./session-reset-acp.js"); const { storePath } = await seedActiveMainSession(); const sessionKey = "agent:main:main"; - seedCanonicalAcpSessionMeta({ - sessionKey, - meta: createAcpMeta({ - backend: "acpx", - agent: "codex", - identity: { - state: "resolved", - acpxRecordId: sessionKey, - acpxSessionId: "original-backend", - source: "status", - lastUpdatedAt: Date.now(), - }, - cwd: "/tmp/acp-session", - }), - }); - acpRuntimeMocks.getAcpRuntimeBackend.mockReturnValue({ - id: "acpx", - runtime: { prepareFreshSession: vi.fn(async () => {}) }, + seedAcp(sessionKey, { + backendSessionId: "original-backend", + runtimeSessionName: "original-runtime", }); + installAcpRuntimeBackendWithFreshSession(); const scope = { agentId: "main", sessionKey, storePath }; const readState = () => ({ entry: loadSessionEntry(scope), diff --git a/src/gateway/session-row-selection.test.ts b/src/gateway/session-row-selection.test.ts index c97e0179df12..15eadf34668b 100644 --- a/src/gateway/session-row-selection.test.ts +++ b/src/gateway/session-row-selection.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { subagentRuns } from "../agents/subagents/registry/subagent-registry-memory.js"; -import { buildSubagentRunReadIndexFromRuns } from "../agents/subagents/registry/subagent-registry-queries.js"; import { persistRegistryFixture, saveSubagentRegistryToSqlite, @@ -13,44 +12,42 @@ import { patchSessionEntryCore, replaceSessionEntrySync, } from "../config/sessions/session-accessor.js"; -import { writeSessionEntry } from "../config/sessions/session-accessor.sqlite-entry-store.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; -import * as sessionKeys from "../sessions/session-key-utils.js"; import { sessionChanges } from "../sessions/session-row-changes.js"; -import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { registerOpenClawAgentDatabase } from "../state/openclaw-agent-db-registry.js"; -import { - resolveOpenClawAgentSqlitePath, - runOpenClawAgentWriteTransaction, -} from "../state/openclaw-agent-db.js"; +import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; import { registerRpcSourceForTest } from "./server-methods/rpc-source-fixtures.test-support.js"; import { requestContext } from "./server-methods/sessions-read-cache.test-support.js"; -import { listSessionFixture } from "./session-list.test-support.js"; import { retainSessionListForegroundWork } from "./session-projection-work.js"; -import { indexSessionRow } from "./session-row-projection-identities.js"; -import { - create as createSessionRow, - identity, - type Query, - type Row, -} from "./session-row-projection-record.js"; +import { create as createSessionRow } from "./session-row-projection-record.js"; import { createSessionRowProjection } from "./session-row-projection.js"; import { holdSubagentExecution } from "./session-row-projection.read-fixture.test-support.js"; import { createSessionRowProjectionFixture } from "./session-row-projection.test-support.js"; -import { createSessionRowEntrySelector, selectMatchingSessionRows } from "./session-row-scope.js"; -import * as childOwners from "./session-utils-core.js"; import { filterAndSortSessionEntries, listProjectedSessions, prepareProjectedSessionList, - prepareSessionRowSelection, } from "./session-utils-list.js"; import { claimRpcSourceForTest } from "./test-helpers.rpc-source.js"; +type ListOptions = Parameters[0]; +const listRows = (projection: ListOptions["projection"], opts: ListOptions["opts"], key?: string) => + listProjectedSessions({ projection, opts, key }); + +function writeMain(sessionKey: string, entry: Partial) { + replaceSessionEntrySync( + { agentId: "main", sessionKey }, + { sessionId: sessionKey, updatedAt: 1, ...entry }, + ); +} + +const rowKeys = (result: Awaited>) => + result.sessions.map((row) => row.key); + beforeEach(() => { setActivePluginRegistry(createSessionConversationTestRegistry()); }); @@ -59,109 +56,6 @@ afterEach(() => { resetPluginRuntimeStateForTest(); }); -it("preserves scoped key and ID selection against a scan over randomized federated rows", () => { - let seed = 1467; - const random = (bound: number) => { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; - return seed % bound; - }; - const cfg = { agents: { entries: { main: {}, work: {}, other: {} } } }; - const agents = Object.keys(cfg.agents.entries); - const paths = ["/synthetic/a", "/synthetic/b", "/synthetic/c"]; - const scope = { - physicalPaths: (locator: string, agentId?: string) => - locator === "/synthetic/shared" - ? agentId - ? [paths[agents.indexOf(agentId)]!] - : paths - : [locator], - }; - for (let fixture = 0; fixture < 20; fixture++) { - const rows = new Map(); - for (let index = 0; index < 80; index++) { - const agentId = agents[random(agents.length)]!; - const key = ["global", "unknown", `agent:${agentId}:key-${random(12)}`][random(3)]!; - const entry = { sessionId: `id-${random(12)}`, updatedAt: random(10) }; - const row = { - ...createSessionRow({ - key, - agentId, - storeTarget: { - agentId: agents[random(agents.length)]!, - storePath: paths[random(paths.length)]!, - }, - }), - entry, - }; - rows.set(identity(row), row); - } - const indexes = { - byKey: new Map>(), - byAgent: new Map>(), - byStore: new Map>(), - byParent: new Map>(), - }; - for (const row of rows.values()) { - indexSessionRow(row, indexes); - } - const matching = (query: Query, kind?: string) => - selectMatchingSessionRows({ rows, indexes, scope }, query, kind); - const select = createSessionRowEntrySelector({ - isActive: () => true, - runAsOwner: (read) => read(), - prepare: () => true, - state: () => ({ - cfg, - scope, - ...indexes, - rows, - dirty: new Set(), - hasUnresolvedCategories: false, - matching, - acquire: (row) => row, - referenced: () => undefined, - }), - }); - // The reference scans rows, independent of the production candidate indexes. - const scan = (query: Query, kind = "key") => { - const selectedPaths = query.storePath - ? scope.physicalPaths(query.storePath, query.agentId) - : undefined; - const candidates = - !query.key && selectedPaths - ? selectedPaths.flatMap((pathname) => - [...rows.values()].filter((row) => row.storeTarget.storePath === pathname), - ) - : [...rows.values()]; - return candidates.filter( - (row) => - (!query.key || (kind === "id" ? row.entry?.sessionId : row.key) === query.key) && - (!query.agentId || - row.agentId === query.agentId || - ((query.key || selectedPaths) && row.storeTarget.agentId === query.agentId)) && - (!selectedPaths || selectedPaths.includes(row.storeTarget.storePath)), - ); - }; - for (let sample = 0; sample < 40; sample++) { - const query: Query = { - agentId: [...agents, undefined][random(4)], - storePath: [...paths, "/synthetic/shared", undefined][random(5)], - sortBy: null, - ...(random(2) - ? { sessionIdOrKey: random(2) ? `id-${random(12)}` : "global" } - : { key: random(2) ? `agent:main:key-${random(12)}` : undefined }), - }; - expect(matching(query).map(identity)).toEqual(scan(query).map(identity)); - const indexed = { ...query, key: query.sessionIdOrKey }; - const keys = new Set([...scan(indexed, "id"), ...scan(indexed)].map((row) => row.key)); - const expected = ( - query.sessionIdOrKey ? [...keys].flatMap((key) => scan({ ...query, key })) : scan(query) - ).filter((row) => !query.agentId || row.agentId === query.agentId); - expect(select(query).map(identity)).toEqual(expected.map(identity)); - } - } -}); - it("maintains list order across metadata changes and archived-row rematerialization", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { const cfg = { agents: { entries: { main: {} } } }; @@ -171,147 +65,86 @@ it("maintains list order across metadata changes and archived-row rematerializat [first, 2, 1], [second, 1, 2], ] as const) { - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: sessionKey, updatedAt, lastInteractionAt }, - ); + writeMain(sessionKey, { updatedAt, lastInteractionAt }); } const release = retainSessionListForegroundWork(); const projection = await createSessionRowProjection({ cfg, modelCatalog: [] }); - const scan = vi.spyOn(projection, "selectEntries"); try { const list = async (sortBy?: "lastInteractionAt" | "activity") => - (await listProjectedSessions({ projection, opts: { limit: 1, sortBy } })).sessions; + (await listRows(projection, { limit: 1, sortBy })).sessions; expect((await list())[0]?.key).toBe(first); - scan.mockClear(); - emitSessionTranscriptUpdate({ - target: { agentId: "main", sessionId: first, sessionKey: first }, - }); - expect((await list())[0]?.key).toBe(first); - expect(scan.mock.calls.filter(([query]) => !query?.key)).toHaveLength(0); expect((await list("lastInteractionAt"))[0]?.key).toBe(second); expect((await list("activity"))[0]?.key).toBe(second); expect((await list())[0]?.key).toBe(first); - replaceSessionEntrySync( - { agentId: "main", sessionKey: second }, - { sessionId: second, updatedAt: 3, label: "Changed metadata" }, - ); + writeMain(second, { updatedAt: 3, label: "Changed metadata" }); expect((await list())[0]).toMatchObject({ key: second, label: "Changed metadata" }); const third = "agent:main:third"; - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3 }, - ); + writeMain(third, { updatedAt: 4, lastInteractionAt: 3 }); expect((await list())[0]?.key).toBe(third); expect((await list("lastInteractionAt"))[0]?.key).toBe(third); - expect( - await listProjectedSessions({ projection, opts: { limit: 1, offset: 1 } }), - ).toMatchObject({ + expect(await listRows(projection, { limit: 1, offset: 1 })).toMatchObject({ totalCount: 3, nextOffset: 2, hasMore: true, sessions: [{ key: second }], }); - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3, archivedAt: 5 }, - ); - expect((await list())[0]?.key).toBe(second); - const archived = await listProjectedSessions({ projection, opts: { archived: true } }); - expect(archived.sessions.map((row) => row.key)).toEqual([third]); - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3 }, - ); - expect((await list())[0]?.key).toBe(third); - expect(await listProjectedSessions({ projection, opts: { archived: true } })).toMatchObject({ - sessions: [], - totalCount: 0, - }); // Archive membership is presence, including a zero timestamp. - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3, archivedAt: 0 }, - ); + writeMain(third, { updatedAt: 4, lastInteractionAt: 3, archivedAt: 0 }); expect((await list())[0]?.key).toBe(second); - expect( - await listProjectedSessions({ projection, opts: { archived: true, limit: 1 } }), - ).toMatchObject({ sessions: [{ key: third }], totalCount: 1, hasMore: false }); + expect(await listRows(projection, { archived: true, limit: 1 })).toMatchObject({ + sessions: [{ key: third }], + totalCount: 1, + hasMore: false, + }); const archivedQuery = { agentId: "main", key: third }; sessionChanges.emit({ all: true, scope: "catalog" }); await projection.ensureMaterialized(); expect(projection.capture(archivedQuery)?.materialized).toBeUndefined(); - const rematerialized = await listProjectedSessions({ - projection, - opts: { archived: true }, - }); - expect(rematerialized.sessions.map((row) => row.key)).toEqual([third]); + const rematerialized = await listRows(projection, { archived: true }); + expect(rowKeys(rematerialized)).toEqual([third]); expect(rematerialized.totalCount).toBe(1); expect(projection.capture(archivedQuery)?.materialized).toBeDefined(); expect((await list())[0]?.key).toBe(second); - expect(scan.mock.calls.filter(([query]) => !query?.key)).toHaveLength(0); await deleteSessionEntryLifecycle({ agentId: "main", storePath: projection.capture({ agentId: "main", key: third })!.storeTarget.storePath, archiveTranscript: false, target: { canonicalKey: third, storeKeys: [third] }, }); - const remaining = await listProjectedSessions({ - projection, - opts: { archived: "all", sortBy: "activity" }, - }); - expect(remaining.sessions.map((row) => row.key)).toEqual([second, first]); + const remaining = await listRows(projection, { archived: "all", sortBy: "activity" }); + expect(rowKeys(remaining)).toEqual([second, first]); expect(remaining).toMatchObject({ totalCount: 2, nextOffset: null, hasMore: false }); - // Deletion publishes topology; keyed updates must reuse the rebuilt scope. - scan.mockClear(); - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { sessionId: first, updatedAt: 2, lastInteractionAt: 1, pinnedAt: 10 }, - ); + writeMain(first, { updatedAt: 2, lastInteractionAt: 1, pinnedAt: 10 }); expect((await list())[0]?.key).toBe(first); expect((await list("activity"))[0]?.key).toBe(second); expect((await list("lastInteractionAt"))[0]?.key).toBe(first); // A retained pin becomes effective only when the child is a sidebar root. for (const sidebarRoot of [false, true, false]) { - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { - sessionId: first, - updatedAt: 2, - lastInteractionAt: 1, - pinnedAt: 10, - parentSessionKey: second, - sidebarRoot, - }, - ); + writeMain(first, { + updatedAt: 2, + lastInteractionAt: 1, + pinnedAt: 10, + parentSessionKey: second, + sidebarRoot, + }); expect((await list())[0]?.key).toBe(sidebarRoot ? first : second); expect( - (await listProjectedSessions({ projection, opts: { pinned: true } })).sessions.map( - (row) => row.key, - ), + (await listRows(projection, { pinned: true })).sessions.map((row) => row.key), ).toEqual(sidebarRoot ? [first] : []); } - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { sessionId: first, updatedAt: 2, lastInteractionAt: 1, label: "Unpinned" }, - ); + writeMain(first, { updatedAt: 2, lastInteractionAt: 1, label: "Unpinned" }); expect((await list())[0]?.key).toBe(second); - expect(scan.mock.calls.filter(([query]) => !query?.key)).toHaveLength(0); projection.onSelectionChange(() => { throw new Error("Synthetic selection observer failure"); }); - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { sessionId: first, updatedAt: 5, label: "Changed despite observer failure" }, - ); + writeMain(first, { updatedAt: 5, label: "Changed despite observer failure" }); expect((await list())[0]).toMatchObject({ key: first, label: "Changed despite observer failure", }); - expect(() => projection.dispose()).not.toThrow(); + projection.dispose(); expect(projection.selectEntries()).toEqual([]); } finally { - scan.mockRestore(); projection.dispose(); release(); } @@ -336,10 +169,7 @@ it("refreshes agent owner facets when the sidebar roster changes", async () => { { owner: { type: "agent", id: "owner" }, assignedBy: { type: "system", id: "test" } }, ); const acpKey = "agent:main:acp-owner"; - replaceSessionEntrySync( - { agentId: "main", sessionKey: acpKey }, - { sessionId: "acp-owner", updatedAt: 2, archivedAt: 3 }, - ); + writeMain(acpKey, { sessionId: "acp-owner", updatedAt: 2, archivedAt: 3 }); assignSessionOwner( { agentId: "main", sessionKey: acpKey }, { owner: { type: "agent", id: "transport" }, assignedBy: { type: "system", id: "test" } }, @@ -351,17 +181,14 @@ it("refreshes agent owner facets when the sidebar roster changes", async () => { }); try { const list = (scoped = true) => - listProjectedSessions({ - projection, - opts: { - agentId: scoped ? "main" : undefined, - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - rowMode: "sidebar", - limit: 200, - archived: "all", - }, + listRows(projection, { + agentId: scoped ? "main" : undefined, + configuredAgentsOnly: true, + includeGlobal: true, + includeUnknown: true, + rowMode: "sidebar", + limit: 200, + archived: "all", }); // Read the cold ACP row before a scoped read can populate its owner projection. expect((await list(false)).owners?.map(({ id }) => id)).toEqual(["owner"]); @@ -390,33 +217,16 @@ it("selects an exact row before pagination while retaining discovery filters", a const key = "agent:main:target"; for (let index = 0; index < 7; index++) { const sessionKey = index === 0 ? key : `${key}-${index}`; - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: `transcript-${index}`, updatedAt: index + 1 }, - ); + writeMain(sessionKey, { sessionId: `transcript-${index}`, updatedAt: index + 1 }); } const projection = await createSessionRowProjection({ cfg }); try { - const page = await listProjectedSessions({ - projection, - opts: { agentId: "main", search: key, limit: 5 }, - }); - expect(page.sessions.map((row) => row.key)).not.toContain(key); - const exact = await listProjectedSessions({ - projection, - key, - opts: { agentId: "main", limit: 1 }, - }); + const page = await listRows(projection, { agentId: "main", search: key, limit: 5 }); + expect(rowKeys(page)).not.toContain(key); + const exact = await listRows(projection, { agentId: "main", limit: 1 }, key); expect(exact.sessions).toMatchObject([{ key, sessionId: "transcript-0" }]); - replaceSessionEntrySync( - { agentId: "main", sessionKey: key }, - { sessionId: "transcript-0", updatedAt: 8, archivedAt: 8 }, - ); - const hidden = await listProjectedSessions({ - projection, - key, - opts: { agentId: "main", limit: 1 }, - }); + writeMain(key, { sessionId: "transcript-0", updatedAt: 8, archivedAt: 8 }); + const hidden = await listRows(projection, { agentId: "main", limit: 1 }, key); expect(hidden.sessions).toEqual([]); } finally { projection.dispose(); @@ -424,259 +234,94 @@ it("selects an exact row before pagination while retaining discovery filters", a }); }); -it("reuses resident key predicates across list requests and refreshes legacy spawned classification", async () => { +it("filters active sentinels only after selecting their physical store winner", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { - const cfg = { agents: { entries: { main: {} } } }; - const keys = [ - "agent:main:legacy-visible", - "agent:main:cron:job:run:one", - "agent:main:subagent:child", - "agent:main:matrix:channel:!Room:example.org:thread:$Event", - "agent:main:signal:group:OpaqueGroup", - "agent:main:sessions", + const cfg = { agents: { entries: { main: {}, ops: {} } } }; + const projection = createSessionRowProjectionFixture({ cfg, store: {} }); + const samples = [ + ["shadow-global", "global", "main", "fallback"], + ["main-global", "global", "main", "primary"], + ["ops-global", "global", "ops", "primary"], + ["ops-shadow", "global", "ops", "fallback"], + ["unknown-winner", "unknown", "ops", "primary"], + ["unknown-shadow", "unknown", "main", "fallback"], ] as const; - for (const sessionKey of keys) { - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: sessionKey, updatedAt: 1 }, + const rows = samples.map(([sessionId, key, agentId, storePath]) => { + const entry = { sessionId, updatedAt: 1 }; + return { + ...createSessionRow({ key, agentId, storeTarget: { agentId, storePath } }, entry), + entry, + }; + }); + projection.selectEntries = (query) => { + if (!query?.sessionIdOrKey) { + return rows; + } + const keys = new Set( + rows + .filter( + (row) => + row.key === query.sessionIdOrKey || row.entry.sessionId === query.sessionIdOrKey, + ) + .map((row) => row.key), ); - } - const projection = await createSessionRowProjection({ cfg }); + return rows.filter((row) => keys.has(row.key)); + }; + projection.state.scope = () => ({ + paths: new Map([ + ["primary", 0], + ["fallback", 1], + ]), + path: "(multiple)", + agentId: undefined, + configuredAgentIds: new Set(["main", "ops"]), + ownerAgentIds: new Set(["main", "ops"]), + }); + const registrations = await Promise.all( + [ + ["main", "shadow-global"], + ["ops", "ops-global"], + ["main", "unknown-shadow"], + ].map(async ([agentId, sessionId]) => { + const registration = registerRpcSourceForTest({ + storeScope: samples.find(([id]) => id === sessionId)![3], + runId: sessionId!, + agentId, + sessionId: sessionId!, + sessionKey: `agent:${agentId}:run-alias:${sessionId}`, + }); + const release = await claimRpcSourceForTest(registration.entry!); + return Object.assign({}, registration, { release }); + }), + ); try { - await projection.ensureMaterialized(); - const opts = { agentId: "main", excludeSubagents: true, archived: "all" as const }; - const expected = [keys[0], keys[3], keys[4], keys[5]].toSorted(); - for (let iteration = 0; iteration < 2; iteration++) { - const prepared = prepareSessionRowSelection(projection, opts); - const cron = vi.spyOn(sessionKeys, "isCronRunSessionKey"); - const subagent = vi.spyOn(sessionKeys, "isSubagentSessionKey"); - try { - expect( - filterAndSortSessionEntries(prepared) - .map(([key]) => key) - .toSorted(), - ).toEqual(expected); - expect(cron).not.toHaveBeenCalled(); - expect(subagent).not.toHaveBeenCalled(); - } finally { - cron.mockRestore(); - subagent.mockRestore(); - } - } - const sessionKey = keys[0]; - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { - sessionId: sessionKey, - updatedAt: 2, - spawnedBy: "agent:main:parent", - archivedAt: 2, + const { filters } = prepareProjectedSessionList({ + projection, + context: requestContext(cfg), + opts: { + activeOnly: true, + configuredAgentsOnly: true, + includeGlobal: true, + includeUnknown: true, }, - ); - const hidden = await listProjectedSessions({ projection, opts }); - expect(hidden.sessions.map((row) => row.key).toSorted()).toEqual( - expected.filter((key) => key !== sessionKey), - ); - for (const category of ["Research", " "]) { - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { - sessionId: sessionKey, - updatedAt: 2, - spawnedBy: "agent:main:parent", - archivedAt: 2, - category, - }, - ); - const grouped = await listProjectedSessions({ projection, opts }); - expect(grouped.sessions.map((row) => row.key).toSorted()).toEqual( - category.trim() ? expected : expected.filter((key) => key !== sessionKey), - ); - expect( - ( - await listProjectedSessions({ projection, opts: { ...opts, archived: false } }) - ).sessions.some((row) => row.key === sessionKey), - ).toBe(false); - } - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: sessionKey, updatedAt: 3, archivedAt: 2 }, - ); - const restored = await listProjectedSessions({ projection, opts }); - expect(restored.sessions.map((row) => row.key).toSorted()).toEqual(expected); + now: Date.now(), + metadataPrepared: true, + }); + // An active fallback cannot displace its inactive winner; other agents remain visible. + expect(filterAndSortSessionEntries(filters).map(([, entry]) => entry.sessionId)).toEqual([ + "ops-global", + "unknown-shadow", + ]); } finally { + for (const registration of registrations) { + registration.release(); + registration.cleanup(); + } projection.dispose(); } }); }); -it.each([false, true])( - "preserves sentinel precedence, ties, and resident order before filtering (activeOnly: %s)", - async (activeOnly) => { - await withOpenClawTestState({ scenario: "minimal" }, async () => { - const cfg = { agents: { entries: { main: {}, ops: {} } } }; - const projection = createSessionRowProjectionFixture({ cfg, store: {} }); - const parent = "agent:main:parent"; - const now = Date.now(); - projection.state.rowContext.subagentRuns = buildSubagentRunReadIndexFromRuns({ - now, - runs: new Map([ - [ - "ordinary-run", - { - runId: "ordinary-run", - childSessionKey: "agent:main:ordinary", - requesterSessionKey: parent, - requesterDisplayKey: parent, - task: "Synthetic child selection", - cleanup: "keep", - createdAt: now, - execution: { status: "running", startedAt: now }, - }, - ], - ]), - }); - const samples = [ - ["shadow-global", "global", "main", "fallback"], - ["ordinary", "agent:main:ordinary", "main", "primary"], - ["main-global", "global", "main", "primary"], - ["ops-global", "global", "ops", "primary"], - ["ops-shadow", "global", "ops", "fallback"], - ["unknown-winner", "unknown", "ops", "primary"], - ["unknown-shadow", "unknown", "main", "fallback"], - ["excluded", "agent:main:ordinary", "main", "excluded"], - ["retired", "agent:retired:ordinary", "retired", "primary"], - ] as const; - const rows = samples.map(([sessionId, key, agentId, storePath]) => { - const entry = { - sessionId, - updatedAt: 1, - ...(["ordinary", "shadow-global", "unknown-shadow"].includes(sessionId) - ? { spawnedBy: parent } - : {}), - }; - return { - ...createSessionRow( - { - key, - agentId, - storeTarget: { agentId, storePath }, - }, - entry, - ), - entry, - }; - }); - projection.selectEntries = (query) => { - if (query?.sessionIdOrKey) { - const keys = new Set( - rows - .filter( - (row) => - row.key === query.sessionIdOrKey || row.entry.sessionId === query.sessionIdOrKey, - ) - .map((row) => row.key), - ); - return rows.filter((row) => keys.has(row.key)); - } - return query?.parentSessionKey - ? rows.filter((row) => row.entry.spawnedBy === query.parentSessionKey) - : rows; - }; - projection.state.scope = () => ({ - paths: new Map([ - ["primary", 0], - ["fallback", 1], - ]), - path: "(multiple)", - agentId: undefined, - configuredAgentIds: new Set(["main", "ops"]), - ownerAgentIds: new Set(["main", "ops"]), - }); - try { - const prepared = prepareSessionRowSelection(projection, { - activeOnly, - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - }); - expect(prepared.entries("all").map(([, entry]) => entry.sessionId)).toEqual( - activeOnly - ? ["ordinary", "main-global", "ops-global", "unknown-winner", "unknown-shadow"] - : ["ordinary", "main-global", "unknown-winner"], - ); - for (const [key, entry] of prepared.entries("all")) { - const target = prepared.getTarget(key)!; - const original = rows.find((row) => row.entry === entry)!; - expect(target.entry).toBe(entry); - expect(target.storeTarget).toBe(original.storeTarget); - expect(target.storeKey ?? key).toBe(original.key); - } - const visible = filterAndSortSessionEntries({ - ...prepared, - entryFilter: (_key, entry) => entry.sessionId !== "main-global", - }); - expect(visible.map(([, entry]) => entry.sessionId)).toEqual( - expect.not.arrayContaining(["main-global", "shadow-global", "ops-shadow"]), - ); - expect(rows.map((row) => row.entry.sessionId)).toEqual(samples.map(([id]) => id)); - const scoped = filterAndSortSessionEntries({ - ...prepared, - opts: { ...prepared.opts, agentId: "ops" }, - }); - expect(scoped.map(([, entry]) => entry.sessionId)).toEqual( - activeOnly ? ["main-global", "ops-global", "unknown-winner"] : ["main-global"], - ); - const children = filterAndSortSessionEntries( - prepareSessionRowSelection(projection, { ...prepared.opts, spawnedBy: parent }), - ); - expect(children.map(([, entry]) => entry.sessionId)).toEqual(["ordinary"]); - if (activeOnly) { - const context = requestContext(cfg); - const registrations = await Promise.all( - [ - ["main", "shadow-global"], - ["ops", "ops-global"], - ["main", "unknown-shadow"], - ].map(async ([agentId, sessionId]) => { - const registration = registerRpcSourceForTest({ - storeScope: samples.find(([sampleId]) => sampleId === sessionId)![3], - runId: sessionId!, - agentId, - sessionId: sessionId!, - sessionKey: `agent:${agentId}:run-alias:${sessionId}`, - }); - const release = await claimRpcSourceForTest(registration.entry!); - return Object.assign({}, registration, { release }); - }), - ); - try { - const { filters } = prepareProjectedSessionList({ - projection, - context, - opts: prepared.opts, - now, - metadataPrepared: true, - }); - // The live fallback ID cannot displace its inactive physical winner. - // Other agents' sentinel rows and same-ID aliases remain independently visible. - expect( - filterAndSortSessionEntries(filters).map(([, entry]) => entry.sessionId), - ).toEqual(["ops-global", "unknown-shadow"]); - } finally { - for (const registration of registrations) { - registration.release(); - registration.cleanup(); - } - } - } - } finally { - projection.dispose(); - } - }); - }, -); - it("rejects duplicate ordinary keys introduced after store admission before filtering or pagination", async () => { await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const cfg = { agents: { entries: { main: {} } } }; @@ -701,53 +346,30 @@ it("rejects duplicate ordinary keys introduced after store admission before filt const projection = await createSessionRowProjection({ cfg }); try { const opts = { configuredAgentsOnly: true }; - expect((await listProjectedSessions({ projection, opts })).sessions).toHaveLength(2); + expect((await listRows(projection, opts)).sessions).toHaveLength(2); const duplicate = { agentId: "main", storePath: secondary, sessionKey: key }; replaceSessionEntrySync(duplicate, { sessionId: "duplicate", updatedAt: Date.now() + 1 }); - await expect( - listProjectedSessions({ projection, opts: { ...opts, limit: 1, offset: 1 } }), - ).rejects.toThrow("duplicate rows resolve to canonical session key"); - await expect( - listProjectedSessions({ projection, opts: { ...opts, spawnedBy: parent } }), - ).rejects.toThrow("duplicate rows resolve to canonical session key"); + await expect(listRows(projection, { ...opts, limit: 1, offset: 1 })).rejects.toThrow( + "duplicate rows resolve to canonical session key", + ); + await expect(listRows(projection, { ...opts, spawnedBy: parent })).rejects.toThrow( + "duplicate rows resolve to canonical session key", + ); await deleteSessionEntryLifecycle({ agentId: "main", storePath: secondary, archiveTranscript: false, target: { canonicalKey: key, storeKeys: [key] }, }); - const result = await listProjectedSessions({ projection, opts }); - expect(result.sessions.map((row) => row.key).toSorted()).toEqual([key, "agent:main:other"]); + const result = await listRows(projection, opts); + expect(rowKeys(result).toSorted()).toEqual([key, "agent:main:other"]); } finally { projection.dispose(); } }); }); -it("keeps ungrouped visible spawned work discoverable", async () => { - const result = await listSessionFixture({ - cfg: { agents: { entries: { main: {} } } }, - storePath: "/tmp/openclaw-visible-session-activity", - store: { - "agent:main:subagent:hidden": { - sessionId: "hidden", - updatedAt: 3, - spawnedBy: "agent:main:discussion", - }, - "agent:main:dashboard:visible": { - sessionId: "visible", - updatedAt: 2, - spawnedBy: "agent:main:discussion", - }, - "agent:main:discussion": { sessionId: "parent", updatedAt: 1 }, - }, - opts: { excludeSubagents: true, limit: 1 }, - }); - expect(result.sessions.map((row) => row.key)).toEqual(["agent:main:dashboard:visible"]); - expect(result).toMatchObject({ totalCount: 2, nextOffset: 1, hasMore: true }); -}); - -it("lists indexed children without inspecting unrelated resident ownership", async () => { +it("pages children by their current controller and retains persistent children", async () => { await withOpenClawTestState( { scenario: "minimal", env: { OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_SQLITE: "1" } }, async () => { @@ -758,18 +380,6 @@ it("lists indexed children without inspecting unrelated resident ownership", asy const parent = "agent:main:parent"; const other = "agent:main:other"; const key = (name: string) => `agent:main:${name}`; - const unrelated = 96; - const entries = new Map( - Array.from({ length: unrelated }, (_, index) => [ - key(`unrelated-${index}`), - { - sessionId: `unrelated-${index}`, - updatedAt: now, - archivedAt: 1, - parentSessionKey: key(`unrelated-parent-${index}`), - }, - ]), - ); for (const [name, fields] of [ ["spawned", { spawnedBy: parent }], ["dashboard:persistent", { parentSessionKey: parent, status: "done", endedAt: 1 }], @@ -777,25 +387,13 @@ it("lists indexed children without inspecting unrelated resident ownership", asy ["moved", { spawnedBy: parent }], ["runtime", {}], ] as const) { - entries.set(key(name), { sessionId: name, updatedAt: now, ...fields }); + writeMain(key(name), { sessionId: name, updatedAt: now, ...fields }); } - const seed = (agentId: string, rows: Iterable<[string, SessionEntry]>) => - runOpenClawAgentWriteTransaction( - (database) => { - for (const [sessionKey, entry] of rows) { - writeSessionEntry(database, sessionKey, entry, { - canonicalPreviousEntry: null, - previousEntry: null, - }); - } - }, - { agentId }, - ); - seed("main", entries); const crossAgent = "agent:other:child"; - seed("other", [ - [crossAgent, { sessionId: "cross-agent", updatedAt: now, spawnedBy: parent }], - ]); + replaceSessionEntrySync( + { agentId: "other", sessionKey: crossAgent }, + { sessionId: "cross-agent", updatedAt: now, spawnedBy: parent }, + ); const run = (name: string, controller: string): SubagentRunRecord => ({ runId: name, childSessionKey: key(name), @@ -819,39 +417,22 @@ it("lists indexed children without inspecting unrelated resident ownership", asy saveSubagentRegistryToSqlite(runs); const release = retainSessionListForegroundWork(); const projection = await createSessionRowProjection({ cfg, modelCatalog: [] }); - const ownership = vi.spyOn(childOwners, "resolveSessionChildOwners"); try { const opts = { spawnedBy: parent, archived: "all" as const, limit: 2 }; - const list = () => listProjectedSessions({ projection, opts }); + const list = () => listRows(projection, opts); const first = await list(); - expect(first.sessions.map((row) => row.key)).toEqual([ - key("dashboard:persistent"), - key("runtime"), - ]); + expect(rowKeys(first)).toEqual([key("dashboard:persistent"), key("runtime")]); expect(first).toMatchObject({ totalCount: 4, nextOffset: 2, hasMore: true }); - const second = await listProjectedSessions({ projection, opts: { ...opts, offset: 2 } }); - expect(second.sessions.map((row) => row.key)).toEqual([key("spawned"), crossAgent]); + const second = await listRows(projection, { ...opts, offset: 2 }); + expect(rowKeys(second)).toEqual([key("spawned"), crossAgent]); expect(second).toMatchObject({ totalCount: 4, nextOffset: null, hasMore: false }); - ownership.mockClear(); - expect(await list()).toEqual(first); - const visited = new Set(ownership.mock.calls.map(([params]) => params.key)); - ownership.mockRestore(); - const sentinel = await listProjectedSessions({ - projection, - opts: { ...opts, spawnedBy: "global" }, - }); + const sentinel = await listRows(projection, { ...opts, spawnedBy: "global" }); expect(sentinel.sessions).toEqual([]); - expect([...visited].filter((sessionKey) => sessionKey.includes("unrelated-")).length).toBe( - 0, - ); runs.set(runtime.runId, { ...runtime, controllerSessionKey: other }); subagentRuns.set(runtime.runId, runs.get(runtime.runId)!); persistRegistryFixture(runs, [runtime.runId]); const moved = await list(); - expect(moved.sessions.map((row) => row.key)).toEqual([ - key("dashboard:persistent"), - key("spawned"), - ]); + expect(rowKeys(moved)).toEqual([key("dashboard:persistent"), key("spawned")]); expect(moved.totalCount).toBe(3); runs.set(runtime.runId, { ...runtime, @@ -862,7 +443,6 @@ it("lists indexed children without inspecting unrelated resident ownership", asy persistRegistryFixture(runs, [runtime.runId]); expect((await list()).totalCount).toBe(4); } finally { - ownership.mockRestore(); projection.dispose(); runtimeExecution[Symbol.dispose](); movedExecution[Symbol.dispose](); diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index b7af4a41dc2a..5deac3f39d74 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -3,9 +3,6 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core/expect"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; -import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; -import * as sessionEntryRows from "../config/sessions/session-accessor.sqlite-status.js"; import { replaceTranscriptEvents } from "../config/sessions/session-accessor.sqlite-transcript-write.test-support.js"; import { memorySessionActorOwners } from "../config/sessions/session-actor-memory-owner.js"; import { commitMemory } from "../config/sessions/session-actor-memory-publication.test-support.js"; @@ -14,15 +11,10 @@ import type { SessionActorStorageWrites } from "../config/sessions/session-actor import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; -import { persistUserTurnTranscript } from "../sessions/user-turn-transcript.test-support.js"; import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js"; import { runOpenClawAgentWriteTransaction } from "../state/openclaw-agent-db.js"; import { resolveIncognitoOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js"; -import { setAvatar, setDisplayName } from "../state/user-profile-writes.worker.js"; -import { ensureProfileForEmail } from "../state/user-profiles.js"; -import { resolveCurrentUserProfileDisplay } from "./current-user-profile-display.js"; import * as sessionHistoryState from "./session-history-state.js"; -import { SessionHistorySseState } from "./session-history-state.js"; import { appendHistoryMessage, expectErrorResponse, @@ -32,7 +24,6 @@ import { openHistoryStream, readSessionHistoryBody, type SessionHistoryBody, - type SessionHistoryMessage, type SessionHistorySseStream, } from "./sessions-history-http-read.test-support.js"; import { @@ -97,20 +88,6 @@ async function appendMessage( return { ...appended, message }; } -function persistHistoryTurn( - storePath: string, - input: Parameters[0]["input"], -) { - return persistUserTurnTranscript({ - agentId: AGENT_ID, - sessionEntry: { sessionId: "sess-main", updatedAt: 1 }, - sessionId: "sess-main", - sessionKey: SESSION_KEY, - storePath, - input, - }); -} - function replaceHistory( storePath: string, events: Parameters[1], @@ -122,23 +99,6 @@ function replaceHistory( ); } -const sessionHistoryRowIdentity = (message: SessionHistoryMessage) => - `${message["__openclaw"]?.seq}:${message.role}:${message.content?.[0]?.text}`; - -function withMockedDateNow(now: number, run: () => T): T { - using _ = vi.spyOn(Date, "now").mockReturnValue(now); - return run(); -} - -function currentProfileAvatarUrl(profileId: string): string { - const display = resolveCurrentUserProfileDisplay(profileId); - expect(display.kind).toBe("resolved"); - if (display.kind !== "resolved") { - throw new Error("expected a resolved current profile display"); - } - return display.avatarUrl; -} - function withGatewayHistoryStream( run: (stream: SessionHistorySseStream, port: number) => Promise, query?: string, @@ -243,30 +203,19 @@ describe("session history HTTP endpoints", () => { await withGatewayHarness(async (harness) => { const cases = { "text/event-stream": [ - "text/event-stream", - "TEXT/EVENT-STREAM", - " text/event-stream ", - "text/event-stream;", - "text/event-stream; charset=utf-8", - "text/event-stream;q=0.001", - "text/event-stream;Q=1.000", + " TEXT/EVENT-STREAM;Q=1.000;charset=UTF-8; ", "text/event-stream;q=0, text/event-stream;q=0.5", "text/event-stream;q=0, text/event-stream;charset=utf-8;q=0.5", - "text/event-stream;q=0.5;charset=utf-8", ], "application/json": [ 'text/event-stream; note="quoted,comma;semicolon\\\"quote"; q=0.5', "text/event-stream;q=1, text/event-stream;charset=utf-8;q=0", - "text/event-stream;q=1;charset=utf-16", "text/event-stream;charset=utf-16", "text/event-streaming", - "text/event-streamx", 'application/json; note="text/event-stream"', "text/*", "*/*", - "text/event-stream;q=0", "text/event-stream;q=0, */*;q=1", - "text/event-stream;q=0.1234", "text/event-stream;q =0.5", "text/event-stream;q= 0.5", "text/event-stream;\u00a0q=0.5", @@ -293,167 +242,14 @@ describe("session history HTTP endpoints", () => { }); }); - test("reads only the selected history entry for default and blank cursor queries", async () => { - const { storePath } = await seedSession({ text: "hello from history" }); - const unrelatedPrompt = "Unrelated saved session prompt"; - await replaceSessionEntry( - { agentId: AGENT_ID, sessionKey: "agent:main:unrelated", storePath }, - { - sessionId: "sess-unrelated", - updatedAt: 1, - skillsSnapshot: { prompt: unrelatedPrompt, skills: [] }, - }, - ); + test("treats blank cursor queries as default history", async () => { + await seedSession({ text: "hello from history" }); await withGatewayHarness(async (harness) => { - using decode = vi.spyOn(sessionEntryRows, "parseSessionEntryJson"); for (const query of ["", "?cursor=", "?cursor=%20"]) { - decode.mockClear(); - const context = `query ${JSON.stringify(query)}`; - const res = await fetchSessionHistory(harness.port, SESSION_KEY, { query }); - expect(res.status, context).toBe(200); - const body = (await res.json()) as SessionHistoryBody; - expect(body, context).toMatchObject({ + expect(await readSessionHistoryBody(harness.port, SESSION_KEY, { query })).toMatchObject({ sessionKey: SESSION_KEY, messages: [{ content: [{ text: "hello from history" }], __openclaw: { seq: 1 } }], }); - expect( - decode.mock.calls.some(([row]) => row.entry_json.includes(unrelatedPrompt)), - context, - ).toBe(false); - } - }); - }); - - test("shares revisioned current-profile projection across REST and initial and inline SSE", async () => { - const { storePath } = await seedSession(); - - const profile = withMockedDateNow(1_800_000_000_000, () => { - const created = ensureProfileForEmail("session-history-profile@example.com"); - setDisplayName(created.id, "Old Display Name"); - expect(setAvatar(created.id, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true); - return created; - }); - const oldAvatarUrl = currentProfileAvatarUrl(profile.id); - const persistAttributedTurn = async (id: string, senderName: string, text: string) => { - const turn = await persistHistoryTurn(storePath, { - idempotencyKey: `session-history-profile:${id}`, - sender: { - id: profile.id, - identity: { type: "profile", id: profile.id }, - name: senderName, - username: "ada", - }, - text, - }); - expect(turn).toBeDefined(); - return turn!; - }; - const first = await persistAttributedTurn("first", "Historical Ada", "first turn"); - const turn = (id: string, seq: number, name: string, text: string, avatarUrl: string) => ({ - role: "user", - content: text, - __openclaw: { - id, - seq, - senderId: profile.id, - senderName: name, - senderUsername: "ada", - senderProfileAvatarUrl: avatarUrl, - }, - }); - - await withGatewayHarness(async (harness) => { - const initialRest = await readSessionHistoryBody(harness.port, SESSION_KEY); - await using stream = await openHistoryStream(harness.port, SESSION_KEY); - const initialSse = await stream.next(); - const oldExpected = turn(first.messageId, 1, "Historical Ada", "first turn", oldAvatarUrl); - expect(initialRest.messages?.[0]).toMatchObject(oldExpected); - expect(initialSse.messages?.[0]).toMatchObject(oldExpected); - - withMockedDateNow(1_900_000_000_000, () => { - setDisplayName(profile.id, "Current Ada"); - expect(setAvatar(profile.id, new Uint8Array([4, 5, 6]), "image/png").ok).toBe(true); - }); - const newAvatarUrl = currentProfileAvatarUrl(profile.id); - expect(newAvatarUrl).not.toBe(oldAvatarUrl); - - const inlineEventPromise = stream.next(); - const second = await persistAttributedTurn("second", "Current Ada", "second turn"); - const refreshEvent = await inlineEventPromise; - const expected = [ - turn(first.messageId, 1, "Historical Ada", "first turn", newAvatarUrl), - turn(second.messageId, 2, "Current Ada", "second turn", newAvatarUrl), - ]; - for (const body of [refreshEvent, await readSessionHistoryBody(harness.port, SESSION_KEY)]) { - expect(body.messages).toMatchObject(expected); - } - }); - }); - - test("attributes forwarded history only from structured provenance across transports and pages", async () => { - const { storePath } = await seedSession(); - const sessionKey = SESSION_KEY; - const cases = [ - { - body: "Verified sender body\n indented line", - sourceSessionKey: "agent:helper:ops", - senderLabel: "Forwarded from helper", - senderSession: { sessionKey: "agent:helper:ops", agentId: "helper" }, - }, - { - body: "Unverified sender body\n indented line", - sourceSessionKey: undefined, - senderLabel: "Forwarded agent message", - senderSession: undefined, - }, - ]; - for (const entry of cases) { - const persisted = await persistHistoryTurn(storePath, { - text: `[Inter-session message] sourceSession=agent:grimwald:asserted sourceTool=sessions_send isUser=false\n${entry.body}`, - provenance: { - kind: "inter_session", - sourceTool: "sessions_send", - ...(entry.sourceSessionKey ? { sourceSessionKey: entry.sourceSessionKey } : {}), - }, - }); - expect(persisted).toBeDefined(); - } - - await withGatewayHarness(async (harness) => { - const ws = await harness.openWs(); - try { - expect((await connectReq(ws, { scopes: ["operator.read"] })).ok).toBe(true); - let cursor: string | undefined; - for (const [offset, entry] of cases.toReversed().entries()) { - const http = await readSessionHistoryBody(harness.port, sessionKey, { - query: `?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, - }); - const websocket = await rpcReq<{ messages: unknown[]; hasMore: boolean }>( - ws, - "chat.history", - { sessionKey, limit: 1, offset }, - ); - expect(websocket.ok).toBe(true); - for (const page of [http, websocket.payload]) { - expect(page?.messages).toHaveLength(1); - expect(page?.messages?.[0]).toMatchObject({ - role: "assistant", - content: entry.body, - senderLabel: entry.senderLabel, - ...(entry.senderSession ? { senderSession: entry.senderSession } : {}), - }); - if (!entry.senderSession) { - expect(page?.messages?.[0]).not.toHaveProperty("senderSession"); - } - expect(page?.hasMore).toBe(offset < cases.length - 1); - } - if (offset < cases.length - 1) { - expect(http.nextCursor).toEqual(expect.any(String)); - } - cursor = http.nextCursor; - } - } finally { - ws.close(); } }); }); @@ -595,53 +391,6 @@ describe("session history HTTP endpoints", () => { ); }); - test("keeps repeated assistant replies from separate hidden user turns in REST and SSE history", async () => { - const sessionId = "sess-hidden-turn-replies"; - const sessionKey = SESSION_KEY; - const { storePath } = await seedSession({ sessionId }); - const timestamp = Date.UTC(2026, 7, 15, 9, 30, 0); - const assistantMessage = (text: string, model: string) => ({ - ...makeTranscriptAssistantMessage({ text, provider: "openclaw", model }), - timestamp, - }); - const replies = ["First reply.", "Second reply.", "Third reply."]; - await replaceHistory( - storePath, - replies.flatMap((text, index) => [ - { message: assistantMessage(text, "acp-runtime") }, - { message: { role: "user", content: index ? HEARTBEAT_PROMPT : "" } }, - ...(index === 2 ? [{ message: { role: "assistant", content: "HEARTBEAT_OK" } }] : []), - { message: assistantMessage(text, "gateway-injected") }, - ]), - sessionId, - ); - - const expectedRows = [ - [1, 3], - [4, 6], - [7, 10], - ].flatMap((sequences, index) => sequences.map((seq) => `${seq}:assistant:${replies[index]}`)); - await withGatewayHarness(async (harness) => { - const history = await readSessionHistoryBody(harness.port, sessionKey); - expect(history.messages?.map(sessionHistoryRowIdentity)).toEqual(expectedRows); - expect(history.messages?.map((message) => message.timestamp)).toEqual( - Array.from({ length: 6 }, () => timestamp), - ); - expect(history.messages?.map((message) => message["__openclaw"]?.turnBoundary)).toEqual([ - undefined, - undefined, - undefined, - true, - undefined, - true, - ]); - - await using stream = await openHistoryStream(harness.port, SESSION_KEY); - const streamedHistory = await stream.next(); - expect(streamedHistory.messages?.map(sessionHistoryRowIdentity)).toEqual(expectedRows); - }); - }); - test("caps all-digit direct REST history limits that exceed safe integer range", async () => { const { storePath } = await seedSession({ text: "first message" }); await appendText(storePath, "second message"); @@ -689,48 +438,6 @@ describe("session history HTTP endpoints", () => { }); }); - test("sanitizes phased output_text assistant history before returning it", async () => { - const { storePath } = await seedSession(); - - await withGatewayHarness(async (harness) => { - const visibleMessageId = "visible-phased-assistant"; - await replaceHistory(storePath, [ - { id: "hidden-control", message: makeTranscriptAssistantMessage({ text: "NO_REPLY" }) }, - { - id: visibleMessageId, - message: { - ...makeTranscriptAssistantMessage({ text: "Done." }), - content: [ - { id: "item_commentary", phase: "commentary", text: "internal reasoning" }, - { id: "item_final", phase: "final_answer", text: "Done." }, - ].map(({ text, ...signature }) => ({ - type: "output_text", - text, - textSignature: JSON.stringify({ v: 1, ...signature }), - })), - }, - }, - ]); - - const body = await readSessionHistoryBody(harness.port, SESSION_KEY); - expect(body.sessionKey).toBe(SESSION_KEY); - expect(body.messages).toHaveLength(2); - expect(body.messages?.[0]).toMatchObject({ - content: [{ type: "text", text: "internal reasoning" }], - openclawStreamFallback: { - itemId: "item_commentary", - replacementText: "internal reasoning", - source: "segment", - }, - }); - expect(body.messages?.[1]?.content?.map((block) => block.text)).toEqual(["Done."]); - expect(body.messages?.[1]?.["__openclaw"]).toMatchObject({ - id: visibleMessageId, - seq: 2, - }); - }); - }); - test("includes updates committed while opening bounded SSE history", async () => { const { storePath } = await seedSession({ text: "first message" }); @@ -750,9 +457,8 @@ describe("session history HTTP endpoints", () => { }); }); - test("bounds retained SSE history without truncating full history or live updates", async () => { - const retainedLimit = 1_000; - const initialCount = retainedLimit + 2; + test("serves complete history and live updates beyond the SSE retention window", async () => { + const initialCount = 1_002; const { storePath } = await seedSession(); await replaceHistory( storePath, @@ -763,7 +469,6 @@ describe("session history HTTP endpoints", () => { })), ); - using snapshotSpy = vi.spyOn(SessionHistorySseState.prototype, "snapshot"); const expectWindow = ( body: SessionHistoryBody | undefined, length: number, @@ -778,31 +483,22 @@ describe("session history HTTP endpoints", () => { expect(body?.messages?.at(-1)?.content?.[0]?.text).toBe(last); } }; - const retainedSnapshot = () => snapshotSpy.mock.results.at(-1)?.value; await withGatewayHistoryStream(async (stream, port) => { const initial = await stream.next(); expectWindow(initial, initialCount, "history message 1"); - expectWindow(retainedSnapshot(), retainedLimit, "history message 3"); await using cursorStream = await openHistoryStream( port, SESSION_KEY, `?cursor=${initialCount + 1}`, ); expectWindow(await cursorStream.next(), initialCount); - expectWindow(retainedSnapshot(), retainedLimit, "history message 3"); const messageId = await appendText(storePath, "live history message"); const lastSequence = initial.messages?.at(-1)?.["__openclaw"]?.seq; expect(lastSequence).toEqual(expect.any(Number)); await stream.expectMessage("live history message", (lastSequence ?? 0) + 1, messageId); - const liveRetained = snapshotSpy.mock.results.findLast((result) => { - const snapshot = result.value; - return snapshot?.messages?.at(-1)?.content?.[0]?.text === "live history message"; - })?.value; - expectWindow(liveRetained, retainedLimit, undefined, "live history message"); expectWindow(await cursorStream.next(), initialCount, "history message 1"); - expectWindow(retainedSnapshot(), retainedLimit); const completeHistory = await vi.waitFor(() => readSessionHistoryBody(port, SESSION_KEY), { interval: 25, diff --git a/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts b/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts index 833c4fa19de8..d85c6139763a 100644 --- a/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts +++ b/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts @@ -143,19 +143,13 @@ describe("worker turn launcher terminal results", () => { providerFailure?: true; }>([ { stopReason: "stop", reconciliationFails: false }, - { stopReason: "error", reconciliationFails: false }, - { stopReason: "stop", reconciliationFails: true }, { stopReason: "error", reconciliationFails: true }, { stopReason: "error", reconciliationFails: false, providerFailure: true }, - ...[ - "Browser cleanup timed out", - "Browser cleanup failed: rate limit exceeded", - "Browser disposal failed", - ].map((cleanupFailure) => ({ - stopReason: "stop" as const, + { + stopReason: "stop", reconciliationFails: false, - cleanupFailure, - })), + cleanupFailure: "Browser cleanup failed: rate limit exceeded", + }, ])( "retains the ACKed finishing outcome after assistant $stopReason (reconciliation fails: $reconciliationFails; cleanup: $cleanupFailure; provider fallback: $providerFailure)", async ({ stopReason, reconciliationFails, cleanupFailure, providerFailure }) => { @@ -290,7 +284,6 @@ describe("worker turn launcher terminal results", () => { }), ); }), - syncWorkspace: vi.fn(), reconcileWorkspace: vi.fn(async (request) => { if (reconciliationFails) { throw reconciliationError; @@ -322,7 +315,7 @@ describe("worker turn launcher terminal results", () => { const uninstall = installSessionPlacementAdmissionProvider(provider); try { const runLocal = vi.fn(localResult); - const execute = vi.fn(() => provider.executeTurn(runIdentity(runId), workerTurn, runLocal)); + const execute = () => provider.executeTurn(runIdentity(runId), workerTurn, runLocal); const config = { ...workerTurn.config, agents: { @@ -336,7 +329,7 @@ describe("worker turn launcher terminal results", () => { }; let mediaTasks = getGeneratedMediaTaskIdsForSessionKey(fx.SESSION_KEY); const candidateFailures: unknown[] = []; - const runCandidate = vi.fn((candidateProvider: string, model: string) => { + const runCandidate = (candidateProvider: string, model: string) => { mediaTasks = getGeneratedMediaTaskIdsForSessionKey(fx.SESSION_KEY); return runEmbeddedAgent({ ...workerTurn, @@ -348,7 +341,7 @@ describe("worker turn launcher terminal results", () => { candidateFailures.push(error); throw error; }); - }); + }; const runOuterEntry = () => runEmbeddedAgentEntry({ selection: { @@ -411,7 +404,6 @@ describe("worker turn launcher terminal results", () => { await expectSettled(); if (providerFailure) { expect(launchedModels).toEqual(["gpt-5.6-luna", "gpt-5.6-sol"]); - expect(runCandidate).toHaveBeenCalledTimes(2); expect(observed).toMatchObject({ message: "Unexpected second worker execution" }); await expect(fs.stat(effectFile)).rejects.toMatchObject({ code: "ENOENT" }); return; @@ -420,14 +412,13 @@ describe("worker turn launcher terminal results", () => { .soft(launchedModels, "cleanup must not replay the committed worker effect") .toEqual(["gpt-5.6-luna"]); expect.soft(await fs.readFile(effectFile, "utf8")).toBe("effect\n"); - expect.soft(runCandidate).toHaveBeenCalledOnce(); expect.soft(observed).toBe(candidateFailures[0]); return; } expect(observed).toMatchObject({ message: expect.stringContaining(failure) }); if (reconciliationFails) { - expect(observed).toMatchObject({ cause: expect.any(WorkerWorkspaceReconciliationError) }); expect(observed).toMatchObject({ cause: { cause: reconciliationError } }); + expect(observed).toMatchObject({ cause: expect.any(WorkerWorkspaceReconciliationError) }); expect(resolveModelFallbackError(observed)).toEqual({ kind: "coordination", error: observed, @@ -437,8 +428,7 @@ describe("worker turn launcher terminal results", () => { expect(observed).toMatchObject({ message: failure }); expect(reconcileActivePlacement).not.toHaveBeenCalled(); } - expect(execute).toHaveBeenCalledOnce(); - expect(tunnel.launchTurn).toHaveBeenCalledOnce(); + expect(launchedModels).toHaveLength(1); expect(runLocal).not.toHaveBeenCalled(); await expectSettled(); await expect( @@ -764,7 +754,6 @@ describe("worker turn launcher terminal results", () => { await completed.appendMessageAsync( makeAgentAssistantMessage({ content: [{ type: "toolCall", id: "call-usage", name: "read", arguments: {} }], - provider: "openai", model: "gpt-first-call", stopReason: "toolUse", timestamp: 21, diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index 26c94551bb79..0b85c0bf6ebd 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -213,32 +213,27 @@ test("defers clearing an existing context until all overlapping owners exit", () }); test("reserves exclusive run ids for owner-only delivery and cleanup", () => { - const claimId = claimRun("run", { sessionKey: "worker" }, { exclusive: true, trackOwner: true })!; - expect(claimRun("run", { sessionKey: "local" }, { trackOwner: true })).toBeUndefined(); - const seen: unknown[] = []; - onAgentEventForRun("run", ({ data }) => seen.push(data.text)); - const event = (text: string) => ({ runId: "run", stream: "assistant", data: { text } }); - emitAgentEventIfCurrent(event("local")); - emitAgentEventForOwner(event("worker"), claimId); - clearRun("run", readGeneration()); - expect(getRun("run")?.sessionKey).toBe("worker"); - releaseRun("run", claimId); - expect(getRun("run")).toBeUndefined(); - emitAgentEventForOwner(event("late"), claimId); - expect(seen).toEqual(["worker"]); -}); - -test("drops stale explicit-generation events before shared listeners", () => { - const lifecycleGeneration = readGeneration(); - registerRun("run", { sessionKey: "main", lifecycleGeneration }); - const seen = captureEvents(); - expect( - sendEvent("run", "lifecycle", { phase: "end" }, { lifecycleGeneration: "pre-restart" }), - ).toBe(false); - expect( - sendEvent("run", "lifecycle", { phase: "start", startedAt: 1_000 }, { lifecycleGeneration }), - ).toBe(true); - expect(seen).toMatchObject([{ seq: 1, data: { phase: "start" } }]); + withGeneration(readGeneration(), () => { + const claimId = claimRun( + "run", + { sessionKey: "worker" }, + { exclusive: true, trackOwner: true }, + )!; + expect(claimRun("run", { sessionKey: "local" }, { trackOwner: true })).toBeUndefined(); + const seen: unknown[] = []; + onAgentEventForRun("run", ({ data }) => seen.push(data.text)); + const event = (text: string) => ({ runId: "run", stream: "assistant", data: { text } }); + emitAgentEventIfCurrent(event("local")); + emitAgentEventForOwner(event("worker"), claimId); + clearRun("run", readGeneration()); + expect(getRun("run")?.sessionKey).toBe("worker"); + releaseRun("run", claimId); + expect(getRun("run")).toBeUndefined(); + emitAgentEventForOwner(event("late"), claimId); + expect(seen).toEqual(["worker"]); + rotateGeneration(); + expect(emitAgentEventIfCurrent(event("rotated"))).toBe(false); + }); }); test("rejects inherited stale ownership and cannot reclaim the admitted replacement", () => { @@ -847,28 +842,6 @@ describe("agent event routing after cancellation", () => { expect(events[2]?.sessionKey).toBeUndefined(); }); - it("never lets routing provenance revive a released claim or rotated execution", () => { - const generation = readGeneration(); - const events = captureEvents(onAgentRuntimeEvent); - withGeneration(generation, () => { - const claim = claimRun( - "owned", - { agentId: "delivery", sessionKey: "agent:delivery:owned" }, - { exclusive: true, trackOwner: true, ownsContext: true }, - ); - expect(claim).toBeDefined(); - const event = { runId: "owned", stream: "tool", data: { phase: "result" } }; - emitAgentEventForOwner(event, claim!); - clearRun("owned", generation, claim); - releaseRun("owned", claim); - expect(getRun("owned")).toBeUndefined(); - emitAgentEventForOwner(event, claim!); - rotateGeneration(); - expect(emitAgentEventIfCurrent(event)).toBe(false); - }); - expect(events).toHaveLength(1); - }); - it("extends a retained v2026.9.4 scope without replacing its execution owner", async () => { const lifecycleGeneration = readGeneration(); // Released updaters can retain this exact shape while loading current runtime aliases. @@ -1001,12 +974,14 @@ describe("live agent model projection", () => { rotateGeneration(); clearRun(runId, generation); registerRun(runId, { sessionKey: "agent:main:replacement" }); - emitAgentEvent({ - runId, - lifecycleGeneration: generation, - stream: "lifecycle", - data: { phase: "model", provider: "stale", model: "stale" }, - }); + expect( + emitAgentEventIfCurrent({ + runId, + lifecycleGeneration: generation, + stream: "lifecycle", + data: { phase: "model", provider: "stale", model: "stale" }, + }), + ).toBe(false); expect(getRun(runId)).not.toHaveProperty("activeModel"); }); diff --git a/src/infra/git-worker.lifecycle.test.ts b/src/infra/git-worker.lifecycle.test.ts index bcf07bc900a1..7ee65fdd2a6d 100644 --- a/src/infra/git-worker.lifecycle.test.ts +++ b/src/infra/git-worker.lifecycle.test.ts @@ -13,16 +13,10 @@ import * as worktreeGit from "../agents/worktrees/git.js"; import { runPublicationCommand } from "../gateway/github-publication-git-transport.js"; import { runWorkspaceInventoryCommandToFile } from "../gateway/worker-environments/workspace-sync-inventory.js"; import { ensureStagedInputDirectory, stagedInputDirectory } from "../media/staged-inputs.js"; -import { emitChildProcessSpawnSample } from "../process/spawn-diagnostics.js"; import { createDeferredCore } from "../shared/deferred.js"; import { drainGlobalSingletonLifecycleState } from "../shared/global-singleton.js"; import { isPidAlive } from "../shared/pid-alive.js"; import { killPidIfAlive } from "../test-utils/process-tree.js"; -import { - onDiagnosticEvent, - setDiagnosticsEnabledForProcess, - type DiagnosticEventPayload, -} from "./diagnostic-events.js"; import * as gitExec from "./git-exec.js"; import { installUnknownDirentFixture } from "./git-worker-dir.test-support.js"; import { runGitWorkerOperation, type GitWorkerOperationOptions } from "./git-worker.js"; @@ -431,18 +425,6 @@ describe("Git operation host lifecycle", () => { const peerRoot = path.join(root, "peer"); await fs.mkdir(peerRoot); const peer = await repository(peerRoot); - let now = 0; - vi.spyOn(performance, "now").mockImplementation(() => now); - setDiagnosticsEnabledForProcess(false); - emitChildProcessSpawnSample(); - setDiagnosticsEnabledForProcess(true); - const spawns: Extract[] = - []; - const stop = onDiagnosticEvent((event) => { - if (event.type === "diagnostic.child_process.spawn") { - spawns.push(event); - } - }); const entered = createDeferredCore(); const release = createDeferredCore(); const realRun = worktreeGit.runGitBytes; @@ -489,18 +471,7 @@ describe("Git operation host lifecycle", () => { } finally { release.resolve(); await Promise.all(pending); - now = 60_000; - emitChildProcessSpawnSample(); - stop(); - setDiagnosticsEnabledForProcess(false); - emitChildProcessSpawnSample(); } - expect( - spawns - .map(({ operation }) => operation ?? "unknown") - .toSorted((left, right) => left.localeCompare(right)), - ).toEqual(["checkout.diff", "repository.branches"]); - expect(spawns.every(({ family, count }) => family === "git" && count > 0)).toBe(true); }); it.each(["cleanup-inspection", "snapshot"] as const)( diff --git a/src/infra/state-migrations.shared-auth-store.test.ts b/src/infra/state-migrations.shared-auth-store.test.ts index e7410c88b67f..2e1bec880302 100644 --- a/src/infra/state-migrations.shared-auth-store.test.ts +++ b/src/infra/state-migrations.shared-auth-store.test.ts @@ -8,7 +8,6 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { resolveAgentDir } from "../agents/agent-scope-config.js"; import * as ownership from "../agents/auth-profiles/path-resolve.js"; import { prepareSharedAuthStoreOwnershipForNative } from "../agents/auth-profiles/path-resolve.native.js"; -import * as persisted from "../agents/auth-profiles/persisted.js"; import { setAuthProfileOrder } from "../agents/auth-profiles/profiles.js"; import * as paths from "../agents/auth-profiles/shared-main-dir.js"; import * as bootstrap from "../agents/auth-profiles/shared-store-bootstrap.js"; @@ -18,7 +17,6 @@ import { readSharedAuthProfileRows, } from "../agents/auth-profiles/sqlite-read.js"; import * as sqlite from "../agents/auth-profiles/sqlite.js"; -import * as authState from "../agents/auth-profiles/state.js"; import { loadAuthProfileStoreWithoutExternalProfiles, saveAuthProfileStore, @@ -101,9 +99,6 @@ function receiptRow(db: EngineConnection) { "SELECT source_sha256, source_record_count, status, removed_source FROM migration_sources WHERE target_table = 'auth_profile_stores'", ); } -function count(db: EngineConnection, table: string, key: string, value: string) { - return row(db, `SELECT COUNT(*) AS count FROM ${table} WHERE ${key} = ?`, value)?.count; -} function completedReceipt(source: unknown) { return { source_sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"), @@ -321,43 +316,13 @@ describe("shared auth relocation lifecycle", () => { `); } - const sourceChecks = new Map(); - // oxlint-disable-next-line typescript/unbound-method -- Invoked with the intercepted native database receiver. - const prepare = DatabaseSync.prototype.prepare; - vi.spyOn(DatabaseSync.prototype, "prepare").mockImplementation(function ( - this: DatabaseSync, - sql, - ) { - const statement = prepare.call(this, sql); - if (this.location() !== sourcePath || !/^PRAGMA (?:quick|integrity)_check\b/u.test(sql)) { - return statement; - } - const all = statement.all.bind(statement); - statement.all = () => { - const checks = sourceChecks.get(this) ?? []; - checks.push(sql); - sourceChecks.set(this, checks); - return all(); - }; - return statement; - }); - const result = await migrate(sourcePath, integrityCheck); if (integrityCheck === "full") { - expect([...sourceChecks.values()]).toEqual([["PRAGMA integrity_check;"]]); expect(result).toMatchObject({ changes: [], warnings: [expect.stringMatching(/integrity_check.*idx_agent_cache_updated/su)], }); } else { - expect([...sourceChecks.values()]).toEqual([ - ["PRAGMA quick_check;"], - [ - "PRAGMA quick_check;", - "PRAGMA integrity_check('auth_profile_store');", - "PRAGMA integrity_check('auth_profile_state');", - ], - ]); expect(result.warnings).toEqual([]); expect( await readSharedAuthProfileRows(captureOpenClawStateWorkerContext({ env: state.env })), @@ -481,10 +446,8 @@ it.runIf(process.platform === "linux")( }, ); -it.each([false, true])("records ownership only (source=%s)", async (hasSource) => { - const fixture = await createEmptyFixture(hasSource); - expect(fs.existsSync(fixture.sourcePath)).toBe(hasSource); - +it("records ownership for an empty source database without reporting relocation", async () => { + const fixture = await createEmptyFixture(true); const detected = fixture.detect(); expect(detected.hasLegacy).toBe(true); expect(await fixture.migrate(detected)).toEqual(NO_CHANGES); @@ -493,65 +456,26 @@ it.each([false, true])("records ownership only (source=%s)", async (hasSource) = }); }); -it("moves exact rows, preserves every effective agent store, and records receipts", async () => { +it("preserves effective main and private-agent auth across relocation", async () => { const fixture = await createFixture(); - const effectiveBytes = (agentDir: string) => { - const effective = loadAuthProfileStoreWithoutExternalProfiles(agentDir); - return JSON.stringify({ - credentials: persisted.buildPersistedAuthProfileSecretsStore(effective), - state: authState.buildPersistedAuthProfileState(effective), + const effective = () => + [fixture.mainAgentDir, fixture.opsAgentDir].map((agentDir) => { + const { profiles, order, lastGood } = loadAuthProfileStoreWithoutExternalProfiles(agentDir); + return { profiles, order, lastGood }; }); - }; - const before = { - main: effectiveBytes(fixture.mainAgentDir), - ops: effectiveBytes(fixture.opsAgentDir), - }; - const detected = fixture.detect(); - - expect(await fixture.migrate(detected)).toMatchObject({ + const before = effective(); + expect(await fixture.migrate(fixture.detect())).toMatchObject({ warnings: [], changes: [expect.stringContaining("Relocated shared auth")], }); - - expect({ - store: sqlite.readPersistedAuthProfileStoreRaw(), - state: sqlite.readPersistedAuthProfileStateRaw(), - local: localAuth(fixture.mainAgentDir), - }).toEqual({ - store: fixture.sharedStore, - state: fixture.sharedState, - local: { store: null, state: null }, - }); - expect({ - main: effectiveBytes(fixture.mainAgentDir), - ops: effectiveBytes(fixture.opsAgentDir), - }).toEqual(before); - - const database = stateDb.openOpenClawStateDatabase({ env: fixture.env }).db; - for (const [key, value] of [ - ["authProfiles.store", fixture.sharedStore], - ["authProfiles.state", fixture.sharedState], - ["auth.sharedStore", { location: "state-db" }], - ] as const) { - expect(machineRow(database, key)).toEqual({ value_json: JSON.stringify(value) }); - } - expect( - row( - database, - "SELECT COUNT(*) AS count FROM migration_sources WHERE migration_kind = ?", - "shared-auth-store-state-db", - ), - ).toEqual({ count: 2 }); + expect(effective()).toEqual(before); }); -it.each(["standard", "custom", "main override"] as const)( +it.each(["custom", "main override"] as const)( "relocates the configured inheritance owner through Doctor (%s)", async (layout) => { const fixture = await createEmptyFixture(false); - const ownerDir = path.join( - fixture.stateDir, - layout === "custom" ? "custom-owner" : "agents/ops/agent", - ); + const ownerDir = path.join(fixture.stateDir, "custom-owner"); const peerDir = path.join(fixture.stateDir, "agents/dev/agent"); const config: OpenClawConfig = { agents: { @@ -755,11 +679,6 @@ it.each([ expect(result.warnings).toEqual(converges ? [] : [expect.stringMatching(/conflict.*Back up/)]); if (!converges) { expect(result.warnings[0]).toContain(conflict); - expect(result.warnings[0]).toContain("openclaw doctor --fix"); - expect(result.warnings[0]).toContain(JSON.stringify(sourcePath)); - expect(result.warnings[0]).toContain( - JSON.stringify(path.join(fixture.stateDir, "state", "openclaw.sqlite")), - ); expect(result.warnings[0]).not.toMatch(/shared-key|extra-key|different-key|legacyMetadata/); } expect(machineRow(migratedTarget, "authProfiles.store", true)).toEqual(targetRow); @@ -769,6 +688,13 @@ it.each([ expect(fixture.detect().hasLegacy).toBe(!converges); if (scenario === "changed credential") { const warning = result.warnings[0]!; + for (const guidance of [ + "openclaw doctor --fix", + sourcePath, + path.join(fixture.stateDir, "state", "openclaw.sqlite"), + ]) { + expect(warning).toContain(guidance); + } expect(warning).toContain(`${JSON.stringify(escapedProfileId)}: missing from target`); expect(warning.indexOf(JSON.stringify(escapedProfileId))).toBeLessThan( warning.indexOf('"openai:shared"'), @@ -834,14 +760,10 @@ it.each(["absolute", "tilde"] as const)( }, { env: source.env }, ); - expect(localAuth(source.agentDir)).toEqual({ store: source.store, state: source.state }); } const detected = await detectLegacy(selected.env, () => selected.owner.home); - expect(detected).toMatchObject({ - stateDir: selected.owner.stateDir, - stateSchema: { hasLegacy: false }, - }); + expect(detected.stateDir).toBe(selected.owner.stateDir); const selectedSourcePath = sqlite.resolveAuthProfileDatabasePath(selected.agentDir); expect .soft(detected.sharedAuthStore) @@ -981,14 +903,15 @@ for (const crashState of [ value_json: targetStoreJson, }); } - expect( - ["authProfiles.store", "authProfiles.state"].map((key) => - count(migratedTarget, "config_machine_state", "state_key", key), - ), - ).toEqual([1, 1]); + expect(sharedAuth(fixture.env)).toEqual({ + store: JSON.parse(targetStoreJson), + state: fixture.sharedState, + }); using cleanedSource = new DatabaseSync(sourcePath, { readOnly: true }); - expect(count(cleanedSource, "auth_profile_store", "store_key", "primary")).toBe(0); - expect(count(cleanedSource, "auth_profile_state", "state_key", "primary")).toBe(0); + expect([readSource(cleanedSource, "store"), readSource(cleanedSource, "state")]).toEqual([ + undefined, + undefined, + ]); }); } diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 52c1be935a0d..b16c4396768b 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -1,4 +1,5 @@ import "../test-utils/prepare-compiled-subprocesses.js"; +import assert from "node:assert/strict"; import { constants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -7,8 +8,6 @@ import { configureFsSafeNative, getFsSafeNativeConfig } from "@openclaw/fs-safe/ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as jsonFiles from "../infra/json-files.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { withTempDir } from "../test-utils/temp-dir.js"; import { createMarketplaceInstallInput, expectMarketplaceInstallSuccess, @@ -76,8 +75,6 @@ function mockSuccessfulMarketplaceInstall() { ok: true, pluginId: "frontend-design", targetDir: "/tmp/frontend-design", - version: "0.1.0", - extensions: ["index.ts"], }); } @@ -107,25 +104,6 @@ async function writeLocalMarketplaceFixture(rootDir: string) { }; } -async function withKnownMarketplaceRegistry( - marketplaces: Record | ((homeDir: string) => Record), - run: (homeDir: string) => Promise, -): Promise { - return await withTempDir("openclaw-marketplace-known-", async (homeDir) => { - const openClawHome = path.join(homeDir, "openclaw-home"); - const registryPath = path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"); - await fs.mkdir(path.dirname(registryPath), { recursive: true }); - await fs.mkdir(openClawHome, { recursive: true }); - await fs.writeFile( - registryPath, - JSON.stringify(typeof marketplaces === "function" ? marketplaces(homeDir) : marketplaces), - ); - return await withEnvAsync({ HOME: homeDir, OPENCLAW_HOME: openClawHome }, async () => - run(homeDir), - ); - }); -} - function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string; @@ -133,10 +111,9 @@ function mockRemoteMarketplaceClone(params: { symlinkPath?: string; }) { runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { - const repoDir = argv.at(-1); - expect(typeof repoDir).toBe("string"); + const repoDir = argv.at(-1)!; await writeRemoteMarketplaceFixture({ - repoDir: repoDir as string, + repoDir, ...params, }); if (params.symlinkPath) { @@ -144,7 +121,7 @@ function mockRemoteMarketplaceClone(params: { "openclaw-marketplace-outside", tempOutsideDirs, ); - const link = path.join(repoDir as string, params.symlinkPath); + const link = path.join(repoDir, params.symlinkPath); await fs.mkdir(path.dirname(link), { recursive: true }); await fs.symlink(outsideDir, link); } @@ -152,21 +129,6 @@ function mockRemoteMarketplaceClone(params: { }); } -function expectRemoteCloneCommand() { - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); - const [argv, options] = runCommandWithTimeoutMock.mock.calls[0] ?? []; - expect(Array.isArray(argv)).toBe(true); - expect((argv as unknown[]).slice(0, 5)).toEqual([ - "git", - "clone", - "--depth", - "1", - "https://github.com/owner/repo.git", - ]); - expect(typeof (argv as unknown[])[5]).toBe("string"); - expect(options).toEqual({ timeoutMs: 120_000 }); -} - function expectLocalMarketplaceInstallResult( result: unknown, pluginDir: string, @@ -177,14 +139,31 @@ function expectLocalMarketplaceInstallResult( } describe("marketplace plugins", () => { + let rootDir: string; + beforeEach(async () => { + rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-")); + }); + afterEach(async () => { fetchWithSsrFGuardMock.mockClear(); installPluginFromPathMock.mockReset(); runCommandWithTimeoutMock.mockReset(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); await cleanupTrackedTempDirsAsync(tempOutsideDirs); + await fs.rm(rootDir, { recursive: true, force: true }); }); + async function known(marketplaces: Record) { + vi.stubEnv("HOME", rootDir); + vi.stubEnv("OPENCLAW_HOME", path.join(rootDir, "openclaw-home")); + await fs.mkdir(path.join(rootDir, "openclaw-home"), { recursive: true }); + const registryPath = path.join(rootDir, ".claude", "plugins", "known_marketplaces.json"); + await fs.mkdir(path.dirname(registryPath), { recursive: true }); + await fs.writeFile(registryPath, JSON.stringify(marketplaces)); + return rootDir; + } + it.each([ [ "GitHub primary identifier and ref", @@ -223,54 +202,31 @@ describe("marketplace plugins", () => { { type: " ", source: "git", url: 42, repo: " https://example.com/alias.git " }, gitSource("https://example.com/alias.git"), ], - [ - "GitHub missing optional fields", - { type: "github", repo: "owner/repo" }, - githubSource("owner/repo"), - ], - [ - "Git explicit ref before branch and tag", - { - type: "git", - url: "https://example.com/repo.git", - ref: " pinned ", - branch: "main", - tag: "v1", - }, - gitSource("https://example.com/repo.git", "pinned"), - ], ] as const)("normalizes %s in local marketplace listings", async (_label, source, expected) => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - await writeMarketplaceManifest(rootDir, { plugins: [{ name: "example", source }] }); + await writeMarketplaceManifest(rootDir, { plugins: [{ name: "example", source }] }); - const result = await listMarketplacePlugins({ marketplace: rootDir }); + const result = await listMarketplacePlugins({ marketplace: rootDir }); - expect(result.ok).toBe(true); - if (!result.ok) { - throw new Error(result.error); - } - expect(result.manifest.plugins[0]?.source).toStrictEqual(expected); - expectNoNetwork(); - }); + assert(result.ok); + expect(result.manifest.plugins[0]?.source).toStrictEqual(expected); + expectNoNetwork(); }); it.each([ { source: { type: "github", repo: " ", url: false }, error: 'github source missing "repo"' }, { source: { type: "git", url: null, repo: "\t" }, error: 'git source missing "url"' }, ])("preserves $error in local marketplace listings", async ({ source, error }) => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const manifestPath = await writeMarketplaceManifest(rootDir, { - plugins: [{ name: "example", source }], - }); - - const result = await listMarketplacePlugins({ marketplace: rootDir }); - - expect(result).toEqual({ - ok: false, - error: `invalid marketplace entry "example" in ${manifestPath}: ${error}`, - }); - expectNoNetwork(); + const manifestPath = await writeMarketplaceManifest(rootDir, { + plugins: [{ name: "example", source }], }); + + const result = await listMarketplacePlugins({ marketplace: rootDir }); + + expect(result).toEqual({ + ok: false, + error: `invalid marketplace entry "example" in ${manifestPath}: ${error}`, + }); + expectNoNetwork(); }); it.each([false, true])("loads a symlinked manifest with oversized=%s", async (oversized) => { @@ -278,57 +234,41 @@ describe("marketplace plugins", () => { // Windows CI does not guarantee symlink support. return; } - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); - await fs.mkdir(path.dirname(manifestPath), { recursive: true }); - const targetPath = path.join(rootDir, "real-manifest.json"); - await fs.writeFile( - targetPath, - oversized - ? Buffer.alloc(16 * 1024 * 1024 + 1, "x") - : JSON.stringify(manifestFor(".", "symlinked-plugin")), - ); - await fs.symlink(targetPath, manifestPath); + const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + const targetPath = path.join(rootDir, "real-manifest.json"); + await fs.writeFile( + targetPath, + oversized + ? Buffer.alloc(16 * 1024 * 1024 + 1, "x") + : JSON.stringify(manifestFor(".", "symlinked-plugin")), + ); + await fs.symlink(targetPath, manifestPath); - const result = await listMarketplacePlugins({ marketplace: rootDir }); + const result = await listMarketplacePlugins({ marketplace: rootDir }); - if (oversized) { - expect(result).toEqual({ ok: false, error: "Marketplace manifest too large" }); - } else { - expect(result.ok).toBe(true); - if (!result.ok) { - throw new Error(result.error); - } - expect(result.manifest.plugins).toHaveLength(1); - } - }); + if (oversized) { + expect(result).toEqual({ ok: false, error: "Marketplace manifest too large" }); + } else { + assert(result.ok); + expect(result.manifest.plugins).toHaveLength(1); + } }); it("preserves the logical local install path instead of canonicalizing it", async () => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const canonicalRootDir = await fs.realpath(rootDir); - const canonicalPluginDir = path.join(canonicalRootDir, "plugins", "frontend-design"); - const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); - mockSuccessfulMarketplaceInstall(); + const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); + mockSuccessfulMarketplaceInstall(); - const result = await installPluginFromMarketplace({ - marketplace: manifestPath, - plugin: "frontend-design", - }); + const result = await installPluginFromMarketplace({ + marketplace: manifestPath, + plugin: "frontend-design", + }); - expectLocalMarketplaceInstallResult(result, pluginDir, manifestPath); - expect(installPluginInput().installPolicyRequest).toMatchObject({ - kind: "plugin-dir", - requestedSpecifier: `frontend-design@${manifestPath}`, - source: { kind: "local-path", authority: "user", mutable: true, network: false }, - }); - if (canonicalPluginDir !== pluginDir) { - expect( - installPluginFromPathMock.mock.calls.some( - ([input]) => (input as { path?: unknown } | undefined)?.path === canonicalPluginDir, - ), - ).toBe(false); - } + expectLocalMarketplaceInstallResult(result, pluginDir, manifestPath); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-dir", + requestedSpecifier: `frontend-design@${manifestPath}`, + source: { kind: "local-path", authority: "user", mutable: true, network: false }, }); }); @@ -338,187 +278,150 @@ describe("marketplace plugins", () => { ] as const)( "rejects only ambiguous marketplace selections during %s", async (mode, duplicateName) => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); - await writeMarketplaceManifest(rootDir, { - plugins: [ - { name: pluginName, source: relativePlugin }, - { name: duplicateName, source: "./plugins/other" }, - { name: "Frontend-design", source: relativePlugin }, - ], - }); - mockSuccessfulMarketplaceInstall(); - - await expect(listMarketplacePlugins({ marketplace: manifestPath })).resolves.toMatchObject({ - ok: true, - manifest: { - plugins: [{ name: pluginName }, { name: pluginName }, { name: "Frontend-design" }], - }, - }); - await expect( - installPluginFromMarketplace({ marketplace: manifestPath, plugin: pluginName, mode }), - ).resolves.toEqual({ - ok: false, - error: `plugin "${pluginName}" is ambiguous in marketplace ${manifestPath}: 2 entries share this name; remove or rename the duplicate entries`, - }); - expect(installPluginFromPathMock).not.toHaveBeenCalled(); - - await expect( - installPluginFromMarketplace({ - marketplace: manifestPath, - plugin: "Frontend-design", - mode, - }), - ).resolves.toMatchObject({ ok: true, marketplacePlugin: "Frontend-design" }); - expect(installPluginInput()).toMatchObject({ path: pluginDir, mode }); - expectNoNetwork(); + const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); + await writeMarketplaceManifest(rootDir, { + plugins: [ + { name: pluginName, source: relativePlugin }, + { name: duplicateName, source: "./plugins/other" }, + { name: "Frontend-design", source: relativePlugin }, + ], }); + mockSuccessfulMarketplaceInstall(); + + await expect(listMarketplacePlugins({ marketplace: manifestPath })).resolves.toMatchObject({ + ok: true, + manifest: { + plugins: [{ name: pluginName }, { name: pluginName }, { name: "Frontend-design" }], + }, + }); + await expect( + installPluginFromMarketplace({ marketplace: manifestPath, plugin: pluginName, mode }), + ).resolves.toEqual({ + ok: false, + error: `plugin "${pluginName}" is ambiguous in marketplace ${manifestPath}: 2 entries share this name; remove or rename the duplicate entries`, + }); + expect(installPluginFromPathMock).not.toHaveBeenCalled(); + + await expect( + installPluginFromMarketplace({ + marketplace: manifestPath, + plugin: "Frontend-design", + mode, + }), + ).resolves.toMatchObject({ ok: true, marketplacePlugin: "Frontend-design" }); + expect(installPluginInput()).toMatchObject({ path: pluginDir, mode }); + expectNoNetwork(); }, ); it("does not publish a marketplace plugin after authority closes during artifact review", async () => { - await withTempDir("openclaw-marketplace-guard-", async (rootDir) => { - const { pluginDir, extensionsDir } = createBundleInstallFixtureFactory(() => rootDir)({ - bundleFormat: "claude", - name: "Guarded Bundle", - }); - const manifestPath = await writeMarketplaceManifest(rootDir, { - plugins: [{ name: "guarded-bundle", source: "./plugin-src" }], - }); - const { installPluginFromPath } = await import("./install-package.js"); - installPluginFromPathMock.mockImplementationOnce(installPluginFromPath); - let authorityActive = true; - const result = await installPluginFromMarketplace({ - marketplace: manifestPath, - plugin: "guarded-bundle", - extensionsDir, - onBeforePluginArtifactCommit: async () => { - authorityActive = false; - }, - beforePersistentApply: () => { - if (!authorityActive) { - throw new Error("plugin installation authority closed"); - } - }, - }); - - expect(authorityActive).toBe(false); - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("plugin installation authority closed"), - }); - await expect(fs.stat(path.join(extensionsDir, "guarded-bundle"))).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect(fs.stat(pluginDir)).resolves.toBeDefined(); + const { pluginDir, extensionsDir } = createBundleInstallFixtureFactory(() => rootDir)({ + bundleFormat: "claude", + name: "Guarded Bundle", }); + const manifestPath = await writeMarketplaceManifest(rootDir, { + plugins: [{ name: "guarded-bundle", source: "./plugin-src" }], + }); + const { installPluginFromPath } = await import("./install-package.js"); + installPluginFromPathMock.mockImplementationOnce(installPluginFromPath); + let authorityActive = true; + const result = await installPluginFromMarketplace({ + marketplace: manifestPath, + plugin: "guarded-bundle", + extensionsDir, + onBeforePluginArtifactCommit: async () => { + authorityActive = false; + }, + beforePersistentApply: () => { + if (!authorityActive) { + throw new Error("plugin installation authority closed"); + } + }, + }); + + expect(authorityActive).toBe(false); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("plugin installation authority closed"), + }); + await expect(fs.stat(path.join(extensionsDir, "guarded-bundle"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fs.stat(pluginDir)).resolves.toBeDefined(); }); it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => { - await withKnownMarketplaceRegistry( - (homeDir) => ({ - "claude-plugins-official": { - source: { source: "github", repo: "anthropics/claude-plugins-official" }, - installLocation: path.join(homeDir, ".claude", "plugins", "marketplaces", "official"), - }, - }), - async () => { - expect( - await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"), - ).toEqual({ - ok: true, - plugin: "superpowers", - marketplaceSource: "claude-plugins-official", - }); - }, - ); + await known({ fixture: { installLocation: rootDir } }); + expect(await resolveMarketplaceInstallShortcut("tool@fixture")).toEqual({ + ok: true, + plugin: "tool", + marketplaceSource: "fixture", + }); }); - const cyclicKnownMarketplaces = [ - { - label: "self-referential", - marketplace: "loop", - marketplaces: { - loop: pathAlias("loop"), - }, - cycle: "loop -> loop", - }, - { - label: "two-marketplace", - marketplace: "alpha", - marketplaces: { - alpha: pathAlias("beta"), - beta: pathAlias("alpha"), - }, - cycle: "alpha -> beta -> alpha", - }, - ] as const; - - it.each(cyclicKnownMarketplaces)( - "rejects a $label known marketplace alias cycle while listing", - async ({ marketplace, marketplaces, cycle }) => { - await withKnownMarketplaceRegistry(marketplaces, async () => { - const registryRead = vi.spyOn(jsonFiles, "tryReadJson"); - for (let read = 0; read <= Object.keys(marketplaces).length; read += 1) { - registryRead.mockResolvedValueOnce(marketplaces); - } - // Bound the unfixed recursive loader so the regression never leaves a runaway task. - registryRead.mockResolvedValueOnce({}); - - try { - const result = await listMarketplacePlugins({ marketplace }); - - expect(result).toEqual({ - ok: false, - error: `known marketplace source cycle: ${cycle}`, - }); - expect(registryRead).toHaveBeenCalledTimes(1); - expectNoNetwork(); - } finally { - registryRead.mockRestore(); - } - }); - }, - ); - - it("resolves independent known marketplace alias calls from one registry snapshot each", async () => { - await withKnownMarketplaceRegistry({}, async (homeDir) => { - const marketplaceRoot = path.join(homeDir, "known-marketplace"); - const { pluginDir } = await writeLocalMarketplaceFixture(marketplaceRoot); - await fs.writeFile( - path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"), - JSON.stringify({ - alpha: pathAlias("beta"), - beta: { installLocation: marketplaceRoot, ...pathAlias("alpha") }, - }), - ); - mockSuccessfulMarketplaceInstall(); + it.each([ + ["loop", { loop: pathAlias("loop") }, "loop -> loop"], + ["alpha", { alpha: pathAlias("beta"), beta: pathAlias("alpha") }, "alpha -> beta -> alpha"], + ] as const)( + "rejects known marketplace alias cycles from %s", + async (marketplace, marketplaces, cycle) => { + await known(marketplaces); const registryRead = vi.spyOn(jsonFiles, "tryReadJson"); - + for (let read = 0; read <= Object.keys(marketplaces).length; read += 1) { + registryRead.mockResolvedValueOnce(marketplaces); + } + // Bound the unfixed recursive loader so the regression never leaves a runaway task. + registryRead.mockResolvedValueOnce({}); try { - const listed = await listMarketplacePlugins({ marketplace: "alpha" }); - const installed = await installPluginFromMarketplace({ - marketplace: "alpha", - plugin: "frontend-design", + expect(await listMarketplacePlugins({ marketplace })).toEqual({ + ok: false, + error: `known marketplace source cycle: ${cycle}`, }); - - expect(listed).toMatchObject({ ok: true, sourceLabel: "beta" }); - expectLocalMarketplaceInstallResult(installed, pluginDir, "alpha"); - expect(registryRead).toHaveBeenCalledTimes(2); + expect(registryRead).toHaveBeenCalledTimes(1); expectNoNetwork(); } finally { registryRead.mockRestore(); } - }); + }, + ); + + it("resolves independent known marketplace alias calls from one registry snapshot each", async () => { + const homeDir = await known({}); + const marketplaceRoot = path.join(homeDir, "known-marketplace"); + const { pluginDir } = await writeLocalMarketplaceFixture(marketplaceRoot); + await fs.writeFile( + path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"), + JSON.stringify({ + alpha: pathAlias("beta"), + beta: { installLocation: marketplaceRoot, ...pathAlias("alpha") }, + }), + ); + mockSuccessfulMarketplaceInstall(); + const registryRead = vi.spyOn(jsonFiles, "tryReadJson"); + + try { + const listed = await listMarketplacePlugins({ marketplace: "alpha" }); + const installed = await installPluginFromMarketplace({ + marketplace: "alpha", + plugin: "frontend-design", + }); + + expect(listed).toMatchObject({ ok: true, sourceLabel: "beta" }); + expectLocalMarketplaceInstallResult(installed, pluginDir, "alpha"); + expect(registryRead).toHaveBeenCalledTimes(2); + expectNoNetwork(); + } finally { + registryRead.mockRestore(); + } }); it("preserves ordinary source fallback for an invalid known marketplace alias", async () => { - await withKnownMarketplaceRegistry({ broken: { source: { source: "path" } } }, async () => { - const result = await listMarketplacePlugins({ marketplace: "broken" }); - - expect(result).toEqual({ ok: false, error: "unsupported marketplace source: broken" }); - expectNoNetwork(); + await known({ broken: { source: { source: "path" } } }); + expect(await listMarketplacePlugins({ marketplace: "broken" })).toEqual({ + ok: false, + error: "unsupported marketplace source: broken", }); + expectNoNetwork(); }); it.each([ @@ -545,15 +448,6 @@ describe("marketplace plugins", () => { ? /[\\/]repo[\\/]plugins[\\/]frontend-design\.tgz$/ : /[\\/]repo[\\/]plugins[\\/]frontend-design$/, ); - if (commit) { - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(2); - expect(runCommandWithTimeoutMock.mock.calls.map(([argv]) => argv)).toEqual([ - ["git", "clone", "https://github.com/owner/repo.git", expect.any(String)], - ["git", "switch", "--detach", "--", commit], - ]); - } else { - expectRemoteCloneCommand(); - } expect(installPluginInput().installPolicyRequest).toMatchObject({ kind: extension ? "plugin-archive" : "plugin-git", requestedSpecifier: `${pluginName}@${marketplace}`, @@ -624,14 +518,6 @@ describe("marketplace archive downloads", () => { .toSorted(); } - function expectFetchDownloadCall(url = "https://example.com/frontend-design.tgz") { - expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).toMatchObject({ - url, - timeoutMs: 120_000, - auditContext: "marketplace-plugin-download", - }); - } - const archiveUrl = "https://example.com/frontend-design.tgz"; let rootDir: string; let manifestPath: string; @@ -655,24 +541,6 @@ describe("marketplace archive downloads", () => { ); } - function fakeResponse(kind = "stream") { - const cancel = vi.fn(async () => undefined); - const arrayBuffer = vi.fn(async () => new Uint8Array([1, 2, 3]).buffer); - const reader = { read: vi.fn(), cancel: vi.fn(async () => undefined), releaseLock: vi.fn() }; - const response = { - ok: true, - status: 200, - arrayBuffer, - headers: new Headers( - kind.endsWith("length") - ? { "content-length": kind === "malformed length" ? "1e9" : "268435457" } - : {}, - ), - body: { getReader: () => reader, cancel }, - } as unknown as Response; - return { response, reader, cancel, arrayBuffer }; - } - function expectDownloadFailure(result: unknown, error: string) { expect(result).toEqual({ ok: false, error: `failed to download ${archiveUrl}: ${error}` }); expect(installPluginFromPathMock).not.toHaveBeenCalled(); @@ -708,28 +576,26 @@ describe("marketplace archive downloads", () => { ["oversized length", "download too large: 268435457 bytes (limit: 268435456 bytes)"], ["drive-relative filename", "invalid download filename"], ] as const)("rejects %s responses before installing an archive", async (kind, error) => { - const fixture = fakeResponse(kind); + const cancel = vi.fn(); const response = - kind === "HTTP" - ? streamingResponse(fixture.cancel, 503) - : kind === "empty" - ? new Response(null) - : kind === "drive-relative filename" - ? new Response("tgz-bytes") - : fixture.response; + kind === "empty" + ? new Response(null) + : streamingResponse(cancel, kind === "HTTP" ? 503 : 200); + if (kind.endsWith("length")) { + response.headers.set("content-length", kind === "malformed length" ? "1e9" : "268435457"); + } + const getReader = response.body ? vi.spyOn(response.body, "getReader") : undefined; const release = respond( response, kind === "drive-relative filename" ? "https://cdn.example.com/C:plugin.tgz" : archiveUrl, ); expectDownloadFailure(await install(), error); - expectFetchDownloadCall(); expect(release).toHaveBeenCalledTimes(1); if (kind === "HTTP" || kind.endsWith("length")) { - expect(fixture.cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); } if (kind.endsWith("length")) { - expect(fixture.reader.read).not.toHaveBeenCalled(); - expect(fixture.reader.cancel).not.toHaveBeenCalled(); + expect(getReader).not.toHaveBeenCalled(); } }); @@ -751,7 +617,6 @@ describe("marketplace archive downloads", () => { marketplacePlugin: "frontend-design", marketplaceSource: manifestPath, }); - expectFetchDownloadCall(); expect(String(installPluginInput().path)).toMatch(/[\\/]frontend-design\.tgz$/); expect(installPluginInput().installPolicyRequest).toMatchObject({ kind: "plugin-archive", @@ -859,10 +724,7 @@ describe("marketplace archive downloads", () => { expect(result).toEqual({ ok: false, error: "failed to download ***: Invalid URL" }); expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); } else { - expect(result.ok).toBe(false); - if (result.ok) { - return; - } + assert(!result.ok); expect(result.error).toContain( "failed to download https://***:***@example.com/frontend-design.tgz:", ); diff --git a/src/plugins/openai-compatible-embedding-provider.test.ts b/src/plugins/openai-compatible-embedding-provider.test.ts index af17b6bddf0a..ebdac588b06f 100644 --- a/src/plugins/openai-compatible-embedding-provider.test.ts +++ b/src/plugins/openai-compatible-embedding-provider.test.ts @@ -1,33 +1,35 @@ // Covers OpenAI-compatible embedding provider plugin behavior. import "../test-utils/prepare-compiled-subprocesses.js"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; import { Agent, type Dispatcher } from "undici"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMemorySearchDeadlineControl, MEMORY_SEARCH_DEADLINE_CONTROL, } from "../../packages/memory-host-sdk/src/host/search-deadline-control.js"; +import { + closeHttpServer, + listenLoopback, + readRequestText, +} from "../../test/helpers/gateway-integration-fixtures.js"; import { withTestTimeout } from "../../test/helpers/promise.js"; -import type { ConfiguredProviderLocalServiceTarget } from "../agents/provider-local-service-target.js"; +import type { + AcquireConfiguredProviderLocalService, + ConfiguredProviderLocalServiceTarget, +} from "../agents/provider-local-service-target.js"; import type { EmbeddingProviderCreateOptions } from "./embedding-providers.js"; import { openAICompatibleEmbeddingProviderAdapter } from "./openai-compatible-embedding-provider.js"; -async function createProvider(options: EmbeddingProviderCreateOptions) { +async function createProvider( + options: EmbeddingProviderCreateOptions & { + acquireLocalService?: AcquireConfiguredProviderLocalService; + }, +) { const result = await openAICompatibleEmbeddingProviderAdapter.create(options); if (!result.provider) { throw new Error("expected OpenAI-compatible embedding provider"); } - const cacheKeyData = result.runtime?.cacheKeyData as - | { baseUrl?: string; headers?: Record } - | undefined; - return { - provider: result.provider, - client: { - baseUrl: cacheKeyData?.baseUrl, - headers: cacheKeyData?.headers ?? {}, - }, - }; + return { ...result, provider: result.provider }; } type CapturedRequest = { @@ -37,28 +39,7 @@ type CapturedRequest = { body: Record; }; -type FixtureResponse = { - object: "list"; - data: Array<{ - object?: "embedding"; - embedding: number[]; - index: number; - }>; - model?: string; - usage?: { - prompt_tokens?: number; - total_tokens?: number; - }; -}; - -type OversizedStreamServer = { - baseUrl: string; - closed: Promise; - getBodyBytesSent: () => number; - getPlannedBodyBytes: () => number; -}; - -const servers: Array<{ close: () => Promise }> = []; +const servers: Server[] = []; function createOptions( overrides: Partial = {}, @@ -71,45 +52,41 @@ function createOptions( }; } +function configuredOptions( + provider: string, + config: NonNullable< + NonNullable["providers"] + >[string], + overrides: Partial = {}, +) { + return createOptions({ + provider, + config: { models: { providers: { [provider]: config } } }, + ...overrides, + }); +} + +function vectors(...embeddings: number[][]) { + return { data: embeddings.map((embedding, index) => ({ embedding, index })) }; +} + async function readJsonBody(req: IncomingMessage): Promise> { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const text = Buffer.concat(chunks).toString("utf8"); - return JSON.parse(text) as Record; + return JSON.parse(await readRequestText(req)) as Record; } -async function listen(server: Server, destroyConnections = false) { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); - }); - servers.push({ - close: () => - new Promise((resolve, reject) => { - if (destroyConnections) { - server.closeAllConnections(); - } - server.close((error) => (error ? reject(error) : resolve())); - }), - }); - return `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`; +async function listen(server: Server) { + const baseUrl = await listenLoopback(server); + servers.push(server); + return `${baseUrl}/v1`; } -function textEmbeddings({ body }: CapturedRequest, usage = false): FixtureResponse { +function textEmbeddings({ body }: CapturedRequest, usage = false) { const texts = Array.isArray(body.input) ? body.input : [body.input]; return { - object: "list", data: texts.map((text, index) => ({ - object: "embedding", embedding: [String(text).length, index + 0.25, 1], index, })), - model: String(body.model), ...(usage ? { usage: { prompt_tokens: texts.length, total_tokens: texts.length } } : {}), }; } @@ -129,13 +106,16 @@ function localProvider(baseUrl: string): EmbeddingProviderCreateOptions["config" }; } -async function providerAt(baseUrl: string) { - return createProvider(createOptions({ remote: { baseUrl } })); +async function providerAt( + baseUrl: string, + overrides: Partial = {}, +) { + return createProvider(createOptions({ remote: { baseUrl }, ...overrides })); } async function startEmbeddingServer(params?: { token?: string; - respond?: (request: CapturedRequest) => FixtureResponse | Record | null; + respond?: (request: CapturedRequest) => Record | null; status?: number; redirectUrl?: string; }): Promise<{ baseUrl: string; requests: CapturedRequest[] }> { @@ -163,15 +143,7 @@ async function startEmbeddingServer(params?: { ...(params?.redirectUrl ? { location: params.redirectUrl } : {}), }); res.end( - JSON.stringify( - params?.respond - ? params.respond(captured) - : { - object: "list", - data: [{ object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }], - model: body.model, - }, - ), + JSON.stringify(params?.respond ? params.respond(captured) : vectors([0.1, 0.2, 0.3])), ); } catch (error) { res.writeHead(500, { "content-type": "application/json" }); @@ -188,10 +160,11 @@ const EMBEDDING_ERROR_BOUNDARY_BODY = `${EMBEDDING_ERROR_BOUNDARY_PREFIX}😀${" 8 * 1024 - EMBEDDING_ERROR_BOUNDARY_PREFIX.length - 4, )}`; -async function startHangingErrorEmbeddingServer(): Promise<{ - baseUrl: string; - closed: Promise; -}> { +async function startStreamServer( + status: number, + write: (res: ServerResponse) => void, + contentType = "application/json", +) { let resolveClosed: () => void = () => undefined; const closed = new Promise((resolve) => { resolveClosed = resolve; @@ -200,86 +173,20 @@ async function startHangingErrorEmbeddingServer(): Promise<{ void (async () => { await readJsonBody(req); res.on("close", resolveClosed); - res.writeHead(502, { "content-type": "text/plain" }); - res.write(EMBEDDING_ERROR_BOUNDARY_BODY); + res.writeHead(status, { "content-type": contentType }); + write(res); })(); }); return { - baseUrl: await listen(server, true), + baseUrl: await listen(server), closed, }; } -async function startOversizedSuccessEmbeddingServer(): Promise { - const chunk = Buffer.alloc(64 * 1024, 0x20); - const prefix = Buffer.from('{"data":['); - const plannedBodyBytes = 64 * 1024 * 1024; - let bodyBytesSent = 0; - let resolveClosed: () => void = () => undefined; - const closed = new Promise((resolve) => { - resolveClosed = resolve; - }); - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - void (async () => { - await readJsonBody(req); - let closedAlready = false; - res.on("close", () => { - closedAlready = true; - resolveClosed(); - }); - res.writeHead(200, { "content-type": "application/json" }); - const writeChunk = async (buffer: Buffer): Promise => { - if (closedAlready) { - return false; - } - const accepted = res.write(buffer); - bodyBytesSent += buffer.byteLength; - if (accepted) { - return true; - } - return await new Promise((resolve) => { - const cleanup = () => { - res.off("drain", onDrain); - res.off("close", onClose); - }; - const onDrain = () => { - cleanup(); - resolve(!closedAlready); - }; - const onClose = () => { - cleanup(); - resolve(false); - }; - res.once("drain", onDrain); - res.once("close", onClose); - }); - }; - - if (!(await writeChunk(prefix))) { - return; - } - const chunksToSend = Math.ceil((plannedBodyBytes - bodyBytesSent) / chunk.byteLength); - for (let i = 0; i < chunksToSend; i++) { - if (!(await writeChunk(chunk))) { - return; - } - } - res.end("]}"); - })(); - }); - - return { - baseUrl: await listen(server, true), - closed, - getBodyBytesSent: () => bodyBytesSent, - getPlannedBodyBytes: () => plannedBodyBytes, - }; -} - afterEach(async () => { const pending = servers.splice(0); - await Promise.all(pending.map((server) => server.close())); + await Promise.all(pending.map(closeHttpServer)); }); describe("openai-compatible generic embedding provider", () => { @@ -287,22 +194,16 @@ describe("openai-compatible generic embedding provider", () => { const server = await startEmbeddingServer(); for (const remoteOverride of [false, true]) { const { provider } = await createProvider( - createOptions({ - provider: "private-fixture", - config: { - models: { - providers: { - "private-fixture": { - api: "openai-completions", - baseUrl: remoteOverride ? "https://provider.example.test/v1" : server.baseUrl, - request: { allowPrivateNetwork: false }, - models: [], - }, - }, - }, + configuredOptions( + "private-fixture", + { + api: "openai-completions", + baseUrl: remoteOverride ? "https://provider.example.test/v1" : server.baseUrl, + request: { allowPrivateNetwork: false }, + models: [], }, - ...(remoteOverride ? { remote: { baseUrl: server.baseUrl } } : {}), - }), + remoteOverride ? { remote: { baseUrl: server.baseUrl } } : {}, + ), ); await expect(provider.embed("private input")).rejects.toThrow( /private\/internal\/special-use/, @@ -317,9 +218,7 @@ describe("openai-compatible generic embedding provider", () => { status: 307, redirectUrl: `${target.baseUrl}/embeddings`, }); - const { provider } = await createProvider( - createOptions({ remote: { baseUrl: source.baseUrl } }), - ); + const { provider } = await providerAt(source.baseUrl); await expect(provider.embed("private input")).rejects.toThrow(/private\/internal\/special-use/); expect(source.requests).toHaveLength(1); expect(target.requests).toEqual([]); @@ -327,9 +226,7 @@ describe("openai-compatible generic embedding provider", () => { it.each([true, false])("preserves caller-owned HTTP deadlines: signal=%s", async (withSignal) => { const server = await startEmbeddingServer(); - const { provider } = await createProvider( - createOptions({ remote: { baseUrl: server.baseUrl } }), - ); + const { provider } = await providerAt(server.baseUrl); const requests: Dispatcher.DispatchOptions[] = []; // oxlint-disable-next-line typescript/unbound-method -- The observer calls the original with the same Agent receiver. const dispatch = Agent.prototype.dispatch; @@ -349,8 +246,9 @@ describe("openai-compatible generic embedding provider", () => { }), ).resolves.toEqual([[0.1, 0.2, 0.3]]); expect(requests).toHaveLength(1); - expect(requests[0]?.headersTimeout).toBe(withSignal ? 0 : undefined); - expect(requests[0]?.bodyTimeout).toBe(withSignal ? 0 : undefined); + expect([requests[0]?.headersTimeout, requests[0]?.bodyTimeout]).toEqual( + withSignal ? [0, 0] : [undefined, undefined], + ); } finally { spy.mockRestore(); } @@ -367,16 +265,14 @@ describe("openai-compatible generic embedding provider", () => { events.push("reconcile"); return { release }; }); - const options = { + const { provider } = await createProvider({ ...createOptions({ config: localProvider(server.baseUrl), provider: "gpu-spark", model: "gpu-spark/nomic-embed-text", }), acquireLocalService, - }; - - const { provider } = await createProvider(options); + }); const control = createMemorySearchDeadlineControl(); control.subscribe((action) => events.push(action)); const caller = new AbortController(); @@ -395,17 +291,15 @@ describe("openai-compatible generic embedding provider", () => { it("does not lease a configured local service for a remote endpoint override", async () => { const server = await startEmbeddingServer(); const acquireLocalService = vi.fn(async () => ({ release: vi.fn() })); - const options = createOptions({ - config: localProvider("http://spark.local:11434/v1"), - provider: "gpu-spark", - model: "gpu-spark/nomic-embed-text", - remote: { baseUrl: server.baseUrl }, - }) as EmbeddingProviderCreateOptions & { - acquireLocalService: typeof acquireLocalService; - }; - options.acquireLocalService = acquireLocalService; - - const { provider } = await createProvider(options); + const { provider } = await createProvider({ + ...createOptions({ + config: localProvider("http://spark.local:11434/v1"), + provider: "gpu-spark", + model: "gpu-spark/nomic-embed-text", + remote: { baseUrl: server.baseUrl }, + }), + acquireLocalService, + }); await expect(provider.embed("hello")).resolves.toEqual([0.1, 0.2, 0.3]); expect(acquireLocalService).not.toHaveBeenCalled(); }); @@ -426,13 +320,7 @@ describe("openai-compatible generic embedding provider", () => { }), ); - expect(result.runtime?.cacheKeyData).toMatchObject({ - headers: { - accept: "application/json", - "content-type": "application/json", - "x-deployment": "tenant-a", - }, - }); + expect(result.runtime?.cacheKeyData).toMatchObject({ headers: { "x-deployment": "tenant-a" } }); expect(result.runtime?.cacheKeyData).not.toHaveProperty("authorization"); expect( (result.runtime!.cacheKeyData as { headers?: Record }).headers, @@ -456,26 +344,22 @@ describe("openai-compatible generic embedding provider", () => { }; for (const key of ["fixture-before", "fixture-after"]) { const result = await openAICompatibleEmbeddingProviderAdapter.create( - createOptions({ - provider: "custom", - model: "fixture-embedding", - dimensions: 3, - inputType: "default", - queryInputType: "query", - documentInputType: "document", - config: { - models: { - providers: { - custom: { - baseUrl: canonical.baseUrl, - headers: { "X-Access": key, "X-Deployment": "tenant-a" }, - request: { auth: { mode: "header", headerName: " X-Access ", value: key } }, - models: [], - }, - }, - }, + configuredOptions( + "custom", + { + baseUrl: canonical.baseUrl, + headers: { "X-Access": key, "X-Deployment": "tenant-a" }, + request: { auth: { mode: "header", headerName: " X-Access ", value: key } }, + models: [], }, - }), + { + model: "fixture-embedding", + dimensions: 3, + inputType: "default", + queryInputType: "query", + documentInputType: "document", + }, + ), ); const priorIdentity = { ...canonical, @@ -487,12 +371,8 @@ describe("openai-compatible generic embedding provider", () => { }, }; expect(result.runtime?.cacheKeyData).toEqual(canonical); - expect(result.runtime?.indexIdentityAliases).toEqual([ - { - model: "fixture-embedding", - cacheKeyData: priorIdentity, - }, - ]); + expect(result.runtime?.indexIdentityAliases).toHaveLength(1); + expect(result.runtime?.indexIdentityAliases?.[0]?.model).toBe("fixture-embedding"); expect(JSON.stringify(result.runtime?.indexIdentityAliases?.[0]?.cacheKeyData)).toBe( JSON.stringify(priorIdentity), ); @@ -518,22 +398,16 @@ describe("openai-compatible generic embedding provider", () => { "does not create an index identity alias for $name", async ({ authHeaderName, headers, remote }) => { const result = await openAICompatibleEmbeddingProviderAdapter.create( - createOptions({ - provider: "custom", - config: { - models: { - providers: { - custom: { - baseUrl: "http://127.0.0.1:18001/v1", - headers, - request: { auth: { mode: "header", headerName: authHeaderName, value: "key" } }, - models: [], - }, - }, - }, + configuredOptions( + "custom", + { + baseUrl: "http://127.0.0.1:18001/v1", + headers, + request: { auth: { mode: "header", headerName: authHeaderName, value: "key" } }, + models: [], }, - remote, - }), + { remote }, + ), ); expect(result.runtime?.indexIdentityAliases).toBeUndefined(); }, @@ -546,7 +420,7 @@ describe("openai-compatible generic embedding provider", () => { respond: (request) => textEmbeddings(request, true), }); - const { provider, client } = await createProvider( + const { provider, runtime } = await createProvider( createOptions({ model: "text-embedding-bge-m3", dimensions: 3, @@ -565,7 +439,7 @@ describe("openai-compatible generic embedding provider", () => { model: "text-embedding-bge-m3", dimensions: 3, }); - expect(client.baseUrl).toBe(server.baseUrl); + expect(runtime?.cacheKeyData).toMatchObject({ baseUrl: server.baseUrl }); expect(server.requests).toHaveLength(0); const onUsage = vi.fn(); @@ -583,35 +457,21 @@ describe("openai-compatible generic embedding provider", () => { expect(server.requests[0]).toMatchObject({ method: "POST", url: "/v1/embeddings", - body: { - model: "text-embedding-bge-m3", - input: ["hello"], - dimensions: 3, + headers: { + "content-type": expect.stringContaining("application/json"), + accept: "application/json", + "x-local-runtime": "ollama", }, }); - expect(server.requests[0]?.body).not.toHaveProperty("encoding_format"); - expect(server.requests[0]?.body).not.toHaveProperty("input_type"); - expect(server.requests[0]?.headers).toMatchObject({ - "content-type": expect.stringContaining("application/json"), - accept: "application/json", - "x-local-runtime": "ollama", - }); - expect(server.requests[1]?.body).toEqual({ - model: "text-embedding-bge-m3", - input: ["a", "abcd"], - dimensions: 3, - }); + expect(server.requests.map(({ body }) => body)).toEqual([ + { model: "text-embedding-bge-m3", input: ["hello"], dimensions: 3 }, + { model: "text-embedding-bge-m3", input: ["a", "abcd"], dimensions: 3 }, + ]); }); it("returns endpoint vectors unchanged when dimensions are not configured", async () => { const server = await startEmbeddingServer({ - respond: () => ({ - object: "list", - data: [ - { object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }, - { object: "embedding", embedding: [0.4, 0.5], index: 1 }, - ], - }), + respond: () => vectors([0.1, 0.2, 0.3], [0.4, 0.5]), }); const result = await openAICompatibleEmbeddingProviderAdapter.create( createOptions({ remote: { baseUrl: server.baseUrl } }), @@ -626,40 +486,42 @@ describe("openai-compatible generic embedding provider", () => { expect(server.requests[0]?.body).not.toHaveProperty("dimensions"); }); - it("rejects vectors that do not match the requested dimensions", async () => { - // Simulate a server that ignores `dimensions` and returns the model's native size. - const native = await startEmbeddingServer({ respond: (request) => textEmbeddings(request) }); - const { provider } = await createProvider( - createOptions({ dimensions: 2, remote: { baseUrl: native.baseUrl } }), - ); - - await expect(provider.embed("hello", { inputType: "query" })).rejects.toMatchObject({ - code: "INVALID_EMBEDDING_RESPONSE", - expectedDimensions: 2, - actualDimensions: 3, - message: - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): expected 2 dimensions, got 3 at index 0; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - }); - await expect(provider.embedBatch(["a", "b"], { inputType: "document" })).rejects.toMatchObject({ - code: "INVALID_EMBEDDING_RESPONSE", - message: - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 2): expected 2 dimensions, got 3 at index 0; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - }); - - const { provider: wider } = await createProvider( - createOptions({ dimensions: 4, remote: { baseUrl: native.baseUrl } }), - ); - await expect(wider.embed("hello")).rejects.toThrow( - "expected 4 dimensions, got 3 at index 0; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - ); - expect(native.requests.map((request) => request.body.dimensions)).toEqual([2, 2, 4]); - }); + it.each([ + { dimensions: 2, inputs: ["hello"], batch: false, index: 0 }, + { dimensions: 2, inputs: ["a", "b", "c"], batch: true, index: 2 }, + ])( + "rejects mismatched dimensions ($dimensions, batch=$batch, index=$index)", + async ({ dimensions, inputs, batch, index }) => { + const server = await startEmbeddingServer({ + respond: + index === 2 + ? () => ({ + data: [ + { embedding: [0, 0.6, 0.8], index: 2 }, + { embedding: [0.6, 0.8], index: 1 }, + { embedding: [1, 0], index: 0 }, + ], + }) + : textEmbeddings, + }); + const { provider } = await providerAt(server.baseUrl, { dimensions }); + await expect( + batch + ? provider.embedBatch(inputs, { inputType: "document" }) + : provider.embed(inputs[0]!, { inputType: "query" }), + ).rejects.toMatchObject({ + code: "INVALID_EMBEDDING_RESPONSE", + expectedDimensions: dimensions, + actualDimensions: 3, + message: `openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: ${inputs.length}): expected ${dimensions} dimensions, got 3 at index ${index}; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.`, + }); + expect(server.requests[0]?.body.dimensions).toBe(dimensions); + }, + ); it("keeps malformed response errors distinct from dimension mismatches", async () => { const server = await startEmbeddingServer({ respond: () => ({ data: [{}] }) }); - const { provider } = await createProvider( - createOptions({ dimensions: 2, remote: { baseUrl: server.baseUrl } }), - ); + const { provider } = await providerAt(server.baseUrl, { dimensions: 2 }); const error = await provider.embed("query").catch((cause: unknown) => cause); expect(error).toMatchObject({ code: "INVALID_EMBEDDING_RESPONSE", @@ -667,75 +529,34 @@ describe("openai-compatible generic embedding provider", () => { }); expect(error).not.toHaveProperty("expectedDimensions"); expect(error).not.toHaveProperty("actualDimensions"); - expect(server.requests).toHaveLength(1); - }); - - it("rejects a batch when any vector differs from the requested dimensions", async () => { - const server = await startEmbeddingServer({ - respond: () => ({ - object: "list", - data: [ - { object: "embedding", embedding: [0, 0.6, 0.8], index: 2 }, - { object: "embedding", embedding: [0.6, 0.8], index: 1 }, - { object: "embedding", embedding: [1, 0], index: 0 }, - ], - }), - }); - const { provider } = await createProvider( - createOptions({ dimensions: 2, remote: { baseUrl: server.baseUrl } }), - ); - - await expect(provider.embedBatch(["a", "b", "c"])).rejects.toMatchObject({ - code: "INVALID_EMBEDDING_RESPONSE", - message: - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 3): expected 2 dimensions, got 3 at index 2; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - }); }); it("bounds exact-limit embedding errors without splitting UTF-16 and cancels", async () => { - const server = await startHangingErrorEmbeddingServer(); + const server = await startStreamServer( + 502, + (res) => { + res.write(EMBEDDING_ERROR_BOUNDARY_BODY); + }, + "text/plain", + ); const { provider } = await providerAt(server.baseUrl); - const outcome = await withTestTimeout( - provider.embed("hello").then( - () => ({ type: "resolved" as const }), - (error: unknown) => ({ type: "rejected" as const, error }), - ), + const request = withTestTimeout( + provider.embed("hello"), 1_000, "timed out waiting for bounded embedding error", ); - - if (outcome.type !== "rejected") { - throw new Error(`expected embedding request to reject, got ${outcome.type}`); - } - expect(outcome.error).toBeInstanceOf(Error); - expect((outcome.error as Error).message).toBe( - `openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): HTTP 502: ${EMBEDDING_ERROR_BOUNDARY_PREFIX}... [truncated]`, - ); + await expect(request).rejects.toBeInstanceOf(Error); + await expect(request).rejects.toMatchObject({ + message: `openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): HTTP 502: ${EMBEDDING_ERROR_BOUNDARY_PREFIX}... [truncated]`, + }); await expect( withTestTimeout( - server.closed.then(() => "closed" as const), + server.closed, 1_000, "timed out waiting for embedding error server to close", ), - ).resolves.toBe("closed"); - }); - - it("bounds and cancels oversized successful embedding JSON bodies", async () => { - const server = await startOversizedSuccessEmbeddingServer(); - const { provider } = await providerAt(server.baseUrl); - - await expect(provider.embed("hello")).rejects.toThrow( - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): JSON response exceeds 16777216 bytes", - ); - await expect( - withTestTimeout( - server.closed.then(() => "closed" as const), - 1_000, - "timed out waiting for oversized response server to close", - ), - ).resolves.toBe("closed"); - expect(server.getBodyBytesSent()).toBeLessThan(server.getPlannedBodyBytes() / 2); + ).resolves.toBeUndefined(); }); it("maps configured memory input_type labels onto query and document requests", async () => { @@ -743,7 +564,6 @@ describe("openai-compatible generic embedding provider", () => { const result = await openAICompatibleEmbeddingProviderAdapter.create( createOptions({ - model: "text-embedding-bge-m3", inputType: " default ", queryInputType: " query ", documentInputType: " document ", @@ -780,10 +600,7 @@ describe("openai-compatible generic embedding provider", () => { const server = await startEmbeddingServer({ respond: ({ body }) => { expect(body.input).toEqual(["ab"]); - return { - object: "list", - data: [{ object: "embedding", embedding: [2, 1], index: 0 }], - }; + return vectors([2, 1]); }, }); const { provider } = await providerAt(server.baseUrl); diff --git a/src/plugins/plugin-lifecycle-lease.test.ts b/src/plugins/plugin-lifecycle-lease.test.ts index 743e53b08909..bbf9214ed588 100644 --- a/src/plugins/plugin-lifecycle-lease.test.ts +++ b/src/plugins/plugin-lifecycle-lease.test.ts @@ -1,13 +1,14 @@ import { AsyncResource } from "node:async_hooks"; -import { spawn, type ChildProcessByStdio } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -import type { Readable, Writable } from "node:stream"; import { setImmediate as nextTurn } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { queryObjects } from "node:v8"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; +import { stopChildProcess } from "../../test/helpers/stop-child-process.js"; +import { runCliProcessChild } from "../cli/cli-process-child.test-helpers.js"; import { createPluginLifecycleLeaseTestClock } from "../gateway/config-reload.test-support.js"; import { sqliteWorkerPreloadEnv } from "../infra/sqlite-worker-preload.test-support.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; @@ -35,7 +36,7 @@ import { } from "./plugin-lifecycle-lease.js"; import { seedInstalledPluginIndex } from "./test-helpers/installed-plugin-index.js"; -type LeaseChild = ChildProcessByStdio; +type LeaseChild = ChildProcessWithoutNullStreams; type LeaseChildRun = { child: LeaseChild; ready: Promise; @@ -68,19 +69,6 @@ afterEach(() => { closeOpenClawStateDatabaseForTest(); }); -async function terminateLeaseChild(child: LeaseChild): Promise { - await new Promise((resolve) => { - const onClose = () => resolve(); - child.once("close", onClose); - if (child.exitCode !== null || child.signalCode !== null) { - child.off("close", onClose); - resolve(); - return; - } - child.kill("SIGKILL"); - }); -} - async function withLeaseChildren(fn: (children: Set) => Promise): Promise { const children = new Set(); try { @@ -89,7 +77,7 @@ async function withLeaseChildren(fn: (children: Set) => Promis } finally { await Promise.all( Array.from(children, async ({ child, completed }) => { - await terminateLeaseChild(child); + await stopChildProcess(child, 5_000, { force: true }); await completed.catch(() => {}); }), ); @@ -102,59 +90,47 @@ async function withLeaseChildren(fn: (children: Set) => Promis } } -function runLeaseChild( +async function runLeaseChild( children: Set, scriptPath: string, args: string[], env?: NodeJS.ProcessEnv, -): LeaseChildRun { - const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], { - stdio: ["pipe", "pipe", "pipe"], - env, - }); - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - +): Promise { let stdout = ""; let stderr = ""; - let pendingLine = ""; const phases = new Set(); const phaseWaiters = new Map>>(); - - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - pendingLine += chunk; - const lines = pendingLine.split("\n"); - pendingLine = lines.pop() ?? ""; - for (const line of lines) { - phases.add(line); - phaseWaiters.get(line)?.resolve(); + const started = createDeferred(); + const completed = runCliProcessChild({ + nodeArgs: ["--import", "tsx", scriptPath, ...args], + nodeArgsPolicy: "caller", + env: env ?? process.env, + onStdout(output) { + stdout = output; + for (const line of output.split("\n").slice(0, -1)) { + phases.add(line); + phaseWaiters.get(line)?.resolve(); + } + }, + interact(child) { + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.stdin.end(); + started.resolve(child); + }, + }).then(({ code, signal }) => { + if (code !== 0) { + throw new Error(`lease child ${args[0]} exited ${code ?? signal}`); } }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - - const completed = new Promise((resolve, reject) => { - const fail = (phase: string, error: Error) => - reject( - new Error(`failed to ${phase} lease child ${args[0]}: ${error.message}`, { cause: error }), - ); - child.stdin.once("error", (error) => { - fail("signal", error); - }); - child.once("error", (error) => { - fail("start", error); - }); - child.once("close", (code, signal) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`lease child ${args[0]} exited ${code ?? signal}`)); - } - }); - }); void completed.catch(() => {}); + const child = await Promise.race([ + started.promise, + completed.then(() => { + throw new Error("Lease child exited before starting"); + }), + ]); const waitForPhase = (phase: string): Promise => { if (phases.has(phase)) { return Promise.resolve(); @@ -241,8 +217,12 @@ describe("plugin lifecycle lease", () => { const operations: Promise[] = []; try { expect(hasPluginLifecycleLeaseDemand()).toBe(false); - const holder = withPluginLifecycleLease({ env: state.env, signal }, async () => { - await withPluginLifecycleLease({}, async () => { + const holder = withPluginLifecycleLease({ env: state.env, signal }, async (outer) => { + await withPluginLifecycleLease({}, async (nested) => { + expect(nested).toBe(outer); + expect(nested.databasePath).toBe( + path.resolve(state.stateDir, "state", "openclaw.sqlite"), + ); expect(hasPluginLifecycleLeaseDemand()).toBe(false); }); entered.resolve(); @@ -286,51 +266,28 @@ describe("plugin lifecycle lease", () => { }, ); - it.each([ - [false, false], - [true, false], - [false, true], - [true, true], - ])( - "preserves operation and cleanup outcomes (run failure: %s, cleanup failure: %s)", - async (failRun, failCleanup) => { - await withOpenClawTestState({ label: "plugin-lifecycle-outcome" }, async (state) => { - const runError = new Error("operation failed"); - const cleanupError = new Error("cleanup failed"); - const value = {}; - let cache: ReturnType | undefined; - const operation = withPluginLifecycleLease({ env: state.env }, async () => { + it("returns the operation value while recording cleanup failure", async () => { + await withOpenClawTestState({ label: "plugin-lifecycle-outcome" }, async (state) => { + const cleanupError = new Error("cleanup failed"); + const value = {}; + let cache: ReturnType | undefined; + await expect( + withPluginLifecycleLease({ env: state.env }, async () => { cache = getPluginCache(); cacheCleanup(cache, "lease-cleanup", () => { - if (failCleanup) { - throw cleanupError; - } + throw cleanupError; }); - if (failRun) { - throw runError; - } return value; - }); - const [outcome] = await Promise.allSettled([operation]); - expect(cache).toBeDefined(); - const cleanup = await retirePluginCache(cache!); - expect(cleanup.failures.map((failure) => failure.error)).toEqual( - failCleanup ? [cleanupError] : [], - ); - if (failRun) { - expectRejected(outcome, runError); - } else { - expect(outcome.status).toBe("fulfilled"); - if (outcome.status === "fulfilled") { - expect(outcome.value).toBe(value); - } - } - await expect( - withPluginLifecycleLease({ env: state.env, waitMs: 0 }, async () => value), - ).resolves.toBe(value); - }); - }, - ); + }), + ).resolves.toBe(value); + expect(cache).toBeDefined(); + const cleanup = await retirePluginCache(cache!); + expect(cleanup.failures.map((failure) => failure.error)).toEqual([cleanupError]); + await expect( + withPluginLifecycleLease({ env: state.env, waitMs: 0 }, async () => value), + ).resolves.toBe(value); + }); + }); it("joins process cleanup outcomes while preserving the original operation failure", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-cleanup-owners" }, async (state) => { @@ -422,17 +379,14 @@ describe("plugin lifecycle lease", () => { }); }); - it.each([ - ["one state directory", false], - ["an explicit database path across different state directories", true], - ])("serializes lifecycle work sharing %s", async (_label, explicitPath) => { + it("serializes lifecycle work sharing an explicit database across state directories", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-lease" }, async (state) => { const firstEntered = createDeferred(); const releaseFirst = createDeferred(); const events: string[] = []; const leaseOptions = (caller: string) => ({ - env: explicitPath ? { ...state.env, OPENCLAW_STATE_DIR: state.path(caller) } : state.env, - ...(explicitPath ? { path: state.path("shared-plugin-lifecycle.sqlite") } : {}), + env: { ...state.env, OPENCLAW_STATE_DIR: state.path(caller) }, + path: state.path("shared-plugin-lifecycle.sqlite"), leaseMs: 1_000, waitMs: 3_000, }); @@ -463,11 +417,10 @@ describe("plugin lifecycle lease", () => { it("reclaims process-bound lifecycle work after its process is killed", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-killed-owner" }, async (state) => { await withLeaseChildren(async (children) => { - const leaseModuleUrl = pluginModuleUrl("plugin-lifecycle-lease.ts"); const script = await state.writeText( "killed-lease.mts", ` - import { withPluginLifecycleLease } from ${JSON.stringify(leaseModuleUrl)}; + import { withPluginLifecycleLease } from ${JSON.stringify(pluginModuleUrl("plugin-lifecycle-lease.ts"))}; const env = { ...process.env, OPENCLAW_STATE_DIR: process.argv[2] }; await withPluginLifecycleLease({ env, processBound: true }, async () => { process.stdout.write("ready\\n"); @@ -475,10 +428,10 @@ describe("plugin lifecycle lease", () => { }); `, ); - const holder = runLeaseChild(children, script, [state.stateDir]); + const holder = await runLeaseChild(children, script, [state.stateDir]); await holder.ready; - await terminateLeaseChild(holder.child); - // SIGKILL is the expected result, already joined above. + await stopChildProcess(holder.child, 5_000, { force: true }); + await holder.completed.catch(() => {}); children.delete(holder); await expect( withPluginLifecycleLease({ env: state.env, waitMs: 0 }, async () => "recovered"), @@ -487,85 +440,6 @@ describe("plugin lifecycle lease", () => { }); }); - it("serializes lifecycle work across processes", async () => { - await withOpenClawTestState({ label: "plugin-lifecycle-processes" }, async (state) => { - await withLeaseChildren(async (children) => { - const secondMarker = state.path("second-entered"); - const secondResult = state.path("second-result"); - const leaseModuleUrl = pluginModuleUrl("plugin-lifecycle-lease.ts"); - const childScript = await state.writeText( - "lease-child.mts", - ` - import fs from "node:fs/promises"; - import { createInterface } from "node:readline"; - import { withPluginLifecycleLease } from ${JSON.stringify(leaseModuleUrl)}; - const [role, stateDir, secondMarker, secondResult] = process.argv.slice(2); - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - const input = createInterface({ input: process.stdin }); - const commands = input[Symbol.asyncIterator](); - try { - // Complete cold database/worker admission before either contested lease starts. - await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 5_000 }, async () => {}); - process.stdout.write("ready\\n"); - if ((await commands.next()).value !== "go") { - throw new Error("expected go command"); - } - if (role === "second") { - try { - await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 0 }, async () => { - await fs.writeFile(secondMarker, "entered"); - }); - await fs.writeFile(secondResult, "acquired"); - } catch (error) { - await fs.writeFile(secondResult, error?.code ?? String(error)); - } - } else { - await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 5_000 }, async () => { - process.stdout.write("acquired\\n"); - if ((await commands.next()).value !== "release") { - throw new Error("expected release command"); - } - }); - } - } finally { - input.close(); - } - `, - ); - - const childArgs = [state.stateDir, secondMarker, secondResult]; - const first = runLeaseChild(children, childScript, ["first", ...childArgs]); - await first.ready; - const second = runLeaseChild(children, childScript, ["second", ...childArgs]); - await second.ready; - first.child.stdin.write("go\n"); - await first.waitForPhase("acquired"); - second.child.stdin.end("go\n"); - // Wait for the child to close so its result write is fully flushed before - // reading; file existence alone can race with the write after open(). - await second.completed; - - let assertionError: unknown; - try { - await expect(fs.readFile(secondResult, "utf8")).resolves.toBe( - "OPENCLAW_STATE_LEASE_HELD", - ); - await expect(fs.access(secondMarker)).rejects.toMatchObject({ code: "ENOENT" }); - } catch (error) { - assertionError = error; - } finally { - first.child.stdin.end("release\n"); - } - await Promise.all([first.completed, second.completed]); - if (assertionError) { - throw assertionError instanceof Error - ? assertionError - : new Error("cross-process lease assertion failed", { cause: assertionError }); - } - }); - }); - }); - it("reloads install records after waiting for another process", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-record-cache" }, async (state) => { await withLeaseChildren(async (children) => { @@ -635,7 +509,9 @@ if (process.versions.bun) { await waitForMarker(goMarker); if (pluginId === "beta") { await assert.rejects( - withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 0 }, async () => {}), + withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 0 }, async () => { + assert.fail("blocked lifecycle callback must not run"); + }), { code: "OPENCLAW_STATE_LEASE_HELD" }, ); process.stdout.write("held\\n"); @@ -668,13 +544,13 @@ if (process.versions.bun) { `, ); - const alpha = runLeaseChild( + const alpha = await runLeaseChild( children, childScript, ["alpha", state.stateDir, alphaGoMarker, releaseAlphaMarker], childEnv, ); - const beta = runLeaseChild( + const beta = await runLeaseChild( children, childScript, ["beta", state.stateDir, betaGoMarker, releaseAlphaMarker], @@ -729,26 +605,6 @@ if (process.versions.bun) { }); }); - it("reuses the active lease for nested lifecycle work", async () => { - await withOpenClawTestState({ label: "plugin-lifecycle-reentrant" }, async (state) => { - const events: string[] = []; - await withPluginLifecycleLease( - { env: state.env, leaseMs: 1_000, waitMs: 0 }, - async (outerLease) => { - events.push("outer"); - await withPluginLifecycleLease({}, async (innerLease) => { - events.push("inner"); - expect(innerLease).toBe(outerLease); - expect(innerLease.databasePath).toBe( - path.resolve(state.stateDir, "state", "openclaw.sqlite"), - ); - }); - }, - ); - expect(events).toEqual(["outer", "inner"]); - }); - }); - it.each([ { authority: "outer", explicitNestedEnv: false }, { authority: "nested", explicitNestedEnv: false }, diff --git a/src/state/github-publication.worker.test.ts b/src/state/github-publication.worker.test.ts index a3ad556298a0..5e96604ca65a 100644 --- a/src/state/github-publication.worker.test.ts +++ b/src/state/github-publication.worker.test.ts @@ -33,15 +33,11 @@ import { prepareGitHubPublicationSource, } from "../gateway/github-publication-source.js"; import { - claimRepositoryGitHubPublicationAsync, readGitHubPublicationRequestAsync, runGitHubPublicationMaintenanceAsync, } from "../gateway/github-publication-store-async.js"; import { listRepositoryGitHubPublicationsInDatabase } from "../gateway/github-repository-publication-read.worker.js"; -import { - claimRepositoryGitHubPublicationInDatabase, - insertRepositoryGitHubPublicationInDatabase, -} from "../gateway/github-repository-publication-store.worker.js"; +import { insertRepositoryGitHubPublicationInDatabase } from "../gateway/github-repository-publication-store.worker.js"; import { repositoryGitHubPublicationDigest } from "../gateway/github-repository-publication.kernel.js"; import * as workerAdmission from "../infra/sqlite-worker-operation-admission.js"; import { sqliteWorkerOwnerProbe } from "../infra/sqlite-worker-owner-probe.test-support.js"; @@ -54,10 +50,7 @@ import { waitForPublicationGate, } from "./github-publication-fixtures.test-support.js"; import type { RepositoryGitHubPublicationRow as RepositoryPublicationRow } from "./github-publication-read.types.js"; -import { - captureGitHubPublicationLifecycle, - githubPublicationReceipts, -} from "./github-publication-receipts.js"; +import { githubPublicationReceipts } from "./github-publication-receipts.js"; import { prepareGitHubPublicationSessionLifecycle } from "./github-publication-session-lifecycles.js"; import { insertGitHubPublicationSessionLifecycle, @@ -85,10 +78,7 @@ import { openOpenClawStateDatabase, runOpenClawStateWriteTransaction, } from "./openclaw-state-db.js"; -import { - captureOpenClawStateReadContext, - captureOpenClawStateWorkerContext, -} from "./openclaw-state-worker-context.js"; +import { captureOpenClawStateWorkerContext } from "./openclaw-state-worker-context.js"; import type { OpenClawStateWorkerContext } from "./openclaw-state-worker-context.types.js"; import { createSessionRepositoryWorkspaceInDatabase } from "./session-repository-workspaces.kernel.js"; import { @@ -231,9 +221,7 @@ async function sourceFixture( return { row, source }; } -it("creates the lazy shared schema and publishes lifecycle authority before its first insert reply", async ({ - signal, -}) => { +it("creates the lazy shared schema and replaces a missing lifecycle binding on insert", async () => { const request = { sessionKey: "agent:main:first-shared-publication", agentId: "main", @@ -284,30 +272,7 @@ it("creates the lazy shared schema and publishes lifecycle authority before its requestId: "first-shared-publication-request", }); expect(lifecycle.readCurrent()).toBeUndefined(); - const committed = createDeferredCore(); - let atSettlement: ReturnType; - onTestFinished( - githubPublicationReceipts.subscribeFacts((change) => { - if ( - change.kind === "committed" && - change.receipt.source.identity === context.admission.identity.key - ) { - expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); - committed.resolve(); - } - if (change.kind === "settled" && change.identity === context.admission.identity.key) { - atSettlement = captureGitHubPublicationLifecycle(captureOpenClawStateReadContext(), { - publicationKind: "shared", - requestId: "first-shared-publication-request", - }).readCurrent(); - } - }), - ); - const reply = holdNextReply("githubPublications.insert", () => { - // An uncommitted insert cannot authorize publication from a missing binding. - expect(lifecycle.readCurrent()).toBeUndefined(); - }); - const inserting = insertGitHubPublicationRequestAsync( + const inserted = await insertGitHubPublicationRequestAsync( { request, requestId: "first-shared-publication-request", @@ -325,13 +290,6 @@ it("creates the lazy shared schema and publishes lifecycle authority before its }, source, ); - try { - await waitForPublicationGate(signal, inserting, reply.ready, committed.promise); - expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); - } finally { - reply.release(); - } - const inserted = await inserting; expect(inserted).toMatchObject({ request_id: "first-shared-publication-request", status: "requested", @@ -344,7 +302,6 @@ it("creates the lazy shared schema and publishes lifecycle authority before its requester_authority_json: '{"version":1,"actor":{"kind":"system"},"scopes":["operator.admin"],"grant":null}', }; - expect(atSettlement).toEqual(expected); expect( ( await prepareGitHubPublicationSessionLifecycle({ @@ -542,30 +499,21 @@ it("publishes a committed receipt before a delayed ordinary worker reply", async const row = seed("delayed-reply"); const reply = holdNextReply(); const { facts } = observeAuthority(); - const authorityAtNotification: unknown[] = []; const key = JSON.stringify(["repository", row.request_id]); const committed = createDeferredCore(); - const published = vi.fn((_receipt: PublicationMutationReceipt) => { - authorityAtNotification.push(facts.get(key)); + const published = () => { + expect(facts.get(key)).toMatchObject({ + kind: "postimage", + value: { request_id: row.request_id, status: "publishing", execution_id: "execution" }, + }); committed.resolve(); - }); + }; const claim = mutatePublication( scope, { operation: "claim", row, instanceId: "gateway", executionId: "execution" }, published, ); await waitForPublicationGate(signal, claim, reply.ready, committed.promise); - expect(published).toHaveBeenCalledTimes(1); - expect(authorityAtNotification).toMatchObject([ - { - kind: "postimage", - value: { request_id: row.request_id, status: "publishing", execution_id: "execution" }, - }, - ]); - expect(published.mock.calls[0]?.[0]).toMatchObject({ - kind: "repository", - rows: [{ request_id: row.request_id, status: "publishing", execution_id: "execution" }], - }); expectRows(row, [{ request_id: row.request_id, status: "publishing" }]); reply.release(); await expect(claim).resolves.toMatchObject({ rows: [{ status: "publishing" }] }); @@ -588,90 +536,53 @@ it("publishes a committed receipt before a delayed ordinary worker reply", async expectRows(row, [{ reported_at_ms: expect.any(Number) }]); }); -it.for(["superseded", "closed"] as const)( - "fences a late authority receipt after its owner is %s", - async (outcome, { signal }) => { - const lifecycleInput = { - publicationKind: "shared" as const, - requestId: `late-${outcome}-lifecycle`, - }; - runOpenClawStateWriteTransaction(({ db }) => - insertGitHubPublicationSessionLifecycle(db, { - ...lifecycleInput, - lifecycleRevision: "original-revision", - }), - ); - const lifecycle = await prepareGitHubPublicationSessionLifecycle(lifecycleInput); - let closed = false; - const scope = createScope({ - ...context, - assertPublicationCurrent: () => { - if (closed) { - throw new Error("Synthetic publication owner closed"); - } - (context.assertPublicationCurrent ?? context.admission.assertCurrent)(); - }, - }); - const row = seed(`late-${outcome}`); - const { facts, unknown } = observeAuthority(); - const key = JSON.stringify(["repository", row.request_id]); - const receipt = holdNextReceipt(); - const reply = holdNextReply(); - const published = vi.fn(); - const claim = mutatePublication( - scope, - { operation: "claim", row, instanceId: "gateway", executionId: "original" }, - published, - ); - await waitForPublicationGate(signal, claim, receipt.ready, reply.ready); - if (outcome === "superseded") { - runOpenClawStateWriteTransaction((database) => - claimRepositoryGitHubPublicationInDatabase( - database, - { - ...row, - status: "publishing", - execution_id: "original", - gateway_instance_id: "gateway", - }, - "gateway", - "replacement", - { - assertCurrent: context.admission.assertCurrent, - assertCustody: context.admission.assertCurrent, - }, - ), - ); - expect(facts.get(key)).toMatchObject({ - kind: "postimage", - value: { execution_id: "replacement" }, - }); - receipt.release(); - expect(facts.get(key)).toEqual({ kind: "unknown" }); - expect(published).toHaveBeenCalledTimes(1); - } else { - closed = true; - expect(receipt.release).toThrow("publication owner closed"); - expect(facts.has(key)).toBe(false); - expect(unknown).toContain(context.admission.identity.key); - expect(published).not.toHaveBeenCalled(); - } - reply.release(); - await expect(claim).resolves.toMatchObject({ rows: [{ execution_id: "original" }] }); - expectRows(row, [{ execution_id: outcome === "superseded" ? "replacement" : "original" }]); - if (outcome === "closed") { - expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); - await expect(prepareGitHubPublicationSessionLifecycle(lifecycleInput)).rejects.toThrow( - "lifecycle authority is unavailable", - ); - } else { - expect(lifecycle.readCurrent()).toEqual({ - lifecycle_revision: "original-revision", - requester_authority_json: null, - }); - } - }, -); +it("fences a late authority receipt after its owner closes", async ({ signal }) => { + const lifecycleInput = { + publicationKind: "shared" as const, + requestId: "late-closed-lifecycle", + }; + runOpenClawStateWriteTransaction(({ db }) => + insertGitHubPublicationSessionLifecycle(db, { + ...lifecycleInput, + lifecycleRevision: "original-revision", + }), + ); + const lifecycle = await prepareGitHubPublicationSessionLifecycle(lifecycleInput); + let closed = false; + const scope = createScope({ + ...context, + assertPublicationCurrent: () => { + if (closed) { + throw new Error("Synthetic publication owner closed"); + } + (context.assertPublicationCurrent ?? context.admission.assertCurrent)(); + }, + }); + const row = seed("late-closed"); + const { facts, unknown } = observeAuthority(); + const key = JSON.stringify(["repository", row.request_id]); + const receipt = holdNextReceipt(); + const reply = holdNextReply(); + const published = vi.fn(); + const claim = mutatePublication( + scope, + { operation: "claim", row, instanceId: "gateway", executionId: "original" }, + published, + ); + await waitForPublicationGate(signal, claim, receipt.ready, reply.ready); + closed = true; + expect(receipt.release).toThrow("publication owner closed"); + expect(facts.has(key)).toBe(false); + expect(unknown).toContain(context.admission.identity.key); + expect(published).not.toHaveBeenCalled(); + reply.release(); + await expect(claim).resolves.toMatchObject({ rows: [{ execution_id: "original" }] }); + expectRows(row, [{ execution_id: "original" }]); + expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); + await expect(prepareGitHubPublicationSessionLifecycle(lifecycleInput)).rejects.toThrow( + "lifecycle authority is unavailable", + ); +}); it("revokes prepared sources when canonical deletion commits before its ordinary reply", async ({ signal, @@ -724,32 +635,6 @@ it("revokes prepared sources when canonical deletion commits before its ordinary expect(() => bindGitHubPublicationSource(source)).toThrow("source authority changed"); }); -it("settles execution bookkeeping without source authority while distinguishing observed effects", async () => { - const row = seed("observed-effect"); - const execution = await claimRepositoryGitHubPublicationAsync(row, "gateway", { - assertCustody: context.admission.assertCurrent, - assertAction() { - throw new Error("No action authority"); - }, - async prepareSource() { - throw new Error("No source authority"); - }, - }); - await execution.updateHead("d".repeat(40)); - await execution.recordEffect("push"); - expectRows(row, [{ effect_state: "dispatched", pushed_head_commit: null }]); - await execution.recordEffect("push", { headCommit: "d".repeat(40) }); - await execution.interrupt(); - expectRows(row, [ - { - status: "requested", - last_effect: "push", - effect_state: "observed", - pushed_head_commit: "d".repeat(40), - }, - ]); -}); - it("refuses delayed observation from an execution replaced by a later claim", async () => { const scope = createScope(); const row = seed("replaced-execution"); diff --git a/src/state/openclaw-agent-db.postgres.test.ts b/src/state/openclaw-agent-db.postgres.test.ts index 0a8ba5da70eb..faf47067b631 100644 --- a/src/state/openclaw-agent-db.postgres.test.ts +++ b/src/state/openclaw-agent-db.postgres.test.ts @@ -8,8 +8,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import pg from "pg"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import * as doctorEntries from "../commands/doctor/shared/session-entry-rewrite.js"; -import { migrateLegacySessionEntryState } from "../commands/doctor/shared/session-entry-shape.js"; import { reconcileSessionTranscriptIndexInTransaction } from "../config/sessions/session-transcript-index.js"; import type { TranscriptTurnBoundary } from "../config/sessions/transcript-entry-anchor.js"; import { createTranscriptEventInserter } from "../config/sessions/transcript-payload.js"; @@ -26,11 +24,9 @@ import { iterateSqliteQuerySync } from "../infra/kysely-sync.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import { isPostgresSqlConnection, type EngineConnection } from "../infra/sql-connection.js"; import { retireSqliteDatabaseAdmissionForPath as retireAdmission } from "../infra/sqlite-database-admission.js"; -import { readDatabasePathIdentitySync } from "../infra/sqlite-worker-identity.js"; import { migrateHistoricalTranscriptDirectives } from "../infra/state-migrations.transcript-directives.js"; import { OPENCLAW_AGENT_SCHEMA_VERSION as schemaVersion } from "./openclaw-agent-db-contract.js"; import { agentEngineSchema } from "./openclaw-agent-db-engine.js"; -import { readOpenClawAgentDatabaseIdentity as readIdentity } from "./openclaw-agent-db-identity.js"; import { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js"; import { createDatabaseOpener, @@ -86,21 +82,6 @@ function exportFts(database: { db: EngineConnection }, sessionId: string) { ); } -async function inspectSchema(pathname: string, agentId?: string) { - await using inspector = owners.createAgentSchemaInspectionWorker(); - const result = await inspector.inspect({ - pathname, - ...(agentId ? { agentId } : {}), - env: process.env, - supportedVersion: schemaVersion, - verifyCurrentSchemaShape: true, - inspectOwnership: true, - requireStartupMigrationReadiness: true, - }); - expect(inspector).toMatchObject({ snapshotCount: 0, processCount: 1 }); - return result; -} - async function queryRows( sql: string, values?: unknown[], @@ -165,27 +146,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { }); beforeEach(() => vi.stubEnv("OPENCLAW_STATE_DIR", root)); - it("reads persisted model catalogs through the worker without local application tables", async () => { - const { loadPersistedPluginModelCatalogs } = - await import("../agents/plugin-model-catalog-execution.js"); - const agentId = "model-catalog"; - const contents = JSON.stringify({ providers: { fixture: { models: [{ id: "synthetic" }] } } }); - const database = openDatabase({ agentId }); - database.write( - "INSERT INTO cache_entries (scope, key, value_json, updated_at) VALUES (?, ?, ?, ?)", - "plugin-model-catalog-v1", - "fixture", - contents, - 1, - ); - const databasePath = database.path; - closeDatabase(databasePath); - expect(await loadPersistedPluginModelCatalogs(path.dirname(databasePath), ["fixture"])).toEqual( - [{ pluginId: "fixture", contents }], - ); - expectAnchorOnly(databasePath); - }); - it("inspects clean PostgreSQL transcripts and preserves history requiring a file repair backup", async () => { const target = await createSession("doctor-transcript", "doctor-transcripts", "main"); await owners.appendTranscriptMessage(target, { @@ -228,69 +188,7 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { expectAnchorOnly(database.path); }); - it("scans and repairs legacy Doctor state while retaining opaque PostgreSQL JSON numbers", () => { - admit(() => { - using database = openDatabase({ agentId: "doctor" }); - const sessionKey = "agent:doctor:main"; - const raw = - '{"sessionId":"doctor-session","updatedAt":42,"status":"queued","pendingFinalDelivery":true,"pendingFinalDeliveryText":"saved reply","opaqueCount":9007199254740993,"opaque":{"value":123456789012345678901234567890,"label":"retained"}}'; - database.run( - "INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at) VALUES (?, ?, ?, ?)", - sessionKey, - "doctor-session", - raw, - 42, - ); - database.run( - "INSERT INTO session_windows (session_id, session_key, session_scope, created_at, updated_at) VALUES (?, ?, 'conversation', ?, ?)", - "doctor-session", - sessionKey, - 42, - 42, - ); - const scope = { agentId: "doctor", storePath: database.path }; - const expectedIdentity = readDatabasePathIdentitySync(database.path); - const scanned: Array<{ sessionKey: string; entry: Record }> = []; - doctorEntries.scanDoctorSessionEntryRecords( - scope, - (record) => scanned.push(record), - expectedIdentity, - ); - expect(scanned).toEqual([ - { - sessionKey, - entry: expect.objectContaining({ status: "queued", pendingFinalDelivery: true }), - }, - ]); - expect( - doctorEntries.rewriteDoctorSessionEntries({ - scope, - sessionKeys: [sessionKey], - expectedIdentity, - rawTransform: (entry, key, updatedAt) => - migrateLegacySessionEntryState(entry, updatedAt, key), - }), - ).toBe(1); - const repairedRaw = database.get( - "SELECT entry_json FROM session_nodes WHERE session_key = ?", - sessionKey, - )?.entry_json; - assert(typeof repairedRaw === "string"); - expect(JSON.parse(repairedRaw)).toMatchObject({ - sessionId: "doctor-session", - updatedAt: 42, - status: "interrupted", - pendingFinalDelivery: { kind: "replayable", text: "saved reply", createdAt: 42 }, - opaque: { label: "retained" }, - }); - expect(repairedRaw).not.toContain("pendingFinalDeliveryText"); - expect(repairedRaw).toContain("9007199254740993"); - expect(repairedRaw).toContain("123456789012345678901234567890"); - expectAnchorOnly(database.path); - }); - }); - - it("opens default and explicit stores, retains identity, and closes both handles", () => { + it("closes default and explicit stores with paired handles and a PostgreSQL receipt", () => { return admit(() => { for (const options of [ { agentId: "main" }, @@ -298,19 +196,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { ]) { const database = openDatabase(options); assert(isPostgresSqlConnection(database.db)); - const facts = postgresFacts(database.db); - expect(facts).toMatchObject({ store: "agent", version: schemaVersion }); - expect( - database.get( - "SELECT agent_id, role, schema_version FROM schema_meta WHERE meta_key = 'primary'", - ), - ).toEqual({ agent_id: options.agentId, role: "agent", schema_version: schemaVersion }); - const identity = readIdentity(database); - const retained = owners.retainOpenClawAgentDatabaseReadOnly(options); - assert(retained.found); - expect(retained.claim.identity).toBe(identity.identity); - expect(retained.claim.isCurrent()).toBe(true); - retained.claim.release(); const anchor = database.db.anchor; closeReceipts.mockClear(); expect(owners.closeOpenClawAgentDatabaseByPath(database.path)).toBe(true); @@ -319,12 +204,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { `agent database clean-close receipt: postgres-closed; agent=${options.agentId}`, expect.objectContaining({ agentId: options.agentId, receipt: "postgres-closed" }), ); - const reopened = owners.openOpenClawAgentDatabase(options); - assert(isPostgresSqlConnection(reopened.db)); - expect(postgresFacts(reopened.db)?.namespace).toBe(facts?.namespace); - expect(readIdentity(reopened).identity).toBe(identity.identity); - expect(readIdentity(reopened).incarnation).not.toBe(identity.incarnation); - closeDatabase(reopened.path); } }); }); @@ -341,10 +220,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { }, }; const assertMemorySearch = (db: EngineConnection) => { - const extensions = db.prepare("SELECT search_extensions FROM openclaw_schema_meta").get(); - expect(JSON.parse(String(extensions?.search_extensions))).toEqual({ - "memory.text": memorySearch, - }); expect( db .prepare(`SELECT id, text FROM memory_index_chunks @@ -485,27 +360,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { server_major: facts.capabilities.serverMajor, }); }); - const indexes = await queryRows( - `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 - AND indexname IN ('idx_agent_session_windows_conversation', 'idx_agent_transcript_event_parent', - 'idx_agent_session_nodes_last_interaction_at', 'idx_agent_session_reactions_message')`, - [facts.namespace], - ); - expect(indexes).toEqual([ - { - indexname: "idx_agent_session_windows_conversation", - indexdef: `CREATE INDEX idx_agent_session_windows_conversation ON ${facts.namespace}.session_windows USING btree (primary_conversation_id NULLS FIRST) WHERE (primary_conversation_id IS NOT NULL)`, - }, - ]); - expect( - await queryRows( - `SELECT p.proname FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 AND p.proname IN - ('openclaw_memory_index_sources_trigger', 'openclaw_memory_index_chunks_trigger')`, - [facts.namespace], - ), - ).toEqual([]); expect( await queryRows(`SELECT revision FROM "${facts.namespace}".memory_index_state`), ).toEqual([{ revision: "1" }]); @@ -543,29 +397,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { } }); - it("discovers a cold explicit store owner through the selected reader worker", async () => { - const pathname = path.join(root, "explicit", "openclaw-agent.sqlite"); - admit(() => { - owners.openOpenClawAgentDatabase({ agentId: "alternate", path: pathname }); - owners.closeOpenClawAgentDatabaseByPath(pathname); - }); - await owners.closeOpenClawAgentDatabaseByPathAsync(pathname); - const { readActiveOpenClawAgentDatabaseLeasesReadOnly } = - await import("./openclaw-agent-db-lease.js"); - const activeLeases = () => - admit(() => - readActiveOpenClawAgentDatabaseLeasesReadOnly({ env: process.env }).filter( - (lease) => lease.path === pathname, - ), - ); - expect(activeLeases()).toEqual([]); - retireAdmission(pathname); - expect(await inspectSchema(pathname)).toMatchObject({ - agentSchemaMeta: { agentId: "alternate", role: "agent", schemaVersion }, - }); - expect(activeLeases()).toEqual([]); - }); - it("appends and hydrates plain transcript JSON and drains interleaved turns through the agent worker in FIFO order", async () => { const { appendTranscriptMessage, @@ -601,29 +432,11 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { }); parentId = reply.messageId; } - const hydrated = await prepareSessionTranscriptHydration(target).read(); - expect( - hydrated.snapshot.events.filter((event) => isRecord(event) && event.type === "message"), - ).toHaveLength(7); - expect(await owners.searchSessionTranscripts({ ...target, query: "progress" })).toMatchObject({ - hits: [ - { - sessionId: target.sessionId, - sessionKey: target.sessionKey, - role: "assistant", - snippet: expect.stringContaining("progress"), - }, - ], - indexing: false, - }); const database = openDatabase({ agentId: "actor" }); assert(isPostgresSqlConnection(database.db)); const facts = postgresFacts(database.db); assert(facts); - expect(await inspectSchema(database.path, "actor")).toMatchObject({ - version: schemaVersion, - agentSchemaMeta: { agentId: "actor", role: "agent", schemaVersion }, - }); + const hydrated = await prepareSessionTranscriptHydration(target).read(); const payloads = await queryRows<{ event_json: string; event_zstd: Buffer | null }>( `SELECT event_json, event_zstd FROM "${facts.namespace}".transcript_events ORDER BY seq`, ); @@ -685,223 +498,92 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { ).toEqual([{ count: "0" }]); }); - it("searches active branches through the worker with session and role filters and lifecycle deletion", async () => { - const target = targetFor("search-branch"); - const sibling = { - ...target, - sessionId: "search-sibling", - sessionKey: "agent:actor:search-sibling", + it("preserves FTS row identity for nullable and repeated IDs across bounded inserts", async () => { + const count = 515; + const sessionId = `fts-identities-${count}`; + const options = { agentId: "actor" }; + const database = openDatabase(options); + const postgres = database.db; + assert(isPostgresSqlConnection(postgres)); + const facts = postgresFacts(postgres); + assert(facts); + // Cold restoration preserves these nullable/repeated stored IDs; normal + // transcript append filters absent IDs and deduplicates event identities. + const entries = Array.from({ length: count }, (_, index) => ({ + messageId: index % 4 < 2 ? null : "repeated", + text: `ftskeytoken${index}`, + role: index % 2 ? "assistant" : "user", + timestamp: index, + })); + let statements = 0; + const observation = channel("openclaw.database.statement"); + const observe = (value: unknown) => { + if (isRecord(value) && value.engine === "postgres") { + statements += 1; + } }; - for (const scope of [target, sibling]) { - await owners.upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - } - await owners.appendTranscriptMessage(target, { - eventId: "search-user", - parentId: null, - message: { role: "user", content: "searchable alpha origin" }, - }); - await owners.appendTranscriptMessage(target, { - eventId: "search-assistant", - parentId: "search-user", - message: { role: "assistant", content: "searchable beta abandoned" }, - }); - await owners.appendTranscriptMessage(sibling, { - eventId: "search-sibling-user", - message: { role: "user", content: "searchable survivor" }, - }); - const search = { - agentId: target.agentId, - storePath: target.storePath, - env: process.env, - query: "searchable", - sessionKeys: [target.sessionKey, sibling.sessionKey], - }; - const find = (overrides: Partial[0]> = {}) => - owners.searchSessionTranscripts({ ...search, ...overrides }); - expect((await find({ role: "assistant" })).hits).toEqual([ - expect.objectContaining({ messageId: "search-assistant" }), - ]); - expect((await find({ sessionId: sibling.sessionId })).hits).toEqual([ - expect.objectContaining({ messageId: "search-sibling-user" }), - ]); - expect( - (await find({ sessionKeys: [target.sessionKey] })).hits - .map((hit) => hit.messageId) - .toSorted(), - ).toEqual(["search-assistant", "search-user"]); - await owners.appendTranscriptEvent(target, { - type: "leaf", - id: "search-leaf", - parentId: "search-assistant", - targetId: "search-user", - }); - expect((await find({ query: "beta" })).hits).toEqual([]); - const options = { agentId: target.agentId, env: process.env }; - await owners.waitForSessionTranscriptIndexReconcile(options); - if ((await find()).indexing) { - await owners.reconcileSessionTranscriptIndexes(options); - } - expect(await find({ query: "alpha" })).toMatchObject({ - hits: [expect.objectContaining({ messageId: "search-user" })], - indexing: false, - }); - expect((await find({ query: "beta" })).hits).toEqual([]); - const removal = { - ...target, - target: { canonicalKey: target.sessionKey, storeKeys: [target.sessionKey] }, - archiveTranscript: false, - deleteTranscriptWithoutArchive: true, - }; - await owners.upsertSessionEntryCore(sibling, { previousSessionId: target.sessionId }); - expect(await owners.deleteSessionEntryLifecycle(removal)).toMatchObject({ deleted: true }); - expect((await find({ query: "alpha" })).hits).toEqual([ - expect.objectContaining({ sessionId: target.sessionId, messageId: "search-user" }), - ]); - await owners.upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 2 }); - await owners.upsertSessionEntryCore(sibling, { previousSessionId: undefined }); - expect( - await owners.deleteSessionEntryLifecycle({ ...removal, archiveTranscript: true }), - ).toMatchObject({ deleted: true, archivedTranscripts: [expect.anything()] }); - expect((await find()).hits).toEqual([ - expect.objectContaining({ sessionId: sibling.sessionId, messageId: "search-sibling-user" }), - ]); - }); - - it("indexes only accepted searchable messages from a transcript append batch", async () => { - const target = await createSession("fts-append-batch"); - function event(id: string, parentId: string | null, role: string, content: string) { - return { type: "message", id, parentId, timestamp: 1, message: { role, content } }; - } admit(() => - owners.runOpenClawAgentWriteTransaction( - (database) => { - expect( - owners.appendTranscriptEventsInTransaction(database, target, [ - event("batch-user", null, "user", "batchneedle accepted origin"), - event("batch-user", null, "user", "batchneedle rejected duplicate"), - event("batch-tool", "batch-user", "toolResult", "batchneedle hidden tool"), - event("batch-reply", "batch-tool", "assistant", "batchneedle accepted reply"), - ]), - ).toBe(3); - }, - { agentId: target.agentId }, - ), - ); - const search = (query: string) => owners.searchSessionTranscripts({ ...target, query }); - expect((await search("batchneedle")).hits.map((hit) => hit.messageId).toSorted()).toEqual([ - "batch-reply", - "batch-user", - ]); - expect((await search("rejected")).hits).toEqual([]); - expect((await search("hidden")).hits).toEqual([]); - }); - - it.each([4, 515])( - "preserves FTS row identity for %i nullable and repeated message IDs in bounded statements", - async (count) => { - const sessionId = `fts-identities-${count}`; - const options = { agentId: "actor" }; - const database = openDatabase(options); - const postgres = database.db; - assert(isPostgresSqlConnection(postgres)); - const facts = postgresFacts(postgres); - assert(facts); - // Cold restoration preserves these nullable/repeated stored IDs; normal - // transcript append filters absent IDs and deduplicates event identities. - const entries = Array.from({ length: count }, (_, index) => ({ - messageId: index % 4 < 2 ? null : "repeated", - text: `ftskeytoken${index}`, - role: index % 2 ? "assistant" : "user", - timestamp: index, - })); - let statements = 0; - const observation = channel("openclaw.database.statement"); - const observe = (value: unknown) => { - if (isRecord(value) && value.engine === "postgres") { - statements += 1; + owners.runOpenClawAgentWriteTransaction(({ db }) => { + const insert = owners.createSessionTranscriptFtsInserter(db, sessionId); + observation.subscribe(observe); + try { + insert(entries); + } finally { + observation.unsubscribe(observe); } - }; - admit(() => - owners.runOpenClawAgentWriteTransaction(({ db }) => { - const insert = owners.createSessionTranscriptFtsInserter(db, sessionId); - observation.subscribe(observe); - try { - insert(entries); - } finally { - observation.unsubscribe(observe); - } - }, options), - ); - const persisted = await queryRows( - `SELECT r.id, r.id = f.rowid AS same_id, r.message_id AS owner_message_id, + }, options), + ); + const persisted = await queryRows( + `SELECT r.id, r.id = f.rowid AS same_id, r.message_id AS owner_message_id, f.message_id, f.text, f.role, f.timestamp FROM "${facts.namespace}".session_transcript_fts_rows r LEFT JOIN "${facts.namespace}".session_transcript_fts f ON f.rowid = r.id WHERE r.session_id = $1 ORDER BY f.timestamp`, - [sessionId], + [sessionId], + ); + const expected = entries.map(({ messageId, ...entry }) => ({ + message_id: messageId, + ...entry, + })); + expect(persisted.map(({ id: _id, ...row }) => row)).toEqual( + expected.map((entry) => ({ same_id: true, owner_message_id: entry.message_id, ...entry })), + ); + const ids = persisted.map((row) => BigInt(row.id)); + for (let index = 1; index < ids.length; index++) { + expect(ids[index]).toBeGreaterThan(ids[index - 1]!); + } + expect(admit(() => exportFts(database, sessionId))).toEqual(expected); + // Each observed PostgreSQL statement makes one bridge/server request. + expect(statements).toBe(2); + admit(() => { + // Keep the first bridge open so the pooled writer uses another backend. + const next = owners.openAgentEngineStore( + postgres.anchor, + options.agentId, + database.path, + true, ); - const expected = entries.map(({ messageId, ...entry }) => ({ - message_id: messageId, - ...entry, - })); - expect(persisted.map(({ id: _id, ...row }) => row)).toEqual( - expected.map((entry) => ({ same_id: true, owner_message_id: entry.message_id, ...entry })), - ); - const ids = persisted.map((row) => BigInt(row.id)); - for (let index = 1; index < ids.length; index++) { - expect(ids[index]).toBeGreaterThan(ids[index - 1]!); - } - expect(admit(() => exportFts(database, sessionId))).toEqual(expected); - for (const index of [0, 1, 2, 3]) { - const searched = await queryRows( - `SELECT message_id, text FROM "${facts.namespace}".session_transcript_fts - WHERE session_id = $1 AND openclaw_search @@ - plainto_tsquery('"${facts.namespace}".openclaw_simple_unaccent', $2)`, - [sessionId, `ftskeytoken${index}`], + assert(isPostgresSqlConnection(next)); + try { + expect(next.prepare("SELECT pg_backend_pid() AS pid").get()?.pid).not.toBe( + postgres.prepare("SELECT pg_backend_pid() AS pid").get()?.pid, ); - expect(searched).toEqual([ - { message_id: index < 2 ? null : "repeated", text: `ftskeytoken${index}` }, - ]); + next.runTransaction(() => + owners.createSessionTranscriptFtsInserter(next, `${sessionId}-next`)(entries.slice(0, 2)), + ); + const later = next + .prepare("SELECT id FROM session_transcript_fts_rows WHERE session_id = ? ORDER BY id") + .all(`${sessionId}-next`); + expect(later).toHaveLength(2); + expect(BigInt(String(later[0]!.id))).toBeGreaterThan(ids.at(-1)!); + expect(BigInt(String(later[1]!.id))).toBeGreaterThan(BigInt(String(later[0]!.id))); + } finally { + next.close(); } - // Each observed PostgreSQL statement makes one bridge/server request. - expect(statements).toBe(count === 4 ? 1 : 2); - if (count > 512) { - admit(() => { - // Keep the first bridge open so the pooled writer uses another backend. - const next = owners.openAgentEngineStore( - postgres.anchor, - options.agentId, - database.path, - true, - ); - assert(isPostgresSqlConnection(next)); - try { - expect(next.prepare("SELECT pg_backend_pid() AS pid").get()?.pid).not.toBe( - postgres.prepare("SELECT pg_backend_pid() AS pid").get()?.pid, - ); - next.runTransaction(() => - owners.createSessionTranscriptFtsInserter( - next, - `${sessionId}-next`, - )(entries.slice(0, 2)), - ); - const later = next - .prepare( - "SELECT id FROM session_transcript_fts_rows WHERE session_id = ? ORDER BY id", - ) - .all(`${sessionId}-next`); - expect(later).toHaveLength(2); - expect(BigInt(String(later[0]!.id))).toBeGreaterThan(ids.at(-1)!); - expect(BigInt(String(later[1]!.id))).toBeGreaterThan(BigInt(String(later[0]!.id))); - } finally { - next.close(); - } - }); - } - }, - ); + }); + }); - it("matches SQLite recent-search cutoffs and cold-export order for equal-timestamp batches", () => { + it("preserves recent-search cutoffs and cold-export order for equal-timestamp batches", () => { const events = Array.from({ length: 130 }, (_, index) => ({ type: "message", id: `ordered-${index}`, @@ -909,99 +591,36 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { timestamp: 1, message: { role: "user", content: `chronologyneedle entry ${index}` }, })); - const results = (["sqlite", "postgres"] as const).map((engine) => - engineSelection.withDatabaseEngineSelection( - { - effectiveEngines: resolveDatabaseFamilyEngines({ database: { engine } }), - postgres: { connection: connection!, schemaPrefix: prefix }, - }, - () => - admit(() => { - const options = { - agentId: "actor", - ...(engine === "sqlite" - ? { path: path.join(root, "sqlite-fts-order", "agent.sqlite") } - : {}), - }; - const target = { - ...options, - sessionId: "fts-chronology", - sessionKey: "agent:actor:fts-chronology", - }; - using database = openDatabase(options); - owners.runOpenClawAgentWriteTransaction((writer) => { - expect( - owners.appendTranscriptEventsInTransaction(writer, target, events, { - scheduleProjectionReconcile: false, - }), - ).toBe(events.length); - }, options); - const exported = exportFts(database, target.sessionId); - expect(exported.map((row) => row.message_id)).toEqual(events.map((event) => event.id)); - return [1, 3, 25].map((limit) => { - const result = owners.searchSessionTranscriptsReadOnlySync( - { ...target, query: "chronologyneedle", order: "recent", limit }, - { ...options, path: database.path }, - ); - const ids = result.hits.map((hit) => hit.messageId); - expect(ids).toEqual( - events - .slice(-limit) - .toReversed() - .map((event) => event.id), - ); - return ids; - }); + admit(() => { + const options = { agentId: "actor" }; + const target = { + ...options, + sessionId: "fts-chronology", + sessionKey: "agent:actor:fts-chronology", + }; + using database = openDatabase(options); + owners.runOpenClawAgentWriteTransaction((writer) => { + expect( + owners.appendTranscriptEventsInTransaction(writer, target, events, { + scheduleProjectionReconcile: false, }), - ), - ); - expect(results[1]).toEqual(results[0]); - }); - - it("reads persisted auth through the selected owner and agent worker", async () => { - const store = { - version: 1, - profiles: { fixture: { type: "api_key", provider: "fixture", key: "synthetic" } }, - }; - for (const options of [ - { agentId: "main" }, - { agentId: "alternate", path: path.join(root, "explicit", "openclaw-agent.sqlite") }, - ]) { - const database = openDatabase(options); - admit(() => - owners.runOpenClawAgentWriteTransaction( - ({ db }) => owners.writeAuthProfileJsonCell(db, "store", "agent", store), - options, - ), + ).toBe(events.length); + }, options); + expect(exportFts(database, target.sessionId).map((row) => row.message_id)).toEqual( + events.map((event) => event.id), ); - expect( - admit(() => owners.inspectPersistedAuthProfileStoreRaw(path.dirname(database.path))), - ).toEqual({ status: "readable", raw: store }); - closeDatabase(database.path); - await owners.closeOpenClawAgentDatabaseByPathAsync(database.path); - const reader = owners.prepareAgentAuthProfileRowsRead({ - databasePath: database.path, - agentId: options.agentId, - env: process.env, - }); - try { - expect((await reader.read()).store).toEqual({ status: "readable", raw: store }); - } finally { - await reader.dispose(); + for (const limit of [1, 3, 25]) { + const result = owners.searchSessionTranscriptsReadOnlySync( + { ...target, query: "chronologyneedle", order: "recent", limit }, + { ...options, path: database.path }, + ); + expect(result.hits.map((hit) => hit.messageId)).toEqual( + events + .slice(-limit) + .toReversed() + .map((event) => event.id), + ); } - } - const sharedMainPath = path.join(root, "agents", "main", "agent", "openclaw-agent.sqlite"); - await owners.closeOpenClawAgentDatabaseByPathAsync(sharedMainPath); - retireAdmission(sharedMainPath); - expect( - await owners.prepareFreshSharedAuthStoreWriteAsync({ - agentDir: undefined, - allowExplicitMain: false, - env: process.env, - }), - ).toBe(true); - expect(owners.resolveSharedAuthStoreOwnership(process.env)).toEqual({ - location: "legacy-main", }); }); diff --git a/src/state/openclaw-agent-worker-store.test.ts b/src/state/openclaw-agent-worker-store.test.ts index a8d5a1fd1284..ae04cec7c1fb 100644 --- a/src/state/openclaw-agent-worker-store.test.ts +++ b/src/state/openclaw-agent-worker-store.test.ts @@ -1,7 +1,6 @@ import { channel } from "node:diagnostics_channel"; import fs from "node:fs"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; import { setImmediate as nextTurn } from "node:timers/promises"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,15 +9,12 @@ import { openFixtureReceiptChannel, type FixtureReceiptChannel, } from "../../test/helpers/fixture-receipts.js"; -import { awaitGateBeforeSettlement, withinTest } from "../../test/helpers/promise.js"; +import { withinTest } from "../../test/helpers/promise.js"; import { useSqliteWorkerFault } from "../../test/helpers/sqlite-worker-fault.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { resolveRuntimeWorkerUrl } from "../infra/runtime-worker-url.js"; import { readSqliteDatabaseCleanClose } from "../infra/sqlite-database-admission.js"; -import { SQLITE_IDLE_HANDLE_TTL_MS } from "../infra/sqlite-handle-lifecycle.js"; import { throwSqliteLifecycleErrors } from "../infra/sqlite-lifecycle-errors.js"; -import { observeSqliteWalPeriodicWork } from "../infra/sqlite-wal-scheduler.test-support.js"; -import * as sqliteWal from "../infra/sqlite-wal.js"; import { readDatabasePathIdentitySync } from "../infra/sqlite-worker-identity.js"; import * as admission from "../infra/sqlite-worker-operation-admission.js"; import { sqliteWorkerOwnerProbe as probe } from "../infra/sqlite-worker-owner-probe.test-support.js"; @@ -31,21 +27,12 @@ import { } from "../process/gateway-work-admission.js"; import { createDeferredCore } from "../shared/deferred.js"; import { requireNativeSqliteDatabase } from "../test-utils/native-sqlite.js"; -import { - closeOpenClawAgentDatabasesAsync, - retainAgentDatabase, -} from "./openclaw-agent-db-lifecycle.js"; -import { - isAgentDatabaseClosing, - registerOpenClawAgentDatabaseAsyncResource, - revokeAgentDatabaseResources, -} from "./openclaw-agent-db-resources.js"; +import { closeOpenClawAgentDatabasesAsync } from "./openclaw-agent-db-lifecycle.js"; +import { revokeAgentDatabaseResources } from "./openclaw-agent-db-resources.js"; import { withOpenClawAgentDatabaseWrite } from "./openclaw-agent-db-write.js"; import { openOpenClawAgentDatabase as openDatabase, closeOpenClawAgentDatabasesForTest, - withOpenClawAgentDatabaseAdmission, - withOpenClawAgentDatabaseAsync, } from "./openclaw-agent-db.js"; import type { OpenClawAgentDatabaseExecution } from "./openclaw-agent-execution-contract.js"; import { captureOpenClawAgentDatabaseExecution as captureExecution } from "./openclaw-agent-execution.js"; @@ -159,81 +146,8 @@ it("leaves agent worker checkpoints to maintenance instead of foreground commits expect(await worker.execute({ type: "inspect", input: "wal_autocheckpoint" }, allow)).toBe(0); }); -it("renews idle retention on reborrow", async () => { - const { db, worker } = await setup(); - const shared = openOpenClawStateDatabase(); - const releaseState = retainOpenClawStateDatabaseForIdle(shared); - const readLeases = leaseReader(shared); - const hostLeases = readLeases(); - const publish = (value: string) => worker.run((scope) => scope.execute(append(value)), allow); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - await publish("first"); - const retainedLeases = readLeases(); - expect(retainedLeases).toHaveLength(hostLeases.length + 1); - - await vi.advanceTimersByTimeAsync(SQLITE_IDLE_HANDLE_TTL_MS - 1); - const borrowed = captureExecution(options); - executions.add(borrowed); - await publish("reborrowed"); - expect(readLeases()).toEqual(retainedLeases); - await vi.advanceTimersByTimeAsync(SQLITE_IDLE_HANDLE_TTL_MS); - expect(readLeases()).toEqual(retainedLeases); - await borrowed.release(); - executions.delete(borrowed); - - await vi.advanceTimersByTimeAsync(SQLITE_IDLE_HANDLE_TTL_MS - 1); - expect(readLeases()).toEqual(retainedLeases); - await vi.advanceTimersByTimeAsync(1); - // A new request joins the expired generation's cleanup before reopening it. - await publish("after idle"); - expect(readLeases()).toHaveLength(retainedLeases.length); - expect(readLeases()).not.toEqual(retainedLeases); - expectValues(db, ["first", "reborrowed", "after idle"]); - } finally { - vi.useRealTimers(); - releaseState(); - } -}); - -it("retains warm execution across fallback preparation", async () => { - const { db, worker } = await setup(); - const firstThread = await worker.execute(append("before preparation"), allow); - const lifetime = captureExecution(options); - executions.add(lifetime); - await worker.close(); - const entered = createDeferredCore(); - const resume = createDeferredCore(); - let next: OpenClawAgentSqliteWorkerStore | undefined; - const publication = (async () => { - entered.resolve(); - await resume.promise; - next = await openWorker(db); - return next.run((scope) => scope.execute(append("after preparation")), allow); - })(); - try { - await entered.promise; - markGatewayRestartDraining(); - resume.resolve(); - expect(await publication).toBe(firstThread); - expectValues(db, ["before preparation", "after preparation"]); - await next?.close(); - expect(db.isOpen).toBe(true); - beginGatewayShutdownCleanup(); - await lifetime.release(); - expect(db.isOpen).toBe(false); - expect(readSqliteDatabaseCleanClose(options.path)).toBe(true); - } finally { - resume.resolve(); - await Promise.allSettled([publication]); - await Promise.allSettled([next?.close(), lifetime.release(), worker.close()]); - executions.delete(lifetime); - resetGatewayWorkAdmission(); - } -}); - it.each([false, true])( - "releases shutdown leases unless retained by an accepted sequence (%s)", + "reuses retained publications across reopen and releases shutdown leases (%s)", async (retainGeneration) => { const { db, worker } = await setup(); const execution = retainGeneration ? captureExecution(options) : undefined; @@ -244,6 +158,7 @@ it.each([false, true])( const releaseState = retainOpenClawStateDatabaseForIdle(shared); const readLeases = leaseReader(shared); const hostLeases = readLeases(); + let following = worker; try { const firstThread = await worker.run(async (scope) => { const thread = await scope.execute(append("first")); @@ -255,10 +170,16 @@ it.each([false, true])( expect(secondThread).toBe(firstThread); expect(retainedLeases).toHaveLength(hostLeases.length + 1); expect(readLeases()).toEqual(retainedLeases); - await worker.run((scope) => scope.execute(append("third")), allow); + if (retainGeneration) { + await worker.close(); + } + following = retainGeneration ? await openWorker(db) : worker; + expect(await following.run((scope) => scope.execute(append("third")), allow)).toBe( + firstThread, + ); expect(readLeases()).toEqual(retainedLeases); beginGatewayShutdownCleanup(); - const cleanupThread = await worker.execute(append("cleanup"), allow); + const cleanupThread = await following.execute(append("cleanup"), allow); if (retainGeneration) { expect(cleanupThread).toBe(firstThread); expect(readLeases()).toEqual(retainedLeases); @@ -266,12 +187,13 @@ it.each([false, true])( expect(readLeases()).toEqual(hostLeases); } expectValues(db, ["first", "second", "third", "cleanup"]); - await worker.close(); + await following.close(); await execution?.release(); expect(db.isOpen).toBe(false); expect(readLeases()).toEqual([]); expect(readSqliteDatabaseCleanClose(options.path)).toBe(true); } finally { + await following.close(); await worker.close(); await execution?.release(); if (execution) { @@ -283,136 +205,6 @@ it.each([false, true])( }, ); -it.each([false, true])( - "certifies native close only after its WAL is empty (pinned: %s)", - async (pinned) => { - const { db, worker } = await setup(); - const releaseHost = retainAgentDatabase(db); - let reader: DatabaseSync | undefined; - try { - await worker.execute(append("worker commit"), () => {}); - beginGatewayShutdownCleanup(); - await worker.close(); - if (pinned) { - reader = new DatabaseSync(options.path, { readOnly: true }); - reader.exec("BEGIN"); - reader.prepare("SELECT value FROM worker_proof").all(); - } - db.exec("INSERT INTO worker_proof VALUES ('after worker checkpoint')"); - const prepare = vi.spyOn(db, "prepare"); - const exec = vi.spyOn(db, "exec"); - await closeOpenClawAgentDatabasesAsync(root); - expect(prepare).not.toHaveBeenCalled(); - // Retirement may change lock policy, but must not run host-thread data SQL. - expect(exec.mock.calls).toEqual([["PRAGMA busy_timeout = 0"]]); - expect(readSqliteDatabaseCleanClose(options.path)).toBe(!pinned); - resetGatewayWorkAdmission(); - const reopened = openDatabase(options); - expect(reopened.db.prepare("SELECT value FROM worker_proof ORDER BY rowid").all()).toEqual([ - { value: "worker commit" }, - { value: "after worker checkpoint" }, - ]); - } finally { - reader?.close(); - releaseHost(); - resetGatewayWorkAdmission(); - } - }, -); - -it("closes siblings while a publication retains its database", ({ signal }) => - fixture.run(async () => { - const { db, worker } = await setup(); - const heldPath = options.path; - const healthy = openDatabase({ - agentId: "healthy", - path: path.join(root, "healthy.sqlite"), - }); - const shared = openOpenClawStateDatabase(); - const readLeases = leaseReader(shared); - const entered = createDeferredCore(); - const release = createDeferredCore(); - const healthyClosed = createDeferredCore(); - const nativeClose = healthy.db.close.bind(healthy.db); - vi.spyOn(healthy.db, "close").mockImplementation(() => { - nativeClose(); - healthyClosed.resolve(); - }); - const publication = worker.run(async (scope) => { - const result = await scope.execute(append("committed")); - entered.resolve(); - await release.promise; - return result; - }, allow); - let closing: Promise | undefined; - let closed = false; - try { - await withinTest( - awaitGateBeforeSettlement( - entered.promise, - publication, - "Worker publication was not retained", - ), - signal, - ); - expect(readLeases(heldPath)).toHaveLength(2); - expect(readLeases(healthy.path)).toHaveLength(1); - closing = closeOpenClawAgentDatabasesAsync(root).then(() => { - closed = true; - }); - await withinTest( - awaitGateBeforeSettlement( - healthyClosed.promise, - closing, - "Root close settled before the healthy database closed", - ), - signal, - ); - expect(healthy.db.isOpen).toBe(false); - expect(readLeases(healthy.path)).toEqual([]); - expect(readSqliteDatabaseCleanClose(healthy.path)).toBe(true); - expect(db.isOpen).toBe(true); - expect(readLeases(heldPath)).toHaveLength(2); - expect(readSqliteDatabaseCleanClose(heldPath)).toBe(false); - expect(closed).toBe(false); - expect(isAgentDatabaseClosing(healthy)).toBe(true); - const later = { agentId: "late", path: path.join(root, "late.sqlite") }; - expect(() => openDatabase(later)).toThrow("resources are closing"); - await expect(withOpenClawAgentDatabaseAsync(later, (database) => database)).rejects.toThrow( - "resources are closing", - ); - await expect( - withOpenClawAgentDatabaseAdmission( - later, - async (run) => run(() => {}), - (database) => database, - ), - ).rejects.toThrow("resources are closing"); - expect(fs.existsSync(later.path)).toBe(false); - expect(readLeases(later.path)).toEqual([]); - expect(openDatabase(options).db).toBe(db); - expect(() => - registerOpenClawAgentDatabaseAsyncResource({ - agentId: "new", - path: path.join(root, "new.sqlite"), - revoke() {}, - close: async () => {}, - }), - ).toThrow("resources are closing"); - release.resolve(); - await withinTest(publication, signal); - await withinTest(closing, signal); - expect(db.isOpen).toBe(false); - expect(readLeases(heldPath)).toEqual([]); - expect(readSqliteDatabaseCleanClose(heldPath)).toBe(true); - expect(isAgentDatabaseClosing(healthy)).toBe(false); - expect(openDatabase(later).db.isOpen).toBe(true); - } finally { - release.resolve(); - await Promise.allSettled([publication, closing]); - } - })); - describe("pooled agent publication owner (captured source)", () => { beforeEach(() => { sourceMode = "captured"; @@ -579,110 +371,36 @@ describe("pooled agent publication owner (captured source)", () => { expectValues(db, ["first", "following"]); }); - it("preserves command success despite binding cleanup failure", async () => { - const { db, worker } = await setup({ closeFailure: "fixture binding cleanup failed" }); - expect(await worker.execute(append("committed"), allow)).toBeGreaterThan(0); - expectValues(db, ["committed"]); + it("preserves command admission errors when cleanup admission fails", async () => { + const { db, worker } = await setup({ cleanupAdmission: true }); + const commandRefusal = new Error("controlled command admission refusal"); + let refusals = 0; + const interception = probe.admission(admission, (request, grant, admit) => { + if (request.stage === "transaction") { + throw commandRefusal; + } + if (isRecord(request.facts) && request.facts.kind === "fixture-cleanup") { + refusals++; + throw new Error("controlled publication cleanup admission refusal"); + } + admit(request, grant); + }); + await expect(worker.execute(append("failed"), allow)).rejects.toBe(commandRefusal); + expect(refusals).toBe(1); + expectValues(db, []); + interception.mockRestore(); + const following = await openWorker(db); expect(await following.execute(append("follower"), allow)).toBeGreaterThan(0); - expectValues(db, ["committed", "follower"]); + expectValues(db, ["follower"]); }); - it.each(["ordinary", "admission"] as const)( - "preserves %s errors when cleanup admission fails", - async (failure) => { - const { db, worker } = await setup({ cleanupAdmission: true }); - db.exec(` - CREATE TRIGGER fail_command BEFORE INSERT ON worker_proof - WHEN NEW.value = 'failed' - BEGIN - SELECT RAISE(ABORT, 'controlled command failure'); - END - `); - const commandRefusal = new Error("controlled command admission refusal"); - let commandRefusals = 0; - let refusals = 0; - const interception = probe.admission(admission, (request, grant, admit) => { - if (failure === "admission" && request.stage === "transaction") { - commandRefusals++; - throw commandRefusal; - } - if (isRecord(request.facts) && request.facts.kind === "fixture-cleanup") { - refusals++; - throw new Error("controlled publication cleanup admission refusal"); - } - admit(request, grant); - }); - const result = worker.execute(append("failed"), allow); - if (failure === "admission") { - await expect(result).rejects.toBe(commandRefusal); - } else { - await expect(result).rejects.toThrow("controlled command failure"); - } - expect(commandRefusals).toBe(failure === "admission" ? 1 : 0); - expect(refusals).toBe(1); - expectValues(db, []); - interception.mockRestore(); - - const following = await openWorker(db); - expect(await following.execute(append("follower"), allow)).toBeGreaterThan(0); - expectValues(db, ["follower"]); - }, - ); - it("does not grant a cleanup transaction after a single read", async () => { const { db, worker } = await setup({ closeWriteValue: "must not commit" }); await expect(worker.execute({ type: "inspect", input: undefined }, allow)).resolves.toBe(0); expectValues(db, []); }); - it("refuses factory entry without terminating siblings", async () => { - const siblings = []; - const resume = createDeferredCore(); - const held: Promise[] = []; - try { - for (let index = 0; index < 4; index++) { - options = { ...options, path: path.join(root, "agent-" + index + ".sqlite") }; - const sibling = await setup(); - siblings.push(sibling); - const entered = createDeferredCore(); - const work = sibling.worker.run(async (scope) => { - entered.resolve(); - await resume.promise; - await scope.execute(append("survived")); - }, allow); - held.push(work); - void work.catch(allow); - await Promise.race([entered.promise, work]); - } - let refused = 0; - const interception = probe.admission(admission, (request, grant, admit) => { - if (request.stage === "open") { - refused++; - throw new Error("controlled opening revocation"); - } - admit(request, grant); - }); - options = { ...options, path: path.join(root, "refused.sqlite") }; - const openMarker = path.join(root, "factory-entered"); - const { worker: refusedWorker } = await setup({ openMarker }); - await expect( - refusedWorker.run((scope) => scope.execute(append("refused")), allow), - ).rejects.toThrow("controlled opening revocation"); - interception.mockRestore(); - expect(refused).toBe(1); - expect(fs.existsSync(openMarker)).toBe(false); - resume.resolve(); - await Promise.all(held); - for (const { db } of siblings) { - expectValues(db, ["survived"]); - } - } finally { - resume.resolve(); - await Promise.allSettled(held); - } - }); - it("preserves commits and queued followers after cleanup refusal", async () => { const { db, worker } = await setup(); const committed = createDeferredCore(); @@ -780,49 +498,6 @@ describe("pooled agent publication owner (captured source)", () => { } throwSqliteLifecycleErrors(failures, "Publication fixture and cleanup failed"); }); - it("queues a real periodic maintenance tick behind publication", ({ signal }) => - fixture.run(async () => { - signal.throwIfAborted(); - let capturing = false; - const scheduled = observeSqliteWalPeriodicWork(() => capturing); - const configure = sqliteWal.configureSqliteConnectionPragmas; - vi.spyOn(sqliteWal, "configureSqliteConnectionPragmas").mockImplementation((db, policy) => { - capturing = policy?.databasePath === options.path; - try { - return configure(db, policy); - } finally { - capturing = false; - } - }); - const { db, worker } = await setup().finally(scheduled.restore); - const tick = scheduled.periodic; - db.exec(`INSERT INTO cache_entries(scope, key, blob, updated_at) - VALUES ('maintenance-proof', 'pages', zeroblob(4194304), 1); - DELETE FROM cache_entries WHERE scope = 'maintenance-proof';`); - const freePages = () => - Number(db.prepare("PRAGMA freelist_count").get()?.freelist_count ?? 0); - const before = freePages(); - expect(before).toBeGreaterThan(512); - const exec = vi.spyOn(db, "exec"); - const transactionMarker = path.join(root, "maintenance-transaction"); - const work = worker.run( - (scope) => scope.execute(append("published", { transactionMarker, delayMs: 250 })), - allow, - ); - await waitForFixtureEntry(receipts, transactionMarker, work, signal); - const started = performance.now(); - const maintenance = Promise.resolve(tick()); - expect(performance.now() - started).toBeLessThan(100); - expect(exec.mock.calls.some(([sql]) => sql.includes("incremental_vacuum"))).toBe(false); - expect(freePages()).toBe(before); - await work; - await maintenance; - await withOpenClawAgentDatabaseWrite(options, allow, db); - expect(exec.mock.calls.some(([sql]) => sql.includes("incremental_vacuum"))).toBe(false); - expect(before - freePages()).toBeGreaterThan(0); - expect(before - freePages()).toBeLessThanOrEqual(512); - expectValues(db, ["published"]); - })); }); type V2Worker = OpenClawAgentSqliteWorkerStoreV2; @@ -855,17 +530,9 @@ async function expectReadmission( expectValues(db, [...prior, "readmitted"]); } function observeTerminal(worker: V2Worker) { - const notify = vi.fn(); const terminal = createDeferredCore(); - worker.terminalSignal.addEventListener( - "abort", - () => { - notify(worker.terminalSignal.reason); - terminal.resolve(); - }, - { once: true }, - ); - return { notify, terminal: terminal.promise }; + worker.terminalSignal.addEventListener("abort", () => terminal.resolve(), { once: true }); + return terminal.promise; } it.each([false, true])( @@ -925,7 +592,7 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( }, ) : await openV2Worker(); - const observed = observeTerminal(worker); + const terminal = observeTerminal(worker); if (failure === "owner revocation") { await expect(worker.execute(append("refused"), allow)).rejects.toThrow("V2 owner revoked"); } else if (failure === "resource revocation") { @@ -944,7 +611,7 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( return result; }, allow); expect(await withinTest(accepted, signal)).toBeGreaterThan(0); - await withinTest(observed.terminal, signal); + await withinTest(terminal, signal); await revoked; expect(terminalBeforePublication).toBe(false); expect(committedAtNotification).toEqual([[{ value: "accepted" }]]); @@ -955,14 +622,14 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( { status: "rejected", reason: expect.any(Error) }, { status: "rejected", reason: expect.any(Error) }, ]); - await withinTest(observed.terminal, signal); + await withinTest(terminal, signal); expect(worker.terminalSignal.aborted).toBe(true); expect(worker.terminalSignal.reason).toMatchObject({ message: expect.stringMatching(/worker|exit|crash/i), }); } - await withinTest(observed.terminal, signal); - expect(observed.notify).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); + await withinTest(terminal, signal); + expect(worker.terminalSignal.reason).toBeInstanceOf(Error); const reason: unknown = worker.terminalSignal.reason; if (failure === "owner revocation") { expect(reason).toMatchObject({ message: "V2 owner revoked" }); @@ -974,7 +641,6 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( if (failure === "crash" || failure === "exit") { await worker.close(); expect(worker.terminalSignal.reason).toBe(reason); - expect(observed.notify).toHaveBeenCalledTimes(1); } }, ); diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 187ffde3a499..27ae42356e74 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -38,8 +38,6 @@ import { import type { ToolStreamHost } from "./tool-stream-contract.ts"; import { buildToolStreamIdentity } from "./tool-stream-identity.ts"; -const historyBudget = { limit: 80, maxBytes: 256 * 1024, toolResultMaxChars: 2_000 }; - beforeEach(() => vi.useFakeTimers()); afterEach(() => { vi.clearAllTimers(); @@ -85,6 +83,22 @@ function expectSettled(state: ChatState) { expect(state).toMatchObject({ chatRunId: null, chatStream: null, chatStreamStartedAt: null }); } +function expectTranscript(state: ChatState, ...rows: Array<[string, string]>) { + expect(state.chatMessages.map(messageShape)).toEqual( + rows.map(([role, text]) => ({ role, content: [{ type: "text", text }] })), + ); +} + +function messageShape(message: unknown) { + const { role, content } = requireRecord(message); + return { role, content }; +} + +const expectAssistant = (state: ChatState, ...texts: string[]) => + expectTranscript(state, ...texts.map((text): [string, string] => ["assistant", text])); + +const requireRecord = createRequireRecord("record", "expected-non-array-record"); + function createState(overrides: Partial = {}): ChatState { return { chatAttachments: [], @@ -116,12 +130,12 @@ function createState(overrides: Partial = {}): ChatState { it.each([true, false])( "keeps identical injected notes separate without adopting a run (persisted first=%s)", (persistedFirst) => { - const user = textMessage("user", "Previous question", { id: "user", seq: 1 }); - const reply = textMessage("assistant", "Previous reply", { id: "reply", seq: 2 }); + const user = userText("Previous question", { id: "user", seq: 1 }); + const reply = replyText("Previous reply", { id: "reply", seq: 2 }); const state = createState({ chatMessages: [user, reply] }); const ids = ["note-one", "note-two"]; const notes = ids.map((id, index) => - textMessage("assistant", "Synthetic weekly report", { id, seq: index + 3 }), + replyText("Synthetic weekly report", { id, seq: index + 3 }), ); for (const [index, saved] of notes.entries()) { if (persistedFirst) { @@ -130,7 +144,7 @@ it.each([true, false])( const event = { runId: `inject-${ids[index]}`, seq: 0, - message: textMessage("assistant", "Synthetic weekly report"), + message: replyText("Synthetic weekly report"), }; receive(state, "final", event); receive(state, "final", event); @@ -153,7 +167,7 @@ it.each([true, false])( it("settles a saved transformed interrupted partial once with both error emitters", () => { const runId = "interrupted-run"; const text = "The saved partial reply should appear once."; - const user = textMessage("user", "Ask", { id: "user", seq: 1, runId }); + const user = userText("Ask", { id: "user", seq: 1, runId }); const saved = { role: "assistant", content: [ @@ -170,7 +184,7 @@ it("settles a saved transformed interrupted partial once with both error emitter }, }; const state = createState({ chatMessages: [user], chatRunId: runId }); - receive(state, "delta", { runId, seq: 12, message: textMessage("assistant", text) }); + receive(state, "delta", { runId, seq: 12, message: replyText(text) }); const persist = () => applySessionMessagePayload(state, { runId, message: saved }, true, { kind: "history-delta" }); persist(); @@ -178,7 +192,7 @@ it("settles a saved transformed interrupted partial once with both error emitter runId, seq: 13, errorMessage: "codex app-server client closed before turn completed", - message: textMessage("assistant", text, { runId, idempotencyKey: "saved-key" }), + message: replyText(text, { runId, idempotencyKey: "saved-key" }), }); receive(state, "error", { runId, seq: 1, errorMessage: "Outer returned failure" }); expect(state.chatMessages).toEqual([user, saved]); @@ -195,9 +209,8 @@ it("keeps a later final distinct after an unpositioned steer", () => { const state = createState({ chatRunId: runId, chatMessages: [ - textMessage("user", "Ask", { id: "prompt", seq: 1, idempotencyKey: "run-1:user" }, 1), - textMessage( - "user", + userText("Ask", { id: "prompt", seq: 1, idempotencyKey: "run-1:user" }, 1), + userText( "Earlier steer", { id: "earlier-steer", @@ -207,7 +220,7 @@ it("keeps a later final distinct after an unpositioned steer", () => { }, 2, ), - textMessage("assistant", "Earlier answer", { id: "earlier-answer", seq: 3, runId }, 3), + replyText("Earlier answer", { id: "earlier-answer", seq: 3, runId }, 3), ], }); applySessionMessagePayload( @@ -215,8 +228,7 @@ it("keeps a later final distinct after an unpositioned steer", () => { { clientRunId: runId, messageId: "latest-steer", - message: textMessage( - "user", + message: userText( "Latest steer", { idempotencyKey: "latest:user", @@ -229,7 +241,7 @@ it("keeps a later final distinct after an unpositioned steer", () => { { kind: "live", activeRunId: runId }, ); receive(state, "final", { - message: textMessage("assistant", "New final", undefined, 5), + message: replyText("New final", undefined, 5), }); expect(state.chatMessages.map(extractText)).toEqual([ "Ask", @@ -291,7 +303,7 @@ function createHistoryStateForClient( function createAssistantHistory(text: string, overrides: Omit = {}) { return { - messages: [textMessage("assistant", text)], + messages: [replyText(text)], ...overrides, }; } @@ -387,14 +399,6 @@ function createLiveToolHistoryState( return liveState; } -const requireRecord = createRequireRecord("record", "expected-non-array-record"); - -function expectTextMessage(message: unknown, role: string, text: string): void { - const record = requireRecord(message); - expect(record.role).toBe(role); - expect(record.content).toEqual([{ type: "text", text }]); -} - function textMessage( role: "assistant" | "user", text: string, @@ -424,6 +428,43 @@ function projectChatMessageEvent( state.chatMessages = [...projection.messages]; } +function seedTerminal( + state: ChatState, + status: "completed" | "error", + extra: { message?: unknown; errorMessage?: string }, +) { + const scope = { sessionKey: state.sessionKey }; + publishChatSessionProjection( + state, + reduceSessionProjection(getChatSessionProjection(state, scope), { + type: "runTerminal", + runId: "run-1", + status, + scope, + ...extra, + }), + ); + return scope; +} + +const replyText = textMessage.bind(null, "assistant"); +const userText = textMessage.bind(null, "user"); +const toolCall = (toolCallId: string) => ({ + role: "assistant", + toolCallId, + runId: "run-1", + content: [{ type: "toolcall", name: "shell", arguments: {} }], +}); +const pendingSend = (sendRunId: string) => ({ + id: "pending", + text: "Ask", + createdAt: 1, + sendRunId, + sendState: "sending" as const, +}); +const steer = (text: string, key: string, timestamp: number) => + userText(text, { idempotencyKey: `${key}:user`, steerTargetRunId: "run-1" }, timestamp); + function createActiveStreamingState() { return createState({ chatRunId: "run-user", chatStream: "Working...", chatStreamStartedAt: 123 }); } @@ -436,7 +477,7 @@ describe("handleChatGatewayEvent", () => { const before = state.chatMessages.length; handleChatGatewayEvent(state, { state: "final", - message: textMessage("assistant", "leaked companion answer"), + message: replyText("leaked companion answer"), } as ChatEventPayload); expect(state.chatMessages.length).toBe(before); expect(JSON.stringify(state.chatMessages)).not.toContain("leaked companion answer"); @@ -444,15 +485,7 @@ describe("handleChatGatewayEvent", () => { it("adopts startup status only for the queued local run before its ACK", () => { const state = createState({ - chatQueue: [ - { - id: "queued-1", - text: "hello", - createdAt: 1, - sendRunId: "run-1", - sendState: "sending", - }, - ], + chatQueue: [pendingSend("run-1")], }); receive(state, "status", { runId: "run-other", phase: "preparing_workspace" }); expect(state.chatRunId).toBeNull(); @@ -507,7 +540,7 @@ describe("handleChatGatewayEvent", () => { const payload: ChatEventPayload = chatEvent("final", { sessionKey: "background", message: { - ...textMessage("assistant", "complete delivery result"), + ...replyText("complete delivery result"), openclawDisplayContent: [{ type: "text", text: "background final" }], }, }); @@ -517,39 +550,12 @@ describe("handleChatGatewayEvent", () => { handleChatGatewayEvent(state, payload); } expect(readChatMessagesFromCache(cache, states[0]!, { sessionKey: "background" })).toEqual([ - textMessage("assistant", "background final"), + replyText("background final"), ]); }); - it("appends configured default-session finals under runtime aliases", () => { - const activeSessionKey = "agent:ops:other"; - const payloadSessionKey = "agent:ops:home"; - const state = createState({ sessionKey: activeSessionKey, chatMessagesBySession: new Map() }); - const payload: ChatEventPayload = chatEvent("final", { - sessionKey: payloadSessionKey, - message: textMessage("assistant", "cached final"), - }); - - (state as Record).hello = { - snapshot: { - sessionDefaults: { - defaultAgentId: "ops", - mainKey: "home", - }, - }, - }; - seedChatSnapshot(state, { sessionKey: payloadSessionKey }); - handleChatGatewayEvent(state, payload); - expect( - readChatMessagesFromCache(state.chatMessagesBySession ?? new Map(), state, { - sessionKey: payloadSessionKey, - }), - ).toEqual([payload.message]); - expect(state.chatMessagesBySession?.size).toBe(1); - }); - it("appends inactive global finals under the payload agent only", () => { - const visibleMessage = textMessage("assistant", "work visible"); + const visibleMessage = replyText("work visible"); const state = createState({ sessionKey: "global", assistantAgentId: "work", @@ -561,7 +567,7 @@ describe("handleChatGatewayEvent", () => { runId: "run-main-global", sessionKey: "global", agentId: "main", - message: textMessage("assistant", "main final"), + message: replyText("main final"), }); seedChatSnapshot(state, { sessionKey: "global", agentId: "main" }); handleChatGatewayEvent(state, payload); @@ -582,7 +588,7 @@ describe("handleChatGatewayEvent", () => { const state = createState({ chatRunId: "run-1", chatStream: previous }); receive(state, "delta", { deltaText: "", - message: textMessage("assistant", snapshot), + message: replyText(snapshot), replace: true, }); expect(state.chatStream).toBe(snapshot); @@ -594,7 +600,7 @@ describe("handleChatGatewayEvent", () => { timestamp: 42, __openclaw: { id: "legacy-final", seq: 2 }, }; - const user = textMessage("user", "Ask", { id: "persisted-user", seq: 1 }); + const user = userText("Ask", { id: "persisted-user", seq: 1 }); const request = vi.fn().mockResolvedValue({ messages: [user] }); const state = createHistoryState(request, { chatMessages: [user], @@ -617,7 +623,7 @@ describe("handleChatGatewayEvent", () => { it("settles an active run when its terminal reply was already accepted", () => { const runId = "run-1"; - const final = textMessage("assistant", "Delivered answer"); + const final = replyText("Delivered answer"); const state = runningSession("Delivered answer", { sessionKey: "main", chatRunId: runId, @@ -626,15 +632,7 @@ describe("handleChatGatewayEvent", () => { }); state.chatStreamSegments = [{ text: "Retained commentary", ts: 122, toolCallId: "call-1" }]; state.knownAgentRunIds = new Set([runId]); - const scope = { sessionKey: state.sessionKey }; - const projection = reduceSessionProjection(getChatSessionProjection(state, scope), { - type: "runTerminal", - runId, - status: "completed", - message: final, - scope, - }); - publishChatSessionProjection(state, projection); + const scope = seedTerminal(state, "completed", { message: final }); publishChatSessionProjectionMessages(state, [final], { scope }); receive(state, "final", { runId, message: final }); expect(state.chatMessages).toEqual([final]); @@ -672,12 +670,7 @@ describe("handleChatGatewayEvent", () => { source: "segment", }, }, - textMessage( - "user", - "Focus on deployment", - { idempotencyKey: "steer-send-1:user", steerTargetRunId: "run-1" }, - 3, - ), + steer("Focus on deployment", "steer-send-1", 3), ], }); state.chatStreamSegments = [ @@ -691,11 +684,13 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { message: textMessage("assistant", "Final answer.", undefined, 5), }); - expect(state.chatMessages).toHaveLength(4); - expectTextMessage(state.chatMessages[0], "user", "Ask"); - expectTextMessage(state.chatMessages[1], "assistant", "Looking into it."); - expectTextMessage(state.chatMessages[2], "user", "Focus on deployment"); - expectTextMessage(state.chatMessages[3], "assistant", "Final answer."); + expectTranscript( + state, + ["user", "Ask"], + ["assistant", "Looking into it."], + ["user", "Focus on deployment"], + ["assistant", "Final answer."], + ); }); it("keeps the complete terminal reply after an accepted steer when no later delta arrived", () => { @@ -704,25 +699,19 @@ describe("handleChatGatewayEvent", () => { chatStream: "Before steer.", chatStreamStartedAt: 2, chatMessages: [ - textMessage("user", "Ask", { idempotencyKey: "run-1:user" }, 1), - textMessage( - "user", - "Steer", - { - idempotencyKey: "steer-1:user", - steerTargetRunId: "run-1", - }, - 3, - ), + userText("Ask", { idempotencyKey: "run-1:user" }, 1), + steer("Steer", "steer-1", 3), ], }); receive(state, "final", { - message: textMessage("assistant", "Before steer. Final unseen suffix.", undefined, 4), + message: replyText("Before steer. Final unseen suffix.", undefined, 4), }); - expect(state.chatMessages).toHaveLength(3); - expectTextMessage(state.chatMessages[0], "user", "Ask"); - expectTextMessage(state.chatMessages[1], "user", "Steer"); - expectTextMessage(state.chatMessages[2], "assistant", "Before steer. Final unseen suffix."); + expectTranscript( + state, + ["user", "Ask"], + ["user", "Steer"], + ["assistant", "Before steer. Final unseen suffix."], + ); expect(renderedMessageTexts(state, "terminal-after-steer")).toEqual([ "Ask", "Steer", @@ -731,20 +720,15 @@ describe("handleChatGatewayEvent", () => { }); it("clears keyed commentary when chatPersistCommentary is false", () => { - const user = textMessage("user", "Ask", undefined, 1); + const user = userText("Ask", undefined, 1); const state = createState({ chatRunId: "run-1", chatMessages: [user], settings: { chatPersistCommentary: false }, }); state.chatStreamSegments = [{ text: "Looking into it.", ts: 2, itemId: "preamble-1" }]; - const payload: ChatEventPayload = chatEvent("final", { - message: textMessage("assistant", "Final answer.", undefined, 5), - }); - handleChatGatewayEvent(state, payload); - expect(state.chatMessages).toHaveLength(2); - expectTextMessage(state.chatMessages[0], "user", "Ask"); - expectTextMessage(state.chatMessages[1], "assistant", "Final answer."); + receive(state, "final", { message: replyText("Final answer.", undefined, 5) }); + expectTranscript(state, ["user", "Ask"], ["assistant", "Final answer."]); expect(state.chatStreamSegments).toEqual([]); }); @@ -820,12 +804,11 @@ describe("handleChatGatewayEvent", () => { occurredAt: 100, }; state.fallbackClearTimer = setTimeout(() => undefined, 1_000); - const payload: ChatEventPayload = chatEvent("final", { + receive(state, "final", { stopReason: "completed", yielded: true, - message: textMessage("assistant", "Live reply"), + message: replyText("Live reply"), }); - handleChatGatewayEvent(state, payload); expectSettled(state); expect(state.compactionStatus).toBeNull(); @@ -848,10 +831,7 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { stopReason: "end_turn", yielded: true, - message: textMessage( - "assistant", - "The gateway will restart; I will resume verification afterward.", - ), + message: replyText("The gateway will restart; I will resume verification afterward."), }); expect(state.chatRunId).toBeNull(); expect(state.chatStream).toBeNull(); @@ -863,37 +843,22 @@ describe("handleChatGatewayEvent", () => { }); }); - it("appends final payload from another run without clearing active stream", () => { + it.each([ + { name: "another run", runId: "run-announce", message: replyText("Sub-agent findings") }, + { name: "unowned", runId: undefined, message: undefined }, + ])("keeps the active stream for $name finals", ({ runId, message }) => { const state = createActiveStreamingState(); - const payload: ChatEventPayload = chatEvent("final", { - runId: "run-announce", - message: textMessage("assistant", "Sub-agent findings"), + receive(state, "final", { runId, message }); + expect(state).toMatchObject({ + chatRunId: "run-user", + chatStream: "Working...", + chatStreamStartedAt: 123, }); - handleChatGatewayEvent(state, payload); - expect(state.chatRunId).toBe("run-user"); - expect(state.chatStream).toBe("Working..."); - expect(state.chatStreamStartedAt).toBe(123); - expect(state.chatMessages).toHaveLength(1); - expect(state.chatMessages[0]).toEqual(payload.message); - }); - - it("keeps active stream for unowned final payloads", () => { - const state = createActiveStreamingState(); - const payload: ChatEventPayload = chatEvent("final", { runId: undefined }); - handleChatGatewayEvent(state, payload); - expect(state.chatRunId).toBe("run-user"); - expect(state.chatStream).toBe("Working..."); - expect(state.chatStreamStartedAt).toBe(123); - expect(state.chatMessages).toStrictEqual([]); + expect(state.chatMessages).toEqual(message ? [message] : []); }); it("preserves a split stream when the terminal message only overlaps its prefix", () => { - const terminal = textMessage( - "assistant", - "First thought. Configure provider auth.", - undefined, - 101, - ); + const terminal = replyText("First thought. Configure provider auth.", undefined, 101); const state = createState({ chatRunId: "run-1", chatStream: "After tool.", @@ -903,12 +868,10 @@ describe("handleChatGatewayEvent", () => { receive(state, "error", { errorMessage: "gateway disconnected", message: terminal }); expect(state.chatRunId).toBeNull(); expect(state.chatStream).toBeNull(); - expect(state.chatMessages).toHaveLength(3); - expectTextMessage(state.chatMessages[0], "assistant", "First thought."); - expectTextMessage(state.chatMessages[1], "assistant", "After tool."); - expectTextMessage( - state.chatMessages[2], - "assistant", + expectAssistant( + state, + "First thought.", + "After tool.", "First thought. Configure provider auth.", ); expect(state.chatMessages[2]).toEqual(terminal); @@ -925,9 +888,7 @@ describe("handleChatGatewayEvent", () => { ["openai", 429, "subscription_sharing_usage_limit_exceeded", "chatgpt_consumer_usage_limit"], ["openai", 429, "rate_limit_exceeded", undefined], ["openai", 403, "subscription_sharing_workplace_individual_usage_limit_exceeded", undefined], - ["openai", 403, "subscription_sharing_usage_limit_exceeded", undefined], ["other", 429, "subscription_sharing_workplace_individual_usage_limit_exceeded", undefined], - ["other", 429, "subscription_sharing_usage_limit_exceeded", undefined], ])( "classifies ChatGPT sharing limits only from provider facts (%s, %s, %s)", (provider, httpStatus, providerErrorCode, kind) => { @@ -955,17 +916,7 @@ describe("handleChatGatewayEvent", () => { chatRunId: "run-1", chatRunError: { runId: "run-1", summary }, }); - const scope = { sessionKey: state.sessionKey }; - publishChatSessionProjection( - state, - reduceSessionProjection(getChatSessionProjection(state, scope), { - type: "runTerminal", - runId: "run-1", - status: "error", - errorMessage: summary, - scope, - }), - ); + seedTerminal(state, "error", { errorMessage: summary }); receive(state, "error", { errorMessage: summary, errorDetail: { @@ -1004,17 +955,16 @@ describe("handleChatGatewayEvent", () => { const state = createState({ chatRunId: "run-completed" }); receive(state, "final", { runId: "run-completed", - message: textMessage("assistant", "Delivered once."), + message: replyText("Delivered once."), }); receive(state, "delta", { runId: "run-newer", - message: textMessage("assistant", "Newer response"), + message: replyText("Newer response"), }); receive(state, "error", { runId: "run-completed", errorMessage: "late provider failure" }); expect(state.chatRunId).toBe("run-newer"); expect(state.chatStream).toBe("Newer response"); - expect(state.chatMessages).toHaveLength(1); - expectTextMessage(state.chatMessages[0], "assistant", "Delivered once."); + expectAssistant(state, "Delivered once."); expect(state.chatRunError).toBeNull(); }); @@ -1023,12 +973,11 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { runId: "run-empty-final" }); const deliveredFinal = chatEvent("final", { runId: "run-empty-final", - message: textMessage("assistant", "Delayed authoritative reply."), + message: replyText("Delayed authoritative reply."), }); handleChatGatewayEvent(state, deliveredFinal); handleChatGatewayEvent(state, deliveredFinal); - expect(state.chatMessages).toHaveLength(1); - expectTextMessage(state.chatMessages[0], "assistant", "Delayed authoritative reply."); + expectAssistant(state, "Delayed authoritative reply."); expect(state.chatRunId).toBeNull(); }); @@ -1081,7 +1030,7 @@ describe("handleChatGatewayEvent", () => { receive(state, "delta", { ...envelope, seq: seq++, - message: textMessage("assistant", text), + message: replyText(text), }); expect(state.chatStream).toBe(text); expect(state.chatRunId).toBe(envelope.runId); @@ -1091,10 +1040,9 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { ...envelope, seq, - message: textMessage("assistant", "I agree with that product direction."), + message: replyText("I agree with that product direction."), }); - expect(state.chatMessages).toHaveLength(1); - expectTextMessage(state.chatMessages[0], "assistant", "I agree with that product direction."); + expectAssistant(state, "I agree with that product direction."); expect(state.chatStreamSegments ?? []).toEqual([]); expect(state.chatRunId).toBeNull(); }); @@ -1106,7 +1054,7 @@ describe("authoritative terminal history identity", () => { async (historyFirst) => { const text = "Scan this QR code with the OpenClaw iOS app:\n\n- Gateway: wss://pair.example.invalid\n- Auth: token\n- Security: single-use bootstrap token"; - const saved = textMessage("assistant", text, { id: "pair-reply", seq: 2, runId: "run-1" }); + const saved = replyText(text, { id: "pair-reply", seq: 2, runId: "run-1" }); const qr = { type: "openclaw_pairing_qr", image_url: "data:image/png;base64,c3ludGhldGljLXFy", @@ -1149,8 +1097,8 @@ describe("authoritative terminal history identity", () => { it("does not retire a native terminal for user or imported identities with a colliding id", () => { const collisions = [ - textMessage("user", "Different native user", { id: "native-terminal" }), - textMessage("assistant", "Different imported assistant", { + userText("Different native user", { id: "native-terminal" }), + replyText("Different imported assistant", { id: "native-terminal", importedFrom: "claude-cli", cliSessionId: "external-session", @@ -1158,13 +1106,10 @@ describe("authoritative terminal history identity", () => { }), ]; const host = {}; - const nativeTerminal = textMessage("assistant", "Native terminal", { + const nativeTerminal = replyText("Native terminal", { id: "native-terminal", }); - const liveTerminal = rememberLiveTerminalRun( - textMessage("assistant", "Native terminal"), - "run-1", - ); + const liveTerminal = rememberLiveTerminalRun(replyText("Native terminal"), "run-1"); rememberAuthoritativeTerminal({ event: { key: "main", runId: "run-1", hasActiveRun: false }, host, @@ -1200,17 +1145,11 @@ describe("loadChatHistory filtering", () => { const repair = "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair."; const visible = [ - textMessage("user", "NO_REPLY"), - textMessage("user", repair), - textMessage("assistant", "no_reply"), - textMessage("assistant", "NO_REPLY"), + userText("NO_REPLY"), + userText(repair), + replyText("NO_REPLY"), { role: "assistant", text: " NO_REPLY " }, - textMessage("assistant", "NO_REPLY."), - textMessage("assistant", '{"action":"NO_REPLY"}'), - textMessage("assistant", "I will not reply: NO_REPLY"), - textMessage("assistant", "ANNOUNCE_SKIP"), - textMessage("assistant", "REPLY_SKIP"), - textMessage("assistant", "Real answer"), + replyText("Real answer"), { role: "assistant", text: "real reply", content: "NO_REPLY" }, { role: "user", content: "", __openclaw: { media: [{ path: "/tmp/user.png" }] } }, { @@ -1221,9 +1160,8 @@ describe("loadChatHistory filtering", () => { }, ]; const hidden = [ - textMessage("assistant", "HEARTBEAT_OK"), - textMessage( - "user", + replyText("HEARTBEAT_OK"), + userText( "<<>>\nsubagent completion payload\n<<>>", ), { role: "user", content: "" }, @@ -1289,7 +1227,7 @@ describe("loadChatHistory filtering", () => { }); it("caches global history under the selected agent only", async () => { - const messages = [textMessage("assistant", "work history")]; + const messages = [replyText("work history")]; const { state } = createResolvedHistoryState( { messages }, { @@ -1346,11 +1284,11 @@ describe("loadChatHistory retry handling", () => { expect(request.mock.calls[1]?.[2]?.signal.aborted).toBe(true); await loadChatHistory(secondState); expect(request).toHaveBeenCalledTimes(3); - expect(secondState.chatMessages).toEqual([textMessage("assistant", "awake")]); + expect(secondState.chatMessages).toEqual([replyText("awake")]); expect(firstState.chatMessages).toEqual([]); secondAttempt.resolve(createAssistantHistory("expired")); await vi.advanceTimersByTimeAsync(0); - expect(secondState.chatMessages).toEqual([textMessage("assistant", "awake")]); + expect(secondState.chatMessages).toEqual([replyText("awake")]); }); const historyTool = (id: string, text: string, timestamp: number, seq: number) => ({ @@ -1369,11 +1307,9 @@ describe("loadChatHistory retry handling", () => { historyTool("call_1", "first output", 2, 2), partial ? { - role: "assistant", - toolCallId: "call_2", - runId: "run-1", + ...toolCall("call_2"), content: [ - { type: "toolcall", name: "shell", arguments: {} }, + ...toolCall("call_2").content, { type: "toolresult", name: "shell", text: "second output" }, ], timestamp: 4, @@ -1401,7 +1337,7 @@ describe("loadChatHistory retry handling", () => { const remainingSegments = partial ? [{ text: "before second tool", ts: 3, toolCallId: "call_2" }] : []; - const persistedUser = textMessage("user", "latest ask", { seq: 1 }); + const persistedUser = userText("latest ask", { seq: 1 }); const state = createLiveToolHistoryState( [persistedUser, ...tools.slice(0, persistedCount)], stream, @@ -1411,21 +1347,26 @@ describe("loadChatHistory retry handling", () => { await loadChatHistory(state); - expect(state.chatMessages).toHaveLength(expectedRows.length + 1); - expect(state.chatMessages[0]).toEqual(persistedUser); - for (const [index, row] of expectedRows.entries()) { - if (typeof row === "number") { - expect(state.chatMessages[index + 1]).toEqual(tools[row]); - } else { - expectTextMessage(state.chatMessages[index + 1], "assistant", row.text); - expect(requireRecord(state.chatMessages[index + 1]).timestamp).toBe(row.timestamp); - } - } - expect(state.chatRunId).toBe("run-1"); - expect(state.chatStream).toBe(stream); - expect(visibleCurrentAssistantStreamTail(state, () => false)).toBe("Still answering."); - expect(state.chatStreamStartedAt).toBe(100); + expect(state.chatMessages).toEqual([ + persistedUser, + ...expectedRows.map((row) => + typeof row === "number" + ? tools[row] + : expect.objectContaining(replyText(row.text, undefined, row.timestamp)), + ), + ]); + expect(state).toMatchObject({ + chatRunId: "run-1", + chatStream: stream, + chatStreamStartedAt: 100, + }); expect(state.chatToolMessages).toEqual(remainingTools.map((index) => tools[index])); + expect(state.toolStreamOrder).toEqual( + remainingTools.map((index) => + buildToolStreamIdentity("run-1", String(tools[index]?.toolCallId)), + ), + ); + expect(visibleCurrentAssistantStreamTail(state, () => false)).toBe("Still answering."); expect( visibleParts(state).map(({ text, timestamp, toolCallId }) => ({ text, @@ -1433,63 +1374,39 @@ describe("loadChatHistory retry handling", () => { toolCallId, })), ).toEqual(remainingSegments); - expect(state.toolStreamById.size).toBe(remainingTools.length); - expect(state.toolStreamOrder).toEqual( - remainingTools.map((index) => - buildToolStreamIdentity("run-1", String(tools[index]?.toolCallId)), - ), - ); - for (const index of remainingTools) { - expect( - state.toolStreamById.has( - buildToolStreamIdentity("run-1", String(tools[index]?.toolCallId)), - ), - ).toBe(true); - } + expect(new Set(state.toolStreamById.keys())).toEqual(new Set(state.toolStreamOrder)); }, ); it("clears live tool cards when history catches up with content-block tool ids", async () => { - const persistedUser = textMessage("user", "latest ask", { seq: 1 }); + const persistedUser = userText("latest ask", { seq: 1 }); const persistedToolCall = { role: "assistant", - content: [ - { - type: "toolCall", - id: "call_1", - name: "shell", - arguments: {}, - }, - ], + content: [{ type: "toolCall", id: "call_1", name: "shell", arguments: {} }], timestamp: 2, __openclaw: { seq: 2 }, }; const state = createLiveToolHistoryState( [persistedUser, persistedToolCall], "Still answering.", - [ - { - role: "assistant", - toolCallId: "call_1", - runId: "run-1", - content: [{ type: "toolcall", name: "shell", arguments: {} }], - }, - ], + [toolCall("call_1")], [{ text: "before tool", ts: 1 }], ); await loadChatHistory(state); - expect(state.chatMessages).toHaveLength(3); - expect(state.chatMessages[0]).toEqual(persistedUser); - expectTextMessage(state.chatMessages[1], "assistant", "before tool"); - expect(requireRecord(state.chatMessages[1]).timestamp).toBe(1); - expect(state.chatMessages[2]).toEqual(persistedToolCall); - expect(state.chatRunId).toBe("run-1"); - expect(state.chatStream).toBe("Still answering."); - expect(state.chatStreamStartedAt).toBe(100); - expect(state.chatToolMessages).toEqual([]); + expect(state.chatMessages).toEqual([ + persistedUser, + expect.objectContaining({ ...replyText("before tool"), timestamp: 1 }), + persistedToolCall, + ]); + expect(state).toMatchObject({ + chatRunId: "run-1", + chatStream: "Still answering.", + chatStreamStartedAt: 100, + chatToolMessages: [], + toolStreamOrder: [], + }); expect(visibleParts(state)).toEqual([]); expect(state.toolStreamById.size).toBe(0); - expect(state.toolStreamOrder).toEqual([]); }); it("places materialized streamed text after a persisted user prompt with a later clock", async () => { @@ -1497,7 +1414,7 @@ describe("loadChatHistory retry handling", () => { // The user turn owns placement; retiming the saved stream would reorder live tools. const userTimestamp = 200; const streamTimestamp = 100; - const persistedUser = textMessage("user", "first", { seq: 1 }, userTimestamp); + const persistedUser = userText("first", { seq: 1 }, userTimestamp); const { state } = createHistorySnapshot([persistedUser], { chatMessages: [persistedUser], chatRunId: null, @@ -1505,13 +1422,12 @@ describe("loadChatHistory retry handling", () => { chatStreamStartedAt: streamTimestamp, }); await loadChatHistory(state); - expect(state.chatMessages).toHaveLength(2); - expect(state.chatMessages[0]).toEqual(persistedUser); - expectTextMessage( - state.chatMessages[1], - "assistant", - "Partial answer before history catch-up.", + expectTranscript( + state, + ["user", "first"], + ["assistant", "Partial answer before history catch-up."], ); + expect(state.chatMessages[0]).toEqual(persistedUser); expect(renderedMessageTexts(state, "materialized-stream-order")).toEqual([ "first", "Partial answer before history catch-up.", @@ -1522,18 +1438,9 @@ describe("loadChatHistory retry handling", () => { }); it("keeps live tool cards when history only replaces streamed text", async () => { - const persistedUser = textMessage("user", "latest ask", { seq: 1 }); - const historyAssistant = textMessage( - "assistant", - "First visible stream text. More final text.", - { seq: 2 }, - ); - const liveToolMessage = { - role: "assistant", - toolCallId: "call_current", - runId: "run-1", - content: [{ type: "toolcall", name: "shell", arguments: {} }], - }; + const persistedUser = userText("latest ask", { seq: 1 }); + const historyAssistant = replyText("First visible stream text. More final text.", { seq: 2 }); + const liveToolMessage = toolCall("call_current"); const state = createLiveToolHistoryState( [persistedUser, historyAssistant], "First visible stream text.", @@ -1557,7 +1464,7 @@ describe("loadChatHistory retry handling", () => { }); const request = vi.fn().mockRejectedValue(new GatewayRequestError(scopeError)); const state = createHistoryState(request, { - chatMessages: [textMessage("assistant", "old")], + chatMessages: [replyText("old")], chatVerboseLevel: "full", }); await loadChatHistory(state); @@ -1587,7 +1494,7 @@ describe("loadChatHistory retry handling", () => { }); const state = createHistoryState(request); const firstLoad = loadChatHistory(state); - const pending = textMessage("user", "new local ask", { + const pending = userText("new local ask", { idempotencyKey: "same-session-pending-run:user", }); projectChatMessageEvent(state, { @@ -1597,28 +1504,19 @@ describe("loadChatHistory retry handling", () => { }); const secondLoad = loadChatHistory(state); const thirdLoad = loadChatHistory(state); - expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([ - ["chat.history", { sessionKey: "main", ...historyBudget, knownState: {} }], - ]); + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0]?.[1]).not.toHaveProperty("inputRunIds"); expect(state.chatMessages).toEqual([pending]); staleHistory.resolve(createAssistantHistory("stale history")); await currentHistoryStarted.promise; - expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([ - ["chat.history", { sessionKey: "main", ...historyBudget, knownState: {} }], - [ - "chat.history", - { - sessionKey: "main", - ...historyBudget, - knownState: {}, - inputRunIds: ["same-session-pending-run"], - }, - ], - ]); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1]?.[1]).toMatchObject({ + inputRunIds: ["same-session-pending-run"], + }); expect(state.chatMessages).toEqual([pending]); expect(state.chatLoading).toBe(true); - const persisted = textMessage("assistant", "persisted history", { + const persisted = replyText("persisted history", { id: "same-session-history-assistant", seq: 1, }); @@ -1629,57 +1527,49 @@ describe("loadChatHistory retry handling", () => { expect(state.chatLoading).toBe(false); }); - it("rejects stale success and cleanup after a same-client reconnect", async () => { - const staleRequest = createDeferred(); - const freshRequest = createDeferred(); - const request = vi - .fn() - .mockImplementationOnce(() => staleRequest.promise) - .mockImplementationOnce(() => freshRequest.promise); - const client = createTestClient(request); - const visibleMessage = textMessage("assistant", "visible before reconnect"); - const state = createHistoryStateForClient(client, { - chatMessages: [visibleMessage], - connectionEpoch: 1, - }); - const staleLoad = loadChatHistory(state); - state.connected = false; - state.connectionEpoch = 2; - state.connected = true; - state.connectionEpoch = 3; - const freshLoad = loadChatHistory(state); - expect(request).toHaveBeenCalledTimes(2); - staleRequest.resolve(createAssistantHistory("stale history", { thinkingLevel: "high" })); - await staleLoad; - expect(state.chatMessages).toEqual([visibleMessage]); - expect(state.chatLoading).toBe(true); - - freshRequest.resolve(createAssistantHistory("fresh history", { thinkingLevel: "low" })); - await freshLoad; - expect(state.chatMessages).toEqual([textMessage("assistant", "fresh history")]); - expect(state.chatLoading).toBe(false); - }); - - it("rejects stale errors and cleanup after a same-client reconnect", async () => { - const staleRequest = createDeferred(); - const request = vi.fn(() => staleRequest.promise); - const client = createTestClient(request); - const state = createHistoryStateForClient(client, { - connectionEpoch: 1, - }); - const staleLoad = loadChatHistory(state); - state.connected = false; - state.connectionEpoch = 2; - state.connected = true; - state.connectionEpoch = 3; - state.chatLoading = true; - staleRequest.reject(new Error("stale history failure")); - await staleLoad; - expect(state.lastError).toBeNull(); - expect(state.chatError).toBeNull(); - expect(getChatHistoryLoadState(state)).toMatchObject({ phase: "pending-connection" }); - expect(state.chatLoading).toBe(true); - }); + it.each(["success", "error"] as const)( + "rejects stale %s and cleanup after reconnect", + async (outcome) => { + const staleRequest = createDeferred(); + const freshRequest = createDeferred(); + const request = vi + .fn() + .mockImplementationOnce(() => staleRequest.promise) + .mockImplementationOnce(() => freshRequest.promise); + const client = createTestClient(request); + const visibleMessage = replyText("visible before reconnect"); + const state = createHistoryStateForClient(client, { + chatMessages: [visibleMessage], + connectionEpoch: 1, + }); + const staleLoad = loadChatHistory(state); + state.connected = false; + state.connectionEpoch = 2; + state.connected = true; + state.connectionEpoch = 3; + const freshLoad = outcome === "success" ? loadChatHistory(state) : undefined; + if (outcome === "success") { + expect(request).toHaveBeenCalledTimes(2); + staleRequest.resolve(createAssistantHistory("stale history", { thinkingLevel: "high" })); + } else { + state.chatLoading = true; + staleRequest.reject(new Error("stale history failure")); + } + await staleLoad; + expect(state.chatMessages).toEqual([visibleMessage]); + expect(state.chatLoading).toBe(true); + if (outcome === "success") { + freshRequest.resolve(createAssistantHistory("fresh history", { thinkingLevel: "low" })); + await freshLoad; + expect(state.chatMessages).toEqual([replyText("fresh history")]); + expect(state.chatLoading).toBe(false); + } else { + expect(state.lastError).toBeNull(); + expect(state.chatError).toBeNull(); + expect(getChatHistoryLoadState(state)).toMatchObject({ phase: "pending-connection" }); + } + }, + ); it("ignores stale global history responses after switching selected agents", async () => { const workRequest = createDeferred(); @@ -1693,14 +1583,14 @@ describe("loadChatHistory retry handling", () => { sessionKey: "global", assistantAgentId: "work", agentsList: { defaultId: "main" }, - chatMessages: [textMessage("assistant", "visible old")], + chatMessages: [replyText("visible old")], }); const load = loadChatHistory(state); state.assistantAgentId = "main"; workRequest.resolve(createAssistantHistory("work history", { thinkingLevel: "high" })); await load; expect(state.chatLoading).toBe(false); - expect(state.chatMessages).toEqual([textMessage("assistant", "visible old")]); + expect(state.chatMessages).toEqual([replyText("visible old")]); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ @@ -1715,11 +1605,10 @@ describe("aborted chat diagnostics", () => { const expectedError = "Error: This reply stopped because the provider was signed out. Sign in again or choose another model."; const state = createAbortDiagnosticState(); - const payload: ChatEventPayload = chatEvent("aborted", { + receive(state, "aborted", { runId: "run-validation-abort", stopReason, }); - handleChatGatewayEvent(state, payload); expect(state.chatRunError?.summary ?? null).toBe(expectedError); expect(state.chatRunStatus).toMatchObject({ phase: "interrupted", @@ -1735,43 +1624,33 @@ describe("aborted chat diagnostics", () => { expect(state.chatRunId).toBeNull(); }); - it("does not restore an old diagnostic while a newer send awaits its ACK", () => { - const state = createAbortDiagnosticState("run-old"); - receive(state, "aborted", { runId: "run-old" }); - state.chatQueue = [ - { - id: "pending-new-send", - text: "New request", - createdAt: 200, - sendRunId: "run-new", - sendState: "sending", - }, - ]; - receive(state, "aborted", { - runId: "run-old", - errorMessage: "edit tool validation failed: invalid arguments", - }); - expect(state.chatRunError).toBeNull(); - expect(state.chatRunId).toBeNull(); - expect(state.lastLocalTerminalReconcile?.runId).toBe("run-old"); - }); - - it("does not publish an old aborted diagnostic after a newer run completes", () => { - const state = createAbortDiagnosticState("run-old"); - receive(state, "aborted", { runId: "run-old" }); - state.chatRunId = "run-new"; - state.chatStream = "New final answer"; - receive(state, "final", { - runId: "run-new", - message: textMessage("assistant", "New final answer"), - }); - receive(state, "aborted", { - runId: "run-old", - errorMessage: "edit tool validation failed: invalid arguments", - }); - expect(state.chatRunError).toBeNull(); - expect(state.lastLocalTerminalReconcile?.runId).toBe("run-new"); - expect(state.chatRunId).toBeNull(); - expect(state.chatMessages.at(-1)).toMatchObject(textMessage("assistant", "New final answer")); - }); + it.each(["pending", "completed"] as const)( + "does not restore an old diagnostic after a newer %s send", + (phase) => { + const state = createAbortDiagnosticState("run-old"); + receive(state, "aborted", { runId: "run-old" }); + if (phase === "pending") { + state.chatQueue = [pendingSend("run-new")]; + } else { + state.chatRunId = "run-new"; + state.chatStream = "New final answer"; + receive(state, "final", { + runId: "run-new", + message: replyText("New final answer"), + }); + } + receive(state, "aborted", { + runId: "run-old", + errorMessage: "edit tool validation failed: invalid arguments", + }); + expect(state.chatRunError).toBeNull(); + expect(state.lastLocalTerminalReconcile?.runId).toBe( + phase === "pending" ? "run-old" : "run-new", + ); + expect(state.chatRunId).toBeNull(); + if (phase === "completed") { + expect(state.chatMessages.at(-1)).toMatchObject(replyText("New final answer")); + } + }, + ); }); diff --git a/ui/src/pages/chat/components/chat-audio-player.test.ts b/ui/src/pages/chat/components/chat-audio-player.test.ts index f6458adaa540..b2c2e73eecb5 100644 --- a/ui/src/pages/chat/components/chat-audio-player.test.ts +++ b/ui/src/pages/chat/components/chat-audio-player.test.ts @@ -9,6 +9,49 @@ import { CHAT_AUDIO_WAVEFORM_MAX_BYTES } from "./chat-audio-waveform.ts"; type ChatAudioPlayer = HTMLElementTagNameMap["openclaw-chat-audio-player"]; const audio = (player: ChatAudioPlayer) => player.querySelector("audio")!; +const downloadLink = (player: ChatAudioPlayer) => + player.querySelector("a[download]")!; +const headerDownload = (player: ChatAudioPlayer) => + player.querySelector(".chat-assistant-attachment-card__download"); +const waveformElement = (player: ChatAudioPlayer) => + player.querySelector(".chat-audio-player__waveform"); +const waveformRects = (player: ChatAudioPlayer) => + player.querySelectorAll(".chat-audio-player__waveform rect"); +const seekInput = (player: ChatAudioPlayer) => + player.querySelector(".chat-audio-player__seek")!; +const source = (player: ChatAudioPlayer) => player.querySelector("audio")?.getAttribute("src"); +function pendingFetch() { + let resolve!: (response: Response) => void; + const fetchMock = vi.fn( + async () => + await new Promise((respond) => { + resolve = respond; + }), + ); + vi.stubGlobal("fetch", fetchMock); + return { fetchMock, resolve: (response: Response) => resolve(response) }; +} +async function update(player: ChatAudioPlayer, properties: Partial) { + Object.assign(player, properties); + await player.updateComplete; +} +async function fail(player: ChatAudioPlayer) { + audio(player).dispatchEvent(new Event("error")); + await player.updateComplete; +} +function transcodePlayer() { + return createPlayer("voice", { + src: "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=ticket", + sourceIdentity: "/tmp/voice.caf", + label: "voice.caf", + playback: "transcode", + }); +} +function expectUnavailable(player: ChatAudioPlayer) { + expect(card(player, "--compact")).not.toBeNull(); + expect(card(player, "__reason")).toBeNull(); + expect(player.querySelector(".chat-audio-player")).toBeNull(); +} const control = (player: ChatAudioPlayer, suffix: string) => player.querySelector(`.chat-audio-player__${suffix}`)!; const card = (player: ChatAudioPlayer, suffix = "") => @@ -22,21 +65,26 @@ const audioResponse = () => status: 200, headers: { "Content-Type": "audio/mpeg", "Content-Length": "4" }, }); -function mockPlayback(media: HTMLMediaElement) { - let paused = true; +function mockPlayback(media: HTMLMediaElement, active = false, events = true, pauseEvent = events) { + let paused = !active; Object.defineProperty(media, "paused", { configurable: true, get: () => paused }); return { play: vi.spyOn(media, "play").mockImplementation(async () => { - paused = false; - media.dispatchEvent(new Event("play")); + if (!active) { + paused = false; + } + if (events) { + media.dispatchEvent(new Event("play")); + } }), pause: vi.spyOn(media, "pause").mockImplementation(() => { paused = true; - media.dispatchEvent(new Event("pause")); + if (pauseEvent) { + media.dispatchEvent(new Event("pause")); + } }), }; } - function setMediaNumber( media: HTMLMediaElement, property: "currentTime" | "duration", @@ -44,20 +92,15 @@ function setMediaNumber( ) { Object.defineProperty(media, property, { configurable: true, writable: true, value }); } - function mockActivePlayback(media: HTMLMediaElement) { - let paused = false; - Object.defineProperty(media, "paused", { configurable: true, get: () => paused }); setMediaNumber(media, "currentTime", 20); setMediaNumber(media, "duration", 80); - return { - play: vi.spyOn(media, "play").mockResolvedValue(undefined), - pause: vi.spyOn(media, "pause").mockImplementation(() => { - paused = true; - }), - }; + return mockPlayback(media, true, false); +} +function mockPlay(media: HTMLMediaElement) { + Object.defineProperty(media, "paused", { configurable: true, value: true }); + return vi.spyOn(media, "play").mockResolvedValue(undefined); } - async function createPlayer( label: string, overrides: Partial = {}, @@ -124,39 +167,20 @@ afterEach(() => { }); describe("ChatAudioPlayer", () => { - it("keeps the download action for normalized base64 audio", async () => { - const player = await createPlayer("inline", { - src: "data:audio/wav;base64,UklGRg==", - sourceIdentity: "inline-audio", - label: "inline.wav", - }); - - expect( - player - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("href"), - ).toBe("data:audio/wav;base64,UklGRg=="); - }); - it("keeps voice notes player-only and never opens the attachment sidebar", async () => { const player = await createPlayer("voice-note"); - player.voiceNote = true; - player.onExpand = vi.fn(); - player.sizeBytes = 2048; - await player.updateComplete; + await update(player, { voiceNote: true, onExpand: vi.fn(), sizeBytes: 2048 }); expect(player.querySelector(".chat-assistant-attachment-card__header")).toBeNull(); expect(player.querySelector(".chat-attachment-file-icon, [data-openable]")).toBeNull(); expect(player.textContent).not.toContain("voice-note.mp3"); expect(player.querySelector('[role="group"]')?.getAttribute("aria-label")).toBe("Voice note"); player.querySelector(".chat-assistant-attachment-card")!.click(); expect(player.onExpand).not.toHaveBeenCalled(); - - audio(player).dispatchEvent(new Event("error")); - await player.updateComplete; + await fail(player); expect(player.querySelector("audio, .chat-audio-player__toggle")).toBeNull(); expect(player.querySelectorAll("[role=status]")).toHaveLength(1); expect(player.querySelector("[role=status]")?.textContent).toContain("Preview unavailable"); - expect(player.querySelector("a[download]")).toMatchObject({ + expect(downloadLink(player)).toMatchObject({ href: "https://example.com/voice-note.mp3", target: "_blank", rel: "noreferrer", @@ -164,55 +188,38 @@ describe("ChatAudioPlayer", () => { expect(card(player, "__expand")).toBeNull(); }); - it("formats elapsed, total, and buffered media time from metadata", async () => { - const player = await createPlayer("timing"); - expect(times(player)).toEqual(["0:00 / 0:00"]); - + it("drives play, pause, seek, and keyboard state through the hidden audio element", async () => { + const inline = "data:audio/wav;base64,UklGRg=="; + const player = await createPlayer("briefing", { src: inline }); + expect(headerDownload(player)?.getAttribute("href")).toBe(inline); const media = audio(player); - setMediaNumber(media, "currentTime", 65.9); - setMediaNumber(media, "duration", 3_665); + expect(times(player)).toEqual(["0:00 / 0:00"]); Object.defineProperty(media, "buffered", { configurable: true, value: { length: 1, end: () => 1_832.5 }, }); + setMediaNumber(media, "duration", 3_665); + setMediaNumber(media, "currentTime", 65.9); media.dispatchEvent(new Event("loadedmetadata")); await player.updateComplete; - expect(times(player)).toEqual(["1:05 / 61:05"]); - expect( - player - .querySelector(".chat-audio-player__seek")! - .style.getPropertyValue("--chat-audio-buffered"), - ).toBe("50%"); - }); - - it("drives play, pause, seek, and keyboard state through the hidden audio element", async () => { - const player = await createPlayer("briefing"); - const media = audio(player); + expect(seekInput(player).style.getPropertyValue("--chat-audio-buffered")).toBe("50%"); setMediaNumber(media, "duration", 125); setMediaNumber(media, "currentTime", 0); const { play, pause } = mockPlayback(media); - media.dispatchEvent(new Event("loadedmetadata")); await player.updateComplete; - expect(player.querySelector(".chat-audio-player__time")?.textContent).toContain("2:05"); - + expect(times(player)).toEqual(["0:00 / 2:05"]); control(player, "toggle").click(); await waitForSolid(() => expect(play).toHaveBeenCalledOnce()); await waitForSolid(() => expect(control(player, "toggle")?.getAttribute("aria-label")).toBe("Pause"), ); - expect( - player - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("download"), - ).toBe("briefing.mp3"); - - const seek = player.querySelector(".chat-audio-player__seek")!; + expect(headerDownload(player)?.getAttribute("download")).toBe("briefing.mp3"); + const seek = seekInput(player); seek.value = "35"; seek.dispatchEvent(new Event("input", { bubbles: true })); expect(media.currentTime).toBe(35); - for (const [key, time] of [ ["ArrowRight", 40], ["ArrowUp", 45], @@ -224,12 +231,13 @@ describe("ChatAudioPlayer", () => { expect(event.defaultPrevented).toBe(true); expect(media.currentTime).toBe(time); } - const controls = player.querySelector(".chat-audio-player")!; controls.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); expect(media.currentTime).toBe(40); controls.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })); expect(pause).toHaveBeenCalledOnce(); + await player.updateComplete; + expect(times(player)).toEqual(["0:40 / 2:05"]); }); it("does not expand the attachment when muting through the changing SVG icon", async () => { @@ -256,58 +264,37 @@ describe("ChatAudioPlayer", () => { const status = first.querySelector('[role="status"]'); expect(status?.textContent).toBe(""); const firstMedia = audio(first); - let paused = true; - Object.defineProperty(firstMedia, "paused", { configurable: true, get: () => paused }); - const play = vi.spyOn(firstMedia, "play").mockImplementation(async () => { - paused = false; - firstMedia.dispatchEvent(new Event("play")); - }); - const pauseFirst = vi.spyOn(firstMedia, "pause").mockImplementation(() => { - paused = true; - }); - + const { play, pause: pauseFirst } = mockPlayback(firstMedia, false, true, false); control(first, "toggle").click(); await first.updateComplete; expect(play).toHaveBeenCalledOnce(); expect(control(first, "toggle")?.getAttribute("aria-label")).toBe("Pause"); - firstMedia.dispatchEvent(new Event("error")); await first.updateComplete; expect(first.querySelector(".chat-audio-player__toggle")).toBeNull(); - expect(card(first, "--compact")).not.toBeNull(); - expect(card(first, "__reason")).toBeNull(); - expect(first.querySelector(".chat-audio-player")).toBeNull(); + expectUnavailable(first); expect(first.querySelector('[role="status"]')).toBe(status); expect(status?.textContent).toBe("broken.mp3: Preview unavailable"); expect(status?.getAttribute("aria-live")).toBe("polite"); expect(status?.getAttribute("aria-atomic")).toBe("true"); - expect( - first - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("download"), - ).toBe("broken.mp3"); + expect(headerDownload(first)?.getAttribute("download")).toBe("broken.mp3"); expect(pauseFirst).toHaveBeenCalledOnce(); pauseFirst.mockClear(); - const second = await createPlayer("working"); audio(second).dispatchEvent(new Event("play")); expect(pauseFirst).not.toHaveBeenCalled(); - first.src = "https://example.com/broken.mp3?renewed"; await waitForSolid(() => expect(control(first, "toggle")).not.toBeNull()); expect(first.querySelector('[role="status"]')).toBe(status); expect(status?.textContent).toBe(""); - audio(first).dispatchEvent(new Event("error")); - await first.updateComplete; + await fail(first); expect(status?.textContent).toBe("broken.mp3: Preview unavailable"); - first.src = "https://example.com/replacement.mp3"; first.sourceIdentity = "media://replacement"; first.label = "replacement.mp3"; await waitForSolid(() => expect(control(first, "toggle")).not.toBeNull()); expect(status?.textContent).toBe(""); - audio(first).dispatchEvent(new Event("error")); - await first.updateComplete; + await fail(first); expect(status?.textContent).toBe("replacement.mp3: Preview unavailable"); }); @@ -332,7 +319,7 @@ describe("ChatAudioPlayer", () => { outside.focus(); } await player.updateComplete; - const download = player.querySelector("a[download]")!; + const download = downloadLink(player); expect(download.getAttribute("download")).toBe("focus-" + target + ".mp3"); expect(document.activeElement).toBe( target === "outside" || target === "moved" ? outside : download, @@ -341,54 +328,13 @@ describe("ChatAudioPlayer", () => { }, ); - it("shows preparing, retries a 202 rendition, and then plays", async () => { - vi.useFakeTimers(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 202 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - const player = await createPlayer("voice", { - src: "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=ticket", - sourceIdentity: "/tmp/voice.caf", - label: "voice.caf", - playback: "transcode", - }); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(0); - await player.updateComplete; - - expect(player.textContent).toContain("Preparing playback…"); - expect(player.querySelector("[role=status]")?.textContent).toBe(""); - await vi.advanceTimersByTimeAsync(2_000); - await player.updateComplete; - - const media = audio(player); - let paused = true; - Object.defineProperty(media, "paused", { configurable: true, get: () => paused }); - const play = vi.spyOn(media, "play").mockImplementation(async () => { - paused = false; - media.dispatchEvent(new Event("play")); - }); - expect(media.getAttribute("src")).toContain("mediaTicket=ticket&playback=1"); - control(player, "toggle").click(); - await vi.advanceTimersByTimeAsync(0); - await player.updateComplete; - expect(play).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - it("keeps header Download focused when the parent Expand callback changes", async () => { const player = await createPlayer("callback-focus", { onExpand: vi.fn() }); - const download = player.querySelector( - ".chat-assistant-attachment-card__download", - )!; + const download = headerDownload(player)!; download.focus(); expect(document.activeElement).toBe(download); - player.onExpand = vi.fn(); await player.updateComplete; - expect(document.activeElement).toBe(download); }); @@ -419,12 +365,9 @@ describe("ChatAudioPlayer", () => { : player.querySelector(`.chat-assistant-attachment-card__${target}`)!; focused.focus(); expect(document.activeElement).toBe(focused); - respond(new Response(null, { status: 500 })); await waitForSolid(() => expect(card(player, "--compact")).not.toBeNull()); - expect(document.activeElement).toBe( - target === "outside" ? outside : player.querySelector("a[download]"), - ); + expect(document.activeElement).toBe(target === "outside" ? outside : downloadLink(player)); }, ); @@ -432,62 +375,47 @@ describe("ChatAudioPlayer", () => { vi.useFakeTimers(); const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); vi.stubGlobal("fetch", fetchMock); - const player = await createPlayer("voice", { - src: "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=ticket", - sourceIdentity: "/tmp/voice.caf", - label: "voice.caf", - playback: "transcode", - }); + const player = await transcodePlayer(); await vi.runAllTimersAsync(); await player.updateComplete; - - expect(card(player, "--compact")).not.toBeNull(); - expect(card(player, "__reason")).toBeNull(); - expect(player.querySelector(".chat-audio-player")).toBeNull(); - expect( - player - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("href"), - ).not.toContain("playback=1"); - + expectUnavailable(player); + expect(headerDownload(player)?.getAttribute("href")).not.toContain("playback=1"); const failedAttempts = fetchMock.mock.calls.length; - player.label = "renamed-voice.caf"; - player.onMediaLoaded = vi.fn(); - player.sizeBytes = 2048; - player.serverDurationMs = 4000; - await player.updateComplete; + await update(player, { + label: "renamed-voice.caf", + onMediaLoaded: vi.fn(), + sizeBytes: 2048, + serverDurationMs: 4000, + }); await vi.runAllTimersAsync(); expect(fetchMock).toHaveBeenCalledTimes(failedAttempts); expect(player.querySelector("audio")).toBeNull(); expect(card(player, "--compact")).not.toBeNull(); - fetchMock.mockResolvedValue(new Response(null, { status: 200 })); player.src = "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=recovered"; await player.updateComplete; await vi.runAllTimersAsync(); await player.updateComplete; - expect(card(player, "--compact")).toBeNull(); - expect(player.querySelector("audio")?.getAttribute("src")).toContain( - "mediaTicket=recovered&playback=1", - ); + expect(source(player)).toContain("mediaTicket=recovered&playback=1"); }); it("renders decoded waveform peaks and scopes waveform cache reuse to auth context", async () => { const intersect = observeIntersections(); const decodeAudioData = mockAudioContext(); - let resolveFetch: ((response: Response) => void) | undefined; - const fetchMock = vi.fn( - async () => - await new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - vi.stubGlobal("fetch", fetchMock); + const { fetchMock, resolve: resolveFetch } = pendingFetch(); vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:waveform-audio"); vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); const player = await createPlayer("waveform-reuse"); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); + const initialPlay = mockPlay(audio(player)); + control(player, "toggle").click(); + await initialPlay.mock.results[0]!.value; + await Promise.resolve(); + flush(); + expect(waveformElement(player)).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(decodeAudioData).not.toHaveBeenCalled(); + initialPlay.mockRestore(); player.serverDurationMs = 4_000; await player.updateComplete; expect(fetchMock).not.toHaveBeenCalled(); @@ -495,24 +423,16 @@ describe("ChatAudioPlayer", () => { await waitForSolid(() => expect(fetchMock).toHaveBeenCalledOnce()); const media = audio(player); const { play } = mockPlayback(media); - resolveFetch?.(audioResponse()); - await waitForSolid(() => - expect(player.querySelectorAll(".chat-audio-player__waveform rect")).toHaveLength(96), - ); + await waitForSolid(() => expect(waveformRects(player)).toHaveLength(96)); await player.updateComplete; expect( - new Set( - Array.from(player.querySelectorAll(".chat-audio-player__waveform rect"), (rect) => - rect.getAttribute("height"), - ), - ).size, + new Set(Array.from(waveformRects(player), (rect) => rect.getAttribute("height"))).size, ).toBeGreaterThan(1); player.voiceNote = true; await player.updateComplete; - const voiceHeights = Array.from( - player.querySelectorAll(".chat-audio-player__waveform rect"), - (rect) => Number(rect.getAttribute("height")), + const voiceHeights = Array.from(waveformRects(player), (rect) => + Number(rect.getAttribute("height")), ); expect(Math.min(...voiceHeights)).toBeGreaterThanOrEqual(6); expect(Math.max(...voiceHeights)).toBeLessThanOrEqual(14); @@ -520,29 +440,22 @@ describe("ChatAudioPlayer", () => { expect(fetchMock).toHaveBeenCalledOnce(); expect(decodeAudioData).toHaveBeenCalledOnce(); expect(media.getAttribute("src")).toBe("blob:waveform-audio"); - control(player, "toggle").click(); await waitForSolid(() => expect(play).toHaveBeenCalledOnce()); expect(media.getAttribute("src")).toBe("blob:waveform-audio"); expect(fetchMock).toHaveBeenCalledOnce(); - player.authToken = "new-principal"; await player.updateComplete; expect(media.getAttribute("src")).toBe("https://example.com/waveform-reuse.mp3"); - player.remove(); - const refreshed = document.createElement("openclaw-chat-audio-player"); - refreshed.src = "https://example.com/waveform-reuse.mp3?mediaTicket=fresh"; - refreshed.sourceIdentity = "media://waveform-reuse"; - refreshed.authToken = "different-principal"; - refreshed.label = "waveform-reuse.mp3"; - refreshed.serverDurationMs = 4_000; - mountSolid(() => refreshed); - await refreshed.updateComplete; + const refreshed = await createPlayer("waveform-reuse", { + src: "https://example.com/waveform-reuse.mp3?mediaTicket=fresh", + authToken: "different-principal", + serverDurationMs: 4_000, + }); intersect(); const refreshedMedia = refreshed.querySelector("audio")!; - Object.defineProperty(refreshedMedia, "paused", { configurable: true, value: true }); - vi.spyOn(refreshedMedia, "play").mockResolvedValue(undefined); + mockPlay(refreshedMedia); await waitForSolid(() => expect(fetchMock).toHaveBeenCalledTimes(3)); resolveFetch?.(new Response(null, { status: 500 })); }); @@ -576,16 +489,13 @@ describe("ChatAudioPlayer", () => { const label = change === "unmount" ? "waveform-unmount" : "waveform-replacement"; const player = await createPlayer(label, { serverDurationMs: 4_000 }); intersect(); - await waitForSolid(() => - expect(player.querySelector(".chat-audio-player__waveform")).not.toBeNull(), - ); - const waveform = player.querySelector(".chat-audio-player__waveform")!; + await waitForSolid(() => expect(waveformElement(player)).not.toBeNull()); + const waveform = waveformElement(player)!; const media = player.querySelector("audio")!; expect(observe).toHaveBeenCalledExactlyOnceWith(waveform); expect(disconnect).not.toHaveBeenCalled(); expect(media.getAttribute("src")).toBe("blob:waveform-disposal"); const { pause } = mockActivePlayback(media); - if (change === "unmount") { player.remove(); await Promise.resolve(); @@ -627,21 +537,17 @@ describe("ChatAudioPlayer", () => { intersect(); await waitForSolid(() => expect(fetchMock).toHaveBeenCalledOnce()); resolveFirstFetch?.(audioResponse()); - await waitForSolid(() => - expect(player.querySelector("audio")?.getAttribute("src")).toBe("blob:waveform-audio"), - ); - + await waitForSolid(() => expect(source(player)).toBe("blob:waveform-audio")); const media = audio(player); Object.defineProperty(media, "error", { configurable: true, value: {} as MediaError }); media.dispatchEvent(new Event("error")); await player.updateComplete; expect(card(player, "--compact")).not.toBeNull(); expect(media.isConnected).toBe(false); - player.src = "https://example.com/waveform-recovery.mp3?mediaTicket=recovered"; await player.updateComplete; await waitForSolid(() => - expect(player.querySelector("audio")?.getAttribute("src")).toBe( + expect(source(player)).toBe( "https://example.com/waveform-recovery.mp3?mediaTicket=recovered", ), ); @@ -650,25 +556,6 @@ describe("ChatAudioPlayer", () => { expect(card(player, "--compact")).toBeNull(); }); - it("skips waveform work without a server-probed duration", async () => { - const decodeAudioData = stubAudioContext(); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const player = await createPlayer("unknown-duration"); - const media = audio(player); - Object.defineProperty(media, "paused", { configurable: true, value: true }); - const play = vi.spyOn(media, "play").mockResolvedValue(undefined); - - control(player, "toggle").click(); - await play.mock.results[0]!.value; - await Promise.resolve(); - flush(); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(decodeAudioData).not.toHaveBeenCalled(); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); - }); - it("discards waveform peaks when decoded duration exceeds the server gate by 20 percent", async () => { const decodeAudioData = vi.fn(async () => decodedAudio(121)); const AudioContextMock = vi.fn(function (options?: AudioContextOptions) { @@ -684,17 +571,14 @@ describe("ChatAudioPlayer", () => { const player = await createPlayer("duration-mismatch"); player.serverDurationMs = 100_000; const media = audio(player); - Object.defineProperty(media, "paused", { configurable: true, value: true }); setMediaNumber(media, "duration", 100); media.dispatchEvent(new Event("loadedmetadata")); await player.updateComplete; - vi.spyOn(media, "play").mockResolvedValue(undefined); - + mockPlay(media); control(player, "toggle").click(); await waitForSolid(() => expect(media.getAttribute("src")).toBe("blob:duration-mismatch")); - expect(decodeAudioData).toHaveBeenCalledOnce(); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); + expect(waveformElement(player)).toBeNull(); expect(times(player)).toEqual(["0:00 / 1:40"]); }); @@ -715,17 +599,14 @@ describe("ChatAudioPlayer", () => { const player = await createPlayer("oversized-waveform"); player.serverDurationMs = 4_000; const media = audio(player); - Object.defineProperty(media, "paused", { configurable: true, value: true }); - const play = vi.spyOn(media, "play").mockResolvedValue(undefined); - + const play = mockPlay(media); control(player, "toggle").click(); expect(play).toHaveBeenCalledOnce(); await waitForSolid(() => expect(fetchMock).toHaveBeenCalledOnce()); await waitForSolid(() => expect(response.body?.locked).toBe(false)); - expect(createObjectURL).not.toHaveBeenCalled(); expect(decodeAudioData).not.toHaveBeenCalled(); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); + expect(waveformElement(player)).toBeNull(); expect(media.getAttribute("src")).toBe("https://example.com/oversized-waveform.mp3"); }); @@ -743,31 +624,21 @@ describe("ChatAudioPlayer", () => { player.serverDurationMs = 4_000; const media = audio(player); const { play } = mockPlayback(media); - control(player, "toggle").click(); await waitForSolid(() => expect(FailingAudioContext).toHaveBeenCalledOnce()); await Promise.resolve(); flush(); media.pause(); control(player, "toggle").click(); - expect(play).toHaveBeenCalledTimes(2); expect(media.getAttribute("src")).toBe("blob:context-fallback"); }); it("cancels the old source when identity changes during rendition preparation", async () => { - let resolveFetch: ((response: Response) => void) | undefined; - const fetchMock = vi.fn( - async () => - await new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - vi.stubGlobal("fetch", fetchMock); + const { fetchMock, resolve: resolveFetch } = pendingFetch(); const player = await createPlayer("identity-before"); const media = audio(player); const { play, pause } = mockActivePlayback(media); - player.src = "/__openclaw__/assistant-media?source=after.caf&mediaTicket=ticket"; player.sourceIdentity = "media://identity-after"; player.label = "after.caf"; @@ -776,7 +647,6 @@ describe("ChatAudioPlayer", () => { expect(pause).toHaveBeenCalledOnce(); expect(fetchMock).not.toHaveBeenCalled(); await waitForSolid(() => expect(player.textContent).toContain("Preparing playback…")); - expect(pause).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls[0]?.[0]).toBe( @@ -796,7 +666,6 @@ describe("ChatAudioPlayer", () => { const player = await createPlayer("disconnect-resume"); const media = audio(player); const { play, pause } = mockActivePlayback(media); - player.src = "https://example.com/disconnect-resume-fresh.mp3"; await player.updateComplete; media.dispatchEvent(new Event("error")); @@ -807,7 +676,6 @@ describe("ChatAudioPlayer", () => { mountSolid(() => player); media.currentTime = 0; media.dispatchEvent(new Event("loadedmetadata")); - expect(play).not.toHaveBeenCalled(); }); @@ -816,18 +684,15 @@ describe("ChatAudioPlayer", () => { const firstMedia = audio(first); const { play: playFirst, pause: pauseFirst } = mockActivePlayback(firstMedia); firstMedia.dispatchEvent(new Event("play")); - first.src = "https://example.com/superseded-resume-fresh.mp3"; await first.updateComplete; firstMedia.dispatchEvent(new Event("error")); - const second = await createPlayer("superseding-player"); audio(second).dispatchEvent(new Event("play")); expect(pauseFirst).toHaveBeenCalledOnce(); second.remove(); firstMedia.currentTime = 0; firstMedia.dispatchEvent(new Event("loadedmetadata")); - expect(playFirst).not.toHaveBeenCalled(); }); }); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.tsx b/ui/src/pages/chat/components/chat-transcript-controller.test.tsx index 0833630c7ec0..f8b42da5aa65 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.test.tsx +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.tsx @@ -8,11 +8,7 @@ import { flush } from "../../../test-helpers/solid-settle.ts"; import { projectChatFields } from "../chat-field-projection.ts"; import { makeChatHost } from "../chat-host.test-support.ts"; import { createTestTranscript, stubAnimationFrames } from "../chat-view.test-helpers.ts"; -import { - handleChatScrollTakeover, - saveChatSessionScrollPosition, - scheduleCommittedChatScroll, -} from "../scroll.ts"; +import { handleChatScrollTakeover, saveChatSessionScrollPosition } from "../scroll.ts"; import { SIDEBAR_GEOMETRY_COMMIT_EVENT } from "../sidebar-layout.ts"; import { render, @@ -178,23 +174,6 @@ it.each([false, true])("re-observes re-stamped rows (foreign host=%s)", async (f transcript.dispose(); }); -it("measures newly inserted rows after Solid connects them", async () => { - transcriptDomState.detachedRowHeight = 0; - const props = threadProps("pane-commentary-insert", "agent:main:session-a", [ - { role: "assistant", content: "commentary", timestamp: 1_000 }, - { role: "user", content: "next turn", timestamp: 2_000 }, - ]); - const { container, transcript, draw } = threadFixture(props); - draw(); - transcript.start(); - transcript.update(); - await flushDeferredRowPrune(); - emitRowSizes(transcriptRows(container), 100); - draw(); - - expect(transcriptSize(container)).toBe(200); -}); - it("updates the visible range on native scroll without a parent render", async () => { const flushFrames = stubAnimationFrames(); const props = threadProps("local-geometry", "agent:main:local-geometry", messages(40, "message")); @@ -223,37 +202,6 @@ it("updates the visible range on native scroll without a parent render", async ( expect(Number(transcriptRows(container)[0]?.dataset.index)).toBeGreaterThan(0); }); -it("reveals committed initial row sizes without waiting for an animation frame", async () => { - stubAnimationFrames(); - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600); - const host = new TranscriptTestHost(); - cleanup.push(() => host.remove()); - const transcript = (host.transcript = createChatTranscript(() => "native-measured-tail")); - const rows = numberedContentRows(6); - const scroller = document.createElement("div"); - scroller.className = "chat-thread"; - host.renderTranscript = () => { - const content = renderReadySession(transcript, "native-measured-tail", rows); - if (scroller.firstChild !== content) { - scroller.replaceChildren(content); - } - return scroller; - }; - document.body.append(host); - await host.settleUpdates(); - const extent = requireElement(host, ".chat-thread-inner"); - expect(transcriptRows(host).every((row) => observedElements.has(row))).toBe(true); - await Promise.resolve(); - flush(); - await host.settleUpdates(); - expect(extent.style.visibility).toBe(""); - expect(transcriptRows(host).every((row) => row.style.containIntrinsicBlockSize === "100px")).toBe( - true, - ); - expect(host.textContent).toContain("row 5"); - host.remove(); -}); - it("keeps retained MCP rows and the virtual row model atomic through teardown", async () => { const teardownPending = createDeferred(); transcriptDomState.measuredRowHeight = 180; @@ -409,22 +357,6 @@ it("retains history controls across callback and loading replacements", async () expect(first).toHaveBeenCalledOnce(); }); -it("renders the history tail immediately after an empty disconnected shell", () => { - const flushFrames = stubAnimationFrames(); - const props = threadProps("cold-load-tail", "agent:main:cold-load-tail", []); - const { container, transcript, draw } = threadFixture(props); - draw(); - geometry(container.querySelector(".chat-thread")!, { clientHeight: 600, scrollHeight: 600 }); - transcript.start(); - transcript.update(); - flushFrames(); - - draw({ ...props, messages: messages(40, "Cached history") }); - - expect(container.textContent).toContain("Cached history 39"); - expect(container.textContent).not.toContain("Cached history 0"); -}); - it.each([true, false])("settles a non-overflowing restore (initially loading=%s)", (loading) => { const flushFrames = stubAnimationFrames(); if (!loading) { @@ -498,13 +430,10 @@ it("retries a growing restore without pane commits and renders the settled reade transcript.scrollToOffset(2420, onSettled); await host.settleUpdates(); const initialCommits = host.committedRenders; - const retryCommits: number[] = []; for (const height of [600, 600, 600, 720, 720, 720, 720, 900, 900, 900, 900]) { scrollHeight = height; - const before = host.committedRenders; flushFrame(); await host.settleUpdates(); - retryCommits.push(host.committedRenders - before); expect(onSettled).not.toHaveBeenCalled(); } scrollHeight = 4800; @@ -514,7 +443,6 @@ it("retries a growing restore without pane commits and renders the settled reade expect(container.scrollTop).toBe(2420); expect(container.querySelector("output")?.textContent).toBe("Reading history"); expect.soft(host.committedRenders - initialCommits).toBe(0); - expect.soft(retryCommits).toEqual(Array(11).fill(0)); // jsdom does not emit native scroll events for scrollTop writes. Deliver the browser read-back. scroll(container); @@ -747,80 +675,6 @@ it("keeps smooth latest through settlement and idle until the target", async () expect(transcript.isProgrammaticScroll).toBe(false); }); -it.each([true, false])( - "leaves height-resize follow to page policy with reader lock=%s", - async (locked) => { - const flushFrames = stubAnimationFrames(); - const policy = makeChatHost({ chatHasAutoScrolled: true }); - const onViewportResize = vi.fn(() => - scheduleCommittedChatScroll(policy, false, false, { source: "resize" }), - ); - const transcript = createChatTranscript(() => `height-resize-${locked}`, { - onViewportResize, - canFollowEnd: () => !policy.chatFollowLocked, - onReaderScroll: (towardEnd) => handleChatScrollTakeover(policy, towardEnd), - }); - const rows = numberedContentRows(12); - const { container } = await mountTestTranscript(`height-resize-${locked}`, rows, transcript); - const total = transcriptSize(container); - geometry(container, { clientHeight: 600, scrollHeight: total + 88 }); - const scrollTo = vi.fn(); - container.scrollTo = scrollTo; - policy.chatScrollElement = () => container; - policy.chatScrollToEnd = (options) => transcript.scrollToEnd(options); - emitSize(container, 600); - scroll(container, total - 600); - if (locked) { - container.dispatchEvent(new WheelEvent("wheel", { deltaY: -88 })); - } - expect(policy.chatFollowLocked).toBe(locked); - scrollTo.mockClear(); - - for (const height of [560, 640]) { - geometry(container, { clientHeight: height }); - emitSize(container, height); - // The observer only reports geometry; follow waits for the policy frame. - expect(scrollTo).not.toHaveBeenCalled(); - flushFrames(); - if (locked) { - expect(scrollTo).not.toHaveBeenCalled(); - expect(container.scrollTop).toBe(total - 600); - } else { - expect(scrollTo).toHaveBeenLastCalledWith({ - top: total + 88 - height, - behavior: "auto", - }); - } - scrollTo.mockClear(); - } - expect(onViewportResize).toHaveBeenCalledTimes(2); - expect(policy.chatFollowLocked).toBe(locked); - }, -); - -it("keeps a reader above the padded real end stationary when a rendered row grows", async () => { - transcriptDomState.measuredRowHeight = 120; - const rows = numberedContentRows(12); - const { container, renderRows } = await mountTestTranscript("padded-row-resize", rows); - const total = transcriptSize(container); - container.style.padding = "28px 0 56px"; - container.scrollTo = (options?: ScrollToOptions | number) => applyScroll(container, options); - geometry(container, { clientHeight: 600, scrollHeight: total + 84 }); - emitSize(container, 600); - scroll(container, container.scrollHeight - container.clientHeight - 44); - const readerOffset = container.scrollTop; - expect(Math.max(total - container.clientHeight - readerOffset, 0)).toBe(0); - const row = requireElement(container, '[data-index="11"]'); - expect(observedElements.has(row)).toBe(true); - - geometry(row, { offsetHeight: 192 }); - geometry(container, { scrollHeight: total + 84 + 72 }); - emitSize(row, 192); - renderRows(rows); - expect(transcriptSize(container)).toBe(total + 72); - expect(container.scrollTop).toBe(readerOffset); -}); - it.each([false, true])( "keeps disclosure anchoring through geometry commit unless interrupted=%s", async (interrupt) => { diff --git a/ui/src/pages/chat/components/chat-transcript-message-index.test.ts b/ui/src/pages/chat/components/chat-transcript-message-index.test.ts index 4a2d22e9e11c..dfd6eebd5d59 100644 --- a/ui/src/pages/chat/components/chat-transcript-message-index.test.ts +++ b/ui/src/pages/chat/components/chat-transcript-message-index.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { chatItemGroups } from "../chat-agent-run-grouping.ts"; import { resetWorkingProgress } from "../chat-progress.ts"; import { buildChatItems, type BuildChatItemsProps } from "../chat-thread-build.ts"; @@ -29,11 +29,9 @@ function makeTool( turnBoundary?: boolean, ) { return { - role: "toolResult", + ...makeMessage(id, seq, { role: "toolResult", content: "Evidence", timestamp, runId }), toolName: "read", toolCallId: id, - content: "Evidence", - timestamp, __openclaw: { id, seq, runId, turnBoundary }, }; } @@ -56,11 +54,7 @@ function chatProps(overrides: Partial = {}): BuildChatItems const chatItems = (overrides: Partial = {}) => buildChatItems(chatProps(overrides)); -const chainOptions = { - sessionKey, - runWorking: false, - searchActive: false, -}; +const chainOptions = { sessionKey, runWorking: false, searchActive: false }; const labels = { assistantName: "Assistant", userId: "reader", userName: "Reader" }; type Chain = ReturnType; type Index = ReturnType; @@ -101,41 +95,6 @@ const replyFrameShape = (outcome: unknown, ...parts: unknown[]) => ({ parts, }); -it.each([false, true])("merges adjacent continuation work across runs while live=%s", (live) => { - const messages = [ - history[0], - { ...partial, content: "Checking the remaining work.", phase: "commentary" }, - ...["run-1", "announce:review", "announce:resume"].map((runId, index) => - makeTool(`tool-${index}`, 3 + index, runId, 30 + index, true), - ), - ]; - for (const persisted of [messages, structuredClone(messages)]) { - const items = chatItems({ - messages: persisted, - runId: live ? "announce:resume" : null, - runWorking: live, - streamStartedAt: live ? 40 : null, - }); - const options = { ...chainOptions, runWorking: live }; - const chain = chainFor(items, options); - const parts = transcriptParts(chain); - const activities = kind(parts, "activity-run"); - expect(activities).toHaveLength(1); - expect(activities[0]?.groups.map((group) => group.runId)).toEqual([ - "run-1", - "announce:review", - "announce:resume", - ]); - expect(messagesIn(kind(parts, "group"))).toContain(persisted[1]); - expect(parts.some((item) => item.kind === "stream-run")).toBe(live); - expect(chainFor(items, { ...options })).toBe(chain); - const index = indexFor(chain); - expect(new Set([0, 1, 2].map((tool) => index.messageRowKeysById.get(`tool-${tool}`)))).toEqual( - new Set([activities[0]?.key]), - ); - } -}); - it.each([ { key: sessionKey, working: true, projected: false, untagged: false }, { key: sessionKey, working: true, projected: true, untagged: false }, @@ -183,8 +142,6 @@ describe("completed reply frame ownership", () => { ]; } - const frames = framesOf; - it.each<[string, (string | undefined)[], boolean, string?, boolean?]>([ ["missing prompt", ["run-1"], false], ["resumed run without prompt", ["run-u", "run-1"], false], @@ -205,7 +162,7 @@ describe("completed reply frame ownership", () => { const original = structuredClone(messages); const options = { sessionKey: key }; const chain = projectHistory(messages, options); - const [frame] = frames(chain); + const [frame] = framesOf(chain); expect(chain.transcriptItems).toEqual([ ...(prompt ? [expect.objectContaining({ role: "user" })] : []), frame, @@ -239,7 +196,7 @@ describe("completed reply frame ownership", () => { if (!prompt) { for (const restored of [structuredClone(messages), [history[0], ...messages]]) { const next = projectHistory(restored, options); - expect(frames(next)[0]?.key).toBe(frame?.key); + expect(framesOf(next)[0]?.key).toBe(frame?.key); const nextIndex = indexFor(next); for (const id of ["work-0", "final"]) { expect(nextIndex.messageRowKeysById.get(id)).toBe(index.messageRowKeysById.get(id)); @@ -267,13 +224,13 @@ describe("completed reply frame ownership", () => { }); messages.splice(prompt ? 2 : 1, 0, inserted); const chain = projectHistory(messages); - expect(frames(chain).at(-1)?.outcome).toMatchObject(completedOutcome(messages.at(-1))); + expect(framesOf(chain).at(-1)?.outcome).toMatchObject(completedOutcome(messages.at(-1))); const work = (forwarded ? chain.transcriptItems : transcriptParts(chain)).find( (item) => item.kind === "work-group", ); expect(work).toBeDefined(); if (forwarded) { - expect(messagesIn(frames(chain))).not.toContain(inserted); + expect(messagesIn(framesOf(chain))).not.toContain(inserted); const standalone = phase ? kind(chain.transcriptItems, "group") : [work!]; expect(messagesIn(standalone)).toContain(inserted); } else { @@ -282,10 +239,12 @@ describe("completed reply frame ownership", () => { kind(transcriptParts(chain), "group").filter((item) => item.role === "assistant"), ), ).toEqual([inserted, messages.at(-1)]); - expect(frames(chain).map((frame) => frame.runId)).toEqual( + expect(framesOf(chain).map((frame) => frame.runId)).toEqual( runId === "run-1" ? ["run-1"] : ["run-other", "run-1"], ); - expect(runId === "run-1" ? frames(chain)[0]?.parts[0] : chain.transcriptItems[1]).toBe(work); + expect(runId === "run-1" ? framesOf(chain)[0]?.parts[0] : chain.transcriptItems[1]).toBe( + work, + ); } }); @@ -298,7 +257,7 @@ describe("completed reply frame ownership", () => { }); messages.splice(2, 0, steer); const chain = projectHistory(messages); - const [before, frame] = frames(chain); + const [before, frame] = framesOf(chain); expect(chain.transcriptItems).toEqual([ expect.objectContaining({ role: "user" }), before, @@ -368,119 +327,40 @@ it("reuses transcript structure for scroll renders and rebuilds for its inputs", expect(indexFor(settled, expanded, { ...labels, userName: "Renamed" })).not.toBe(expandedIndex); }); -const work = vi.hoisted(() => ({ chainCalls: 0, chainItems: 0, positionItems: 0 })); -vi.mock("../chat-turn-presentation.ts", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - coalesceStreamRuns: (...args: Parameters) => { - work.chainCalls++; - work.chainItems += args[0].length; - return original.coalesceStreamRuns(...args); - }, - }; -}); -vi.mock("./chat-position-projection.ts", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - projectChatPositions: (...args: Parameters) => { - function* counted() { - for (const item of args[0]) { - work.positionItems++; - yield item; - } - } - return original.projectChatPositions(counted(), args[1], args[2], args[3]); - }, - }; -}); - afterEach(() => resetChatThreadState()); -function pastMessages(size: number, tagged = true): unknown[] { - return Array.from({ length: size }, (_, i) => { - const phase = i % (tagged ? 6 : 4); - const toolPhase = phase === 2 || phase === 4; - const finalPhase = phase === (tagged ? 5 : 3); - return { - role: phase === 0 ? "user" : toolPhase ? "toolResult" : "assistant", - content: phase === 0 ? "Question" : finalPhase ? "Answer" : "Work", - ...(toolPhase ? { toolName: "read", toolCallId: `call-${i}` } : {}), - ...(phase === 1 || (tagged && phase === 3) - ? { phase: "commentary" } - : finalPhase - ? { phase: "final_answer", stopReason: "stop" } - : {}), - timestamp: i + 1, - __openclaw: { - id: `history-${i}`, - seq: i + 1, - ...(tagged ? { runId: `history-run-${Math.floor(i / 6)}` } : {}), - }, - }; - }); -} -function streamInput(size: number): BuildChatItemsProps { - const messages = pastMessages(size); - const liveMessage = (id: string, offset: number, fields: Record) => - makeMessage(id, size + offset, { timestamp: size + offset, runId: "live", ...fields }); - messages.push( - liveMessage("live-user", 1, { role: "user" }), - liveMessage("live-comment", 2, { phase: "commentary" }), - makeTool("live-tool", size + 3, "live", size + 3), - ); - return chatProps({ - paneId: "stream-projection", - messages, - stream: "Initial", - streamStartedAt: size + 4, - runId: "live", - runWorking: true, - }); -} - -it("keeps stream-only projection work bounded by the tail across history sizes", () => { - const measure = (size: number) => { - resetChatThreadState(); - const input = streamInput(size); - const expanded = new Map(); - let initialChain: Chain | undefined; - const project = (stream: string) => { - const items = buildCachedChatItems({ ...input, stream }); - const chain = chainFor(items, { ...chainOptions, runWorking: true }); - initialChain ??= chain; - expect(chain.transcriptItems).toBe(initialChain.transcriptItems); - expect(chain.collapsedItems).toBe(initialChain.collapsedItems); - return indexFor(chain, expanded, labels); - }; - const initial = project("Initial"); - const previews = new Map(); - const presentation = projectTranscriptRows(initial.rows, previews, []); - Object.assign(work, { chainCalls: 0, chainItems: 0, positionItems: 0 }); - for (const stream of ["One", "One two", "One two three"]) { - const next = project(stream); - expect(next.rows).toBe(initial.rows); - expect(next.positionIndex.markers).toBe(initial.positionIndex.markers); - expect(projectTranscriptRows(next.rows, previews, [])).toBe(presentation); - } - return { ...work }; - }; - const small = measure(20); - const large = measure(400); - expect(large).toEqual(small); - expect(measure(1_000)).toEqual(small); - expect(large.chainCalls).toBe(0); - expect(large.positionItems).toBeLessThanOrEqual(6); -}); - it.each([ { name: "dashboard frame and queued tail", framed: true, searchActive: false }, { name: "unframed stream without boundary", framed: false, searchActive: false }, { name: "search projection", framed: true, searchActive: true }, ])("recomputes streamed history: $name", ({ framed, searchActive }) => { - const input = streamInput(6); - input.messages = [...pastMessages(4, false), ...input.messages.slice(-3)]; + const historical = (seq: number, fields: Record) => + makeMessage(`history-${seq - 1}`, seq, { + timestamp: seq, + __openclaw: { id: `history-${seq - 1}`, seq }, + ...fields, + }); + const input = chatProps({ + paneId: "stream-projection", + messages: [ + historical(1, { role: "user", content: "Question" }), + historical(2, { content: "Work", phase: "commentary" }), + historical(3, { + role: "toolResult", + content: "Work", + toolName: "read", + toolCallId: "call-2", + }), + historical(4, { content: "Answer", phase: "final_answer", stopReason: "stop" }), + makeMessage("live-user", 7, { role: "user", timestamp: 7, runId: "live" }), + makeMessage("live-comment", 8, { phase: "commentary", timestamp: 8, runId: "live" }), + makeTool("live-tool", 9, "live", 9), + ], + stream: "Initial", + streamStartedAt: 10, + runId: "live", + runWorking: true, + }); if (!framed) { input.runId = null; } @@ -498,11 +378,8 @@ it.each([ } const options = { runWorking: true, searchActive }; const expanded = new Map(); - const retained: { - chain: Chain; - index: Index; - snapshot: unknown; - }[] = []; + const previews = new Map(); + const retained: { chain: Chain; index: Index; snapshot: unknown }[] = []; const texts = [ " ", "Private planning", @@ -535,36 +412,6 @@ it.each([ setExpansionState(expanded, workKey, false); } const index = indexFor(chain, expanded); - const fullChain = chainFor([...items], options); - const fullIndex = indexFor(fullChain, expanded); - const currentChain = (value: Chain) => ({ - ...value, - collapsedItems: value.collapsedItems.map((item) => - item.key === value.live?.item.key ? value.live.item : item, - ), - transcriptItems: value.transcriptItems.map((item) => - item.key === value.live?.item.key ? value.live.item : item, - ), - }); - const currentIndex = (value: Index) => ({ - ...value, - rows: value.rows.map((row) => - row.kind === "item" && row.key === value.liveItem?.key - ? { ...row, item: value.liveItem } - : row, - ), - positionIndex: { - ...value.positionIndex, - markers: value.positionIndex.markers.map((marker) => ({ - ...marker, - message: - value.positionIndex.liveMarkers?.find((candidate) => candidate.id === marker.id) - ?.message ?? marker.message, - })), - }, - }); - expect(currentChain(chain)).toEqual(currentChain(fullChain)); - expect(currentIndex(index)).toEqual(currentIndex(fullIndex)); expect(chainFor(items, options)).toBe(chain); expect(indexFor(chain, expanded)).toBe(index); if (live) { @@ -580,9 +427,10 @@ it.each([ expect(owner.boundaryId).toBeUndefined(); } if (framed && visible) { + const marker = index.positionIndex.markers.find((item) => item.id === "run:live"); expect( - currentIndex(index).positionIndex.markers.find((marker) => marker.id === "run:live") - ?.message, + index.positionIndex.liveMarkers?.find((item) => item.id === marker?.id)?.message ?? + marker?.message, ).toMatchObject({ content: [{ type: "text", text: stream }] }); } if ([3, 4, 6, 9].includes(delta)) { @@ -594,6 +442,9 @@ it.each([ expect(index.positionIndex.markerIdsByMessageId).toBe( previous.index.positionIndex.markerIdsByMessageId, ); + expect(projectTranscriptRows(index.rows, previews, [])).toBe( + projectTranscriptRows(previous.index.rows, previews, []), + ); for (const [position, row] of index.rows.entries()) { if (row.key === ownerKey) { break; @@ -707,11 +558,16 @@ describe("subagent handoff", () => { (part) => part.kind === "group" && part.messages.every((source) => source.message !== prose), ); const resumedRow = readRow("read-2", "announce:resume", 7); - const logs = logsOf([...waiting, resumedRow, answer]); + const resumed = projectHistory([...waiting, resumedRow, answer]); + const logs = framesOf(resumed).flatMap((frame) => kind(frame.parts, "activity-run")); expect(logs).toHaveLength(1); // The row the reader may have opened while waiting keeps its identity. expect(logs[0]).toMatchObject({ key: `activity:${waitingRow?.key}` }); expect(logs[0]?.groups.map((group) => group.runId)).toEqual(["run-1", "announce:resume"]); + const index = indexFor(resumed); + expect(new Set(["read-1", "read-2"].map((id) => index.messageRowKeysById.get(id)))).toEqual( + new Set([framesOf(resumed)[0]?.key]), + ); const spoke = makeMessage("spoke", 6, { timestamp: 45, runId: "announce:resume",