Download src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 29 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts
-
curl -L -o cli-backend-dispatch.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts
29 kB
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; | |
| import { | |
| appendTranscriptMessage, | |
| loadSessionEntryReadOnly, | |
| loadTranscriptEventsSync, | |
| upsertSessionEntryCore, | |
| } from "../../config/sessions/session-accessor.js"; | |
| import { emitAgentEvent } from "../../infra/agent-events.js"; | |
| import { createTestAdmittedRunContext } from "../admitted-run-context.test-support.js"; | |
| import { loadCliSessionHistoryMessages } from "../cli-runner/session-history.js"; | |
| import type { RunCliAgentParams } from "../cli-runner/types.js"; | |
| import { SessionManager } from "../sessions/index.js"; | |
| import { resolveEmbeddedCliBackendDispatchEligibility } from "./cli-backend-dispatch-eligibility.js"; | |
| import { runEmbeddedAgentViaCliBackendIfEligible } from "./cli-backend-dispatch.js"; | |
| import type { EmbeddedAgentRunResult } from "./types.js"; | |
| const ensureAuthProfileStore = vi.hoisted(() => vi.fn()); | |
| const resolveAuthProfileOrder = vi.hoisted(() => vi.fn()); | |
| const resolveModelAuthMode = vi.hoisted(() => vi.fn()); | |
| const resolveRuntimeCliBackends = vi.hoisted(() => vi.fn()); | |
| const resolveCliRuntimeExecutionProvider = vi.hoisted(() => vi.fn()); | |
| const runCliAgent = vi.hoisted(() => vi.fn()); | |
| const retireSessionMcpRuntime = vi.hoisted(() => vi.fn()); | |
| const retireSessionMcpRuntimeForSessionKey = vi.hoisted(() => vi.fn()); | |
| const tempDirs = useAutoCleanupTempDirTracker(afterEach); | |
| vi.mock("../model-auth.js", () => ({ | |
| ensureAuthProfileStore, | |
| resolveAuthProfileOrder, | |
| resolveModelAuthMode, | |
| })); | |
| vi.mock("../model-runtime-aliases.js", () => ({ | |
| resolveCliRuntimeExecutionProvider, | |
| })); | |
| vi.mock("../../plugins/cli-backends.runtime.js", () => ({ | |
| resolveRuntimeCliBackends, | |
| })); | |
| vi.mock("../cli-runner.runtime.js", () => ({ | |
| runCliAgent, | |
| })); | |
| vi.mock("../agent-bundle-mcp-tools.js", () => ({ | |
| retireSessionMcpRuntime, | |
| retireSessionMcpRuntimeForSessionKey, | |
| })); | |
| const transcriptRecorder = vi.hoisted(() => ({ | |
| noteToolEvent: vi.fn(), | |
| noteAssistantText: vi.fn(), | |
| flushAssistantSnapshot: vi.fn(), | |
| finalize: vi.fn(async () => undefined), | |
| })); | |
| const createCliDispatchTranscriptRecorder = vi.hoisted(() => | |
| vi.fn<typeof import("./cli-backend-dispatch-transcript.js").createCliDispatchTranscriptRecorder>( | |
| () => transcriptRecorder, | |
| ), | |
| ); | |
| vi.mock("./cli-backend-dispatch-transcript.js", () => ({ | |
| createCliDispatchTranscriptRecorder, | |
| })); | |
| type CliDispatchParams = Parameters<typeof runEmbeddedAgentViaCliBackendIfEligible>[0]; | |
| function baseRunParams(overrides: Partial<CliDispatchParams> = {}): CliDispatchParams { | |
| const runId = overrides.runId ?? "run-cli-dispatch-test"; | |
| return { | |
| admittedRunContext: createTestAdmittedRunContext(runId), | |
| sessionId: "recall-session", | |
| sessionKey: "agent:main:recall", | |
| sessionTarget: { | |
| agentId: "main", | |
| sessionId: overrides.sessionId ?? "recall-session", | |
| sessionKey: overrides.sessionKey ?? "agent:main:recall", | |
| storePath: "/tmp/recall/openclaw-agent.sqlite", | |
| }, | |
| sessionFile: "/tmp/recall/session.jsonl", | |
| workspaceDir: "/tmp/recall/workspace", | |
| prompt: "recall prompt", | |
| provider: "claude-cli", | |
| model: "claude-opus-4-8", | |
| timeoutMs: 30_000, | |
| runId, | |
| cliBackendDispatch: "subscription-auth" as const, | |
| toolsAllow: ["memory_search"], | |
| ...overrides, | |
| }; | |
| } | |
| function cliRunResult(meta: Partial<EmbeddedAgentRunResult["meta"]> = {}): EmbeddedAgentRunResult { | |
| return { | |
| payloads: [{ text: "recall summary" }], | |
| meta: meta as EmbeddedAgentRunResult["meta"], | |
| } as EmbeddedAgentRunResult; | |
| } | |
| beforeEach(() => { | |
| ensureAuthProfileStore.mockReset(); | |
| resolveAuthProfileOrder.mockReset(); | |
| resolveModelAuthMode.mockReset(); | |
| resolveRuntimeCliBackends.mockReset(); | |
| runCliAgent.mockReset(); | |
| retireSessionMcpRuntime.mockReset(); | |
| retireSessionMcpRuntimeForSessionKey.mockReset(); | |
| resolveCliRuntimeExecutionProvider.mockReset(); | |
| ensureAuthProfileStore.mockReturnValue({ profiles: {} }); | |
| resolveAuthProfileOrder.mockReturnValue([]); | |
| resolveModelAuthMode.mockReturnValue("oauth"); | |
| // Default registry: the claude-cli backend declares the capability, a | |
| // gemini backend does not. | |
| resolveRuntimeCliBackends.mockReturnValue([ | |
| { id: "claude-cli", subscriptionAuthDispatch: true }, | |
| { id: "google-gemini-cli" }, | |
| ]); | |
| resolveCliRuntimeExecutionProvider.mockReturnValue(undefined); | |
| retireSessionMcpRuntimeForSessionKey.mockResolvedValue(true); | |
| retireSessionMcpRuntime.mockResolvedValue(true); | |
| runCliAgent.mockResolvedValue(cliRunResult()); | |
| transcriptRecorder.noteToolEvent.mockReset(); | |
| transcriptRecorder.noteAssistantText.mockReset(); | |
| transcriptRecorder.flushAssistantSnapshot.mockReset(); | |
| transcriptRecorder.finalize.mockClear(); | |
| createCliDispatchTranscriptRecorder.mockClear(); | |
| }); | |
| describe("resolveEmbeddedCliBackendDispatchEligibility", () => { | |
| // The recall timeout default consumes this decision directly; these cases | |
| // pin that API-key and missing-backend setups stay ineligible so callers | |
| // budgeting on it keep the passthrough default. | |
| it("resolves the provider for subscription (oauth) credentials", () => { | |
| expect(resolveEmbeddedCliBackendDispatchEligibility({ provider: "claude-cli" })).toEqual({ | |
| provider: "claude-cli", | |
| }); | |
| }); | |
| it("returns undefined for API-key credentials", () => { | |
| resolveModelAuthMode.mockReturnValue("api-key"); | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ provider: "claude-cli" }), | |
| ).toBeUndefined(); | |
| }); | |
| it("returns undefined without a registered claude-cli backend", () => { | |
| resolveRuntimeCliBackends.mockReturnValue([]); | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ provider: "claude-cli" }), | |
| ).toBeUndefined(); | |
| }); | |
| it("honors an explicitly pinned API-key profile over subscription-first order", () => { | |
| // A pinned profile is the credential the run executes on; order must not | |
| // override it in either direction. | |
| ensureAuthProfileStore.mockReturnValue({ | |
| profiles: { | |
| "anthropic:claude-cli": { type: "oauth", provider: "claude-cli" }, | |
| "anthropic:api": { type: "api_key", provider: "anthropic" }, | |
| }, | |
| }); | |
| resolveAuthProfileOrder.mockReturnValue(["anthropic:claude-cli", "anthropic:api"]); | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ | |
| provider: "claude-cli", | |
| authProfileId: "anthropic:api", | |
| }), | |
| ).toBeUndefined(); | |
| expect(resolveModelAuthMode).not.toHaveBeenCalled(); | |
| }); | |
| it("honors an explicitly pinned OAuth profile over API-key-first order", () => { | |
| ensureAuthProfileStore.mockReturnValue({ | |
| profiles: { | |
| "anthropic:api": { type: "api_key", provider: "anthropic" }, | |
| "anthropic:claude-cli": { type: "oauth", provider: "claude-cli" }, | |
| }, | |
| }); | |
| resolveAuthProfileOrder.mockReturnValue(["anthropic:api", "anthropic:claude-cli"]); | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ | |
| provider: "claude-cli", | |
| authProfileId: "anthropic:claude-cli", | |
| }), | |
| ).toEqual({ provider: "claude-cli" }); | |
| }); | |
| it("falls back to ordered selection when the pinned profile is unknown", () => { | |
| ensureAuthProfileStore.mockReturnValue({ | |
| profiles: { "anthropic:api": { type: "api_key", provider: "anthropic" } }, | |
| }); | |
| resolveAuthProfileOrder.mockReturnValue(["anthropic:api"]); | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ | |
| provider: "claude-cli", | |
| authProfileId: "anthropic:missing", | |
| }), | |
| ).toBeUndefined(); | |
| }); | |
| it("returns undefined when the backend does not declare the capability", () => { | |
| // The provider plugin owns the subscription-billing claim; a registered | |
| // backend without it (e.g. gemini) keeps the passthrough. | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ provider: "google-gemini-cli" }), | |
| ).toBeUndefined(); | |
| expect(resolveModelAuthMode).not.toHaveBeenCalled(); | |
| }); | |
| it("resolves canonical refs through the configured runtime", () => { | |
| resolveCliRuntimeExecutionProvider.mockReturnValue("claude-cli"); | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ | |
| provider: "anthropic", | |
| model: "claude-opus-4-8", | |
| agentId: "main", | |
| }), | |
| ).toEqual({ provider: "claude-cli" }); | |
| }); | |
| it("forwards the pinned auth profile into runtime resolution", () => { | |
| // The pin can be what maps a canonical ref onto its CLI runtime; the | |
| // resolver must see it or dispatch strands the run on the passthrough. | |
| resolveCliRuntimeExecutionProvider.mockReturnValue("claude-cli"); | |
| ensureAuthProfileStore.mockReturnValue({ | |
| profiles: { "anthropic:claude-cli": { type: "oauth", provider: "claude-cli" } }, | |
| }); | |
| expect( | |
| resolveEmbeddedCliBackendDispatchEligibility({ | |
| provider: "anthropic", | |
| model: "claude-opus-4-8", | |
| agentId: "main", | |
| authProfileId: "anthropic:claude-cli", | |
| }), | |
| ).toEqual({ provider: "claude-cli" }); | |
| expect(resolveCliRuntimeExecutionProvider).toHaveBeenCalledWith( | |
| expect.objectContaining({ authProfileId: "anthropic:claude-cli" }), | |
| ); | |
| }); | |
| }); | |
| describe("runEmbeddedAgentViaCliBackendIfEligible gate", () => { | |
| const runGate = (overrides: Partial<CliDispatchParams> = {}) => | |
| runEmbeddedAgentViaCliBackendIfEligible(baseRunParams(overrides)); | |
| it("returns undefined without the opt-in", async () => { | |
| expect(await runGate({ cliBackendDispatch: undefined })).toBeUndefined(); | |
| expect(resolveModelAuthMode).not.toHaveBeenCalled(); | |
| expect(runCliAgent).not.toHaveBeenCalled(); | |
| }); | |
| it("keeps message-tool-only source replies on the embedded delivery owner", async () => { | |
| expect( | |
| await runGate({ | |
| sourceReplyDeliveryMode: "message_tool_only", | |
| toolsAllow: ["message"], | |
| messageChannel: "telegram", | |
| currentChannelId: "telegram:source-chat", | |
| }), | |
| ).toBeUndefined(); | |
| expect(runCliAgent).not.toHaveBeenCalled(); | |
| }); | |
| it("dispatches claude-cli runs with subscription (oauth) credentials", async () => { | |
| expect(await runGate({ agentDir: "/agents/main", workspaceDir: "/workspace" })).toBeDefined(); | |
| expect(ensureAuthProfileStore).toHaveBeenCalledWith("/agents/main", expect.anything()); | |
| expect(resolveModelAuthMode).toHaveBeenCalledWith( | |
| "claude-cli", | |
| undefined, | |
| expect.anything(), | |
| expect.objectContaining({ workspaceDir: "/workspace" }), | |
| ); | |
| expect(runCliAgent.mock.calls[0]?.[0]).toMatchObject({ provider: "claude-cli" }); | |
| }); | |
| it("dispatches when no credential mode resolves for the passthrough", async () => { | |
| resolveModelAuthMode.mockReturnValue(undefined); | |
| expect(await runGate()).toBeDefined(); | |
| expect(runCliAgent).toHaveBeenCalledTimes(1); | |
| }); | |
| it("dispatches when the credential store is unreadable", async () => { | |
| ensureAuthProfileStore.mockImplementation(() => { | |
| throw new Error("store unreadable"); | |
| }); | |
| expect(await runGate()).toBeDefined(); | |
| expect(runCliAgent).toHaveBeenCalledTimes(1); | |
| }); | |
| it.each(["api-key", "mixed", "aws-sdk"] as const)( | |
| "keeps the passthrough when the auth mode is %s", | |
| async (mode) => { | |
| resolveModelAuthMode.mockReturnValue(mode); | |
| expect(await runGate()).toBeUndefined(); | |
| expect(runCliAgent).not.toHaveBeenCalled(); | |
| }, | |
| ); | |
| it("dispatches when the ordered profile selection picks a subscription credential in a mixed store", async () => { | |
| // The passthrough follows auth.order; a store-wide "mixed" aggregate must | |
| // not suppress dispatch when the selected profile is oauth. | |
| ensureAuthProfileStore.mockReturnValue({ | |
| profiles: { | |
| "anthropic:claude-cli": { type: "oauth", provider: "claude-cli" }, | |
| "anthropic:api": { type: "api_key", provider: "anthropic" }, | |
| }, | |
| }); | |
| resolveAuthProfileOrder.mockReturnValue(["anthropic:claude-cli", "anthropic:api"]); | |
| resolveModelAuthMode.mockReturnValue("mixed"); | |
| expect(await runGate()).toBeDefined(); | |
| expect(resolveModelAuthMode).not.toHaveBeenCalled(); | |
| }); | |
| it("keeps the passthrough when the ordered profile selection picks an API key", async () => { | |
| ensureAuthProfileStore.mockReturnValue({ | |
| profiles: { | |
| "anthropic:api": { type: "api_key", provider: "anthropic" }, | |
| "anthropic:claude-cli": { type: "oauth", provider: "claude-cli" }, | |
| }, | |
| }); | |
| resolveAuthProfileOrder.mockReturnValue(["anthropic:api", "anthropic:claude-cli"]); | |
| expect(await runGate()).toBeUndefined(); | |
| expect(runCliAgent).not.toHaveBeenCalled(); | |
| }); | |
| it.each([undefined, "raw", "resolved"] as const)( | |
| "dispatches canonical refs through claude-cli with %s logical route resolution", | |
| async (requestedRouteResolution) => { | |
| resolveCliRuntimeExecutionProvider.mockReturnValue("claude-cli"); | |
| expect( | |
| await runGate({ | |
| provider: "anthropic", | |
| model: "claude-opus-4-8", | |
| agentId: "main", | |
| requestedRouteResolution, | |
| }), | |
| ).toBeDefined(); | |
| expect(resolveCliRuntimeExecutionProvider).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| provider: "anthropic", | |
| agentId: "main", | |
| modelId: "claude-opus-4-8", | |
| }), | |
| ); | |
| const dispatched = runCliAgent.mock.calls[0]?.[0]; | |
| expect(dispatched).toMatchObject({ provider: "claude-cli" }); | |
| expect(dispatched.requesterModel).toEqual( | |
| requestedRouteResolution === "resolved" | |
| ? { provider: "anthropic", model: "claude-opus-4-8" } | |
| : undefined, | |
| ); | |
| }, | |
| ); | |
| it("keeps the passthrough for canonical refs without a claude-cli runtime", async () => { | |
| resolveCliRuntimeExecutionProvider.mockReturnValue(undefined); | |
| expect(await runGate({ provider: "anthropic", model: "claude-opus-4-8" })).toBeUndefined(); | |
| resolveCliRuntimeExecutionProvider.mockReturnValue("google-gemini-cli"); | |
| expect(await runGate({ provider: "google", model: "gemini-3.1-pro-preview" })).toBeUndefined(); | |
| expect(runCliAgent).not.toHaveBeenCalled(); | |
| }); | |
| it("keeps the passthrough for other CLI runtimes until verified", async () => { | |
| expect(await runGate({ provider: "google-gemini-cli" })).toBeUndefined(); | |
| expect(resolveModelAuthMode).not.toHaveBeenCalled(); | |
| }); | |
| it("keeps the passthrough without a caller-owned session file", async () => { | |
| expect(await runGate({ sessionFile: undefined })).toBeUndefined(); | |
| }); | |
| it("keeps the passthrough when no claude-cli backend is registered", async () => { | |
| resolveRuntimeCliBackends.mockReturnValue([]); | |
| expect(await runGate()).toBeUndefined(); | |
| }); | |
| }); | |
| describe("runEmbeddedAgentViaCliBackendIfEligible execution", () => { | |
| it("reads and mirrors the dispatched turn in the selected custom SQLite store", async () => { | |
| const dir = tempDirs.make("cli-dispatch-history-"); | |
| const sessionTarget = { | |
| agentId: "main", | |
| sessionId: "recall-session", | |
| sessionKey: "agent:main:recall", | |
| storePath: path.join(dir, "openclaw-agent.sqlite"), | |
| }; | |
| await upsertSessionEntryCore(sessionTarget, { | |
| sessionId: sessionTarget.sessionId, | |
| updatedAt: 1, | |
| }); | |
| await appendTranscriptMessage(sessionTarget, { | |
| message: { role: "user", content: "persisted prior context", timestamp: 1 }, | |
| cwd: dir, | |
| }); | |
| const actualRecorder = await vi.importActual< | |
| typeof import("./cli-backend-dispatch-transcript.js") | |
| >("./cli-backend-dispatch-transcript.js"); | |
| createCliDispatchTranscriptRecorder.mockImplementationOnce( | |
| actualRecorder.createCliDispatchTranscriptRecorder, | |
| ); | |
| runCliAgent.mockImplementationOnce(async (cliParams: RunCliAgentParams) => { | |
| expect | |
| .soft(await loadCliSessionHistoryMessages({ sessionTarget: cliParams.sessionTarget })) | |
| .toEqual( | |
| expect.arrayContaining([expect.objectContaining({ content: "persisted prior context" })]), | |
| ); | |
| return cliRunResult(); | |
| }); | |
| await runEmbeddedAgentViaCliBackendIfEligible( | |
| baseRunParams({ sessionTarget, workspaceDir: dir }), | |
| ); | |
| expect(loadTranscriptEventsSync(sessionTarget)).toEqual( | |
| expect.arrayContaining([ | |
| expect.objectContaining({ | |
| message: expect.objectContaining({ | |
| role: "user", | |
| content: [{ type: "text", text: "recall prompt" }], | |
| }), | |
| }), | |
| expect.objectContaining({ | |
| message: expect.objectContaining({ | |
| role: "assistant", | |
| content: [{ type: "text", text: "recall summary" }], | |
| }), | |
| }), | |
| ]), | |
| ); | |
| }); | |
| it("maps the embedded run onto a one-shot restricted CLI run", async () => { | |
| runCliAgent.mockResolvedValue(cliRunResult()); | |
| const sessionTarget = { | |
| agentId: "main", | |
| sessionId: "recall-session", | |
| sessionKey: "agent:main:recall", | |
| storePath: "/tmp/recall/custom/openclaw-agent.sqlite", | |
| }; | |
| const params = baseRunParams({ | |
| sessionTarget, | |
| toolsAllow: ["memory_search", "memory_get", "notes_retrieve_context"], | |
| fastMode: "auto", | |
| fastModeStartedAtMs: 1000, | |
| fastModeAutoOnSeconds: 15, | |
| authProfileId: "selected-subscription-profile", | |
| }); | |
| const result = await runEmbeddedAgentViaCliBackendIfEligible(params); | |
| expect(result?.payloads?.[0]?.text).toBe("recall summary"); | |
| expect(runCliAgent).toHaveBeenCalledTimes(1); | |
| const cliParams = runCliAgent.mock.calls[0]?.[0]; | |
| expect(cliParams?.sessionTarget).toBe(sessionTarget); | |
| expect(cliParams).toMatchObject({ | |
| provider: "claude-cli", | |
| model: "claude-opus-4-8", | |
| sessionFile: "/tmp/recall/session.jsonl", | |
| timeoutMs: 30_000, | |
| runTimeoutOverrideMs: 30_000, | |
| fastMode: "auto", | |
| fastModeStartedAtMs: 1000, | |
| fastModeAutoOnSeconds: 15, | |
| authProfileId: "selected-subscription-profile", | |
| disableCliLiveSession: true, | |
| cleanupCliLiveSessionOnRunEnd: true, | |
| requireExplicitMessageTarget: true, | |
| cliToolAvailability: { | |
| native: [], | |
| openClaw: ["memory_search", "memory_get", "notes_retrieve_context"], | |
| }, | |
| }); | |
| // Embedded toolsAllow must never reach the CLI runner: it fails closed. | |
| expect(cliParams).not.toHaveProperty("toolsAllow"); | |
| }); | |
| it.each(["group", "channel"] as const)( | |
| "forwards authoritative %s type through embedded-to-CLI dispatch for opaque keys", | |
| async (chatType) => { | |
| await runEmbeddedAgentViaCliBackendIfEligible( | |
| baseRunParams({ | |
| sessionKey: "agent:main:opaque:binding", | |
| chatType, | |
| }), | |
| ); | |
| expect(runCliAgent.mock.calls[0]?.[0]).toMatchObject({ | |
| sessionKey: "agent:main:opaque:binding", | |
| chatType, | |
| }); | |
| }, | |
| ); | |
| // Fail-closed tool policy: only a non-empty named allowlist is expressible | |
| // on the CLI surface. Every other embedded tool state keeps the passthrough | |
| // so no closed state silently widens. | |
| it.each([ | |
| ["a wildcard allowlist", { toolsAllow: ["*"] }], | |
| ["a mixed wildcard allowlist", { toolsAllow: ["memory_search", "*"] }], | |
| ["a deny-all allowlist", { toolsAllow: [] }], | |
| ["no allowlist", { toolsAllow: undefined }], | |
| ["disableTools", { disableTools: true }], | |
| ["a raw model run", { modelRun: true }], | |
| ] as const)("refuses dispatch for %s", async (_label, overrides) => { | |
| expect( | |
| await runEmbeddedAgentViaCliBackendIfEligible( | |
| baseRunParams(overrides as Partial<CliDispatchParams>), | |
| ), | |
| ).toBeUndefined(); | |
| expect(runCliAgent).not.toHaveBeenCalled(); | |
| }); | |
| it("invokes onExecutionStarted once at the dispatch boundary", async () => { | |
| runCliAgent.mockResolvedValue(cliRunResult()); | |
| const onExecutionStarted = vi.fn(); | |
| await runEmbeddedAgentViaCliBackendIfEligible( | |
| baseRunParams({ onExecutionStarted, lifecycleGeneration: "gen-1" }), | |
| ); | |
| expect(onExecutionStarted).toHaveBeenCalledTimes(1); | |
| expect(onExecutionStarted).toHaveBeenCalledWith({ lifecycleGeneration: "gen-1" }); | |
| }); | |
| it("retains the prepared vision capability with ordered prompt images and media", async () => { | |
| const images = [{ type: "image" as const, data: "aGVsbG8=", mimeType: "image/png" }]; | |
| const imageOrder = ["inline" as const]; | |
| const media = [{ path: "/tmp/recall.png", contentType: "image/png" }]; | |
| await runEmbeddedAgentViaCliBackendIfEligible( | |
| baseRunParams({ modelHasVision: true, images, imageOrder, media }), | |
| ); | |
| expect(runCliAgent.mock.calls[0]?.[0]).toMatchObject({ | |
| prompt: "recall prompt", | |
| modelHasVision: true, | |
| images, | |
| imageOrder, | |
| media, | |
| }); | |
| }); | |
| it("forwards execution phases from the CLI backend", async () => { | |
| const onExecutionPhase = vi.fn(); | |
| runCliAgent.mockImplementation( | |
| async (cliParams: { onExecutionPhase?: CliDispatchParams["onExecutionPhase"] }) => { | |
| cliParams.onExecutionPhase?.({ | |
| phase: "model_call_started", | |
| provider: "anthropic", | |
| model: "claude-opus-4-8", | |
| backend: "claude-cli", | |
| firstModelCallStarted: true, | |
| }); | |
| return cliRunResult(); | |
| }, | |
| ); | |
| await runEmbeddedAgentViaCliBackendIfEligible(baseRunParams({ onExecutionPhase })); | |
| expect(onExecutionPhase).toHaveBeenCalledWith({ | |
| phase: "model_call_started", | |
| provider: "anthropic", | |
| model: "claude-opus-4-8", | |
| backend: "claude-cli", | |
| firstModelCallStarted: true, | |
| }); | |
| }); | |
| it("bridges CLI tool result events to onAgentToolResult without the MCP prefix", async () => { | |
| const observed: Array<{ toolName: string; isError: boolean }> = []; | |
| const params = baseRunParams({ | |
| toolsAllow: ["memory_search"], | |
| onAgentToolResult: (event) => { | |
| observed.push({ toolName: event.toolName, isError: event.isError }); | |
| }, | |
| }); | |
| runCliAgent.mockImplementation(async (cliParams: { runId: string }) => { | |
| emitAgentEvent({ | |
| runId: cliParams.runId, | |
| stream: "tool", | |
| data: { | |
| phase: "result", | |
| name: "mcp__openclaw__memory_search", | |
| result: { content: [] }, | |
| isError: false, | |
| resultContentSource: "network", | |
| }, | |
| }); | |
| // Soft tool failures must surface as isError like the native path. | |
| emitAgentEvent({ | |
| runId: cliParams.runId, | |
| stream: "tool", | |
| data: { | |
| phase: "result", | |
| name: "mcp__openclaw__memory_get", | |
| result: { details: { status: "error" } }, | |
| isError: false, | |
| }, | |
| }); | |
| emitAgentEvent({ | |
| runId: "other-run", | |
| stream: "tool", | |
| data: { phase: "result", name: "mcp__openclaw__memory_get", isError: true }, | |
| }); | |
| return cliRunResult(); | |
| }); | |
| await runEmbeddedAgentViaCliBackendIfEligible(params); | |
| expect(observed).toEqual([ | |
| { toolName: "memory_search", isError: false }, | |
| { toolName: "memory_get", isError: true }, | |
| ]); | |
| // The bridge must not outlive the run. | |
| emitAgentEvent({ | |
| runId: params.runId, | |
| stream: "tool", | |
| data: { phase: "result", name: "mcp__openclaw__memory_search", isError: false }, | |
| }); | |
| expect(observed).toHaveLength(2); | |
| }); | |
| it.each([true, undefined])( | |
| "delegates MCP lifetime policy %s to the CLI settlement owner", | |
| async (cleanupBundleMcpOnRunEnd) => { | |
| runCliAgent.mockResolvedValue(cliRunResult()); | |
| await runEmbeddedAgentViaCliBackendIfEligible(baseRunParams({ cleanupBundleMcpOnRunEnd })); | |
| expect(runCliAgent.mock.calls[0]?.[0]?.cleanupBundleMcpOnRunEnd).toBe( | |
| cleanupBundleMcpOnRunEnd, | |
| ); | |
| expect(retireSessionMcpRuntime).not.toHaveBeenCalled(); | |
| expect(retireSessionMcpRuntimeForSessionKey).not.toHaveBeenCalled(); | |
| }, | |
| ); | |
| it("mirrors the run into the transcript recorder", async () => { | |
| runCliAgent.mockImplementation(async (cliParams: { runId: string }) => { | |
| emitAgentEvent({ | |
| runId: cliParams.runId, | |
| stream: "assistant", | |
| data: { text: "partial answer" }, | |
| }); | |
| emitAgentEvent({ | |
| runId: cliParams.runId, | |
| stream: "tool", | |
| data: { | |
| phase: "start", | |
| name: "mcp__openclaw__memory_search", | |
| toolCallId: "call-1", | |
| args: { query: "wings" }, | |
| }, | |
| }); | |
| emitAgentEvent({ | |
| runId: cliParams.runId, | |
| stream: "tool", | |
| data: { | |
| phase: "result", | |
| name: "mcp__openclaw__memory_search", | |
| toolCallId: "call-1", | |
| result: { content: [] }, | |
| isError: false, | |
| resultContentSource: "network", | |
| }, | |
| }); | |
| return cliRunResult(); | |
| }); | |
| await runEmbeddedAgentViaCliBackendIfEligible(baseRunParams({ toolsAllow: ["memory_search"] })); | |
| expect(createCliDispatchTranscriptRecorder).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| sessionFile: "/tmp/recall/session.jsonl", | |
| sessionId: "recall-session", | |
| prompt: "recall prompt", | |
| provider: "claude-cli", | |
| }), | |
| ); | |
| expect(transcriptRecorder.noteAssistantText).toHaveBeenCalledWith("partial answer"); | |
| expect(transcriptRecorder.noteToolEvent).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| phase: "start", | |
| toolName: "memory_search", | |
| toolCallId: "call-1", | |
| args: { query: "wings" }, | |
| }), | |
| ); | |
| expect(transcriptRecorder.noteToolEvent).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| phase: "result", | |
| toolName: "memory_search", | |
| resultContentSource: "network", | |
| }), | |
| ); | |
| expect(transcriptRecorder.finalize).toHaveBeenCalledWith("recall summary"); | |
| }); | |
| it("flushes the assistant snapshot the moment the run aborts", async () => { | |
| const abortController = new AbortController(); | |
| runCliAgent.mockImplementation(async () => { | |
| abortController.abort(new Error("recall timeout")); | |
| expect(transcriptRecorder.flushAssistantSnapshot).toHaveBeenCalledTimes(1); | |
| throw new Error("aborted"); | |
| }); | |
| await expect( | |
| runEmbeddedAgentViaCliBackendIfEligible( | |
| baseRunParams({ abortSignal: abortController.signal }), | |
| ), | |
| ).rejects.toThrow("aborted"); | |
| expect(transcriptRecorder.finalize).toHaveBeenCalledWith(undefined); | |
| }); | |
| it("finalizes the transcript when the CLI run fails", async () => { | |
| runCliAgent.mockRejectedValue(new Error("boom")); | |
| await expect(runEmbeddedAgentViaCliBackendIfEligible(baseRunParams())).rejects.toThrow("boom"); | |
| expect(transcriptRecorder.finalize).toHaveBeenCalledWith(undefined); | |
| }); | |
| it("drops CLI session bindings from the run result", async () => { | |
| runCliAgent.mockResolvedValue( | |
| cliRunResult({ | |
| agentMeta: { | |
| sessionId: "native-session", | |
| cliSessionBinding: { sessionId: "native-session" }, | |
| }, | |
| } as Partial<EmbeddedAgentRunResult["meta"]>), | |
| ); | |
| const result = await runEmbeddedAgentViaCliBackendIfEligible(baseRunParams()); | |
| expect(result?.meta.agentMeta?.cliSessionBinding).toBeUndefined(); | |
| }); | |
| }); | |
| describe("detached CLI transcript ownership", () => { | |
| it.each([false, true])( | |
| "does not mirror a detached turn into durable metadata (existing: %s)", | |
| async (existing) => { | |
| const root = tempDirs.make("openclaw-detached-cli-"); | |
| const storePath = path.join(root, "final", "openclaw-agent.sqlite"); | |
| const params = baseRunParams({ | |
| agentId: "main", | |
| sessionPersistence: "detached", | |
| sessionManager: SessionManager.inMemory(root), | |
| sessionTarget: { | |
| agentId: "main", | |
| sessionId: "recall-session", | |
| sessionKey: "agent:main:recall", | |
| storePath, | |
| }, | |
| }); | |
| if (existing) { | |
| await upsertSessionEntryCore(params.sessionTarget, { | |
| sessionId: params.sessionId, | |
| updatedAt: 1, | |
| liveModelSwitchPending: true, | |
| }); | |
| } | |
| const before = loadSessionEntryReadOnly(params.sessionTarget); | |
| const recorder = await vi.importActual<typeof import("./cli-backend-dispatch-transcript.js")>( | |
| "./cli-backend-dispatch-transcript.js", | |
| ); | |
| createCliDispatchTranscriptRecorder.mockImplementationOnce( | |
| recorder.createCliDispatchTranscriptRecorder, | |
| ); | |
| await expect(runEmbeddedAgentViaCliBackendIfEligible(params)).resolves.toMatchObject({ | |
| payloads: [{ text: "recall summary" }], | |
| }); | |
| expect(runCliAgent).toHaveBeenCalledOnce(); | |
| expect(loadSessionEntryReadOnly(params.sessionTarget)).toEqual(before); | |
| if (!existing) { | |
| await expect(fs.access(path.dirname(storePath))).rejects.toThrow(); | |
| } else { | |
| expect(loadTranscriptEventsSync(params.sessionTarget)).toEqual([]); | |
| } | |
| }, | |
| ); | |
| }); | |