























@@ -1,9 +1,10 @@
1-import { beforeAll, beforeEach, describe, expect, it } from "vitest";
1+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22import type {
33diagnosticSessionStates as DiagnosticSessionStatesType,
44getDiagnosticSessionState as GetDiagnosticSessionStateType,
55SessionState,
66} from "../../logging/diagnostic-session-state.js";
7+import type { wrapToolWithBeforeToolCallHook as WrapToolWithBeforeToolCallHookType } from "../pi-tools.before-tool-call.js";
78import type {
89recordToolCall as RecordToolCallType,
910recordToolCallOutcome as RecordToolCallOutcomeType,
@@ -35,6 +36,7 @@ let diagnosticSessionStates: typeof DiagnosticSessionStatesType;
3536let getDiagnosticSessionState: typeof GetDiagnosticSessionStateType;
3637let recordToolCall: typeof RecordToolCallType;
3738let recordToolCallOutcome: typeof RecordToolCallOutcomeType;
39+let wrapToolWithBeforeToolCallHook: typeof WrapToolWithBeforeToolCallHookType;
3840let PostCompactionLoopPersistedError: typeof PostCompactionLoopPersistedErrorType;
39414042// Mirror the production trim cap (resolveLoopDetectionConfig default
@@ -49,7 +51,7 @@ function recordToolOutcome(
4951result: unknown,
5052runId?: string,
5153): void {
52-const toolCallId = `${toolName}-${state.toolOutcomeSeq ?? 0}`;
54+const toolCallId = `${toolName}-${state.toolCallHistory?.length ?? 0}`;
5355const scope = runId ? { runId } : undefined;
5456recordToolCall(state, toolName, toolParams, toolCallId, undefined, scope);
5557const outcome: Parameters<typeof recordToolCallOutcome>[1] = {
@@ -64,6 +66,30 @@ function recordToolOutcome(
6466recordToolCallOutcome(state, outcome);
6567}
666869+let liveToolCallSeq = 0;
70+71+async function executeWrappedToolOutcome(
72+toolName: string,
73+toolParams: unknown,
74+result: unknown,
75+runId = baseParams.runId,
76+): Promise<unknown> {
77+const tool = wrapToolWithBeforeToolCallHook(
78+{
79+name: toolName,
80+execute: vi.fn(async () => result),
81+} as never,
82+{
83+agentId: "main",
84+sessionKey: baseParams.sessionKey,
85+sessionId: baseParams.sessionId,
86+ runId,
87+},
88+);
89+liveToolCallSeq += 1;
90+return tool.execute(`${toolName}-${liveToolCallSeq}`, toolParams, undefined, undefined);
91+}
92+6793describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
6894beforeAll(async () => {
6995({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness());
@@ -72,10 +98,12 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
7298({ diagnosticSessionStates, getDiagnosticSessionState } =
7399await import("../../logging/diagnostic-session-state.js"));
74100({ recordToolCall, recordToolCallOutcome } = await import("../tool-loop-detection.js"));
101+({ wrapToolWithBeforeToolCallHook } = await import("../pi-tools.before-tool-call.js"));
75102({ PostCompactionLoopPersistedError } = await import("./post-compaction-loop-guard.js"));
76103});
7710478105beforeEach(() => {
106+liveToolCallSeq = 0;
79107diagnosticSessionStates.clear();
80108mockedRunEmbeddedAttempt.mockReset();
81109mockedCompactDirect.mockReset();
@@ -122,29 +150,24 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
122150123151it("aborts the run with PostCompactionLoopPersistedError when identical (tool, args, result) repeats windowSize times after compaction", async () => {
124152const overflowError = makeOverflowError();
125-const sessionState = getDiagnosticSessionState({
126-sessionKey: baseParams.sessionKey,
127-sessionId: baseParams.sessionId,
128-});
153+let attemptReturned = false;
129154130155// Attempt 1: overflow → triggers compaction.
131156mockedRunEmbeddedAttempt.mockImplementationOnce(async () =>
132157makeAttemptResult({ promptError: overflowError }),
133158);
134-// Attempt 2: post-compaction. The wrapped tool layer would have
135-// recorded `windowSize` identical (tool, args, result) outcomes during
136-// this single attempt. The runner's after-attempt guard observation
137-// sees all three at once, accumulates matches, and aborts on the third.
159+// Attempt 2: post-compaction. The live wrapped-tool path records each
160+// outcome while the prompt is still running; the third identical result
161+// aborts before the attempt can return.
138162mockedRunEmbeddedAttempt.mockImplementationOnce(async () => {
139163for (let i = 0; i < 3; i += 1) {
140-recordToolOutcome(
141-sessionState,
164+await executeWrappedToolOutcome(
142165"gateway",
143166{ action: "lookup", path: "x" },
144167"identical-result",
145-baseParams.runId,
146168);
147169}
170+attemptReturned = true;
148171return makeAttemptResult({
149172promptError: null,
150173toolMetas: [{ toolName: "gateway" }, { toolName: "gateway" }, { toolName: "gateway" }],
@@ -165,35 +188,25 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
165188166189expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
167190expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
191+expect(attemptReturned).toBe(false);
168192});
169193170194it("does not abort when the result hash changes across post-compaction attempts (progress was made)", async () => {
171195const overflowError = makeOverflowError();
172-const sessionState = getDiagnosticSessionState({
173-sessionKey: baseParams.sessionKey,
174-sessionId: baseParams.sessionId,
175-});
176-177196// Attempt 1: overflow → triggers compaction.
178197mockedRunEmbeddedAttempt.mockImplementationOnce(async () =>
179198makeAttemptResult({ promptError: overflowError }),
180199);
181200// Attempt 2 (post-compaction): identical args, but DIFFERENT result hash
182-// each time. Only one further attempt is needed since the runner exits
183-// on a successful prompt with no further retry trigger.
184-let callCounter = 0;
201+// each time. This fills the window without triggering the persisted-loop
202+// abort because the tool is making progress.
185203mockedRunEmbeddedAttempt.mockImplementationOnce(async () => {
186-callCounter += 1;
187-recordToolOutcome(
188-sessionState,
189-"gateway",
190-{ action: "lookup", path: "x" },
191-`result-${callCounter}`,
192-baseParams.runId,
193-);
204+for (let i = 0; i < 3; i += 1) {
205+await executeWrappedToolOutcome("gateway", { action: "lookup", path: "x" }, `result-${i}`);
206+}
194207return makeAttemptResult({
195208promptError: null,
196-toolMetas: [{ toolName: "gateway" }],
209+toolMetas: [{ toolName: "gateway" }, { toolName: "gateway" }, { toolName: "gateway" }],
197210});
198211});
199212@@ -214,10 +227,6 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
214227it("disarms after windowSize observations regardless of match, so later identical calls do not abort", async () => {
215228// Use windowSize: 2 so the guard disarms after 2 observations.
216229const overflowError = makeOverflowError();
217-const sessionState = getDiagnosticSessionState({
218-sessionKey: baseParams.sessionKey,
219-sessionId: baseParams.sessionId,
220-});
221230222231// Attempt 1: overflow → triggers compaction.
223232mockedRunEmbeddedAttempt.mockImplementationOnce(async () =>
@@ -227,8 +236,8 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
227236// guard disarms with no abort. We then append more identical records
228237// afterwards in this test to confirm they are not observed by the guard.
229238mockedRunEmbeddedAttempt.mockImplementationOnce(async () => {
230-recordToolOutcome(sessionState, "read", { path: "/a" }, "ra", baseParams.runId);
231-recordToolOutcome(sessionState, "write", { path: "/b" }, "rb", baseParams.runId);
239+await executeWrappedToolOutcome("read", { path: "/a" }, "ra");
240+await executeWrappedToolOutcome("write", { path: "/b" }, "rb");
232241return makeAttemptResult({
233242promptError: null,
234243toolMetas: [{ toolName: "read" }, { toolName: "write" }],
@@ -259,12 +268,10 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
259268expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
260269});
261270262-it("aborts post-compaction loop even when toolCallHistory is at its trim cap (regression: index-cursor blind spot in long-running sessions)", async () => {
271+it("aborts post-compaction loop from the live tool path even when toolCallHistory is at its trim cap", async () => {
263272// Long-running sessions accumulate up to historySize (default 30) records
264-// in toolCallHistory. Pushing more entries triggers trim, which would
265-// shift records out from under an absolute index cursor and let the
266-// guard silently miss every loop. The seq-based observation must still
267-// see the new records via the tail-slice path.
273+// in toolCallHistory. The live observer must still see the new outcome
274+// before trimming can make any after-attempt cursor ambiguous.
268275const overflowError = makeOverflowError();
269276const sessionState = getDiagnosticSessionState({
270277sessionKey: baseParams.sessionKey,
@@ -283,20 +290,15 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
283290mockedRunEmbeddedAttempt.mockImplementationOnce(async () =>
284291makeAttemptResult({ promptError: overflowError }),
285292);
286-// Attempt 2 (post-compaction): three identical records appended while
287-// history is already at the cap. These pushes trigger trim, shifting
288-// older entries out. With the old index-cursor scheme, length never
289-// grew so the observation loop never ran. With the seq-based scheme,
290-// the tail of length-30 history contains the three new records and
291-// the guard aborts on the third match.
293+// Attempt 2 (post-compaction): three identical live tool outcomes while
294+// history is already at the cap. The guard aborts on the third result
295+// before the mocked attempt can return.
292296mockedRunEmbeddedAttempt.mockImplementationOnce(async () => {
293297for (let i = 0; i < 3; i += 1) {
294-recordToolOutcome(
295-sessionState,
298+await executeWrappedToolOutcome(
296299"gateway",
297300{ action: "lookup", path: "x" },
298301"identical-result",
299-baseParams.runId,
300302);
301303}
302304// History is still capped at HISTORY_TRIM_CAP after the trim.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。