| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| |
| import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; |
| import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { persistAcpDispatchTranscript } from "../../auto-reply/reply/dispatch-acp-transcript.runtime.js"; |
| import type { SessionEntry } from "../../config/sessions.js"; |
| import { |
| formatSqliteSessionFileMarker, |
| parseSqliteSessionFileMarker, |
| } from "../../config/sessions/legacy-sqlite-marker.js"; |
| import { |
| appendTranscriptMessage, |
| listSessionEntriesCore, |
| loadTranscriptEvents, |
| replaceSessionEntry, |
| } from "../../config/sessions/session-accessor.js"; |
| import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js"; |
| import { applyAssistantDeliveryDirectives } from "../../config/sessions/transcript-assistant-delivery.js"; |
| import type { OpenClawConfig } from "../../config/types.openclaw.js"; |
| import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js"; |
| import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js"; |
| import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js"; |
| import { createDeferredCore } from "../../shared/deferred.js"; |
| import { |
| disposeOpenClawAgentDatabaseByPath, |
| runOpenClawAgentWriteTransaction, |
| } from "../../state/openclaw-agent-db.js"; |
| import { listOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.test-support.js"; |
| import { registerGeneratedMediaTaskActivity } from "../../tasks/generated-media-task-activity.js"; |
| import { resetGeneratedMediaTaskActivityForTests } from "../../tasks/task-runtime.test-helpers.js"; |
| import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js"; |
| import { captureEnv, setTestEnvValue } from "../../test-utils/env.js"; |
| import { createTestPreparedRunAdmission } from "../admitted-run-context.test-support.js"; |
| import { buildAgentRunTerminalOutcomeFromLifecycleEvent } from "../agent-run-terminal-outcome.js"; |
| import { |
| createApiKeyCredential, |
| createAuthProfileStoreFixture, |
| } from "../auth-profiles/credential-fixtures.test-support.js"; |
| import { clearRuntimeAuthProfileStoreSnapshots } from "../auth-profiles/runtime-snapshots.js"; |
| import { saveAuthProfileStore } from "../auth-profiles/store-runtime.js"; |
| import { testing as cliBackendsTesting } from "../cli-backends.test-support.js"; |
| import { buildPreparedCliRunContext } from "../cli-runner.test-helpers.js"; |
| import { buildCliRunResult } from "../cli-runner/cli-run-settlement.js"; |
| import { buildCliMcpGrantContext } from "../cli-runner/mcp-grant-context.js"; |
| import type { RunCliAgentParams } from "../cli-runner/types.js"; |
| import { createCronCreatorAuthorityCapability } from "../cron-creator-authority-context.js"; |
| import { classifyEmbeddedAgentRunResultForModelFallback } from "../embedded-agent-runner/result-fallback-classifier.js"; |
| import type { RunEmbeddedAgentInternalParams } from "../embedded-agent-runner/run/internal-params.js"; |
| import type { EmbeddedAgentRunResult } from "../embedded-agent.js"; |
| import { FailoverError } from "../failover-error.js"; |
| import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../failover/user-copy.js"; |
| import type { ModelFallbackAttemptProvenance } from "../model-fallback.types.js"; |
| import { installSessionPlacementAdmissionProvider } from "../session-placement-admission.js"; |
| import { attachToolAllowlistIntersection } from "../tool-policy.js"; |
| import { createAgentAttemptLifecycleCallbacks } from "./attempt-callbacks.js"; |
| import { |
| persistAcpTurnTranscript, |
| persistCliTurnTranscript, |
| runAgentAttempt as runAgentAttemptImpl, |
| } from "./attempt-execution.js"; |
| import { resolveClaudeCliProjectDirForWorkspace } from "./claude-cli-project-dir.js"; |
|
|
| type RunAgentAttemptParams = Parameters<typeof runAgentAttemptImpl>[0]; |
| const SUBAGENT_ANNOUNCE_CHILD_SESSION_KEY = "agent:main:subagent:child"; |
| const SUBAGENT_ANNOUNCE_REQUESTER_TOOLS = ["read", "exec", "sessions_spawn", "message"]; |
|
|
| function createSubagentAnnounceHandoffOptions(params: { |
| sourceReplyDeliveryMode: "automatic" | "message_tool_only"; |
| targetSessionKey: string; |
| targetSessionId: string; |
| provider: string; |
| model: string; |
| disableMessageTool?: boolean; |
| requireExplicitMessageTarget?: boolean; |
| modelRun?: boolean; |
| promptMode?: "none"; |
| runtimeToolsAllow?: string[]; |
| trustedInternalHandoff?: boolean; |
| }): Partial<RunAgentAttemptParams["opts"]> { |
| return { |
| sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, |
| ...(params.disableMessageTool ? { disableMessageTool: true } : {}), |
| ...(params.requireExplicitMessageTarget ? { requireExplicitMessageTarget: true } : {}), |
| ...(params.modelRun ? { modelRun: true } : {}), |
| ...(params.promptMode ? { promptMode: params.promptMode } : {}), |
| toolsAllow: params.runtimeToolsAllow ?? [...SUBAGENT_ANNOUNCE_REQUESTER_TOOLS], |
| ...(params.trustedInternalHandoff === false |
| ? {} |
| : { |
| trustedInternalHandoff: { |
| kind: "subagent-completion" as const, |
| sourceSessionKey: SUBAGENT_ANNOUNCE_CHILD_SESSION_KEY, |
| sourceSessionId: "subagent-announce-child", |
| targetSessionKey: params.targetSessionKey, |
| targetSessionId: params.targetSessionId, |
| provider: params.provider, |
| model: params.model, |
| }, |
| }), |
| inputProvenance: { |
| kind: "inter_session", |
| sourceSessionKey: SUBAGENT_ANNOUNCE_CHILD_SESSION_KEY, |
| sourceChannel: "internal", |
| sourceTool: "subagent_announce", |
| }, |
| internalEvents: [ |
| { |
| type: "task_completion", |
| source: "subagent", |
| childSessionKey: SUBAGENT_ANNOUNCE_CHILD_SESSION_KEY, |
| childSessionId: "subagent-announce-child", |
| announceType: "subagent task", |
| taskLabel: "review", |
| status: "ok", |
| statusLabel: "completed", |
| result: "child output", |
| replyInstruction: "Relay this completion.", |
| }, |
| ], |
| }; |
| } |
|
|
| type SubagentAnnounceDeliveryCase = { |
| name: string; |
| sourceReplyDeliveryMode: "automatic" | "message_tool_only"; |
| disableMessageTool: boolean; |
| requireExplicitMessageTarget?: boolean; |
| modelRun?: boolean; |
| promptMode?: "none"; |
| inheritedToolAllow?: readonly string[]; |
| inheritedToolDeny?: readonly string[]; |
| runtimeToolsAllow?: string[]; |
| operatorTools?: OpenClawConfig["tools"]; |
| sandboxMode?: "off" | "non-main" | "all"; |
| trustedInternalHandoff?: boolean; |
| expectedDisableTools: boolean; |
| expectedToolsAllow?: readonly string[]; |
| }; |
|
|
| const SUBAGENT_ANNOUNCE_DELIVERY_CASES: readonly SubagentAnnounceDeliveryCase[] = [ |
| { |
| name: "automatic source replies", |
| sourceReplyDeliveryMode: "automatic" as const, |
| disableMessageTool: false, |
| expectedDisableTools: true, |
| }, |
| { |
| name: "message-tool-only source replies", |
| sourceReplyDeliveryMode: "message_tool_only" as const, |
| disableMessageTool: false, |
| expectedDisableTools: false, |
| expectedToolsAllow: ["message"], |
| }, |
| { |
| name: "message-tool-only source replies requiring an explicit target", |
| sourceReplyDeliveryMode: "message_tool_only" as const, |
| disableMessageTool: false, |
| requireExplicitMessageTarget: true, |
| expectedDisableTools: false, |
| expectedToolsAllow: ["message"], |
| }, |
| { |
| name: "an explicitly disabled message tool", |
| sourceReplyDeliveryMode: "message_tool_only" as const, |
| disableMessageTool: true, |
| expectedDisableTools: true, |
| }, |
| { |
| name: "a coding profile with a source-bound message grant", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| inheritedToolAllow: ["read", "exec", "sessions_spawn"], |
| operatorTools: { profile: "coding" }, |
| expectedDisableTools: false, |
| expectedToolsAllow: ["message"], |
| }, |
| { |
| name: "an operator allowlist with a source-bound message grant", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| operatorTools: { allow: ["read", "exec"] }, |
| expectedDisableTools: false, |
| expectedToolsAllow: ["message"], |
| }, |
| { |
| name: "an inherited explicit message deny", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| inheritedToolAllow: ["*"], |
| inheritedToolDeny: ["message"], |
| expectedDisableTools: true, |
| }, |
| { |
| name: "a current operator message deny", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| operatorTools: { deny: ["message"] }, |
| expectedDisableTools: true, |
| }, |
| { |
| name: "an active sandbox message deny", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| operatorTools: { sandbox: { tools: { deny: ["message"] } } }, |
| sandboxMode: "all", |
| expectedDisableTools: true, |
| }, |
| { |
| name: "a non-main sandbox message deny", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| operatorTools: { sandbox: { tools: { deny: ["message"] } } }, |
| sandboxMode: "non-main", |
| expectedDisableTools: true, |
| }, |
| { |
| name: "an inactive sandbox message deny", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| operatorTools: { sandbox: { tools: { deny: ["message"] } } }, |
| sandboxMode: "off", |
| expectedDisableTools: false, |
| expectedToolsAllow: ["message"], |
| }, |
| { |
| name: "a runtime allowlist excluding message", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| runtimeToolsAllow: ["read", "exec"], |
| expectedDisableTools: true, |
| }, |
| { |
| name: "an empty runtime allowlist", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| runtimeToolsAllow: [], |
| expectedDisableTools: true, |
| }, |
| { |
| name: "an intersected runtime allowlist excluding message", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| runtimeToolsAllow: attachToolAllowlistIntersection(["*", "message"], [["*"], ["read"]]), |
| expectedDisableTools: true, |
| }, |
| { |
| name: "an authorized messaging tool group", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| inheritedToolAllow: ["group:messaging"], |
| runtimeToolsAllow: ["group:messaging"], |
| operatorTools: { profile: "coding" }, |
| expectedDisableTools: false, |
| expectedToolsAllow: ["message"], |
| }, |
| { |
| name: "an untrusted completion handoff", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| trustedInternalHandoff: false, |
| expectedDisableTools: true, |
| }, |
| ]; |
|
|
| const SUBAGENT_ANNOUNCE_EMBEDDED_DELIVERY_CASES: readonly SubagentAnnounceDeliveryCase[] = [ |
| ...SUBAGENT_ANNOUNCE_DELIVERY_CASES.map((testCase) => { |
| if (testCase.name === "automatic source replies") { |
| return { |
| ...testCase, |
| expectedDisableTools: false, |
| expectedToolsAllow: SUBAGENT_ANNOUNCE_REQUESTER_TOOLS, |
| }; |
| } |
| if (!testCase.expectedDisableTools) { |
| return { |
| ...testCase, |
| expectedToolsAllow: testCase.runtimeToolsAllow ?? SUBAGENT_ANNOUNCE_REQUESTER_TOOLS, |
| }; |
| } |
| return testCase; |
| }), |
| { |
| name: "a raw model run despite message-tool-only delivery", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| modelRun: true, |
| expectedDisableTools: true, |
| }, |
| { |
| name: "prompt mode none despite message-tool-only delivery", |
| sourceReplyDeliveryMode: "message_tool_only", |
| disableMessageTool: false, |
| promptMode: "none", |
| expectedDisableTools: true, |
| }, |
| ]; |
|
|
| const runAgentAttempt = (params: RunAgentAttemptOverrides) => |
| runAgentAttemptImpl(makeRunAgentAttemptParams(params)); |
|
|
| type RunAgentAttemptOverrides = Omit< |
| Partial<RunAgentAttemptParams>, |
| | "agentDir" |
| | "modelRoutingProvenance" |
| | "opts" |
| | "runContext" |
| | "sessionEntry" |
| | "sessionKey" |
| | "workspaceDir" |
| > & { |
| agentDir: RunAgentAttemptParams["agentDir"]; |
| modelRoutingProvenance?: ModelFallbackAttemptProvenance; |
| sessionEntry: NonNullable<RunAgentAttemptParams["sessionEntry"]>; |
| sessionKey: NonNullable<RunAgentAttemptParams["sessionKey"]>; |
| workspaceDir: RunAgentAttemptParams["workspaceDir"]; |
| opts?: Partial<RunAgentAttemptParams["opts"]>; |
| runContext?: Partial<RunAgentAttemptParams["runContext"]>; |
| }; |
|
|
| function makeRunAgentAttemptParams(overrides: RunAgentAttemptOverrides): RunAgentAttemptParams { |
| const provider = overrides.providerOverride ?? "openai"; |
| const model = overrides.modelOverride ?? "gpt-5.4"; |
| const isFallbackRetry = overrides.isFallbackRetry ?? false; |
| const runId = overrides.runId ?? `run-${overrides.sessionEntry.sessionId}`; |
| const modelRoutingProvenance: ModelFallbackAttemptProvenance = |
| overrides.modelRoutingProvenance ?? { |
| requestedProvider: overrides.originalProvider ?? provider, |
| requestedModel: model, |
| stage: isFallbackRetry ? "fallback" : "initial", |
| }; |
| return { |
| providerOverride: provider, |
| originalProvider: provider, |
| modelOverride: model, |
| cfg: {} as OpenClawConfig, |
| sessionId: overrides.sessionEntry.sessionId, |
| sessionAgentId: "main", |
| sessionFile: path.join(overrides.workspaceDir, "session.jsonl"), |
| body: "continue", |
| isFallbackRetry, |
| resolvedThinkLevel: "medium", |
| timeoutMs: 1_000, |
| runId, |
| spawnedBy: undefined, |
| messageChannel: undefined, |
| skillsSnapshot: undefined, |
| resolvedVerboseLevel: undefined, |
| onAgentEvent: vi.fn(), |
| authProfileProvider: provider, |
| sessionHasHistory: false, |
| ...overrides, |
| modelRoutingProvenance, |
| pluginGeneration: overrides.pluginGeneration, |
| preparedRunAdmission: overrides.preparedRunAdmission ?? createTestPreparedRunAdmission(runId), |
| lifecycleGeneration: overrides.lifecycleGeneration ?? getAgentEventLifecycleGeneration(), |
| opts: { ...overrides.opts } as RunAgentAttemptParams["opts"], |
| runContext: { ...overrides.runContext } as RunAgentAttemptParams["runContext"], |
| }; |
| } |
|
|
| const runCliAgentMock = vi.hoisted(() => vi.fn()); |
| const runEmbeddedAgentMock = vi.hoisted(() => vi.fn()); |
| const hasClaudeSessionMock = vi.hoisted(() => vi.fn(() => false)); |
| const providerAuthAliasMocks = vi.hoisted(() => ({ |
| resolveProviderAuthAliasMap: vi.fn(() => ({})), |
| resolveProviderIdForAuth: vi.fn( |
| ( |
| provider: string, |
| params?: { |
| metadataSnapshot?: { |
| plugins?: readonly { providerAuthAliases?: Record<string, string> }[]; |
| }; |
| }, |
| ) => { |
| const normalized = provider.trim().toLowerCase(); |
| for (const plugin of params?.metadataSnapshot?.plugins ?? []) { |
| const alias = plugin.providerAuthAliases?.[normalized]?.trim(); |
| if (alias) { |
| return alias.toLowerCase(); |
| } |
| } |
| return ["codex-cli", "openai"].includes(normalized) ? "openai" : normalized; |
| }, |
| ), |
| })); |
| vi.mock("../cli-runner.js", () => ({ |
| runCliAgent: runCliAgentMock, |
| })); |
|
|
| vi.mock("../cli-runner/cli-live-session-registry.js", () => ({ |
| getCliLiveSessionGeneration: vi.fn(() => undefined), |
| hasCliLiveSession: hasClaudeSessionMock, |
| })); |
|
|
| vi.mock("../model-selection.js", async () => ({ |
| ...(await vi.importActual<typeof import("../model-selection.js")>("../model-selection.js")), |
| isCliProvider: (provider: string, _cfg?: OpenClawConfig) => { |
| const normalized = provider.trim().toLowerCase(); |
| return ( |
| normalized === "claude-cli" || |
| normalized === "codex-cli" || |
| normalized === "google-gemini-cli" |
| ); |
| }, |
| normalizeProviderId: (provider: string) => provider.trim().toLowerCase(), |
| })); |
|
|
| vi.mock("../provider-auth-aliases.js", () => ({ |
| resolveProviderAuthAliasMap: providerAuthAliasMocks.resolveProviderAuthAliasMap, |
| resolveProviderIdForAuth: providerAuthAliasMocks.resolveProviderIdForAuth, |
| })); |
|
|
| vi.mock("../model-runtime-aliases.js", async () => { |
| const actual = await vi.importActual<typeof import("../model-runtime-aliases.js")>( |
| "../model-runtime-aliases.js", |
| ); |
| return { |
| ...actual, |
| resolveCliRuntimeExecutionProvider: ({ |
| provider, |
| cfg, |
| modelId, |
| }: { |
| provider?: string; |
| cfg?: OpenClawConfig; |
| modelId?: string; |
| }) => { |
| const key = provider && modelId ? `${provider}/${modelId}` : undefined; |
| |
| |
| const runtime = key |
| ? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim() |
| : undefined; |
| return runtime || provider; |
| }, |
| }; |
| }); |
|
|
| vi.mock("../embedded-agent.js", () => ({ |
| runEmbeddedAgent: runEmbeddedAgentMock, |
| })); |
|
|
| function makeCliResult(text: string, sessionId = "session-cli"): EmbeddedAgentRunResult { |
| return { |
| payloads: [{ text }], |
| meta: { |
| durationMs: 5, |
| finalAssistantVisibleText: text, |
| agentMeta: { |
| sessionId, |
| ...(sessionId ? { cliSessionBinding: { sessionId } } : {}), |
| provider: "claude-cli", |
| model: "opus", |
| usage: { |
| input: 12, |
| output: 4, |
| cacheRead: 3, |
| cacheWrite: 0, |
| total: 19, |
| }, |
| lastCallUsage: { |
| input: 12, |
| output: 4, |
| cacheRead: 3, |
| cacheWrite: 0, |
| total: 19, |
| }, |
| }, |
| executionTrace: { |
| winnerProvider: "claude-cli", |
| winnerModel: "opus", |
| fallbackUsed: false, |
| runner: "cli", |
| }, |
| }, |
| }; |
| } |
|
|
| function makeSessionEntry(sessionId: string, overrides: Partial<SessionEntry> = {}): SessionEntry { |
| return { sessionId, updatedAt: Date.now(), ...overrides }; |
| } |
|
|
| async function persistCliTranscriptEntry( |
| params: Parameters<typeof persistCliTurnTranscript>[0], |
| ): Promise<SessionEntry | undefined> { |
| const result = await persistCliTurnTranscript(params); |
| if (result.kind !== "persisted") { |
| throw new Error("expected CLI transcript persistence to keep the current session"); |
| } |
| return result.sessionEntry; |
| } |
|
|
| type TranscriptReadTarget = |
| | string |
| | { agentId: string; sessionId: string; sessionKey: string; storePath: string }; |
|
|
| async function readSessionMessages(target: TranscriptReadTarget) { |
| return (await readTranscriptEntries(target)) |
| .filter((entry) => entry.type === "message") |
| .map( |
| (entry) => |
| entry.message as { |
| role?: string; |
| content?: unknown; |
| provider?: string; |
| model?: string; |
| usage?: unknown; |
| }, |
| ); |
| } |
|
|
| async function readSessionFileEntries(target: TranscriptReadTarget) { |
| return await readTranscriptEntries<{ |
| type?: string; |
| id?: string; |
| parentId?: string | null; |
| cwd?: string; |
| message?: { role?: string }; |
| }>(target); |
| } |
|
|
| async function readTranscriptEntries<T extends { type?: string; message?: unknown }>( |
| target: TranscriptReadTarget, |
| ): Promise<T[]> { |
| if (typeof target !== "string") { |
| return (await loadTranscriptEvents(target)) as T[]; |
| } |
| const sessionFile = target; |
| const marker = parseSqliteSessionFileMarker(sessionFile); |
| if (marker) { |
| return (await loadTranscriptEvents({ |
| agentId: marker.agentId, |
| sessionId: marker.sessionId, |
| storePath: marker.storePath, |
| })) as T[]; |
| } |
| |
| |
| const raw = await fs.readFile(sessionFile, "utf-8"); |
| const entries: T[] = []; |
| for (const line of raw.split(/\r?\n/)) { |
| if (line.length === 0) { |
| continue; |
| } |
| entries.push(JSON.parse(line) as T); |
| } |
| return entries; |
| } |
|
|
| const requireRecord = createRequireRecord("object", "label-not-object"); |
|
|
| function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) { |
| for (const [key, value] of Object.entries(fields)) { |
| expect(record[key]).toEqual(value); |
| } |
| } |
|
|
| function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex: number, label: string) { |
| const arg = mock.mock.calls[callIndex]?.[0]; |
| if (arg === undefined) { |
| throw new Error(`Expected mock argument for ${label}`); |
| } |
| return requireRecord(arg, label); |
| } |
|
|
| function expectMockArgFields( |
| mock: ReturnType<typeof vi.fn>, |
| fields: Record<string, unknown>, |
| callIndex = 0, |
| ) { |
| expectRecordFields(requireMockArg(mock, callIndex, "mock call argument"), fields); |
| } |
|
|
| function firstRunCliAgentArg(callIndex = 0) { |
| return requireMockArg(runCliAgentMock, callIndex, "run CLI agent argument"); |
| } |
|
|
| function firstEmbeddedAgentArg(callIndex = 0) { |
| return requireMockArg(runEmbeddedAgentMock, callIndex, "embedded OpenClaw agent argument"); |
| } |
|
|
| describe("CLI attempt execution", () => { |
| const fixtureRoot = createSuiteTempRootTracker({ prefix: "openclaw-cli-attempt-suite-" }); |
| let suiteRoot: string; |
| let agentDir: string; |
| let tmpDir: string; |
| let storePath: string; |
| let homeEnvSnapshot: ReturnType<typeof captureEnv> | undefined; |
|
|
| beforeAll(async () => { |
| suiteRoot = await fixtureRoot.setup(); |
| agentDir = path.join(suiteRoot, "agents", "main", "agent"); |
| storePath = path.join(suiteRoot, "sessions.json"); |
| await fs.mkdir(agentDir, { recursive: true }); |
| }); |
|
|
| async function runOpenClawEmbeddedAttemptForTest(overrides?: { |
| opts?: Partial<RunAgentAttemptParams["opts"]>; |
| config?: OpenClawConfig; |
| subagentAnnounceEnvelope?: Pick< |
| SubagentAnnounceDeliveryCase, |
| "inheritedToolAllow" | "inheritedToolDeny" |
| >; |
| runId?: string; |
| sessionKey?: string; |
| body?: string; |
| transcriptBody?: string; |
| providerOverride?: string; |
| modelOverride?: string; |
| isFallbackRetry?: boolean; |
| fallbackRuntimeState?: RunAgentAttemptParams["fallbackRuntimeState"]; |
| userTurnTranscriptRecorder?: RunAgentAttemptParams["userTurnTranscriptRecorder"]; |
| sessionEntry?: Partial<SessionEntry>; |
| additionalSessionEntries?: Record<string, Partial<SessionEntry>>; |
| configuredAuthProfileId?: string; |
| timeoutMs?: number; |
| runTimeoutOverrideMs?: number; |
| }) { |
| const runId = overrides?.runId ?? "run-embedded-live-stream-gate"; |
| const sessionKey = overrides?.sessionKey ?? `agent:main:direct:${runId}`; |
| const sessionEntry: SessionEntry = { |
| sessionId: `session-${runId}`, |
| updatedAt: Date.now(), |
| ...overrides?.sessionEntry, |
| }; |
| const sessionStore = overrides?.subagentAnnounceEnvelope |
| ? createSubagentAnnounceSessionStore( |
| sessionKey, |
| sessionEntry, |
| overrides.subagentAnnounceEnvelope, |
| ) |
| : { [sessionKey]: sessionEntry }; |
| for (const [additionalSessionKey, additionalEntry] of Object.entries( |
| overrides?.additionalSessionEntries ?? {}, |
| )) { |
| sessionStore[additionalSessionKey] = { |
| sessionId: `${additionalSessionKey}-session`, |
| updatedAt: Date.now(), |
| ...additionalEntry, |
| } as SessionEntry; |
| } |
| await writeSessionStoreSeed(sessionStore); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
| const providerOverride = overrides?.providerOverride ?? "openai"; |
|
|
| await runAgentAttempt({ |
| providerOverride, |
| originalProvider: "openai", |
| modelOverride: overrides?.modelOverride ?? "gpt-5.4", |
| configuredAuthProfileId: overrides?.configuredAuthProfileId, |
| cfg: overrides?.config ?? ({ session: { store: storePath } } as OpenClawConfig), |
| sessionEntry, |
| sessionKey, |
| sessionFile: path.join(tmpDir, `${runId}.jsonl`), |
| workspaceDir: tmpDir, |
| body: overrides?.body ?? "stream gate", |
| transcriptBody: overrides?.transcriptBody, |
| isFallbackRetry: overrides?.isFallbackRetry ?? false, |
| fallbackRuntimeState: overrides?.fallbackRuntimeState, |
| timeoutMs: overrides?.timeoutMs ?? 1_000, |
| runTimeoutOverrideMs: overrides?.runTimeoutOverrideMs, |
| runId, |
| opts: { |
| message: "stream gate", |
| ...overrides?.opts, |
| }, |
| messageChannel: "telegram", |
| agentDir, |
| authProfileProvider: providerOverride, |
| sessionStore, |
| storePath, |
| userTurnTranscriptRecorder: overrides?.userTurnTranscriptRecorder, |
| }); |
|
|
| return firstEmbeddedAgentArg(runEmbeddedAgentMock.mock.calls.length - 1); |
| } |
|
|
| beforeEach(async () => { |
| homeEnvSnapshot = captureEnv(["HOME", "OPENCLAW_STATE_DIR"]); |
| setTestEnvValue("OPENCLAW_STATE_DIR", suiteRoot); |
| tmpDir = await fixtureRoot.make(); |
| runCliAgentMock.mockReset(); |
| runEmbeddedAgentMock.mockReset(); |
| resetGeneratedMediaTaskActivityForTests(); |
| hasClaudeSessionMock.mockReset(); |
| hasClaudeSessionMock.mockReturnValue(false); |
| providerAuthAliasMocks.resolveProviderAuthAliasMap.mockClear(); |
| providerAuthAliasMocks.resolveProviderIdForAuth.mockClear(); |
| cliBackendsTesting.setDepsForTest({ |
| resolvePluginSetupCliBackend: () => undefined, |
| resolvePluginSetupRegistry: () => ({ cliBackends: [] }) as never, |
| resolveRuntimeCliBackends: () => [ |
| { |
| id: "claude-cli", |
| modelProvider: "anthropic", |
| pluginId: "anthropic", |
| config: { command: "claude", forkArg: "--fork-session" }, |
| }, |
| { |
| id: "google-gemini-cli", |
| modelProvider: "google", |
| pluginId: "google", |
| config: { command: "gemini" }, |
| }, |
| ], |
| }); |
| }); |
|
|
| async function writeSessionStoreSeed(sessionStore: Record<string, SessionEntry>): Promise<void> { |
| for (const [sessionKey, entry] of Object.entries(sessionStore)) { |
| await replaceSessionEntry({ sessionKey, storePath }, entry); |
| } |
| } |
|
|
| function runStoredAttempt( |
| overrides: Omit<RunAgentAttemptOverrides, "agentDir" | "storePath" | "workspaceDir">, |
| ) { |
| return runAgentAttempt({ workspaceDir: tmpDir, agentDir, storePath, ...overrides }); |
| } |
|
|
| function createSubagentAnnounceSessionStore( |
| requesterSessionKey: string, |
| requesterSessionEntry: SessionEntry, |
| envelope: Pick<SubagentAnnounceDeliveryCase, "inheritedToolAllow" | "inheritedToolDeny">, |
| ): Record<string, SessionEntry> { |
| return { |
| [requesterSessionKey]: requesterSessionEntry, |
| [SUBAGENT_ANNOUNCE_CHILD_SESSION_KEY]: { |
| sessionId: "subagent-announce-child", |
| updatedAt: Date.now(), |
| spawnedBy: requesterSessionKey, |
| spawnDepth: 1, |
| subagentRole: "leaf", |
| subagentControlScope: "none", |
| inheritedToolPolicyVersion: 1, |
| inheritedToolAllow: [...(envelope.inheritedToolAllow ?? SUBAGENT_ANNOUNCE_REQUESTER_TOOLS)], |
| ...(envelope.inheritedToolDeny |
| ? { inheritedToolDeny: [...envelope.inheritedToolDeny] } |
| : {}), |
| }, |
| }; |
| } |
|
|
| function readSessionStore(): Record<string, SessionEntry> { |
| return Object.fromEntries( |
| listSessionEntriesCore({ storePath }).map(({ entry, sessionKey }) => [sessionKey, entry]), |
| ); |
| } |
|
|
| afterEach(async () => { |
| vi.useRealTimers(); |
| cliBackendsTesting.resetDepsForTest(); |
| clearRuntimeAuthProfileStoreSnapshots(); |
| clearSessionStoreCacheForTest(); |
| for (const database of listOpenClawAgentDatabasesForTest()) { |
| if (!database.path.startsWith(`${suiteRoot}${path.sep}`)) { |
| continue; |
| } |
| runOpenClawAgentWriteTransaction( |
| (fixture) => { |
| fixture.db.exec(` |
| DELETE FROM session_transcript_fts; |
| DELETE FROM session_nodes; |
| DELETE FROM conversations; |
| DELETE FROM auth_profile_store; |
| DELETE FROM auth_profile_state; |
| DELETE FROM cache_entries; |
| `); |
| }, |
| database, |
| { operationLabel: "test.attempt-execution.reset" }, |
| ); |
| } |
| await fs.rm(tmpDir, { recursive: true, force: true }); |
| await fs.rm(storePath, { force: true }); |
| homeEnvSnapshot?.restore(); |
| homeEnvSnapshot = undefined; |
| }); |
|
|
| afterAll(async () => { |
| for (const database of listOpenClawAgentDatabasesForTest()) { |
| if (database.path.startsWith(`${suiteRoot}${path.sep}`)) { |
| disposeOpenClawAgentDatabaseByPath(database.path, { |
| env: { OPENCLAW_STATE_DIR: suiteRoot }, |
| }); |
| } |
| } |
| await fixtureRoot.cleanup(); |
| }); |
|
|
| it("forwards explicit local-agent timeouts while preserving the default when omitted", async () => { |
| const explicitTimeoutMs = 21_600_000; |
| const explicit = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "explicit-local-timeout", |
| timeoutMs: explicitTimeoutMs, |
| runTimeoutOverrideMs: explicitTimeoutMs, |
| }); |
| expect(explicit.timeoutMs).toBe(explicitTimeoutMs); |
| expect(explicit.runTimeoutOverrideMs).toBe(explicitTimeoutMs); |
|
|
| const configuredDefaultMs = 600_000; |
| const inherited = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "inherited-local-timeout", |
| timeoutMs: configuredDefaultMs, |
| }); |
| expect(inherited.timeoutMs).toBe(configuredDefaultMs); |
| expect(inherited.runTimeoutOverrideMs).toBeUndefined(); |
| }); |
|
|
| it("forwards execution admission callbacks to the embedded runtime", async () => { |
| const onExecutionStarted = vi.fn(); |
| const embedded = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "embedded-execution-started", |
| opts: { onExecutionStarted }, |
| }); |
| const callback = embedded.onExecutionStarted; |
|
|
| expect(callback).toBeTypeOf("function"); |
| (callback as (info?: { lifecycleGeneration?: string }) => void)({ |
| lifecycleGeneration: "next-generation", |
| }); |
| expect(onExecutionStarted).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("forwards authoritative channel type to embedded runs with opaque session keys", async () => { |
| const embedded = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "embedded-opaque-channel", |
| sessionKey: "agent:main:opaque:binding", |
| sessionEntry: { chatType: "channel" }, |
| }); |
|
|
| expect(embedded).toMatchObject({ |
| sessionKey: "agent:main:opaque:binding", |
| chatType: "channel", |
| }); |
| }); |
|
|
| it.each(["cli", "embedded"] as const)( |
| "preserves recovered dashboard authoring through the %s runtime without inline capability", |
| async (runtime) => { |
| const sessionKey = "agent:main:dashboard:recovered"; |
| const sessionEntry = makeSessionEntry("recovered-dashboard-session"); |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("recovered")); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 } }); |
|
|
| await runAgentAttempt({ |
| providerOverride: runtime === "cli" ? "claude-cli" : "openai", |
| modelOverride: runtime === "cli" ? "opus" : "gpt-5.4", |
| sessionEntry, |
| sessionKey, |
| sessionStore, |
| storePath, |
| workspaceDir: tmpDir, |
| agentDir, |
| opts: { pinnedWidgetAuthoring: true }, |
| runContext: { replyToMode: "all" }, |
| }); |
|
|
| const run = runtime === "cli" ? firstRunCliAgentArg() : firstEmbeddedAgentArg(); |
| expect(run.pinnedWidgetAuthoring).toBe(true); |
| expect(run.clientCaps).toBeUndefined(); |
| expect(run.replyToMode).toBe("all"); |
| }, |
| ); |
|
|
| async function runClaudeCliAttempt(params: { |
| sessionKey: string; |
| sessionEntry: SessionEntry; |
| sessionStore: Record<string, SessionEntry>; |
| body: string; |
| runId: string; |
| cwd?: string; |
| abortSignal?: AbortSignal; |
| onExecutionStarted?: () => void; |
| onAgentEvent?: RunAgentAttemptParams["onAgentEvent"]; |
| classifyResult?: RunAgentAttemptParams["classifyResult"]; |
| }) { |
| await runAgentAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry: params.sessionEntry, |
| sessionKey: params.sessionKey, |
| workspaceDir: tmpDir, |
| cwd: params.cwd, |
| body: params.body, |
| classifyResult: params.classifyResult, |
| runId: params.runId, |
| opts: { |
| onExecutionStarted: params.onExecutionStarted, |
| abortSignal: params.abortSignal, |
| }, |
| ...(params.onAgentEvent ? { onAgentEvent: params.onAgentEvent } : {}), |
| agentDir, |
| sessionStore: params.sessionStore, |
| storePath, |
| }); |
| } |
|
|
| it.each([true, false, "auto"] as const)( |
| "forwards resolved fast mode %s and its logical turn clock to CLI execution", |
| async (fastMode) => { |
| const sessionKey = "agent:main:fast-cli"; |
| const sessionEntry = makeSessionEntry("session-fast-cli"); |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("fast result")); |
|
|
| await runAgentAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| storePath, |
| agentDir, |
| workspaceDir: tmpDir, |
| body: "fast mode", |
| runId: "fast-cli-run", |
| fastMode, |
| fastModeStartedAtMs: 1000, |
| fastModeAutoOnSeconds: 15, |
| }); |
|
|
| expect(firstRunCliAgentArg()).toMatchObject({ |
| fastMode, |
| fastModeStartedAtMs: 1000, |
| fastModeAutoOnSeconds: 15, |
| }); |
| }, |
| ); |
|
|
| it.each(["assistant_output_started", "tool_execution_started"] as const)( |
| "keeps CLI admission separate from observed %s", |
| async (phase) => { |
| const sessionKey = "agent:main:direct:cli-execution-started"; |
| const sessionEntry = makeSessionEntry("session-cli-execution-started"); |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| const onExecutionStarted = vi.fn(); |
| const onRuntimeTurnStarted = vi.fn(); |
| const callbacks = createAgentAttemptLifecycleCallbacks( |
| { |
| currentTurnUserMessagePersisted: false, |
| lifecycleFinishing: false, |
| lifecycleEnded: false, |
| }, |
| onRuntimeTurnStarted, |
| ); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("started")); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "start", |
| runId: "run-cli-execution-started", |
| onExecutionStarted, |
| onAgentEvent: callbacks.onAgentEvent, |
| }); |
|
|
| expect(firstRunCliAgentArg().onExecutionStarted).toBe(onExecutionStarted); |
| const observePhase = firstRunCliAgentArg().onExecutionPhase; |
| if (typeof observePhase !== "function") { |
| throw new Error("CLI execution phase observer is missing"); |
| } |
| observePhase({ phase: "process_spawned" }); |
| expect(onRuntimeTurnStarted).not.toHaveBeenCalled(); |
| observePhase({ phase }); |
| expect(onRuntimeTurnStarted).toHaveBeenCalledOnce(); |
| }, |
| ); |
|
|
| it("forwards authoritative group type to CLI runs with opaque session keys", async () => { |
| const sessionKey = "agent:main:opaque:binding"; |
| const sessionEntry = makeSessionEntry("session-cli-opaque-group", { |
| chatType: "group", |
| }); |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("shared")); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "shared", |
| runId: "run-cli-opaque-group", |
| }); |
|
|
| expect(firstRunCliAgentArg()).toMatchObject({ |
| sessionKey, |
| chatType: "group", |
| }); |
| }); |
|
|
| it.each(["updated", "replaced", "revised", "revision-established"])( |
| "refreshes a %s session after CLI placement admission", |
| async (change) => { |
| const sessionKey = "agent:main:cli-admission"; |
| const sessionEntry = { |
| ...makeClaudeCliSessionEntry("admitted-session", "old-native-session"), |
| ...(change === "revision-established" ? {} : { lifecycleRevision: "admitted-revision" }), |
| }; |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| hasClaudeSessionMock.mockReturnValue(true); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("completion delivered")); |
| const admittedEntry: SessionEntry = { |
| ...sessionEntry, |
| sessionId: change === "replaced" ? "replacement-session" : sessionEntry.sessionId, |
| lifecycleRevision: |
| change === "revised" || change === "revision-established" |
| ? "replacement-revision" |
| : sessionEntry.lifecycleRevision, |
| permissionMode: "read-only", |
| cliSessionBindings: { |
| "claude-cli": { sessionId: "new-native-session", authProfileId: "anthropic:claude-cli" }, |
| }, |
| }; |
| const uninstall = installSessionPlacementAdmissionProvider({ |
| assertCompactionSuccessorAllowed: () => {}, |
| executeLocalTurn: async (_claim, runLocal) => { |
| await replaceSessionEntry({ sessionKey, storePath }, admittedEntry); |
| return await runLocal(); |
| }, |
| executeTurn: async (_claim, _params, runLocal) => await runLocal(), |
| }); |
| try { |
| const run = runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "deliver completion", |
| runId: "cli-admission", |
| }); |
| if (change !== "updated") { |
| await expect(run).rejects.toMatchObject({ code: "AGENT_RUN_SUPERSEDED_ABORT" }); |
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| return; |
| } |
| await run; |
| expect(firstRunCliAgentArg()).toMatchObject({ |
| cliSessionId: "new-native-session", |
| cliSessionBinding: { sessionId: "new-native-session" }, |
| sessionEntry: { permissionMode: "read-only" }, |
| }); |
| } finally { |
| uninstall(); |
| } |
| }, |
| ); |
|
|
| async function writeClaudeCliAssistantTranscript( |
| cliSessionId: string, |
| homeDir = path.join(tmpDir, `home-${cliSessionId}`), |
| ) { |
| |
| |
| const projectsDir = resolveClaudeCliProjectDirForWorkspace({ |
| workspaceDir: tmpDir, |
| homeDir, |
| }); |
| setTestEnvValue("HOME", homeDir); |
| await fs.mkdir(projectsDir, { recursive: true }); |
| await fs.writeFile( |
| path.join(projectsDir, `${cliSessionId}.jsonl`), |
| `${JSON.stringify({ |
| type: "assistant", |
| message: { role: "assistant", content: [{ type: "text", text: "old reply" }] }, |
| })}\n`, |
| "utf-8", |
| ); |
| } |
|
|
| async function runOuterCliFallback(params: { |
| suppression?: "heartbeat" | "preserved-state"; |
| sessionKey: string; |
| sessionEntry: SessionEntry; |
| sessionStore: Record<string, SessionEntry>; |
| runId: string; |
| }) { |
| const [ |
| { getAcpSessionManager }, |
| { prepareAgentCommandExecutionIdentity }, |
| { runEmbeddedAgentAttempt }, |
| ] = await Promise.all([ |
| import("../../acp/control-plane/manager.js"), |
| import("../agent-command-execution-identity.js"), |
| import("./run-embedded-attempt.js"), |
| ]); |
| const cfg: OpenClawConfig = { |
| agents: { |
| defaults: { model: { primary: "claude-cli/sonnet", fallbacks: ["claude-cli/opus"] } }, |
| }, |
| }; |
| const opts = { |
| message: "outer fallback", |
| modelFallbacksOverride: ["claude-cli/opus"], |
| bootstrapContextRunKind: params.suppression === "heartbeat" ? "heartbeat" : undefined, |
| } satisfies RunAgentAttemptParams["opts"]; |
| const lifecycleGeneration = getAgentEventLifecycleGeneration(); |
| const prepared: Parameters<typeof runEmbeddedAgentAttempt>[0]["prepared"] = { |
| ...params, |
| opts, |
| cfg, |
| body: opts.message, |
| transcriptBody: opts.message, |
| configuredThinkingCatalog: [], |
| normalizedSpawned: {}, |
| agentCfg: undefined, |
| thinkOverride: undefined, |
| thinkOnce: undefined, |
| verboseOverride: undefined, |
| timeoutMs: 10_000, |
| runTimeoutOverrideMs: undefined, |
| sessionId: params.sessionEntry.sessionId, |
| storePath, |
| isNewSession: false, |
| previousSessionId: undefined, |
| persistedThinking: undefined, |
| persistedVerbose: undefined, |
| sessionAgentId: "main", |
| outboundSession: undefined, |
| workspaceDir: tmpDir, |
| cwd: undefined, |
| agentDir, |
| pluginsEnabled: false, |
| manifestMetadataSnapshot: undefined, |
| modelManifestContext: { manifestPlugins: [] }, |
| isSubagentLane: false, |
| acpManager: getAcpSessionManager(), |
| acpResolution: null, |
| runLease: undefined, |
| }; |
| const admission = prepareAgentCommandExecutionIdentity({ |
| opts, |
| prepared, |
| ingress: { kind: "system", boundary: "cold-cli-fallback-test", state: "present" }, |
| lifecycleGeneration, |
| }); |
| try { |
| const attempt = await runEmbeddedAgentAttempt({ |
| preparedRunAdmission: admission, |
| prepared, |
| opts, |
| sessionEntry: params.sessionEntry, |
| lifecycleGeneration, |
| onLifecycleGenerationChanged: () => {}, |
| suppressVisibleSessionEffects: false, |
| preserveUserFacingSessionModelState: params.suppression === "preserved-state", |
| trackInternalModelRunTarget: () => {}, |
| embeddedSessionState: { |
| sessionEntry: params.sessionEntry, |
| requestedThinkLevel: "off", |
| resolvedVerboseLevel: undefined, |
| skillsSnapshot: { prompt: "", skills: [] }, |
| runContext: {}, |
| }, |
| modelSelection: { |
| sessionEntry: params.sessionEntry, |
| provider: "claude-cli", |
| model: "sonnet", |
| requestedRouteResolution: "resolved", |
| defaultProvider: "claude-cli", |
| defaultModel: "sonnet", |
| configuredDefaultAuthProfileId: undefined, |
| providerForAuthProfileValidation: "claude-cli", |
| hasExplicitRunOverride: false, |
| storedProviderOverride: undefined, |
| storedModelOverride: undefined, |
| storedModelOverrideSource: undefined, |
| hasStoredAutoFallbackProvenance: false, |
| autoFallbackPrimaryProbe: undefined, |
| sessionEntryForAttempt: params.sessionEntry, |
| thinkingCatalog: [], |
| immutableThinkLevel: "off", |
| effectiveTurnThinkLevel: "off", |
| sessionFile: path.join(tmpDir, "session.jsonl"), |
| }, |
| }); |
| try { |
| await attempt.fallbackTrajectoryRecorder?.flush(); |
| return attempt; |
| } finally { |
| await attempt.deferredLifecycle.complete(); |
| } |
| } finally { |
| await admission.finish(); |
| } |
| } |
|
|
| it.each([ |
| "accepted", |
| "rejected", |
| "rejected-clear", |
| "outer-fallback", |
| "heartbeat", |
| "preserved-state", |
| ])("settles a cold %s CLI binding before the next queued command starts", async (outcome) => { |
| const suppression = |
| outcome === "heartbeat" || outcome === "preserved-state" ? outcome : undefined; |
| const outerFallback = outcome === "outer-fallback" || suppression !== undefined; |
| const accepted = outcome === "accepted" || outerFallback; |
| const previousBinding = { sessionId: "previous-native-session" }; |
| const sessionKey = "agent:main:cli-binding-settlement"; |
| const sessionEntry = makeSessionEntry("binding-settlement-session"); |
| if (outcome === "rejected-clear" || suppression) { |
| sessionEntry.cliSessionBindings = { "claude-cli": previousBinding }; |
| await writeClaudeCliAssistantTranscript( |
| "previous-native-session", |
| path.join(tmpDir, "cold-home"), |
| ); |
| } |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| await writeClaudeCliAssistantTranscript( |
| "settled-native-session", |
| path.join(tmpDir, "cold-home"), |
| ); |
| const firstStarted = createDeferredCore(); |
| const finishFirst = createDeferredCore(); |
| const binding = { |
| sessionId: "settled-native-session", |
| authProfileId: "anthropic:claude-cli", |
| }; |
| if (outerFallback) { |
| runCliAgentMock.mockRejectedValueOnce( |
| new FailoverError("primary capacity", { |
| reason: "rate_limit", |
| provider: "claude-cli", |
| model: "sonnet", |
| }), |
| ); |
| } |
| runCliAgentMock |
| .mockImplementationOnce(async () => { |
| firstStarted.resolve(); |
| await finishFirst.promise; |
| const result = makeCliResult("parent completed"); |
| if (!accepted) { |
| result.payloads = [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }]; |
| result.meta.finalAssistantVisibleText = GENERIC_EXTERNAL_RUN_FAILURE_TEXT; |
| } |
| if (outcome === "rejected-clear") { |
| result.meta.agentMeta!.clearCliSessionBinding = true; |
| } |
| result.meta.agentMeta!.cliSessionBinding = binding; |
| result.meta.agentMeta!.sessionId = binding.sessionId; |
| return result; |
| }) |
| .mockResolvedValueOnce(makeCliResult("follow-up completed")); |
| const run = (runId: string) => |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: runId, |
| runId, |
| classifyResult: (result) => |
| classifyEmbeddedAgentRunResultForModelFallback({ |
| result, |
| provider: "claude-cli", |
| model: "opus", |
| }), |
| }); |
| const first = outerFallback |
| ? runOuterCliFallback({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| runId: "binding-parent", |
| suppression, |
| }) |
| : run("binding-parent"); |
| await Promise.race([ |
| firstStarted.promise, |
| first.then(() => { |
| throw new Error("first command settled before its CLI started"); |
| }), |
| ]); |
| if (outcome === "rejected-clear") { |
| expect(firstRunCliAgentArg().cliSessionId).toBe("previous-native-session"); |
| } |
| const second = run("binding-follow-up"); |
| finishFirst.resolve(); |
| await Promise.all([first, second]); |
|
|
| if (outerFallback) { |
| expect(firstRunCliAgentArg(0).model).toBe("sonnet"); |
| expect(firstRunCliAgentArg(1).model).toBe("opus"); |
| } |
| const expectedBinding = suppression ? previousBinding : accepted ? binding : undefined; |
| expect(firstRunCliAgentArg(outerFallback ? 2 : 1)).toMatchObject({ |
| cliSessionId: expectedBinding?.sessionId, |
| cliSessionBinding: expectedBinding, |
| }); |
| }); |
|
|
| it("retains rejected-clear CLI output without replay when continuity settlement loses its owner", async () => { |
| const sessionKey = "agent:main:cli-settlement-owner-loss"; |
| const sessionEntry = makeSessionEntry("cli-settlement-owner-loss"); |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| const runId = "cli-settlement-owner-loss-run"; |
| await writeSessionStoreSeed(sessionStore); |
| const output = { |
| text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, |
| rawText: "Captured raw action result", |
| sessionId: "captured-native-session", |
| usage: { input: 71, output: 9, total: 80 }, |
| }; |
| const context = buildPreparedCliRunContext({ |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| runId, |
| workspaceDir: tmpDir, |
| }); |
| const cliResult = buildCliRunResult({ |
| context, |
| output, |
| effectiveCliSessionId: output.sessionId, |
| bindingFlushOk: false, |
| usedHistoryPrompt: false, |
| userTurnHandled: true, |
| sessionBindingDisabled: false, |
| preparedContextAgentMeta: {}, |
| }); |
| const provider: Parameters<typeof installSessionPlacementAdmissionProvider>[0] = { |
| assertCompactionSuccessorAllowed: () => {}, |
| executeLocalTurn: async (_claim, runLocal) => await runLocal(), |
| executeTurn: async (_claim, _params, runLocal) => await runLocal(), |
| }; |
| const uninstallOriginal = installSessionPlacementAdmissionProvider(provider); |
| let uninstallReplacement: (() => void) | undefined; |
| runCliAgentMock |
| .mockImplementationOnce(async (runParams: RunCliAgentParams) => { |
| |
| uninstallReplacement = installSessionPlacementAdmissionProvider({ ...provider }); |
| expect(runParams.abortSignal?.aborted).toBe(false); |
| return cliResult; |
| }) |
| .mockResolvedValueOnce(makeCliResult("Unexpected replay")); |
| try { |
| const attempt = await runOuterCliFallback({ sessionKey, sessionEntry, sessionStore, runId }); |
| expect.soft(runCliAgentMock).toHaveBeenCalledOnce(); |
| expect.soft(attempt.result.payloads).toContainEqual({ text: output.text }); |
| expect.soft(attempt.result.meta).toMatchObject({ |
| replayInvalid: true, |
| finalAssistantVisibleText: output.text, |
| finalAssistantRawText: output.rawText, |
| agentMeta: { usage: output.usage, lastCallUsage: output.usage }, |
| error: { |
| message: expect.stringContaining("CLI session continuity could not be saved"), |
| fallbackSafe: false, |
| }, |
| }); |
| expect.soft(attempt.terminal.outcome.status).toBe("error"); |
| expect |
| .soft(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId) |
| .not.toBe(output.sessionId); |
| } finally { |
| uninstallReplacement?.(); |
| uninstallOriginal(); |
| } |
| }); |
|
|
| function makeClaudeCliSessionEntry( |
| openclawSessionId: string, |
| cliSessionId: string, |
| ): SessionEntry { |
| return { |
| sessionId: openclawSessionId, |
| updatedAt: Date.now(), |
| cliSessionBindings: { |
| "claude-cli": { |
| sessionId: cliSessionId, |
| authProfileId: "anthropic:claude-cli", |
| }, |
| }, |
| cliSessionIds: { "claude-cli": cliSessionId }, |
| claudeCliSessionId: cliSessionId, |
| }; |
| } |
|
|
| it("clears stale Claude CLI session IDs before a fresh retry after session expiration", async () => { |
| const sessionKey = "agent:main:subagent:cli-expired"; |
| const homeDir = path.join(tmpDir, "home"); |
| const projectsDir = resolveClaudeCliProjectDirForWorkspace({ |
| workspaceDir: tmpDir, |
| homeDir, |
| }); |
| setTestEnvValue("HOME", homeDir); |
| await fs.mkdir(projectsDir, { recursive: true }); |
| await fs.writeFile( |
| path.join(projectsDir, "stale-cli-session.jsonl"), |
| `${JSON.stringify({ |
| type: "assistant", |
| message: { role: "assistant", content: [{ type: "text", text: "old reply" }] }, |
| })}\n`, |
| "utf-8", |
| ); |
| const sessionEntry: SessionEntry = { |
| sessionId: "session-cli-123", |
| updatedAt: Date.now(), |
| cliSessionIds: { "claude-cli": "stale-cli-session" }, |
| claudeCliSessionId: "stale-legacy-session", |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
|
|
| |
| |
| runCliAgentMock.mockImplementationOnce(async (args: unknown) => { |
| const retry = requireRecord(args, "run CLI agent argument").onBeforeFreshCliSessionRetry; |
| expect(retry).toBeTypeOf("function"); |
| await ( |
| retry as (params: { |
| provider: string; |
| reason: "session_expired"; |
| sessionId: string; |
| }) => Promise<boolean> |
| )({ |
| provider: "claude-cli", |
| reason: "session_expired", |
| sessionId: "stale-cli-session", |
| }); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined(); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined(); |
| return makeCliResult("hello from cli"); |
| }); |
|
|
| await runClaudeCliAttempt({ |
| sessionEntry, |
| sessionKey, |
| sessionStore, |
| body: "retry this", |
| runId: "run-cli-expired", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe("stale-cli-session"); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("session-cli"); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| "session-cli", |
| ); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined(); |
|
|
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("session-cli"); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| "session-cli", |
| ); |
| expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined(); |
| }); |
|
|
| it("preserves and resumes a valid Claude CLI binding after format failover", async () => { |
| const sessionKey = "agent:main:subagent:cli-format"; |
| const cliSessionId = "format-retry-session"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry("session-cli-format", cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
|
|
| runCliAgentMock.mockImplementationOnce(async () => { |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| throw new FailoverError("Claude CLI returned an unusable result", { |
| reason: "format", |
| code: "cli_synthetic_no_response", |
| provider: "claude-cli", |
| model: "opus", |
| }); |
| }); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionEntry, |
| sessionKey, |
| sessionStore, |
| body: "retry this malformed turn", |
| runId: "run-cli-format", |
| }), |
| ).rejects.toMatchObject({ name: "FailoverError", reason: "format" }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| runCliAgentMock.mockResolvedValueOnce( |
| makeCliResult("hello after retained resume", cliSessionId), |
| ); |
|
|
| await runClaudeCliAttempt({ |
| sessionEntry, |
| sessionKey, |
| sessionStore, |
| body: "continue on the next turn", |
| runId: "run-cli-format-resume", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(2); |
| expect(firstRunCliAgentArg(1).cliSessionId).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| }); |
|
|
| it("preserves and resumes a reused Claude CLI session after AbortError", async () => { |
| const sessionKey = "agent:main:direct:cli-abort"; |
| const cliSessionId = "abort-poisoned-session"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry("session-cli-abort", cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| const abortError = Object.assign(new Error("aborted"), { name: "AbortError" }); |
| runCliAgentMock.mockRejectedValueOnce(abortError); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "resume after abort", |
| runId: "run-cli-abort", |
| }), |
| ).rejects.toMatchObject({ name: "AbortError" }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe(cliSessionId); |
|
|
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe(cliSessionId); |
| expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(cliSessionId); |
| expect(persisted[sessionKey]?.claudeCliSessionId).toBe(cliSessionId); |
|
|
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("continued after abort", cliSessionId)); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "continue after abort", |
| runId: "run-cli-abort-resume", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(2); |
| expect(firstRunCliAgentArg(1).cliSessionId).toBe(cliSessionId); |
| }); |
|
|
| it.each([ |
| { reason: "aborted", replacement: false }, |
| { reason: "timeout", replacement: false }, |
| { reason: "aborted", replacement: true }, |
| { reason: "timeout", replacement: true }, |
| ] as const)( |
| "settles returned $reason partial output with replacement=$replacement before the next turn", |
| async ({ reason, replacement }) => { |
| const sessionKey = "agent:main:direct:cli-partial-interruption"; |
| const cliSessionId = "established-session"; |
| const successorCliSessionId = "unfinished-successor"; |
| const homeDir = path.join(tmpDir, "home"); |
| await writeClaudeCliAssistantTranscript(cliSessionId, homeDir); |
| await writeClaudeCliAssistantTranscript(successorCliSessionId, homeDir); |
| const sessionEntry = makeClaudeCliSessionEntry("session-partial-interruption", cliSessionId); |
| if (replacement) { |
| sessionEntry.cliSessionBindings!["claude-cli"]!.forkNextResume = true; |
| } |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| const controller = new AbortController(); |
| runCliAgentMock.mockImplementationOnce(async (runParams: RunCliAgentParams) => { |
| expect(runParams.cliSessionId).toBe(cliSessionId); |
| if (replacement) { |
| expect(await runParams.claimCliSessionFork?.()).toBe(true); |
| await runParams.persistCliSessionForkSuccessor?.(successorCliSessionId); |
| expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]).toMatchObject({ |
| sessionId: successorCliSessionId, |
| forceReuse: true, |
| }); |
| } |
| controller.abort( |
| new DOMException(reason, reason === "timeout" ? "TimeoutError" : "AbortError"), |
| ); |
| expect(runParams.abortSignal?.aborted).toBe(true); |
| const context = buildPreparedCliRunContext({ |
| provider: "claude-cli", |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| workspaceDir: tmpDir, |
| }); |
| context.reusableCliSession = { mode: "reuse", sessionId: cliSessionId }; |
| return buildCliRunResult({ |
| context, |
| output: { text: "partial reply", terminalInterruption: { reason } }, |
| effectiveCliSessionId: replacement ? successorCliSessionId : cliSessionId, |
| bindingFlushOk: true, |
| usedHistoryPrompt: false, |
| userTurnHandled: true, |
| sessionBindingDisabled: false, |
| preparedContextAgentMeta: {}, |
| }); |
| }); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "continue the conversation", |
| runId: "run-partial-interruption", |
| abortSignal: controller.signal, |
| }); |
|
|
| const expectedSessionId = replacement ? undefined : cliSessionId; |
| const persisted = readSessionStore()[sessionKey]; |
| expect.soft(persisted?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe(expectedSessionId); |
| expect.soft(persisted?.cliSessionBindings?.["claude-cli"]?.forceReuse).toBeUndefined(); |
| expect |
| .soft(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId) |
| .toBe(expectedSessionId); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("continued after interruption")); |
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry: sessionStore[sessionKey], |
| sessionStore, |
| body: "continue", |
| runId: "run-after-partial-interruption", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(2); |
| expect(firstRunCliAgentArg(1).cliSessionId).toBe(expectedSessionId); |
| }, |
| ); |
|
|
| it("clears a fork-marked Claude CLI session after terminal failover", async () => { |
| const sessionKey = "agent:main:direct:cli-fork-expired"; |
| const cliSessionId = "expired-fork-source"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry("session-cli-fork-expired", cliSessionId); |
| sessionEntry.cliSessionBindings!["claude-cli"]!.forkNextResume = true; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockRejectedValueOnce( |
| new FailoverError("fork source expired", { |
| reason: "session_expired", |
| provider: "claude-cli", |
| model: "opus", |
| }), |
| ); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "fork from an expired source", |
| runId: "run-cli-fork-expired", |
| }), |
| ).rejects.toMatchObject({ name: "FailoverError", reason: "session_expired" }); |
|
|
| expect(firstRunCliAgentArg().forkCliSessionOnResume).toBe(true); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| }); |
|
|
| it("preserves a reused Claude CLI session after detached media starts", async () => { |
| const sessionKey = "agent:main:cron:job:run:run-id"; |
| const cliSessionId = "media-continuation-session"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry("run-id", cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| const abortError = Object.assign(new Error("aborted after media start"), { |
| name: "AbortError", |
| }); |
| runCliAgentMock.mockImplementationOnce(async () => { |
| registerGeneratedMediaTaskActivity("tool:image_generate:run-1", sessionKey); |
| throw abortError; |
| }); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "generate and continue", |
| runId: "run-cli-media", |
| }), |
| ).rejects.toBe(abortError); |
|
|
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe(cliSessionId); |
| }); |
|
|
| it("atomically forks and rebinds a reused Claude CLI session after timeout failover", async () => { |
| const sessionKey = "agent:main:direct:cli-timeout"; |
| const cliSessionId = "timeout-poisoned-session"; |
| const forkedCliSessionId = "timeout-recovery-fork"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry("session-cli-timeout", cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockImplementationOnce(async (args: unknown) => { |
| const runArgs = requireRecord(args, "run CLI agent argument"); |
| const prepareFork = runArgs.onBeforeForkedCliSessionRetry; |
| const claimFork = runArgs.claimCliSessionFork; |
| const persistFork = runArgs.persistCliSessionForkSuccessor; |
| expect(prepareFork).toBeTypeOf("function"); |
| expect(claimFork).toBeTypeOf("function"); |
| expect(persistFork).toBeTypeOf("function"); |
| await ( |
| prepareFork as (params: { |
| provider: string; |
| reason: "timeout"; |
| sessionId: string; |
| }) => Promise<boolean> |
| )({ |
| provider: "claude-cli", |
| reason: "timeout", |
| sessionId: cliSessionId, |
| }); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.forkNextResume).toBe( |
| true, |
| ); |
| await (claimFork as () => Promise<boolean>)(); |
| expect( |
| sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.forkNextResume, |
| ).toBeUndefined(); |
| await (persistFork as (sessionId: string) => Promise<void>)(forkedCliSessionId); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| forkedCliSessionId, |
| ); |
| return makeCliResult("hello after timeout", forkedCliSessionId); |
| }); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "resume after timeout", |
| runId: "run-cli-timeout", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| forkedCliSessionId, |
| ); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(forkedCliSessionId); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe(cliSessionId); |
|
|
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| forkedCliSessionId, |
| ); |
| }); |
|
|
| it("clears a persisted fork successor when fresh recovery is authorized", async () => { |
| const sessionKey = "agent:main:direct:cli-fork-timeout"; |
| const cliSessionId = "timeout-parent-session"; |
| const forkedCliSessionId = "timeout-stalled-fork"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry("session-cli-fork-timeout", cliSessionId); |
| sessionEntry.cliSessionBindings!["claude-cli"]!.forkNextResume = true; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockImplementationOnce(async (args: unknown) => { |
| const runArgs = requireRecord(args, "run CLI agent argument"); |
| const claimFork = runArgs.claimCliSessionFork; |
| const persistFork = runArgs.persistCliSessionForkSuccessor; |
| const clearFork = runArgs.onBeforeFreshCliSessionRetry; |
| expect(runArgs.forkCliSessionOnResume).toBe(true); |
| expect(runArgs.onBeforeForkedCliSessionRetry).toBeUndefined(); |
| expect(clearFork).toBeTypeOf("function"); |
| await (claimFork as () => Promise<boolean>)(); |
| await (persistFork as (sessionId: string) => Promise<void>)(forkedCliSessionId); |
| await expect( |
| ( |
| clearFork as (params: { |
| provider: string; |
| reason: "timeout"; |
| sessionId: string; |
| }) => Promise<boolean> |
| )({ |
| provider: "claude-cli", |
| reason: "timeout", |
| sessionId: forkedCliSessionId, |
| }), |
| ).resolves.toBe(true); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| return makeCliResult("hello after fork timeout"); |
| }); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "retry after fork timeout", |
| runId: "run-cli-fork-timeout", |
| }); |
|
|
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ |
| sessionId: "session-cli", |
| }); |
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ |
| sessionId: "session-cli", |
| }); |
| }); |
|
|
| it("clears a persisted fork successor when recovery fails after rebinding", async () => { |
| const sessionKey = "agent:main:direct:cli-fork-finalization-failure"; |
| const cliSessionId = "finalization-parent-session"; |
| const forkedCliSessionId = "partial-fork-successor"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry( |
| "session-cli-fork-finalization-failure", |
| cliSessionId, |
| ); |
| sessionEntry.cliSessionBindings!["claude-cli"]!.forkNextResume = true; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| const finalizationError = Object.assign(new Error("fork finalization failed"), { |
| name: "AbortError", |
| }); |
| runCliAgentMock.mockImplementationOnce(async (args: unknown) => { |
| const runArgs = requireRecord(args, "run CLI agent argument"); |
| await (runArgs.claimCliSessionFork as () => Promise<boolean>)(); |
| await (runArgs.persistCliSessionForkSuccessor as (sessionId: string) => Promise<void>)( |
| forkedCliSessionId, |
| ); |
| throw finalizationError; |
| }); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "resume and fail after fork", |
| runId: "run-cli-fork-finalization-failure", |
| }), |
| ).rejects.toBe(finalizationError); |
|
|
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| }); |
|
|
| it.each(["recovery failure", "catalog cancellation"] as const)( |
| "preserves a restored fork marker before a successor after %s", |
| async (scenario) => { |
| const sessionKey = "agent:main:direct:cli-fork-before-successor-failure"; |
| const cliSessionId = "recovery-source-session"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry( |
| "session-cli-fork-before-successor-failure", |
| cliSessionId, |
| ); |
| const catalogCancellation = scenario === "catalog cancellation"; |
| if (catalogCancellation) { |
| sessionEntry.cliSessionBindings!["claude-cli"] = { |
| sessionId: cliSessionId, |
| forceReuse: true, |
| forkNextResume: true, |
| resumeCheckpointId: "source-checkpoint", |
| }; |
| } |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| const recoveryError = Object.assign(new Error("fork process died before init"), { |
| name: "AbortError", |
| }); |
| const controller = new AbortController(); |
| runCliAgentMock.mockImplementationOnce(async (runArgs: RunCliAgentParams) => { |
| if (!catalogCancellation) { |
| expect( |
| await runArgs.onBeforeForkedCliSessionRetry?.({ |
| provider: "claude-cli", |
| reason: "timeout", |
| sessionId: cliSessionId, |
| }), |
| ).toBe(true); |
| } |
| expect(await runArgs.claimCliSessionFork?.()).toBe(true); |
| expect( |
| readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]?.forkNextResume, |
| ).toBeUndefined(); |
| if (catalogCancellation) { |
| controller.abort(recoveryError); |
| expect(runArgs.abortSignal?.aborted).toBe(true); |
| } |
| await runArgs.restoreCliSessionFork?.(); |
| throw recoveryError; |
| }); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "resume and fail before fork init", |
| runId: "run-cli-fork-before-successor-failure", |
| abortSignal: controller.signal, |
| }), |
| ).rejects.toBe(recoveryError); |
|
|
| expect.soft(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toMatchObject({ |
| sessionId: cliSessionId, |
| forkNextResume: true, |
| }); |
| expect |
| .soft(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]) |
| .toMatchObject({ |
| sessionId: cliSessionId, |
| forkNextResume: true, |
| ...(catalogCancellation |
| ? { forceReuse: true, resumeCheckpointId: "source-checkpoint" } |
| : {}), |
| }); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("continued in fork", "fork-successor")); |
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "continue in a fork", |
| runId: "run-cli-after-before-successor-failure", |
| }); |
| expect(runCliAgentMock).toHaveBeenCalledTimes(2); |
| expect(firstRunCliAgentArg(1)).toMatchObject({ |
| cliSessionId, |
| forkCliSessionOnResume: true, |
| }); |
| }, |
| ); |
|
|
| it("does not clear a concurrent rebind after failed fork recovery", async () => { |
| const sessionKey = "agent:main:direct:cli-fork-concurrent-rebind"; |
| const cliSessionId = "concurrent-parent-session"; |
| const forkedCliSessionId = "failed-fork-successor"; |
| const concurrentCliSessionId = "newer-concurrent-session"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry( |
| "session-cli-fork-concurrent-rebind", |
| cliSessionId, |
| ); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| const recoveryError = Object.assign(new Error("fork recovery aborted"), { |
| name: "AbortError", |
| }); |
| runCliAgentMock.mockImplementationOnce(async (args: unknown) => { |
| const runArgs = requireRecord(args, "run CLI agent argument"); |
| await ( |
| runArgs.onBeforeForkedCliSessionRetry as (params: { |
| provider: string; |
| reason: "timeout"; |
| sessionId: string; |
| }) => Promise<boolean> |
| )({ provider: "claude-cli", reason: "timeout", sessionId: cliSessionId }); |
| await (runArgs.claimCliSessionFork as () => Promise<boolean>)(); |
| await (runArgs.persistCliSessionForkSuccessor as (sessionId: string) => Promise<void>)( |
| forkedCliSessionId, |
| ); |
|
|
| const concurrentEntry = makeClaudeCliSessionEntry( |
| sessionEntry.sessionId, |
| concurrentCliSessionId, |
| ); |
| await replaceSessionEntry({ sessionKey, storePath }, concurrentEntry); |
| sessionStore[sessionKey] = concurrentEntry; |
| const clearBeforeFreshRetry = runArgs.onBeforeFreshCliSessionRetry; |
| expect(clearBeforeFreshRetry).toBeTypeOf("function"); |
| await expect( |
| ( |
| clearBeforeFreshRetry as (params: { |
| provider: string; |
| reason: "timeout"; |
| sessionId: string; |
| }) => Promise<boolean> |
| )({ provider: "claude-cli", reason: "timeout", sessionId: forkedCliSessionId }), |
| ).resolves.toBe(false); |
| throw recoveryError; |
| }); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "resume while another turn rebinds", |
| runId: "run-cli-fork-concurrent-rebind", |
| }), |
| ).rejects.toBe(recoveryError); |
|
|
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| concurrentCliSessionId, |
| ); |
| expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| concurrentCliSessionId, |
| ); |
| }); |
|
|
| it("does not install a stale-session clearing hook for storeless CLI attempts", async () => { |
| const sessionKey = "agent:main:internal-storeless"; |
| const cliSessionId = "storeless-stale-session"; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry("session-storeless", cliSessionId); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("storeless ok")); |
|
|
| |
| |
| await runAgentAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| workspaceDir: tmpDir, |
| body: "storeless retry path", |
| runId: "run-storeless-cli", |
| agentDir, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(firstRunCliAgentArg().onBeforeFreshCliSessionRetry).toBeUndefined(); |
| }); |
|
|
| it.each(["auth", "auth_permanent"] as const)( |
| "preserves reused Claude CLI session IDs after %s failover without retrying", |
| async (reason) => { |
| const sessionKey = `agent:main:direct:cli-${reason}`; |
| const cliSessionId = `${reason}-poisoned-session`; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry(`session-cli-${reason}`, cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockRejectedValueOnce( |
| new FailoverError(`${reason} failed`, { |
| reason, |
| provider: "claude-cli", |
| model: "opus", |
| }), |
| ); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: `resume after ${reason}`, |
| runId: `run-cli-${reason}`, |
| }), |
| ).rejects.toMatchObject({ name: "FailoverError", reason }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe(cliSessionId); |
|
|
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(cliSessionId); |
| expect(persisted[sessionKey]?.claudeCliSessionId).toBe(cliSessionId); |
| }, |
| ); |
|
|
| it.each(["billing", "rate_limit"] as const)( |
| "preserves reused Claude CLI session IDs after %s failover without retrying", |
| async (reason) => { |
| const sessionKey = `agent:main:direct:cli-${reason}`; |
| const cliSessionId = `${reason}-poisoned-session`; |
| await writeClaudeCliAssistantTranscript(cliSessionId); |
| const sessionEntry = makeClaudeCliSessionEntry(`session-cli-${reason}`, cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockRejectedValueOnce( |
| new FailoverError(`${reason} failed`, { |
| reason, |
| provider: "claude-cli", |
| model: "opus", |
| }), |
| ); |
|
|
| await expect( |
| runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: `resume after ${reason}`, |
| runId: `run-cli-${reason}`, |
| }), |
| ).rejects.toMatchObject({ name: "FailoverError", reason }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeDefined(); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe(cliSessionId); |
| }, |
| ); |
|
|
| it("clears the persisted Claude CLI binding but still forwards the candidate when the stored transcript is missing", async () => { |
| const sessionKey = "agent:main:direct:claude-missing-transcript"; |
| const homeDir = path.join(tmpDir, "home"); |
| setTestEnvValue("HOME", homeDir); |
| const sessionEntry: SessionEntry = { |
| sessionId: "openclaw-session-123", |
| updatedAt: Date.now(), |
| cliSessionBindings: { |
| "claude-cli": { |
| sessionId: "phantom-claude-session", |
| authProfileId: "anthropic:claude-cli", |
| }, |
| }, |
| cliSessionIds: { "claude-cli": "phantom-claude-session" }, |
| claudeCliSessionId: "phantom-claude-session", |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockImplementationOnce(async () => { |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| return makeCliResult("fresh cli response"); |
| }); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "remember me", |
| runId: "run-cli-missing-transcript", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| |
| |
| |
| expect(firstRunCliAgentArg().cliSessionId).toBe("phantom-claude-session"); |
| expect(firstRunCliAgentArg().cliSessionBinding).toEqual({ |
| sessionId: "phantom-claude-session", |
| authProfileId: "anthropic:claude-cli", |
| }); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ |
| sessionId: "session-cli", |
| }); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("session-cli"); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined(); |
|
|
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ |
| sessionId: "session-cli", |
| }); |
| expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("session-cli"); |
| expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined(); |
| }); |
|
|
| it("keeps the bound claude-cli session id as the reuse candidate when the native transcript is missing (so reseed can recover)", async () => { |
| const sessionKey = "agent:main:direct:claude-missing-transcript-reseed"; |
| const cliSessionId = "cli-sid-abc"; |
| |
| |
| |
| const homeDir = path.join(tmpDir, "home-missing-transcript"); |
| const projectsDir = resolveClaudeCliProjectDirForWorkspace({ |
| workspaceDir: tmpDir, |
| homeDir, |
| }); |
| setTestEnvValue("HOME", homeDir); |
| await fs.mkdir(projectsDir, { recursive: true }); |
| |
| const sessionEntry = makeClaudeCliSessionEntry("openclaw-sid", cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| hasClaudeSessionMock.mockReturnValue(true); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("ok", cliSessionId)); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "remember our earlier chat", |
| runId: "run-cli-missing-transcript-reseed", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| |
| |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(firstRunCliAgentArg().cliSessionBinding).toEqual({ |
| sessionId: cliSessionId, |
| authProfileId: "anthropic:claude-cli", |
| }); |
| expect(hasClaudeSessionMock).toHaveBeenCalledWith({ |
| backendId: "claude-cli", |
| agentAccountId: undefined, |
| agentId: "main", |
| authProfileId: "anthropic:claude-cli", |
| sessionId: "openclaw-sid", |
| sessionKey, |
| }); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| cliSessionId, |
| ); |
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe(cliSessionId); |
| }); |
|
|
| it("keeps Claude CLI resume when the stored transcript has assistant content", async () => { |
| const sessionKey = "agent:main:direct:claude-transcript-present"; |
| const cliSessionId = "existing-claude-session"; |
| const homeDir = path.join(tmpDir, "home"); |
| const projectsDir = resolveClaudeCliProjectDirForWorkspace({ |
| workspaceDir: tmpDir, |
| homeDir, |
| }); |
| setTestEnvValue("HOME", homeDir); |
| await fs.mkdir(projectsDir, { recursive: true }); |
| await fs.writeFile( |
| path.join(projectsDir, `${cliSessionId}.jsonl`), |
| `${JSON.stringify({ |
| type: "assistant", |
| message: { |
| role: "assistant", |
| content: [{ type: "text", text: "previous reply" }], |
| }, |
| })}\n`, |
| "utf-8", |
| ); |
| const sessionEntry: SessionEntry = { |
| sessionId: "openclaw-session-456", |
| updatedAt: Date.now(), |
| cliSessionBindings: { |
| "claude-cli": { |
| sessionId: cliSessionId, |
| authProfileId: "anthropic:claude-cli", |
| }, |
| }, |
| cliSessionIds: { "claude-cli": cliSessionId }, |
| claudeCliSessionId: cliSessionId, |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("resumed cli response", cliSessionId)); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "continue", |
| runId: "run-cli-transcript-present", |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(firstRunCliAgentArg().cliSessionBinding).toEqual({ |
| sessionId: cliSessionId, |
| authProfileId: "anthropic:claude-cli", |
| }); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(cliSessionId); |
| expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe(cliSessionId); |
| }); |
|
|
| it("checks Claude CLI transcript content under the process cwd", async () => { |
| const sessionKey = "agent:main:direct:claude-transcript-cwd-present"; |
| const cliSessionId = "existing-claude-cwd-session"; |
| const homeDir = path.join(tmpDir, "home"); |
| const cwd = path.join(tmpDir, "task"); |
| const projectsDir = resolveClaudeCliProjectDirForWorkspace({ |
| workspaceDir: cwd, |
| homeDir, |
| }); |
| setTestEnvValue("HOME", homeDir); |
| await fs.mkdir(projectsDir, { recursive: true }); |
| await fs.writeFile( |
| path.join(projectsDir, `${cliSessionId}.jsonl`), |
| `${JSON.stringify({ |
| type: "assistant", |
| message: { |
| role: "assistant", |
| content: [{ type: "text", text: "previous reply" }], |
| }, |
| })}\n`, |
| "utf-8", |
| ); |
| const sessionEntry = makeClaudeCliSessionEntry("openclaw-session-cwd", cliSessionId); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("resumed cli response", cliSessionId)); |
|
|
| await runClaudeCliAttempt({ |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| body: "continue from task cwd", |
| runId: "run-cli-transcript-cwd-present", |
| cwd, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().cliSessionId).toBe(cliSessionId); |
| expect(firstRunCliAgentArg().cwd).toBe(cwd); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe(cliSessionId); |
| }); |
|
|
| it("passes session-bound OpenAI Codex auth profile to codex-cli aliases", async () => { |
| const sessionKey = "agent:main:direct:codex-cli-auth-alias"; |
| const sessionEntry = makeSessionEntry("openclaw-session-codex", { |
| authProfileOverride: "openai:work", |
| authProfileOverrideSource: "user", |
| }); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("codex cli response")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "codex-cli", |
| sessionEntry, |
| sessionKey, |
| runId: "run-codex-cli-auth-alias", |
| authProfileProvider: "openai", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().authProfileId).toBe("openai:work"); |
| }); |
|
|
| it("skips auto auth-profile resolution for CLI-owned transport", async () => { |
| const sessionKey = "agent:main:direct:codex-cli-owned-transport"; |
| const sessionEntry = makeSessionEntry("openclaw-session-codex-owned"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| const cfg: OpenClawConfig = { |
| agents: { |
| defaults: { |
| agentRuntime: { id: "codex" }, |
| }, |
| }, |
| }; |
| await writeSessionStoreSeed(sessionStore); |
| await fs.writeFile(path.join(tmpDir, "auth-profiles.json"), "{", "utf-8"); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("codex cli response")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "codex-cli", |
| cfg, |
| sessionEntry, |
| sessionKey, |
| runId: "run-codex-cli-owned-transport-auth-skip", |
| authProfileProvider: "openai-codex", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().authProfileId).toBeUndefined(); |
| }); |
|
|
| it("selects a google-gemini-cli auth profile for canonical Google models routed through Gemini CLI", async () => { |
| const sessionKey = "agent:main:direct:gemini-cli-auth-bridge"; |
| const sessionEntry = makeSessionEntry("openclaw-session-gemini"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "google-gemini-cli:user@example.test": { |
| type: "oauth", |
| provider: "google-gemini-cli", |
| access: "access-token", |
| refresh: "refresh-token", |
| expires: Date.now() + 3_600_000, |
| email: "user@example.test", |
| }, |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("gemini cli response")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "google", |
| modelOverride: "gemini-3.1-pro-preview", |
| cfg: { |
| auth: { |
| order: { |
| "google-gemini-cli": ["google-gemini-cli:user@example.test"], |
| }, |
| }, |
| agents: { |
| defaults: { |
| models: { |
| "google/gemini-3.1-pro-preview": { |
| agentRuntime: { id: "google-gemini-cli" }, |
| }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| runId: "run-gemini-cli-auth-bridge", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().provider).toBe("google-gemini-cli"); |
| expect(firstRunCliAgentArg().authProfileId).toBe("google-gemini-cli:user@example.test"); |
| }); |
|
|
| it("forwards pinned canonical Google API-key profiles to Google models routed through Gemini CLI", async () => { |
| const sessionKey = "agent:main:direct:gemini-cli-google-api-key"; |
| const sessionEntry = makeSessionEntry("openclaw-session-gemini-api-key", { |
| authProfileOverride: "google:api-key", |
| authProfileOverrideSource: "user", |
| }); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "google:api-key": createApiKeyCredential("google", "gemini-api-key"), |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("gemini cli api-key response")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "google", |
| modelOverride: "gemini-3.1-pro-preview", |
| cfg: { |
| agents: { |
| defaults: { |
| models: { |
| "google/gemini-3.1-pro-preview": { |
| agentRuntime: { id: "google-gemini-cli" }, |
| }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| runId: "run-gemini-cli-google-api-key", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().provider).toBe("google-gemini-cli"); |
| expect(firstRunCliAgentArg().authProfileId).toBe("google:api-key"); |
| }); |
|
|
| it("rejects incompatible pinned profiles before selecting another CLI identity", async () => { |
| const sessionKey = "agent:main:direct:gemini-cli-incompatible-auth"; |
| const sessionEntry = makeSessionEntry("openclaw-session-gemini-incompatible-auth", { |
| authProfileOverride: "vercel-ai-gateway:default", |
| authProfileOverrideSource: "user", |
| }); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "vercel-ai-gateway:default": createApiKeyCredential("vercel-ai-gateway", "vercel-key"), |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| expect(() => |
| runStoredAttempt({ |
| providerOverride: "google", |
| modelOverride: "gemini-3.1-pro-preview", |
| cfg: { |
| agents: { |
| defaults: { |
| models: { |
| "google/gemini-3.1-pro-preview": { |
| agentRuntime: { id: "google-gemini-cli" }, |
| }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| runId: "run-gemini-cli-incompatible-auth", |
| sessionStore, |
| }), |
| ).toThrow(/cannot use auth profile "vercel-ai-gateway:default"/); |
|
|
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("ignores stale auto-selected profiles when resolving Gemini CLI auth order", async () => { |
| const sessionKey = "agent:main:direct:gemini-cli-stale-auto-auth"; |
| const sessionEntry = makeSessionEntry("openclaw-session-gemini-stale-auto-auth", { |
| authProfileOverride: "openai:work", |
| authProfileOverrideSource: "auto", |
| }); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "openai:work": { |
| type: "oauth", |
| provider: "openai", |
| access: "openai-access", |
| refresh: "openai-refresh", |
| expires: Date.now() + 60_000, |
| }, |
| "google:api-key": createApiKeyCredential("google", "gemini-api-key"), |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("gemini cli api-key response")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "google", |
| modelOverride: "gemini-3.1-pro-preview", |
| cfg: { |
| auth: { |
| order: { |
| google: ["google:api-key"], |
| }, |
| }, |
| agents: { |
| defaults: { |
| models: { |
| "google/gemini-3.1-pro-preview": { |
| agentRuntime: { id: "google-gemini-cli" }, |
| }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| runId: "run-gemini-cli-stale-auto-auth", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().provider).toBe("google-gemini-cli"); |
| expect(firstRunCliAgentArg().authProfileId).toBe("google:api-key"); |
| }); |
|
|
| it("selects canonical Google API-key auth order for Google models routed through Gemini CLI", async () => { |
| const sessionKey = "agent:main:direct:gemini-cli-google-api-key-order"; |
| const sessionEntry = makeSessionEntry("openclaw-session-gemini-api-key-order"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "google:api-key": createApiKeyCredential("google", "gemini-api-key"), |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("gemini cli api-key response")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "google", |
| modelOverride: "gemini-3.1-pro-preview", |
| cfg: { |
| auth: { |
| order: { |
| google: ["google:api-key"], |
| }, |
| }, |
| agents: { |
| defaults: { |
| models: { |
| "google/gemini-3.1-pro-preview": { |
| agentRuntime: { id: "google-gemini-cli" }, |
| }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| runId: "run-gemini-cli-google-api-key-order", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().provider).toBe("google-gemini-cli"); |
| expect(firstRunCliAgentArg().authProfileId).toBe("google:api-key"); |
| }); |
|
|
| it.each(["CLI", "ACP"] as const)( |
| "keeps an explicit internal %s transcript out of the visible session path", |
| async (runtime) => { |
| const visibleSessionId = `session-explicit-internal-${runtime.toLowerCase()}`; |
| const sessionId = `internal-${visibleSessionId}`; |
| const sessionKey = `agent:main:internal-session-effects:${visibleSessionId}`; |
| setTestEnvValue("HOME", tmpDir); |
| setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tmpDir, "state")); |
| const internalStorePath = storePath; |
| const internalSessionFile = formatSqliteSessionFileMarker({ |
| agentId: "main", |
| sessionId, |
| storePath: internalStorePath, |
| }); |
| const sessionEntry: SessionEntry = { |
| sessionId, |
| updatedAt: Date.now(), |
| }; |
|
|
| if (runtime === "CLI") { |
| await persistCliTurnTranscript({ |
| body: "internal prompt", |
| result: makeCliResult("internal reply"), |
| sessionId, |
| sessionKey, |
| sessionFile: internalSessionFile, |
| sessionEntry, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| storePath, |
| config: {}, |
| }); |
| } else { |
| await persistAcpTurnTranscript({ |
| body: "internal prompt", |
| finalText: "internal reply", |
| terminalOutcome: { reason: "completed", status: "ok" }, |
| sessionId, |
| sessionKey, |
| sessionFile: internalSessionFile, |
| sessionEntry, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| storePath, |
| config: {}, |
| }); |
| } |
|
|
| expect(await readSessionMessages(internalSessionFile)).toContainEqual( |
| expect.objectContaining({ |
| role: "assistant", |
| content: [{ type: "text", text: "internal reply" }], |
| }), |
| ); |
| expect( |
| await loadTranscriptEvents({ |
| agentId: "main", |
| sessionId: visibleSessionId, |
| storePath, |
| }), |
| ).toEqual([]); |
| }, |
| ); |
|
|
| it("persists CLI replies into the session transcript", async () => { |
| const sessionKey = "agent:main:subagent:cli-transcript"; |
| const sessionEntry: SessionEntry = { |
| sessionId: "session-cli-transcript", |
| updatedAt: 1, |
| status: "running", |
| startedAt: 2, |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed({ |
| [sessionKey]: { |
| ...sessionEntry, |
| updatedAt: 5, |
| status: "done", |
| endedAt: 4, |
| }, |
| }); |
| clearSessionStoreCacheForTest(); |
|
|
| const nowCalls: number[] = []; |
| let nextNow = 10_000; |
| const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => { |
| nextNow += 1_000; |
| nowCalls.push(nextNow); |
| return nextNow; |
| }); |
| let updatedEntry: SessionEntry | undefined; |
| try { |
| const result = makeCliResult("hello from cli"); |
| if (!result.meta.agentMeta) { |
| throw new Error("expected agent metadata"); |
| } |
| result.meta.agentMeta.usage = { input: 12, output: 4, cacheRead: 3, total: 19 }; |
| result.meta.agentMeta.lastCallUsage = { input: 7, output: 4, cacheRead: 2, total: 13 }; |
| updatedEntry = await persistCliTranscriptEntry({ |
| body: "persist this", |
| result, |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| storePath, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| config: {}, |
| }); |
| } finally { |
| nowSpy.mockRestore(); |
| } |
|
|
| expect(updatedEntry).not.toHaveProperty("sessionFile"); |
| const target = { |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| storePath, |
| }; |
| const entries = await readSessionFileEntries(target); |
| expectRecordFields(requireRecord(entries[0], "session entry"), { |
| type: "session", |
| id: sessionEntry.sessionId, |
| cwd: tmpDir, |
| }); |
| expectRecordFields(requireRecord(entries[1], "user transcript entry"), { |
| type: "message", |
| parentId: null, |
| }); |
| expectRecordFields(requireRecord(entries[2], "assistant transcript entry"), { |
| type: "message", |
| parentId: entries[1]?.id, |
| }); |
| const messages = await readSessionMessages(target); |
| expect(messages).toHaveLength(2); |
| expectRecordFields(requireRecord(messages[0], "user message"), { |
| role: "user", |
| content: "persist this", |
| }); |
| expectRecordFields(requireRecord(messages[1], "assistant message"), { |
| role: "assistant", |
| api: "cli", |
| provider: "claude-cli", |
| model: "opus", |
| content: [{ type: "text", text: "hello from cli" }], |
| }); |
| expectRecordFields(requireRecord(messages[1]?.usage, "assistant usage"), { |
| input: 7, |
| output: 4, |
| cacheRead: 2, |
| totalTokens: 13, |
| contextUsage: { state: "available", promptTokens: 9, totalTokens: 13 }, |
| }); |
|
|
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]).not.toHaveProperty("sessionFile"); |
| expect(persisted[sessionKey]?.updatedAt).toBeGreaterThan(sessionEntry.updatedAt); |
| expect(persisted[sessionKey]?.updatedAt).toBeLessThanOrEqual(nowCalls.at(-1) ?? 0); |
| expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt); |
| }); |
|
|
| it("marks CLI transcript context unavailable when only cumulative usage exists", async () => { |
| const sessionKey = "agent:main:subagent:cli-cumulative-only"; |
| const sessionEntry = makeSessionEntry("session-cli-cumulative-only"); |
| const result = makeCliResult("cumulative reply"); |
| if (!result.meta.agentMeta) { |
| throw new Error("expected agent metadata"); |
| } |
| result.meta.agentMeta.lastCallUsage = undefined; |
|
|
| await persistCliTurnTranscript({ |
| body: "run tools", |
| result, |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionEntry, |
| storePath, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| config: {}, |
| }); |
|
|
| const messages = await readSessionMessages({ |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| storePath, |
| }); |
| const assistant = requireRecord(messages.at(-1), "assistant message"); |
| expectRecordFields(requireRecord(assistant.usage, "assistant usage"), { |
| input: 0, |
| output: 0, |
| cacheRead: 0, |
| cacheWrite: 0, |
| totalTokens: 0, |
| contextUsage: { state: "unavailable" }, |
| }); |
| }); |
|
|
| it("mirrors only the CLI reply when the shared recorder already persisted the user turn", async () => { |
| const sessionKey = "agent:main:direct:cli-recorder-owned-user"; |
| const sessionEntry = makeSessionEntry("session-cli-recorder-owned-user"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| await appendTranscriptMessage( |
| { agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath }, |
| { |
| message: { |
| role: "user", |
| content: "canonical current ask", |
| timestamp: Date.now(), |
| }, |
| cwd: tmpDir, |
| }, |
| ); |
|
|
| await persistCliTurnTranscript({ |
| body: "canonical current ask", |
| result: makeCliResult("hello from cli"), |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| storePath, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| config: {}, |
| userMessage: { |
| role: "user", |
| content: "duplicate custom ask", |
| timestamp: Date.now(), |
| }, |
| skipUserTurn: true, |
| }); |
|
|
| const messages = await readSessionMessages( |
| formatSqliteSessionFileMarker({ |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| storePath, |
| }), |
| ); |
| expect(messages.filter((message) => message.role === "user")).toHaveLength(1); |
| expect(messages).toContainEqual( |
| expect.objectContaining({ |
| role: "assistant", |
| content: [{ type: "text", text: "hello from cli" }], |
| }), |
| ); |
| }); |
|
|
| it("does not append a CLI assistant already owned by the runtime", async () => { |
| const sessionKey = "agent:main:direct:runtime-owned-assistant"; |
| const sessionEntry = makeSessionEntry("session-runtime-owned-assistant"); |
| await appendTranscriptMessage( |
| { agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath }, |
| { |
| message: { |
| role: "assistant", |
| content: [{ type: "text", text: "runtime answer" }], |
| timestamp: Date.now(), |
| }, |
| cwd: tmpDir, |
| }, |
| ); |
|
|
| await persistCliTurnTranscript({ |
| body: "ignored prompt", |
| result: makeCliResult("runtime answer"), |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionEntry, |
| storePath, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| config: {}, |
| skipUserTurn: true, |
| skipAssistantTurn: true, |
| }); |
|
|
| const messages = await readSessionMessages( |
| formatSqliteSessionFileMarker({ |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| storePath, |
| }), |
| ); |
| expect(messages).toHaveLength(1); |
| expect(messages[0]).toMatchObject({ |
| role: "assistant", |
| content: [{ type: "text", text: "runtime answer" }], |
| }); |
| }); |
|
|
| it.each([ |
| [false, "end", "completed", "stop"], |
| [true, "end", "completed", "stop"], |
| [false, "error", "failed", "error"], |
| [true, "error", "failed", "error"], |
| [false, "end", "cancelled", "aborted"], |
| [true, "end", "cancelled", "aborted"], |
| ] as const)( |
| "persists ACP assistant media ownership %s and %s/%s as %s", |
| async (managed, phase, status, stopReason) => { |
| const sessionKey = "agent:main:direct:acp-media-ownership"; |
| const sessionEntry = makeSessionEntry("session-acp-media-ownership"); |
| await writeSessionStoreSeed({ [sessionKey]: sessionEntry }); |
| const finalText = "Artifacts ready\nMEDIA:./report.png"; |
|
|
| await persistAcpDispatchTranscript({ |
| cfg: { session: { store: storePath } }, |
| agentId: "main", |
| sessionKey, |
| expectedSessionId: sessionEntry.sessionId, |
| promptText: "Prepare the report", |
| finalText, |
| terminalOutcome: buildAgentRunTerminalOutcomeFromLifecycleEvent({ |
| phase, |
| data: { status, stopReason: phase === "error" ? "error" : "stop" }, |
| }), |
| prepareAssistantTranscriptMessage: managed |
| ? (message, sourceText) => { |
| expect(sourceText).toBe(finalText); |
| expect(message.stopReason).toBe(stopReason); |
| return applyAssistantDeliveryDirectives(message, { |
| managedMediaUrls: ["./report.png"], |
| }); |
| } |
| : undefined, |
| }); |
|
|
| const messages = await readSessionMessages({ |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| storePath, |
| }); |
| expect(messages).toHaveLength(2); |
| expect(messages[1]).toMatchObject({ |
| role: "assistant", |
| content: [{ type: "text", text: finalText }], |
| stopReason, |
| }); |
| if (managed) { |
| expect(messages[1]).toHaveProperty("openclawDelivery.mediaUrls", ["./report.png"]); |
| } else { |
| expect(messages[1]).not.toHaveProperty("openclawDelivery"); |
| } |
| }, |
| ); |
|
|
| it("persists a media-only ACP user turn when the reply is empty", async () => { |
| const sessionKey = "agent:main:direct:acp-media-only"; |
| const sessionEntry = makeSessionEntry("session-acp-media-only"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
|
|
| await persistAcpTurnTranscript({ |
| body: "[media attached: media://inbound/image-1]", |
| terminalOutcome: { reason: "completed", status: "ok" }, |
| transcriptBody: "", |
| userInput: { |
| text: "", |
| media: [{ path: "/media/inbound/image-1.png", contentType: "image/png" }], |
| }, |
| finalText: "", |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| storePath, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| config: {}, |
| }); |
|
|
| expect( |
| await readSessionMessages( |
| formatSqliteSessionFileMarker({ |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| storePath, |
| }), |
| ), |
| ).toContainEqual( |
| expect.objectContaining({ |
| role: "user", |
| content: "", |
| __openclaw: { |
| media: [ |
| expect.objectContaining({ |
| path: "/media/inbound/image-1.png", |
| contentType: "image/png", |
| }), |
| ], |
| }, |
| }), |
| ); |
| }); |
|
|
| it("does not append a CLI transcript after the session is deleted", async () => { |
| const sessionKey = "agent:main:subagent:cli-transcript-deleted"; |
| const staleEntry: SessionEntry = { |
| sessionId: "session-cli-stale", |
| updatedAt: 1, |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: staleEntry }; |
| clearSessionStoreCacheForTest(); |
|
|
| const result = await persistCliTurnTranscript({ |
| body: "late prompt", |
| result: makeCliResult("late reply"), |
| sessionId: staleEntry.sessionId, |
| sessionKey, |
| sessionEntry: staleEntry, |
| sessionStore, |
| storePath, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| config: {}, |
| }); |
|
|
| expect(result).toEqual({ kind: "session-rebound", sessionEntry: undefined }); |
| expect( |
| await loadTranscriptEvents({ agentId: "main", sessionId: staleEntry.sessionId, storePath }), |
| ).toEqual([]); |
| const persisted = readSessionStore(); |
| expect(persisted[sessionKey]).toBeUndefined(); |
| }); |
|
|
| it("persists the transcript body instead of runtime-only CLI prompt context", async () => { |
| const sessionKey = "agent:main:subagent:cli-transcript-clean"; |
| const sessionEntry = makeSessionEntry("session-cli-transcript-clean"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
|
|
| await persistCliTranscriptEntry({ |
| body: [ |
| "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>", |
| "secret runtime context", |
| "<<<END_OPENCLAW_INTERNAL_CONTEXT>>>", |
| "", |
| "visible ask", |
| ].join("\n"), |
| transcriptBody: "visible ask", |
| result: makeCliResult("hello from cli"), |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| storePath, |
| sessionAgentId: "main", |
| sessionCwd: tmpDir, |
| config: {}, |
| }); |
|
|
| const messages = await readSessionMessages({ |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| storePath, |
| }); |
| expectRecordFields(requireRecord(messages[0], "transcript user message"), { |
| role: "user", |
| content: "visible ask", |
| }); |
| }); |
|
|
| it("forwards separate user trigger, channel, and provider context to CLI runs", async () => { |
| const sessionKey = "agent:main:direct:claude-channel-context"; |
| const sessionEntry = makeSessionEntry("openclaw-session-channel"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("channel aware")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| runId: "run-cli-channel-context", |
| opts: { messageProvider: "discord-voice" }, |
| runContext: { |
| currentChannelId: "channel:voice-room", |
| chatId: "voice-room", |
| channelContext: { |
| sender: { id: "sender-voice", unionId: "sender-union" }, |
| chat: { id: "voice-room" }, |
| }, |
| senderId: "sender-voice", |
| }, |
| messageChannel: "discord", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expectMockArgFields(runCliAgentMock, { |
| trigger: "user", |
| messageChannel: "discord", |
| messageProvider: "discord-voice", |
| currentChannelId: "channel:voice-room", |
| chatId: "voice-room", |
| channelContext: { |
| sender: { id: "sender-voice", unionId: "sender-union" }, |
| chat: { id: "voice-room" }, |
| }, |
| senderId: "sender-voice", |
| }); |
| }); |
|
|
| it("forwards message-tool-only policy and requires explicit subagent targets", async () => { |
| const sessionKey = "agent:main:subagent:claude-message-policy"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-message-policy"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("sent")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| runId: "run-cli-message-policy", |
| opts: { sourceReplyDeliveryMode: "message_tool_only" }, |
| messageChannel: "discord", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| sourceReplyDeliveryMode: "message_tool_only", |
| requireExplicitMessageTarget: true, |
| }); |
| }); |
|
|
| it("does not pass auth-order profiles to CLI backends that do not stage them", async () => { |
| const sessionKey = "agent:main:direct:claude-auth-order"; |
| const sessionEntry = makeSessionEntry("openclaw-session-claude-auth-order"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("ambient claude cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| cfg: { |
| auth: { |
| order: { |
| "claude-cli": ["claude-cli:work"], |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "use ambient cli auth", |
| runId: "run-claude-auth-order", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expect(firstRunCliAgentArg().authProfileId).toBeUndefined(); |
| }); |
|
|
| it("does not pass auth-order profiles to configured CLI runtimes that do not stage them", async () => { |
| const sessionKey = "agent:main:direct:anthropic-claude-runtime-auth-order"; |
| const sessionEntry = makeSessionEntry("openclaw-session-anthropic-claude-runtime-auth-order"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "anthropic:work": createApiKeyCredential("anthropic", "test-key"), |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("configured claude cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "anthropic", |
| modelOverride: "claude-opus-4-7", |
| cfg: { |
| auth: { |
| order: { |
| anthropic: ["anthropic:work"], |
| }, |
| }, |
| agents: { |
| defaults: { |
| models: { |
| "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "use ambient cli auth", |
| runId: "run-configured-claude-auth-order", |
| opts: { |
| messageProvider: "discord", |
| bashElevated: { |
| enabled: true, |
| allowed: true, |
| defaultLevel: "ask", |
| fullAccessAvailable: false, |
| fullAccessBlockedReason: "runtime", |
| }, |
| }, |
| runContext: { |
| groupId: "group-a", |
| groupChannel: "ops", |
| groupSpace: "guild-a", |
| }, |
| spawnedBy: "agent:main:discord:channel:parent", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).toHaveBeenCalledTimes(1); |
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| modelProvider: "anthropic", |
| model: "claude-opus-4-7", |
| messageProvider: "discord", |
| groupId: "group-a", |
| groupChannel: "ops", |
| groupSpace: "guild-a", |
| spawnedBy: "agent:main:discord:channel:parent", |
| bashElevated: { |
| enabled: true, |
| allowed: true, |
| defaultLevel: "ask", |
| fullAccessAvailable: false, |
| fullAccessBlockedReason: "runtime", |
| }, |
| }); |
| expect(firstRunCliAgentArg().authProfileId).toBeUndefined(); |
| }); |
|
|
| it("forwards runtime toolsAllow into CLI attempts so the CLI harness can fail closed", async () => { |
| const sessionKey = "agent:main:direct:claude-tools-allow"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-tools-allow"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("restricted cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| runId: "run-cli-tools-allow", |
| opts: { toolsAllow: ["read", "web_search"] }, |
| messageChannel: "discord", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| toolsAllow: ["read", "web_search"], |
| }); |
| }); |
|
|
| it("merges the collector result transport into a restricted CLI toolsAllow", async () => { |
| const sessionKey = "agent:main:direct:claude-collector-tools-allow"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-collector-allow"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("restricted collector cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| body: "collect this", |
| runId: "run-cli-collector-tools-allow", |
| opts: { |
| toolsAllow: ["read"], |
| swarmCollector: true, |
| swarmOutputSchema: { type: "object", properties: { answer: { type: "string" } } }, |
| }, |
| messageChannel: "discord", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| toolsAllow: ["read", "structured_output"], |
| }); |
| }); |
|
|
| it("leaves a schema-less collector's CLI toolsAllow untouched by the merge", async () => { |
| const sessionKey = "agent:main:direct:claude-schemaless-collector-allow"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-schemaless-collector-allow"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("schema-less collector cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| runId: "run-cli-schemaless-collector-allow", |
| opts: { toolsAllow: ["read"], swarmCollector: true }, |
| messageChannel: "discord", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| toolsAllow: ["read"], |
| }); |
| }); |
|
|
| it("leaves a non-collector CLI toolsAllow untouched by the collector merge", async () => { |
| const sessionKey = "agent:main:direct:claude-no-collector-allow"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-no-collector-allow"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("plain collector-less cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| runId: "run-cli-no-collector-allow", |
| opts: { toolsAllow: ["read"] }, |
| messageChannel: "discord", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| toolsAllow: ["read"], |
| }); |
| }); |
|
|
| it.each(SUBAGENT_ANNOUNCE_DELIVERY_CASES)( |
| "bounds CLI subagent completion handoff tools for $name", |
| async ({ |
| sourceReplyDeliveryMode, |
| disableMessageTool, |
| requireExplicitMessageTarget, |
| inheritedToolAllow, |
| inheritedToolDeny, |
| runtimeToolsAllow, |
| operatorTools, |
| sandboxMode, |
| trustedInternalHandoff, |
| expectedDisableTools, |
| expectedToolsAllow, |
| }) => { |
| const sessionKey = "agent:main:direct:claude-announce"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-announce"); |
| const sessionStore = createSubagentAnnounceSessionStore(sessionKey, sessionEntry, { |
| inheritedToolAllow, |
| inheritedToolDeny, |
| }); |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("completion announce")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| cfg: { |
| session: { store: storePath }, |
| ...(operatorTools ? { tools: operatorTools } : {}), |
| ...(sandboxMode ? { agents: { defaults: { sandbox: { mode: sandboxMode } } } } : {}), |
| }, |
| sessionEntry, |
| sessionKey, |
| body: "A background task finished. Process the completion update now.", |
| runId: "run-cli-announce", |
| opts: createSubagentAnnounceHandoffOptions({ |
| sourceReplyDeliveryMode, |
| targetSessionKey: sessionKey, |
| targetSessionId: sessionEntry.sessionId, |
| provider: "claude-cli", |
| model: "opus", |
| disableMessageTool, |
| requireExplicitMessageTarget, |
| runtimeToolsAllow, |
| trustedInternalHandoff, |
| }), |
| messageChannel: "telegram", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| sourceReplyDeliveryMode, |
| requireExplicitMessageTarget: requireExplicitMessageTarget === true, |
| toolsAllow: expectedToolsAllow, |
| disableTools: expectedDisableTools, |
| allowEmptyAssistantReplyAsSilent: true, |
| }); |
| expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it.each(SUBAGENT_ANNOUNCE_EMBEDDED_DELIVERY_CASES)( |
| "bounds embedded subagent completion handoff tools for $name", |
| async ({ |
| sourceReplyDeliveryMode, |
| disableMessageTool, |
| requireExplicitMessageTarget, |
| modelRun, |
| promptMode, |
| inheritedToolAllow, |
| inheritedToolDeny, |
| runtimeToolsAllow, |
| operatorTools, |
| sandboxMode, |
| trustedInternalHandoff, |
| expectedDisableTools, |
| expectedToolsAllow, |
| }) => { |
| const runId = `embedded-announce-${sourceReplyDeliveryMode}-${disableMessageTool}`; |
| const sessionKey = `agent:main:direct:${runId}`; |
| const sessionId = `session-${runId}`; |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId, |
| body: "A background task finished. Process the completion update now.", |
| config: { |
| session: { store: storePath }, |
| ...(operatorTools ? { tools: operatorTools } : {}), |
| ...(sandboxMode ? { agents: { defaults: { sandbox: { mode: sandboxMode } } } } : {}), |
| }, |
| subagentAnnounceEnvelope: { inheritedToolAllow, inheritedToolDeny }, |
| opts: createSubagentAnnounceHandoffOptions({ |
| sourceReplyDeliveryMode, |
| targetSessionKey: sessionKey, |
| targetSessionId: sessionId, |
| provider: "openai", |
| model: "gpt-5.4", |
| disableMessageTool, |
| requireExplicitMessageTarget, |
| modelRun, |
| promptMode, |
| runtimeToolsAllow, |
| trustedInternalHandoff, |
| }), |
| }); |
|
|
| expectRecordFields(embeddedArg, { |
| provider: "openai", |
| sourceReplyDeliveryMode, |
| requireExplicitMessageTarget, |
| toolsAllow: expectedToolsAllow, |
| disableTools: expectedDisableTools, |
| disableMessageTool: disableMessageTool || undefined, |
| modelRun: modelRun || undefined, |
| promptMode, |
| allowEmptyAssistantReplyAsSilent: true, |
| }); |
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it("keeps trusted CLI completion handoffs tool-free when inherited policy is restricted", async () => { |
| const sessionKey = "agent:main:direct:claude-trusted-announce"; |
| const childSessionKey = "agent:openclaw:subagent:child"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-trusted-announce"); |
| const sessionStore: Record<string, SessionEntry> = { |
| [sessionKey]: sessionEntry, |
| [childSessionKey]: { |
| sessionId: "child-session-id", |
| updatedAt: Date.now(), |
| spawnedBy: sessionKey, |
| spawnDepth: 1, |
| subagentRole: "orchestrator", |
| subagentControlScope: "children", |
| inheritedToolPolicyVersion: 1, |
| inheritedToolDeny: ["exec"], |
| }, |
| }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("trusted announce")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| cfg: { session: { store: storePath } } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "A background task finished. Process the completion update now.", |
| runId: "run-cli-trusted-announce", |
| opts: { |
| trustedInternalHandoff: { |
| kind: "subagent-completion", |
| sourceSessionKey: childSessionKey, |
| sourceSessionId: "child-session-id", |
| targetSessionKey: sessionKey, |
| targetSessionId: sessionEntry.sessionId, |
| provider: "claude-cli", |
| model: "opus", |
| }, |
| inputProvenance: { |
| kind: "inter_session", |
| sourceSessionKey: childSessionKey, |
| sourceChannel: "internal", |
| sourceTool: "subagent_announce", |
| }, |
| internalEvents: [ |
| { |
| type: "task_completion", |
| source: "subagent", |
| childSessionKey, |
| childSessionId: "child-session-id", |
| announceType: "subagent task", |
| taskLabel: "review", |
| status: "ok", |
| statusLabel: "completed", |
| result: "child output", |
| replyInstruction: "Relay this completion.", |
| }, |
| ], |
| }, |
| messageChannel: "telegram", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| disableTools: true, |
| allowEmptyAssistantReplyAsSilent: true, |
| }); |
| expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("stamps CLI prompts and forwards the transcript target", async () => { |
| vi.useFakeTimers(); |
| vi.setSystemTime(new Date("2024-06-05T15:30:00Z")); |
| const sessionKey = "agent:main:direct:claude-timestamp"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-timestamp"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("timestamped cli")); |
| const userTurnTranscriptRecorder = createUserTurnTranscriptRecorder({ |
| input: { text: "canonical timestamp question" }, |
| target: createTestUserTurnTranscriptTarget({ |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionEntry, |
| agentId: "main", |
| cwd: tmpDir, |
| storePath, |
| }), |
| }); |
| const sessionTarget = { |
| agentId: "main", |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| storePath, |
| }; |
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| cfg: { agents: { defaults: { userTimezone: "UTC" } } } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "what time is it?", |
| transcriptBody: "canonical timestamp question", |
| runId: "run-cli-timestamp", |
| messageChannel: "discord", |
| sessionStore, |
| sessionTarget, |
| userTurnTranscriptRecorder, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| prompt: "[Wed 2024-06-05 15:30 UTC] what time is it?", |
| transcriptPrompt: "canonical timestamp question", |
| imagePrompt: "what time is it?", |
| userTurnTranscriptRecorder, |
| sessionTarget, |
| suppressNextUserMessagePersistence: false, |
| }); |
| }); |
|
|
| it.each([ |
| { |
| label: "a fallback completion report", |
| isFallbackRetry: true, |
| inputProvenance: { kind: "inter_session", sourceTool: "subagent_announce" }, |
| expected: "report_only", |
| }, |
| { |
| label: "a fallback answering ordinary user input", |
| isFallbackRetry: true, |
| inputProvenance: { kind: "external_user" }, |
| expected: undefined, |
| }, |
| { |
| label: "a primary completion report", |
| isFallbackRetry: false, |
| inputProvenance: { kind: "inter_session", sourceTool: "subagent_announce" }, |
| expected: undefined, |
| }, |
| ])( |
| "stamps the command-fallback CLI grant delegation capability for $label", |
| async ({ isFallbackRetry, inputProvenance, expected }) => { |
| const runId = `run-command-fallback-delegation-${String(isFallbackRetry)}-${expected}`; |
| const sessionKey = `agent:main:direct:${runId}`; |
| const sessionEntry: SessionEntry = { |
| sessionId: `session-${runId}`, |
| updatedAt: Date.now(), |
| }; |
| const cfg = { |
| session: { store: storePath }, |
| agents: { |
| defaults: { |
| models: { |
| "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig; |
| await writeSessionStoreSeed({ [sessionKey]: sessionEntry }); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("delegation gate")); |
|
|
| await runAgentAttempt({ |
| providerOverride: "anthropic", |
| originalProvider: "anthropic", |
| modelOverride: "claude-opus-4-7", |
| cfg, |
| sessionEntry, |
| sessionId: sessionEntry.sessionId, |
| sessionKey, |
| sessionAgentId: "main", |
| sessionFile: path.join(tmpDir, `${runId}.jsonl`), |
| workspaceDir: tmpDir, |
| body: "report the completion", |
| isFallbackRetry, |
| resolvedThinkLevel: "medium", |
| timeoutMs: 1_000, |
| runId, |
| opts: { |
| message: "report the completion", |
| inputProvenance, |
| } as RunAgentAttemptParams["opts"], |
| runContext: {} as RunAgentAttemptParams["runContext"], |
| spawnedBy: undefined, |
| messageChannel: "telegram", |
| skillsSnapshot: undefined, |
| resolvedVerboseLevel: undefined, |
| agentDir, |
| onAgentEvent: vi.fn(), |
| authProfileProvider: "anthropic", |
| sessionStore: { [sessionKey]: sessionEntry }, |
| storePath, |
| sessionHasHistory: false, |
| }); |
|
|
| |
| |
| |
| const grantContext = buildCliMcpGrantContext({ |
| run: firstRunCliAgentArg() as unknown as Parameters< |
| typeof buildCliMcpGrantContext |
| >[0]["run"], |
| config: cfg, |
| requireExplicitMessageTarget: false, |
| agentId: "main", |
| modelProvider: "anthropic", |
| modelId: "claude-opus-4-7", |
| }); |
| expect(grantContext.delegationCapability).toBe(expected); |
| }, |
| ); |
|
|
| it("keeps a plugin-owned CLI request on the CLI path after usage records its runtime", async () => { |
| const sessionEntry = makeSessionEntry("plugin-cli-session", { |
| pluginOwnerId: "cli-owner", |
| modelSelectionLocked: true, |
| agentRuntimeOverride: "claude-cli", |
| agentHarnessId: "claude-cli", |
| }); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("continued")); |
|
|
| await runAgentAttempt({ |
| sessionKey: "agent:main:main", |
| workspaceDir: tmpDir, |
| agentDir: tmpDir, |
| providerOverride: "anthropic", |
| modelOverride: "claude-sonnet-4-6", |
| sessionEntry, |
| agentHarnessRuntimeOverride: "claude-cli", |
| runId: "plugin-cli-continuation", |
| }); |
|
|
| expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); |
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| modelProvider: "anthropic", |
| model: "claude-sonnet-4-6", |
| }); |
| }); |
|
|
| it("routes canonical Anthropic models through the configured Claude CLI runtime", async () => { |
| const sessionKey = "agent:main:direct:canonical-claude-cli"; |
| const sessionEntry = makeSessionEntry("openclaw-session-canonical-cli"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("canonical cli")); |
| const fallbackRuntimeState: NonNullable<RunAgentAttemptParams["fallbackRuntimeState"]> = {}; |
| const images = [{ type: "image" as const, data: "aGVsbG8=", mimeType: "image/png" }]; |
| const imageOrder = ["inline" as const]; |
|
|
| await runStoredAttempt({ |
| providerOverride: "anthropic", |
| modelOverride: "claude-opus-4-7", |
| cfg: { |
| agents: { |
| defaults: { |
| models: { |
| "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| isFallbackRetry: true, |
| runId: "run-canonical-claude-cli", |
| opts: { images, imageOrder }, |
| messageChannel: "telegram", |
| sessionStore, |
| fallbackRuntimeState, |
| }); |
|
|
| expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); |
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| model: "claude-opus-4-7", |
| imagePrompt: "route this", |
| images, |
| imageOrder, |
| }); |
| expect(fallbackRuntimeState.originRuntime).toBe("cli"); |
|
|
| const fallbackArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "run-canonical-claude-cli-fallback", |
| isFallbackRetry: true, |
| fallbackRuntimeState, |
| opts: { images }, |
| }); |
| expect(fallbackArg.images).toEqual(images); |
| }); |
|
|
| it("publishes logical cancellation before an embedded-to-CLI fallback starts", async () => { |
| const sessionKey = "agent:main:direct:cli-lifecycle-handoff"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cli-lifecycle-handoff"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("fallback complete")); |
| const controller = new AbortController(); |
| const handoffToCli = vi.fn(); |
| const deferredLifecycle: NonNullable<RunAgentAttemptParams["deferredLifecycle"]> = { |
| signal: controller.signal, |
| beginRetryWait: () => undefined, |
| abort: vi.fn(), |
| adopt: vi.fn(), |
| handoffToCli, |
| complete: vi.fn(async () => undefined), |
| }; |
|
|
| await runStoredAttempt({ |
| providerOverride: "anthropic", |
| modelOverride: "claude-opus-4-7", |
| cfg: { |
| agents: { |
| defaults: { |
| models: { |
| "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "continue after overload", |
| isFallbackRetry: true, |
| runId: "run-cli-lifecycle-handoff", |
| messageChannel: "telegram", |
| sessionStore, |
| deferredLifecycle, |
| }); |
|
|
| expect(handoffToCli).toHaveBeenCalledOnce(); |
| expect(handoffToCli.mock.invocationCallOrder[0]).toBeLessThan( |
| runCliAgentMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, |
| ); |
| expectMockArgFields(runCliAgentMock, { abortSignal: controller.signal }); |
| }); |
|
|
| it("routes provider-qualified Anthropic shorthand through the configured Claude CLI runtime", async () => { |
| const sessionKey = "agent:main:direct:shorthand-claude-cli"; |
| const sessionEntry = makeSessionEntry("openclaw-session-shorthand-cli"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("shorthand cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "anthropic", |
| modelOverride: "opus-4.7", |
| cfg: { |
| agents: { |
| defaults: { |
| models: { |
| "anthropic/opus-4.7": { agentRuntime: { id: "claude-cli" } }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| runId: "run-shorthand-claude-cli", |
| messageChannel: "telegram", |
| sessionStore, |
| }); |
|
|
| expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); |
| expectMockArgFields(runCliAgentMock, { |
| provider: "claude-cli", |
| model: "opus-4.7", |
| }); |
| }); |
|
|
| it("routes canonical OpenAI models through the configured embedded Codex runtime", async () => { |
| const sessionKey = "agent:main:direct:canonical-codex-cli"; |
| const sessionEntry = { |
| ...makeSessionEntry("openclaw-session-canonical-codex-cli"), |
| toolOverrides: { webSearch: false }, |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| payloads: [{ text: "canonical codex embedded" }], |
| meta: { |
| durationMs: 5, |
| finalAssistantVisibleText: "canonical codex embedded", |
| executionTrace: { runner: "openclaw" }, |
| }, |
| }); |
|
|
| await runStoredAttempt({ |
| cfg: { |
| agents: { |
| defaults: { |
| models: { |
| "openai/gpt-5.4": { agentRuntime: { id: "codex" } }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "route this", |
| runId: "run-canonical-codex-cli", |
| runContext: { |
| chatId: "chat-embedded", |
| channelContext: { |
| sender: { id: "sender-embedded", unionId: "embedded-union" }, |
| chat: { id: "chat-embedded" }, |
| }, |
| senderId: "sender-embedded", |
| }, |
| messageChannel: "telegram", |
| sessionStore, |
| }); |
|
|
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "openai", |
| model: "gpt-5.4", |
| chatId: "chat-embedded", |
| channelContext: { |
| sender: { id: "sender-embedded", unionId: "embedded-union" }, |
| chat: { id: "chat-embedded" }, |
| }, |
| senderId: "sender-embedded", |
| toolOverrides: { webSearch: false }, |
| }); |
| }); |
|
|
| it("keeps live stream output for visible subagent lane runs", async () => { |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| opts: { lane: "subagent" }, |
| runId: "visible-subagent-stream", |
| }); |
|
|
| expect(embeddedArg.suppressLiveStreamOutput).toBe(false); |
| expect(embeddedArg.terminalReplyExpectation).toBe("optional"); |
| expect(embeddedArg.allowEmptyAssistantReplyAsSilent).toBe(true); |
| }); |
|
|
| it.each([ |
| { |
| name: "subagent lane", |
| lane: "subagent" as const, |
| sessionKey: "agent:main:subagent:cli-empty-completion", |
| expected: true, |
| }, |
| { |
| name: "ordinary lane", |
| lane: undefined, |
| sessionKey: "agent:main:direct:cli-empty-completion", |
| expected: false, |
| }, |
| ])("allows empty CLI output only for $name runs", async ({ lane, sessionKey, expected }) => { |
| const sessionEntry = makeSessionEntry(`session-${lane ?? "ordinary"}`); |
| const sessionStore = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("cli completion")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "opus", |
| sessionEntry, |
| sessionKey, |
| body: "complete the task", |
| runId: `run-${lane ?? "ordinary"}-cli-empty-completion`, |
| opts: lane ? { lane } : {}, |
| sessionStore, |
| }); |
|
|
| expect(firstRunCliAgentArg().allowEmptyAssistantReplyAsSilent).toBe(expected); |
| expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("forwards exact cron creator authority into embedded execution", async () => { |
| const runId = "embedded-cron-creator-authority"; |
| const capability = createCronCreatorAuthorityCapability(runId); |
| if (!capability) { |
| throw new Error("expected cron creator authority capability"); |
| } |
|
|
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId, |
| opts: { cronCreatorAuthorityCapability: capability }, |
| }); |
|
|
| expect(embeddedArg.cronCreatorAuthorityCapability).toBe(capability); |
| }); |
|
|
| it("forwards Gateway plugin runtime binding to embedded runs", async () => { |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| opts: { allowGatewaySubagentBinding: true }, |
| runId: "gateway-plugin-runtime-binding", |
| }); |
|
|
| expect(embeddedArg.allowGatewaySubagentBinding).toBe(true); |
| }); |
|
|
| it("suppresses live stream output for hidden internal runs", async () => { |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| opts: { lane: "subagent", sessionEffects: "internal" }, |
| runId: "internal-subagent-stream", |
| }); |
|
|
| expect(embeddedArg.suppressLiveStreamOutput).toBe(true); |
| }); |
|
|
| it("preserves embedded OpenAI-compatible tools for the exact trusted completion", async () => { |
| const runId = "trusted-glm-completion"; |
| const childSessionKey = "agent:main:subagent:glm-child"; |
| const sessionKey = `agent:main:direct:${runId}`; |
| const sessionId = `session-${runId}`; |
| const trustedInternalHandoff = { |
| kind: "subagent-completion" as const, |
| sourceSessionKey: childSessionKey, |
| sourceSessionId: "glm-child-session", |
| targetSessionKey: sessionKey, |
| targetSessionId: sessionId, |
| provider: "openai", |
| model: "glm-4.5", |
| }; |
|
|
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId, |
| modelOverride: "glm-4.5", |
| additionalSessionEntries: { |
| [childSessionKey]: { |
| sessionId: "glm-child-session", |
| spawnedBy: sessionKey, |
| spawnDepth: 1, |
| subagentRole: "orchestrator", |
| subagentControlScope: "children", |
| inheritedToolPolicyVersion: 1, |
| }, |
| }, |
| opts: { |
| trustedInternalHandoff, |
| inputProvenance: { |
| kind: "inter_session", |
| sourceSessionKey: childSessionKey, |
| sourceTool: "subagent_announce", |
| }, |
| internalEvents: [ |
| { |
| type: "task_completion", |
| source: "subagent", |
| childSessionKey, |
| childSessionId: "glm-child-session", |
| announceType: "subagent task", |
| taskLabel: "review", |
| status: "ok", |
| statusLabel: "completed", |
| result: "child output", |
| replyInstruction: "Review and continue.", |
| }, |
| ], |
| }, |
| }); |
|
|
| expect(embeddedArg.disableTools).toBe(false); |
| expect(embeddedArg.trustedInternalHandoff).toEqual(trustedInternalHandoff); |
| }); |
|
|
| it("preserves embedded tools for a verified nested subagent completion", async () => { |
| const runId = "trusted-nested-glm-completion"; |
| const requesterSessionKey = "agent:main:subagent:parent-child"; |
| const childSessionKey = "agent:main:subagent:leaf"; |
| const requesterSessionId = "parent-child-session"; |
| const childSessionId = "leaf-session"; |
| const trustedInternalHandoff = { |
| kind: "subagent-completion" as const, |
| sourceSessionKey: childSessionKey, |
| sourceSessionId: childSessionId, |
| targetSessionKey: requesterSessionKey, |
| targetSessionId: requesterSessionId, |
| provider: "openai", |
| model: "glm-4.5", |
| }; |
|
|
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId, |
| sessionKey: requesterSessionKey, |
| modelOverride: "glm-4.5", |
| sessionEntry: { |
| sessionId: requesterSessionId, |
| spawnedBy: "agent:main:direct:root", |
| spawnDepth: 1, |
| subagentRole: "orchestrator", |
| subagentControlScope: "children", |
| inheritedToolPolicyVersion: 1, |
| inheritedToolDeny: ["exec"], |
| }, |
| additionalSessionEntries: { |
| [childSessionKey]: { |
| sessionId: childSessionId, |
| spawnedBy: requesterSessionKey, |
| spawnDepth: 2, |
| subagentRole: "leaf", |
| subagentControlScope: "none", |
| inheritedToolPolicyVersion: 1, |
| inheritedToolDeny: ["exec", "read"], |
| }, |
| }, |
| opts: { |
| trustedInternalHandoff, |
| inputProvenance: { |
| kind: "inter_session", |
| sourceSessionKey: childSessionKey, |
| sourceTool: "subagent_announce", |
| }, |
| internalEvents: [ |
| { |
| type: "task_completion", |
| source: "subagent", |
| childSessionKey, |
| childSessionId, |
| announceType: "subagent task", |
| taskLabel: "review", |
| status: "ok", |
| statusLabel: "completed", |
| result: "child output", |
| replyInstruction: "Review and continue.", |
| }, |
| ], |
| }, |
| }); |
|
|
| expect(embeddedArg.disableTools).toBe(false); |
| expect(embeddedArg.trustedInternalHandoff).toEqual(trustedInternalHandoff); |
| }); |
|
|
| it("keeps duplicate completion events tool-free despite an otherwise exact capability", async () => { |
| const runId = "duplicate-glm-completion"; |
| const childSessionKey = "agent:main:subagent:duplicate-child"; |
| const sessionKey = `agent:main:direct:${runId}`; |
| const sessionId = `session-${runId}`; |
| const completionEvent = { |
| type: "task_completion" as const, |
| source: "subagent" as const, |
| childSessionKey, |
| childSessionId: "duplicate-child-session", |
| announceType: "subagent task", |
| taskLabel: "review", |
| status: "ok" as const, |
| statusLabel: "completed", |
| result: "child output", |
| replyInstruction: "Review and continue.", |
| }; |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId, |
| modelOverride: "glm-4.5", |
| additionalSessionEntries: { |
| [childSessionKey]: { |
| sessionId: completionEvent.childSessionId, |
| spawnedBy: sessionKey, |
| spawnDepth: 1, |
| subagentRole: "orchestrator", |
| subagentControlScope: "children", |
| inheritedToolPolicyVersion: 1, |
| }, |
| }, |
| opts: { |
| trustedInternalHandoff: { |
| kind: "subagent-completion", |
| sourceSessionKey: childSessionKey, |
| sourceSessionId: completionEvent.childSessionId, |
| targetSessionKey: sessionKey, |
| targetSessionId: sessionId, |
| provider: "openai", |
| model: "glm-4.5", |
| }, |
| inputProvenance: { |
| kind: "inter_session", |
| sourceSessionKey: childSessionKey, |
| sourceTool: "subagent_announce", |
| }, |
| internalEvents: [completionEvent, completionEvent], |
| }, |
| }); |
|
|
| expect(embeddedArg.disableTools).toBe(true); |
| expect(embeddedArg.trustedInternalHandoff).toBeUndefined(); |
| }); |
|
|
| it("keeps an exact completion capability tool-free when persisted lineage is missing", async () => { |
| const runId = "missing-lineage-completion"; |
| const childSessionKey = "agent:main:subagent:missing"; |
| const sessionKey = `agent:main:direct:${runId}`; |
| const sessionId = `session-${runId}`; |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId, |
| modelOverride: "glm-4.5", |
| opts: { |
| trustedInternalHandoff: { |
| kind: "subagent-completion", |
| sourceSessionKey: childSessionKey, |
| targetSessionKey: sessionKey, |
| targetSessionId: sessionId, |
| provider: "openai", |
| model: "glm-4.5", |
| }, |
| inputProvenance: { |
| kind: "inter_session", |
| sourceSessionKey: childSessionKey, |
| sourceTool: "subagent_announce", |
| }, |
| internalEvents: [ |
| { |
| type: "task_completion", |
| source: "subagent", |
| childSessionKey, |
| announceType: "subagent task", |
| taskLabel: "review", |
| status: "ok", |
| statusLabel: "completed", |
| result: "child output", |
| replyInstruction: "Review and continue.", |
| }, |
| ], |
| }, |
| }); |
|
|
| expect(embeddedArg.disableTools).toBe(true); |
| expect(embeddedArg.trustedInternalHandoff).toBeUndefined(); |
| }); |
|
|
| it("forwards canonical transcript text without replacing embedded image content", async () => { |
| const recorder = createUserTurnTranscriptRecorder({ |
| target: createTestUserTurnTranscriptTarget({ |
| sessionId: "session-embedded-image-turn", |
| sessionKey: "agent:main:direct:embedded-image-turn", |
| agentId: "main", |
| cwd: tmpDir, |
| storePath, |
| }), |
| }); |
| const images = [{ type: "image" as const, data: "aGVsbG8=", mimeType: "image/png" }]; |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "embedded-image-turn", |
| body: "runtime image prompt", |
| transcriptBody: "canonical image caption", |
| opts: { images }, |
| userTurnTranscriptRecorder: recorder, |
| }); |
|
|
| expect(embeddedArg).toMatchObject({ |
| prompt: "runtime image prompt", |
| transcriptPrompt: "canonical image caption", |
| images, |
| userTurnTranscriptRecorder: recorder, |
| suppressNextUserMessagePersistence: false, |
| }); |
| }); |
|
|
| it.each([ |
| { originRuntime: undefined, retry: false, expectedImages: true }, |
| { originRuntime: "cli" as const, retry: true, expectedImages: true }, |
| { originRuntime: "embedded" as const, retry: true, expectedImages: false }, |
| ])( |
| "forwards embedded images for originRuntime=$originRuntime retry=$retry", |
| async ({ originRuntime, retry, expectedImages }) => { |
| const images = [{ type: "image" as const, data: "aGVsbG8=", mimeType: "image/png" }]; |
| const imageOrder = ["inline" as const]; |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: `embedded-image-fallback-${originRuntime ?? "unset"}-${retry}`, |
| isFallbackRetry: retry, |
| fallbackRuntimeState: originRuntime === undefined ? undefined : { originRuntime }, |
| opts: { images, imageOrder }, |
| }); |
|
|
| expect(embeddedArg.images).toEqual(expectedImages ? images : undefined); |
| expect(embeddedArg.imageOrder).toEqual(expectedImages ? imageOrder : undefined); |
| }, |
| ); |
|
|
| it("records raw CLI-shaped model runs as embedded origins", async () => { |
| const images = [{ type: "image" as const, data: "aGVsbG8=", mimeType: "image/png" }]; |
| const fallbackRuntimeState: NonNullable<RunAgentAttemptParams["fallbackRuntimeState"]> = {}; |
|
|
| const firstArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "raw-cli-shaped-origin", |
| providerOverride: "claude-cli", |
| modelOverride: "claude-opus-4-7", |
| fallbackRuntimeState, |
| opts: { modelRun: true, images }, |
| }); |
| expect(fallbackRuntimeState.originRuntime).toBe("embedded"); |
| expect(firstArg.images).toEqual(images); |
|
|
| const retryArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "raw-cli-shaped-origin-retry", |
| providerOverride: "claude-cli", |
| modelOverride: "claude-opus-4-7", |
| isFallbackRetry: true, |
| fallbackRuntimeState, |
| opts: { modelRun: true, images }, |
| }); |
| expect(retryArg.images).toBeUndefined(); |
| }); |
|
|
| it("forwards selected auth profiles through metadata-scoped provider aliases", async () => { |
| const sessionKey = "agent:main:direct:metadata-auth-alias"; |
| const sessionEntry = makeSessionEntry("openclaw-session-metadata-auth-alias", { |
| authProfileOverride: "openai:work", |
| authProfileOverrideSource: "user", |
| }); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "openai:work": { |
| type: "oauth", |
| provider: "openai", |
| access: "access-token", |
| refresh: "refresh-token", |
| expires: Date.now() + 60_000, |
| }, |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runStoredAttempt({ |
| providerOverride: "fixture", |
| modelOverride: "fixture-model", |
| sessionEntry, |
| sessionKey, |
| body: "use selected auth", |
| runId: "run-metadata-auth-alias", |
| sessionStore, |
| pluginsEnabled: true, |
| metadataSnapshot: { |
| plugins: [ |
| { |
| id: "alias-owner", |
| origin: "global", |
| providerAuthAliases: { fixture: "openai" }, |
| }, |
| ], |
| } as never, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "fixture", |
| model: "fixture-model", |
| authProfileId: "openai:work", |
| authProfileIdSource: "user", |
| }); |
| }); |
|
|
| it("forwards user-pinned OpenAI API-key backup profiles to Codex harness runs", async () => { |
| const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); |
| const sessionKey = "agent:main:direct:openai-chatgpt-api-key"; |
| const sessionEntry = makeSessionEntry("openclaw-session-openai-chatgpt-api-key", { |
| authProfileOverride: "openai:backup", |
| authProfileOverrideSource: "user", |
| }); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "openai:backup": createApiKeyCredential("openai", "sk-test"), |
| }), |
| agentDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| clearAgentHarnesses(); |
| registerAgentHarness({ |
| id: "codex", |
| label: "Codex", |
| supports: () => ({ supported: true, priority: 100 }), |
| runAttempt: vi.fn(), |
| }); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| try { |
| await runStoredAttempt({ |
| sessionEntry, |
| sessionKey, |
| body: "use backup auth", |
| runId: "run-openai-chatgpt-api-key-backup", |
| sessionStore, |
| }); |
| } finally { |
| clearAgentHarnesses(); |
| } |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "openai", |
| model: "gpt-5.4", |
| authProfileId: "openai:backup", |
| authProfileIdSource: "user", |
| }); |
| }); |
|
|
| it("keeps one-shot model runs on the raw embedded provider path", async () => { |
| const sessionKey = "agent:main:direct:model-run-raw"; |
| const sessionEntry = makeSessionEntry("openclaw-session-model-run-raw"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runStoredAttempt({ |
| providerOverride: "anthropic", |
| modelOverride: "claude-opus-4-7", |
| cfg: { |
| agents: { |
| defaults: { |
| agentRuntime: { id: "claude-cli" }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| sessionKey, |
| body: "raw prompt", |
| runId: "run-model-run-raw", |
| opts: { |
| modelRun: true, |
| promptMode: "none", |
| messageProvider: "discord-voice", |
| inputProvenance: { |
| kind: "inter_session", |
| sourceSessionKey: "agent:main:discord:source", |
| sourceTool: "sessions_send", |
| }, |
| }, |
| messageChannel: "discord", |
| sessionStore, |
| sessionHasHistory: true, |
| }); |
|
|
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "anthropic", |
| model: "claude-opus-4-7", |
| agentHarnessId: undefined, |
| agentHarnessRuntimeOverride: "openclaw", |
| prompt: "raw prompt", |
| messageChannel: "discord", |
| messageProvider: "discord-voice", |
| modelRun: true, |
| promptMode: "none", |
| disableTools: true, |
| }); |
| expect(firstEmbeddedAgentArg().prompt).not.toContain("[Inter-session message]"); |
| }); |
|
|
| it("forwards trusted elevated defaults to embedded agent runs", async () => { |
| const sessionKey = "agent:main:telegram:direct:123"; |
| const sessionEntry = makeSessionEntry("openclaw-session-elevated-followup"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| const bashElevated = { |
| enabled: true, |
| allowed: true, |
| defaultLevel: "on" as const, |
| }; |
| await writeSessionStoreSeed(sessionStore); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runStoredAttempt({ |
| sessionEntry, |
| sessionKey, |
| body: "follow up after approved exec", |
| runId: "run-elevated-followup", |
| opts: { bashElevated }, |
| messageChannel: "telegram", |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "openai", |
| model: "gpt-5.4", |
| bashElevated, |
| }); |
| }); |
|
|
| it("forwards one-shot CLI cleanup to CLI providers", async () => { |
| const sessionKey = "agent:main:direct:cleanup-claude-cli"; |
| const sessionEntry = makeSessionEntry("openclaw-session-cleanup-cli"); |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await writeSessionStoreSeed(sessionStore); |
| runCliAgentMock.mockResolvedValueOnce(makeCliResult("cleanup cli")); |
|
|
| await runStoredAttempt({ |
| providerOverride: "claude-cli", |
| modelOverride: "claude-opus-4-7", |
| sessionEntry, |
| sessionKey, |
| body: "cleanup", |
| runId: "run-cleanup-claude-cli", |
| opts: { |
| cleanupBundleMcpOnRunEnd: true, |
| cleanupCliLiveSessionOnRunEnd: true, |
| }, |
| sessionStore, |
| }); |
|
|
| expectMockArgFields(runCliAgentMock, { |
| cleanupBundleMcpOnRunEnd: true, |
| cleanupCliLiveSessionOnRunEnd: true, |
| }); |
| expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it.each([ |
| { reportedId: "openai:configured", source: "user" }, |
| { reportedId: "openai:rotated", source: "auto" }, |
| { reportedId: undefined, source: undefined }, |
| ] as const)( |
| "reports successful maintenance auth $reportedId without artifact capture", |
| async ({ reportedId, source }) => { |
| const onSuccessfulAuthProfile = vi.fn(); |
| runEmbeddedAgentMock.mockImplementationOnce( |
| async (params: RunEmbeddedAgentInternalParams) => { |
| expect(params.onSuccessfulAuthBinding).toBeUndefined(); |
| params.onSuccessfulAuthProfile?.(reportedId); |
| return { meta: { durationMs: 1 } } satisfies EmbeddedAgentRunResult; |
| }, |
| ); |
|
|
| await runStoredAttempt({ |
| sessionEntry: makeSessionEntry("maintenance-auth", { |
| authProfileOverride: "openai:stale", |
| authProfileOverrideSource: "auto", |
| }), |
| sessionKey: "agent:main:direct:maintenance-auth", |
| modelOverride: "gpt-5.6-luna", |
| configuredAuthProfileId: "openai:configured", |
| onSuccessfulAuthProfile, |
| }); |
|
|
| expect(onSuccessfulAuthProfile).toHaveBeenCalledExactlyOnceWith({ |
| authProfileId: reportedId, |
| authProfileIdSource: source, |
| }); |
| }, |
| ); |
|
|
| it("replaces a stale automatic session profile with the configured model profile", async () => { |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "configured-auth-replaces-auto", |
| configuredAuthProfileId: "openai:verified", |
| sessionEntry: { |
| authProfileOverride: "openai:stale-auto", |
| authProfileOverrideSource: "auto", |
| }, |
| }); |
|
|
| expectRecordFields(embeddedArg, { |
| authProfileId: "openai:verified", |
| authProfileIdSource: "user", |
| }); |
| }); |
|
|
| it("replaces a legacy marker-backed automatic profile with the configured model profile", async () => { |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "configured-auth-replaces-legacy-auto", |
| configuredAuthProfileId: "openai:verified", |
| sessionEntry: { |
| authProfileOverride: "openai:legacy-auto", |
| authProfileOverrideCompactionCount: 0, |
| }, |
| }); |
|
|
| expectRecordFields(embeddedArg, { |
| authProfileId: "openai:verified", |
| authProfileIdSource: "user", |
| }); |
| }); |
|
|
| it("preserves an explicit session profile over the configured model profile", async () => { |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "session-auth-over-configured", |
| configuredAuthProfileId: "openai:verified", |
| sessionEntry: { |
| authProfileOverride: "openai:session-choice", |
| authProfileOverrideSource: "user", |
| }, |
| }); |
|
|
| expectRecordFields(embeddedArg, { |
| authProfileId: "openai:session-choice", |
| authProfileIdSource: "user", |
| }); |
| }); |
|
|
| it("preserves a legacy source-less user profile over the configured model profile", async () => { |
| const embeddedArg = await runOpenClawEmbeddedAttemptForTest({ |
| runId: "legacy-session-auth-over-configured", |
| configuredAuthProfileId: "openai:verified", |
| sessionEntry: { |
| authProfileOverride: "openai:legacy-user", |
| }, |
| }); |
|
|
| expectRecordFields(embeddedArg, { |
| authProfileId: "openai:legacy-user", |
| authProfileIdSource: "user", |
| }); |
| }); |
| }); |
|
|
| describe("embedded attempt harness pinning", () => { |
| let tmpDir: string; |
|
|
| beforeEach(async () => { |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-attempt-")); |
| runCliAgentMock.mockReset(); |
| runEmbeddedAgentMock.mockReset(); |
| }); |
|
|
| afterEach(async () => { |
| await fs.rm(tmpDir, { recursive: true, force: true }); |
| }); |
|
|
| function runHarnessAttempt( |
| overrides: Omit<RunAgentAttemptOverrides, "agentDir" | "sessionKey" | "workspaceDir">, |
| ) { |
| return runAgentAttempt({ |
| sessionKey: "agent:main:main", |
| workspaceDir: tmpDir, |
| agentDir: tmpDir, |
| ...overrides, |
| }); |
| } |
|
|
| it("does not store a session harness pin for default OpenAI Codex routing", async () => { |
| const sessionEntry = makeSessionEntry("legacy-session"); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| sessionEntry, |
| runId: "run-legacy-runtime-pin", |
| sessionHasHistory: true, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); |
| }); |
|
|
| it("keeps a catalog-adopted Codex harness pinned for direct command attempts", async () => { |
| const sessionEntry = makeSessionEntry("mixed-provider-session", { |
| agentHarnessId: "codex", |
| modelSelectionLocked: true, |
| pluginExtensions: { |
| codex: { |
| supervision: { |
| sourceThreadId: "019f-codex-thread", |
| modelLocked: true, |
| }, |
| }, |
| }, |
| }); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| providerOverride: "anthropic", |
| modelOverride: "claude-opus-4-7", |
| cfg: { |
| agents: { |
| defaults: { |
| models: { |
| "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| agentHarnessRuntimeOverride: "codex", |
| body: "switch to minimax", |
| runId: "run-mixed-provider-auto-runtime", |
| sessionHasHistory: true, |
| }); |
|
|
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "anthropic", |
| model: "claude-opus-4-7", |
| agentHarnessId: "codex", |
| agentHarnessRuntimeOverride: "codex", |
| modelSelectionLocked: true, |
| }); |
| }); |
|
|
| it("ignores stale session Codex harness pins on non-OpenAI model switches", async () => { |
| const sessionEntry = makeSessionEntry("mixed-provider-session", { |
| agentHarnessId: "codex", |
| }); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| providerOverride: "minimax", |
| modelOverride: "minimax-m2.7", |
| sessionEntry, |
| body: "switch to minimax", |
| runId: "run-mixed-provider-auto-runtime", |
| sessionHasHistory: true, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); |
| }); |
|
|
| it("does not leak a persisted CLI harness alias across providers", async () => { |
| const sessionEntry = makeSessionEntry("legacy-cli-pin", { |
| agentHarnessId: "claude-cli", |
| }); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| sessionEntry, |
| runId: "run-provider-incompatible-cli-pin", |
| sessionHasHistory: true, |
| }); |
|
|
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "openai", |
| model: "gpt-5.4", |
| agentHarnessId: undefined, |
| agentHarnessRuntimeOverride: undefined, |
| }); |
| }); |
|
|
| it("forwards invocation tool restrictions into embedded attempts", async () => { |
| const sessionEntry = makeSessionEntry("tools-allow-session"); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| sessionEntry, |
| body: "read only", |
| runId: "run-tools-allow", |
| opts: { toolsAllow: ["read", "web_search"], codeModeOverride: false }, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| toolsAllow: ["read", "web_search"], |
| codeModeOverride: false, |
| }); |
| }); |
|
|
| it("lets provider/model runtime policy choose Codex without storing a session harness pin", async () => { |
| const sessionEntry = makeSessionEntry("codex-history-session"); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| providerOverride: "codex", |
| cfg: { |
| models: { |
| providers: { |
| codex: { |
| baseUrl: "https://api.openai.com/v1", |
| agentRuntime: { id: "codex" }, |
| models: [], |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| runId: "run-codex-no-runtime-pin", |
| sessionHasHistory: true, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| agentHarnessId: undefined, |
| agentHarnessRuntimeOverride: undefined, |
| agentHarnessRuntimePreparationHint: "codex", |
| }); |
| }); |
|
|
| it("auto-forwards OpenAI Codex auth profiles to default Codex harness runs", async () => { |
| const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); |
| const sessionEntry = makeSessionEntry("codex-auth-session"); |
| saveAuthProfileStore( |
| createAuthProfileStoreFixture({ |
| "openai:work": { |
| type: "oauth", |
| provider: "openai", |
| access: "access-token", |
| refresh: "refresh-token", |
| expires: Date.now() + 60_000, |
| }, |
| }), |
| tmpDir, |
| { filterExternalAuthProfiles: false, syncExternalCli: false }, |
| ); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
| clearAgentHarnesses(); |
| registerAgentHarness({ |
| id: "codex", |
| label: "Codex", |
| supports: () => ({ supported: true, priority: 100 }), |
| runAttempt: vi.fn(), |
| }); |
|
|
| try { |
| await runHarnessAttempt({ |
| sessionEntry, |
| runId: "run-codex-auto-auth-profile", |
| sessionHasHistory: true, |
| }); |
| } finally { |
| clearAgentHarnesses(); |
| } |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| agentHarnessId: undefined, |
| authProfileId: "openai:work", |
| authProfileIdSource: "auto", |
| }); |
| }); |
|
|
| it("pins a fresh OpenAI session to the Codex harness by default", async () => { |
| const sessionEntry = makeSessionEntry("fresh-session"); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| sessionEntry, |
| body: "start", |
| runId: "run-fresh-no-pin", |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); |
| }); |
|
|
| it("honors a resolved persisted OpenClaw harness", async () => { |
| const sessionEntry = makeSessionEntry("stale-agent-session", { |
| agentHarnessId: "openclaw", |
| }); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| sessionEntry, |
| agentHarnessRuntimeOverride: "openclaw", |
| runId: "run-stale-openai-runtime-pin", |
| sessionHasHistory: true, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "openai", |
| agentHarnessId: undefined, |
| agentHarnessRuntimeOverride: "openclaw", |
| }); |
| }); |
|
|
| it.each([undefined, "model-owner"])( |
| "honors a runtime request without promoting observations to a pin (owner %s)", |
| async (pluginOwnerId) => { |
| const sessionEntry = makeSessionEntry("explicit-openclaw-session", { |
| agentRuntimeOverride: "openclaw", |
| agentHarnessId: "codex", |
| modelSelectionLocked: pluginOwnerId !== undefined, |
| pluginOwnerId, |
| }); |
| const modelThinkingCapability = { |
| provider: "openai", |
| modelId: "gpt-5.6-sol", |
| agentRuntime: "openclaw", |
| route: { |
| api: "openai-responses", |
| baseUrl: "https://api.openai.com/v1", |
| }, |
| compat: { |
| thinkingFormat: "openai", |
| supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], |
| }, |
| } as const; |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| modelOverride: "gpt-5.6-sol", |
| modelThinkingCapability, |
| sessionEntry, |
| agentHarnessRuntimeOverride: "openclaw", |
| resolvedThinkLevel: "max", |
| runId: "run-explicit-openclaw-runtime", |
| sessionHasHistory: true, |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "openai", |
| model: "gpt-5.6-sol", |
| modelThinkingCapability, |
| agentHarnessId: undefined, |
| agentHarnessRuntimeOverride: "openclaw", |
| thinkLevel: "max", |
| }); |
| }, |
| ); |
|
|
| it("routes explicit OpenAI native runs with legacy Codex OAuth through OpenClaw", async () => { |
| const sessionEntry = makeSessionEntry("explicit-agent-codex-oauth-session", { |
| authProfileOverride: "openai:work", |
| authProfileOverrideSource: "user", |
| }); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| cfg: { |
| models: { |
| providers: { |
| openai: { |
| baseUrl: "https://api.openai.com/v1", |
| agentRuntime: { id: "openclaw" }, |
| models: [], |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| runId: "run-openai-agent-codex-oauth", |
| }); |
|
|
| expectMockArgFields(runEmbeddedAgentMock, { |
| provider: "openai", |
| model: "gpt-5.4", |
| agentHarnessId: undefined, |
| agentHarnessRuntimeOverride: "openclaw", |
| authProfileId: "openai:work", |
| authProfileIdSource: "user", |
| }); |
| }); |
|
|
| it("does not pass CLI runtime aliases as embedded harness ids for fallback providers", async () => { |
| const sessionEntry = makeSessionEntry("fallback-session"); |
| runEmbeddedAgentMock.mockResolvedValueOnce({ |
| meta: { durationMs: 1 }, |
| } satisfies EmbeddedAgentRunResult); |
|
|
| await runHarnessAttempt({ |
| originalProvider: "claude-cli", |
| modelRoutingProvenance: { |
| requestedProvider: "claude-cli", |
| requestedModel: "opus", |
| stage: "fallback", |
| }, |
| cfg: { |
| agents: { |
| defaults: { |
| agentRuntime: { id: "claude-cli" }, |
| }, |
| }, |
| } as OpenClawConfig, |
| sessionEntry, |
| body: "fallback", |
| isFallbackRetry: true, |
| runId: "run-openai-fallback-with-cli-runtime", |
| }); |
|
|
| expect(runCliAgentMock).not.toHaveBeenCalled(); |
| expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); |
| expect(firstEmbeddedAgentArg()).not.toHaveProperty("agentHarnessId", "claude-cli"); |
| }); |
| }); |
| |
|
|