From 2249529e70386cd778da0136711d706d11e6f1f7 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 18 Feb 2026 01:38:22 +0100 Subject: [PATCH] feat(coding-agent): added TTSR injection tracking and deduplication - Added TTSR injection tracking with per-turn recording and deduplication to prevent repeated rule injections within the same turn. - Changed TTSR message format to use custom message type with metadata fields for improved injection tracking and session persistence. - Fixed TTSR repeat-after-gap mode to correctly restore injected rules from previous sessions and recalculate gap thresholds. - Added test suite with 6 test cases covering TTSR repeat modes (once, after-gap, restored) and injection deduplication behavior. --- packages/coding-agent/CHANGELOG.md | 5 +- packages/coding-agent/src/export/ttsr.ts | 18 ++- packages/coding-agent/src/sdk.ts | 3 + .../coding-agent/src/session/agent-session.ts | 61 +++++++-- packages/coding-agent/test/ttsr.test.ts | 125 ++++++++++++++++++ 5 files changed, 191 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f88538bcf..c4cb112dd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added `condition` and `scope` fields to rule frontmatter for advanced TTSR matching and stream filtering @@ -13,6 +12,9 @@ ### Changed +- Changed TTSR injection tracking to record all turns where rules were injected (instead of only the last turn) to support repeat-after-gap mode across resumed sessions +- Changed TTSR injection messages to use custom message type with metadata instead of synthetic user messages for better session tracking +- Changed TTSR rule injection to persist injected rule names in session state for restoration when resuming sessions - Changed model discovery to automatically discover built-in provider models (Anthropic, OpenAI, Groq, Cerebras, Xai, Mistral, OpenCode, OpenRouter, Vercel AI Gateway, Kimi Code, GitHub Copilot, Google, Cursor, Google Antigravity, Google Gemini CLI, OpenAI Codex) when credentials are configured - Changed `getModel()` and `getModels()` imports to `getBundledModel()` and `getBundledModels()` across test utilities - Changed TTSR rule matching from single `ttsrTrigger` regex to multiple `condition` patterns with scope filtering @@ -23,6 +25,7 @@ ### Fixed +- Fixed TTSR repeat-after-gap mode to correctly calculate gaps when rules are restored from previous sessions - Fixed TTSR matching to respect tool-specific scope filters, preventing cross-tool rule contamination - Fixed path normalization in TTSR glob matching to handle both relative and absolute path variants diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index e6eb3b0cf..82365396b 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -42,10 +42,10 @@ interface TtsrEntry { globalPathGlobs?: Bun.Glob[]; } -/** Tracks when a rule was last injected (for repeat-after-gap mode). */ +/** Tracks the turns where a rule was injected (for repeat gating). */ interface InjectionRecord { - /** Message count when the rule was last injected. */ - lastInjectedAt: number; + /** Message counts (turn indexes) when the rule was injected. */ + injectedAtTurns: number[]; } const DEFAULT_SETTINGS: Required = { @@ -85,7 +85,8 @@ export class TtsrManager { return false; } - const gap = this.#messageCount - record.lastInjectedAt; + const lastInjectedAt = record.injectedAtTurns[record.injectedAtTurns.length - 1]; + const gap = this.#messageCount - lastInjectedAt; return gap >= this.#settings.repeatGap; } @@ -369,7 +370,12 @@ export class TtsrManager { /** Mark rules as injected (won't trigger again until conditions allow). */ markInjected(rulesToMark: Rule[]): void { for (const rule of rulesToMark) { - this.#injectionRecords.set(rule.name, { lastInjectedAt: this.#messageCount }); + const record = this.#injectionRecords.get(rule.name); + if (!record) { + this.#injectionRecords.set(rule.name, { injectedAtTurns: [this.#messageCount] }); + } else if (record.injectedAtTurns[record.injectedAtTurns.length - 1] !== this.#messageCount) { + record.injectedAtTurns.push(this.#messageCount); + } logger.debug("TTSR rule marked as injected", { ruleName: rule.name, messageCount: this.#messageCount, @@ -386,7 +392,7 @@ export class TtsrManager { /** Restore injected state from a list of rule names. */ restoreInjected(ruleNames: string[]): void { for (const name of ruleNames) { - this.#injectionRecords.set(name, { lastInjectedAt: 0 }); + this.#injectionRecords.set(name, { injectedAtTurns: [0] }); } if (ruleNames.length > 0) { logger.debug("TTSR injected state restored", { ruleNames }); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 7a76285e8..783df1395 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -680,6 +680,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } } + if (existingSession.injectedTtsrRules.length > 0) { + ttsrManager.restoreInjected(existingSession.injectedTtsrRules); + } time("discoverTtsrRules"); // Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cc39b72e2..7e9f90b78 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -494,13 +494,11 @@ export class AgentSession { return; } - const latestMessage = this.agent.state.messages[this.agent.state.messages.length - 1]; + const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp); if ( !this.#ttsrAbortPending || this.#promptGeneration !== generation || - !latestMessage || - latestMessage.role !== "assistant" || - (targetMessageTimestamp !== undefined && latestMessage.timestamp !== targetMessageTimestamp) + targetAssistantIndex === -1 ) { this.#ttsrAbortPending = false; this.#pendingTtsrInjections = []; @@ -509,19 +507,23 @@ export class AgentSession { this.#ttsrAbortPending = false; const ttsrSettings = this.#ttsrManager?.getSettings(); if (ttsrSettings?.contextMode === "discard") { - // Remove the partial/aborted message from agent state - this.agent.popMessage(); + // Remove the partial/aborted assistant turn from agent state + this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex)); } // Inject TTSR rules as system reminder before retry const injection = this.#getTtsrInjectionContent(); if (injection) { + const details = { rules: injection.rules.map(rule => rule.name) }; this.agent.appendMessage({ - role: "user", - content: [{ type: "text", text: injection.content }], + role: "custom", + customType: "ttsr-injection", + content: injection.content, + display: false, + details, timestamp: Date.now(), - synthetic: true, }); - this.#ttsrManager?.markInjected(injection.rules); + this.sessionManager.appendCustomMessageEntry("ttsr-injection", injection.content, false, details); + this.#markTtsrInjected(injection.rules); } this.agent.continue().catch(() => {}); }, 50); @@ -671,6 +673,28 @@ export class AgentSession { } } + #markTtsrInjected(rules: Rule[]): void { + if (rules.length === 0) { + return; + } + this.#ttsrManager?.markInjected(rules); + this.sessionManager.appendTtsrInjection(rules.map(rule => rule.name)); + } + + #findTtsrAssistantIndex(targetTimestamp: number | undefined): number { + const messages = this.agent.state.messages; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role !== "assistant") { + continue; + } + if (targetTimestamp === undefined || message.timestamp === targetTimestamp) { + return i; + } + } + return -1; + } + #shouldInterruptForTtsrMatch(matchContext: TtsrMatchContext): boolean { const mode = this.#ttsrManager?.getSettings().interruptMode ?? "always"; if (mode === "never") { @@ -699,12 +723,21 @@ export class AgentSession { return; } this.agent.followUp({ - role: "user", - content: [{ type: "text", text: injection.content }], + role: "custom", + customType: "ttsr-injection", + content: injection.content, + display: false, + details: { rules: injection.rules.map(rule => rule.name) }, timestamp: Date.now(), - synthetic: true, }); - this.#ttsrManager?.markInjected(injection.rules); + this.#markTtsrInjected(injection.rules); + // followUp() only enqueues; resume on the next tick once streaming settles. + setTimeout(() => { + if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) { + return; + } + this.agent.continue().catch(() => {}); + }, 0); } /** Build TTSR match context for tool call argument deltas. */ diff --git a/packages/coding-agent/test/ttsr.test.ts b/packages/coding-agent/test/ttsr.test.ts index f92f2ada1..34256491b 100644 --- a/packages/coding-agent/test/ttsr.test.ts +++ b/packages/coding-agent/test/ttsr.test.ts @@ -272,3 +272,128 @@ describe("TtsrManager scope matching", () => { ).toEqual([rule]); }); }); + +describe("TtsrManager repeat behavior", () => { + const turnContext = { source: "text" as const }; + + function createRepeatRule(name = "repeat-rule"): Rule { + return makeRule({ + name, + condition: ["forbidden"], + scope: ["text"], + }); + } + + function runTurn(manager: TtsrManager, rule: Rule): Rule[] { + manager.resetBuffer(); + const matches = manager.checkDelta("forbidden", turnContext); + if (matches.length > 0) { + manager.markInjected([rule]); + } + manager.incrementMessageCount(); + return matches; + } + + it("never repeats when repeat mode is once", () => { + const manager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "once", + repeatGap: 10, + }); + const rule = createRepeatRule("once"); + manager.addRule(rule); + + expect(runTurn(manager, rule)).toEqual([rule]); + expect(runTurn(manager, rule)).toEqual([]); + expect(runTurn(manager, rule)).toEqual([]); + }); + + it("repeats every turn when repeat mode is after-gap and gap is 1", () => { + const manager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "after-gap", + repeatGap: 1, + }); + const rule = createRepeatRule("gap-1"); + manager.addRule(rule); + + expect(runTurn(manager, rule)).toEqual([rule]); + expect(runTurn(manager, rule)).toEqual([rule]); + expect(runTurn(manager, rule)).toEqual([rule]); + }); + + it("respects repeat gap when repeat mode is after-gap", () => { + const manager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "after-gap", + repeatGap: 2, + }); + const rule = createRepeatRule("gap-2"); + manager.addRule(rule); + + expect(runTurn(manager, rule)).toEqual([rule]); + expect(runTurn(manager, rule)).toEqual([]); + expect(runTurn(manager, rule)).toEqual([rule]); + expect(runTurn(manager, rule)).toEqual([]); + expect(runTurn(manager, rule)).toEqual([rule]); + }); + + it("blocks restored rules in once mode across resumed sessions", () => { + const manager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "once", + repeatGap: 10, + }); + const rule = createRepeatRule("restored-once"); + manager.addRule(rule); + manager.restoreInjected([rule.name]); + + expect(runTurn(manager, rule)).toEqual([]); + expect(runTurn(manager, rule)).toEqual([]); + }); + + it("applies repeat gap to restored rules in after-gap mode", () => { + const manager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "after-gap", + repeatGap: 2, + }); + const rule = createRepeatRule("restored-gap"); + manager.addRule(rule); + manager.restoreInjected([rule.name]); + + expect(runTurn(manager, rule)).toEqual([]); + expect(runTurn(manager, rule)).toEqual([]); + expect(runTurn(manager, rule)).toEqual([rule]); + }); + + it("tracks only one injection record per rule per turn", () => { + const manager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "after-gap", + repeatGap: 1, + }); + const rule = createRepeatRule("single-record"); + manager.addRule(rule); + + manager.markInjected([rule]); + manager.markInjected([rule]); + manager.markInjected([rule]); + expect(manager.getInjectedRuleNames()).toEqual([rule.name]); + + manager.incrementMessageCount(); + expect(manager.checkDelta("forbidden", turnContext)).toEqual([rule]); + }); +});