Files
openclaw/src/agents/cli-runner/prepare.test.ts
Mariano 7299c56953 Fix sub-agent cwd/workspace separation (#87218)
Merged via squash.

Prepared head SHA: f47b073830
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-05-27 23:55:24 +02:00

2006 lines
69 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { registerLegacyContextEngine } from "../../context-engine/legacy.registration.js";
import {
registerContextEngine,
registerContextEngineForOwner,
} from "../../context-engine/registry.js";
import type { ContextEngine } from "../../context-engine/types.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { clearMemoryPluginState, registerMemoryPromptSection } from "../../plugins/memory-state.js";
import { testing as cliBackendsTesting } from "../cli-backends.js";
import { hashCliSessionText } from "../cli-session.js";
import { resetContextWindowCacheForTest } from "../context.js";
import { buildActiveImageGenerationTaskPromptContextForSession } from "../image-generation-task-status.js";
import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js";
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../video-generation-task-status.js";
import {
prepareCliRunContext,
setCliRunnerPrepareTestDeps,
shouldSkipLocalCliCredentialEpoch,
} from "./prepare.js";
const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: getRuntimeConfigMock,
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => null),
}));
vi.mock("../../plugin-sdk/anthropic-cli.js", () => ({
CLAUDE_CLI_BACKEND_ID: "claude-cli",
isClaudeCliProvider: (providerId: string) => providerId === "claude-cli",
}));
vi.mock("../../tts/tts.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
}));
vi.mock("../video-generation-task-status.js", () => ({
VIDEO_GENERATION_TASK_KIND: "video_generation",
buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(() => undefined),
buildVideoGenerationTaskStatusDetails: vi.fn(() => ({})),
buildVideoGenerationTaskStatusText: vi.fn(() => ""),
findActiveVideoGenerationTaskForSession: vi.fn(() => undefined),
getVideoGenerationTaskProviderId: vi.fn(() => undefined),
isActiveVideoGenerationTask: vi.fn(() => false),
}));
vi.mock("../image-generation-task-status.js", () => ({
IMAGE_GENERATION_TASK_KIND: "image_generation",
buildActiveImageGenerationTaskPromptContextForSession: vi.fn(() => undefined),
buildImageGenerationTaskStatusDetails: vi.fn(() => ({})),
buildImageGenerationTaskStatusText: vi.fn(() => ""),
findActiveImageGenerationTaskForSession: vi.fn(() => undefined),
getImageGenerationTaskProviderId: vi.fn(() => undefined),
isActiveImageGenerationTask: vi.fn(() => false),
}));
vi.mock("../music-generation-task-status.js", () => ({
MUSIC_GENERATION_TASK_KIND: "music_generation",
buildActiveMusicGenerationTaskPromptContextForSession: vi.fn(() => undefined),
buildMusicGenerationTaskStatusDetails: vi.fn(() => ({})),
buildMusicGenerationTaskStatusText: vi.fn(() => ""),
findActiveMusicGenerationTaskForSession: vi.fn(() => undefined),
}));
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
const mockBuildActiveVideoGenerationTaskPromptContextForSession = vi.mocked(
buildActiveVideoGenerationTaskPromptContextForSession,
);
const mockBuildActiveImageGenerationTaskPromptContextForSession = vi.mocked(
buildActiveImageGenerationTaskPromptContextForSession,
);
const mockBuildActiveMusicGenerationTaskPromptContextForSession = vi.mocked(
buildActiveMusicGenerationTaskPromptContextForSession,
);
function wrappedPluginSystemContext(text: string): string {
return `---\n\nOpenClaw plugin-injected system context. This block is not workspace file content.\n\n${text}\n\n---`;
}
function createTestMcpLoopbackServerConfig(port: number) {
return {
mcpServers: {
openclaw: {
type: "http",
url: `http://127.0.0.1:${port}/mcp`,
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
},
},
},
};
}
async function createTestMcpLoopbackServer(port = 0) {
return {
port,
close: vi.fn(async () => undefined),
};
}
function createCliBackendConfig(
params: {
systemPromptOverride?: string | null;
bundleMcp?: boolean;
reseedFromRawTranscriptWhenUncompacted?: boolean;
} = {},
): OpenClawConfig {
return {
agents: {
defaults: {
...(params.systemPromptOverride !== null
? { systemPromptOverride: params.systemPromptOverride ?? "test system prompt" }
: {}),
cliBackends: {
"test-cli": {
command: "test-cli",
args: ["--print"],
systemPromptArg: "--system-prompt",
systemPromptWhen: "first",
sessionMode: "existing",
output: "text",
input: "arg",
...(params.reseedFromRawTranscriptWhenUncompacted
? { reseedFromRawTranscriptWhenUncompacted: true }
: {}),
...(params.bundleMcp
? { bundleMcp: true, bundleMcpMode: "claude-config-file" as const }
: {}),
},
},
},
},
} satisfies OpenClawConfig;
}
function createSessionFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-prepare-"));
vi.stubEnv("OPENCLAW_STATE_DIR", dir);
const sessionFile = path.join(dir, "agents", "main", "sessions", "session-test.jsonl");
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
fs.writeFileSync(
sessionFile,
`${JSON.stringify({
type: "session",
version: CURRENT_SESSION_VERSION,
id: "session-test",
timestamp: new Date(0).toISOString(),
cwd: dir,
})}\n`,
"utf-8",
);
return { dir, sessionFile };
}
function appendTranscriptEntry(
sessionFile: string,
entry: {
id: string;
parentId: string | null;
timestamp: string;
message: unknown;
},
): void {
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "message",
id: entry.id,
parentId: entry.parentId,
timestamp: entry.timestamp,
message: entry.message,
})}\n`,
"utf-8",
);
}
describe("shouldSkipLocalCliCredentialEpoch", () => {
beforeEach(() => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [],
});
setCliRunnerPrepareTestDeps({
makeBootstrapWarn: vi.fn(() => () => undefined),
resolveBootstrapContextForRun: vi.fn(async () => ({
bootstrapFiles: [],
contextFiles: [],
})),
getActiveMcpLoopbackRuntime: vi.fn(() => undefined),
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
resolveMcpLoopbackBearerToken: vi.fn((runtime, senderIsOwner) =>
senderIsOwner ? runtime.ownerToken : runtime.nonOwnerToken,
),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: null, sourcePath: null })),
prepareClaudeCliSkillsPlugin: vi.fn(async () => ({
args: [],
cleanup: vi.fn(async () => undefined),
})),
});
mockGetGlobalHookRunner.mockReturnValue(null);
getRuntimeConfigMock.mockReturnValue({});
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(undefined);
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(undefined);
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(undefined);
});
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
getRuntimeConfigMock.mockReset();
mockGetGlobalHookRunner.mockReset();
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReset();
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReset();
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReset();
resetContextWindowCacheForTest();
clearMemoryPluginState();
vi.unstubAllEnvs();
});
it("skips local cli auth only when a profile-owned execution was prepared", () => {
expect(
shouldSkipLocalCliCredentialEpoch({
authEpochMode: "profile-only",
authProfileId: "openai-codex:default",
authCredential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
preparedExecution: {
env: {
CODEX_HOME: "/tmp/codex-home",
},
},
}),
).toBe(true);
});
it("keeps local cli auth in the epoch when the selected profile has no bridgeable execution", () => {
expect(
shouldSkipLocalCliCredentialEpoch({
authEpochMode: "profile-only",
authProfileId: "openai-codex:default",
authCredential: undefined,
preparedExecution: null,
}),
).toBe(false);
});
it("applies prompt-build hook context to Claude-style CLI preparation", async () => {
const { dir, sessionFile } = createSessionFile();
try {
appendTranscriptEntry(sessionFile, {
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: "earlier context", timestamp: 1 },
});
appendTranscriptEntry(sessionFile, {
id: "msg-2",
parentId: "msg-1",
timestamp: new Date(2).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: "earlier reply" }],
api: "responses",
provider: "test-cli",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
});
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async ({ messages }: { messages: unknown[] }) => ({
prependContext: `history:${messages.length}`,
systemPrompt: "hook system",
prependSystemContext: "prepend system",
appendSystemContext: "append system",
})),
runBeforeAgentStart: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test",
messageChannel: "telegram",
messageProvider: "acp",
config: {
...createCliBackendConfig(),
},
});
expect(context.params.prompt).toBe("history:2\n\nlatest ask");
expect(context.contextEngineTurnPrompt).toBe("latest ask");
expect(context.systemPrompt).toBe(
`${wrappedPluginSystemContext("prepend system")}\n\nhook system\n\n${wrappedPluginSystemContext("append system")}\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
);
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1);
const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array<
[unknown, unknown]
>;
expect(beforePromptBuildCalls[0]?.[0]).toEqual({
prompt: "latest ask",
messages: [
{ role: "user", content: "earlier context", timestamp: 1 },
{
role: "assistant",
content: [{ type: "text", text: "earlier reply" }],
api: "responses",
provider: "test-cli",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
],
});
const hookContext = beforePromptBuildCalls[0]?.[1] as
| {
runId?: string;
agentId?: string;
sessionKey?: string;
sessionId?: string;
workspaceDir?: string;
modelProviderId?: string;
modelId?: string;
messageProvider?: string;
trigger?: string;
channelId?: string;
}
| undefined;
expect(hookContext?.runId).toBe("run-test");
expect(hookContext?.agentId).toBe("main");
expect(hookContext?.sessionKey).toBe("agent:main:test");
expect(hookContext?.sessionId).toBe("session-test");
expect(hookContext?.workspaceDir).toBe(dir);
expect(hookContext?.modelProviderId).toBe("test-cli");
expect(hookContext?.modelId).toBe("test-model");
expect(hookContext?.messageProvider).toBe("acp");
expect(hookContext?.trigger).toBe("user");
expect(hookContext?.channelId).toBe("telegram");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("prepends current-turn context after prompt-build hooks without changing hook or transcript prompt", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({
prependContext: "trusted hook context",
appendContext: "trusted hook tail",
})),
runBeforeAgentStart: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
transcriptPrompt: "latest ask",
currentInboundContext: {
text: "Sender (untrusted metadata):\nsender_id=U123",
promptJoiner: " ",
},
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-context",
config: createCliBackendConfig(),
});
expect(context.params.prompt).toBe(
"Sender (untrusted metadata):\nsender_id=U123 trusted hook context\n\nlatest ask\n\ntrusted hook tail",
);
expect(context.params.transcriptPrompt).toBe("latest ask");
expect(context.contextEngineTurnPrompt).toBe("latest ask");
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1);
const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array<
[unknown, unknown]
>;
const promptBuildParams = beforePromptBuildCalls[0]?.[0] as { prompt?: string } | undefined;
expect(promptBuildParams?.prompt).toBe("latest ask");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("marks inter-session prompts after CLI prompt-build hook context is applied", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({
prependContext: "trusted hook context",
})),
runBeforeAgentStart: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
sessionFile,
workspaceDir: dir,
prompt: "foreign reply text",
inputProvenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:slack:dm:U123",
sourceChannel: "slack",
sourceTool: "sessions_send",
},
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test",
config: createCliBackendConfig(),
});
expect(context.params.prompt).toMatch(/^\[Inter-session message/);
expect(context.params.prompt).toContain("sourceSession=agent:main:slack:dm:U123");
expect(context.params.prompt).toContain("isUser=false");
expect(context.params.prompt).toContain("trusted hook context");
expect(context.params.prompt).toContain("foreign reply text");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("applies agent_turn_prepare-only context on the CLI path", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "agent_turn_prepare"),
runAgentTurnPrepare: vi.fn(async () => ({
prependContext: "turn prepend",
appendContext: "turn append",
})),
runBeforePromptBuild: vi.fn(),
runBeforeAgentStart: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-turn-prepare",
config: createCliBackendConfig(),
});
expect(context.params.prompt).toBe("turn prepend\n\nlatest ask\n\nturn append");
expect(hookRunner.runAgentTurnPrepare).toHaveBeenCalledTimes(1);
const agentTurnPrepareCalls = hookRunner.runAgentTurnPrepare.mock.calls as unknown as Array<
[unknown, unknown]
>;
expect(agentTurnPrepareCalls[0]?.[0]).toEqual({
prompt: "latest ask",
messages: [],
queuedInjections: [],
});
const turnPrepareContext = agentTurnPrepareCalls[0]?.[1] as
| { runId?: string; sessionKey?: string }
| undefined;
expect(turnPrepareContext?.runId).toBe("run-test-turn-prepare");
expect(turnPrepareContext?.sessionKey).toBe("agent:main:test");
expect(hookRunner.runBeforePromptBuild).not.toHaveBeenCalled();
expect(hookRunner.runBeforeAgentStart).not.toHaveBeenCalled();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("merges before_prompt_build and legacy before_agent_start hook context for CLI preparation", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const hookRunner = {
hasHooks: vi.fn((_hookName: string) => true),
runBeforePromptBuild: vi.fn(async () => ({
prependContext: "prompt prepend",
systemPrompt: "prompt system",
prependSystemContext: "prompt prepend system",
appendSystemContext: "prompt append system",
})),
runBeforeAgentStart: vi.fn(async () => ({
prependContext: "legacy prepend",
systemPrompt: "legacy system",
prependSystemContext: "legacy prepend system",
appendSystemContext: "legacy append system",
})),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-legacy-merge",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.params.prompt).toBe("prompt prepend\n\nlegacy prepend\n\nlatest ask");
expect(context.systemPrompt).toBe(
`${wrappedPluginSystemContext("prompt prepend system")}\n\n${wrappedPluginSystemContext("legacy prepend system")}\n\nprompt system\n\n${wrappedPluginSystemContext("prompt append system")}\n\n${wrappedPluginSystemContext("legacy append system")}\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
);
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
expect(hookRunner.runBeforeAgentStart).toHaveBeenCalledOnce();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("preserves the base prompt when prompt-build hooks fail", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => {
throw new Error("hook exploded");
}),
runBeforeAgentStart: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-hook-failure",
config: createCliBackendConfig({ systemPromptOverride: "base extra system" }),
});
expect(context.params.prompt).toBe("latest ask");
expect(context.systemPrompt).toBe(
"base extra system\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.",
);
expect(context.systemPrompt).not.toContain("hook exploded");
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("does not allocate a non-legacy context engine before fallible CLI preparation finishes", async () => {
const { dir, sessionFile } = createSessionFile();
const engineId = `cli-prepare-late-engine-${Date.now().toString(36)}`;
const dispose = vi.fn(async () => {});
const factory = vi.fn((): ContextEngine => {
return {
info: { id: engineId, name: "CLI prepare late engine" },
ingest: vi.fn(async () => ({ ingested: true })),
assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })),
compact: vi.fn(async () => ({ ok: true, compacted: false })),
dispose,
};
});
registerContextEngine(engineId, factory);
setCliRunnerPrepareTestDeps({
resolveOpenClawReferencePaths: vi.fn(async () => {
throw new Error("reference path lookup failed");
}),
});
try {
await expect(
prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-prepare-failure",
config: {
...createCliBackendConfig(),
plugins: { slots: { contextEngine: engineId } },
},
}),
).rejects.toThrow("reference path lookup failed");
expect(factory).not.toHaveBeenCalled();
expect(dispose).not.toHaveBeenCalled();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("cleans up prepared CLI backend when context-engine resolution fails", async () => {
const { dir, sessionFile } = createSessionFile();
const cleanup = vi.fn(async () => {});
const prepareExecution = vi.fn(async () => ({ cleanup }));
registerContextEngineForOwner(
"legacy",
() => {
throw new Error("context engine failed");
},
"core",
{ allowSameOwnerRefresh: true },
);
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "test-cli",
pluginId: "test-plugin",
bundleMcp: false,
prepareExecution,
config: {
command: "test-cli",
args: ["--print"],
systemPromptArg: "--system-prompt",
systemPromptWhen: "first",
sessionMode: "existing",
output: "text",
input: "arg",
},
},
],
});
try {
await expect(
prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-context-engine-resolution-failure",
config: createCliBackendConfig(),
}),
).rejects.toThrow("context engine failed");
expect(prepareExecution).toHaveBeenCalledOnce();
expect(cleanup).toHaveBeenCalledOnce();
} finally {
registerLegacyContextEngine();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("rejects CLI runs for context engines that require pre-prompt assembly", async () => {
const { dir, sessionFile } = createSessionFile();
const engineId = `cli-unsupported-engine-${Date.now().toString(36)}`;
registerContextEngine(engineId, (): ContextEngine => {
return {
info: {
id: engineId,
name: "CLI unsupported engine",
hostRequirements: {
"agent-run": {
requiredCapabilities: ["assemble-before-prompt"],
unsupportedMessage: "Use the native Codex or OpenClaw embedded runtime.",
},
},
},
ingest: vi.fn(async () => ({ ingested: true })),
assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })),
compact: vi.fn(async () => ({ ok: true, compacted: false })),
};
});
try {
await expect(
prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-context-engine-host-compat",
config: {
...createCliBackendConfig(),
plugins: { slots: { contextEngine: engineId } },
},
}),
).rejects.toThrow(
`Context engine "${engineId}" cannot run operation "agent-run" on CLI backend "test-cli".`,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses runtime config when resolving the CLI context engine", async () => {
const { dir, sessionFile } = createSessionFile();
const engineId = `cli-runtime-config-engine-${Date.now().toString(36)}`;
const runtimeAgentDir = path.join(dir, "runtime-agent");
const runtimeConfig = {
agents: {
list: [{ id: "main", default: true, agentDir: runtimeAgentDir }],
},
plugins: { slots: { contextEngine: engineId } },
} satisfies OpenClawConfig;
const factory = vi.fn((_ctx: unknown): ContextEngine => {
return {
info: { id: engineId, name: "CLI runtime config engine" },
ingest: vi.fn(async () => ({ ingested: true })),
assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })),
compact: vi.fn(async () => ({ ok: true, compacted: false })),
};
});
registerContextEngine(engineId, factory);
getRuntimeConfigMock.mockReturnValue(runtimeConfig);
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "test-cli",
pluginId: "test-plugin",
bundleMcp: false,
config: {
command: "test-cli",
args: ["--print"],
systemPromptArg: "--system-prompt",
systemPromptWhen: "first",
sessionMode: "existing",
output: "text",
input: "arg",
},
},
],
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-runtime-config-context-engine",
});
expect(context.contextEngine?.info.id).toBe(engineId);
expect(context.contextEngineConfig).toBe(runtimeConfig);
expect(context.params.config).toBe(runtimeConfig);
expect(factory).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: runtimeAgentDir,
config: runtimeConfig,
workspaceDir: dir,
}),
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses explicit static prompt text for CLI session reuse hashing", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-static-prompt",
extraSystemPrompt: "## Inbound Context\nchannel=telegram",
extraSystemPromptStatic: "",
cliSessionBinding: {
sessionId: "cli-session",
cwdHash: hashCliSessionText(dir),
},
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.systemPrompt).toContain("## Inbound Context\nchannel=telegram");
expect(context.extraSystemPromptHash).toBeUndefined();
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses cwd for CLI system prompt workspace guidance", async () => {
const { dir, sessionFile } = createSessionFile();
const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-task-"));
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
cwd: taskDir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-cwd-prompt",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.cwd).toBe(taskDir);
expect(context.systemPrompt).toContain(`Your working directory is: ${taskDir}`);
expect(context.systemPrompt).not.toContain(`Your working directory is: ${dir}`);
} finally {
fs.rmSync(taskDir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("ignores volatile prompt text when static prompt text matches", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const staticPrompt = "## Direct Context\nYou are in a Telegram direct conversation.";
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-volatile-prompt",
extraSystemPrompt: `## Inbound Context\nchannel=heartbeat\n\n${staticPrompt}`,
extraSystemPromptStatic: staticPrompt,
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText(staticPrompt),
cwdHash: hashCliSessionText(dir),
},
config: createCliBackendConfig(),
});
expect(context.extraSystemPromptHash).toBe(hashCliSessionText(staticPrompt));
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("prepares raw-tail history for safe invalidations only when the backend opts in", async () => {
const { dir, sessionFile } = createSessionFile();
appendTranscriptEntry(sessionFile, {
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior no-compaction ask",
timestamp: 1,
},
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-raw-reseed-opt-in",
extraSystemPrompt: "changed stable prompt",
extraSystemPromptStatic: "changed stable prompt",
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText("old stable prompt"),
},
config: createCliBackendConfig({
systemPromptOverride: null,
reseedFromRawTranscriptWhenUncompacted: true,
}),
});
expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" });
expect(context.openClawHistoryPrompt).toContain("prior no-compaction ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("prepares opted-in raw-tail history for session-expired retry without disabling native resume", async () => {
const { dir, sessionFile } = createSessionFile();
appendTranscriptEntry(sessionFile, {
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior resumable ask",
timestamp: 1,
},
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-session-expired-reseed-opt-in",
cliSessionBinding: {
sessionId: "cli-session",
cwdHash: hashCliSessionText(dir),
},
config: createCliBackendConfig({
systemPromptOverride: null,
reseedFromRawTranscriptWhenUncompacted: true,
}),
});
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
expect(context.openClawHistoryPrompt).toContain("prior resumable ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("applies direct-run prepend system context helpers on the CLI path", async () => {
const { dir, sessionFile } = createSessionFile();
try {
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(
"active image task",
);
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(
"active video task",
);
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({
systemPrompt: "hook system",
prependSystemContext: "hook prepend system",
})),
runBeforeAgentStart: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
trigger: "user",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-prepend-helper",
config: createCliBackendConfig(),
});
expect(context.systemPrompt).toBe(
`active image task\n\nactive video task\n\n${wrappedPluginSystemContext("hook prepend system")}\n\nhook system\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.`,
);
expect(mockBuildActiveImageGenerationTaskPromptContextForSession).toHaveBeenCalledWith(
"agent:main:test",
);
expect(mockBuildActiveVideoGenerationTaskPromptContextForSession).toHaveBeenCalledWith(
"agent:main:test",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("skips bundle MCP preparation when tools are disabled", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-disable-tools",
config: createCliBackendConfig({ bundleMcp: true }),
disableTools: true,
});
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
expect(ensureMcpLoopbackServer).not.toHaveBeenCalled();
expect(createMcpLoopbackServerConfig).not.toHaveBeenCalled();
expect(context.preparedBackend.mcpConfigHash).toBeUndefined();
expect(context.preparedBackend.env).toBeUndefined();
expect(context.preparedBackend.backend.args).toEqual(["--print"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses loopback-scoped tools when building bundled MCP CLI prompts", async () => {
const { dir, sessionFile } = createSessionFile();
try {
registerMemoryPromptSection(({ availableTools }) =>
availableTools.has("memory_search")
? ["## Memory Recall", `tools=${[...availableTools].toSorted().join(",")}`, ""]
: [],
);
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
{
name: "memory_search",
label: "Memory Search",
description: "Search memory",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
],
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
resolveMcpLoopbackScopedTools,
});
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "native-cli",
pluginId: "native-plugin",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "native-cli",
args: ["--print"],
systemPromptArg: "--system-prompt",
systemPromptWhen: "first",
output: "text",
input: "arg",
sessionMode: "existing",
},
},
],
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "native-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-loopback-prompt-tools",
config: createCliBackendConfig({ bundleMcp: true, systemPromptOverride: null }),
cliSessionBinding: {
sessionId: "cli-session",
promptToolNamesHash: "old-tool-surface",
},
});
expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith({
cfg: expect.any(Object),
sessionKey: "agent:main:test",
messageProvider: undefined,
accountId: undefined,
inboundEventKind: undefined,
sourceReplyDeliveryMode: undefined,
});
expect(context.systemPrompt).toContain("## Memory Recall");
expect(context.systemPrompt).toContain("tools=memory_search");
expect(context.systemPromptReport.tools.entries.map((entry) => entry.name)).toEqual([
"memory_search",
]);
expect(context.promptToolNamesHash).toBe(
hashCliSessionText(JSON.stringify(["memory_search"])),
);
expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("does not advertise loopback prompt tools when the runtime is unavailable", async () => {
const { dir, sessionFile } = createSessionFile();
try {
registerMemoryPromptSection(({ availableTools }) =>
availableTools.has("memory_search")
? ["## Memory Recall", `tools=${[...availableTools].toSorted().join(",")}`, ""]
: [],
);
const getActiveMcpLoopbackRuntime = vi.fn(() => undefined);
const ensureMcpLoopbackServer = vi.fn(async () => {
throw new Error("loopback unavailable");
});
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
{
name: "memory_search",
label: "Memory Search",
description: "Search memory",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
],
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
resolveMcpLoopbackScopedTools,
});
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "native-cli",
pluginId: "native-plugin",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "native-cli",
args: ["--print"],
systemPromptArg: "--system-prompt",
systemPromptWhen: "first",
output: "text",
input: "arg",
sessionMode: "existing",
},
},
],
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "native-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-loopback-prompt-tools-fallback",
config: createCliBackendConfig({ bundleMcp: true, systemPromptOverride: null }),
});
expect(ensureMcpLoopbackServer).toHaveBeenCalledTimes(1);
expect(getActiveMcpLoopbackRuntime).toHaveBeenCalledTimes(2);
expect(createMcpLoopbackServerConfig).not.toHaveBeenCalled();
expect(resolveMcpLoopbackScopedTools).not.toHaveBeenCalled();
expect(context.systemPrompt).not.toContain("## Memory Recall");
expect(context.systemPrompt).not.toContain("memory_search");
expect(context.systemPromptReport.tools.entries).toEqual([]);
expect(context.promptToolNamesHash).toBeUndefined();
expect(context.preparedBackend.env).toBeUndefined();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("passes current turn kind into bundle MCP loopback env", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
});
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "native-cli",
pluginId: "native-plugin",
bundleMcp: true,
bundleMcpMode: "codex-config-overrides",
config: {
command: "native-cli",
args: ["--print"],
output: "text",
input: "arg",
sessionMode: "existing",
},
},
],
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:group:chat123",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "native-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-room-event-tools",
config: createCliBackendConfig(),
currentInboundEventKind: "room_event",
messageChannel: "telegram",
sourceReplyDeliveryMode: "message_tool_only",
});
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram",
OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event",
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only",
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
});
await expect(
prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-tools-allow",
config: createCliBackendConfig({ bundleMcp: true }),
toolsAllow: ["read", "web_search"],
}),
).rejects.toThrow(
"CLI backend test-cli cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy",
);
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("fails closed for native tool-capable CLI backends when tools are disabled", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
});
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "native-cli",
pluginId: "native-plugin",
bundleMcp: true,
bundleMcpMode: "codex-config-overrides",
nativeToolMode: "always-on",
config: {
command: "native-cli",
args: ["exec", "--sandbox", "workspace-write"],
resumeArgs: ["exec", "resume", "{sessionId}"],
output: "jsonl",
input: "arg",
sessionMode: "existing",
},
},
],
});
await expect(
prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "native-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-disable-native-tools",
config: createCliBackendConfig(),
disableTools: true,
}),
).rejects.toThrow(
"CLI backend native-cli cannot run with tools disabled because it exposes native tools",
);
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("drops the claude-cli sessionId when the on-disk transcript is missing (#77011)", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
resumeArgs: ["--resume", "{sessionId}"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const transcriptCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:direct:peer",
sessionFile,
workspaceDir: dir,
prompt: "follow-up",
provider: "claude-cli",
model: "opus",
timeoutMs: 1_000,
runId: "run-77011-missing",
cliSessionBinding: { sessionId: "stale-claude-sid" },
cliSessionId: "stale-claude-sid",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(transcriptCheck).toHaveBeenCalledWith({ sessionId: "stale-claude-sid" });
expect(context.reusableCliSession).toEqual({ invalidatedReason: "missing-transcript" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("keeps the claude-cli sessionId when the on-disk transcript is present", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
resumeArgs: ["--resume", "{sessionId}"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const transcriptCheck = vi.fn(async () => true);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:direct:peer",
sessionFile,
workspaceDir: dir,
prompt: "follow-up",
provider: "claude-cli",
model: "opus",
timeoutMs: 1_000,
runId: "run-77011-present",
cliSessionBinding: { sessionId: "live-claude-sid", cwdHash: hashCliSessionText(dir) },
cliSessionId: "live-claude-sid",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(transcriptCheck).toHaveBeenCalledWith({ sessionId: "live-claude-sid" });
expect(context.reusableCliSession).toEqual({ sessionId: "live-claude-sid" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("omits Claude CLI prompt skills when the native skills plugin can carry them", async () => {
const { dir, sessionFile } = createSessionFile();
const skillDir = path.join(dir, "skills", "weather");
fs.mkdirSync(skillDir, { recursive: true });
const skillFilePath = path.join(skillDir, "SKILL.md");
fs.writeFileSync(
skillFilePath,
[
"---",
"name: weather",
"description: Use weather tools for forecasts.",
"---",
"",
"Read forecast data before replying.",
].join("\n"),
"utf-8",
);
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
setCliRunnerPrepareTestDeps({
prepareClaudeCliSkillsPlugin: vi.fn(async () => ({
args: ["--plugin-dir", path.join(dir, "openclaw-skills")],
cleanup: vi.fn(async () => undefined),
pluginDir: path.join(dir, "openclaw-skills"),
})),
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "opus",
timeoutMs: 1_000,
runId: "run-claude-plugin-skills-prompt",
config: createCliBackendConfig({ systemPromptOverride: null }),
skillsSnapshot: {
prompt: [
"<available_skills>",
" <skill>",
" <name>weather</name>",
" <description>Use weather tools for forecasts.</description>",
` <location>${skillFilePath}</location>`,
" </skill>",
"</available_skills>",
].join("\n"),
skills: [{ name: "weather" }],
resolvedSkills: [
{
name: "weather",
description: "Use weather tools for forecasts.",
filePath: skillFilePath,
baseDir: skillDir,
source: "test",
sourceInfo: {
path: skillDir,
source: "test",
scope: "project",
origin: "top-level",
baseDir: skillDir,
},
disableModelInvocation: false,
},
],
},
});
expect(context.systemPrompt).not.toContain("<available_skills>");
expect(context.systemPrompt).not.toContain("<name>weather</name>");
expect(context.systemPromptReport.skills.promptChars).toBe(0);
expect(context.claudeSkillsPluginArgs).toEqual([
"--plugin-dir",
path.join(dir, "openclaw-skills"),
]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("keeps Claude CLI prompt skills when the snapshot has no materialized plugin skills", async () => {
const { dir, sessionFile } = createSessionFile();
const missingSkillDir = path.join(dir, "skills", "missing");
const missingSkillFilePath = path.join(missingSkillDir, "SKILL.md");
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "opus",
timeoutMs: 1_000,
runId: "run-claude-plugin-skills-prompt-fallback",
config: createCliBackendConfig({ systemPromptOverride: null }),
skillsSnapshot: {
prompt: [
"<available_skills>",
" <skill>",
" <name>weather</name>",
" <description>Use weather tools for forecasts.</description>",
` <location>${missingSkillFilePath}</location>`,
" </skill>",
"</available_skills>",
].join("\n"),
skills: [{ name: "weather" }],
resolvedSkills: [
{
name: "weather",
description: "Use weather tools for forecasts.",
filePath: missingSkillFilePath,
baseDir: missingSkillDir,
source: "test",
sourceInfo: {
path: missingSkillDir,
source: "test",
scope: "project",
origin: "top-level",
baseDir: missingSkillDir,
},
disableModelInvocation: false,
},
],
},
});
expect(context.systemPrompt).toContain("<available_skills>");
expect(context.systemPrompt).toContain("<name>weather</name>");
expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0);
expect(context.claudeSkillsPluginArgs).toEqual([]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("keeps Claude CLI prompt skills when plugin materialization produces no args", async () => {
const { dir, sessionFile } = createSessionFile();
const skillDir = path.join(dir, "skills", "weather");
fs.mkdirSync(skillDir, { recursive: true });
const skillFilePath = path.join(skillDir, "SKILL.md");
fs.writeFileSync(
skillFilePath,
[
"---",
"name: weather",
"description: Use weather tools for forecasts.",
"---",
"",
"Read forecast data before replying.",
].join("\n"),
"utf-8",
);
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
setCliRunnerPrepareTestDeps({
prepareClaudeCliSkillsPlugin: vi.fn(async () => ({
args: [],
cleanup: vi.fn(async () => undefined),
})),
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "opus",
timeoutMs: 1_000,
runId: "run-claude-plugin-skills-prompt-materialization-fallback",
config: createCliBackendConfig({ systemPromptOverride: null }),
skillsSnapshot: {
prompt: [
"<available_skills>",
" <skill>",
" <name>weather</name>",
" <description>Use weather tools for forecasts.</description>",
` <location>${skillFilePath}</location>`,
" </skill>",
"</available_skills>",
].join("\n"),
skills: [{ name: "weather" }],
resolvedSkills: [
{
name: "weather",
description: "Use weather tools for forecasts.",
filePath: skillFilePath,
baseDir: skillDir,
source: "test",
sourceInfo: {
path: skillDir,
source: "test",
scope: "project",
origin: "top-level",
baseDir: skillDir,
},
disableModelInvocation: false,
},
],
},
});
expect(context.systemPrompt).toContain("<available_skills>");
expect(context.systemPrompt).toContain("<name>weather</name>");
expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0);
expect(context.claudeSkillsPluginArgs).toEqual([]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("does not probe the transcript for non-claude-cli providers", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const transcriptCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-77011-other-provider",
cliSessionBinding: { sessionId: "test-cli-sid", cwdHash: hashCliSessionText(dir) },
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(transcriptCheck).not.toHaveBeenCalled();
expect(context.reusableCliSession).toEqual({ sessionId: "test-cli-sid" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses a larger automatic reseed history cap for Claude CLI", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const summaryMarker = "RESEED_SUMMARY_MARKER_KEEP";
const padding = "x".repeat(40_000);
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "compaction",
summary: `${summaryMarker} ${padding}`,
})}\n`,
"utf-8",
);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "claude-haiku-3-5",
timeoutMs: 1_000,
runId: "run-auto-claude-reseed-history-chars",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain(summaryMarker);
expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses the automatic Claude CLI cap before mapping canonical models to CLI aliases", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
modelAliases: {
"claude-opus-4-7": "opus",
},
},
},
],
});
const summaryMarker = "RESEED_ALIAS_SUMMARY_MARKER_KEEP";
const padding = "x".repeat(90_000);
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "compaction",
summary: `${summaryMarker} ${padding}`,
})}\n`,
"utf-8",
);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "claude-opus-4-7",
timeoutMs: 1_000,
runId: "run-auto-claude-alias-reseed-history-chars",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain(summaryMarker);
expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("keeps the default reseed history cap for non-Claude CLI backends", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const summaryMarker = "RESEED_SUMMARY_MARKER_DEFAULT";
const padding = "x".repeat(20_000);
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "compaction",
summary: `${summaryMarker} ${padding}`,
})}\n`,
"utf-8",
);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-default-reseed-history-chars",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses the automatic Claude CLI cap through the raw-tail reseed path", async () => {
const { dir, sessionFile } = createSessionFile();
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
reseedFromRawTranscriptWhenUncompacted: true,
},
},
],
});
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: vi.fn(async () => true),
});
const recentMarker = "RAW_RESEED_RECENT_MARKER_KEEP";
const padding = "x".repeat(8_000);
appendTranscriptEntry(sessionFile, {
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: `EARLIEST_USER ${padding}`, timestamp: 1 },
});
appendTranscriptEntry(sessionFile, {
id: "msg-2",
parentId: "msg-1",
timestamp: new Date(2).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: `${recentMarker} ${padding}` }],
api: "responses",
provider: "test-cli",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "claude-haiku-3-5",
timeoutMs: 1_000,
runId: "run-raw-reseed-cap-override",
cliSessionBinding: { sessionId: "cli-session", cwdHash: hashCliSessionText(dir) },
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain(recentMarker);
expect(context.openClawHistoryPrompt).toContain("EARLIEST_USER");
expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});