fix: stop blocked tool loops stalling until timeout (#114673)

* fix(agents): make global loop breaker reachable

Record loop-detector vetoes as typed no-progress outcomes so repeated blocked calls continue the existing streak without colliding with plugin or approval denials. Extract streak accounting into its own owner module and keep completed veto records out of argument reconciliation.\n\nFixes #109435.

* test: harden isolated project routing

Route registry-sensitive UI tests through the isolated project for both focused and broad runs, centralize the isolated file list, and register the Codex prewarm test in the full extension shard.

* test(qa): prove global loop breaker runtime

Drive 31 identical read attempts through the real QA Gateway agent loop and verify the typed veto streak reaches the global circuit breaker before the turn returns a final marker.

* test: keep isolated UI files out of shared runs

Always exclude registry-sensitive files from the shared UI project and reject broad watch targets that would span shared and isolated projects.
This commit is contained in:
Peter Steinberger
2026-07-27 16:02:28 -04:00
committed by GitHub
parent 9c7d83117d
commit f0371e8d88
15 changed files with 394 additions and 86 deletions
@@ -167,6 +167,7 @@ export const QA_FINAL_ONLY_MARKER_STREAMING_PROMPT_RE = /final-only marker strea
export const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
export const QA_TOOL_PROGRESS_ERROR_PROMPT_RE = /tool progress error qa check/i;
export const QA_TOOL_PROGRESS_PROMPT_RE = /tool progress qa check/i;
export const QA_TOOL_LOOP_GLOBAL_BREAKER_PROMPT_RE = /global tool loop breaker qa check/i;
export const QA_PROVIDER_HTTP_503_AFTER_TOOL_PROMPT_RE = /provider http 503 after tool qa check/i;
export const QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE = /qa group visible reply tool check/i;
export const QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE = /qa a2a message-tool mirror check/i;
@@ -260,6 +261,7 @@ export type MockScenarioState = {
anthropicThinkingErrorPhase: number;
subagentFanoutPhase: number;
subagentHandoffSpawned: boolean;
toolLoopReadAttempts: number;
};
export function sourceDiscoveryReadPathForProvider(providerVariant: MockOpenAiProviderVariant) {
@@ -27,6 +27,7 @@ import {
QA_BLOCK_STREAMING_PROMPT_RE,
QA_TOOL_PROGRESS_ERROR_PROMPT_RE,
QA_TOOL_PROGRESS_PROMPT_RE,
QA_TOOL_LOOP_GLOBAL_BREAKER_PROMPT_RE,
QA_PROVIDER_HTTP_503_AFTER_TOOL_PROMPT_RE,
QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE,
QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE,
@@ -224,6 +225,19 @@ async function buildResponsesPayload(
const command = execCommandFromToolProgressPrompt(toolProgressPrompt || prompt || allInputText);
return command ? buildToolCallEventsWithArgs("exec", { command }) : null;
};
if (QA_TOOL_LOOP_GLOBAL_BREAKER_PROMPT_RE.test(allInputText)) {
if (!toolOutput) {
scenarioState.toolLoopReadAttempts = 0;
}
if (/global circuit breaker/i.test(toolOutput)) {
return buildAssistantEvents(exactReplyDirective ?? "GLOBAL-LOOP-BREAKER-OK");
}
scenarioState.toolLoopReadAttempts += 1;
if (scenarioState.toolLoopReadAttempts > 31) {
return buildAssistantEvents("GLOBAL-LOOP-BREAKER-NOT-REACHED");
}
return buildToolCallEventsWithArgs("read", { path: "LOOP_STEADY.txt" });
}
if (
(QA_TOOL_SEARCH_PROMPT_RE.test(allInputText) ||
QA_TOOL_SEARCH_FAILURE_PROMPT_RE.test(allInputText)) &&
@@ -1309,6 +1323,7 @@ export async function startQaMockOpenAiServer(params?: {
anthropicThinkingErrorPhase: 0,
subagentFanoutPhase: 0,
subagentHandoffSpawned: false,
toolLoopReadAttempts: 0,
};
let lastRequest: MockOpenAiRequestSnapshot | null = null;
const requests: MockOpenAiRequestSnapshot[] = [];
@@ -0,0 +1,111 @@
title: Tool-loop global circuit breaker
scenario:
id: tool-loop-global-breaker
surface: runtime
coverage:
primary:
- automation.no-progress-loop-detection
secondary:
- agent-runtime.failure-recovery-retry-policy
gatewayConfigPatch:
tools:
loopDetection:
enabled: true
objective: Verify repeated critical tool-loop vetoes advance through the real agent loop to the global circuit breaker instead of stalling until the turn timeout.
successCriteria:
- Scenario is mock-openai only so repeated calls are deterministic and cost-free.
- The real Gateway agent loop admits 20 identical reads, then records typed loop vetoes while the model keeps retrying.
- The 31st read attempt is rejected by the global circuit breaker and the Gateway logs that terminal detector.
- The model receives the global-breaker result and returns the exact final marker before the turn timeout.
docsRefs:
- docs/tools/loop-detection.md
- docs/help/testing.md
codeRefs:
- src/agents/tool-loop-detection.ts
- src/agents/tool-loop-no-progress.ts
- src/agents/agent-tools.before-tool-call.policy.ts
- extensions/qa-lab/src/providers/mock-openai/server.ts
execution:
kind: flow
summary: Drive repeated identical reads through the real Gateway agent loop until typed vetoes reach the global circuit breaker.
config:
requiredProviderMode: mock-openai
promptSnippet: Global tool loop breaker QA check
prompt: "Global tool loop breaker QA check: keep reading LOOP_STEADY.txt until the runtime blocks the loop globally, then reply with exactly GLOBAL-LOOP-BREAKER-OK."
expectedReply: GLOBAL-LOOP-BREAKER-OK
expectedReadAttempts: 31
breakerLogNeedle: Global circuit breaker triggered
fixtureFile: LOOP_STEADY.txt
flow:
steps:
- name: reaches the global breaker and finishes the agent turn
actions:
- assert:
expr: "env.providerMode === config.requiredProviderMode"
message: this seeded scenario is mock-openai only
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: reset
- call: fs.writeFile
args:
- expr: "path.join(env.gateway.workspaceDir, config.fixtureFile)"
- steady loop output
- utf8
- set: logCursor
value:
expr: markGatewayLogCursor()
- set: requestCursorBefore
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor"
- set: sessionKey
value:
expr: "`agent:qa:tool-loop-breaker:${randomUUID().slice(0, 8)}`"
- call: runAgentPrompt
args:
- ref: env
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 120000)
- call: waitForOutboundMessage
saveAs: outbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'qa-operator' && candidate.text.includes(config.expectedReply)"
- expr: liveTurnTimeoutMs(env, 30000)
- set: transcript
value:
expr: "await readSessionTranscriptSummary(env, sessionKey)"
- set: scenarioRequests
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBefore}`)).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet))"
- set: readRequests
value:
expr: "scenarioRequests.filter((request) => request.plannedToolName === 'read')"
- set: breakerLog
value:
expr: "String(readGatewayLogs() ?? '').slice(logCursor)"
- set: breakerLine
value:
expr: "(breakerLog.split('\\n').find((line) => line.includes(config.breakerLogNeedle)) ?? '').trim()"
- assert:
expr: "outbound.text.includes(config.expectedReply) && transcript.finalText.includes(config.expectedReply)"
message:
expr: "`agent turn did not finish with ${config.expectedReply}; outbound=${outbound.text} final=${transcript.finalText}`"
- assert:
expr: "readRequests.length === config.expectedReadAttempts && transcript.assistantToolCallCounts.read === config.expectedReadAttempts"
message:
expr: "`expected ${config.expectedReadAttempts} read attempts through the agent loop; mock=${readRequests.length} transcript=${String(transcript.assistantToolCallCounts.read ?? 0)}`"
- assert:
expr: "breakerLog.includes(config.breakerLogNeedle)"
message:
expr: "`expected Gateway log containing ${config.breakerLogNeedle}`"
detailsExpr: "`status=pass reads=${readRequests.length} final=${transcript.finalText.trim()} breaker=${breakerLine}`"
+25
View File
@@ -53,6 +53,10 @@ import {
isToolingIsolatedTestFile,
toolingIsolatedTestFiles,
} from "../test/vitest/vitest.tooling-isolated-paths.mjs";
import {
isUiIsolatedTestFile,
uiIsolatedTestFiles,
} from "../test/vitest/vitest.ui-isolated-paths.mjs";
import {
getUnitFastIsolatedTestFiles,
getUnitFastTestFiles,
@@ -314,6 +318,7 @@ const TUI_VITEST_CONFIG = "test/vitest/vitest.tui.config.ts";
const TUI_PTY_VITEST_CONFIG = "test/vitest/vitest.tui-pty.config.ts";
const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts";
const UI_E2E_VITEST_CONFIG = "test/vitest/vitest.ui-e2e.config.ts";
const UI_ISOLATED_VITEST_CONFIG = "test/vitest/vitest.ui-isolated.config.ts";
const UTILS_VITEST_CONFIG = "test/vitest/vitest.utils.config.ts";
const WIZARD_VITEST_CONFIG = "test/vitest/vitest.wizard.config.ts";
const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE";
@@ -409,6 +414,7 @@ const VITEST_CONFIG_BY_KIND = {
tuiPty: TUI_PTY_VITEST_CONFIG,
ui: UI_VITEST_CONFIG,
uiE2e: UI_E2E_VITEST_CONFIG,
uiIsolated: UI_ISOLATED_VITEST_CONFIG,
utils: UTILS_VITEST_CONFIG,
wizard: WIZARD_VITEST_CONFIG,
};
@@ -4009,6 +4015,9 @@ function classifyTarget(arg, cwd) {
if (isControlUiE2eTarget(relative)) {
return "uiE2e";
}
if (isUiIsolatedTestFile(relative)) {
return "uiIsolated";
}
if (isPathAtOrUnder(relative, "ui/src")) {
return "ui";
}
@@ -4405,6 +4414,21 @@ export function buildVitestRunPlans(
}
groupedTargets.set("toolingIsolated", current);
}
const uiTargets = groupedTargets.get("ui") ?? [];
const impliedUiIsolatedTargets = uiIsolatedTestFiles.filter((file) =>
uiTargets.some((targetArg) =>
includePatternMatchesAnyFile(toScopedIncludePattern(targetArg, cwd), [file]),
),
);
if (impliedUiIsolatedTargets.length > 0) {
const current = groupedTargets.get("uiIsolated") ?? [];
for (const target of impliedUiIsolatedTargets) {
if (!current.includes(target)) {
current.push(target);
}
}
groupedTargets.set("uiIsolated", current);
}
if (watchMode && groupedTargets.size > 1) {
throw new Error(
@@ -4467,6 +4491,7 @@ export function buildVitestRunPlans(
"agentsTools",
"plugin",
"ui",
"uiIsolated",
"uiE2e",
"unitSrc",
"unitSecurity",
@@ -916,6 +916,25 @@ describe("before_tool_call loop detection behavior", () => {
});
});
it("escalates repeated critical vetoes to the global circuit breaker", async () => {
await withToolLoopEvents(async (emitted) => {
const { tool, params } = createGenericReadRepeatFixture();
for (let i = 0; i <= 30; i += 1) {
await tool.execute(`read-global-${i}`, params, undefined, undefined);
}
expect(emitted.at(-1)).toMatchObject({
type: "tool.loop",
level: "critical",
action: "block",
detector: "global_circuit_breaker",
count: 30,
toolName: "read",
});
});
});
it("emits structured warning diagnostic events for ping-pong loops", async () => {
await withToolLoopEvents(async (emitted) => {
const { readTool, listTool } = createPingPongTools();
+5 -1
View File
@@ -33,7 +33,11 @@ export function reconcileToolCallExecutionParams(
if (params.toolCallId && call.toolCallId !== params.toolCallId) {
continue;
}
if (call.toolName !== params.toolName || call.resultHash !== undefined) {
if (
call.toolName !== params.toolName ||
call.resultHash !== undefined ||
call.outcomeKind !== undefined
) {
continue;
}
+107 -21
View File
@@ -631,6 +631,33 @@ describe("tool-loop-detection", () => {
expect(reconciled).toEqual({ active: true, count: 6, variantCount: 2 });
});
it("does not reconcile a completed loop veto as a pending call", () => {
const state = createState();
state.toolCallHistory = [
{
toolName: "write",
argsHash: "pending-args",
timestamp: 1,
},
{
toolName: "write",
argsHash: "vetoed-args",
outcomeKind: "tool-loop-veto",
timestamp: 2,
},
];
expect(
reconcileToolCallExecutionParams(state, {
toolName: "write",
toolParams: { path: "/tmp/rewritten.md", content: "same content" },
warningThreshold: 6,
}),
).toEqual({ active: false, count: 0, variantCount: 0 });
expect(state.toolCallHistory[0]?.argsHash).not.toBe("pending-args");
expect(state.toolCallHistory[1]?.argsHash).toBe("vetoed-args");
});
it("keeps completed churn evidence across a pending same-tool sibling", () => {
const state = createState();
const paths = ["/tmp/a.md", "/tmp/b.md", "/tmp/a.md", "/tmp/a.md", "/tmp/b.md"];
@@ -1519,33 +1546,92 @@ describe("tool-loop-detection", () => {
expect(hashes?.[0]).not.toBe(hashes?.[1]);
});
it("keeps a critical send block sticky after the veto result is recorded", () => {
it("counts loop vetoes until the global circuit breaker becomes reachable", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
expect(detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig).stuck).toBe(
true,
);
// The loop veto records a blocked result (buildBlockedToolResult, deniedReason "tool-loop");
// it must not reset the no-progress streak, so the next identical send is still blocked.
recordToolCall(state, "message", params, "message-veto", enabledLoopDetectionConfig);
recordToolCallOutcome(state, {
toolName: "message",
toolParams: params,
toolCallId: "message-veto",
result: {
content: [{ type: "text", text: "blocked" }],
details: { status: "blocked", deniedReason: "tool-loop" },
},
config: enabledLoopDetectionConfig,
});
const after = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(after.stuck).toBe(true);
if (after.stuck) {
expect(after.level).toBe("critical");
for (let i = CRITICAL_THRESHOLD; i < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) {
const before = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(before).toMatchObject({
stuck: true,
level: "critical",
detector: "generic_repeat",
count: i,
});
const recorded = recordToolCallOutcome(state, {
toolName: "message",
toolParams: params,
toolCallId: `message-veto-${i}`,
result: {
content: [{ type: "text", text: "blocked" }],
details: { status: "blocked", deniedReason: "tool-loop" },
},
config: enabledLoopDetectionConfig,
});
expect(recorded).toMatchObject({
toolCallId: `message-veto-${i}`,
outcomeKind: "tool-loop-veto",
resultHash: undefined,
});
}
const after = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(after).toMatchObject({
stuck: true,
level: "critical",
detector: "global_circuit_breaker",
count: GLOBAL_CIRCUIT_BREAKER_THRESHOLD,
});
});
it("does not count unrelated hashless calls as no-progress outcomes", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
for (let i = CRITICAL_THRESHOLD; i < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) {
recordToolCall(state, "message", params, `pending-${i}`, enabledLoopDetectionConfig);
}
expect(
detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig),
).toMatchObject({
stuck: true,
detector: "generic_repeat",
count: CRITICAL_THRESHOLD,
});
});
it("does not carry older loop vetoes across a later progressing outcome", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
for (let i = 0; i < 5; i += 1) {
recordToolCallOutcome(state, {
toolName: "message",
toolParams: params,
toolCallId: `old-veto-${i}`,
result: {
content: [{ type: "text", text: "blocked" }],
details: { status: "blocked", deniedReason: "tool-loop" },
},
config: enabledLoopDetectionConfig,
});
}
recordSend(state, "message", params, { ...sendPayload(25), route: { id: "new-route" } }, 25);
expect(
detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig),
).toMatchObject({
stuck: true,
level: "warning",
detector: "generic_repeat",
count: 26,
});
});
it("still escalates repeated plugin/approval vetoes to a critical loop", () => {
+11 -39
View File
@@ -19,6 +19,7 @@ import {
getArgumentChurnNoProgressStreak,
} from "./tool-loop-argument-churn.js";
import { isKnownPollToolCall } from "./tool-loop-call-kind.js";
import { getNoProgressStreak } from "./tool-loop-no-progress.js";
import { TOOL_LOOP_WARNING_THRESHOLD } from "./tool-loop-thresholds.js";
import { isWriteNoProgressOutcome } from "./tool-loop-write-outcome.js";
@@ -286,7 +287,7 @@ function hashToolOutcome(
params: unknown,
result: unknown,
error: unknown,
): { resultHash?: string; noProgress?: true; unknownToolName?: string } {
): Pick<ToolCallRecord, "outcomeKind" | "resultHash" | "noProgress" | "unknownToolName"> {
if (error !== undefined) {
const unknownToolName = extractUnknownToolName(error);
return {
@@ -301,10 +302,10 @@ function hashToolOutcome(
const details = isPlainObject(result.details) ? result.details : {};
const text = extractTextContent(result);
// The loop detector's own veto is not real progress; giving it no result hash keeps a
// critical loop block sticky instead of letting the block reset the streak (#89090).
// A loop veto extends the prior no-progress streak but is not a real tool outcome.
// Keep it typed so it cannot reset the streak or collide with plugin/approval denials.
if (isLoopVetoResult(details)) {
return { resultHash: undefined };
return { outcomeKind: "tool-loop-veto" };
}
if (toolName === "exec") {
const execHash = hashExecToolOutcome(details, text);
@@ -383,36 +384,6 @@ function getUnknownToolRepeatStreak(
return { count: streak, unknownToolName: repeatedUnknownToolName };
}
function getNoProgressStreak(
history: readonly ToolCallRecord[],
toolName: string,
argsHash: string,
): { count: number; latestResultHash?: string } {
let streak = 0;
let latestResultHash: string | undefined;
for (let i = history.length - 1; i >= 0; i -= 1) {
const record = history[i];
if (!record || record.toolName !== toolName || record.argsHash !== argsHash) {
continue;
}
if (typeof record.resultHash !== "string" || !record.resultHash) {
continue;
}
if (!latestResultHash) {
latestResultHash = record.resultHash;
streak = 1;
continue;
}
if (record.resultHash !== latestResultHash) {
break;
}
streak += 1;
}
return { count: streak, latestResultHash };
}
function getPingPongStreak(
history: readonly ToolCallRecord[],
currentSignature: string,
@@ -738,8 +709,7 @@ export function recordToolCallOutcome(
const resolvedConfig = resolveLoopDetectionConfig(params.config);
const runId = normalizeRunId(params.runId);
const outcome = hashToolOutcome(params.toolName, params.toolParams, params.result, params.error);
const resultHash = outcome.resultHash;
if (!resultHash) {
if (!outcome.resultHash && !outcome.outcomeKind) {
return undefined;
}
@@ -764,10 +734,11 @@ export function recordToolCallOutcome(
if (call.toolName !== params.toolName || call.argsHash !== argsHash) {
continue;
}
if (call.resultHash !== undefined) {
if (call.resultHash !== undefined || call.outcomeKind !== undefined) {
continue;
}
call.resultHash = resultHash;
call.outcomeKind = outcome.outcomeKind;
call.resultHash = outcome.resultHash;
if (outcome.noProgress) {
call.noProgress = true;
} else {
@@ -785,7 +756,8 @@ export function recordToolCallOutcome(
argsHash,
toolCallId: params.toolCallId,
...(runId && { runId }),
resultHash,
outcomeKind: outcome.outcomeKind,
resultHash: outcome.resultHash,
...(outcome.noProgress ? { noProgress: true as const } : {}),
unknownToolName: outcome.unknownToolName,
timestamp: Date.now(),
+43
View File
@@ -0,0 +1,43 @@
import type { ToolCallRecord } from "../logging/diagnostic-session-state.js";
export function getNoProgressStreak(
history: readonly ToolCallRecord[],
toolName: string,
argsHash: string,
): { count: number; latestResultHash?: string } {
let streak = 0;
let latestResultHash: string | undefined;
// Vetoes are provisional until an older concrete outcome anchors them; a newer
// changed outcome must reset vetoes from the previous no-progress streak.
let pendingLoopVetoes = 0;
for (let i = history.length - 1; i >= 0; i -= 1) {
const record = history[i];
if (!record || record.toolName !== toolName || record.argsHash !== argsHash) {
continue;
}
if (record.outcomeKind === "tool-loop-veto") {
pendingLoopVetoes += 1;
continue;
}
if (typeof record.resultHash !== "string" || !record.resultHash) {
continue;
}
if (!latestResultHash) {
latestResultHash = record.resultHash;
streak = pendingLoopVetoes + 1;
pendingLoopVetoes = 0;
continue;
}
if (record.resultHash !== latestResultHash) {
break;
}
streak += pendingLoopVetoes + 1;
pendingLoopVetoes = 0;
}
return {
count: latestResultHash ? streak : pendingLoopVetoes,
latestResultHash,
};
}
+1
View File
@@ -24,6 +24,7 @@ export type ToolCallRecord = {
argsHash: string;
toolCallId?: string;
runId?: string;
outcomeKind?: "tool-loop-veto";
resultHash?: string;
noProgress?: true;
unknownToolName?: string;
+27
View File
@@ -3635,6 +3635,33 @@ describe("scripts/test-projects changed-target routing", () => {
]);
});
it("routes isolated ui test targets to the isolated project", () => {
expect(buildVitestRunPlans(["ui/src/pages/workboard/view.test.ts"])).toEqual([
{
config: "test/vitest/vitest.ui-isolated.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/pages/workboard/view.test.ts"],
watchMode: false,
},
]);
});
it("adds the isolated project for broad ui targets", () => {
const plans = buildVitestRunPlans(["ui/src"]);
expect(plans.map((plan) => plan.config)).toEqual([
"test/vitest/vitest.ui.config.ts",
"test/vitest/vitest.ui-isolated.config.ts",
]);
expect(plans[1]?.includePatterns).toContain("ui/src/pages/workboard/view.test.ts");
});
it("rejects broad ui watch targets that cross shared and isolated projects", () => {
expect(() => buildVitestRunPlans(["--watch", "ui/src"])).toThrow(
"watch mode with mixed test suites is not supported",
);
});
it("keeps explicit non-renderer ui test targets scoped", () => {
expect(
buildVitestRunPlans([
@@ -0,0 +1,2 @@
export const uiIsolatedTestFiles: string[];
export function isUiIsolatedTestFile(file: string): boolean;
+20
View File
@@ -0,0 +1,20 @@
// The shared UI runner reuses its module graph across fresh jsdom registries.
// Tests in this list depend on module singletons or custom-element registration
// matching the current registry, so they need a fresh graph in the isolated lane.
export const uiIsolatedTestFiles = [
"ui/src/pages/chat/chat-pane-history.test.ts",
"ui/src/pages/chat/chat-pane-identity.test.ts",
"ui/src/pages/chat/chat-pane-lifecycle.test.ts",
"ui/src/pages/chat/chat-pane-pull-requests.test.ts",
"ui/src/pages/chat/chat-pane.message-cut.test.ts",
"ui/src/pages/chat/chat-pane.read-marker.test.ts",
"ui/src/pages/chat/chat-pane.session-discussion.test.ts",
"ui/src/pages/chat/chat-pane.test.ts",
"ui/src/pages/workboard/view.test.ts",
];
const uiIsolatedTestFileSet = new Set(uiIsolatedTestFiles);
export function isUiIsolatedTestFile(value) {
return uiIsolatedTestFileSet.has(value.replaceAll("\\", "/"));
}
+3 -3
View File
@@ -1,13 +1,13 @@
// Vitest ui-isolated config runs jsdom ui tests that need a fresh module graph.
// The shared ui shard runs non-isolated for speed, but tests that spy on module
// internals and assert the component uses that spy must not share a module cache
// with stateful predecessor files (see UI_ISOLATED_TEST_FILES).
// with stateful predecessor files (see uiIsolatedTestFiles).
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
import { jsdomOptimizedDeps } from "./vitest.shared.config.ts";
import { UI_ISOLATED_TEST_FILES } from "./vitest.ui.config.ts";
import { uiIsolatedTestFiles } from "./vitest.ui-isolated-paths.mjs";
export function createUiIsolatedVitestConfig(env?: Record<string, string | undefined>) {
return createScopedVitestConfig(UI_ISOLATED_TEST_FILES, {
return createScopedVitestConfig(uiIsolatedTestFiles, {
deps: jsdomOptimizedDeps,
environment: "jsdom",
env,
+3 -22
View File
@@ -1,34 +1,15 @@
// Vitest ui config wires the ui test shard.
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
import { jsdomOptimizedDeps } from "./vitest.shared.config.ts";
// Full chat-pane lifecycle tests instantiate the pane component, which relies on
// chat-thread/chat-message module-level singletons (thread state maps, module-scoped
// document context-menu listeners) and spies on those modules. Under the non-isolated
// ui runner a stateful predecessor file can leave those modules duplicated across the
// shared graph, so the pane binds to a different instance than the test's spy/registry
// — surfacing as flaky teardown assertions or 120s session-lifecycle hangs, depending
// on file order. These tests run in the isolated ui lane for a fresh module graph;
// keep this list in sync with vitest.ui-isolated.config.ts's include.
export const UI_ISOLATED_TEST_FILES = [
"ui/src/pages/chat/chat-pane-history.test.ts",
"ui/src/pages/chat/chat-pane-identity.test.ts",
"ui/src/pages/chat/chat-pane-lifecycle.test.ts",
"ui/src/pages/chat/chat-pane-pull-requests.test.ts",
"ui/src/pages/chat/chat-pane.message-cut.test.ts",
"ui/src/pages/chat/chat-pane.read-marker.test.ts",
"ui/src/pages/chat/chat-pane.session-discussion.test.ts",
"ui/src/pages/chat/chat-pane.test.ts",
];
import { uiIsolatedTestFiles } from "./vitest.ui-isolated-paths.mjs";
export function createUiVitestConfig(
env?: Record<string, string | undefined>,
options?: { includePatterns?: string[]; name?: string },
) {
const includePatterns = options?.includePatterns ?? ["ui/src/**/*.test.ts"];
const exclude = options?.includePatterns
? []
: ["ui/src/**/*.e2e.test.ts", ...UI_ISOLATED_TEST_FILES];
// Isolated files must never enter the shared module graph, including scoped runs.
const exclude = ["ui/src/**/*.e2e.test.ts", ...uiIsolatedTestFiles];
return createScopedVitestConfig(includePatterns, {
deps: jsdomOptimizedDeps,
environment: "jsdom",