fix(test): route agent directory runs to owning shard (#122514)

* fix(test): route agent directories to owner projects

* fix(test): preserve invalid signal diagnostics
This commit is contained in:
Peter Steinberger
2026-08-12 00:44:49 -07:00
committed by GitHub
parent af3550df73
commit 94c28e093d
21 changed files with 466 additions and 568 deletions
+54 -35
View File
@@ -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,
+18
View File
@@ -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) =>
+1 -23
View File
@@ -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?: {
+44
View File
@@ -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);
});
});
+22
View File
@@ -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;
}
@@ -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", () => ({
@@ -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,
@@ -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<TBootstrapFile = unknown, TContextFile = unknown> = {
@@ -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,
@@ -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,
@@ -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 {
@@ -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,
@@ -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<typeof vi.fn>, sessionKey: string) {
expect(mock).toHaveBeenCalledWith(expect.objectContaining({ sessionKey }));
}
const testModel = {
api: "openai-completions",
provider: "openai",
@@ -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,
@@ -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"));
});
});
@@ -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<string, unknown> } };
type TrajectoryEvent = { type?: string; data?: Record<string, unknown> };
type ToolResultGuardInstallParams = {
midTurnPrecheck?: {
@@ -156,67 +141,6 @@ function createTestContextEngine(params: Partial<AttemptContextEngine>): Attempt
} as AttemptContextEngine;
}
async function runBootstrap(
sessionKey: string,
contextEngine: AttemptContextEngine,
overrides: Partial<Parameters<typeof runAttemptContextEngineBootstrap>[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<Parameters<typeof assembleAttemptContextEngine>[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<Parameters<typeof finalizeAttemptContextEngineTurn>[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 () => {
@@ -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 }),
@@ -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,
@@ -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<ContextEngine["assemble"]>[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 },
+30 -6
View File
@@ -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) =>
+19 -11
View File
@@ -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(plans).toEqual(
expect.arrayContaining([
expect(buildVitestRunPlans([directory])).toEqual([
{
config,
forwardedArgs: [],
includePatterns: [`${directory}/**/*.test.ts`],
forwardedArgs: [directory],
includePatterns: null,
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", () => {