fix(coding-agent): honor ttsr.enabled: false in TtsrManager

`TtsrManager` previously ignored the `ttsr.enabled: false` toggle:
- `addRule()` still registered rules, so `bucketRules` demoted them
  to the TTSR bucket and dropped the rulebook/alwaysApply fallback
- `hasRules()` still returned true, so `AgentSession` entered the
  TTSR matching path on every message_update
- `checkDelta` / `checkSnapshot` (via `#matchBuffer`) still matched
  patterns and could abort the stream and inject system reminders

Gate all three public-ish entry points on `#settings.enabled` so
disabling TTSR short-circuits the whole path. Condition rules fall
through to the rulebook bucket in `bucketRules` instead of being
silently swallowed.

Closes #1767
This commit is contained in:
QianYan-Art
2026-06-06 04:05:04 -07:00
parent d9f5a8c7ac
commit 55c4efec4e
4 changed files with 90 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `ttsr.enabled: false` being ignored at runtime. TTSR rules were still being registered with `TtsrManager.addRule` and matched against stream deltas even when the global toggle was off, so disabling TTSR did not suppress rule injection or stream abort. The manager now gates `addRule`, `hasRules`, and `#matchBuffer` on the enabled flag, so disabling fully short-circuits the TTSR path. Condition rules fall through to the rulebook bucket instead of being silently swallowed. ([#1767](https://github.com/can1357/oh-my-pi/issues/1767))
## [15.9.5] - 2026-06-05
### Added
+9
View File
@@ -294,6 +294,9 @@ export class TtsrManager {
/** Add a TTSR rule to be monitored. */
addRule(rule: Rule): boolean {
if (!this.#settings.enabled) {
return false;
}
if (this.#rules.has(rule.name)) {
return false;
}
@@ -357,6 +360,9 @@ export class TtsrManager {
}
#matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
if (!this.#settings.enabled) {
return [];
}
const matches: Rule[] = [];
for (const [name, entry] of this.#rules) {
if (!this.#canTrigger(name)) {
@@ -433,6 +439,9 @@ export class TtsrManager {
/** Check if any TTSR rules are registered. */
hasRules(): boolean {
if (!this.#settings.enabled) {
return false;
}
return this.#rules.size > 0;
}
@@ -96,4 +96,23 @@ describe("bucketRules", () => {
expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" }).map(r => r.name)).toEqual(["builtin-foo"]);
});
it("falls condition rules through to the rulebook when ttsr is disabled on the manager", () => {
const mgr = new TtsrManager({
enabled: false,
contextMode: "discard",
interruptMode: "always",
repeatMode: "once",
repeatGap: 10,
});
const ttsr = makeRule({ name: "no-foo", condition: ["FORBIDDEN"], description: "blocks foo" });
const { rulebookRules, alwaysApplyRules } = bucketRules([ttsr], mgr);
// Manager refused to register; condition rule degrades to its rulebook shape.
expect(mgr.hasRules()).toBe(false);
expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" })).toEqual([]);
expect(alwaysApplyRules.map(r => r.name)).toEqual([]);
expect(rulebookRules.map(r => r.name)).toEqual(["no-foo"]);
});
});
+58
View File
@@ -1,9 +1,21 @@
import { describe, expect, it } from "bun:test";
import * as path from "node:path";
import { parseRuleConditionAndScope, type Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
import type { TtsrSettings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EDIT_MODE_STRATEGIES } from "@oh-my-pi/pi-coding-agent/edit";
import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr";
function ttsrManager(overrides: Partial<TtsrSettings> = {}): TtsrManager {
return new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "once",
repeatGap: 10,
...overrides,
});
}
function makeRule(partial: Partial<Rule>): Rule {
return {
name: partial.name ?? "rule",
@@ -274,6 +286,52 @@ describe("TtsrManager scope matching", () => {
});
});
describe("TtsrManager enabled gate", () => {
it("rejects registration when ttsr is disabled", () => {
const manager = ttsrManager({ enabled: false });
const rule = makeRule({
name: "no-foo",
condition: ["FORBIDDEN"],
scope: ["text"],
});
expect(manager.addRule(rule)).toBe(false);
});
it("reports no rules when ttsr is disabled, even after a registration attempt", () => {
const manager = ttsrManager({ enabled: false });
manager.addRule(
makeRule({
name: "no-foo",
condition: ["FORBIDDEN"],
scope: ["text"],
}),
);
expect(manager.hasRules()).toBe(false);
});
it("returns no matches from stream deltas when ttsr is disabled", () => {
const manager = ttsrManager({ enabled: false });
expect(manager.checkDelta("contains FORBIDDEN token", { source: "text" })).toEqual([]);
expect(manager.checkDelta("FORBIDDEN", { source: "tool", toolName: "edit" })).toEqual([]);
});
it("preserves the default (enabled) registration and matching contract", () => {
const manager = ttsrManager();
const rule = makeRule({
name: "no-foo",
condition: ["FORBIDDEN"],
scope: ["text"],
});
expect(manager.addRule(rule)).toBe(true);
expect(manager.hasRules()).toBe(true);
expect(manager.checkDelta("FORBIDDEN", { source: "text" })).toEqual([rule]);
});
});
describe("TtsrManager snapshot matching", () => {
it("matches source-level conditions against a tool digest where the raw patch grammar fails", () => {
const manager = new TtsrManager();