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