mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 18:08:05 -06:00
3974 lines
134 KiB
TypeScript
3974 lines
134 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
// Covers CLI-backed attempt execution and session-binding persistence.
|
|
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
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 type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
|
|
import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js";
|
|
import {
|
|
disposeOpenClawAgentDatabaseByPath,
|
|
listOpenClawAgentDatabasesForTest,
|
|
runOpenClawAgentWriteTransaction,
|
|
} from "../../state/openclaw-agent-db.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 { clearRuntimeAuthProfileStoreSnapshots } from "../auth-profiles/runtime-snapshots.js";
|
|
import { saveAuthProfileStore } from "../auth-profiles/store.js";
|
|
import { testing as cliBackendsTesting } from "../cli-backends.test-support.js";
|
|
import { createCronCreatorAuthorityCapability } from "../cron-creator-authority-context.js";
|
|
import type { EmbeddedAgentRunResult } from "../embedded-agent.js";
|
|
import { FailoverError } from "../failover-error.js";
|
|
import { attachToolAllowlistIntersection } from "../tool-policy.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" | "opts" | "runContext" | "sessionEntry" | "sessionKey" | "workspaceDir"
|
|
> & {
|
|
agentDir: RunAgentAttemptParams["agentDir"];
|
|
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 runId = overrides.runId ?? `run-${overrides.sessionEntry.sessionId}`;
|
|
return {
|
|
providerOverride: provider,
|
|
originalProvider: provider,
|
|
modelOverride: "gpt-5.4",
|
|
cfg: {} as OpenClawConfig,
|
|
sessionId: overrides.sessionEntry.sessionId,
|
|
sessionAgentId: "main",
|
|
sessionFile: path.join(overrides.workspaceDir, "session.jsonl"),
|
|
body: "continue",
|
|
isFallbackRetry: false,
|
|
resolvedThinkLevel: "medium",
|
|
timeoutMs: 1_000,
|
|
runId,
|
|
spawnedBy: undefined,
|
|
messageChannel: undefined,
|
|
skillsSnapshot: undefined,
|
|
resolvedVerboseLevel: undefined,
|
|
onAgentEvent: vi.fn(),
|
|
authProfileProvider: provider,
|
|
sessionHasHistory: false,
|
|
...overrides,
|
|
pluginGeneration: overrides.pluginGeneration,
|
|
preparedRunAdmission: overrides.preparedRunAdmission ?? createTestPreparedRunAdmission(runId),
|
|
lifecycleGeneration: overrides.lifecycleGeneration ?? "test-generation",
|
|
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/claude-live-registry.js", () => ({
|
|
getClaudeGeneration: vi.fn(() => undefined),
|
|
hasClaudeSession: hasClaudeSessionMock,
|
|
}));
|
|
|
|
vi.mock("../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;
|
|
// Runtime alias tests only need the model-level runtime override path;
|
|
// keeping the mock narrow avoids loading provider catalogs here.
|
|
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): EmbeddedAgentRunResult {
|
|
return {
|
|
payloads: [{ text }],
|
|
meta: {
|
|
durationMs: 5,
|
|
finalAssistantVisibleText: text,
|
|
agentMeta: {
|
|
sessionId: "session-cli",
|
|
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[];
|
|
}
|
|
// Session transcripts are JSONL; tests preserve that format so parent/child
|
|
// id ordering and append behavior are covered end-to-end.
|
|
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",
|
|
});
|
|
});
|
|
|
|
async function runClaudeCliAttempt(params: {
|
|
sessionKey: string;
|
|
sessionEntry: SessionEntry;
|
|
sessionStore: Record<string, SessionEntry>;
|
|
body: string;
|
|
runId: string;
|
|
cwd?: string;
|
|
onExecutionStarted?: () => void;
|
|
}) {
|
|
await runAgentAttempt({
|
|
providerOverride: "claude-cli",
|
|
modelOverride: "opus",
|
|
sessionEntry: params.sessionEntry,
|
|
sessionKey: params.sessionKey,
|
|
workspaceDir: tmpDir,
|
|
cwd: params.cwd,
|
|
body: params.body,
|
|
runId: params.runId,
|
|
opts: { onExecutionStarted: params.onExecutionStarted },
|
|
agentDir,
|
|
sessionStore: params.sessionStore,
|
|
storePath,
|
|
});
|
|
}
|
|
|
|
it("forwards execution admission callbacks to the CLI runtime", async () => {
|
|
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();
|
|
runCliAgentMock.mockResolvedValueOnce(makeCliResult("started"));
|
|
|
|
await runClaudeCliAttempt({
|
|
sessionKey,
|
|
sessionEntry,
|
|
sessionStore,
|
|
body: "start",
|
|
runId: "run-cli-execution-started",
|
|
onExecutionStarted,
|
|
});
|
|
|
|
expect(firstRunCliAgentArg().onExecutionStarted).toBe(onExecutionStarted);
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
|
|
async function writeClaudeCliAssistantTranscript(cliSessionId: string) {
|
|
// Claude stores resumable sessions under a workspace-derived project dir,
|
|
// so stale-session tests must create the same on-disk shape.
|
|
const 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",
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
// The retry hook must clear poisoned bindings before the fresh CLI attempt
|
|
// runs, otherwise the runner would resume the same expired Claude session.
|
|
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"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
|
|
const persisted = readSessionStore();
|
|
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
});
|
|
|
|
it("clears reused Claude CLI session IDs after AbortError without retrying", 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"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
|
|
const persisted = readSessionStore();
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
});
|
|
|
|
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 fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
|
|
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 = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
|
|
string,
|
|
SessionEntry
|
|
>;
|
|
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");
|
|
});
|
|
|
|
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(forkedCliSessionId);
|
|
|
|
const persisted = readSessionStore();
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe(
|
|
forkedCliSessionId,
|
|
);
|
|
});
|
|
|
|
it("clears a persisted fork successor before transcript fallback", 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 (
|
|
clearFork as (params: {
|
|
provider: string;
|
|
reason: "timeout";
|
|
sessionId: string;
|
|
}) => Promise<boolean>
|
|
)({
|
|
provider: "claude-cli",
|
|
reason: "timeout",
|
|
sessionId: forkedCliSessionId,
|
|
});
|
|
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"]).toBeUndefined();
|
|
const persisted = readSessionStore();
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
});
|
|
|
|
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("preserves a restored fork marker when recovery dies before producing a successor", async () => {
|
|
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 sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
await writeSessionStoreSeed(sessionStore);
|
|
const recoveryError = Object.assign(new Error("fork process died before init"), {
|
|
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.restoreCliSessionFork as () => Promise<void>)();
|
|
throw recoveryError;
|
|
});
|
|
|
|
await expect(
|
|
runClaudeCliAttempt({
|
|
sessionKey,
|
|
sessionEntry,
|
|
sessionStore,
|
|
body: "resume and fail before fork init",
|
|
runId: "run-cli-fork-before-successor-failure",
|
|
}),
|
|
).rejects.toBe(recoveryError);
|
|
|
|
expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toMatchObject({
|
|
sessionId: cliSessionId,
|
|
forkNextResume: true,
|
|
});
|
|
expect(readSessionStore()[sessionKey]?.cliSessionBindings?.["claude-cli"]).toMatchObject({
|
|
sessionId: cliSessionId,
|
|
forkNextResume: 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"));
|
|
|
|
// Storeless attempts cannot persist binding cleanup, so installing the hook
|
|
// would only give callers a false sense that stale state was repaired.
|
|
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", "billing", "rate_limit"] as const)(
|
|
"clears 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"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
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.mockResolvedValueOnce(makeCliResult("fresh cli response"));
|
|
|
|
await runClaudeCliAttempt({
|
|
sessionKey,
|
|
sessionEntry,
|
|
sessionStore,
|
|
body: "remember me",
|
|
runId: "run-cli-missing-transcript",
|
|
});
|
|
|
|
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
|
// The persisted binding is cleared so no later turn can blindly --resume the
|
|
// phantom session, but the candidate id still rides along to runCliAgent so
|
|
// prepare can re-detect the missing transcript and arm raw-transcript reseed.
|
|
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"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
|
|
const persisted = readSessionStore();
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
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";
|
|
// Bug condition: the managed stdio child is still live but Claude wrote no
|
|
// native transcript. The durable binding and the current candidate must both
|
|
// survive until prepare/execution prove that exact child reusable.
|
|
const homeDir = path.join(tmpDir, "home-missing-transcript");
|
|
const projectsDir = resolveClaudeCliProjectDirForWorkspace({
|
|
workspaceDir: tmpDir,
|
|
homeDir,
|
|
});
|
|
setTestEnvValue("HOME", homeDir);
|
|
await fs.mkdir(projectsDir, { recursive: true });
|
|
// Intentionally do NOT write `${cliSessionId}.jsonl` (no native transcript).
|
|
const sessionEntry = makeClaudeCliSessionEntry("openclaw-sid", cliSessionId);
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
|
|
hasClaudeSessionMock.mockReturnValue(true);
|
|
runCliAgentMock.mockResolvedValueOnce(makeCliResult("ok"));
|
|
|
|
await runClaudeCliAttempt({
|
|
sessionKey,
|
|
sessionEntry,
|
|
sessionStore,
|
|
body: "remember our earlier chat",
|
|
runId: "run-cli-missing-transcript-reseed",
|
|
});
|
|
|
|
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
|
// Regression guard: before the fix the candidate was dropped (undefined),
|
|
// starving prepare's reseed; the bound id must survive as the candidate.
|
|
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 = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
|
|
string,
|
|
SessionEntry
|
|
>;
|
|
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"));
|
|
|
|
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"));
|
|
|
|
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 };
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"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 };
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"google:api-key": {
|
|
type: "api_key",
|
|
provider: "google",
|
|
key: "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(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"vercel-ai-gateway:default": {
|
|
type: "api_key",
|
|
provider: "vercel-ai-gateway",
|
|
key: "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 };
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"openai:work": {
|
|
type: "oauth",
|
|
provider: "openai",
|
|
access: "openai-access",
|
|
refresh: "openai-refresh",
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
"google:api-key": {
|
|
type: "api_key",
|
|
provider: "google",
|
|
key: "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 };
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"google:api-key": {
|
|
type: "api_key",
|
|
provider: "google",
|
|
key: "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,
|
|
sessionFile: internalSessionFile,
|
|
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",
|
|
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 sessionFile = path.join(tmpDir, "session-cli-transcript.jsonl");
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session-cli-transcript",
|
|
sessionFile,
|
|
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 sessionFile = path.join(tmpDir, "session-cli-recorder-owned-user.jsonl");
|
|
const sessionEntry = makeSessionEntry("session-cli-recorder-owned-user", { sessionFile });
|
|
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("persists a media-only ACP user turn when the reply is empty", async () => {
|
|
const sessionKey = "agent:main:direct:acp-media-only";
|
|
const sessionFile = path.join(tmpDir, "session-acp-media-only.jsonl");
|
|
const sessionEntry = makeSessionEntry("session-acp-media-only", { sessionFile });
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
await writeSessionStoreSeed(sessionStore);
|
|
|
|
await persistAcpTurnTranscript({
|
|
body: "[media attached: media://inbound/image-1]",
|
|
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 staleSessionFile = path.join(tmpDir, "session-cli-stale.jsonl");
|
|
const staleEntry: SessionEntry = {
|
|
sessionId: "session-cli-stale",
|
|
sessionFile: staleSessionFile,
|
|
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 });
|
|
await expect(fs.stat(staleSessionFile)).rejects.toMatchObject({ code: "ENOENT" });
|
|
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 };
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"anthropic:work": {
|
|
type: "api_key",
|
|
provider: "anthropic",
|
|
key: "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.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("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("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");
|
|
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",
|
|
});
|
|
});
|
|
|
|
it("adds Git attribution only to provider-bound CLI and plugin prompts", async () => {
|
|
const attribution =
|
|
"Git commit attribution for this turn:\nCo-authored-by: octocat <583231+octocat@users.noreply.github.com>";
|
|
const sessionKey = "agent:main:direct:coauthor-runtime-prompts";
|
|
const sessionEntry = makeSessionEntry("coauthor-runtime-prompts");
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
await writeSessionStoreSeed(sessionStore);
|
|
runCliAgentMock.mockResolvedValueOnce(makeCliResult("cli result"));
|
|
|
|
await runStoredAttempt({
|
|
providerOverride: "claude-cli",
|
|
modelOverride: "opus",
|
|
sessionEntry,
|
|
sessionKey,
|
|
body: "commit from CLI",
|
|
runId: "run-cli-coauthor-prompt",
|
|
opts: { gitCoauthorAttribution: attribution },
|
|
sessionStore,
|
|
});
|
|
|
|
const cliArg = firstRunCliAgentArg();
|
|
const attributionSuffix = `\n\n${attribution}`;
|
|
expect(cliArg.prompt).toEqual(expect.stringContaining("commit from CLI"));
|
|
expect(String(cliArg.prompt).endsWith(attributionSuffix)).toBe(true);
|
|
expect(cliArg.transcriptPrompt).toBe(String(cliArg.prompt).slice(0, -attributionSuffix.length));
|
|
|
|
const codexSessionKey = "agent:main:direct:coauthor-codex-prompt";
|
|
const codexSessionEntry = makeSessionEntry("coauthor-codex-prompt");
|
|
const codexSessionStore: Record<string, SessionEntry> = {
|
|
[codexSessionKey]: codexSessionEntry,
|
|
};
|
|
await writeSessionStoreSeed(codexSessionStore);
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
meta: { durationMs: 1 },
|
|
} satisfies EmbeddedAgentRunResult);
|
|
|
|
await runStoredAttempt({
|
|
agentHarnessRuntimeOverride: "codex",
|
|
body: "commit from Codex",
|
|
sessionEntry: codexSessionEntry,
|
|
sessionKey: codexSessionKey,
|
|
runId: "run-codex-coauthor-prompt",
|
|
opts: { gitCoauthorAttribution: attribution },
|
|
sessionStore: codexSessionStore,
|
|
});
|
|
|
|
const codexArg = firstEmbeddedAgentArg();
|
|
expectRecordFields(codexArg, {
|
|
agentHarnessId: "codex",
|
|
prompt: `commit from Codex\n\n${attribution}`,
|
|
transcriptPrompt: "commit from Codex",
|
|
});
|
|
});
|
|
|
|
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(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"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(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"openai:backup": {
|
|
type: "api_key",
|
|
provider: "openai",
|
|
key: "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: "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("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 runtime toolsAllow 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"] },
|
|
});
|
|
|
|
expectMockArgFields(runEmbeddedAgentMock, { toolsAllow: ["read", "web_search"] });
|
|
});
|
|
|
|
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(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"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: "openclaw",
|
|
agentHarnessRuntimeOverride: "openclaw",
|
|
});
|
|
});
|
|
|
|
it("honors an explicit OpenClaw session runtime override", async () => {
|
|
const sessionEntry = makeSessionEntry("explicit-openclaw-session", {
|
|
agentRuntimeOverride: "openclaw",
|
|
agentHarnessId: "codex",
|
|
});
|
|
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: "openclaw",
|
|
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: "openclaw",
|
|
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",
|
|
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");
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|