Files
openclaw/src/agents/cli-runner/prepare.test.ts
T
Peter Steinberger ecc49b5a87 refactor(tts): absorb speech core package (#118513)
* refactor(tts): absorb speech core package

* fix(tts): preserve runtime SDK exports

* refactor(tts): remove private package exports

* test(tts): align canonical runtime mocks

* test(tts): complete canonical settings mocks

* chore(plugin-sdk): regenerate API baseline for #118513
2026-08-03 02:25:48 -07:00

4458 lines
153 KiB
TypeScript

// Exercises CLI run preparation: auth boundaries, prompt hooks, context
// injection, MCP loopback setup, and reusable session decisions.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared";
import { expectDefined } from "@openclaw/normalization-core";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildGroupChatContext, buildGroupIntro } from "../../auto-reply/reply/groups.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { registerContextEngineForOwner } from "../../context-engine/registry.js";
import type { ContextEngine } from "../../context-engine/types.js";
import type { CliBackendPlugin } from "../../plugins/cli-backend.types.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import {
clearMemoryPluginState,
registerTestMemoryPromptBuilder,
} from "../../plugins/memory-state.test-fixtures.js";
import { createPluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import type { PluginRuntime } from "../../plugins/runtime/types.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
import { readExternalCliBootstrapCredential as readExternalCliBootstrapCredentialImpl } from "../auth-profiles/external-cli-sync.js";
import { resolveApiKeyForProfile as resolveApiKeyForProfileImpl } from "../auth-profiles/oauth.js";
import {
loadAuthProfileStoreWithoutExternalProfiles,
saveAuthProfileStore,
} from "../auth-profiles/store.js";
import {
resetCliAuthEpochTestDeps,
setCliAuthEpochTestDeps,
} from "../cli-auth-epoch.test-support.js";
import { testing as cliBackendsTesting } from "../cli-backends.test-support.js";
import {
buildDefaultTestCliBackend,
createCliRunnerPrepareFixture,
createTestMcpLoopbackClientGrant,
createTestMcpLoopbackServer,
createTestMcpLoopbackServerConfig,
createWeatherSkillFixture,
wrappedPluginSystemContext,
type TestCliBackendParams,
} from "../cli-runner.test-helpers.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 type { SandboxWorkspaceInfo } from "../sandbox/types.js";
import type { SystemAgentToolOptions } from "../tools/system-agent-tool.js";
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../video-generation-task-status.js";
import { prepareCliRunContext } from "./prepare.js";
import { setCliRunnerPrepareTestDeps } from "./prepare.test-support.js";
import type { RunCliAgentParams } from "./types.js";
function registerTestContextEngine(
id: string,
factory: Parameters<typeof registerContextEngineForOwner>[1],
) {
return registerContextEngineForOwner(id, factory, `test:${id}`, {
allowSameOwnerRefresh: true,
});
}
function installTestPluginRegistry() {
const builder = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
activateGlobalSideEffects: true,
});
setActivePluginRegistry(builder.registry);
return builder;
}
const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({})));
const ensureSandboxWorkspaceForSessionMock = vi.hoisted(() =>
vi.fn<() => Promise<SandboxWorkspaceInfo | null>>(async () => null),
);
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: getRuntimeConfigMock,
}));
vi.mock("../sandbox.js", () => ({
ensureSandboxWorkspaceForSession: ensureSandboxWorkspaceForSessionMock,
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => null),
}));
vi.mock("../../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
resolveModelOverridePolicy: vi.fn(),
setTtsMachinePrefsPathResolver: vi.fn(),
}));
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),
}));
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),
}));
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,
);
let defaultTestCliBackend = buildDefaultTestCliBackend();
function createCliBackendConfig(params: TestCliBackendParams = {}): OpenClawConfig {
defaultTestCliBackend = buildDefaultTestCliBackend(params);
return {};
}
function setCliBackendForPrepareTest(
params: {
authEpochMode?: CliBackendPlugin["authEpochMode"];
autoSelectAuthProfile?: boolean;
bundleMcp?: boolean;
command?: string;
id?: string;
liveSession?: boolean;
modelAliases?: Record<string, string>;
modelProvider?: string;
pluginId?: string;
prepareExecution?: CliBackendPlugin["prepareExecution"];
sessionMode?: "always" | "existing" | "none";
reseedFromRawTranscriptWhenUncompacted?: boolean;
} = {},
) {
const id = params.id ?? "claude-cli";
// Keep preparation behind the same runtime resolver seam that production
// uses; direct backend constants would bypass provider ownership.
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id,
pluginId: params.pluginId ?? "anthropic",
modelProvider: params.modelProvider ?? "anthropic",
bundleMcp: params.bundleMcp ?? false,
...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}),
...(params.bundleMcp ? { bundleMcpMode: "claude-config-file" as const } : {}),
...(params.autoSelectAuthProfile !== undefined
? { autoSelectAuthProfile: params.autoSelectAuthProfile }
: {}),
...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}),
...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}),
config: {
command: params.command ?? "claude",
args: ["--print"],
resumeArgs: ["--resume", "{sessionId}"],
output: "jsonl",
input: "stdin",
sessionMode: params.sessionMode ?? "existing",
...(params.modelAliases ? { modelAliases: params.modelAliases } : {}),
...(params.liveSession ? { liveSession: "claude-stdio" as const } : {}),
...(params.reseedFromRawTranscriptWhenUncompacted
? { reseedFromRawTranscriptWhenUncompacted: true }
: {}),
},
},
],
});
}
function setRawCliBackendForPrepareTest(backend: CliBackendPlugin & { pluginId: string }) {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [backend],
});
}
type CliContextBudgetTestCase = {
name: string;
provider: string;
agentContextTokens?: number;
expectedContextTokens: number;
model: string;
modelAliases?: Record<string, string>;
};
describe("prepareCliRunContext", () => {
let fixture: ReturnType<typeof createCliRunnerPrepareFixture>;
it.each<CliContextBudgetTestCase>([
{
name: "Claude CLI with a selected-agent cap",
provider: "claude-cli",
agentContextTokens: 80_000,
expectedContextTokens: 80_000,
model: "claude-opus-4-7",
},
{
name: "a Claude CLI user alias",
provider: "claude-cli",
agentContextTokens: undefined,
expectedContextTokens: 100_000,
model: "large",
modelAliases: { large: "claude-opus-4-7" },
},
{
name: "a Claude CLI-native alias",
provider: "claude-cli",
agentContextTokens: undefined,
expectedContextTokens: 100_000,
model: "claude-opus-4-7",
modelAliases: { "claude-opus-4-7": "deployment-large" },
},
{
name: "a generic CLI backend alias",
provider: "fixture-cli",
agentContextTokens: undefined,
expectedContextTokens: 100_000,
model: "claude-opus-4-7",
},
])("resolves canonical model budgets for $name", async (testCase) => {
const prepareExecution = vi.fn(async () => undefined);
const baseConfig = createCliBackendConfig();
setCliBackendForPrepareTest({
id: testCase.provider,
command: testCase.provider === "claude-cli" ? "claude" : testCase.provider,
modelProvider: "fixture-anthropic",
pluginId: "fixture-plugin",
prepareExecution,
modelAliases: testCase.modelAliases,
});
const context = await fixture.prepare({
provider: testCase.provider,
model: testCase.model,
config: {
...baseConfig,
agents: {
...baseConfig.agents,
...(testCase.agentContextTokens
? { list: [{ id: "main", contextTokens: testCase.agentContextTokens }] }
: {}),
},
models: {
providers: {
"fixture-anthropic": {
baseUrl: "https://api.anthropic.com",
contextTokens: 200_000,
models: [
{
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
contextTokens: 100_000,
},
],
},
"collision-provider": {
baseUrl: "https://collision.invalid",
models: [
{
id: "large",
name: "Unrelated Large",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_000,
maxTokens: 4_096,
contextTokens: 32_000,
},
],
},
"claude-cli": {
baseUrl: "https://runtime.invalid",
models: [
{
id: "large",
name: "Configured Alias Source",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
contextTokens: 200_000,
},
],
},
},
},
} satisfies OpenClawConfig,
});
expect(context.backendResolved.modelProvider).toBe("fixture-anthropic");
expect(context.contextWindowInfo?.tokens).toBe(testCase.expectedContextTokens);
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ contextTokenBudget: testCase.expectedContextTokens }),
);
});
beforeEach(() => {
// Install narrow test doubles for external runtime seams so preparation
// remains about data flow, not bundled plugin or loopback startup cost.
defaultTestCliBackend = buildDefaultTestCliBackend();
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [defaultTestCliBackend],
});
setCliRunnerPrepareTestDeps({
isWorkspaceBootstrapPending: vi.fn(async () => false),
makeBootstrapWarn: vi.fn(() => () => undefined),
resolveBootstrapContextForRun: vi.fn(async () => ({
bootstrapFiles: [],
contextFiles: [],
})),
getActiveMcpLoopbackRuntime: vi.fn(() => undefined),
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
revokeMcpLoopbackClientGrant: vi.fn(() => true),
resolveMcpLoopbackPolicyTools: vi.fn(() => ({ agentId: "main", tools: [] })),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: null, sourcePath: null })),
prepareClaudeCliSkillsPlugin: vi.fn(async () => ({
args: [],
cleanup: vi.fn(async () => undefined),
})),
getClaudeLiveSessionGenerationForOwner: vi.fn(() => undefined),
readExternalCliBootstrapCredential: readExternalCliBootstrapCredentialImpl,
resolveApiKeyForProfile: resolveApiKeyForProfileImpl,
});
mockGetGlobalHookRunner.mockReturnValue(null);
getRuntimeConfigMock.mockReturnValue({});
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(undefined);
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(undefined);
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(undefined);
ensureSandboxWorkspaceForSessionMock.mockReset();
ensureSandboxWorkspaceForSessionMock.mockResolvedValue(null);
fixture = createCliRunnerPrepareFixture(prepareCliRunContext);
});
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
resetCliAuthEpochTestDeps();
getRuntimeConfigMock.mockReset();
mockGetGlobalHookRunner.mockReset();
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReset();
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReset();
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReset();
ensureSandboxWorkspaceForSessionMock.mockReset();
resetContextWindowCacheForTest();
clearMemoryPluginState();
setActivePluginRegistry(createTestRegistry());
vi.unstubAllEnvs();
fixture.cleanup();
});
it("honors an explicit auth agent directory independently of session identity", async () => {
const { dir } = fixture.session;
const modelOwnerAgentDir = path.join(dir, "ops-agent");
const systemAgentDir = path.join(dir, "openclaw-agent");
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(modelOwnerAgentDir, { recursive: true });
setRawCliBackendForPrepareTest({
id: "test-cli",
pluginId: "test-plugin",
bundleMcp: false,
prepareExecution,
config: {
command: "test-cli",
args: ["--print"],
output: "text",
input: "arg",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
sessionKey: "agent:openclaw:main",
agentId: "openclaw",
agentDir: modelOwnerAgentDir,
authProfileId: "test-cli:ops",
config: {
agents: {
list: [
{ id: "ops", default: true, agentDir: modelOwnerAgentDir },
{ id: "openclaw", agentDir: systemAgentDir },
],
},
},
});
expect(context.effectiveAuthProfileId).toBe("test-cli:ops");
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: modelOwnerAgentDir,
authProfileId: "test-cli:ops",
}),
);
});
it("passes expired Gemini CLI OAuth fields to CLI-owned refresh", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "google-gemini-cli:user@example.test";
const prepareExecution = vi.fn(async () => ({
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
}));
const resolveApiKeyForProfile = vi.fn(async () => {
throw new Error("Gemini CLI OAuth must not enter core refresh");
});
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "google-gemini-cli",
access: "raw-access-token",
refresh: "raw-refresh-token",
expires: 1,
projectId: "project-1",
email: "user@example.test",
},
},
},
agentDir,
);
setRawCliBackendForPrepareTest({
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: false,
authEpochMode: "profile-only",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
});
const context = await fixture.prepare({
sessionKey: "agent:main:main",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
authProfileId,
onSuccessfulAuthBinding: () => {},
config: {},
});
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
authCredential: expect.objectContaining({
type: "oauth",
provider: "google-gemini-cli",
access: "raw-access-token",
refresh: "raw-refresh-token",
expires: 1,
}),
}),
);
expect(context.authBindingFingerprint).toMatch(/^[a-f0-9]{64}$/);
expect(context.authBindingSkipsLocalCredential).toBe(true);
});
it("still materializes selected API keys for Gemini CLI preparation", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "google:api-key";
const prepareExecution = vi.fn(async () => ({
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
}));
const resolveApiKeyForProfile = vi.fn(async () => ({
apiKey: "resolved-api-key",
profileId: authProfileId,
profileType: "api_key" as const,
provider: "google",
}));
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "api_key",
provider: "google",
key: "stored-api-key",
},
},
},
agentDir,
);
setRawCliBackendForPrepareTest({
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: false,
authEpochMode: "profile-only",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
});
await fixture.prepare({
sessionKey: "agent:main:main",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
authProfileId,
config: {},
});
expect(resolveApiKeyForProfile).toHaveBeenCalledOnce();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
authCredential: expect.objectContaining({
type: "api_key",
provider: "google",
key: "resolved-api-key",
}),
}),
);
});
it("selects the configured Gemini CLI OAuth profile when no explicit profile is passed", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "google-gemini-cli:user@example.test";
const prepareExecution = vi.fn(async () => ({
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
}));
const resolveApiKeyForProfile = vi.fn(async () => {
throw new Error("Gemini CLI OAuth must not enter core refresh");
});
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "google-gemini-cli",
access: "raw-access-token",
refresh: "raw-refresh-token",
expires: 1_800_000_000_000,
projectId: "project-1",
email: "user@example.test",
},
},
},
agentDir,
);
setRawCliBackendForPrepareTest({
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: false,
authEpochMode: "profile-only",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
});
await fixture.prepare({
sessionKey: "agent:main:main",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
config: {
auth: {
profiles: {
[authProfileId]: {
provider: "google-gemini-cli",
mode: "oauth",
email: "user@example.test",
},
},
},
} as OpenClawConfig,
});
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
authCredential: expect.objectContaining({
type: "oauth",
provider: "google-gemini-cli",
access: "raw-access-token",
refresh: "raw-refresh-token",
expires: 1_800_000_000_000,
}),
}),
);
});
it("does not expose auth profile credentials to non-bundled prepare hooks", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "test-cli:secret";
const prepareExecution = vi.fn(async (_ctx: unknown) => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "api_key",
provider: "test-cli",
key: "secret-key",
},
},
},
agentDir,
);
setRawCliBackendForPrepareTest({
id: "test-cli",
pluginId: "test-plugin",
bundleMcp: false,
prepareExecution,
config: {
command: "test-cli",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
});
await fixture.prepare({
sessionKey: "agent:main:main",
authProfileId,
config: {},
});
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
}),
);
expect(prepareExecution.mock.calls[0]?.[0]).not.toHaveProperty("authCredential");
});
it("persists and forwards a refreshed managed Anthropic OAuth profile", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:openclaw-managed";
const prepareExecution = vi.fn(async () => undefined);
const refreshedCredential = {
type: "oauth" as const,
provider: "anthropic",
access: "refreshed-access-token",
refresh: "refreshed-refresh-token",
expires: Date.now() + 60 * 60_000,
};
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "stored-refresh-token",
expires: Date.now() - 60_000,
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
const resolveApiKeyForProfile = vi.fn<typeof resolveApiKeyForProfileImpl>(async ({ store }) => {
saveAuthProfileStore(
{
...store,
profiles: {
...store.profiles,
[authProfileId]: refreshedCredential,
},
},
agentDir,
);
return {
apiKey: refreshedCredential.access,
provider: refreshedCredential.provider,
profileId: authProfileId,
profileType: refreshedCredential.type,
credential: refreshedCredential,
};
});
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
});
await fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId,
authCredential: expect.objectContaining({
type: "oauth",
provider: "anthropic",
access: refreshedCredential.access,
refresh: refreshedCredential.refresh,
}),
}),
);
expect(loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[authProfileId]).toEqual(
refreshedCredential,
);
expect(resolveApiKeyForProfile).toHaveBeenCalledWith(
expect.objectContaining({
profileId: authProfileId,
agentDir,
allowProfileFallback: false,
}),
);
});
it("runs an imported Claude CLI login natively without forwarding a credential", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:claude-cli";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "claude-cli",
access: "expired-imported-access",
refresh: "imported-refresh",
expires: Date.now() - 60_000,
email: "owner@example.com",
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
const resolveApiKeyForProfile = vi.fn<typeof resolveApiKeyForProfileImpl>(async () => null);
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
// Live login is expired too: expiry is Claude's to repair via its own
// refresh token, so passthrough must not gate on it.
readExternalCliBootstrapCredential: vi.fn(() => ({
type: "oauth" as const,
provider: "claude-cli",
access: "live-native-access",
refresh: "live-native-refresh",
expires: Date.now() - 30_000,
email: "owner@example.com",
})),
});
await fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
expect(prepareExecution).toHaveBeenCalledTimes(1);
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId, authCredential: undefined }),
);
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
});
it("fails closed when the imported Claude CLI login is gone from the host", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:claude-cli";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "claude-cli",
access: "expired-imported-access",
refresh: "imported-refresh",
expires: Date.now() - 60_000,
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
setCliRunnerPrepareTestDeps({
readExternalCliBootstrapCredential: vi.fn(() => null),
});
const preparation = fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
await expect(preparation).rejects.toThrow("no reusable Claude CLI login is available");
await expect(preparation).rejects.toThrow("claude auth login");
expect(prepareExecution).not.toHaveBeenCalled();
});
it("fails closed when the host Claude CLI login is a different account", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:claude-cli";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "claude-cli",
access: "imported-access",
refresh: "imported-refresh",
expires: Date.now() + 60 * 60_000,
accountId: "acct-selected",
email: "owner@example.com",
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
setCliRunnerPrepareTestDeps({
readExternalCliBootstrapCredential: vi.fn(() => ({
type: "oauth" as const,
provider: "claude-cli",
access: "other-account-access",
refresh: "other-account-refresh",
expires: Date.now() + 60 * 60_000,
accountId: "acct-other",
email: "other@example.com",
})),
});
const preparation = fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
await expect(preparation).rejects.toThrow(
"current Claude CLI login belongs to a different account",
);
expect(prepareExecution).not.toHaveBeenCalled();
});
it("does not revive a selected managed credential when auth resolution returns null", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:openclaw-managed";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "expired-refresh-token",
expires: Date.now() - 60_000,
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile: vi.fn(async () => null),
});
const preparation = fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
await expect(preparation).rejects.toThrow(
`could not materialize selected auth profile "${authProfileId}"`,
);
await expect(preparation).rejects.toThrow("openclaw models auth login --provider anthropic");
expect(prepareExecution).not.toHaveBeenCalled();
});
it("does not replace an explicit Claude CLI profile with a resolver fallback", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:account-a";
const fallbackProfileId = "anthropic:account-b";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-account-a-access",
refresh: "account-a-refresh",
expires: Date.now() - 60_000,
},
[fallbackProfileId]: {
type: "oauth",
provider: "anthropic",
access: "account-b-access",
refresh: "account-b-refresh",
expires: Date.now() + 60 * 60_000,
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile: vi.fn(async () => ({
apiKey: "account-b-access",
provider: "anthropic",
profileId: fallbackProfileId,
profileType: "oauth",
credential: {
type: "oauth",
provider: "anthropic",
access: "account-b-access",
refresh: "account-b-refresh",
expires: Date.now() + 60 * 60_000,
},
})),
});
await expect(
fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
}),
).rejects.toThrow(`resolved as "${fallbackProfileId}"`);
expect(prepareExecution).not.toHaveBeenCalled();
});
it("surfaces managed profile refresh failures before backend preparation", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:openclaw-managed";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "expired-refresh-token",
expires: Date.now() - 60_000,
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile: vi.fn(async () => {
throw new Error("OAuth refresh failed. Run claude auth login.");
}),
});
await expect(
fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
}),
).rejects.toThrow("Run claude auth login");
expect(prepareExecution).not.toHaveBeenCalled();
});
it.each([
{
name: "keeps implicit profile selection for auth bridges",
autoSelectAuthProfile: undefined,
expectedAuthProfileId: "claude-cli:stored",
},
{
name: "lets environment-only hooks opt out of profile selection",
autoSelectAuthProfile: false,
expectedAuthProfileId: undefined,
},
])("$name", async (testCase) => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "claude-cli:stored";
const prepareExecution = vi.fn(async () => ({ env: { TEST_PREPARED_ENV: "1" } }));
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "api_key",
provider: "claude-cli",
key: "stored-key",
},
},
},
agentDir,
);
setCliBackendForPrepareTest({
prepareExecution,
autoSelectAuthProfile: testCase.autoSelectAuthProfile,
});
const context = await fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
config: {
auth: {
profiles: {
[authProfileId]: { provider: "claude-cli", mode: "api_key" },
},
},
},
});
expect(context.effectiveAuthProfileId).toBe(testCase.expectedAuthProfileId);
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: testCase.expectedAuthProfileId }),
);
});
it("keeps bundled Claude secret input on the private prepared runner context", async () => {
const secretInput = {
fd: 3,
fingerprint: "credential-a",
createData: () => Buffer.from("secret"),
};
const prepareExecution = vi.fn(async () => ({
env: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" },
secretInput,
}));
setCliBackendForPrepareTest({
prepareExecution: prepareExecution as CliBackendPlugin["prepareExecution"],
});
const context = await fixture.prepare({
provider: "claude-cli",
model: "sonnet",
config: {},
});
expect(context.preparedBackend.secretInput).toBe(secretInput);
expect(context.preparedBackend.env).toMatchObject({
CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3",
});
});
it("carries projected network-result capability into the prepared CLI context", async () => {
setRawCliBackendForPrepareTest({
id: "network-tool-cli",
pluginId: "network-tool-plugin",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "network-tool-cli",
args: ["--print"],
output: "text",
input: "arg",
sessionMode: "none",
},
});
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime: vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
})),
resolveMcpLoopbackScopedTools: vi.fn(() => ({
agentId: "main",
tools: [
{
name: "fake_web_tool",
label: "Fake web tool",
description: "Test network content capability",
parameters: Type.Object({}, { additionalProperties: false }),
resultContentSource: "network" as const,
execute: vi.fn(async () => ({ content: [] })),
},
],
})),
});
const context = await fixture.prepare({
provider: "network-tool-cli",
model: "test-model",
config: createCliBackendConfig({ bundleMcp: true }),
});
expect(context.resultContentSourceByToolName?.get("fake_web_tool")).toBe("network");
});
it("lets Gemini CLI preparation override generated MCP system settings auth", async () => {
const { dir } = fixture.session;
const profileSystemSettingsPath = path.join(dir, "profile-system-settings.json");
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const prepareExecution = vi.fn(async (_ctx: unknown) => ({
env: {
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileSystemSettingsPath,
},
}));
setRawCliBackendForPrepareTest({
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: true,
bundleMcpMode: "gemini-system-settings",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
});
let cleanup: (() => Promise<void>) | undefined;
try {
const context = await fixture.prepare({
sessionKey: "agent:main:main",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
config: {},
});
cleanup = context.preparedBackend.cleanup;
const prepareExecutionArg = prepareExecution.mock.calls[0]?.[0] as
| { env?: Record<string, string> }
| undefined;
const generatedSystemSettingsPath = prepareExecutionArg?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
expect(typeof generatedSystemSettingsPath).toBe("string");
expect(generatedSystemSettingsPath).not.toBe(profileSystemSettingsPath);
const generatedSettings = JSON.parse(
fs.readFileSync(generatedSystemSettingsPath ?? "", "utf8"),
) as {
mcp?: { allowed?: string[] };
mcpServers?: Record<string, { url?: string }>;
};
expect(generatedSettings.mcp?.allowed).toEqual(["openclaw"]);
expect(generatedSettings.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:31783/mcp");
expect(context.preparedBackend.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe(
profileSystemSettingsPath,
);
} finally {
await cleanup?.();
}
});
it("preserves backend staging for queued execution without running it during prepare", async () => {
const beforeExecution = vi.fn(async () => {});
const prepareExecution = vi.fn(async () => ({ beforeExecution }));
setRawCliBackendForPrepareTest({
id: "test-cli",
pluginId: "test-plugin",
bundleMcp: false,
prepareExecution,
config: {
command: "test-cli",
args: ["--print"],
sessionMode: "existing",
output: "text",
input: "arg",
},
});
const context = await fixture.prepare({});
expect(prepareExecution).toHaveBeenCalledOnce();
expect(beforeExecution).not.toHaveBeenCalled();
await context.preparedBackend.beforeExecution?.();
expect(beforeExecution).toHaveBeenCalledOnce();
});
it("cleans generated Gemini MCP settings when auth preparation fails", async () => {
let generatedSystemSettingsPath: string | undefined;
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const prepareExecution = vi.fn(async (ctx: unknown) => {
generatedSystemSettingsPath = (ctx as { env?: Record<string, string> }).env
?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
throw new Error("Gemini auth profile was selected but no credential material was found");
});
const revokeMcpLoopbackClientGrant = vi.fn(() => true);
setRawCliBackendForPrepareTest({
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: true,
bundleMcpMode: "gemini-system-settings",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
revokeMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
});
await expect(
fixture.prepare({
sessionKey: "agent:main:main",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
config: {},
}),
).rejects.toThrow(/no credential material/);
expect(generatedSystemSettingsPath).toBeTruthy();
expect(fs.existsSync(generatedSystemSettingsPath ?? "")).toBe(false);
expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token");
});
it("cleans prepared execution resources when auth epoch resolution fails", async () => {
const preparedExecutionCleanup = vi.fn(async () => undefined);
const prepareExecution = vi.fn(async () => ({ cleanup: preparedExecutionCleanup }));
setCliAuthEpochTestDeps({
loadAuthProfileStoreForRuntime: () => {
throw new Error("auth epoch read failed");
},
});
setRawCliBackendForPrepareTest({
id: "test-cli",
pluginId: "test",
bundleMcp: false,
authEpochMode: "profile-only",
prepareExecution,
config: {
command: "test-cli",
args: ["--print"],
systemPromptArg: "--system-prompt",
systemPromptWhen: "first",
output: "text",
input: "arg",
sessionMode: "existing",
},
});
await expect(
fixture.prepare({
sessionKey: "agent:main:main",
authProfileId: "test-cli:profile",
config: {},
}),
).rejects.toThrow("auth epoch read failed");
expect(prepareExecution).toHaveBeenCalledOnce();
expect(preparedExecutionCleanup).toHaveBeenCalledOnce();
});
it("cleans prepared MCP and skills plugin dirs when mid-prepare reference lookup fails", async () => {
const { dir } = fixture.session;
const tempEnvSnapshot = captureEnv(["TMPDIR", "TMP", "TEMP"]);
const tempRoot = path.join(dir, "tmp");
const skillsPluginDir = path.join(dir, "claude-skills-plugin");
const skillsCleanup = vi.fn(async () => {
fs.rmSync(skillsPluginDir, { recursive: true, force: true });
});
fs.mkdirSync(tempRoot, { recursive: true });
fs.mkdirSync(skillsPluginDir, { recursive: true });
setTestEnvValue("TMPDIR", tempRoot);
setTestEnvValue("TMP", tempRoot);
setTestEnvValue("TEMP", tempRoot);
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
prepareClaudeCliSkillsPlugin: vi.fn(async () => ({
args: ["--plugin-dir", skillsPluginDir],
cleanup: skillsCleanup,
})),
resolveOpenClawReferencePaths: vi.fn(async () => {
throw new Error("reference path lookup failed");
}),
});
try {
await expect(
fixture.prepare({
sessionKey: "agent:main:main",
config: createCliBackendConfig({ bundleMcp: true }),
}),
).rejects.toThrow("reference path lookup failed");
expect(skillsCleanup).toHaveBeenCalledOnce();
expect(fs.existsSync(skillsPluginDir)).toBe(false);
expect(
fs.readdirSync(tempRoot).filter((entry) => entry.startsWith("openclaw-cli-mcp-")),
).toEqual([]);
} finally {
tempEnvSnapshot.restore();
}
});
it("prepares side questions without agent-turn context, tools, hooks, or reusable sessions", async () => {
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: "prior user text", timestamp: 1 },
});
const resolveBootstrapContextForRun = vi.fn(async () => ({
bootstrapFiles: [
{ name: "AGENTS.md" as const, path: "AGENTS.md", content: "bootstrap", missing: false },
],
contextFiles: [{ path: "context.md", content: "context" }],
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const prepareClaudeCliSkillsPlugin = vi.fn(async () => ({
args: ["--plugin-dir", "/tmp/claude-skills"],
cleanup: vi.fn(async () => undefined),
}));
const prepareExecution = vi.fn(async () => undefined);
setRawCliBackendForPrepareTest({
id: "test-cli",
pluginId: "test",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "always-on",
sideQuestionToolMode: "disabled",
prepareExecution,
config: {
command: "test-cli",
args: ["--print"],
liveSession: "claude-stdio",
sessionMode: "always",
output: "jsonl",
input: "stdin",
},
});
setCliRunnerPrepareTestDeps({
resolveBootstrapContextForRun,
ensureMcpLoopbackServer,
prepareClaudeCliSkillsPlugin,
makeBootstrapWarn: vi.fn(() => () => undefined),
getActiveMcpLoopbackRuntime: vi.fn(() => undefined),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
resolveMcpLoopbackScopedTools: vi.fn(() => ({
agentId: "main",
tools: [
{
name: "exec",
label: "exec",
description: "test exec tool",
parameters: Type.Object({}, { additionalProperties: false }),
execute: vi.fn(async () => ({ content: [], details: { ok: true } })),
},
],
})),
resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: "docs", sourcePath: "src" })),
});
const context = await fixture.prepare({
sessionKey: "agent:main:main",
config: createCliBackendConfig({ bundleMcp: true }),
prompt: "side question prompt",
executionMode: "side-question",
timeoutMs: 120_000,
extraSystemPrompt: "BTW system prompt",
disableTools: true,
cliSessionId: "existing-cli-session",
});
expect(resolveBootstrapContextForRun).not.toHaveBeenCalled();
expect(ensureMcpLoopbackServer).not.toHaveBeenCalled();
expect(prepareClaudeCliSkillsPlugin).not.toHaveBeenCalled();
expect(mockGetGlobalHookRunner).not.toHaveBeenCalled();
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "side-question" }),
);
expect(context.systemPrompt).toBe("BTW system prompt");
expect(context.params.prompt).toBe("side question prompt");
expect(context.openClawHistoryPrompt).toBeUndefined();
expect(context.contextEngine).toBeUndefined();
expect(context.contextEngineTurnPrompt).toBeUndefined();
expect(context.hadSessionFile).toBe(false);
expect(context.claudeSkillsPluginArgs).toEqual([]);
expect(context.preparedBackend.backend.sessionMode).toBe("none");
expect(context.preparedBackend.backend.liveSession).toBeUndefined();
expect(context.bootstrapPromptWarningLines).toEqual([]);
expect(context.systemPromptReport.injectedWorkspaceFiles).toEqual([]);
expect(context.systemPromptReport.tools.entries).toEqual([]);
});
it.each([
{
name: "full guidance for a backend with native file tools",
nativeToolMode: "always-on" as const,
transportsSystemPrompt: true,
expectedText: "BOOTSTRAP.md below; follow before normal reply.",
},
{
name: "limited guidance for a backend without native file tools",
nativeToolMode: undefined,
transportsSystemPrompt: true,
expectedText: "this run cannot safely finish full BOOTSTRAP.md",
},
{
name: "no guidance for a backend without system-prompt transport",
nativeToolMode: "always-on" as const,
transportsSystemPrompt: false,
expectedText: undefined,
},
])("renders $name", async ({ nativeToolMode, transportsSystemPrompt, expectedText }) => {
const { dir } = fixture.session;
const bootstrapPath = path.join(dir, "BOOTSTRAP.md");
const config = {
agents: { defaults: { workspace: dir } },
} satisfies OpenClawConfig;
setRawCliBackendForPrepareTest({
id: "test-cli",
pluginId: "test",
bundleMcp: false,
nativeToolMode,
config: {
command: "test-cli",
args: ["--print"],
...(transportsSystemPrompt ? { systemPromptArg: "--system-prompt" } : {}),
systemPromptWhen: "first",
sessionMode: "existing",
output: "text",
input: "arg",
},
});
setCliRunnerPrepareTestDeps({
isWorkspaceBootstrapPending: vi.fn(async () => true),
resolveBootstrapContextForRun: vi.fn(async () => ({
bootstrapFiles: [
{
name: "BOOTSTRAP.md" as const,
path: bootstrapPath,
content: "Complete the first-run ritual, then delete this file.",
missing: false,
},
],
contextFiles: [
{
path: bootstrapPath,
content: "Complete the first-run ritual, then delete this file.",
},
],
})),
});
const context = await fixture.prepare({
sessionKey: "agent:main:main",
config,
prompt: "Hello",
runId: `run-bootstrap-${nativeToolMode ?? "limited"}`,
trigger: "user",
extraSystemPrompt: "stable prompt",
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText("stable prompt"),
cwdHash: hashCliSessionText(dir),
},
});
if (expectedText) {
expect(context.systemPrompt).toContain("## Bootstrap Pending");
expect(context.systemPrompt).toContain(expectedText);
if (nativeToolMode === "always-on") {
expect(context.systemPrompt).toContain("## " + bootstrapPath);
expect(context.systemPrompt).toContain("Complete the first-run ritual");
expect(context.systemPromptReport.injectedWorkspaceFiles).toEqual([
expect.objectContaining({
name: "BOOTSTRAP.md",
injectedChars: expect.any(Number),
truncated: false,
}),
]);
} else {
expect(context.systemPrompt).not.toContain("## " + bootstrapPath);
expect(context.systemPrompt).not.toContain("Complete the first-run ritual");
expect(context.systemPromptReport.injectedWorkspaceFiles).toEqual([]);
}
expect(context.reusableCliSession).toEqual({
mode: "reuse-with-drift",
sessionId: "cli-session",
drift: { reasons: ["system-prompt"] },
});
} else {
expect(context.systemPrompt).not.toContain("## Bootstrap Pending");
expect(context.reusableCliSession).toEqual({
mode: "reuse",
sessionId: "cli-session",
});
}
});
it("applies prompt-build hook context to Claude-style CLI preparation", async () => {
const { dir } = fixture.session;
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: "earlier context", timestamp: 1 },
});
fixture.appendTranscript({
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",
})),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
// The hook receives historical messages, while the final prompt receives
// only the hook-approved prepend context plus the latest user prompt.
const context = await fixture.prepare({
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
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")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`,
);
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");
});
it("prepends current-turn context after prompt-build hooks without changing hook or transcript prompt", async () => {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({
prependContext: "trusted hook context",
appendContext: "trusted hook tail",
})),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
// Current inbound metadata is untrusted channel context. It should shape
// the CLI prompt without contaminating transcript or hook inputs.
const context = await fixture.prepare({
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
transcriptPrompt: "latest ask",
currentInboundContext: {
text: "Sender: ⟦openclaw:ctx⟧\nsender_id=U123",
promptJoiner: " ",
},
runId: "run-test-context",
});
expect(context.params.prompt).toBe(
"Sender: ⟦openclaw:ctx⟧\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");
});
it("uses compact current-turn context when a room event resumes a CLI session", async () => {
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior room event",
timestamp: 1,
},
});
// Room resumes carry compact event text into the CLI prompt but keep the
// richer room context in OpenClaw history for reseed and audits.
const context = await fixture.prepare({
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
prompt: "[OpenClaw room event]",
currentInboundEventKind: "room_event",
currentInboundContext: {
text: "Room context:\nAlice: lunch?\n\nCurrent event:\nBob: yes",
resumableText: "Current event:\nBob: yes",
},
cliSessionBinding: {
sessionId: "cli-session",
},
config: createCliBackendConfig({
reseedFromRawTranscriptWhenUncompacted: true,
}),
});
expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" });
expect(context.params.prompt).toBe("Current event:\nBob: yes\n\n[OpenClaw room event]");
expect(context.openClawHistoryPrompt).toContain("Room context:\nAlice: lunch?");
expect(context.openClawHistoryPrompt).toContain("Current event:\nBob: yes");
});
it("marks inter-session prompts after CLI prompt-build hook context is applied", async () => {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({
prependContext: "trusted hook context",
})),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await fixture.prepare({
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
prompt: "foreign reply text",
inputProvenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:slack:dm:U123",
sourceChannel: "slack",
sourceTool: "sessions_send",
},
runId: "run-test",
});
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");
});
it("applies agent_turn_prepare-only context on the CLI path", async () => {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "agent_turn_prepare"),
runAgentTurnPrepare: vi.fn(async () => ({
prependContext: "turn prepend",
appendContext: "turn append",
})),
runBeforePromptBuild: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await fixture.prepare({
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
runId: "run-test-turn-prepare",
messageChannel: "telegram",
currentChannelId: "chat-1",
senderId: "user-456",
});
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
| {
channel?: string;
chatId?: string;
runId?: string;
senderId?: string;
sessionKey?: string;
}
| undefined;
expect(turnPrepareContext?.runId).toBe("run-test-turn-prepare");
expect(turnPrepareContext?.sessionKey).toBe("agent:main:test");
expect(turnPrepareContext?.channel).toBe("telegram");
expect(turnPrepareContext?.chatId).toBe("chat-1");
expect(turnPrepareContext?.senderId).toBe("user-456");
expect(hookRunner.runBeforePromptBuild).not.toHaveBeenCalled();
});
it("applies before_prompt_build hook context for CLI preparation", async () => {
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",
})),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await fixture.prepare({
messageChannel: "discord",
currentChannelId: "channel:room-1",
senderId: "user-789",
});
expect(context.params.prompt).toBe("prompt prepend\n\nlatest ask");
expect(context.systemPrompt).toBe(
`${wrappedPluginSystemContext("prompt prepend system")}\n\nprompt system\n\n${wrappedPluginSystemContext("prompt append system")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`,
);
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array<
[unknown, unknown]
>;
const promptContext = beforePromptBuildCalls[0]?.[1] as
| { channel?: string; chatId?: string; senderId?: string }
| undefined;
expect(promptContext?.channel).toBe("discord");
expect(promptContext?.chatId).toBe("room-1");
expect(promptContext?.senderId).toBe("user-789");
});
it("preserves the base prompt when prompt-build hooks fail", async () => {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => {
throw new Error("hook exploded");
}),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await fixture.prepare({});
expect(context.params.prompt).toBe("latest ask");
expect(context.systemPrompt).toContain("You are a personal assistant running inside OpenClaw.");
expect(context.systemPrompt).toContain("Current model identity: test-cli/test-model.");
expect(context.systemPrompt).not.toContain("hook exploded");
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
});
it("does not allocate a non-legacy context engine before fallible CLI preparation finishes", async () => {
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,
};
});
registerTestContextEngine(engineId, factory);
setCliRunnerPrepareTestDeps({
resolveOpenClawReferencePaths: vi.fn(async () => {
throw new Error("reference path lookup failed");
}),
});
await expect(
fixture.prepare({
config: {
...createCliBackendConfig(),
plugins: { slots: { contextEngine: engineId } },
},
}),
).rejects.toThrow("reference path lookup failed");
expect(factory).not.toHaveBeenCalled();
expect(dispose).not.toHaveBeenCalled();
});
it("cleans up prepared CLI backend when context-engine host validation fails", async () => {
installTestPluginRegistry();
const engineId = `cli-cleanup-engine-${Date.now().toString(36)}`;
const cleanup = vi.fn(async () => {});
const prepareExecution = vi.fn(async () => ({ cleanup }));
registerTestContextEngine(
engineId,
(): ContextEngine => ({
info: {
id: engineId,
name: "CLI cleanup engine",
hostRequirements: {
"agent-run": {
requiredCapabilities: ["assemble-before-prompt"],
unsupportedMessage: "context engine failed",
},
},
},
ingest: vi.fn(async () => ({ ingested: true })),
assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })),
compact: vi.fn(async () => ({ ok: true, compacted: false })),
}),
);
setRawCliBackendForPrepareTest({
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",
},
});
await expect(
fixture.prepare({
config: { plugins: { slots: { contextEngine: engineId } } },
}),
).rejects.toThrow("context engine failed");
expect(prepareExecution).toHaveBeenCalledOnce();
expect(cleanup).toHaveBeenCalledOnce();
});
it("rejects CLI runs for context engines that require pre-prompt assembly", async () => {
const engineId = `cli-unsupported-engine-${Date.now().toString(36)}`;
registerTestContextEngine(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 })),
};
});
await expect(
fixture.prepare({
config: {
...createCliBackendConfig(),
plugins: { slots: { contextEngine: engineId } },
},
}),
).rejects.toThrow(
`Context engine "${engineId}" cannot run operation "agent-run" on CLI backend "test-cli".`,
);
});
it("uses runtime config when resolving the CLI context engine", async () => {
const { dir } = fixture.session;
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 })),
};
});
registerTestContextEngine(engineId, factory);
getRuntimeConfigMock.mockReturnValue(runtimeConfig);
setRawCliBackendForPrepareTest({
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",
},
});
const context = await fixture.prepare({
config: undefined,
});
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,
}),
);
});
it("uses explicit static prompt text for CLI session reuse hashing", async () => {
const { dir } = fixture.session;
const context = await fixture.prepare({
extraSystemPrompt: "## Inbound Context\nchannel=telegram",
extraSystemPromptStatic: "",
cliSessionBinding: {
sessionId: "cli-session",
cwdHash: hashCliSessionText(dir),
},
});
expect(context.systemPrompt).toContain("## Inbound Context\nchannel=telegram");
expect(context.extraSystemPromptHash).toBeUndefined();
expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" });
});
it("invalidates CLI session reuse when explicit message-target policy changes", async () => {
const context = await fixture.prepare({
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
cliSessionBinding: {
sessionId: "cli-session",
messageToolPolicyHash: hashCliSessionText(
JSON.stringify({
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: false,
}),
),
},
});
expect(context.messageToolPolicyHash).toBeDefined();
expect(context.reusableCliSession).toEqual({
mode: "invalidate",
invalidatedReason: "message-policy",
});
});
it("requires explicit message targets by default for CLI subagents", async () => {
const context = await fixture.prepare({
sessionKey: "agent:main:subagent:child",
sourceReplyDeliveryMode: "message_tool_only",
});
expect(context.params.requireExplicitMessageTarget).toBe(true);
expect(context.messageToolPolicyHash).toBe(
hashCliSessionText(
JSON.stringify({
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
}),
),
);
});
it("uses cwd for CLI system prompt workspace guidance", async () => {
const { dir } = fixture.session;
const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-task-"));
try {
const context = await fixture.prepare({
cwd: taskDir,
});
expect(context.cwd).toBe(taskDir);
expect(context.systemPrompt).toContain(`Working directory: ${taskDir}`);
expect(context.systemPrompt).not.toContain(`Working directory: ${dir}`);
} finally {
fs.rmSync(taskDir, { recursive: true, force: true });
}
});
it("passes Telegram channel context into CLI system prompts without core rich guidance", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
agentPrompt: {
messageToolCapabilities: () => ["inlineButtons"],
},
} satisfies ChannelPlugin,
},
]),
);
const context = await fixture.prepare({
messageChannel: "telegram",
});
expect(context.systemPrompt).toContain("channel=telegram");
expect(context.systemPrompt).not.toContain("Telegram rich ON");
expect(context.systemPrompt).not.toContain("Telegram rich OFF");
});
it("ignores volatile prompt text when static prompt text matches", async () => {
const { dir } = fixture.session;
const staticPrompt = "## Direct Context\nYou are in a Telegram direct conversation.";
const context = await fixture.prepare({
extraSystemPrompt: `## Inbound Context\nchannel=heartbeat\n\n${staticPrompt}`,
extraSystemPromptStatic: staticPrompt,
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText(staticPrompt),
cwdHash: hashCliSessionText(dir),
},
});
expect(context.extraSystemPromptHash).toBe(hashCliSessionText(staticPrompt));
expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" });
});
it("soft-resumes content drift and surfaces a per-turn drift note", async () => {
const { dir } = fixture.session;
const context = await fixture.prepare({
sessionKey: "agent:main:test",
currentInboundContext: {
text: "Conversation info: ⟦openclaw:ctx⟧\nchannel=telegram",
},
extraSystemPrompt: "new stable prompt",
extraSystemPromptStatic: "new stable prompt",
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText("old stable prompt"),
cwdHash: hashCliSessionText(dir),
},
});
expect(context.reusableCliSession).toEqual({
mode: "reuse-with-drift",
sessionId: "cli-session",
drift: { reasons: ["system-prompt"] },
});
expect(context.openClawHistoryPrompt).toBeUndefined();
expect(context.params.prompt).toContain(
"OpenClaw resumed this CLI session after prompt content changed.",
);
expect(context.params.prompt).toContain("changed=system-prompt");
expect(context.params.prompt).toContain("latest ask");
});
it("invalidates content drift when the backend cannot receive a resumed system prompt", async () => {
const { dir } = fixture.session;
const context = await fixture.prepare({
extraSystemPrompt: "new stable prompt",
extraSystemPromptStatic: "new stable prompt",
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText("old stable prompt"),
cwdHash: hashCliSessionText(dir),
},
config: createCliBackendConfig({ systemPromptWhen: "never" }),
});
expect(context.reusableCliSession).toEqual({
mode: "invalidate",
invalidatedReason: "system-prompt",
});
expect(context.params.prompt).not.toContain(
"OpenClaw resumed this CLI session after prompt content changed.",
);
});
it.each([
{
name: "automatic config",
stableMode: "automatic",
staticPrompt: "group:telegram:group:automatic",
expectedStrongPrompt: false,
},
{
name: "message-tool config",
stableMode: "message_tool_only",
staticPrompt: "group:telegram:group:message_tool_only",
expectedStrongPrompt: true,
},
] as const)(
"reuses CLI session bindings across new inbound messages with stable binding facts for $name",
async ({ stableMode, staticPrompt, expectedStrongPrompt }) => {
const { dir } = fixture.session;
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
{
name: "message",
label: "Message",
description: "Send a message",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
],
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
resolveMcpLoopbackScopedTools,
});
const cliSessionBindingFacts = {
extraSystemPromptStatic: staticPrompt,
sourceReplyDeliveryMode: stableMode,
};
const first = await fixture.prepare({
sessionKey: "main",
prompt: "first ask",
extraSystemPrompt: `volatile msg-1\n\n${staticPrompt}`,
sourceReplyDeliveryMode: "message_tool_only",
currentMessageId: "msg-1",
cliSessionBindingFacts,
});
const second = await fixture.prepare({
sessionKey: "main",
prompt: "second ask",
extraSystemPrompt: `volatile msg-2\n\n${staticPrompt}`,
sourceReplyDeliveryMode: stableMode,
currentMessageId: "msg-2",
cliSessionBindingFacts,
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: first.extraSystemPromptHash,
messageToolPolicyHash: first.messageToolPolicyHash,
promptToolNamesHash: first.promptToolNamesHash,
cwdHash: hashCliSessionText(dir),
},
});
expect(first.extraSystemPromptHash).toBe(hashCliSessionText(staticPrompt));
expect(first.messageToolPolicyHash).toBeDefined();
expect(second.extraSystemPromptHash).toBe(first.extraSystemPromptHash);
expect(second.messageToolPolicyHash).toBe(first.messageToolPolicyHash);
expect(second.promptToolNamesHash).toBe(first.promptToolNamesHash);
if (expectedStrongPrompt) {
expect(first.systemPrompt).toContain(
"Current source visible reply MUST use `message(action=send)`",
);
} else {
expect(first.systemPrompt).toContain(
"Current-session final text normally routes to source",
);
expect(first.systemPrompt).toContain(
"If turn says final private, visible output uses `message(action=send)`",
);
}
expect(second.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
},
);
it("reuses CLI session bindings across explicit mention toggles with stable group prompt facts", async () => {
const { dir } = fixture.session;
const baseGroupCtx = {
ChatType: "group",
Provider: "telegram",
BotUsername: "SirPinchALotBot",
} as const;
const mentionedStaticPrompt = [
buildGroupChatContext({
sessionCtx: {
...baseGroupCtx,
ExplicitlyMentionedBot: true,
},
sourceReplyDeliveryMode: "automatic",
silentReplyPolicy: "allow",
silentToken: "NO_REPLY",
}),
buildGroupIntro({
defaultActivation: "mention",
}),
].join("\n\n");
const unmentionedStaticPrompt = [
buildGroupChatContext({
sessionCtx: {
...baseGroupCtx,
ExplicitlyMentionedBot: false,
},
sourceReplyDeliveryMode: "automatic",
silentReplyPolicy: "allow",
silentToken: "NO_REPLY",
}),
buildGroupIntro({
defaultActivation: "mention",
}),
].join("\n\n");
expect(unmentionedStaticPrompt).toBe(mentionedStaticPrompt);
const first = await fixture.prepare({
sessionKey: "agent:main:telegram:group:chat123",
prompt: "first ask",
extraSystemPrompt: [
"The incoming message explicitly mentions your channel identity @SirPinchALotBot.",
mentionedStaticPrompt,
].join("\n\n"),
sourceReplyDeliveryMode: "automatic",
cliSessionBindingFacts: {
extraSystemPromptStatic: mentionedStaticPrompt,
sourceReplyDeliveryMode: "automatic",
},
});
const second = await fixture.prepare({
sessionKey: "agent:main:telegram:group:chat123",
prompt: "second ask",
extraSystemPrompt: unmentionedStaticPrompt,
sourceReplyDeliveryMode: "automatic",
cliSessionBindingFacts: {
extraSystemPromptStatic: unmentionedStaticPrompt,
sourceReplyDeliveryMode: "automatic",
},
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: first.extraSystemPromptHash,
messageToolPolicyHash: first.messageToolPolicyHash,
cwdHash: hashCliSessionText(dir),
},
});
expect(second.extraSystemPromptHash).toBe(first.extraSystemPromptHash);
expect(second.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" });
});
it("invalidates CLI session bindings when owner policy changes prompt tool scope", async () => {
const { dir } = fixture.session;
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const resolveMcpLoopbackScopedTools = vi.fn((scope: { senderIsOwner?: boolean }) => ({
agentId: "main",
tools: [
{
name: "message",
label: "Message",
description: "Send a message",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
...(scope.senderIsOwner === false
? []
: [
{
name: "gateway",
label: "Gateway",
description: "Manage the gateway",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
]),
],
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
resolveMcpLoopbackScopedTools,
});
setRawCliBackendForPrepareTest({
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 cliSessionBindingFacts = {
extraSystemPromptStatic: "group:telegram:group:message_tool_only",
sourceReplyDeliveryMode: "message_tool_only" as const,
};
const first = await fixture.prepare({
sessionKey: "agent:main:telegram:group:chat123",
prompt: "first ask",
provider: "native-cli",
extraSystemPrompt: "volatile owner turn",
currentMessageId: "owner-message",
senderIsOwner: true,
cliSessionBindingFacts,
config: createCliBackendConfig({ bundleMcp: true }),
});
const second = await fixture.prepare({
sessionKey: "agent:main:telegram:group:chat123",
prompt: "second ask",
provider: "native-cli",
extraSystemPrompt: "volatile non-owner turn",
currentMessageId: "non-owner-message",
senderIsOwner: false,
cliSessionBindingFacts,
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: first.extraSystemPromptHash,
messageToolPolicyHash: first.messageToolPolicyHash,
promptToolNamesHash: first.promptToolNamesHash,
cwdHash: hashCliSessionText(dir),
mcpConfigHash: first.preparedBackend.mcpConfigHash,
mcpResumeHash: first.preparedBackend.mcpResumeHash,
},
config: createCliBackendConfig({ bundleMcp: true }),
});
expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledTimes(2);
expect(resolveMcpLoopbackScopedTools).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
senderIsOwner: true,
currentMessageId: "owner-message",
sourceReplyDeliveryMode: undefined,
}),
);
expect(resolveMcpLoopbackScopedTools).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
senderIsOwner: false,
currentMessageId: "non-owner-message",
sourceReplyDeliveryMode: undefined,
}),
);
expect(second.promptToolNamesHash).not.toBe(first.promptToolNamesHash);
expect(second.reusableCliSession).toEqual({
mode: "reuse-with-drift",
sessionId: "cli-session",
drift: { reasons: ["prompt-tools"] },
});
});
it("prepares raw-tail history for safe invalidations only when the backend opts in", async () => {
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior no-compaction ask",
timestamp: 1,
},
});
const context = await fixture.prepare({
extraSystemPrompt: "changed stable prompt",
extraSystemPromptStatic: "changed stable prompt",
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText("old stable prompt"),
},
config: createCliBackendConfig({
reseedFromRawTranscriptWhenUncompacted: true,
}),
});
expect(context.reusableCliSession).toEqual({
mode: "reuse-with-drift",
sessionId: "cli-session",
drift: { reasons: ["system-prompt"] },
});
expect(context.openClawHistoryPrompt).toContain("prior no-compaction ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
});
it("prepares opted-in raw-tail history for session-expired retry without disabling native resume", async () => {
const { dir } = fixture.session;
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior resumable ask",
timestamp: 1,
},
});
const context = await fixture.prepare({
cliSessionBinding: {
sessionId: "cli-session",
cwdHash: hashCliSessionText(dir),
},
config: createCliBackendConfig({
reseedFromRawTranscriptWhenUncompacted: true,
}),
});
expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" });
expect(context.openClawHistoryPrompt).toContain("prior resumable ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
});
it("applies direct-run prepend system context helpers on the CLI path", async () => {
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",
})),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await fixture.prepare({
sessionKey: "agent:main:test",
trigger: "user",
});
expect(context.systemPrompt).toBe(
`${wrappedPluginSystemContext("hook prepend system")}\n\nhook system${SYSTEM_PROMPT_CACHE_BOUNDARY}active image task\n\nactive video task\n\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`,
);
expect(mockBuildActiveImageGenerationTaskPromptContextForSession).toHaveBeenCalledWith(
"agent:main:test",
);
expect(mockBuildActiveVideoGenerationTaskPromptContextForSession).toHaveBeenCalledWith(
"agent:main:test",
);
});
it("skips bundle MCP preparation when tools are disabled", async () => {
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 fixture.prepare({
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"]);
});
it("uses loopback-scoped tools when building bundled MCP CLI prompts", async () => {
registerTestMemoryPromptBuilder(({ 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 activateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant);
const revokeMcpLoopbackClientGrant = vi.fn(() => true);
const resolveMcpLoopbackScopedTools = vi.fn((_scope: Record<string, unknown>) => ({
agentId: "main",
tools: [
{
name: "memory_search",
label: "Memory Search",
description: "Search memory",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
],
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
activateMcpLoopbackClientGrantCapture,
deactivateMcpLoopbackClientGrantCapture,
mintMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools,
});
setRawCliBackendForPrepareTest({
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 baselineContext = await fixture.prepare({
sessionKey: "main",
agentId: "worker",
provider: "native-cli",
config: createCliBackendConfig({ bundleMcp: true }),
});
const context = await fixture.prepare({
sessionKey: "main",
agentId: "worker",
provider: "native-cli",
runId: "run-test-loopback-prompt-tools",
config: createCliBackendConfig({ bundleMcp: true }),
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:worker:discord:group:ops",
ownerAccountId: "default",
},
cliSessionBinding: {
sessionId: "cli-session",
promptToolNamesHash: "old-tool-surface",
...(baselineContext.preparedBackend.mcpConfigHash
? { mcpConfigHash: baselineContext.preparedBackend.mcpConfigHash }
: {}),
...(baselineContext.preparedBackend.mcpResumeHash
? { mcpResumeHash: baselineContext.preparedBackend.mcpResumeHash }
: {}),
},
});
const projected = resolveMcpLoopbackScopedTools.mock.calls.at(-1)?.[0];
const grantContext = mintMcpLoopbackClientGrant.mock.calls.at(-1)?.[0]?.context;
expect(projected).toBeDefined();
expect(grantContext).toBeDefined();
const { cfg: projectedConfig, ...projectedContext } = projected ?? {};
expect(projectedConfig).toEqual(expect.any(Object));
expect(projectedContext).toEqual(grantContext);
expect(projectedContext).toMatchObject({
sessionKey: "agent:worker:main",
sessionId: expect.any(String),
runId: "run-test-loopback-prompt-tools",
workspaceDir: expect.any(String),
modelProvider: "native-cli",
modelId: "test-model",
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:worker:discord:group:ops",
ownerAccountId: "default",
},
});
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({
mode: "reuse-with-drift",
sessionId: "cli-session",
drift: { reasons: ["prompt-tools"] },
});
});
it("fails bundled MCP preparation when the loopback runtime is unavailable", async () => {
registerTestMemoryPromptBuilder(({ 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,
});
setRawCliBackendForPrepareTest({
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",
},
});
await expect(
fixture.prepare({
sessionKey: "agent:main:test",
provider: "native-cli",
config: createCliBackendConfig({ bundleMcp: true }),
}),
).rejects.toThrow(/loopback unavailable/);
expect(ensureMcpLoopbackServer).toHaveBeenCalledTimes(1);
expect(getActiveMcpLoopbackRuntime).toHaveBeenCalledTimes(1);
expect(createMcpLoopbackServerConfig).not.toHaveBeenCalled();
expect(resolveMcpLoopbackScopedTools).not.toHaveBeenCalled();
});
it("binds current turn context into the bundle MCP client grant", async () => {
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 activateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant);
const revokeMcpLoopbackClientGrant = vi.fn(() => true);
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
{
name: "message",
label: "Message",
description: "Send a message",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
],
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
activateMcpLoopbackClientGrantCapture,
deactivateMcpLoopbackClientGrantCapture,
mintMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools,
});
setRawCliBackendForPrepareTest({
id: "native-cli",
pluginId: "native-plugin",
bundleMcp: true,
bundleMcpMode: "codex-config-overrides",
config: {
command: "native-cli",
args: ["--print"],
input: "arg",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
sessionKey: "agent:main:telegram:group:chat123",
runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender",
agentId: "worker",
provider: "native-cli",
modelProvider: "anthropic",
runId: "run-test-room-event-tools",
sessionEntry: {
execHost: "node",
execSecurity: "allowlist",
execAsk: "on-miss",
execNode: "mac-a",
} as never,
execOverrides: {
host: "node",
security: "allowlist",
ask: "always",
node: "mac-b",
},
bashElevated: {
enabled: true,
allowed: true,
defaultLevel: "full",
fullAccessAvailable: false,
fullAccessBlockedReason: "runtime",
},
trigger: "user",
currentInboundEventKind: "room_event",
messageChannel: "telegram",
messageProvider: "discord",
clientCaps: ["tool-events", "inline-widgets"],
currentChannelId: "telegram:-100123:topic:42",
currentThreadTs: "42",
currentMessageId: "reply-message-1",
currentInboundAudio: true,
sourceReplyDeliveryMode: "message_tool_only",
taskSuggestionDeliveryMode: "gateway",
requireExplicitMessageTarget: true,
approvalReviewerDeviceId: "reviewer-device",
senderId: "canonical-sender",
senderName: "Canonical Name",
senderUsername: "canonical-user",
senderE164: "+15551234567",
groupId: "chat123",
groupChannel: "ops",
groupSpace: "workspace-a",
spawnedBy: "agent:main:telegram:group:parent",
channelContext: {
sender: { id: "sender-1", displayName: "not-forwarded" },
chat: { id: "chat-1", title: "not-forwarded" },
},
});
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_TOKEN: "loopback-token",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
});
expect(mintMcpLoopbackClientGrant).toHaveBeenCalledWith({
context: {
sessionKey: "agent:main:telegram:group:chat123",
runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender",
agentId: "worker",
sessionId: "session-test",
runId: "run-test-room-event-tools",
workspaceDir: context.workspaceDir,
modelProvider: "anthropic",
modelId: "test-model",
messageProvider: "discord",
clientCaps: ["tool-events", "inline-widgets"],
currentChannelId: "telegram:-100123:topic:42",
currentThreadTs: "42",
currentMessageId: "reply-message-1",
currentInboundAudio: true,
accountId: undefined,
inboundEventKind: "room_event",
sourceReplyDeliveryMode: "message_tool_only",
taskSuggestionDeliveryMode: "gateway",
requireExplicitMessageTarget: true,
senderIsOwner: false,
nodeExecAllowed: true,
execSession: {
execHost: "node",
execSecurity: "allowlist",
execAsk: "on-miss",
execNode: "mac-a",
},
execOverrides: {
host: "node",
security: "allowlist",
ask: "always",
node: "mac-b",
},
bashElevated: {
enabled: true,
allowed: true,
defaultLevel: "full",
fullAccessAvailable: false,
fullAccessBlockedReason: "runtime",
},
trigger: "user",
approvalReviewerDeviceId: "reviewer-device",
channelContext: {
sender: { id: "canonical-sender" },
chat: { id: "chat-1" },
},
senderName: "Canonical Name",
senderUsername: "canonical-user",
senderE164: "+15551234567",
groupId: "chat123",
groupChannel: "ops",
groupSpace: "workspace-a",
spawnedBy: "agent:main:telegram:group:parent",
},
runtimeOwnerToken: "loopback-owner-token",
});
context.preparedBackend.mcpClientGrantCapture?.activate("capture-test");
context.preparedBackend.mcpClientGrantCapture?.deactivate("capture-test");
expect(activateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({
token: "loopback-token",
runtimeOwnerToken: "loopback-owner-token",
captureKey: "capture-test",
});
expect(deactivateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({
token: "loopback-token",
runtimeOwnerToken: "loopback-owner-token",
captureKey: "capture-test",
});
expect(context.mcpDeliveryCapture).toBe(true);
expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith(
expect.objectContaining({
clientCaps: ["tool-events", "inline-widgets"],
taskSuggestionDeliveryMode: "gateway",
requireExplicitMessageTarget: true,
senderIsOwner: false,
runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender",
agentId: "worker",
modelProvider: "anthropic",
modelId: "test-model",
execOverrides: {
host: "node",
security: "allowlist",
ask: "always",
node: "mac-b",
},
bashElevated: {
enabled: true,
allowed: true,
defaultLevel: "full",
fullAccessAvailable: false,
fullAccessBlockedReason: "runtime",
},
channelContext: {
sender: { id: "canonical-sender" },
chat: { id: "chat-1" },
},
senderName: "Canonical Name",
senderUsername: "canonical-user",
senderE164: "+15551234567",
messageProvider: "discord",
groupId: "chat123",
groupChannel: "ops",
groupSpace: "workspace-a",
spawnedBy: "agent:main:telegram:group:parent",
}),
);
expect(context.systemPrompt).toContain(
"`send`: `target` + `message`; target required this turn",
);
expect(context.systemPrompt).not.toContain("current source is default target");
await context.preparedBackend.cleanup?.();
expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token");
});
it("enables gateway delivery capture for Claude-style JSONL bundle MCP", async () => {
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime: vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
})),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
});
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
jsonlDialect: "claude-stream-json",
input: "stdin",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
provider: "claude-cli",
});
expect(context.mcpDeliveryCapture).toBe(true);
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
});
});
it("fails closed with upgrade guidance when a backend cannot enforce a runtime toolsAllow", async () => {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
});
const run = fixture.prepare({
config: createCliBackendConfig({ bundleMcp: true }),
toolsAllow: ["read", "web_search"],
});
await expect(run).rejects.toThrow(
`CLI backend "test-cli" cannot enforce this run's tool cap. Upgrade its plugin and retry; if current, ask its maintainer to add exact-cap support. OpenClaw did not start the run.`,
);
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
});
it("materializes runtime toolsAllow for selectable backends without bundle MCP", async () => {
const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [
...context.baseArgs,
]);
const resolveMcpLoopbackPolicyTools = vi.fn((_scope: Record<string, unknown>) => ({
agentId: "main",
tools: ["write", "apply_patch"].map((name) => ({ name })),
}));
setRawCliBackendForPrepareTest({
id: "selectable-cli",
pluginId: "selectable-plugin",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "selectable-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({ resolveMcpLoopbackPolicyTools });
const context = await fixture.prepare({
provider: "selectable-cli",
toolsAllow: ["write"],
});
expect(context.params.cliToolAvailability).toEqual({
native: [],
openClaw: ["write", "apply_patch"],
});
expect(resolveMcpLoopbackPolicyTools).toHaveBeenCalledWith(
expect.objectContaining({ toolsAllow: ["write"] }),
);
});
it("translates disableTools into an exact empty cap for selectable backends", async () => {
const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [
...context.baseArgs,
]);
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime });
setRawCliBackendForPrepareTest({
id: "selectable-cli",
pluginId: "selectable-plugin",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "selectable-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
provider: "selectable-cli",
disableTools: true,
});
expect(context.params.cliToolAvailability).toEqual({ native: [], openClaw: [] });
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
});
it("lets disableTools override a selectable backend toolsAllow projection", async () => {
const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [
...context.baseArgs,
]);
setRawCliBackendForPrepareTest({
id: "selectable-cli",
pluginId: "selectable-plugin",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "selectable-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
provider: "selectable-cli",
disableTools: true,
toolsAllow: ["write"],
});
expect(context.params.cliToolAvailability).toEqual({ native: [], openClaw: [] });
});
it("requires prepared-execution backends to enforce the derived disabled-tools cap", async () => {
const cleanup = vi.fn(async () => {});
const prepareExecution = vi.fn(async () => ({ cleanup }));
setRawCliBackendForPrepareTest({
id: "settings-cli",
pluginId: "settings-plugin",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "settings-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
await expect(
fixture.prepare({
provider: "settings-cli",
disableTools: true,
}),
).rejects.toThrow(
"did not enforce exact per-run tool availability during execution preparation",
);
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ toolAvailability: { native: [], openClaw: [], mcp: [] } }),
);
expect(cleanup).toHaveBeenCalledOnce();
});
it("still rejects disableTools when a selectable backend cannot enforce an exact cap", async () => {
setRawCliBackendForPrepareTest({
id: "selectable-cli",
pluginId: "selectable-plugin",
nativeToolMode: "selectable",
config: {
command: "selectable-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
await expect(
fixture.prepare({
provider: "selectable-cli",
disableTools: true,
}),
).rejects.toThrow(
"CLI backend selectable-cli cannot run with tools disabled because it exposes native tools",
);
});
it("accepts a positive prepared-execution enforcement acknowledgement", async () => {
const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const }));
setRawCliBackendForPrepareTest({
id: "settings-cli",
pluginId: "settings-plugin",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "settings-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
provider: "settings-cli",
cliToolAvailability: { native: [], openClaw: [] },
});
expect(context.params.cliToolAvailability).toEqual({ native: [], openClaw: [] });
await context.preparedBackend.cleanup?.();
});
it("privately forwards isolated-completion system prompts to bundled preparation", async () => {
const { dir } = fixture.session;
const prepareExecution = vi.fn(async () => ({
isolatedCompletionEnforced: true as const,
toolAvailabilityEnforced: true as const,
}));
setRawCliBackendForPrepareTest({
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "jsonl",
input: "arg",
sessionMode: "existing",
},
});
await fixture.prepare({
provider: "google-gemini-cli",
executionMode: "side-question",
isolatedCompletion: true,
extraSystemPrompt: "Return only valid JSON.",
cliToolAvailability: { native: [], openClaw: [] },
});
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
isolatedCompletionCwd: dir,
isolatedCompletionModelId: "test-model",
isolatedCompletionPrompt: "latest ask",
isolatedCompletionSystemPrompt: "Return only valid JSON.",
}),
);
});
it("rejects a CLI backend that does not adopt isolated completion", async () => {
const cleanup = vi.fn(async () => {});
const prepareExecution = vi.fn(async () => ({
cleanup,
toolAvailabilityEnforced: true as const,
}));
setRawCliBackendForPrepareTest({
id: "external-cli",
pluginId: "external",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "external-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "none",
},
});
await expect(
fixture.prepare({
provider: "external-cli",
executionMode: "side-question",
isolatedCompletion: true,
cliToolAvailability: { native: [], openClaw: [] },
}),
).rejects.toMatchObject({
code: "unsupported",
message:
'CLI backend "external-cli" does not support isolated completion; OpenClaw did not start the run.',
});
expect(cleanup).toHaveBeenCalledOnce();
});
it("projects node-placed Claude availability before prepared-execution enforcement", async () => {
const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const }));
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
provider: "claude-cli",
sessionEntry: { execHost: "node", execNode: "node-a" } as never,
cliToolAvailability: { native: ["Read"], openClaw: ["message"] },
});
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
toolAvailability: { native: ["Read"], openClaw: [], mcp: [] },
}),
);
expect(context.params.cliToolAvailability).toEqual({ native: ["Read"], openClaw: [] });
await context.preparedBackend.cleanup?.();
});
it("finalizes prompt guidance after backend message-tool projection", async () => {
const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const }));
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
const finalizePromptForResolvedTools = vi.fn(
({ prompt, messageToolAvailable }: { prompt: string; messageToolAvailable: boolean }) =>
`${prompt}\nmessage-tool-available:${messageToolAvailable}`,
);
const context = await fixture.prepare({
provider: "claude-cli",
cliToolAvailability: { native: ["Read"], openClaw: ["message"] },
finalizePromptForResolvedTools,
});
expect(finalizePromptForResolvedTools).toHaveBeenCalledWith({
prompt: "latest ask",
messageToolAvailable: false,
});
expect(context.params.prompt).toContain("message-tool-available:false");
expect(context.params.transcriptPrompt).toBe("latest ask");
await context.preparedBackend.cleanup?.();
});
it.each([
{
name: "materializes exact availability when the caller did not provide it",
cliToolAvailability: undefined,
hookToolsAllow: ["read"],
projectedToolNames: ["read", "message"],
},
{
name: "keeps existing CLI availability as the upper bound",
cliToolAvailability: { native: [], openClaw: ["read", "message"] },
hookToolsAllow: ["read", "write"],
projectedToolNames: ["read", "message", "write"],
},
])(
"applies before_prompt_build tool filtering before CLI guidance and submission: $name",
async ({ cliToolAvailability, hookToolsAllow, projectedToolNames }) => {
const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const }));
const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant);
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({ toolsAllow: hookToolsAllow })),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime: vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
})),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools: vi.fn(() => ({
agentId: "main",
tools: projectedToolNames.map((name) => ({ name })),
})),
});
const finalizePromptForResolvedTools = vi.fn(
({ prompt, messageToolAvailable }: { prompt: string; messageToolAvailable: boolean }) =>
`${prompt}\nmessage-tool-available:${messageToolAvailable}`,
);
const context = await fixture.prepare({
provider: "claude-cli",
...(cliToolAvailability ? { cliToolAvailability } : {}),
finalizePromptForResolvedTools,
});
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1);
expect(hookRunner.runBeforePromptBuild.mock.invocationCallOrder[0]).toBeLessThan(
prepareExecution.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
expect(context.params.cliToolAvailability).toEqual({
native: [],
openClaw: ["read"],
});
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
toolAvailability: { native: [], openClaw: ["read"], mcp: ["mcp__openclaw__read"] },
}),
);
expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.toolsAllow).toEqual(["read"]);
expect(context.systemPromptReport.tools.entries.map((entry) => entry.name)).toEqual(["read"]);
expect(finalizePromptForResolvedTools).toHaveBeenCalledWith({
prompt: "latest ask",
messageToolAvailable: false,
});
expect(context.params.prompt).toContain("message-tool-available:false");
expect(context.params.transcriptPrompt).toBe("latest ask");
await context.preparedBackend.cleanup?.();
},
);
it("fails closed when a prompt hook restricts an always-on CLI backend", async () => {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({ toolsAllow: ["read"] })),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
setRawCliBackendForPrepareTest({
id: "test-cli",
pluginId: "test-plugin",
bundleMcp: false,
nativeToolMode: "always-on",
config: {
command: "test-cli",
args: ["--print"],
output: "text",
input: "arg",
sessionMode: "existing",
},
});
await expect(fixture.prepare({ provider: "test-cli" })).rejects.toThrow(
'CLI backend "test-cli" cannot enforce before_prompt_build tool restrictions',
);
});
it("keeps runtime toolsAllow canonical and bounds the backend-independent MCP grant", async () => {
const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [
...context.baseArgs,
]);
const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant);
const resolveMcpLoopbackPolicyTools = vi.fn((_scope: Record<string, unknown>) => ({
agentId: "main",
tools: ["write", "apply_patch"].map((name) => ({ name })),
}));
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
jsonlDialect: "claude-stream-json",
input: "stdin",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime: vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
})),
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant,
resolveMcpLoopbackPolicyTools,
});
let cleanup: (() => Promise<void>) | undefined;
try {
const context = await fixture.prepare({
sessionKey: "agent:main:main",
provider: "claude-cli",
toolsAllow: ["write"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
});
cleanup = context.preparedBackend.cleanup;
expect(context.params.toolsAllow).toBeUndefined();
expect(context.params.cliToolAvailability).toEqual({
native: [],
openClaw: ["write", "apply_patch"],
});
expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.toolsAllow).toEqual([
"write",
"apply_patch",
]);
expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.scheduledToolPolicy).toEqual({
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
});
expect(resolveMcpLoopbackPolicyTools).toHaveBeenCalledWith(
expect.objectContaining({
toolsAllow: ["write"],
scheduledToolPolicy: {
version: 1,
mode: "account",
ownerSessionKey: "agent:main:discord:group:ops",
ownerAccountId: "default",
},
}),
);
const projected = resolveMcpLoopbackPolicyTools.mock.calls[0]?.[0];
const grantContext = mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context;
const {
cfg: _projectedConfig,
toolsAllow: projectedPolicy,
...projectedTrustedContext
} = projected ?? {};
const { toolsAllow: grantedTools, ...grantTrustedContext } = grantContext ?? {};
expect(projectedPolicy).toEqual(["write"]);
expect(grantedTools).toEqual(["write", "apply_patch"]);
expect(projectedTrustedContext).toEqual(grantTrustedContext);
} finally {
await cleanup?.();
}
});
it("bounds the loopback grant to the selectable MCP tool allowlist", async () => {
const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [
...context.baseArgs,
]);
const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant);
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
jsonlDialect: "claude-stream-json",
input: "stdin",
sessionMode: "existing",
},
});
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime: vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
})),
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
mintMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
});
let cleanup: (() => Promise<void>) | undefined;
try {
const context = await fixture.prepare({
sessionKey: "agent:main:main",
provider: "claude-cli",
config: {
...createCliBackendConfig(),
mcp: {
servers: {
userProbe: { command: "node", args: ["user-probe.mjs"] },
},
},
},
cliToolAvailability: {
native: [],
openClaw: ["memory_search", "memory_get"],
},
});
cleanup = context.preparedBackend.cleanup;
// The grant carries exactly the canonical gateway tool names.
const grantContext = mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context;
expect(grantContext?.toolsAllow).toEqual(["memory_search", "memory_get"]);
// Restricted runs must not see user/plugin MCP servers: the generated
// bundle serves only the grant-scoped loopback server.
const args = context.preparedBackend.backend.args ?? [];
const mcpConfigPath = args[args.indexOf("--mcp-config") + 1];
const rawBundle = JSON.parse(fs.readFileSync(mcpConfigPath ?? "", "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
expect(Object.keys(rawBundle.mcpServers ?? {})).toEqual(["openclaw"]);
} finally {
await cleanup?.();
}
});
it("serves only the openclaw MCP server for ring-zero runs", async () => {
const { dir, sessionFile } = fixture.session;
const getActiveMcpLoopbackRuntime = vi.fn(() => undefined);
const resolveExecutionArgs = vi.fn(
(context: {
baseArgs: readonly string[];
toolAvailability?: { native: readonly string[]; openClaw: readonly string[] };
}) => [
...context.baseArgs,
"--tools",
context.toolAvailability?.native.join(",") ?? "default",
"--allowedTools",
context.toolAvailability?.openClaw.join(",") ?? "",
],
);
setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime });
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "claude",
args: ["--print"],
resumeArgs: ["--print", "--resume", "{sessionId}"],
output: "jsonl",
jsonlDialect: "claude-stream-json",
input: "stdin",
sessionMode: "existing",
},
});
const params: RunCliAgentParams & { systemAgentTool: SystemAgentToolOptions } = {
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-openclaw-mcp",
config: createCliBackendConfig(),
systemAgentTool: { surface: "cli" },
cliToolAvailability: {
native: [],
openClaw: ["openclaw"],
},
};
const context = await prepareCliRunContext(params);
// Ring-zero runs never touch the loopback surface (no message tools).
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
expect(context.mcpDeliveryCapture).toBeUndefined();
const args = context.preparedBackend.backend.args ?? [];
expect(args).toContain("--strict-mcp-config");
expect(args).not.toContain("--tools");
expect(args).not.toContain("--allowedTools");
expect(context.preparedBackend.backend.resumeArgs).toEqual(
expect.arrayContaining(["--strict-mcp-config"]),
);
expect(resolveExecutionArgs).not.toHaveBeenCalled();
expect(context.params.cliToolAvailability).toEqual({
native: [],
openClaw: ["openclaw"],
});
const mcpConfigPath = expectDefined(
args[args.indexOf("--mcp-config") + 1],
'args[args.indexOf("--mcp-config") + 1] test invariant',
);
const raw = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8")) as {
mcpServers?: Record<string, { env?: Record<string, string> }>;
};
expect(Object.keys(raw.mcpServers ?? {})).toEqual(["openclaw"]);
expect(raw.mcpServers?.openclaw?.env).toMatchObject({
OPENCLAW_TOOLS_MCP_TOOLS: "openclaw",
OPENCLAW_TOOLS_MCP_SYSTEM_AGENT_SURFACE: "cli",
});
await context.preparedBackend.cleanup?.();
});
it("fails closed for native tool-capable CLI backends when tools are disabled", async () => {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
});
setRawCliBackendForPrepareTest({
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(
fixture.prepare({
provider: "native-cli",
disableTools: true,
}),
).rejects.toThrow(
"CLI backend native-cli cannot run with tools disabled because it exposes native tools",
);
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
});
it.each([
{
name: "drops the claude-cli sessionId when the on-disk transcript is missing (#77011)",
sessionId: "stale-claude-sid",
hasContent: false,
hasOrphan: true,
withCwdHash: false,
checksTranscript: true,
checksOrphan: false,
expected: { mode: "invalidate", invalidatedReason: "missing-transcript" },
},
{
name: "invalidates orphaned claude-cli transcripts during run preparation",
sessionId: "orphaned-claude-sid",
hasContent: true,
hasOrphan: true,
withCwdHash: true,
checksTranscript: true,
checksOrphan: true,
expected: { mode: "invalidate", invalidatedReason: "orphaned-tool-use" },
},
{
name: "keeps auth-boundary invalidation ahead of orphaned transcript checks",
sessionId: "orphaned-claude-sid",
authProfileId: "anthropic:old-profile",
hasContent: true,
hasOrphan: true,
withCwdHash: true,
checksTranscript: false,
checksOrphan: false,
expected: { mode: "invalidate", invalidatedReason: "auth-profile" },
},
{
name: "keeps the claude-cli sessionId when the on-disk transcript is present",
sessionId: "live-claude-sid",
hasContent: true,
hasOrphan: false,
withCwdHash: true,
checksTranscript: true,
checksOrphan: true,
expected: { mode: "reuse", sessionId: "live-claude-sid" },
},
])("$name", async (testCase) => {
const { dir } = fixture.session;
setCliBackendForPrepareTest();
const transcriptCheck = vi.fn(async () => testCase.hasContent);
const orphanCheck = vi.fn(async () => testCase.hasOrphan);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
});
const cliSessionBinding = {
sessionId: testCase.sessionId,
...(testCase.withCwdHash ? { cwdHash: hashCliSessionText(dir) } : {}),
...(testCase.authProfileId ? { authProfileId: testCase.authProfileId } : {}),
};
const context = await fixture.prepare({
sessionKey: "agent:main:telegram:direct:peer",
prompt: "follow-up",
provider: "claude-cli",
model: "opus",
cliSessionBinding,
cliSessionId: testCase.sessionId,
});
const transcriptArgs = { sessionId: testCase.sessionId, workspaceDir: dir };
if (testCase.checksTranscript) {
expect(transcriptCheck).toHaveBeenCalledWith(transcriptArgs);
} else {
expect(transcriptCheck).not.toHaveBeenCalled();
}
if (testCase.checksOrphan) {
expect(orphanCheck).toHaveBeenCalledWith(transcriptArgs);
} else {
expect(orphanCheck).not.toHaveBeenCalled();
}
expect(context.reusableCliSession).toEqual(testCase.expected);
});
it("arms raw-transcript reseed for a missing claude-cli transcript so prior conversation is redelivered", async () => {
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior claude-cli ask",
timestamp: 1,
},
});
setCliBackendForPrepareTest({
reseedFromRawTranscriptWhenUncompacted: true,
});
const transcriptCheck = vi.fn(async () => false);
const orphanCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
});
const context = await fixture.prepare({
sessionKey: "agent:main:telegram:direct:peer",
provider: "claude-cli",
model: "opus",
cliSessionBinding: { sessionId: "stale-claude-sid" },
cliSessionId: "stale-claude-sid",
});
// Candidate is invalidated (no native --resume) yet reseed still fires:
// prepare hands the prior OpenClaw conversation forward as history.
expect(context.reusableCliSession).toEqual({
mode: "invalidate",
invalidatedReason: "missing-transcript",
});
expect(context.openClawHistoryPrompt).toContain("prior claude-cli ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
});
it("prepares node-placed Claude resumes without Gateway MCP, skills, or transcript checks", async () => {
fixture.appendTranscript({
id: "msg-node-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: "gateway-only history", timestamp: 1 },
});
const prepareExecution = vi.fn(async () => ({
env: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" },
secretInput: {
fd: 3,
fingerprint: "selected-node-token-fingerprint",
createData: () => Buffer.from("selected-node-token"),
},
clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
}));
setCliBackendForPrepareTest({
bundleMcp: true,
liveSession: true,
prepareExecution,
reseedFromRawTranscriptWhenUncompacted: true,
});
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const prepareClaudeCliSkillsPlugin = vi.fn(async () => ({
args: ["--plugin-dir", "/tmp/gateway-skills"],
cleanup: vi.fn(async () => undefined),
}));
const transcriptCheck = vi.fn(async () => false);
const orphanCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
ensureMcpLoopbackServer,
prepareClaudeCliSkillsPlugin,
claudeCliSessionTranscriptHasContent: transcriptCheck,
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
});
await expect(
fixture.prepare({
provider: "claude-cli",
model: "opus",
sessionEntry: { execHost: "node" } as never,
}),
).rejects.toThrow("node-placed Claude CLI session is missing execNode");
expect(ensureMcpLoopbackServer).not.toHaveBeenCalled();
const context = await fixture.prepare({
sessionKey: "agent:main:catalog-adopt:claude:node",
provider: "claude-cli",
model: "opus",
cliSessionBinding: {
sessionId: "node-source-session",
forceReuse: true,
forkNextResume: true,
},
cliSessionId: "node-source-session",
sessionEntry: {
execHost: "node",
execNode: "node-a",
execCwd: "/work/on-node",
} as never,
skillsSnapshot: {
prompt: "GATEWAY_ONLY_SKILL_PATH=/tmp/gateway-skill/SKILL.md",
skills: [],
resolvedSkills: [],
},
});
expect(context.reusableCliSession).toEqual({
mode: "reuse",
sessionId: "node-source-session",
});
// The reseed prompt is gateway-built text, so node placement keeps the
// backend's raw-transcript reseed semantics for fresh-retry paths.
expect(context.openClawHistoryPrompt).toContain("gateway-only history");
expect(context.claudeSkillsPluginArgs).toEqual([]);
expect(context.systemPrompt).not.toContain("GATEWAY_ONLY_SKILL_PATH");
expect(context.mcpDeliveryCapture).toBeUndefined();
expect(ensureMcpLoopbackServer).not.toHaveBeenCalled();
expect(prepareClaudeCliSkillsPlugin).not.toHaveBeenCalled();
expect(transcriptCheck).not.toHaveBeenCalled();
expect(orphanCheck).not.toHaveBeenCalled();
expect(prepareExecution).toHaveBeenCalledOnce();
expect(context.preparedBackend.env).toMatchObject({
CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3",
});
expect(context.preparedBackend.secretInput?.fingerprint).toBe(
"selected-node-token-fingerprint",
);
expect(context.preparedBackend.backend.clearEnv).toEqual(
expect.arrayContaining(["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]),
);
});
it("keeps a warm claude-cli binding when its managed stdio child is still live", async () => {
const { dir } = fixture.session;
fixture.appendTranscript({
id: "msg-warm-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "earlier warm context",
timestamp: 1,
},
});
setCliBackendForPrepareTest({
liveSession: true,
reseedFromRawTranscriptWhenUncompacted: true,
});
const transcriptCheck = vi.fn(async () => false);
const orphanCheck = vi.fn(async () => true);
const getLiveSessionGeneration = vi.fn(() => "warm-live-generation");
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
getClaudeLiveSessionGenerationForOwner: getLiveSessionGeneration,
});
const context = await fixture.prepare({
sessionKey: "agent:main:telegram:direct:peer",
prompt: "warm follow-up",
provider: "claude-cli",
model: "opus",
cliSessionBinding: { sessionId: "warm-claude-sid" },
cliSessionId: "warm-claude-sid",
});
expect(getLiveSessionGeneration).toHaveBeenCalledWith({
backendId: "claude-cli",
agentAccountId: undefined,
agentId: undefined,
authProfileId: undefined,
sessionId: "session-test",
sessionKey: "agent:main:telegram:direct:peer",
});
expect(transcriptCheck).toHaveBeenCalledWith({
sessionId: "warm-claude-sid",
workspaceDir: dir,
});
expect(orphanCheck).not.toHaveBeenCalled();
expect(context.reusableCliSession).toEqual({
mode: "reuse",
sessionId: "warm-claude-sid",
});
expect(context.requiredClaudeLiveSessionGeneration).toBe("warm-live-generation");
expect(context.openClawHistoryPrompt).toContain("earlier warm context");
expect(context.openClawHistoryPrompt).toContain("warm follow-up");
});
it("disables Claude live transport while preserving native transcript resume", async () => {
setCliBackendForPrepareTest({ liveSession: true });
const transcriptCheck = vi.fn(async () => true);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
claudeCliSessionTranscriptHasOrphanedToolUse: vi.fn(async () => false),
});
const context = await fixture.prepare({
sessionKey: "agent:openclaw:main",
prompt: "approve the proposal",
provider: "claude-cli",
model: "opus",
cliSessionBinding: { sessionId: "native-claude-sid" },
disableCliLiveSession: true,
});
expect(context.preparedBackend.backend.liveSession).toBeUndefined();
expect(context.preparedBackend.backend.sessionMode).toBe("existing");
expect(context.reusableCliSession).toEqual({
mode: "reuse",
sessionId: "native-claude-sid",
});
});
it("ignores stored CLI session candidates when the backend disables sessions", async () => {
setCliBackendForPrepareTest({
sessionMode: "none",
reseedFromRawTranscriptWhenUncompacted: true,
});
const transcriptCheck = vi.fn(async () => false);
const orphanCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
});
const context = await fixture.prepare({
sessionKey: "agent:main:telegram:direct:peer",
prompt: "stateless ask",
provider: "claude-cli",
model: "opus",
cliSessionBinding: { sessionId: "stale-claude-sid" },
cliSessionId: "stale-claude-sid",
});
expect(context.reusableCliSession).toEqual({ mode: "none" });
expect(transcriptCheck).not.toHaveBeenCalled();
expect(orphanCheck).not.toHaveBeenCalled();
});
it("checks claude-cli transcript content under the resolved cwd", async () => {
const { dir } = fixture.session;
const taskDir = path.join(dir, "task");
fs.mkdirSync(taskDir, { recursive: true });
setRawCliBackendForPrepareTest({
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 fixture.prepare({
sessionKey: "agent:main:telegram:direct:peer",
cwd: taskDir,
prompt: "follow-up",
provider: "claude-cli",
model: "opus",
cliSessionBinding: { sessionId: "live-claude-sid", cwdHash: hashCliSessionText(taskDir) },
cliSessionId: "live-claude-sid",
});
expect(transcriptCheck).toHaveBeenCalledWith({
sessionId: "live-claude-sid",
workspaceDir: taskDir,
});
expect(context.reusableCliSession).toEqual({
mode: "reuse",
sessionId: "live-claude-sid",
});
});
it("renders CLI skills from sandbox-readable paths instead of persisted host snapshots", async () => {
const { dir } = fixture.session;
const hostSkillDir = "/home/tzdai/.npm-global/lib/node_modules/openclaw/skills/gog";
const hostSkillPath = `${hostSkillDir}/SKILL.md`;
const materializedWorkspace = path.join(dir, "state", "sandbox-skills");
const materializedSkillDir = path.join(materializedWorkspace, "skills", "gog");
const materializedSkillPath = path.join(materializedSkillDir, "SKILL.md");
fs.mkdirSync(materializedSkillDir, { recursive: true });
fs.writeFileSync(
materializedSkillPath,
[
"---",
"name: gog",
"description: Read Gmail safely.",
"---",
"",
"Use the Gmail tools before answering mail questions.",
].join("\n"),
"utf-8",
);
ensureSandboxWorkspaceForSessionMock.mockResolvedValue({
workspaceDir: dir,
containerWorkdir: "/workspace",
skillsWorkspaceDir: materializedWorkspace,
workspaceAccess: "rw",
});
const context = await fixture.prepare({
sessionKey: "agent:main:sandboxed-user",
agentId: "main",
prompt: "are there any unread emails",
skillsSnapshot: {
prompt: [
"<available_skills>",
" <skill>",
" <name>gog</name>",
" <description>Read Gmail safely.</description>",
` <location>${hostSkillPath}</location>`,
" </skill>",
"</available_skills>",
].join("\n"),
skills: [{ name: "gog" }],
resolvedSkills: [
{
name: "gog",
description: "Read Gmail safely.",
filePath: hostSkillPath,
baseDir: hostSkillDir,
source: "openclaw-bundled",
sourceInfo: {
path: hostSkillPath,
source: "openclaw-bundled",
scope: "project",
origin: "top-level",
baseDir: hostSkillDir,
},
disableModelInvocation: false,
},
],
},
});
expect(ensureSandboxWorkspaceForSessionMock).toHaveBeenCalledWith({
config: createCliBackendConfig(),
sessionKey: "agent:main:sandboxed-user",
workspaceDir: dir,
});
expect(context.systemPrompt).toContain(
"/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md",
);
expect(context.systemPrompt).not.toContain(hostSkillPath);
expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0);
expect(context.systemPromptReport.skills.entries).toEqual([
{ name: "gog", blockChars: expect.any(Number) },
]);
});
it.each([
{
name: "omits prompt skills when the native skills plugin can carry them",
materialized: true,
pluginResult: "args",
expectsPromptSkills: false,
},
{
name: "keeps prompt skills when the snapshot has no materialized plugin skills",
materialized: false,
pluginResult: "default",
expectsPromptSkills: true,
},
{
name: "keeps prompt skills when plugin materialization produces no args",
materialized: true,
pluginResult: "empty",
expectsPromptSkills: true,
},
])("handles Claude CLI skills: $name", async (testCase) => {
const { dir } = fixture.session;
const skill = createWeatherSkillFixture(dir, testCase.materialized);
setCliBackendForPrepareTest({ id: "claude-cli", pluginId: "anthropic" });
if (testCase.pluginResult !== "default") {
const pluginDir = path.join(dir, "openclaw-skills");
setCliRunnerPrepareTestDeps({
prepareClaudeCliSkillsPlugin: vi.fn(async () => ({
args: testCase.pluginResult === "args" ? ["--plugin-dir", pluginDir] : [],
cleanup: vi.fn(async () => undefined),
...(testCase.pluginResult === "args" ? { pluginDir } : {}),
})),
});
}
const context = await fixture.prepare({
provider: "claude-cli",
model: "opus",
skillsSnapshot: skill.snapshot,
});
if (testCase.expectsPromptSkills) {
expect(context.systemPrompt).toContain("<available_skills>");
expect(context.systemPrompt).toContain("<name>weather</name>");
expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0);
expect(context.claudeSkillsPluginArgs).toEqual([]);
} else {
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"),
]);
}
});
it("does not probe the transcript for non-claude-cli providers", async () => {
const { dir } = fixture.session;
const transcriptCheck = vi.fn(async () => false);
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: transcriptCheck,
});
const context = await fixture.prepare({
cliSessionBinding: { sessionId: "test-cli-sid", cwdHash: hashCliSessionText(dir) },
});
expect(transcriptCheck).not.toHaveBeenCalled();
expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "test-cli-sid" });
});
it.each([
{
name: "uses a larger automatic reseed history cap for Claude CLI",
provider: "claude-cli",
model: "claude-haiku-3-5",
marker: "RESEED_SUMMARY_MARKER_KEEP",
padding: 40_000,
expectsTruncation: false,
},
{
name: "uses the plan-safe Claude CLI cap before mapping canonical models to CLI aliases",
provider: "claude-cli",
model: "claude-opus-4-8",
modelAliases: { "claude-opus-4-8": "opus" },
marker: "RESEED_ALIAS_SUMMARY_MARKER_KEEP",
padding: 40_000,
expectsTruncation: false,
},
{
name: "keeps the default reseed history cap for non-Claude CLI backends",
provider: "test-cli",
model: "test-model",
marker: "RESEED_SUMMARY_MARKER_DEFAULT",
padding: 20_000,
expectsTruncation: true,
},
])("$name", async (testCase) => {
const { sessionFile } = fixture.session;
if (testCase.provider === "claude-cli") {
setCliBackendForPrepareTest({ modelAliases: testCase.modelAliases });
}
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "compaction",
summary: `${testCase.marker} ${"x".repeat(testCase.padding)}`,
})}\n`,
"utf-8",
);
const context = await fixture.prepare({
provider: testCase.provider,
model: testCase.model,
});
expect(context.openClawHistoryPrompt).toBeDefined();
if (testCase.expectsTruncation) {
expect(context.openClawHistoryPrompt).toContain("OpenClaw reseed history truncated");
} else {
expect(context.openClawHistoryPrompt).toContain(testCase.marker);
expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated");
}
});
it("uses the automatic Claude CLI cap through the raw-tail reseed path", async () => {
const { dir } = fixture.session;
setRawCliBackendForPrepareTest({
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);
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: `EARLIEST_USER ${padding}`, timestamp: 1 },
});
fixture.appendTranscript({
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,
},
});
const context = await fixture.prepare({
provider: "claude-cli",
model: "claude-haiku-3-5",
cliSessionBinding: { sessionId: "cli-session", cwdHash: hashCliSessionText(dir) },
});
expect(context.reusableCliSession).toEqual({ mode: "reuse", 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");
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */