openclaw/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts

1092 lines
35 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type {
AuthStorage,
ExtensionContext,
ModelRegistry,
ToolDefinition,
} from "@mariozechner/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
AssembleResult,
BootstrapResult,
CompactResult,
ContextEngineInfo,
IngestBatchResult,
IngestResult,
} from "../../../context-engine/types.js";
import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js";
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js";
import type { WorkspaceBootstrapFile } from "../../workspace.js";
const hoisted = vi.hoisted(() => {
type BootstrapContext = {
bootstrapFiles: WorkspaceBootstrapFile[];
contextFiles: EmbeddedContextFile[];
};
const spawnSubagentDirectMock = vi.fn();
const createAgentSessionMock = vi.fn();
const sessionManagerOpenMock = vi.fn();
const resolveSandboxContextMock = vi.fn();
const subscribeEmbeddedPiSessionMock = vi.fn();
const acquireSessionWriteLockMock = vi.fn();
const resolveBootstrapContextForRunMock = vi.fn<() => Promise<BootstrapContext>>(async () => ({
bootstrapFiles: [],
contextFiles: [],
}));
const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined);
const initializeGlobalHookRunnerMock = vi.fn();
const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined);
const sessionLockReleaseMock = vi.fn(async () => {});
const flushPendingToolResultsAfterIdleMock = vi.fn(async () => {});
const setActiveEmbeddedRunMock = vi.fn();
const clearActiveEmbeddedRunMock = vi.fn();
const updateActiveEmbeddedRunSnapshotMock = vi.fn();
const releaseWsSessionMock = vi.fn();
const sessionManager = {
getLeafEntry: vi.fn(() => null),
branch: vi.fn(),
resetLeaf: vi.fn(),
buildSessionContext: vi.fn<() => { messages: AgentMessage[] }>(() => ({ messages: [] })),
appendCustomEntry: vi.fn(),
};
return {
spawnSubagentDirectMock,
createAgentSessionMock,
sessionManagerOpenMock,
resolveSandboxContextMock,
subscribeEmbeddedPiSessionMock,
acquireSessionWriteLockMock,
resolveBootstrapContextForRunMock,
getGlobalHookRunnerMock,
initializeGlobalHookRunnerMock,
runContextEngineMaintenanceMock,
sessionLockReleaseMock,
flushPendingToolResultsAfterIdleMock,
setActiveEmbeddedRunMock,
clearActiveEmbeddedRunMock,
updateActiveEmbeddedRunSnapshotMock,
releaseWsSessionMock,
sessionManager,
};
});
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
const actual = await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
return {
...actual,
createAgentSession: (...args: unknown[]) => hoisted.createAgentSessionMock(...args),
DefaultResourceLoader: class {
async reload() {}
},
SessionManager: {
open: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args),
} as unknown as typeof actual.SessionManager,
};
});
vi.mock("../../subagent-spawn.js", () => ({
SUBAGENT_SPAWN_MODES: ["run", "session"],
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
}));
vi.mock("../../sandbox.js", () => ({
resolveSandboxContext: (...args: unknown[]) => hoisted.resolveSandboxContextMock(...args),
}));
vi.mock("../../session-tool-result-guard-wrapper.js", () => ({
guardSessionManager: () => hoisted.sessionManager,
}));
vi.mock("../../pi-embedded-subscribe.js", () => ({
subscribeEmbeddedPiSession: (...args: unknown[]) =>
hoisted.subscribeEmbeddedPiSessionMock(...args),
}));
vi.mock("../../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: hoisted.getGlobalHookRunnerMock,
initializeGlobalHookRunner: hoisted.initializeGlobalHookRunnerMock,
}));
vi.mock("../../../infra/machine-name.js", () => ({
getMachineDisplayName: async () => "test-host",
}));
vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
ensureGlobalUndiciEnvProxyDispatcher: () => {},
ensureGlobalUndiciStreamTimeouts: () => {},
}));
vi.mock("../../bootstrap-files.js", () => ({
makeBootstrapWarn: () => () => {},
resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock,
}));
vi.mock("../../skills.js", () => ({
applySkillEnvOverrides: () => () => {},
applySkillEnvOverridesFromSnapshot: () => () => {},
resolveSkillsPromptForRun: () => "",
}));
vi.mock("../skills-runtime.js", () => ({
resolveEmbeddedRunSkillEntries: () => ({
shouldLoadSkillEntries: false,
skillEntries: undefined,
}),
}));
vi.mock("../context-engine-maintenance.js", () => ({
runContextEngineMaintenance: (params: unknown) => hoisted.runContextEngineMaintenanceMock(params),
}));
vi.mock("../../docs-path.js", () => ({
resolveOpenClawDocsPath: async () => undefined,
}));
vi.mock("../../pi-project-settings.js", () => ({
createPreparedEmbeddedPiSettingsManager: () => ({}),
}));
vi.mock("../../pi-settings.js", () => ({
applyPiAutoCompactionGuard: () => {},
}));
vi.mock("../extensions.js", () => ({
buildEmbeddedExtensionFactories: () => [],
}));
vi.mock("../google.js", () => ({
logToolSchemasForGoogle: () => {},
sanitizeSessionHistory: async ({ messages }: { messages: unknown[] }) => messages,
sanitizeToolsForGoogle: ({ tools }: { tools: unknown[] }) => tools,
}));
vi.mock("../../session-file-repair.js", () => ({
repairSessionFileIfNeeded: async () => {},
}));
vi.mock("../session-manager-cache.js", () => ({
prewarmSessionFile: async () => {},
trackSessionManagerAccess: () => {},
}));
vi.mock("../session-manager-init.js", () => ({
prepareSessionManagerForRun: async () => {},
}));
vi.mock("../../session-write-lock.js", () => ({
acquireSessionWriteLock: (...args: unknown[]) => hoisted.acquireSessionWriteLockMock(...args),
resolveSessionLockMaxHoldFromTimeout: () => 1,
}));
vi.mock("../tool-result-context-guard.js", () => ({
installToolResultContextGuard: () => () => {},
}));
vi.mock("../wait-for-idle-before-flush.js", () => ({
flushPendingToolResultsAfterIdle: hoisted.flushPendingToolResultsAfterIdleMock,
}));
vi.mock("../runs.js", () => ({
setActiveEmbeddedRun: (...args: unknown[]) => hoisted.setActiveEmbeddedRunMock(...args),
clearActiveEmbeddedRun: (...args: unknown[]) => hoisted.clearActiveEmbeddedRunMock(...args),
updateActiveEmbeddedRunSnapshot: (...args: unknown[]) =>
hoisted.updateActiveEmbeddedRunSnapshotMock(...args),
}));
vi.mock("./images.js", () => ({
detectAndLoadPromptImages: async () => ({ images: [] }),
}));
vi.mock("../../system-prompt-params.js", () => ({
buildSystemPromptParams: () => ({
runtimeInfo: {},
userTimezone: "UTC",
userTime: "00:00",
userTimeFormat: "24h",
}),
}));
vi.mock("../../system-prompt-report.js", () => ({
buildSystemPromptReport: () => undefined,
}));
vi.mock("../system-prompt.js", () => ({
applySystemPromptOverrideToSession: () => {},
buildEmbeddedSystemPrompt: () => "system prompt",
createSystemPromptOverride: (prompt: string) => () => prompt,
}));
vi.mock("../extra-params.js", () => ({
applyExtraParamsToAgent: () => {},
}));
vi.mock("../../openai-ws-stream.js", () => ({
createOpenAIWebSocketStreamFn: vi.fn(),
releaseWsSession: (...args: unknown[]) => hoisted.releaseWsSessionMock(...args),
}));
vi.mock("../../anthropic-payload-log.js", () => ({
createAnthropicPayloadLogger: () => undefined,
}));
vi.mock("../../cache-trace.js", () => ({
createCacheTrace: () => undefined,
}));
vi.mock("../../pi-tools.js", () => ({
createOpenClawCodingTools: () => [],
resolveToolLoopDetectionConfig: () => undefined,
}));
vi.mock("../../../image-generation/runtime.js", () => ({
generateImage: vi.fn(),
listRuntimeImageGenerationProviders: () => [],
}));
vi.mock("../../model-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../model-selection.js")>();
return {
...actual,
normalizeProviderId: (providerId?: string) => providerId?.trim().toLowerCase() ?? "",
resolveDefaultModelForAgent: () => ({ provider: "openai", model: "gpt-test" }),
};
});
const { runEmbeddedAttempt } = await import("./attempt.js");
type MutableSession = {
sessionId: string;
messages: unknown[];
isCompacting: boolean;
isStreaming: boolean;
agent: {
streamFn?: unknown;
replaceMessages: (messages: unknown[]) => void;
};
prompt: (prompt: string, options?: { images?: unknown[] }) => Promise<void>;
abort: () => Promise<void>;
dispose: () => void;
steer: (text: string) => Promise<void>;
};
function createSubscriptionMock() {
return {
assistantTexts: [] as string[],
toolMetas: [] as Array<{ toolName: string; meta?: string }>,
unsubscribe: () => {},
waitForCompactionRetry: async () => {},
getMessagingToolSentTexts: () => [] as string[],
getMessagingToolSentMediaUrls: () => [] as string[],
getMessagingToolSentTargets: () => [] as unknown[],
getSuccessfulCronAdds: () => 0,
didSendViaMessagingTool: () => false,
didSendDeterministicApprovalPrompt: () => false,
getLastToolError: () => undefined,
getUsageTotals: () => undefined,
getCompactionCount: () => 0,
isCompacting: () => false,
};
}
function resetEmbeddedAttemptHarness(
params: {
includeSpawnSubagent?: boolean;
subscribeImpl?: () => ReturnType<typeof createSubscriptionMock>;
sessionMessages?: AgentMessage[];
} = {},
) {
if (params.includeSpawnSubagent) {
hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({
status: "accepted",
childSessionKey: "agent:main:subagent:child",
runId: "run-child",
});
}
hoisted.createAgentSessionMock.mockReset();
hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
hoisted.resolveSandboxContextMock.mockReset();
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
release: hoisted.sessionLockReleaseMock,
});
hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({
bootstrapFiles: [],
contextFiles: [],
});
hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined);
hoisted.sessionLockReleaseMock.mockReset().mockResolvedValue(undefined);
hoisted.flushPendingToolResultsAfterIdleMock.mockReset().mockResolvedValue(undefined);
hoisted.setActiveEmbeddedRunMock.mockReset();
hoisted.clearActiveEmbeddedRunMock.mockReset();
hoisted.updateActiveEmbeddedRunSnapshotMock.mockReset();
hoisted.releaseWsSessionMock.mockReset();
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();
hoisted.sessionManager.buildSessionContext
.mockReset()
.mockReturnValue({ messages: params.sessionMessages ?? [] });
hoisted.sessionManager.appendCustomEntry.mockReset();
if (params.subscribeImpl) {
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(params.subscribeImpl);
}
}
async function cleanupTempPaths(tempPaths: string[]) {
while (tempPaths.length > 0) {
const target = tempPaths.pop();
if (target) {
await fs.rm(target, { recursive: true, force: true });
}
}
}
function createDefaultEmbeddedSession(params?: {
prompt?: (
session: MutableSession,
prompt: string,
options?: { images?: unknown[] },
) => Promise<void>;
}): MutableSession {
const session: MutableSession = {
sessionId: "embedded-session",
messages: [],
isCompacting: false,
isStreaming: false,
agent: {
replaceMessages: (messages: unknown[]) => {
session.messages = [...messages];
},
},
prompt: async (prompt, options) => {
if (params?.prompt) {
await params.prompt(session, prompt, options);
return;
}
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
abort: async () => {},
dispose: () => {},
steer: async () => {},
};
return session;
}
function createContextEngineBootstrapAndAssemble() {
return {
bootstrap: vi.fn(async (_params: { sessionKey?: string }) => ({ bootstrapped: true })),
assemble: vi.fn(
async ({ messages }: { messages: AgentMessage[]; sessionKey?: string; model?: string }) => ({
messages,
estimatedTokens: 1,
}),
),
};
}
function expectCalledWithSessionKey(mock: ReturnType<typeof vi.fn>, sessionKey: string) {
expect(mock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
}),
);
}
const testModel = {
api: "openai-completions",
provider: "openai",
compat: {},
contextWindow: 8192,
input: ["text"],
} as unknown as Model<Api>;
const cacheTtlEligibleModel = {
api: "anthropic",
provider: "anthropic",
compat: {},
contextWindow: 8192,
input: ["text"],
} as unknown as Model<Api>;
describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => {
const tempPaths: string[] = [];
beforeEach(() => {
resetEmbeddedAttemptHarness({
includeSpawnSubagent: true,
subscribeImpl: createSubscriptionMock,
});
});
afterEach(async () => {
await cleanupTempPaths(tempPaths);
});
it("passes the real workspace to sessions_spawn when workspaceAccess is ro", async () => {
const realWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-real-workspace-"));
const sandboxWorkspace = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-sandbox-workspace-"),
);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-dir-"));
tempPaths.push(realWorkspace, sandboxWorkspace, agentDir);
hoisted.resolveSandboxContextMock.mockResolvedValue(
createPiToolsSandboxContext({
workspaceDir: sandboxWorkspace,
agentWorkspaceDir: realWorkspace,
workspaceAccess: "ro",
fsBridge: createHostSandboxFsBridge(sandboxWorkspace),
tools: { allow: ["sessions_spawn"], deny: [] },
sessionKey: "agent:main:main",
}),
);
hoisted.createAgentSessionMock.mockImplementation(
async (params: { customTools: ToolDefinition[] }) => {
const session = createDefaultEmbeddedSession({
prompt: async () => {
const spawnTool = params.customTools.find((tool) => tool.name === "sessions_spawn");
expect(spawnTool).toBeDefined();
if (!spawnTool) {
throw new Error("missing sessions_spawn tool");
}
await spawnTool.execute(
"call-sessions-spawn",
{ task: "inspect workspace" },
undefined,
undefined,
{} as unknown as ExtensionContext,
);
},
});
return { session };
},
);
const result = await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:main",
sessionFile: path.join(realWorkspace, "session.jsonl"),
workspaceDir: realWorkspace,
agentDir,
config: {},
prompt: "spawn a child session",
timeoutMs: 10_000,
runId: "run-1",
provider: "openai",
modelId: "gpt-test",
model: testModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
});
expect(result.promptError).toBeNull();
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1);
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "inspect workspace",
}),
expect.objectContaining({
workspaceDir: realWorkspace,
}),
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
workspaceDir: sandboxWorkspace,
}),
);
});
});
describe("runEmbeddedAttempt cleanup", () => {
const tempPaths: string[] = [];
beforeEach(() => {
resetEmbeddedAttemptHarness({
subscribeImpl: createSubscriptionMock,
});
});
afterEach(async () => {
await cleanupTempPaths(tempPaths);
});
it("clears the active run and releases session resources when idle flush fails", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-agent-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
const disposeMock = vi.fn();
const flushError = new Error("flush failed");
hoisted.flushPendingToolResultsAfterIdleMock.mockRejectedValueOnce(flushError);
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: {
...createDefaultEmbeddedSession(),
dispose: disposeMock,
},
}));
await expect(
runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:test-cleanup",
sessionFile,
workspaceDir,
agentDir,
config: {},
prompt: "hello",
timeoutMs: 10_000,
runId: "run-cleanup-flush-failure",
provider: "openai",
modelId: "gpt-test",
model: testModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
}),
).rejects.toThrow(flushError);
expect(hoisted.flushPendingToolResultsAfterIdleMock).toHaveBeenCalledTimes(1);
expect(hoisted.setActiveEmbeddedRunMock).toHaveBeenCalledTimes(1);
expect(hoisted.clearActiveEmbeddedRunMock).toHaveBeenCalledWith(
"embedded-session",
expect.any(Object),
"agent:main:test-cleanup",
);
expect(hoisted.clearActiveEmbeddedRunMock.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.flushPendingToolResultsAfterIdleMock.mock.invocationCallOrder[0] ??
Number.POSITIVE_INFINITY,
);
expect(disposeMock).toHaveBeenCalledTimes(1);
expect(hoisted.releaseWsSessionMock).toHaveBeenCalledWith("embedded-session");
expect(hoisted.sessionLockReleaseMock).toHaveBeenCalledTimes(1);
});
it("skips active-run clear without masking the original error when subscribe fails before registration", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-agent-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
const disposeMock = vi.fn();
const subscribeError = new Error("subscribe failed");
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(() => {
throw subscribeError;
});
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: {
...createDefaultEmbeddedSession(),
dispose: disposeMock,
},
}));
await expect(
runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:test-subscribe-failure",
sessionFile,
workspaceDir,
agentDir,
config: {},
prompt: "hello",
timeoutMs: 10_000,
runId: "run-cleanup-subscribe-failure",
provider: "openai",
modelId: "gpt-test",
model: testModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
}),
).rejects.toThrow(subscribeError);
expect(hoisted.setActiveEmbeddedRunMock).not.toHaveBeenCalled();
expect(hoisted.clearActiveEmbeddedRunMock).not.toHaveBeenCalled();
expect(hoisted.flushPendingToolResultsAfterIdleMock).toHaveBeenCalledTimes(1);
expect(disposeMock).toHaveBeenCalledTimes(1);
expect(hoisted.releaseWsSessionMock).toHaveBeenCalledWith("embedded-session");
expect(hoisted.sessionLockReleaseMock).toHaveBeenCalledTimes(1);
});
it("clears the active run before waiting for the idle flush", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cleanup-agent-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: createDefaultEmbeddedSession(),
}));
const result = await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:test-cleanup-order",
sessionFile,
workspaceDir,
agentDir,
config: {},
prompt: "hello",
timeoutMs: 10_000,
runId: "run-cleanup-order",
provider: "openai",
modelId: "gpt-test",
model: testModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
});
expect(result.promptError).toBeNull();
expect(hoisted.clearActiveEmbeddedRunMock).toHaveBeenCalledTimes(1);
expect(hoisted.flushPendingToolResultsAfterIdleMock).toHaveBeenCalledTimes(1);
expect(hoisted.clearActiveEmbeddedRunMock.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.flushPendingToolResultsAfterIdleMock.mock.invocationCallOrder[0] ??
Number.POSITIVE_INFINITY,
);
});
});
describe("runEmbeddedAttempt bootstrap warning prompt assembly", () => {
const tempPaths: string[] = [];
beforeEach(() => {
resetEmbeddedAttemptHarness({
subscribeImpl: createSubscriptionMock,
});
});
afterEach(async () => {
await cleanupTempPaths(tempPaths);
});
it("keeps bootstrap warnings in the sent prompt after hook prepend context", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-warning-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-warning-agent-dir-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
hoisted.resolveBootstrapContextForRunMock.mockResolvedValue({
bootstrapFiles: [
{
name: "AGENTS.md",
path: path.join(workspaceDir, "AGENTS.md"),
content: "A".repeat(200),
missing: false,
},
],
contextFiles: [{ path: "AGENTS.md", content: "A".repeat(20) }],
});
hoisted.getGlobalHookRunnerMock.mockReturnValue({
hasHooks: (hookName: string) => hookName === "before_prompt_build",
runBeforePromptBuild: async () => ({ prependContext: "hook context" }),
});
let seenPrompt = "";
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: createDefaultEmbeddedSession({
prompt: async (session, prompt) => {
seenPrompt = prompt;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
}),
}));
const result = await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
agentDir,
config: {
agents: {
defaults: {
bootstrapMaxChars: 50,
bootstrapTotalMaxChars: 50,
},
},
},
prompt: "hello",
timeoutMs: 10_000,
runId: "run-warning",
provider: "openai",
modelId: "gpt-test",
model: testModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
});
expect(result.promptError).toBeNull();
expect(seenPrompt).toContain("hook context");
expect(seenPrompt).toContain("[Bootstrap truncation warning]");
expect(seenPrompt).toContain("- AGENTS.md: 200 raw -> 20 injected");
expect(seenPrompt).toContain("hello");
});
});
describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => {
const tempPaths: string[] = [];
beforeEach(() => {
resetEmbeddedAttemptHarness();
});
afterEach(async () => {
await cleanupTempPaths(tempPaths);
});
async function runAttemptWithCacheTtl(compactionCount: number) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cache-ttl-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cache-ttl-agent-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(() => ({
...createSubscriptionMock(),
getCompactionCount: () => compactionCount,
}));
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: createDefaultEmbeddedSession(),
}));
return await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:test-cache-ttl",
sessionFile,
workspaceDir,
agentDir,
config: {
agents: {
defaults: {
contextPruning: {
mode: "cache-ttl",
},
},
},
},
prompt: "hello",
timeoutMs: 10_000,
runId: `run-cache-ttl-${compactionCount}`,
provider: "anthropic",
modelId: "claude-sonnet-4-20250514",
model: cacheTtlEligibleModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
});
}
it("skips cache-ttl append when compaction completed during the attempt", async () => {
const result = await runAttemptWithCacheTtl(1);
expect(result.promptError).toBeNull();
expect(hoisted.sessionManager.appendCustomEntry).not.toHaveBeenCalledWith(
"openclaw.cache-ttl",
expect.anything(),
);
});
it("appends cache-ttl when no compaction completed during the attempt", async () => {
const result = await runAttemptWithCacheTtl(0);
expect(result.promptError).toBeNull();
expect(hoisted.sessionManager.appendCustomEntry).toHaveBeenCalledWith(
"openclaw.cache-ttl",
expect.objectContaining({
provider: "anthropic",
modelId: "claude-sonnet-4-20250514",
timestamp: expect.any(Number),
}),
);
});
});
describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
const tempPaths: string[] = [];
const sessionKey = "agent:main:discord:channel:test-ctx-engine";
beforeEach(() => {
hoisted.createAgentSessionMock.mockReset();
hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
hoisted.resolveSandboxContextMock.mockReset();
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(createSubscriptionMock);
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
release: async () => {},
});
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();
hoisted.sessionManager.appendCustomEntry.mockReset();
});
afterEach(async () => {
while (tempPaths.length > 0) {
const target = tempPaths.pop();
if (target) {
await fs.rm(target, { recursive: true, force: true });
}
}
});
// Build a minimal real attempt harness so lifecycle hooks run against
// the actual runner flow instead of a hand-written wrapper.
async function runAttemptWithContextEngine(contextEngine: {
bootstrap?: (params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
}) => Promise<BootstrapResult>;
assemble: (params: {
sessionId: string;
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
model?: string;
}) => Promise<AssembleResult>;
afterTurn?: (params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
messages: AgentMessage[];
prePromptMessageCount: number;
tokenBudget?: number;
runtimeContext?: Record<string, unknown>;
}) => Promise<void>;
ingestBatch?: (params: {
sessionId: string;
sessionKey?: string;
messages: AgentMessage[];
}) => Promise<IngestBatchResult>;
ingest?: (params: {
sessionId: string;
sessionKey?: string;
message: AgentMessage;
}) => Promise<IngestResult>;
compact?: (params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
tokenBudget?: number;
}) => Promise<CompactResult>;
info?: Partial<ContextEngineInfo>;
}) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-agent-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
const seedMessages: AgentMessage[] = [
{ role: "user", content: "seed", timestamp: 1 } as AgentMessage,
];
const infoId = contextEngine.info?.id ?? "test-context-engine";
const infoName = contextEngine.info?.name ?? "Test Context Engine";
const infoVersion = contextEngine.info?.version ?? "0.0.1";
hoisted.sessionManager.buildSessionContext
.mockReset()
.mockReturnValue({ messages: seedMessages });
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: createDefaultEmbeddedSession(),
}));
return await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey,
sessionFile,
workspaceDir,
agentDir,
config: {},
prompt: "hello",
timeoutMs: 10_000,
runId: "run-context-engine-forwarding",
provider: "openai",
modelId: "gpt-test",
model: testModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
contextTokenBudget: 2048,
contextEngine: {
...contextEngine,
ingest:
contextEngine.ingest ??
(async () => ({
ingested: true,
})),
compact:
contextEngine.compact ??
(async () => ({
ok: false,
compacted: false,
reason: "not used in this test",
})),
info: {
id: infoId,
name: infoName,
version: infoVersion,
},
},
});
}
it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {});
const result = await runAttemptWithContextEngine({
bootstrap,
assemble,
afterTurn,
});
expect(result.promptError).toBeNull();
expectCalledWithSessionKey(bootstrap, sessionKey);
expectCalledWithSessionKey(assemble, sessionKey);
expectCalledWithSessionKey(afterTurn, sessionKey);
});
it("forwards modelId to assemble", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const result = await runAttemptWithContextEngine({
bootstrap,
assemble,
});
expect(result.promptError).toBeNull();
expect(assemble).toHaveBeenCalledWith(
expect.objectContaining({
model: "gpt-test",
}),
);
});
it("forwards sessionKey to ingestBatch when afterTurn is absent", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const ingestBatch = vi.fn(
async (_params: { sessionKey?: string; messages: AgentMessage[] }) => ({ ingestedCount: 1 }),
);
const result = await runAttemptWithContextEngine({
bootstrap,
assemble,
ingestBatch,
});
expect(result.promptError).toBeNull();
expectCalledWithSessionKey(ingestBatch, sessionKey);
});
it("forwards sessionKey to per-message ingest when ingestBatch is absent", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const ingest = vi.fn(async (_params: { sessionKey?: string; message: AgentMessage }) => ({
ingested: true,
}));
const result = await runAttemptWithContextEngine({
bootstrap,
assemble,
ingest,
});
expect(result.promptError).toBeNull();
expect(ingest).toHaveBeenCalled();
expect(
ingest.mock.calls.every((call) => {
const params = call[0];
return params.sessionKey === sessionKey;
}),
).toBe(true);
});
it("skips maintenance when afterTurn fails", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const afterTurn = vi.fn(async () => {
throw new Error("afterTurn failed");
});
const result = await runAttemptWithContextEngine({
bootstrap,
assemble,
afterTurn,
});
expect(result.promptError).toBeNull();
expect(afterTurn).toHaveBeenCalled();
expect(hoisted.runContextEngineMaintenanceMock).not.toHaveBeenCalledWith(
expect.objectContaining({ reason: "turn" }),
);
});
it("runs startup maintenance for existing sessions even without bootstrap()", async () => {
const { assemble } = createContextEngineBootstrapAndAssemble();
const result = await runAttemptWithContextEngine({
assemble,
});
expect(result.promptError).toBeNull();
expect(hoisted.runContextEngineMaintenanceMock).toHaveBeenCalledWith(
expect.objectContaining({ reason: "bootstrap" }),
);
});
it("skips maintenance when ingestBatch fails", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const ingestBatch = vi.fn(async () => {
throw new Error("ingestBatch failed");
});
const result = await runAttemptWithContextEngine({
bootstrap,
assemble,
ingestBatch,
});
expect(result.promptError).toBeNull();
expect(ingestBatch).toHaveBeenCalled();
expect(hoisted.runContextEngineMaintenanceMock).not.toHaveBeenCalledWith(
expect.objectContaining({ reason: "turn" }),
);
});
});