diff --git a/changes/unreleased/4538.md b/changes/unreleased/4538.md new file mode 100644 index 0000000000..58f231ee5b --- /dev/null +++ b/changes/unreleased/4538.md @@ -0,0 +1,43 @@ + +## 2026-10-27 - repetitive-turns never cuts a reply off and ignores summaries separated by tool work (senpi#4537) + +### What changed + +- `repetitive-turns-lane.ts`: the lane compares each completed text-only reply with the previous one; a reply that calls a tool resets the comparison (`noteToolWork`, and `readPersistedAssistantTexts` restores only the text-only replies since the last tool call). The streaming arm path `observeTextDelta` is removed. +- `index.ts`: repetitive-turns no longer aborts the stream; it is judged at `message_end` and nudges after the complete reply. `queueRuleNudge` writes the nudge activation only when the nudge will be sent. +- `follow-up-limit.ts`: `ruleAlreadyCorrected` ends the one-correction episode at the next user message or when the agent calls a tool after the correction. + +### Why + +- In a long autonomous goal, a progress summary in the mandated status format was compared with the previous summary across hours of tool work, cut off mid-sentence on one near-duplicate pair, and the session was paused because one correction stayed spent until the next user message (senpi#4537, reported by jamestesting). + +### Why an extension could not handle it + +- The rule is a built-in extension; its lane, follow-up bound and nudge path are internal to it. + +### Expected merge conflict zones + +- LOW: `message_update` / `message_end` handlers in `ttsr/index.ts`. + + +## 2026-10-27 - The repetitive-turns activation no longer says output was interrupted (senpi#4537) + +### What changed + +- `rule-activation/renderer.ts`: a `nudge` activation owned by `repetitive-turns` reads "Reply repeated the previous one; a corrective nudge was queued." Other stream rules keep "Output interrupted; a corrective nudge was queued." + +### Why + +- repetitive-turns now judges a completed reply and never cuts it off, so "Output interrupted" would be false (senpi#4537). + +### Why an extension could not handle it + +- The renderer belongs to the built-in rule-activation extension. + +### Expected merge conflict zones + +- LOW: `summaryLine` in `rule-activation/renderer.ts`. + + +### Fixed +- The repetitive-turns stream rule no longer cuts off a reply or pauses a long autonomous session when similar progress summaries are separated by tool work; it nudges after the complete reply and renews its one correction once the agent acts again ([#4538](https://github.com/code-yeongyu/senpi/pull/4538)) diff --git a/packages/coding-agent/src/core/extensions/builtin/rule-activation/renderer.ts b/packages/coding-agent/src/core/extensions/builtin/rule-activation/renderer.ts index 8415c52267..b5852ea0a6 100644 --- a/packages/coding-agent/src/core/extensions/builtin/rule-activation/renderer.ts +++ b/packages/coding-agent/src/core/extensions/builtin/rule-activation/renderer.ts @@ -24,9 +24,11 @@ function summaryLine(details: RuleActivationDetails): string { return `${details.rules.length} ${noun} matched and injected for this tool result.`; } case "ttsr": - return details.remediation === "nudge" - ? "Output interrupted; a corrective nudge was queued." - : "Corrupted generation discarded; bounded provider retry started."; + if (details.remediation !== "nudge") return "Corrupted generation discarded; bounded provider retry started."; + // repetitive-turns judges a completed reply and never cuts it off (senpi#4537). + return details.owner === "repetitive-turns" + ? "Reply repeated the previous one; a corrective nudge was queued." + : "Output interrupted; a corrective nudge was queued."; } } diff --git a/packages/coding-agent/src/core/extensions/builtin/ttsr/follow-up-limit.ts b/packages/coding-agent/src/core/extensions/builtin/ttsr/follow-up-limit.ts index b65683c728..35539b83a5 100644 --- a/packages/coding-agent/src/core/extensions/builtin/ttsr/follow-up-limit.ts +++ b/packages/coding-agent/src/core/extensions/builtin/ttsr/follow-up-limit.ts @@ -1,4 +1,5 @@ import type { SessionEntry } from "../../../session-manager.ts"; +import { hasToolCall } from "./repetitive-turns-lane.ts"; import { TTSR_INJECTION_CUSTOM_TYPE } from "./types.ts"; export const TTSR_LOOP_STOPPED_ENTRY_TYPE = "ttsr-loop-stopped"; @@ -16,14 +17,16 @@ function nudgedRules(entry: SessionEntry): readonly unknown[] { } /** - * Whether this rule already sent its one corrective follow-up since the user's last message. Read from the - * session, so a host that rebuilds the extension between turns cannot re-arm it (senpi#2967); the engine-wide - * turn bound covers every other source. + * Whether this rule already sent its one corrective follow-up in the current episode. Read from the session, so a + * host that rebuilds the extension between turns cannot re-arm it (senpi#2967). An episode ends at the user's next + * message, or when the agent acts again (calls a tool) after the correction: the correction worked, so a later + * violation in a long autonomous run gets its own correction instead of a pause (senpi#4537). */ export function ruleAlreadyCorrected(entries: readonly SessionEntry[], rule: string): boolean { for (let index = entries.length - 1; index >= 0; index--) { const entry = entries[index]; if (entry.type === "message" && entry.message.role === "user") return false; + if (entry.type === "message" && entry.message.role === "assistant" && hasToolCall(entry.message)) return false; if (nudgedRules(entry).includes(rule)) return true; } return false; diff --git a/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts b/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts index c3e4359fad..560564c860 100644 --- a/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts @@ -20,7 +20,12 @@ import { TtsrManager } from "./manager.ts"; import { getTtsrStreamDelta } from "./message-update.ts"; import { REPETITIVE_TURNS_RULE_CONTENT } from "./prompts.ts"; import { buildNudgeMessage, type TtsrNudgeMessage } from "./remediation.ts"; -import { collectAssistantText, RepetitiveTurnsLane, readPersistedAssistantTexts } from "./repetitive-turns-lane.ts"; +import { + collectAssistantText, + hasToolCall, + RepetitiveTurnsLane, + readPersistedAssistantTexts, +} from "./repetitive-turns-lane.ts"; import { compileRuleCondition } from "./rule-condition.ts"; import { buildStreamRemediation } from "./stream-remediation.ts"; import { @@ -90,7 +95,6 @@ export default function ttsrExtension(pi: ExtensionAPI): void { pendingRemediation = null; pendingRuleNudge = null; pendingNudge = null; - repetitiveTurns.disarm(); } } @@ -99,7 +103,6 @@ export default function ttsrExtension(pi: ExtensionAPI): void { genState = createGenerationState(); pendingRemediation = null; pendingRuleNudge = null; - repetitiveTurns.resetTurn(); watcher?.reset(); } @@ -112,6 +115,17 @@ export default function ttsrExtension(pi: ExtensionAPI): void { }); } + /** + * Queues a rule's corrective nudge for the settle point. The activation notice says a nudge was queued, so it is + * written only when the nudge will be sent; a rule already corrected in this episode is stopped at settle instead + * (senpi#4537). + */ + function queueRuleNudge(ctx: ExtensionContext, rule: string, content: string): void { + manager?.markInjectedByNames([rule]); + if (!ruleAlreadyCorrected(ctx.sessionManager.getEntries(), rule)) recordInjection(rule, [rule], "nudge"); + pendingNudge = buildNudgeMessage(rule, content); + } + function ensureInitialized(ctx: ExtensionContext): void { if (manager !== null) return; disabled = pi.getFlag("ttsr-disabled") === true; @@ -204,16 +218,6 @@ export default function ttsrExtension(pi: ExtensionAPI): void { ctx.abort("system"); return; } - if (source === "text") { - const canArm = pendingRuleNudge === null && !genState.abortClaimed; - if (repetitiveTurns.observeTextDelta(delta, canArm) && !genState.abortClaimed) { - genState.abortClaimed = true; - genState.abortOwner = "collapse-repetition"; - genState.selfAbortAt = Date.now(); - ctx.abort("system"); - return; - } - } const interrupting = outcome.ruleMatches.filter((rule) => rule.interruptMode === "always"); const rule = interrupting[0]; if (rule !== undefined && pendingRuleNudge === null && !genState.abortClaimed) { @@ -225,7 +229,7 @@ export default function ttsrExtension(pi: ExtensionAPI): void { } }); - pi.on("message_end", (event) => { + pi.on("message_end", (event, ctx) => { // A completed message ends its stream: nothing it buffered can match a // later message's deltas, so drop the tail windows now instead of holding // them until the next turn_start reset. @@ -233,23 +237,14 @@ export default function ttsrExtension(pi: ExtensionAPI): void { if (genState.userCancelled) return undefined; if (event.message.role !== "assistant") return undefined; const turnText = collectAssistantText(event.message); - if (repetitiveTurns.armed) { - repetitiveTurns.commitArmedTurn(turnText); - manager?.markInjectedByNames([REPETITIVE_TURNS_RULE_NAME]); - recordInjection(REPETITIVE_TURNS_RULE_NAME, [REPETITIVE_TURNS_RULE_NAME], "nudge"); - pendingNudge = buildNudgeMessage(REPETITIVE_TURNS_RULE_NAME, REPETITIVE_TURNS_RULE_CONTENT); - return undefined; - } if (pendingRuleNudge !== null) { const pending = pendingRuleNudge; pendingRuleNudge = null; - manager?.markInjectedByNames([pending.rule.name]); - recordInjection(pending.rule.name, [pending.rule.name], "nudge"); - pendingNudge = buildNudgeMessage(pending.rule.name, pending.rule.content); + queueRuleNudge(ctx, pending.rule.name, pending.rule.content); return undefined; } if (pendingRemediation !== null) { - if (turnText !== null) repetitiveTurns.recordCompletedTurn(turnText); + if (turnText !== null) repetitiveTurns.completeTurn(turnText); const pending = pendingRemediation; pendingRemediation = null; try { @@ -268,7 +263,13 @@ export default function ttsrExtension(pi: ExtensionAPI): void { return undefined; } } - if (turnText !== null) repetitiveTurns.recordCompletedTurn(turnText); + if (hasToolCall(event.message)) { + repetitiveTurns.noteToolWork(); + return undefined; + } + if (turnText !== null && repetitiveTurns.completeTurn(turnText)) { + queueRuleNudge(ctx, REPETITIVE_TURNS_RULE_NAME, REPETITIVE_TURNS_RULE_CONTENT); + } return undefined; }); diff --git a/packages/coding-agent/src/core/extensions/builtin/ttsr/repetitive-turns-lane.ts b/packages/coding-agent/src/core/extensions/builtin/ttsr/repetitive-turns-lane.ts index e0fb5bb4c6..1272a7e356 100644 --- a/packages/coding-agent/src/core/extensions/builtin/ttsr/repetitive-turns-lane.ts +++ b/packages/coding-agent/src/core/extensions/builtin/ttsr/repetitive-turns-lane.ts @@ -1,11 +1,8 @@ import { - createRepetitiveTurnsState, isNearDuplicateOfPreviousTurn, normalizeTurnText, REPETITIVE_TURNS_MIN_NORMALIZED_CHARS, REPETITIVE_TURNS_RULE_NAME, - type RepetitiveTurnsState, - recordTurnText, } from "./detectors/repetitive-turns.ts"; export function collectAssistantText(message: { readonly content: unknown }): string | null { @@ -20,32 +17,48 @@ export function collectAssistantText(message: { readonly content: unknown }): st return combined.length > 0 ? combined : null; } +/** Whether an assistant message called a tool: the agent acted instead of only reporting (senpi#4537). */ +export function hasToolCall(message: { readonly content: unknown }): boolean { + if (!Array.isArray(message.content)) return false; + return message.content.some( + (block: unknown) => typeof block === "object" && block !== null && "type" in block && block.type === "toolCall", + ); +} + const RESTORED_TURN_LIMIT = 8; interface SessionEntrySource { readonly sessionManager: { getEntries(): readonly unknown[] }; } +/** The text-only replies since the agent last called a tool, the window the lane compares within. */ export function readPersistedAssistantTexts(ctx: SessionEntrySource): string[] { - const texts: string[] = []; + let texts: string[] = []; for (const entry of ctx.sessionManager.getEntries()) { if (typeof entry !== "object" || entry === null) continue; if (!("type" in entry) || entry.type !== "message") continue; const message: unknown = Reflect.get(entry, "message"); if (typeof message !== "object" || message === null) continue; if (Reflect.get(message, "role") !== "assistant") continue; - const text = collectAssistantText({ content: Reflect.get(message, "content") }); + const content: unknown = Reflect.get(message, "content"); + if (hasToolCall({ content })) { + texts = []; + continue; + } + const text = collectAssistantText({ content }); if (text !== null) texts.push(text); } return texts.slice(-RESTORED_TURN_LIMIT); } +/** + * Compares each completed text-only reply with the previous one. The rule is for a model that repeats its status + * instead of acting, so a reply that calls a tool resets the comparison: summaries separated by real work are + * progress reports, however similar their format (senpi#4537). The verdict comes after the reply has completed; + * the lane never cuts a reply off mid-stream. + */ export class RepetitiveTurnsLane { - #state: RepetitiveTurnsState = createRepetitiveTurnsState(); - #armed = false; - #recovering = false; - #currentTurnText = ""; - #lastCompletedTurnText: string | null = null; + #previous: string | null = null; #enabled = true; configure(disabledRules: ReadonlySet): void { @@ -53,64 +66,27 @@ export class RepetitiveTurnsLane { } resetSession(): void { - this.#state = createRepetitiveTurnsState(); - this.#armed = false; - this.#recovering = false; - this.#currentTurnText = ""; - this.#lastCompletedTurnText = null; + this.#previous = null; } restoreFromHistory(assistantTexts: readonly string[]): void { for (const text of assistantTexts) { - this.recordCompletedTurn(text); - } - this.#armed = false; - } - - resetTurn(): void { - this.#armed = false; - this.#currentTurnText = ""; - } - - disarm(): void { - this.#armed = false; - } - - get armed(): boolean { - return this.#armed; - } - - observeTextDelta(delta: string, canArm: boolean): boolean { - if (this.#enabled && !this.#armed && !this.#recovering && canArm && this.#lastCompletedTurnText !== null) { - const candidate = normalizeTurnText(this.#currentTurnText + delta); - if ( - candidate.length >= REPETITIVE_TURNS_MIN_NORMALIZED_CHARS && - isNearDuplicateOfPreviousTurn(candidate, this.#lastCompletedTurnText) - ) { - this.#armed = true; - } + this.#previous = normalizeTurnText(text); } - this.#currentTurnText += delta; - return this.#armed; } - commitArmedTurn(turnText: string | null): void { - this.#armed = false; - this.#recovering = true; - if (turnText !== null) { - this.#lastCompletedTurnText = normalizeTurnText(turnText); - } + noteToolWork(): void { + this.#previous = null; } - recordCompletedTurn(turnText: string): void { - const normalized = normalizeTurnText(turnText); - this.#lastCompletedTurnText = normalized; - if (!this.#enabled) return; - const match = recordTurnText(this.#state, turnText); - if (match === null) { - if (this.#recovering) this.#recovering = false; - return; - } - this.#armed = true; + /** Records a completed text-only reply; true when it near-duplicates the previous one with no tool work between. */ + completeTurn(turnText: string): boolean { + const candidate = normalizeTurnText(turnText); + const previous = this.#previous; + this.#previous = candidate; + if (!this.#enabled || previous === null) return false; + return ( + candidate.length >= REPETITIVE_TURNS_MIN_NORMALIZED_CHARS && isNearDuplicateOfPreviousTurn(candidate, previous) + ); } } diff --git a/packages/coding-agent/test/suite/regressions/4537-repetitive-turns-tool-work.test.ts b/packages/coding-agent/test/suite/regressions/4537-repetitive-turns-tool-work.test.ts new file mode 100644 index 0000000000..562f1835e5 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4537-repetitive-turns-tool-work.test.ts @@ -0,0 +1,227 @@ +import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import ttsrExtension from "../../../src/core/extensions/builtin/ttsr/index.ts"; +import { ruleAlreadyCorrected } from "../../../src/core/extensions/builtin/ttsr/follow-up-limit.ts"; +import type { SessionEntry } from "../../../src/core/session-manager.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +// senpi#4537: two progress summaries in the same mandated status format, describing different work. +const SUMMARY_A = + "Implemented the parser cache loader toward release readiness. You need: 2,026 new cases plus 802 regressions pass in strict and debug modes; two release builds pass and 82 guards match. Evidence saved. Schema and runtime gates remain open; milestone M1 stays PARTIAL, 195/222. No owned resources remain active. Now: Wire cache state into the runtime. Next: Finish the remaining acceptance requirements."; +const SUMMARY_B = + "Implemented the string table move semantics toward release readiness. You need: 259 new cases plus 2,828 regressions pass in strict and debug modes; two release builds pass and 86 guards match. Captured and audited 841 dependency words. Construction and runtime gates remain open; milestone M1 stays PARTIAL, 195/222. No owned resources remain active. Now: Wire cache state into the runtime. Next: Finish the remaining acceptance requirements."; +const STATUS = "Still working on the frobnicate step. Pass 1 of 9 is done, queue drained here."; + +const echoTool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo text back", + parameters: Type.Object({ text: Type.String() }), + execute: async (_toolCallId, params) => { + const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : ""; + return { content: [{ type: "text", text: `echo:${text}` }], details: { text } }; + }, +}; + +interface AssistantView { + readonly stopReason: unknown; + readonly text: string; +} + +function assistantViews(harness: Harness): AssistantView[] { + return harness.sessionManager.getEntries().flatMap((entry) => { + if (entry.type !== "message" || entry.message.role !== "assistant") return []; + const text = entry.message.content.map((block) => (block.type === "text" ? block.text : "")).join(""); + return [{ stopReason: entry.message.stopReason, text }]; + }); +} + +function customEntries(harness: Harness, customType: string): SessionEntry[] { + return harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom" && entry.customType === customType); +} + +/** Resolves at the session's final idle, after any automatic follow-up turns, bounded by a timeout. */ +function nextIdle(harness: Harness): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error("agent_idle was not emitted")); + }, 10_000); + const unsubscribe = harness.session.subscribe((event) => { + if (event.type !== "agent_idle") return; + clearTimeout(timer); + unsubscribe(); + resolve(); + }); + }); +} + +function corrections(harness: Harness): SessionEntry[] { + return harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === "ttsr-injection"); +} + +describe("senpi#4537 repetitive-turns in a long working session", () => { + let harness: Harness; + + afterEach(() => { + harness.cleanup(); + }); + + it("leaves similar progress summaries alone when the agent did tool work between them", async () => { + // given a summary, then a run that calls a tool and reports in the same status format + harness = await createHarness({ extensionFactories: [ttsrExtension], tools: [echoTool] }); + harness.setResponses([ + fauxAssistantMessage([fauxText(SUMMARY_A)]), + fauxAssistantMessage([fauxToolCall("echo", { text: "edit" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText(SUMMARY_B)]), + ]); + + // when + await harness.session.prompt("work"); + await harness.session.prompt("continue"); + + // then the second summary is kept whole, with no correction, activation or pause + expect(assistantViews(harness)).toEqual([ + { stopReason: "stop", text: SUMMARY_A }, + { stopReason: "toolUse", text: "" }, + { stopReason: "stop", text: SUMMARY_B }, + ]); + expect(corrections(harness)).toEqual([]); + expect(customEntries(harness, "rule-activation")).toEqual([]); + expect(customEntries(harness, "engine-paused")).toEqual([]); + }); + + it("still corrects a text-only repeat, after the reply has completed instead of cutting it off", async () => { + // given a model that repeats the same status with no work in between + harness = await createHarness({ extensionFactories: [ttsrExtension], tools: [echoTool] }); + harness.setResponses([ + fauxAssistantMessage([fauxText(STATUS)]), + fauxAssistantMessage([fauxText(STATUS)]), + fauxAssistantMessage([fauxToolCall("echo", { text: "act" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Took a concrete action; the frobnicate step is finished.")]), + ]); + + // when + await harness.session.prompt("frob the widget"); + await harness.session.prompt("continue"); + + // then one correction follows the complete repeated reply + expect(corrections(harness)).toHaveLength(1); + expect(assistantViews(harness)[1]).toEqual({ stopReason: "stop", text: STATUS }); + expect(customEntries(harness, "ttsr-loop-stopped")).toEqual([]); + }); + + it("stops a repeat right after the correction", async () => { + // given a model that keeps repeating even after the correction + harness = await createHarness({ extensionFactories: [ttsrExtension], tools: [echoTool] }); + harness.setResponses([ + fauxAssistantMessage([fauxText(STATUS)]), + fauxAssistantMessage([fauxText(STATUS)]), + fauxAssistantMessage([fauxText(STATUS)]), + fauxAssistantMessage([fauxText("unexpected extra turn")]), + ]); + + // when + await harness.session.prompt("frob the widget"); + await harness.session.prompt("continue"); + + // then the loop ends after one correction + expect(harness.faux.getCallLog()).toHaveLength(3); + expect(corrections(harness)).toHaveLength(1); + expect(customEntries(harness, "ttsr-loop-stopped")).toHaveLength(1); + }); + + it("writes no 'nudge queued' activation for a correction it suppresses", async () => { + // given a correction, a different reply, then another owner's automatic turn that repeats that reply, + // all with no user message and no tool call, so the rule fires again inside the same episode + const other = "Checked the queue again; the frobnicate step is still running on the second worker."; + let afterCorrection = false; + let sentOtherWork = false; + harness = await createHarness({ + extensionFactories: [ + ttsrExtension, + (pi) => { + pi.on("message_start", (event) => { + if (event.message.role === "custom" && event.message.customType === "ttsr-injection") { + afterCorrection = true; + } + }); + pi.on("agent_settled", () => { + if (!afterCorrection || sentOtherWork) return; + sentOtherWork = true; + pi.sendMessage( + { customType: "other-work", content: "do more", display: false }, + { triggerTurn: true }, + ); + }); + }, + ], + tools: [echoTool], + persistSession: true, + }); + await harness.session.bindExtensions({ mode: "rpc" }); + harness.setResponses([ + fauxAssistantMessage([fauxText(STATUS)]), + fauxAssistantMessage([fauxText(STATUS)]), + fauxAssistantMessage([fauxText(other)]), + fauxAssistantMessage([fauxText(other)]), + fauxAssistantMessage([fauxText("unexpected extra turn")]), + ]); + + // when + await harness.session.prompt("frob the widget"); + const idle = nextIdle(harness); + await harness.session.prompt("continue"); + await idle; + + // then the second hit is stopped, and only the correction actually sent has an activation notice + expect(harness.faux.getCallLog()).toHaveLength(4); + expect(corrections(harness)).toHaveLength(1); + expect(customEntries(harness, "ttsr-loop-stopped")).toHaveLength(1); + expect(customEntries(harness, "rule-activation")).toHaveLength(1); + }); +}); + +describe("senpi#4537 the one-correction bound is scoped to one repetition episode", () => { + const injection = { + type: "custom_message", + customType: "ttsr-injection", + details: { rules: ["repetitive-turns"] }, + } as unknown as SessionEntry; + const user = { type: "message", message: { role: "user", content: "go" } } as unknown as SessionEntry; + const acted = { + type: "message", + message: { role: "assistant", content: [{ type: "toolCall", id: "t1", name: "echo", arguments: {} }] }, + } as unknown as SessionEntry; + const toolResult = { + type: "message", + message: { role: "toolResult", toolName: "echo", content: [{ type: "text", text: "ok" }] }, + } as unknown as SessionEntry; + const statusOnly = { + type: "message", + message: { role: "assistant", content: [{ type: "text", text: STATUS }] }, + } as unknown as SessionEntry; + + it.each([ + { name: "a repeat right after the correction", entries: [user, injection, statusOnly], spent: true }, + { + name: "a new episode after the agent acted again", + entries: [user, injection, acted, toolResult, statusOnly], + spent: false, + }, + { name: "a new user message", entries: [injection, user, statusOnly], spent: false }, + { + name: "tool work before the correction", + entries: [user, acted, toolResult, injection, statusOnly], + spent: true, + }, + ])("treats the correction as spent only for $name -> $spent", ({ entries, spent }) => { + expect(ruleAlreadyCorrected(entries, "repetitive-turns")).toBe(spent); + }); +});