From 94c28e093d694fc8308c42f4a856f89fdf68cfc5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:44:49 -0700 Subject: [PATCH] fix(test): route agent directory runs to owning shard (#122514) * fix(test): route agent directories to owner projects * fix(test): preserve invalid signal diagnostics --- scripts/run-vitest.mts | 89 ++-- scripts/test-projects.test-support.mts | 18 + src/agents/agent-tools.ts | 24 +- src/agents/bash-process-scope.test.ts | 44 ++ src/agents/bash-process-scope.ts | 22 + .../compact.hooks.harness.ts | 11 - .../prepared-compaction-runtime.ts | 3 +- .../run/attempt-context-engine-helpers.ts | 6 - .../run/attempt-finalize.ts | 8 +- .../run/attempt-history.ts | 8 +- .../run/attempt-prompt-helpers.ts | 2 +- .../run/attempt-session-prepare.ts | 8 +- .../attempt-spawn-workspace.test-support.ts | 17 +- .../run/attempt-system-prompt-prepare.ts | 2 +- .../attempt.context-engine-helpers.test.ts | 127 ++++- ...mpt.spawn-workspace.context-engine.test.ts | 434 +----------------- ....spawn-workspace.context-injection.test.ts | 4 +- .../run/compaction-runtime.ts | 2 +- .../harness/context-engine-lifecycle.test.ts | 133 +++++- test/scripts/run-vitest.test.ts | 36 +- test/scripts/test-projects.test.ts | 36 +- 21 files changed, 466 insertions(+), 568 deletions(-) create mode 100644 src/agents/bash-process-scope.test.ts create mode 100644 src/agents/bash-process-scope.ts diff --git a/scripts/run-vitest.mts b/scripts/run-vitest.mts index 4fffeff8a8ee..62206a6c63db 100644 --- a/scripts/run-vitest.mts +++ b/scripts/run-vitest.mts @@ -5,8 +5,10 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import { constants as osConstants } from "node:os"; import path from "node:path"; -import type { Readable, Writable } from "node:stream"; -import { embeddedAgentVitestProjectOwners } from "../test/vitest/vitest.agents-paths.mjs"; +import { + agentVitestProjectOwners, + embeddedAgentVitestProjectOwners, +} from "../test/vitest/vitest.agents-paths.mjs"; import { toolingIsolatedTestFiles } from "../test/vitest/vitest.tooling-isolated-paths.mjs"; import { isUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs"; import { boundaryTestFiles } from "../test/vitest/vitest.unit-paths.mjs"; @@ -35,6 +37,15 @@ type WatchdogStream = { on(event: string, listener: (...args: unknown[]) => void): unknown; off(event: string, listener: (...args: unknown[]) => void): unknown; }; +type NodeSignal = keyof typeof osConstants.signals; +type VitestOutputStream = { + setEncoding(encoding: "utf8"): unknown; + on(event: "data", listener: (chunk: string) => void): unknown; + on(event: "end", listener: () => void): unknown; +}; +type VitestOutputTarget = { + write(chunk: string): unknown; +}; const ANSI_CSI_PREFIX = `${String.fromCharCode(27)}[`; const ANSI_CSI_SUFFIX_RE = /^[0-?]*[ -/]*[@-~]/u; @@ -117,7 +128,10 @@ const VITEST_OPTIONS_WITH_VALUE = new Set([ "--retry", "--root", "-r", - "--sequence.shuffle.seed", + "--sequence", + "--sequence.hooks", + "--sequence.seed", + "--sequence.setupFiles", "--shard", "--silent", "--slowTestThreshold", @@ -138,7 +152,6 @@ const VITEST_DOTTED_OPTIONS_WITH_VALUE_PREFIXES = [ "--experimental.", "--outputFile.", "--retry.", - "--sequence.", "--typecheck.", ]; const UNBOUNDED_CONFIG_ONLY_OPTIONS = [ @@ -178,16 +191,17 @@ function isErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoExc return error instanceof Error && "code" in error && error.code === code; } -function isNodeSignal(signal: string): signal is NodeJS.Signals { +function isNodeSignal(signal: string): signal is NodeSignal { return Object.hasOwn(osConstants.signals, signal); } -function normalizeNodeSignal(signal: string | null): NodeJS.Signals | null { +function normalizeNodeSignal(signal: string | null): NodeSignal | null { if (!signal) { return null; } + const unknownSignalMessage = `child process exited with unknown signal: ${signal}`; if (!isNodeSignal(signal)) { - throw new Error(`child process exited with unknown signal: ${signal}`); + throw new Error(unknownSignalMessage); } return signal; } @@ -667,6 +681,18 @@ function isDelegableBroadProjectRouterTarget(arg: string, cwd: string): boolean ); } +function isPathAtOrUnder(value: string, root: string): boolean { + return value === root || value.startsWith(`${root}/`); +} + +function isOwnedAgentDirectoryTarget(arg: string, cwd: string, fsImpl: VitestPathFs): boolean { + const relative = toRepoRelativeArg(arg, cwd).replace(/\/+$/u, ""); + return ( + isPathAtOrUnder(relative, agentVitestProjectOwners.all.root) && + isExplicitDirectoryTargetArg(arg, cwd, fsImpl) + ); +} + function isExplicitProjectRouterTargetArg( arg: string, cwd = process.cwd(), @@ -683,7 +709,7 @@ function isExplicitProjectRouterTargetArg( } const filePath = path.isAbsolute(arg) ? arg : path.resolve(cwd, arg); return fsImpl.existsSync(filePath) - ? isDelegableBroadProjectRouterTarget(arg, cwd) + ? isDelegableBroadProjectRouterTarget(arg, cwd) || isOwnedAgentDirectoryTarget(arg, cwd, fsImpl) : path.extname(arg) === "" && /^(?:src|test|extensions|ui|packages|apps)\//u.test(toRepoRelativeArg(arg, cwd)); } @@ -810,42 +836,36 @@ function hasExplicitDisabledRunFlag(argv: string[]): boolean { return false; } -function hasSeparateVitestOptionValueArg(argv: string[]): boolean { - for (const arg of argv) { - if (arg === "--") { - return false; - } - if (optionConsumesNextArg(arg)) { - return true; - } - } - return false; -} - -function stripRunSubcommand(argv: string[]): string[] { - const stripped: string[] = []; +function resolveDelegatedVitestArgs(argv: string[]): string[] { + const positionalArgs: string[] = []; + const optionArgs: string[] = []; let canRemoveRunSubcommand = true; + let passthrough = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === undefined) { break; } if (arg === "--") { - stripped.push(arg); + passthrough = true; canRemoveRunSubcommand = false; continue; } - if (canRemoveRunSubcommand && optionConsumesNextArg(arg)) { - stripped.push(arg); + if (passthrough) { + optionArgs.push(arg); + continue; + } + if (optionConsumesNextArg(arg)) { + optionArgs.push(arg); const optionValue = argv[index + 1]; if (optionValue !== undefined) { + optionArgs.push(optionValue); index += 1; - stripped.push(optionValue); } continue; } - if (canRemoveRunSubcommand && arg.startsWith("-")) { - stripped.push(arg); + if (arg.startsWith("-")) { + optionArgs.push(arg); continue; } if (canRemoveRunSubcommand && arg === "run") { @@ -853,9 +873,9 @@ function stripRunSubcommand(argv: string[]): string[] { continue; } canRemoveRunSubcommand = false; - stripped.push(arg); + positionalArgs.push(arg); } - return stripped; + return optionArgs.length > 0 ? [...positionalArgs, "--", ...optionArgs] : positionalArgs; } function hasNonRunVitestSubcommand(argv: string[]): boolean { @@ -893,12 +913,11 @@ export function resolveTestProjectsDelegationArgs( resolveExplicitVitestMode(argv) === "watch" || hasNonRunVitestSubcommand(argv) || hasExplicitDisabledRunFlag(argv) || - hasSeparateVitestOptionValueArg(argv) || collectExplicitProjectRouterTargetArgs(argv, cwd).length === 0 ) { return null; } - return stripRunSubcommand(argv); + return resolveDelegatedVitestArgs(argv); } /** @@ -1138,8 +1157,8 @@ export function installVitestNoOutputWatchdog(params: { * Forwards child output while optionally suppressing complete stderr lines. */ function forwardVitestOutput( - stream: Readable | null, - target: Writable, + stream: VitestOutputStream | null, + target: VitestOutputTarget, shouldSuppressLine: (line: string) => boolean = () => false, ): void { if (!stream) { @@ -1185,7 +1204,7 @@ export function spawnWatchedVitestProcess({ label?: string; onNoOutputTimeout?: () => void; }) { - let forwardedSignal: NodeJS.Signals | null = null; + let forwardedSignal: NodeSignal | null = null; const child = spawnVitestProcess({ pnpmArgs, spawnParams, diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 3331ec29516b..5b0386ad9b2d 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -1233,6 +1233,9 @@ function resolveExactSourceDirectoryTestTargets(targetArg: string, cwd: string) if (!isExactSourceDirectoryTarget(relative)) { return null; } + if (isCanonicalAgentOwnerDirectoryTarget(targetArg, cwd)) { + return [targetArg]; + } const prefix = `${relative}/`; const lightTargets = uniqueOrdered([ ...getUnitFastTestFiles(), @@ -1242,6 +1245,20 @@ function resolveExactSourceDirectoryTestTargets(targetArg: string, cwd: string) return lightTargets.length > 0 ? [...lightTargets, targetArg] : null; } +function isCanonicalAgentOwnerDirectoryTarget(targetArg: string, cwd: string) { + if (!isExistingDirectoryTarget(targetArg, cwd)) { + return false; + } + const kind = classifyTarget(targetArg, cwd); + if (kind === agentVitestProjectOwners.all.kind) { + return false; + } + const relative = toRepoRelativeTarget(targetArg, cwd).replace(/\/+$/u, ""); + return Object.values(agentVitestProjectOwners).some( + (owner) => owner.kind === kind && isPathAtOrUnder(relative, owner.root), + ); +} + /** * Finds explicit test path targets that do not match any known project plan. */ @@ -3650,6 +3667,7 @@ export function buildVitestRunPlans( const useCliTargetArgs = kind === "e2e" || kind === "packageDocker" || + grouped.every((targetArg) => isCanonicalAgentOwnerDirectoryTarget(targetArg, cwd)) || (kind === "default" && grouped.every((targetArg) => isFileLikeTarget(toRepoRelativeTarget(targetArg, cwd)))); const useWholeConfigTarget = grouped.some((targetArg) => diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index ede2fe371611..83797a9ffcdd 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -41,6 +41,7 @@ import { import type { AnyAgentTool } from "./agent-tools.types.js"; import { isApplyPatchAllowedForModel } from "./apply-patch-model-policy.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { resolveProcessToolScopeKey } from "./bash-process-scope.js"; import type { ExecToolDefaults } from "./bash-tools.exec-types.js"; import type { ProcessToolDefaults } from "./bash-tools.process.js"; import { listChannelAgentTools } from "./channel-tools.js"; @@ -110,29 +111,6 @@ import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-contex const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); -/** Resolve the process-tool isolation key for exec/process session state. */ -export function resolveProcessToolScopeKey(params: { - scopeKey?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; -}): string | undefined { - const explicitScopeKey = params.scopeKey?.trim(); - if (explicitScopeKey) { - return explicitScopeKey; - } - const sessionKey = params.sessionKey?.trim(); - if (sessionKey) { - return sessionKey; - } - const sessionId = params.sessionId?.trim(); - if (sessionId) { - return sessionId; - } - const agentId = params.agentId?.trim(); - return agentId ? `agent:${agentId}` : undefined; -} - function applyModelProviderToolPolicy( toolsInput: AnyAgentTool[], params?: { diff --git a/src/agents/bash-process-scope.test.ts b/src/agents/bash-process-scope.test.ts new file mode 100644 index 000000000000..77fe8e8124b3 --- /dev/null +++ b/src/agents/bash-process-scope.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveProcessToolScopeKey } from "./bash-process-scope.js"; + +describe("resolveProcessToolScopeKey", () => { + it.each([ + { + name: "explicit scope before session identifiers", + params: { + scopeKey: " scope:explicit ", + sessionKey: "session-key", + sessionId: "session-id", + agentId: "main", + }, + expected: "scope:explicit", + }, + { + name: "session key before session and agent ids", + params: { + scopeKey: " ", + sessionKey: " session-key ", + sessionId: "session-id", + agentId: "main", + }, + expected: "session-key", + }, + { + name: "session id before agent id", + params: { sessionKey: "\t", sessionId: " session-id ", agentId: "main" }, + expected: "session-id", + }, + { + name: "agent id fallback", + params: { sessionId: "\n", agentId: " main " }, + expected: "agent:main", + }, + { + name: "blank inputs", + params: { scopeKey: " ", sessionKey: "\t", sessionId: "\n", agentId: " " }, + expected: undefined, + }, + ])("uses $name", ({ params, expected }) => { + expect(resolveProcessToolScopeKey(params)).toBe(expected); + }); +}); diff --git a/src/agents/bash-process-scope.ts b/src/agents/bash-process-scope.ts new file mode 100644 index 000000000000..85076b9143b6 --- /dev/null +++ b/src/agents/bash-process-scope.ts @@ -0,0 +1,22 @@ +/** Resolve the process-tool isolation key for exec/process session state. */ +export function resolveProcessToolScopeKey(params: { + scopeKey?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; +}): string | undefined { + const explicitScopeKey = params.scopeKey?.trim(); + if (explicitScopeKey) { + return explicitScopeKey; + } + const sessionKey = params.sessionKey?.trim(); + if (sessionKey) { + return sessionKey; + } + const sessionId = params.sessionId?.trim(); + if (sessionId) { + return sessionId; + } + const agentId = params.agentId?.trim(); + return agentId ? `agent:${agentId}` : undefined; +} diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 7f5f93c58db0..5d7f9bc9d5ca 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -875,17 +875,6 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../agent-tools.js", () => ({ createOpenClawCodingTools: createOpenClawCodingToolsMock, - resolveProcessToolScopeKey: ({ - scopeKey, - sessionKey, - sessionId, - agentId, - }: { - scopeKey?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - }) => scopeKey ?? sessionKey ?? sessionId ?? (agentId ? `agent:${agentId}` : undefined), })); vi.doMock("./replay-history.js", () => ({ diff --git a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts index 40b29f75b085..c6150fa11ccc 100644 --- a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts @@ -27,8 +27,9 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { createBundleLspToolRuntime } from "../agent-bundle-lsp-runtime.js"; import { createBundleMcpToolRuntime } from "../agent-bundle-mcp-tools.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; -import { createOpenClawCodingTools, resolveProcessToolScopeKey } from "../agent-tools.js"; +import { createOpenClawCodingTools } from "../agent-tools.js"; import { listActiveProcessSessionReferences } from "../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../bash-process-scope.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun, diff --git a/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts index bdb09493155a..8a374a76c841 100644 --- a/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-context-engine-helpers.ts @@ -12,12 +12,6 @@ import type { AgentMessage } from "../../runtime/index.js"; import { hasNonzeroUsage, normalizeUsage, type NormalizedUsage } from "../../usage.js"; import type { PromptCacheChange } from "../prompt-cache-observability.js"; import type { EmbeddedRunAttemptResult } from "./types.js"; -export { - assembleHarnessContextEngine as assembleAttemptContextEngine, - bootstrapHarnessContextEngine as runAttemptContextEngineBootstrap, - finalizeHarnessContextEngineTurn as finalizeAttemptContextEngineTurn, -} from "../../harness/context-engine-lifecycle.js"; - export type AttemptContextEngine = ContextEngine; type AttemptBootstrapContext = { diff --git a/src/agents/embedded-agent-runner/run/attempt-finalize.ts b/src/agents/embedded-agent-runner/run/attempt-finalize.ts index 6f1466d8c128..cb0442bccc87 100644 --- a/src/agents/embedded-agent-runner/run/attempt-finalize.ts +++ b/src/agents/embedded-agent-runner/run/attempt-finalize.ts @@ -20,6 +20,7 @@ import type { createCacheTrace } from "../../cache-trace.js"; import { countActiveToolExecutions } from "../../embedded-agent-subscribe.handlers.tools.js"; import { isSignalTimeoutReason } from "../../failover-error.js"; import { runAgentEndSideEffects } from "../../harness/agent-end-side-effects.js"; +import { finalizeHarnessContextEngineTurn } from "../../harness/context-engine-lifecycle.js"; import { runAgentCleanupStep } from "../../run-cleanup-timeout.js"; import type { AgentMessage } from "../../runtime/index.js"; import type { AgentSession, SessionManager } from "../../sessions/index.js"; @@ -28,10 +29,7 @@ import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; import { log } from "../logger.js"; import { markActiveEmbeddedRunAbandoned, type EmbeddedAgentQueueHandle } from "../runs.js"; import { buildEmbeddedAgentEndContext } from "./agent-end-context.js"; -import { - finalizeAttemptContextEngineTurn, - type buildContextEnginePromptCacheInfo, -} from "./attempt-context-engine-helpers.js"; +import type { buildContextEnginePromptCacheInfo } from "./attempt-context-engine-helpers.js"; import { buildAfterTurnRuntimeContextFromUsage } from "./attempt-prompt-helpers.js"; import { shouldPersistCompletedBootstrapTurn } from "./attempt-thread-helpers.js"; import { @@ -253,7 +251,7 @@ export async function completeEmbeddedAttemptAfterTurn( sessionManager?: SessionManager; withSessionManagerRewriteLock: WithOwnedTranscriptWrite; }) => { - await finalizeAttemptContextEngineTurn({ + await finalizeHarnessContextEngineTurn({ contextEngine: activeContextEngine, promptError: Boolean(state.promptError), aborted: lifecycleState.aborted, diff --git a/src/agents/embedded-agent-runner/run/attempt-history.ts b/src/agents/embedded-agent-runner/run/attempt-history.ts index 41a30614d140..dd6e9e089853 100644 --- a/src/agents/embedded-agent-runner/run/attempt-history.ts +++ b/src/agents/embedded-agent-runner/run/attempt-history.ts @@ -24,6 +24,7 @@ import { import type { createPreparedEmbeddedAgentSettingsManager } from "../../agent-project-settings.js"; import type { createCacheTrace } from "../../cache-trace.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; +import { assembleHarnessContextEngine } from "../../harness/context-engine-lifecycle.js"; import type { AgentRuntimePlan } from "../../runtime-plan/types.js"; import type { AgentMessage } from "../../runtime/index.js"; import type { AgentSession, SessionManager } from "../../sessions/index.js"; @@ -32,10 +33,7 @@ import { resolveTranscriptPolicy, type TranscriptPolicy } from "../../transcript import { getHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js"; import { log } from "../logger.js"; import { sanitizeSessionHistory, validateReplayTurns } from "../replay-history.js"; -import { - assembleAttemptContextEngine, - type AttemptContextEngine, -} from "./attempt-context-engine-helpers.js"; +import type { AttemptContextEngine } from "./attempt-context-engine-helpers.js"; import type { resolveOrphanRepairPlan } from "./attempt-orphan-repair.js"; import { prependSystemPromptAddition } from "./attempt-prompt-helpers.js"; import { isRunnerToolCallBlockType } from "./attempt-tool-call-block-type.js"; @@ -575,7 +573,7 @@ export async function prepareEmbeddedAttemptHistory(input: { }); const messageBudget = Math.max(1, promptBudget - renderedPromptTokens); const transcriptReadFence = attempt.userTurnTranscriptRecorder?.getAdmissionReceipt(); - const assembled = await assembleAttemptContextEngine({ + const assembled = await assembleHarnessContextEngine({ contextEngine: input.activeContextEngine, sessionId: attempt.sessionId, sessionKey: attempt.sessionKey, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts index 499d06e87d83..a45092a64fd4 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-helpers.ts @@ -21,8 +21,8 @@ import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../../sessions/input-provenance.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { truncateUtf16Safe } from "../../../utils.js"; -import { resolveProcessToolScopeKey } from "../../agent-tools.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../../bash-process-scope.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { wrapPluginSystemContextSection } from "../../hook-system-context-boundary.js"; import { diff --git a/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts index 55d43b1c6cf8..ee6516bf732d 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts @@ -19,6 +19,7 @@ import { } from "../../agent-settings.js"; import { toToolDefinitions } from "../../agent-tool-definition-adapter.js"; import { resolveUserTimezone } from "../../date-time.js"; +import { bootstrapHarnessContextEngine } from "../../harness/context-engine-lifecycle.js"; import { relocateCurrentRuntimeContextCarrierToTail } from "../../internal-runtime-context.js"; import type { AgentMessage } from "../../runtime/index.js"; import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; @@ -36,10 +37,7 @@ import { log } from "../logger.js"; import { createEmbeddedAgentResourceLoader } from "../resource-loader.js"; import { applySystemPromptToSession } from "../system-prompt.js"; import { prepareEmbeddedAttemptClientTools } from "./attempt-client-tools.js"; -import { - type AttemptContextEngine, - runAttemptContextEngineBootstrap, -} from "./attempt-context-engine-helpers.js"; +import type { AttemptContextEngine } from "./attempt-context-engine-helpers.js"; import { resolveAttemptTranscriptPolicy } from "./attempt-history.js"; import { normalizeMessagesForLlmBoundary } from "./attempt-llm-boundary.js"; import { @@ -484,7 +482,7 @@ export async function prepareEmbeddedAttemptSessionManager(input: { input.onSessionManagerCreated(sessionManager); await input.withOwnedTranscriptWrite(async () => { - await runAttemptContextEngineBootstrap({ + await bootstrapHarnessContextEngine({ hadSessionFile: transcriptState.hasBootstrapTranscriptState, contextEngine: input.activeContextEngine, sessionId: attempt.sessionId, diff --git a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts index bd43014ac263..2e4df4e01e80 100644 --- a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts @@ -6,7 +6,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; -import { expect, vi, type Mock } from "vitest"; +import { vi, type Mock } from "vitest"; import type { AssembleResult, BootstrapResult, @@ -667,17 +667,6 @@ vi.mock("../../cache-trace.js", () => ({ vi.mock("../../agent-tools.js", () => ({ createOpenClawCodingTools: (options?: { workspaceDir?: string; spawnWorkspaceDir?: string }) => hoisted.createOpenClawCodingToolsMock(options), - resolveProcessToolScopeKey: ({ - scopeKey, - sessionKey, - sessionId, - agentId, - }: { - scopeKey?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - }) => scopeKey ?? sessionKey ?? sessionId ?? (agentId ? `agent:${agentId}` : undefined), resolveToolLoopDetectionConfig: () => undefined, })); @@ -1238,10 +1227,6 @@ export function createContextEngineBootstrapAndAssemble() { }; } -export function expectCalledWithSessionKey(mock: ReturnType, sessionKey: string) { - expect(mock).toHaveBeenCalledWith(expect.objectContaining({ sessionKey })); -} - const testModel = { api: "openai-completions", provider: "openai", diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts index 8cbc9fb547b2..aa895dbe2c62 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts @@ -10,8 +10,8 @@ import { } from "../../../plugins/provider-runtime.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; -import { resolveProcessToolScopeKey } from "../../agent-tools.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../../bash-process-scope.js"; import { buildBootstrapPromptWarningNotice, buildBootstrapTruncationReportMeta, diff --git a/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts index 3e8d263bfbd9..a54c5a033665 100644 --- a/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import type { AssistantMessage } from "../../../llm/types.js"; -import { findLatestUncompactedAttemptUsageSnapshot } from "./attempt-context-engine-helpers.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import { + buildContextEnginePromptCacheInfo, + buildLoopPromptCacheInfo, + findLatestUncompactedAttemptUsageSnapshot, + resolvePromptCacheTouchTimestamp, +} from "./attempt-context-engine-helpers.js"; const ASSISTANT_WITH_USAGE = { role: "assistant", @@ -41,3 +47,122 @@ describe("findLatestUncompactedAttemptUsageSnapshot", () => { ).toBeUndefined(); }); }); + +describe("context-engine prompt cache metadata", () => { + const seedMessage = { role: "user", content: "seed", timestamp: 1 } as AgentMessage; + + it("builds retention, last-call usage, and cache-touch metadata", () => { + expect( + buildContextEnginePromptCacheInfo({ + retention: "short", + lastCallUsage: { input: 10, output: 5, cacheRead: 40, cacheWrite: 2, total: 57 }, + lastCacheTouchAt: 123, + }), + ).toEqual({ + retention: "short", + lastCallUsage: { input: 10, output: 5, cacheRead: 40, cacheWrite: 2, total: 57 }, + lastCacheTouchAt: 123, + }); + }); + + it("omits metadata when no cache data is available", () => { + expect(buildContextEnginePromptCacheInfo({})).toBeUndefined(); + }); + + it("does not reuse a prior turn's usage when the current attempt has no assistant", () => { + const priorAssistant = { + role: "assistant", + content: "prior turn", + timestamp: 2, + usage: { input: 99, output: 7, cacheRead: 1234, total: 1340 }, + } as unknown as AgentMessage; + + expect( + buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, priorAssistant], + prePromptMessageCount: 2, + retention: "short", + }), + ).toEqual({ retention: "short" }); + }); + + it("derives live loop metadata from the current attempt assistant", () => { + const assistant = { + role: "assistant", + content: "tool use", + timestamp: "2026-04-16T16:49:59.536Z", + usage: { input: 1, output: 2, cacheRead: 39036, cacheWrite: 59934, total: 98973 }, + } as unknown as AgentMessage; + + const promptCache = buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, assistant], + prePromptMessageCount: 1, + retention: "short", + fallbackLastCacheTouchAt: 123, + }); + expect(promptCache?.retention).toBe("short"); + expect(promptCache?.lastCallUsage).toMatchObject({ + cacheRead: 39036, + cacheWrite: 59934, + total: 98973, + }); + expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); + }); + + it("keeps the latest nonzero usage when an aborted assistant reports zeros", () => { + const completedAssistant = { + role: "assistant", + content: "tool use", + timestamp: "2026-04-16T16:49:59.536Z", + usage: { input: 38_333, output: 66, cacheRead: 120_320, total: 158_719 }, + } as unknown as AgentMessage; + const abortedAssistant = { + role: "assistant", + content: "", + timestamp: "2026-04-16T16:50:00.000Z", + stopReason: "aborted", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + } as unknown as AgentMessage; + + const promptCache = buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, completedAssistant, abortedAssistant], + prePromptMessageCount: 1, + retention: "short", + }); + expect(promptCache?.lastCallUsage).toMatchObject({ + input: 38_333, + cacheRead: 120_320, + total: 158_719, + }); + expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); + }); + + it("falls back to the persisted cache touch when loop usage has no cache metrics", () => { + const assistant = { + role: "assistant", + content: "tool use", + timestamp: "2026-04-16T16:49:59.536Z", + usage: { input: 1, output: 2, total: 3 }, + } as unknown as AgentMessage; + + const promptCache = buildLoopPromptCacheInfo({ + messagesSnapshot: [seedMessage, assistant], + prePromptMessageCount: 1, + retention: "short", + fallbackLastCacheTouchAt: 123, + }); + expect(promptCache?.retention).toBe("short"); + expect(promptCache?.lastCallUsage?.total).toBe(3); + expect(promptCache?.lastCacheTouchAt).toBe(123); + }); + + it("derives a live cache touch timestamp for final afterTurn usage snapshots", () => { + expect( + resolvePromptCacheTouchTimestamp({ + lastCallUsage: { input: 1, output: 2, cacheRead: 39036, cacheWrite: 0, total: 39039 }, + assistantTimestamp: "2026-04-16T17:04:46.974Z", + fallbackLastCacheTouchAt: 123, + }), + ).toBe(Date.parse("2026-04-16T17:04:46.974Z")); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index 3f975fac7daf..f22e3a3734de 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -11,29 +11,16 @@ import { createSessionEntryWithTranscript, } from "../../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../../config/types.js"; -import { buildMemorySystemPromptAddition } from "../../../context-engine/delegate.js"; -import { - clearMemoryPluginState, - registerTestMemoryPromptBuilder, -} from "../../../plugins/memory-state.test-fixtures.js"; +import { clearMemoryPluginState } from "../../../plugins/memory-state.test-fixtures.js"; import { createUserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.js"; import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js"; import { makeAgentAssistantMessage } from "../../test-helpers/agent-message-fixtures.js"; -import { - type AttemptContextEngine, - buildLoopPromptCacheInfo, - assembleAttemptContextEngine, - buildContextEnginePromptCacheInfo, - finalizeAttemptContextEngineTurn, - resolvePromptCacheTouchTimestamp, - runAttemptContextEngineBootstrap, -} from "./attempt-context-engine-helpers.js"; +import type { AttemptContextEngine } from "./attempt-context-engine-helpers.js"; import { cleanupTempPaths, createDefaultEmbeddedSession, createContextEngineBootstrapAndAssemble, createContextEngineAttemptRunner, - expectCalledWithSessionKey, getHoisted, preloadRunEmbeddedAttemptForTests, resetEmbeddedAttemptHarness, @@ -43,14 +30,12 @@ import type { MidTurnPrecheckRequest } from "./midturn-precheck.js"; const hoisted = getHoisted(); const embeddedSessionId = "embedded-session"; -const sessionFile = "/tmp/session.jsonl"; const seedMessage = { role: "user", content: "seed", timestamp: 1 } as AgentMessage; const doneMessage = { role: "assistant", content: "done", timestamp: 2 } as unknown as AgentMessage; beforeAll(async () => { await preloadRunEmbeddedAttemptForTests(); }); -type AfterTurnPromptCacheCall = { runtimeContext?: { promptCache?: Record } }; type TrajectoryEvent = { type?: string; data?: Record }; type ToolResultGuardInstallParams = { midTurnPrecheck?: { @@ -156,67 +141,6 @@ function createTestContextEngine(params: Partial): Attempt } as AttemptContextEngine; } -async function runBootstrap( - sessionKey: string, - contextEngine: AttemptContextEngine, - overrides: Partial[0]> = {}, -) { - // Shared bootstrap harness keeps session identifiers stable across context - // engine implementations. - await runAttemptContextEngineBootstrap({ - hadSessionFile: true, - contextEngine, - sessionId: embeddedSessionId, - sessionKey, - sessionFile, - sessionManager: hoisted.sessionManager, - runtimeContext: {}, - runMaintenance: hoisted.runContextEngineMaintenanceMock, - warn: () => {}, - ...overrides, - }); -} - -async function runAssemble( - sessionKey: string, - contextEngine: AttemptContextEngine, - overrides: Partial[0]> = {}, -) { - return await assembleAttemptContextEngine({ - contextEngine, - sessionId: embeddedSessionId, - sessionKey, - messages: [seedMessage], - tokenBudget: 2048, - modelId: "gpt-test", - ...overrides, - }); -} - -async function finalizeTurn( - sessionKey: string, - contextEngine: AttemptContextEngine, - overrides: Partial[0]> = {}, -) { - await finalizeAttemptContextEngineTurn({ - contextEngine, - promptError: false, - aborted: false, - yieldAborted: false, - sessionIdUsed: embeddedSessionId, - sessionKey, - sessionFile, - messagesSnapshot: [doneMessage], - prePromptMessageCount: 0, - tokenBudget: 2048, - runtimeContext: {}, - runMaintenance: hoisted.runContextEngineMaintenanceMock, - sessionManager: hoisted.sessionManager, - warn: () => {}, - ...overrides, - }); -} - describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { const sessionKey = "agent:main:guildchat:channel:test-ctx-engine"; const tempPaths: string[] = []; @@ -2678,24 +2602,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(events.slice(0, afterTurnIndex)).toContain("flush"); }); - it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {}); - const contextEngine = createTestContextEngine({ - bootstrap, - assemble, - afterTurn, - }); - - await runBootstrap(sessionKey, contextEngine); - await runAssemble(sessionKey, contextEngine); - await finalizeTurn(sessionKey, contextEngine); - - expectCalledWithSessionKey(bootstrap, sessionKey); - expectCalledWithSessionKey(assemble, sessionKey); - expectCalledWithSessionKey(afterTurn, sessionKey); - }); - it("uses SQLite transcript messages for bootstrap without treating the marker as a file", async () => { const storeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-sqlite-")); tempPaths.push(storeDir); @@ -2753,101 +2659,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(bootstrap).toHaveBeenCalled(); }); - it("forwards modelId to assemble", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const contextEngine = createTestContextEngine({ bootstrap, assemble }); - - await runBootstrap(sessionKey, contextEngine); - await runAssemble(sessionKey, contextEngine); - - expect(mockParams(assemble as MockCallSource, 0, "assemble params").model).toBe("gpt-test"); - }); - - it("forwards availableTools and citationsMode to assemble", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const contextEngine = createTestContextEngine({ bootstrap, assemble }); - - await runBootstrap(sessionKey, contextEngine); - await runAssemble(sessionKey, contextEngine, { - availableTools: new Set(["memory_search", "wiki_search"]), - citationsMode: "on", - }); - - expectFields(mockParams(assemble as MockCallSource, 0, "assemble params"), { - availableTools: new Set(["memory_search", "wiki_search"]), - citationsMode: "on", - }); - }); - - it("lets non-legacy engines opt into the active memory prompt helper", async () => { - registerTestMemoryPromptBuilder(({ availableTools, citationsMode }) => { - if (!availableTools.has("memory_search")) { - return []; - } - return [ - "## Memory Recall", - `tools=${[...availableTools].toSorted().join(",")}`, - `citations=${citationsMode ?? "auto"}`, - "", - ]; - }); - - const contextEngine = createTestContextEngine({ - assemble: async ({ messages, availableTools, citationsMode }) => ({ - messages, - estimatedTokens: messages.length, - systemPromptAddition: buildMemorySystemPromptAddition({ - availableTools: availableTools ?? new Set(), - citationsMode, - }), - }), - }); - - const result = await runAssemble(sessionKey, contextEngine, { - availableTools: new Set(["wiki_search", "memory_search"]), - citationsMode: "on", - }); - - const assembled = requireRecord(result, "assembled context"); - expect(assembled.estimatedTokens).toBe(1); - expect(assembled.systemPromptAddition).toBe( - "## Memory Recall\ntools=memory_search,wiki_search\ncitations=on", - ); - }); - - 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 }), - ); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, ingestBatch }), { - messagesSnapshot: [seedMessage, doneMessage], - prePromptMessageCount: 1, - }); - - 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, - })); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, ingest }), { - messagesSnapshot: [seedMessage, doneMessage], - prePromptMessageCount: 1, - }); - - expect(ingest).toHaveBeenCalledTimes(1); - expect(ingest).toHaveBeenCalledWith({ - message: doneMessage, - sessionId: embeddedSessionId, - sessionKey, - }); - }); - it("forwards silentExpected to the embedded subscription", async () => { await createContextEngineAttemptRunner({ contextEngine: createContextEngineBootstrapAndAssemble(), @@ -2904,247 +2715,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(result.didDeliverSourceReplyViaMessageTool).toBe(true); }); - it("skips maintenance when afterTurn fails", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const afterTurn = vi.fn(async () => { - throw new Error("afterTurn failed"); - }); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, afterTurn })); - - expectCalledWithSessionKey(afterTurn, sessionKey); - expect( - hoisted.runContextEngineMaintenanceMock.mock.calls.some( - ([params]) => requireRecord(params, "maintenance params").reason === "turn", - ), - ).toBe(false); - }); - - it("runs startup maintenance for existing sessions even without bootstrap()", async () => { - const { assemble } = createContextEngineBootstrapAndAssemble(); - - await runBootstrap( - sessionKey, - createTestContextEngine({ - assemble, - maintain: async () => ({ - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "test maintenance", - }), - }), - ); - - expect( - hoisted.runContextEngineMaintenanceMock.mock.calls.some( - ([params]) => requireRecord(params, "maintenance params").reason === "bootstrap", - ), - ).toBe(true); - }); - - it("builds prompt-cache retention, last-call usage, and cache-touch metadata", () => { - expect( - buildContextEnginePromptCacheInfo({ - retention: "short", - lastCallUsage: { - input: 10, - output: 5, - cacheRead: 40, - cacheWrite: 2, - total: 57, - }, - lastCacheTouchAt: 123, - }), - ).toEqual({ - retention: "short", - lastCallUsage: { - input: 10, - output: 5, - cacheRead: 40, - cacheWrite: 2, - total: 57, - }, - lastCacheTouchAt: 123, - }); - }); - - it("omits prompt-cache metadata when no cache data is available", () => { - expect(buildContextEnginePromptCacheInfo({})).toBeUndefined(); - }); - - it("does not reuse a prior turn's usage when the current attempt has no assistant", () => { - const priorAssistant = { - role: "assistant", - content: "prior turn", - timestamp: 2, - usage: { - input: 99, - output: 7, - cacheRead: 1234, - total: 1340, - }, - } as unknown as AgentMessage; - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, priorAssistant], - prePromptMessageCount: 2, - retention: "short", - }); - - expect(promptCache).toEqual({ retention: "short" }); - }); - - it("derives live loop prompt-cache info from the current attempt assistant", () => { - const toolUseAssistant = { - role: "assistant", - content: "tool use", - timestamp: "2026-04-16T16:49:59.536Z", - usage: { - input: 1, - output: 2, - cacheRead: 39036, - cacheWrite: 59934, - total: 98973, - }, - } as unknown as AgentMessage; - - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, toolUseAssistant], - prePromptMessageCount: 1, - retention: "short", - fallbackLastCacheTouchAt: 123, - }); - expect(promptCache?.retention).toBe("short"); - expect(promptCache?.lastCallUsage?.cacheRead).toBe(39036); - expect(promptCache?.lastCallUsage?.cacheWrite).toBe(59934); - expect(promptCache?.lastCallUsage?.total).toBe(98973); - expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); - }); - - it("keeps the latest nonzero usage when an aborted assistant reports zeros", () => { - const completedAssistant = { - role: "assistant", - content: "tool use", - timestamp: "2026-04-16T16:49:59.536Z", - usage: { input: 38_333, output: 66, cacheRead: 120_320, total: 158_719 }, - } as unknown as AgentMessage; - const abortedAssistant = { - role: "assistant", - content: "", - timestamp: "2026-04-16T16:50:00.000Z", - stopReason: "aborted", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - } as unknown as AgentMessage; - - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, completedAssistant, abortedAssistant], - prePromptMessageCount: 1, - retention: "short", - }); - - expect(promptCache?.lastCallUsage).toMatchObject({ - input: 38_333, - cacheRead: 120_320, - total: 158_719, - }); - expect(promptCache?.lastCacheTouchAt).toBe(Date.parse("2026-04-16T16:49:59.536Z")); - }); - - it("falls back to the persisted cache touch when loop usage has no cache metrics", () => { - const toolUseAssistant = { - role: "assistant", - content: "tool use", - timestamp: "2026-04-16T16:49:59.536Z", - usage: { - input: 1, - output: 2, - total: 3, - }, - } as unknown as AgentMessage; - - const promptCache = buildLoopPromptCacheInfo({ - messagesSnapshot: [seedMessage, toolUseAssistant], - prePromptMessageCount: 1, - retention: "short", - fallbackLastCacheTouchAt: 123, - }); - expect(promptCache?.retention).toBe("short"); - expect(promptCache?.lastCallUsage?.total).toBe(3); - expect(promptCache?.lastCacheTouchAt).toBe(123); - }); - - it("derives a live cache touch timestamp for final afterTurn usage snapshots", () => { - const lastCallUsage = { - input: 1, - output: 2, - cacheRead: 39036, - cacheWrite: 0, - total: 39039, - }; - - expect( - resolvePromptCacheTouchTimestamp({ - lastCallUsage, - assistantTimestamp: "2026-04-16T17:04:46.974Z", - fallbackLastCacheTouchAt: 123, - }), - ).toBe(Date.parse("2026-04-16T17:04:46.974Z")); - }); - - it("threads prompt-cache break observations into afterTurn", async () => { - const afterTurn = vi.fn(async (_params: AfterTurnPromptCacheCall) => {}); - - await finalizeTurn(sessionKey, createTestContextEngine({ afterTurn }), { - runtimeContext: { - promptCache: { - observation: { - broke: true, - previousCacheRead: 5000, - cacheRead: 2000, - changes: [{ code: "systemPrompt", detail: "system prompt digest changed" }], - }, - }, - }, - }); - - const afterTurnCall = afterTurn.mock.calls.at(0)?.[0]; - const runtimeContext = afterTurnCall?.runtimeContext; - const observation = runtimeContext?.promptCache?.observation as - | { broke?: boolean; previousCacheRead?: number; cacheRead?: number; changes?: unknown[] } - | undefined; - - const observationRecord = requireRecord(observation, "prompt cache observation"); - expectFields(observationRecord, { - broke: true, - previousCacheRead: 5000, - cacheRead: 2000, - }); - expect( - requireRecords(observationRecord.changes, "prompt cache observation changes").some( - (change) => change.code === "systemPrompt", - ), - ).toBe(true); - }); - - it("skips maintenance when ingestBatch fails", async () => { - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - const ingestBatch = vi.fn(async () => { - throw new Error("ingestBatch failed"); - }); - - await finalizeTurn(sessionKey, createTestContextEngine({ bootstrap, assemble, ingestBatch }), { - messagesSnapshot: [seedMessage, doneMessage], - prePromptMessageCount: 1, - }); - - expectCalledWithSessionKey(ingestBatch, sessionKey); - expect( - hoisted.runContextEngineMaintenanceMock.mock.calls.some( - ([params]) => requireRecord(params, "maintenance params").reason === "turn", - ), - ).toBe(false); - }); - it("disposes the session even when teardown cleanup throws", async () => { const disposeMock = vi.fn(); const flushMock = vi.fn(async () => { diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts index a7b58fbd450c..05308177d301 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -4,10 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { filterHeartbeatTranscriptArtifacts } from "../../../auto-reply/heartbeat-filter.js"; import { HEARTBEAT_PROMPT } from "../../../auto-reply/heartbeat.js"; import type { BootstrapContextRunKind } from "../../bootstrap-mode.js"; +import { assembleHarnessContextEngine } from "../../harness/context-engine-lifecycle.js"; import { limitHistoryTurns } from "../history.js"; import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; import { - assembleAttemptContextEngine, type AttemptContextEngine, resolveAttemptBootstrapContext, } from "./attempt-context-engine-helpers.js"; @@ -232,7 +232,7 @@ describe("embedded attempt context injection", () => { HEARTBEAT_PROMPT, ); const limited = limitHistoryTurns(heartbeatFiltered, 1); - await assembleAttemptContextEngine({ + await assembleHarnessContextEngine({ contextEngine: { info: { id: "test", name: "Test", version: "0.0.1" }, ingest: async () => ({ ingested: true }), diff --git a/src/agents/embedded-agent-runner/run/compaction-runtime.ts b/src/agents/embedded-agent-runner/run/compaction-runtime.ts index cc3289924367..c54cd0ea4802 100644 --- a/src/agents/embedded-agent-runner/run/compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/run/compaction-runtime.ts @@ -4,8 +4,8 @@ import { resolveCompactionSuccessorTranscript, type ContextEngineSessionTarget, } from "../../../context-engine/types.js"; -import { resolveProcessToolScopeKey } from "../../agent-tools.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; +import { resolveProcessToolScopeKey } from "../../bash-process-scope.js"; import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { compactContextEngineWithSafetyTimeout, diff --git a/src/agents/harness/context-engine-lifecycle.test.ts b/src/agents/harness/context-engine-lifecycle.test.ts index c48ecc35ad15..ef1bcb042c08 100644 --- a/src/agents/harness/context-engine-lifecycle.test.ts +++ b/src/agents/harness/context-engine-lifecycle.test.ts @@ -86,6 +86,52 @@ function uniqueConfiguredProofEngineId() { } describe("harness context engine lifecycle", () => { + it("forwards session keys across bootstrap, assemble, and afterTurn hooks", async () => { + const bootstrap = vi.fn(async () => ({ bootstrapped: true })); + const assemble = vi.fn(async (params: Parameters[0]) => ({ + messages: params.messages, + estimatedTokens: 0, + })); + const afterTurn = vi.fn(async () => {}); + const contextEngine = createContextEngine({ bootstrap, assemble, afterTurn }); + + await bootstrapHarnessContextEngine({ + hadSessionFile: true, + contextEngine, + sessionId: sessionParams.sessionId, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + runMaintenance: async () => undefined, + warn: () => {}, + }); + await assembleHarnessContextEngine({ + contextEngine, + sessionId: sessionParams.sessionId, + sessionKey: sessionParams.sessionKey, + messages: [textMessage("user", "ask", 1)], + modelId: "gpt-test", + }); + await finalizeHarnessContextEngineTurn({ + contextEngine, + promptError: false, + aborted: false, + yieldAborted: false, + sessionIdUsed: sessionParams.sessionIdUsed, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + messagesSnapshot: [textMessage("assistant", "done", 2)], + prePromptMessageCount: 0, + runMaintenance: async () => undefined, + warn: () => {}, + }); + + for (const hook of [bootstrap, assemble, afterTurn]) { + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey: sessionParams.sessionKey }), + ); + } + }); + it("scopes async memory preparation to non-legacy assembly with sandbox context", async () => { const prepare = vi.fn(async ({ sandboxed }) => [ "## Prepared Memory", @@ -212,7 +258,15 @@ describe("harness context engine lifecycle", () => { const bootstrapRuntimeContext = { transcriptStorage: { kind: "sqlite" as const }, sessionTarget, - }; + promptCache: { + observation: { + broke: true, + previousCacheRead: 5000, + cacheRead: 2000, + changes: [{ code: "systemPrompt", detail: "system prompt digest changed" }], + }, + }, + } satisfies ContextEngineRuntimeContext; const engine = createContextEngine({ info: { id: engineId, @@ -238,6 +292,7 @@ describe("harness context engine lifecycle", () => { afterTurn: vi.fn(async (params) => { captured.push({ hook: "afterTurn", + runtimeContext: params.runtimeContext, runtimeSettings: params.runtimeSettings, sessionTarget: params.sessionTarget, }); @@ -282,6 +337,7 @@ describe("harness context engine lifecycle", () => { sessionKey: sessionParams.sessionKey, messages: [textMessage("user", "visible ask", 1)], tokenBudget: 2048, + runtimeContext: bootstrapRuntimeContext, providerId: "openai", requestedModelId: "openai/gpt-5.5", modelId: "anthropic/claude-sonnet-4-6", @@ -305,6 +361,7 @@ describe("harness context engine lifecycle", () => { ], prePromptMessageCount: 2, tokenBudget: 2048, + runtimeContext: bootstrapRuntimeContext, providerId: "openai", requestedModelId: "openai/gpt-5.5", modelId: "anthropic/claude-sonnet-4-6", @@ -349,6 +406,9 @@ describe("harness context engine lifecycle", () => { expect(captured.find((entry) => entry.hook === "afterTurn")?.sessionTarget).toEqual( sessionTarget, ); + expect(captured.find((entry) => entry.hook === "afterTurn")?.runtimeContext).toEqual( + bootstrapRuntimeContext, + ); expect(captured.find((entry) => entry.hook === "maintain")?.sessionTarget).toEqual( sessionTarget, ); @@ -555,10 +615,11 @@ describe("harness context engine lifecycle", () => { const ingestBatchCalls = (ingestBatch as unknown as { mock: { calls: unknown[][] } }).mock .calls; const ingestBatchParams = ingestBatchCalls[0]?.[0] as - | { isHeartbeat?: boolean; messages?: AgentMessage[] } + | { isHeartbeat?: boolean; messages?: AgentMessage[]; sessionKey?: string } | undefined; expect(ingestBatchParams?.messages).toEqual([turnUser, turnAssistant]); expect(ingestBatchParams?.isHeartbeat).toBe(true); + expect(ingestBatchParams?.sessionKey).toBe(sessionParams.sessionKey); }); it("forwards heartbeat state to per-message ingest fallbacks", async () => { @@ -586,11 +647,77 @@ describe("harness context engine lifecycle", () => { const ingestCalls = (ingest as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(ingestCalls).toHaveLength(2); for (const call of ingestCalls) { - const ingestParams = call[0] as { isHeartbeat?: boolean }; + const ingestParams = call[0] as { isHeartbeat?: boolean; sessionKey?: string }; expect(ingestParams.isHeartbeat).toBe(true); + expect(ingestParams.sessionKey).toBe(sessionParams.sessionKey); } }); + it.each(["afterTurn", "ingestBatch"] as const)( + "skips turn maintenance when %s fails", + async (failingHook) => { + const runMaintenance = vi.fn(async () => undefined); + const contextEngine = createContextEngine({ + afterTurn: + failingHook === "afterTurn" + ? vi.fn(async () => { + throw new Error("afterTurn failed"); + }) + : undefined, + ingestBatch: + failingHook === "ingestBatch" + ? vi.fn(async () => { + throw new Error("ingestBatch failed"); + }) + : undefined, + }); + + await finalizeHarnessContextEngineTurn({ + contextEngine, + promptError: false, + aborted: false, + yieldAborted: false, + sessionIdUsed: sessionParams.sessionIdUsed, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + messagesSnapshot: [textMessage("assistant", "done", 1)], + prePromptMessageCount: 0, + runMaintenance, + warn: () => {}, + }); + + expect(runMaintenance).not.toHaveBeenCalled(); + }, + ); + + it("runs bootstrap maintenance for existing sessions without bootstrap()", async () => { + const runMaintenance = vi.fn(async () => undefined); + + await bootstrapHarnessContextEngine({ + hadSessionFile: true, + contextEngine: createContextEngine({ + bootstrap: undefined, + maintain: vi.fn(async () => ({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + })), + }), + sessionId: sessionParams.sessionId, + sessionKey: sessionParams.sessionKey, + sessionFile: sessionParams.sessionFile, + runMaintenance, + warn: () => {}, + }); + + expect(runMaintenance).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "bootstrap", + sessionKey: sessionParams.sessionKey, + }), + ); + }); + it.each([ { promptError: true, aborted: false, yieldAborted: false }, { promptError: false, aborted: true, yieldAborted: false }, diff --git a/test/scripts/run-vitest.test.ts b/test/scripts/run-vitest.test.ts index b6e077bfabed..9bbc06a69326 100644 --- a/test/scripts/run-vitest.test.ts +++ b/test/scripts/run-vitest.test.ts @@ -348,11 +348,11 @@ describe("scripts/run-vitest", () => { [["run", file], [file]], [ ["run", file, "--reporter=verbose"], - [file, "--reporter=verbose"], + [file, "--", "--reporter=verbose"], ], [ ["--reporter=verbose", "run", file], - ["--reporter=verbose", file], + [file, "--", "--reporter=verbose"], ], [ ["run", file, "--", "--watch"], @@ -373,6 +373,7 @@ describe("scripts/run-vitest", () => { expect(resolveTestProjectsDelegationArgs([file])).toEqual([file]); expect(resolveTestProjectsDelegationArgs(["run", file, "--reporter=verbose"])).toEqual([ file, + "--", "--reporter=verbose", ]); }); @@ -381,7 +382,7 @@ describe("scripts/run-vitest", () => { expect(resolveTestProjectsDelegationArgs(["test/scripts"])).toEqual(["test/scripts"]); expect( resolveTestProjectsDelegationArgs(["run", "test/scripts", "--reporter=verbose"]), - ).toEqual(["test/scripts", "--reporter=verbose"]); + ).toEqual(["test/scripts", "--", "--reporter=verbose"]); expect(resolveTestProjectsDelegationArgs(["test/scripts/*.test.ts"])).toEqual([ "test/scripts/*.test.ts", ]); @@ -392,6 +393,15 @@ describe("scripts/run-vitest", () => { expect(resolveTestProjectsDelegationArgs([prefix])).toEqual([prefix]); }); + it("delegates owned agent directories with separate Vitest option values", () => { + const directory = "src/agents/embedded-agent-runner/run"; + + expect(resolveTestProjectsDelegationArgs([directory])).toEqual([directory]); + expect( + resolveTestProjectsDelegationArgs([directory, "--sequence.shuffle", "--sequence.seed", "3"]), + ).toEqual([directory, "--", "--sequence.shuffle", "--sequence.seed", "3"]); + }); + it("delegates mixed filters when an explicit file target is present", () => { expect( resolveTestProjectsDelegationArgs(["src/agents", "test/scripts/run-vitest.test.ts"]), @@ -420,15 +430,29 @@ describe("scripts/run-vitest", () => { ["--run=false", "test/scripts/run-vitest.test.ts"], ["--no-run", "test/scripts/run-vitest.test.ts"], ["--run", "false", "test/scripts/run-vitest.test.ts"], - ["--diff", "scripts/run-vitest.mjs"], - ["--testNamePattern", "run", "test/scripts/run-vitest.test.ts"], - ["run", "test/scripts/run-vitest.test.ts", "-t", "src"], ]; for (const argv of directArgvCases) { expect(resolveTestProjectsDelegationArgs(argv)).toBeNull(); } }); + it.each([ + [ + ["--diff", "scripts/run-vitest.mjs", "test/scripts/run-vitest.test.ts"], + ["test/scripts/run-vitest.test.ts", "--", "--diff", "scripts/run-vitest.mjs"], + ], + [ + ["--testNamePattern", "run", "test/scripts/run-vitest.test.ts"], + ["test/scripts/run-vitest.test.ts", "--", "--testNamePattern", "run"], + ], + [ + ["run", "test/scripts/run-vitest.test.ts", "-t", "src"], + ["test/scripts/run-vitest.test.ts", "--", "-t", "src"], + ], + ])("keeps option value %j out of project target classification", (argv, expected) => { + expect(resolveTestProjectsDelegationArgs(argv)).toEqual(expected); + }); + it("reports missing explicit test files before Vitest can silently ignore them", () => { const fsImpl = { existsSync: (filePath: string) => diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 627ebd8ae902..d1366894e5c2 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1048,21 +1048,29 @@ describe("scripts/test-projects changed-target routing", () => { ["src/agents/runtime-plan", "test/vitest/vitest.agents-support.config.ts"], ["src/agents/tools", "test/vitest/vitest.agents-tools.config.ts"], ])("routes focused agent directory %s to its owning shard", (directory, config) => { - const plans = buildVitestRunPlans([directory]); + expect(buildVitestRunPlans([directory])).toEqual([ + { + config, + forwardedArgs: [directory], + includePatterns: null, + watchMode: false, + }, + ]); + }); - expect(plans).toEqual( - expect.arrayContaining([ - { - config, - forwardedArgs: [], - includePatterns: [`${directory}/**/*.test.ts`], - watchMode: false, - }, - ]), - ); - expect(plans.map((plan) => plan.config)).not.toContain( - "test/vitest/vitest.agents-core.config.ts", - ); + it("keeps shuffle options on the single owning embedded-run shard", () => { + const directory = "src/agents/embedded-agent-runner/run"; + + expect( + buildVitestRunPlans([directory, "--", "--sequence.shuffle", "--sequence.seed", "3"]), + ).toEqual([ + { + config: "test/vitest/vitest.agents-embedded-agent-run.config.ts", + forwardedArgs: ["--sequence.shuffle", "--sequence.seed", "3", directory], + includePatterns: null, + watchMode: false, + }, + ]); }); it("splits the embedded-agent parent directory across every isolated harness", () => {