From 4e661d5c4b18e4a6747c0fe783b28c6ac5c64dae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Mar 2026 05:20:21 -0700 Subject: [PATCH] test: split attempt spawn-workspace thread fixtures --- ....spawn-workspace.bootstrap-warning.test.ts | 42 + .../attempt.spawn-workspace.cache-ttl.test.ts | 71 ++ ...mpt.spawn-workspace.context-engine.test.ts | 186 +++++ ...mpt.spawn-workspace.sessions-spawn.test.ts | 42 + .../attempt.spawn-workspace.test-support.ts | 716 ++++++++++++++++++ .../run/attempt.thread-helpers.ts | 72 ++ src/agents/pi-embedded-runner/run/attempt.ts | 61 +- 7 files changed, 1155 insertions(+), 35 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts create mode 100644 src/agents/pi-embedded-runner/run/attempt.spawn-workspace.cache-ttl.test.ts create mode 100644 src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts create mode 100644 src/agents/pi-embedded-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts create mode 100644 src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts create mode 100644 src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts new file mode 100644 index 00000000000..0fc83e076b6 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeBootstrapBudget, + buildBootstrapInjectionStats, + buildBootstrapPromptWarning, + prependBootstrapPromptWarning, +} from "../../bootstrap-budget.js"; +import { composeSystemPromptWithHookContext } from "./attempt.thread-helpers.js"; + +describe("runEmbeddedAttempt bootstrap warning prompt assembly", () => { + it("keeps bootstrap warnings in the sent prompt after hook prepend context", () => { + const analysis = analyzeBootstrapBudget({ + files: buildBootstrapInjectionStats({ + bootstrapFiles: [ + { + name: "AGENTS.md", + path: "/tmp/openclaw-warning-workspace/AGENTS.md", + content: "A".repeat(200), + missing: false, + }, + ], + injectedFiles: [{ path: "AGENTS.md", content: "A".repeat(20) }], + }), + bootstrapMaxChars: 50, + bootstrapTotalMaxChars: 50, + }); + const warning = buildBootstrapPromptWarning({ + analysis, + mode: "once", + }); + const promptWithWarning = prependBootstrapPromptWarning("hello", warning.lines); + const systemPrompt = composeSystemPromptWithHookContext({ + baseSystemPrompt: promptWithWarning, + prependSystemContext: "hook context", + }); + + expect(systemPrompt).toContain("hook context"); + expect(systemPrompt).toContain("[Bootstrap truncation warning]"); + expect(systemPrompt).toContain("- AGENTS.md: 200 raw -> 20 injected"); + expect(systemPrompt).toContain("hello"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.cache-ttl.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.cache-ttl.test.ts new file mode 100644 index 00000000000..0447b766fac --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.cache-ttl.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { + appendAttemptCacheTtlIfNeeded, + ATTEMPT_CACHE_TTL_CUSTOM_TYPE, +} from "./attempt.thread-helpers.js"; + +describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => { + it("skips cache-ttl append when compaction completed during the attempt", async () => { + const sessionManager = { + appendCustomEntry: vi.fn(), + }; + const appended = appendAttemptCacheTtlIfNeeded({ + sessionManager, + timedOutDuringCompaction: false, + compactionOccurredThisAttempt: true, + config: { + agents: { + defaults: { + contextPruning: { + mode: "cache-ttl", + }, + }, + }, + }, + provider: "anthropic", + modelId: "claude-sonnet-4-20250514", + isCacheTtlEligibleProvider: () => true, + now: 123, + }); + + expect(appended).toBe(false); + expect(sessionManager.appendCustomEntry).not.toHaveBeenCalledWith( + ATTEMPT_CACHE_TTL_CUSTOM_TYPE, + expect.anything(), + ); + }); + + it("appends cache-ttl when no compaction completed during the attempt", async () => { + const sessionManager = { + appendCustomEntry: vi.fn(), + }; + const appended = appendAttemptCacheTtlIfNeeded({ + sessionManager, + timedOutDuringCompaction: false, + compactionOccurredThisAttempt: false, + config: { + agents: { + defaults: { + contextPruning: { + mode: "cache-ttl", + }, + }, + }, + }, + provider: "anthropic", + modelId: "claude-sonnet-4-20250514", + isCacheTtlEligibleProvider: () => true, + now: 123, + }); + + expect(appended).toBe(true); + expect(sessionManager.appendCustomEntry).toHaveBeenCalledWith( + ATTEMPT_CACHE_TTL_CUSTOM_TYPE, + expect.objectContaining({ + provider: "anthropic", + modelId: "claude-sonnet-4-20250514", + timestamp: 123, + }), + ); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts new file mode 100644 index 00000000000..fd6db658842 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -0,0 +1,186 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanupTempPaths, + createContextEngineAttemptRunner, + createContextEngineBootstrapAndAssemble, + createSubscriptionMock, + expectCalledWithSessionKey, + getHoisted, +} from "./attempt.spawn-workspace.test-support.js"; + +const hoisted = getHoisted(); + +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 () => { + await cleanupTempPaths(tempPaths); + }); + + it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => { + const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); + const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {}); + + const result = await createContextEngineAttemptRunner({ + contextEngine: { + bootstrap, + assemble, + afterTurn, + }, + sessionKey, + tempPaths, + }); + + 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 createContextEngineAttemptRunner({ + contextEngine: { + bootstrap, + assemble, + }, + sessionKey, + tempPaths, + }); + + 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 createContextEngineAttemptRunner({ + contextEngine: { + bootstrap, + assemble, + ingestBatch, + }, + sessionKey, + tempPaths, + }); + + 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 createContextEngineAttemptRunner({ + contextEngine: { + bootstrap, + assemble, + ingest, + }, + sessionKey, + tempPaths, + }); + + 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 createContextEngineAttemptRunner({ + contextEngine: { + bootstrap, + assemble, + afterTurn, + }, + sessionKey, + tempPaths, + }); + + 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 createContextEngineAttemptRunner({ + contextEngine: { + assemble, + maintain: true, + }, + sessionKey, + tempPaths, + }); + + 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 createContextEngineAttemptRunner({ + contextEngine: { + bootstrap, + assemble, + ingestBatch, + }, + sessionKey, + tempPaths, + }); + + expect(result.promptError).toBeNull(); + expect(ingestBatch).toHaveBeenCalled(); + expect(hoisted.runContextEngineMaintenanceMock).not.toHaveBeenCalledWith( + expect.objectContaining({ reason: "turn" }), + ); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts new file mode 100644 index 00000000000..e4e8a9abb3b --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js"; +import { resolveAttemptSpawnWorkspaceDir } from "./attempt.thread-helpers.js"; + +describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { + it("passes the real workspace to sessions_spawn when workspaceAccess is ro", async () => { + const realWorkspace = "/tmp/openclaw-real-workspace"; + const sandboxWorkspace = "/tmp/openclaw-sandbox-workspace"; + const sandbox = createPiToolsSandboxContext({ + workspaceDir: sandboxWorkspace, + agentWorkspaceDir: realWorkspace, + workspaceAccess: "ro", + tools: { allow: ["sessions_spawn"], deny: [] }, + sessionKey: "agent:main:main", + }); + + expect( + resolveAttemptSpawnWorkspaceDir({ + sandbox, + resolvedWorkspace: realWorkspace, + }), + ).toBe(realWorkspace); + }); + + it("does not override spawned workspace when sandbox workspace is rw", async () => { + const realWorkspace = "/tmp/openclaw-real-workspace"; + const sandbox = createPiToolsSandboxContext({ + workspaceDir: realWorkspace, + agentWorkspaceDir: realWorkspace, + workspaceAccess: "rw", + tools: { allow: ["sessions_spawn"], deny: [] }, + sessionKey: "agent:main:main", + }); + + expect( + resolveAttemptSpawnWorkspaceDir({ + sandbox, + resolvedWorkspace: realWorkspace, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts new file mode 100644 index 00000000000..82be4d8e648 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -0,0 +1,716 @@ +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 { expect, 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 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, + }; +}); + +export function getHoisted() { + return hoisted; +} + +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; + +export async function loadRunEmbeddedAttempt() { + runEmbeddedAttemptPromise ??= import("./attempt.js").then((mod) => mod.runEmbeddedAttempt); + return await runEmbeddedAttemptPromise; +} + +export 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; +}; + +export 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, + }; +} + +export 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); + } +} + +export async function cleanupTempPaths(tempPaths: string[]) { + while (tempPaths.length > 0) { + const target = tempPaths.pop(); + if (target) { + await fs.rm(target, { recursive: true, force: true }); + } + } +} + +export 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; +} + +export 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, + }), + ), + }; +} + +export function expectCalledWithSessionKey(mock: ReturnType, sessionKey: string) { + expect(mock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + }), + ); +} + +export const testModel = { + api: "openai-completions", + provider: "openai", + compat: {}, + contextWindow: 8192, + input: ["text"], +} as unknown as Model; + +export const cacheTtlEligibleModel = { + api: "anthropic", + provider: "anthropic", + compat: {}, + contextWindow: 8192, + input: ["text"], +} as unknown as Model; + +export async function createContextEngineAttemptRunner(params: { + 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; + }; + sessionKey: string; + tempPaths: string[]; +}) { + const { maintain: rawMaintain, ...contextEngineRest } = params.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"); + params.tempPaths.push(workspaceDir, agentDir); + await fs.writeFile(sessionFile, "", "utf8"); + const seedMessages: AgentMessage[] = [ + { role: "user", content: "seed", timestamp: 1 } as AgentMessage, + ]; + const infoId = params.contextEngine.info?.id ?? "test-context-engine"; + const infoName = params.contextEngine.info?.name ?? "Test Context Engine"; + const infoVersion = params.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: params.sessionKey, + sessionFile, + workspaceDir, + agentDir, + config: {}, + prompt: "hello", + timeoutMs: 10_000, + runId: "run-context-engine-forwarding", + provider: "openai", + modelId: "gpt-test", + model: testModel, + authStorage: {} as never, + modelRegistry: {} as never, + thinkLevel: "off", + senderIsOwner: true, + disableMessageTool: true, + contextTokenBudget: 2048, + contextEngine: { + ...contextEngineRest, + ingest: + params.contextEngine.ingest ?? + (async () => ({ + ingested: true, + })), + compact: + params.contextEngine.compact ?? + (async () => ({ + ok: false, + compacted: false, + reason: "not used in this test", + })), + ...(maintain ? { maintain } : {}), + info: { + id: infoId, + name: infoName, + version: infoVersion, + }, + }, + }); +} diff --git a/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts new file mode 100644 index 00000000000..1db0d9ed5c3 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts @@ -0,0 +1,72 @@ +import type { OpenClawConfig } from "../../../config/config.js"; +import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; + +export const ATTEMPT_CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl"; + +export function composeSystemPromptWithHookContext(params: { + baseSystemPrompt?: string; + prependSystemContext?: string; + appendSystemContext?: string; +}): string | undefined { + const prependSystem = params.prependSystemContext?.trim(); + const appendSystem = params.appendSystemContext?.trim(); + if (!prependSystem && !appendSystem) { + return undefined; + } + return joinPresentTextSegments( + [params.prependSystemContext, params.baseSystemPrompt, params.appendSystemContext], + { trim: true }, + ); +} + +export function resolveAttemptSpawnWorkspaceDir(params: { + sandbox?: { + enabled?: boolean; + workspaceAccess?: string; + } | null; + resolvedWorkspace: string; +}): string | undefined { + return params.sandbox?.enabled && params.sandbox.workspaceAccess !== "rw" + ? params.resolvedWorkspace + : undefined; +} + +export function shouldAppendAttemptCacheTtl(params: { + timedOutDuringCompaction: boolean; + compactionOccurredThisAttempt: boolean; + config?: OpenClawConfig; + provider: string; + modelId: string; + isCacheTtlEligibleProvider: (provider: string, modelId: string) => boolean; +}): boolean { + if (params.timedOutDuringCompaction || params.compactionOccurredThisAttempt) { + return false; + } + return ( + params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" && + params.isCacheTtlEligibleProvider(params.provider, params.modelId) + ); +} + +export function appendAttemptCacheTtlIfNeeded(params: { + sessionManager: { + appendCustomEntry?: (customType: string, data: unknown) => void; + }; + timedOutDuringCompaction: boolean; + compactionOccurredThisAttempt: boolean; + config?: OpenClawConfig; + provider: string; + modelId: string; + isCacheTtlEligibleProvider: (provider: string, modelId: string) => boolean; + now?: number; +}): boolean { + if (!shouldAppendAttemptCacheTtl(params)) { + return false; + } + params.sessionManager.appendCustomEntry?.(ATTEMPT_CACHE_TTL_CUSTOM_TYPE, { + timestamp: params.now ?? Date.now(), + provider: params.provider, + modelId: params.modelId, + }); + return true; +} diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ac4e0d77d74..6de71ca2a82 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -102,7 +102,7 @@ import type { TranscriptPolicy } from "../../transcript-policy.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; -import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js"; +import { isCacheTtlEligibleProvider } from "../cache-ttl.js"; import type { CompactEmbeddedPiSessionParams } from "../compact.js"; import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; @@ -139,6 +139,11 @@ import { installToolResultContextGuard } from "../tool-result-context-guard.js"; import { splitSdkTools } from "../tool-split.js"; import { describeUnknownError, mapThinkingLevel } from "../utils.js"; import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js"; +import { + appendAttemptCacheTtlIfNeeded, + composeSystemPromptWithHookContext, + resolveAttemptSpawnWorkspaceDir, +} from "./attempt.thread-helpers.js"; import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js"; import { resolveRunTimeoutDuringCompaction, @@ -1501,21 +1506,11 @@ export async function resolvePromptBuildHookResult(params: { }; } -export function composeSystemPromptWithHookContext(params: { - baseSystemPrompt?: string; - prependSystemContext?: string; - appendSystemContext?: string; -}): string | undefined { - const prependSystem = params.prependSystemContext?.trim(); - const appendSystem = params.appendSystemContext?.trim(); - if (!prependSystem && !appendSystem) { - return undefined; - } - return joinPresentTextSegments( - [params.prependSystemContext, params.baseSystemPrompt, params.appendSystemContext], - { trim: true }, - ); -} +export { + appendAttemptCacheTtlIfNeeded, + composeSystemPromptWithHookContext, + resolveAttemptSpawnWorkspaceDir, +} from "./attempt.thread-helpers.js"; export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "full" { if (!sessionKey) { @@ -1663,7 +1658,6 @@ export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { const resolvedWorkspace = resolveUserPath(params.workspaceDir); - const prevCwd = process.cwd(); const runAbortController = new AbortController(); // Proxy bootstrap must happen before timeout tuning so the timeouts wrap the // active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher. @@ -1690,7 +1684,6 @@ export async function runEmbeddedAttempt( await fs.mkdir(effectiveWorkspace, { recursive: true }); let restoreSkillEnv: (() => void) | undefined; - process.chdir(effectiveWorkspace); try { const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ workspaceDir: effectiveWorkspace, @@ -1798,8 +1791,10 @@ export async function runEmbeddedAttempt( workspaceDir: effectiveWorkspace, // When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points // at the sandbox copy. Spawned subagents should inherit the real workspace instead. - spawnWorkspaceDir: - sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? resolvedWorkspace : undefined, + spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({ + sandbox, + resolvedWorkspace, + }), config: params.config, abortSignal: runAbortController.signal, modelProvider: params.model.provider, @@ -1945,7 +1940,7 @@ export async function runEmbeddedAttempt( config: params.config, agentId: sessionAgentId, workspaceDir: effectiveWorkspace, - cwd: process.cwd(), + cwd: effectiveWorkspace, runtime: { host: machineName, os: `${os.type()} ${os.release()}`, @@ -1964,7 +1959,7 @@ export async function runEmbeddedAttempt( const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], - cwd: process.cwd(), + cwd: effectiveWorkspace, moduleUrl: import.meta.url, }); const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; @@ -2977,18 +2972,15 @@ export async function runEmbeddedAttempt( // Skip when timed out during compaction — session state may be inconsistent. // Also skip when compaction ran this attempt — appending a custom entry // after compaction would break the guard again. See: #28491 - if (!timedOutDuringCompaction && !compactionOccurredThisAttempt) { - const shouldTrackCacheTtl = - params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" && - isCacheTtlEligibleProvider(params.provider, params.modelId); - if (shouldTrackCacheTtl) { - appendCacheTtlTimestamp(sessionManager, { - timestamp: Date.now(), - provider: params.provider, - modelId: params.modelId, - }); - } - } + appendAttemptCacheTtlIfNeeded({ + sessionManager, + timedOutDuringCompaction, + compactionOccurredThisAttempt, + config: params.config, + provider: params.provider, + modelId: params.modelId, + isCacheTtlEligibleProvider, + }); // If timeout occurred during compaction, use pre-compaction snapshot when available // (compaction restructures messages but does not add user/assistant turns). @@ -3244,6 +3236,5 @@ export async function runEmbeddedAttempt( } } finally { restoreSkillEnv?.(); - process.chdir(prevCwd); } }