diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts deleted file mode 100644 index b3d0260f0f8..00000000000 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts +++ /dev/null @@ -1,1132 +0,0 @@ -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>(async () => ({ - bootstrapFiles: [], - contextFiles: [], - })); - const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined); - const initializeGlobalHookRunnerMock = vi.fn(); - const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined); - 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, - sessionManager, - }; -}); - -vi.mock("@mariozechner/pi-coding-agent", () => { - class AuthStorage {} - class DefaultResourceLoader { - async reload() {} - } - class ModelRegistry {} - - return { - AuthStorage, - createAgentSession: (...args: unknown[]) => hoisted.createAgentSessionMock(...args), - DefaultResourceLoader, - ModelRegistry, - SessionManager: { - open: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args), - }, - }; -}); - -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: async () => {}, -})); - -vi.mock("../runs.js", () => ({ - setActiveEmbeddedRun: () => {}, - clearActiveEmbeddedRun: () => {}, - updateActiveEmbeddedRunSnapshot: () => {}, -})); - -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: () => {}, -})); - -vi.mock("../../anthropic-payload-log.js", () => ({ - createAnthropicPayloadLogger: () => undefined, -})); - -vi.mock("../../cache-trace.js", () => ({ - createCacheTrace: () => undefined, -})); - -vi.mock("../../pi-tools.js", () => ({ - createOpenClawCodingTools: (options?: { workspaceDir?: string; spawnWorkspaceDir?: string }) => [ - { - name: "sessions_spawn", - execute: async ( - _callId: string, - input: { task?: string }, - _session?: unknown, - _abortSignal?: unknown, - _ctx?: unknown, - ) => - await hoisted.spawnSubagentDirectMock( - { - task: input.task ?? "", - }, - { - workspaceDir: options?.spawnWorkspaceDir ?? options?.workspaceDir, - }, - ), - }, - ], - resolveToolLoopDetectionConfig: () => undefined, -})); - -vi.mock("../../pi-bundle-mcp-tools.js", () => ({ - createBundleMcpToolRuntime: async () => undefined, -})); - -vi.mock("../../pi-bundle-lsp-runtime.js", () => ({ - createBundleLspToolRuntime: async () => undefined, -})); - -vi.mock("../../../image-generation/runtime.js", () => ({ - generateImage: vi.fn(), - listRuntimeImageGenerationProviders: () => [], -})); - -vi.mock("../../model-selection.js", () => ({ - normalizeProviderId: (providerId?: string) => providerId?.trim().toLowerCase() ?? "", - resolveDefaultModelForAgent: () => ({ provider: "openai", model: "gpt-test" }), -})); - -vi.mock("../../anthropic-vertex-stream.js", () => ({ - createAnthropicVertexStreamFnForModel: vi.fn(), -})); - -vi.mock("../../custom-api-registry.js", () => ({ - ensureCustomApiRegistered: () => {}, -})); - -vi.mock("../../model-auth.js", () => ({ - resolveModelAuthMode: () => undefined, -})); - -vi.mock("../../model-tool-support.js", () => ({ - supportsModelTools: () => true, -})); - -vi.mock("../../ollama-stream.js", () => ({ - createConfiguredOllamaStreamFn: vi.fn(), -})); - -vi.mock("../../owner-display.js", () => ({ - resolveOwnerDisplaySetting: () => ({ - ownerDisplay: undefined, - ownerDisplaySecret: undefined, - }), -})); - -vi.mock("../../sandbox/runtime-status.js", () => ({ - resolveSandboxRuntimeStatus: () => ({ - agentId: "main", - sessionKey: "agent:main:main", - mainSessionKey: "agent:main:main", - mode: "off", - sandboxed: false, - toolPolicy: { allow: [], deny: [], sources: { allow: { key: "" }, deny: { key: "" } } }, - }), -})); - -vi.mock("../../tool-call-id.js", () => ({ - sanitizeToolCallIdsForCloudCodeAssist: (messages: T) => messages, -})); - -vi.mock("../../tool-fs-policy.js", () => ({ - resolveEffectiveToolFsWorkspaceOnly: () => false, -})); - -vi.mock("../../tool-policy.js", () => ({ - normalizeToolName: (name: string) => name, -})); - -vi.mock("../../transcript-policy.js", () => ({ - resolveTranscriptPolicy: () => ({ - allowSyntheticToolResults: false, - }), -})); - -vi.mock("../cache-ttl.js", () => ({ - appendCacheTtlTimestamp: ( - sessionManager: { appendCustomEntry?: (customType: string, data: unknown) => void }, - data: unknown, - ) => sessionManager.appendCustomEntry?.("openclaw.cache-ttl", data), - isCacheTtlEligibleProvider: (provider?: string) => provider === "anthropic", -})); - -vi.mock("../compaction-runtime-context.js", () => ({ - buildEmbeddedCompactionRuntimeContext: () => ({}), -})); - -vi.mock("../compaction-safety-timeout.js", () => ({ - resolveCompactionTimeoutMs: () => undefined, -})); - -vi.mock("../history.js", () => ({ - getDmHistoryLimitFromSessionKey: () => undefined, - limitHistoryTurns: (messages: T) => messages, -})); - -vi.mock("../logger.js", () => ({ - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - isEnabled: () => false, - }, -})); - -vi.mock("../message-action-discovery-input.js", () => ({ - buildEmbeddedMessageActionDiscoveryInput: () => undefined, -})); - -vi.mock("../model.js", () => ({ - buildModelAliasLines: () => [], -})); - -vi.mock("../sandbox-info.js", () => ({ - buildEmbeddedSandboxInfo: () => undefined, -})); - -vi.mock("../thinking.js", () => ({ - dropThinkingBlocks: (messages: T) => messages, -})); - -vi.mock("../tool-name-allowlist.js", () => ({ - collectAllowedToolNames: () => undefined, -})); - -vi.mock("../tool-split.js", () => ({ - splitSdkTools: ({ tools }: { tools: unknown[] }) => ({ - builtInTools: [], - customTools: tools, - }), -})); - -vi.mock("../utils.js", () => ({ - describeUnknownError: (error: unknown) => - error instanceof Error ? error.message : String(error), - mapThinkingLevel: () => undefined, -})); - -vi.mock("./compaction-retry-aggregate-timeout.js", () => ({ - waitForCompactionRetryWithAggregateTimeout: async () => ({ - timedOut: false, - aborted: false, - }), -})); - -vi.mock("./compaction-timeout.js", () => ({ - resolveRunTimeoutDuringCompaction: () => "abort", - resolveRunTimeoutWithCompactionGraceMs: ({ - runTimeoutMs, - compactionTimeoutMs, - }: { - runTimeoutMs: number; - compactionTimeoutMs: number; - }) => runTimeoutMs + compactionTimeoutMs, - selectCompactionTimeoutSnapshot: ({ - currentSnapshot, - currentSessionId, - }: { - currentSnapshot: unknown[]; - currentSessionId: string; - }) => ({ - messagesSnapshot: currentSnapshot, - sessionIdUsed: currentSessionId, - source: "current", - }), - shouldFlagCompactionTimeout: () => false, -})); - -vi.mock("./history-image-prune.js", () => ({ - pruneProcessedHistoryImages: (messages: T) => messages, -})); - -let runEmbeddedAttemptPromise: - | Promise - | undefined; - -async function loadRunEmbeddedAttempt() { - runEmbeddedAttemptPromise ??= import("./attempt.js").then((mod) => mod.runEmbeddedAttempt); - return await runEmbeddedAttemptPromise; -} - -type MutableSession = { - sessionId: string; - messages: unknown[]; - isCompacting: boolean; - isStreaming: boolean; - agent: { - streamFn?: unknown; - replaceMessages: (messages: unknown[]) => void; - }; - prompt: (prompt: string, options?: { images?: unknown[] }) => Promise; - abort: () => Promise; - dispose: () => void; - steer: (text: string) => Promise; -}; - -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; - 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: async () => {}, - }); - hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({ - bootstrapFiles: [], - contextFiles: [], - }); - hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined); - hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined); - 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; -}): 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, sessionKey: string) { - expect(mock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey, - }), - ); -} - -const testModel = { - api: "openai-completions", - provider: "openai", - compat: {}, - contextWindow: 8192, - input: ["text"], -} as unknown as Model; - -const cacheTtlEligibleModel = { - api: "anthropic", - provider: "anthropic", - compat: {}, - contextWindow: 8192, - input: ["text"], -} as unknown as Model; - -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 runEmbeddedAttempt = await loadRunEmbeddedAttempt(); - 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 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 runEmbeddedAttempt = await loadRunEmbeddedAttempt(); - 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(), - })); - - const runEmbeddedAttempt = await loadRunEmbeddedAttempt(); - 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.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined); - 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; - maintain?: - | boolean - | ((params: { - sessionId: string; - sessionKey?: string; - sessionFile: string; - runtimeContext?: Record; - }) => Promise<{ - changed: boolean; - bytesFreed: number; - rewrittenEntries: number; - reason?: string; - }>); - assemble: (params: { - sessionId: string; - sessionKey?: string; - messages: AgentMessage[]; - tokenBudget?: number; - model?: string; - }) => Promise; - afterTurn?: (params: { - sessionId: string; - sessionKey?: string; - sessionFile: string; - messages: AgentMessage[]; - prePromptMessageCount: number; - tokenBudget?: number; - runtimeContext?: Record; - }) => Promise; - ingestBatch?: (params: { - sessionId: string; - sessionKey?: string; - messages: AgentMessage[]; - }) => Promise; - ingest?: (params: { - sessionId: string; - sessionKey?: string; - message: AgentMessage; - }) => Promise; - compact?: (params: { - sessionId: string; - sessionKey?: string; - sessionFile: string; - tokenBudget?: number; - }) => Promise; - info?: Partial; - }) { - const { maintain: rawMaintain, ...contextEngineRest } = contextEngine; - 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"; - const maintain = - typeof rawMaintain === "function" - ? rawMaintain - : rawMaintain - ? async () => ({ - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "test maintenance", - }) - : undefined; - - hoisted.sessionManager.buildSessionContext - .mockReset() - .mockReturnValue({ messages: seedMessages }); - - hoisted.createAgentSessionMock.mockImplementation(async () => ({ - session: createDefaultEmbeddedSession(), - })); - - const runEmbeddedAttempt = await loadRunEmbeddedAttempt(); - 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: { - ...contextEngineRest, - ingest: - contextEngine.ingest ?? - (async () => ({ - ingested: true, - })), - compact: - contextEngine.compact ?? - (async () => ({ - ok: false, - compacted: false, - reason: "not used in this test", - })), - ...(maintain ? { maintain } : {}), - 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, - maintain: true, - }); - - 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" }), - ); - }); -});