| 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 { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; |
| import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js"; |
|
|
| const hoisted = vi.hoisted(() => { |
| 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 hookRunner = { |
| hasHooks: vi.fn((_: string) => false), |
| runBeforePromptBuild: vi.fn(async () => undefined), |
| runBeforeAgentStart: vi.fn(async () => undefined), |
| runAgentEnd: vi.fn(async () => 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, |
| hookRunner, |
| 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.hookRunner, |
| })); |
|
|
| 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: async () => ({ bootstrapFiles: [], contextFiles: [] }), |
| })); |
|
|
| vi.mock("../../skills.js", () => ({ |
| applySkillEnvOverrides: () => () => {}, |
| applySkillEnvOverridesFromSnapshot: () => () => {}, |
| resolveSkillsPromptForRun: () => "", |
| })); |
|
|
| vi.mock("../skills-runtime.js", () => ({ |
| resolveEmbeddedRunSkillEntries: () => ({ |
| shouldLoadSkillEntries: false, |
| skillEntries: undefined, |
| }), |
| })); |
|
|
| 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: () => {}, |
| })); |
|
|
| 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("../../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: async () => {}, |
| }); |
| hoisted.hookRunner.hasHooks.mockReset().mockReturnValue(false); |
| hoisted.hookRunner.runBeforePromptBuild.mockReset().mockResolvedValue(undefined); |
| hoisted.hookRunner.runBeforeAgentStart.mockReset().mockResolvedValue(undefined); |
| hoisted.hookRunner.runAgentEnd.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) => Promise<void>; |
| }): MutableSession { |
| const session: MutableSession = { |
| sessionId: "embedded-session", |
| messages: [], |
| isCompacting: false, |
| isStreaming: false, |
| agent: { |
| replaceMessages: (messages: unknown[]) => { |
| session.messages = [...messages]; |
| }, |
| }, |
| prompt: async () => { |
| if (params?.prompt) { |
| await params.prompt(session); |
| 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 }) => ({ |
| 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 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 }); |
| } |
| } |
| }); |
|
|
| |
| |
| async function runAttemptWithContextEngine(contextEngine: { |
| bootstrap?: (params: { |
| sessionId: string; |
| sessionKey?: string; |
| sessionFile: string; |
| }) => Promise<BootstrapResult>; |
| assemble: (params: { |
| sessionId: string; |
| sessionKey?: string; |
| messages: AgentMessage[]; |
| tokenBudget?: number; |
| }) => 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 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); |
| }); |
| }); |
|
|
| describe("runEmbeddedAttempt agent_end hook outcome", () => { |
| const tempPaths: string[] = []; |
|
|
| beforeEach(() => { |
| resetEmbeddedAttemptHarness({ |
| subscribeImpl: createSubscriptionMock, |
| }); |
| }); |
|
|
| afterEach(async () => { |
| await cleanupTempPaths(tempPaths); |
| }); |
|
|
| it("reports failure to agent_end hooks when the assistant ends with stopReason=error", async () => { |
| const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-end-workspace-")); |
| const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-end-agent-")); |
| const sessionFile = path.join(workspaceDir, "session.jsonl"); |
| tempPaths.push(workspaceDir, agentDir); |
| await fs.writeFile(sessionFile, "", "utf8"); |
|
|
| hoisted.hookRunner.hasHooks.mockImplementation((hookName?: string) => hookName === "agent_end"); |
|
|
| hoisted.createAgentSessionMock.mockImplementation(async () => ({ |
| session: createDefaultEmbeddedSession({ |
| prompt: async (session) => { |
| session.messages = [ |
| ...session.messages, |
| { |
| role: "assistant", |
| content: "model failed", |
| stopReason: "error", |
| errorMessage: "provider exploded", |
| timestamp: 2, |
| }, |
| ]; |
| }, |
| }), |
| })); |
|
|
| const result = await runEmbeddedAttempt({ |
| sessionId: "embedded-session", |
| sessionKey: "agent:main:test-agent-end", |
| sessionFile, |
| workspaceDir, |
| agentDir, |
| config: {}, |
| prompt: "hello", |
| timeoutMs: 10_000, |
| runId: "run-agent-end-error", |
| provider: "openai", |
| modelId: "gpt-test", |
| model: testModel, |
| authStorage: {} as AuthStorage, |
| modelRegistry: {} as ModelRegistry, |
| thinkLevel: "off", |
| senderIsOwner: true, |
| disableMessageTool: true, |
| }); |
|
|
| expect(result.promptError).toBeNull(); |
| await vi.waitFor(() => { |
| expect(hoisted.hookRunner.runAgentEnd).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| success: false, |
| error: "provider exploded", |
| }), |
| expect.objectContaining({ |
| sessionKey: "agent:main:test-agent-end", |
| }), |
| ); |
| }); |
| }); |
| }); |
|
|