From eda1a1056b241ce5eef4a5a07bdb89b8a4d80b73 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 3 Jun 2026 10:05:24 +0200 Subject: [PATCH] feat(coding-agent): added matcherDigest hook for TTSR wire-format normalization - Added optional `AgentTool.matcherDigest(args)` hook so tools can expose plain source text instead of wire-encoded arguments to TTSR rule matchers. - Implemented `matcherDigest` on edit (all modes: hashline, patch, apply_patch, replace) and write tools, stripping patch prefixes and JSON escaping. - Added `TtsrManager.checkSnapshot()` to replace the scoped buffer with a tool digest rather than appending raw deltas. - Fixed TTSR conditions never matching streamed edit/write calls whose wire format obscured real content. --- packages/agent/CHANGELOG.md | 4 ++ packages/agent/src/types.ts | 9 +++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/edit/index.ts | 10 +++ packages/coding-agent/src/edit/streaming.ts | 65 +++++++++++++++++++ packages/coding-agent/src/export/ttsr.ts | 19 +++++- .../coding-agent/src/session/agent-session.ts | 52 ++++++++++++--- packages/coding-agent/src/tools/write.ts | 6 ++ .../test/edit-streaming-preview.test.ts | 52 +++++++++++++++ packages/coding-agent/test/ttsr.test.ts | 63 ++++++++++++++++++ 10 files changed, 272 insertions(+), 9 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 02beb32d8..bb86bf11e 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added optional `AgentTool.matcherDigest(args)` hook: tools whose streamed arguments encode content in a wire grammar (patch formats, escaped strings) can expose the real content they introduce, so stream-content matchers (e.g. TTSR rules) run against plain source text instead of the wire format. + ## [15.8.0] - 2026-06-02 ### Fixed diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index acc835f36..b58b21a1a 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -441,6 +441,15 @@ export interface AgentTool>) => string | undefined); + /** + * Normalize (potentially partial) streamed arguments into the plain text that + * stream-content matchers (e.g. TTSR rules) should inspect — the real content + * the call introduces, without wire grammar such as patch prefixes or JSON + * string escaping. Return `undefined` to fall back to raw argument-delta + * matching. + */ + matcherDigest?: (args: unknown) => string | undefined; + /** Capability tier declaration used by approval gates. Omitted means "exec". */ approval?: ToolApproval; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index af364e4d1..4234bf9c9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed `/review`'s uncommitted-change mode in Jujutsu repositories to read `jj diff --git` from the current workspace, so non-default JJ workspaces include their working-copy changes instead of falling back to the colocated Git checkout. - Fixed empty assistant stop retry continuations preserving auto-retry state until a non-empty assistant turn completes or recovery reaches its retry cap. +- Fixed TTSR rule conditions never matching streamed `edit`/`write` tool calls whose wire format obscures the real content (hashline `+` body rows, apply_patch envelopes, JSON-escaped `write` content). The edit and write tools now expose a `matcherDigest` normalization and TTSR matches against the introduced source text, so rule regexes stay universal regardless of the active edit mode. ### Changed diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 28128c7c9..6686c4d5c 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -24,6 +24,7 @@ import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" }; import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch"; import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace"; import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer"; +import { EDIT_MODE_STRATEGIES } from "./streaming"; export * from "@oh-my-pi/hashline"; export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode"; @@ -360,6 +361,15 @@ export class EditTool implements AgentTool { return "apply_patch"; } + /** + * Normalize streamed args into the source text this edit introduces, so + * stream matchers (TTSR rules) run against real file content instead of the + * mode-specific patch grammar. + */ + matcherDigest(args: unknown): string | undefined { + return EDIT_MODE_STRATEGIES[this.mode].matcherDigest(args); + } + async execute( _toolCallId: string, params: EditParams, diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index fcc33f628..b411ed70b 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -69,6 +69,14 @@ export interface EditStreamingStrategy { * compute returned `null` because args are still too partial). */ renderStreamingFallback(args: Args, uiTheme: Theme): string; + /** + * Project the (potentially partial) args onto the plain text the edit + * introduces into files — added lines without patch grammar — so stream + * matchers (TTSR rules) can run source-level patterns against real content + * instead of the mode-specific wire format. Returns `undefined` when the + * args don't yet carry any content. + */ + matcherDigest(args: Args): string | undefined; } // ----------------------------------------------------------------------------- @@ -161,6 +169,28 @@ function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map< return groups; } +/** + * Extract the lines a patch-style payload adds (`+` prefix, excluding `+++ ` + * file headers), stripped of the prefix. When the text carries no added lines, + * returns the whole text if `fallbackToWhole` (full-content payloads such as a + * `create` op), otherwise an empty string (grammar-only payloads). + */ +function extractAddedLines(text: string, fallbackToWhole: boolean): string { + let added: string | undefined; + let lineStart = 0; + while (lineStart <= text.length) { + let lineEnd = text.indexOf("\n", lineStart); + if (lineEnd === -1) lineEnd = text.length; + if (text.charCodeAt(lineStart) === 43 /* + */ && !text.startsWith("+++ ", lineStart)) { + const line = text.slice(lineStart + 1, lineEnd); + added = added === undefined ? line : `${added}\n${line}`; + } + lineStart = lineEnd + 1; + } + if (added === undefined) return fallbackToWhole ? text : ""; + return added; +} + // ----------------------------------------------------------------------------- // Strategies // ----------------------------------------------------------------------------- @@ -196,6 +226,16 @@ const replaceStrategy: EditStreamingStrategy = { renderStreamingFallback() { return ""; }, + matcherDigest(args) { + const edits = args?.edits; + if (!Array.isArray(edits)) return undefined; + let digest: string | undefined; + for (const edit of edits) { + if (typeof edit?.new_text !== "string") continue; + digest = digest === undefined ? edit.new_text : `${digest}\n${edit.new_text}`; + } + return digest; + }, }; interface PatchArgs { @@ -225,6 +265,19 @@ const patchStrategy: EditStreamingStrategy = { renderStreamingFallback() { return ""; }, + matcherDigest(args) { + const edits = args?.edits; + if (!Array.isArray(edits)) return undefined; + let digest: string | undefined; + for (const edit of edits) { + if (typeof edit?.diff !== "string") continue; + // `create` ops carry full file content in `diff` with no +/- markers; + // pass that content through whole. + const added = extractAddedLines(edit.diff, true); + digest = digest === undefined ? added : `${digest}\n${added}`; + } + return digest; + }, }; interface HashlineArgs { @@ -378,6 +431,12 @@ const hashlineStrategy: EditStreamingStrategy = { // than a sigil dump. return ""; }, + matcherDigest(args) { + const input = args?.input; + if (typeof input !== "string") return undefined; + // Body rows are `+TEXT`; headers and op lines are grammar, never content. + return extractAddedLines(input, false); + }, }; interface ApplyPatchArgs { @@ -430,6 +489,12 @@ const applyPatchStrategy: EditStreamingStrategy = { renderStreamingFallback() { return ""; }, + matcherDigest(args) { + const input = args?.input; + if (typeof input !== "string") return undefined; + // Envelope markers and `@@` hunk headers are grammar, never content. + return extractAddedLines(input, false); + }, }; export const EDIT_MODE_STRATEGIES: Record> = { replace: replaceStrategy as EditStreamingStrategy, diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index ae0710bdf..fa973b420 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -339,7 +339,24 @@ export class TtsrManager { const bufferKey = this.#bufferKey(context); const nextBuffer = `${this.#buffers.get(bufferKey) ?? ""}${delta}`; this.#buffers.set(bufferKey, nextBuffer); + return this.#matchBuffer(nextBuffer, context); + } + /** + * Replace the scoped buffer with a tool-provided normalized snapshot and + * return matching rules. + * + * Used for tools exposing `matcherDigest`: the digest is recomputed from the + * full (partial) arguments on every delta, so it replaces the buffer instead + * of being appended to it. + */ + checkSnapshot(snapshot: string, context: TtsrMatchContext): Rule[] { + const bufferKey = this.#bufferKey(context); + this.#buffers.set(bufferKey, snapshot); + return this.#matchBuffer(snapshot, context); + } + + #matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] { const matches: Rule[] = []; for (const [name, entry] of this.#rules) { if (!this.#canTrigger(name)) { @@ -351,7 +368,7 @@ export class TtsrManager { if (!this.#matchesGlobalPaths(entry, context)) { continue; } - if (!this.#matchesCondition(entry, nextBuffer)) { + if (!this.#matchesCondition(entry, buffer)) { continue; } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f72b23a35..9725156f0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1604,17 +1604,19 @@ export class AgentSession { if (event.type === "message_update" && this.#ttsrManager?.hasRules()) { const assistantEvent = event.assistantMessageEvent; let matchContext: TtsrMatchContext | undefined; + let streamingToolCall: ToolCall | undefined; if (assistantEvent.type === "text_delta") { matchContext = { source: "text" }; } else if (assistantEvent.type === "thinking_delta") { matchContext = { source: "thinking" }; } else if (assistantEvent.type === "toolcall_delta") { - matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex); + streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex); + matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex); } if (matchContext && "delta" in assistantEvent) { - const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext); + const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall); if (matches.length > 0) { // Decide first: a non-interrupting tool-source match attaches to the // specific tool call's result instead of driving a loop-wide follow-up. @@ -2285,30 +2287,64 @@ export class AgentSession { }); } - /** Build TTSR match context for tool call argument deltas. */ - #getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext { - const context: TtsrMatchContext = { source: "tool" }; + /** Extract the tool-call block a toolcall_delta event refers to, if present. */ + #getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined { if (message.role !== "assistant") { - return context; + return undefined; } const content = message.content; if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) { - return context; + return undefined; } const block = content[contentIndex]; if (!block || typeof block !== "object" || block.type !== "toolCall") { + return undefined; + } + + return block as ToolCall; + } + + /** Build TTSR match context for tool call argument deltas. */ + #getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext { + const context: TtsrMatchContext = { source: "tool" }; + if (!toolCall) { return context; } - const toolCall = block as ToolCall; context.toolName = toolCall.name; context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`; context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments); return context; } + /** + * Match a stream delta against TTSR rules. + * + * Tool argument streams prefer the tool's `matcherDigest` normalization — the + * real content the call introduces — over the raw argument delta, so rule + * conditions written against source text keep working regardless of the + * tool's wire format (hashline patches, JSON-escaped strings, ...). + */ + #checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] { + const manager = this.#ttsrManager; + if (!manager) { + return []; + } + if (toolCall) { + const tools = this.agent.state.tools; + const tool = + tools.find(t => t.name === toolCall.name) ?? + tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name); + const digest = tool?.matcherDigest?.(toolCall.arguments ?? {}); + if (digest !== undefined) { + return manager.checkSnapshot(digest, matchContext); + } + } + return manager.checkDelta(delta, matchContext); + } + /** Extract path-like arguments from tool call payload for TTSR glob matching. */ #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) { diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index f3f037f4d..9d6e0086f 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -273,6 +273,12 @@ export class WriteTool implements AgentTool).content; + return typeof content === "string" ? content : undefined; + } + readonly #writethrough: WritethroughCallback; constructor(private readonly session: ToolSession) { diff --git a/packages/coding-agent/test/edit-streaming-preview.test.ts b/packages/coding-agent/test/edit-streaming-preview.test.ts index ae263797f..7e808b736 100644 --- a/packages/coding-agent/test/edit-streaming-preview.test.ts +++ b/packages/coding-agent/test/edit-streaming-preview.test.ts @@ -242,3 +242,55 @@ describe("apply_patch streaming preview (trailing partial line)", () => { expect(cIdx).toBeGreaterThan(posB); }); }); + +describe("matcherDigest", () => { + test("hashline: digests stripped `+` body rows only, never headers or op lines", () => { + const input = ["¶a.ts#AB12", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n"); + expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input })).toBe("const x = 1;\nconst y = 2;"); + }); + + test("hashline: grammar-only payload digests to empty, missing input to undefined", () => { + expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "¶a.ts#AB12\ndelete 3\n" })).toBe(""); + expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({})).toBeUndefined(); + }); + + test("apply_patch: digests added lines, never envelope markers or context", () => { + const input = [ + "*** Begin Patch", + "*** Update File: a.ts", + "@@", + " const a = 1;", + "-const old = 1;", + "+const fresh = 2;", + "*** End Patch", + "", + ].join("\n"); + expect(EDIT_MODE_STRATEGIES.apply_patch.matcherDigest({ input })).toBe("const fresh = 2;"); + }); + + test("patch: digests added lines from diffs and passes create content through whole", () => { + expect( + EDIT_MODE_STRATEGIES.patch.matcherDigest({ + edits: [{ diff: " ctx\n-removed line\n+added line\n" }], + }), + ).toBe("added line"); + const createContent = "full file content\nwith no diff markers\n"; + expect( + EDIT_MODE_STRATEGIES.patch.matcherDigest({ + edits: [{ op: "create", diff: createContent }], + }), + ).toBe(createContent); + }); + + test("replace: digests new_text of every edit", () => { + expect( + EDIT_MODE_STRATEGIES.replace.matcherDigest({ + edits: [ + { old_text: "a", new_text: "const b = 1;" }, + { old_text: "c", new_text: "const d = 2;" }, + ], + }), + ).toBe("const b = 1;\nconst d = 2;"); + expect(EDIT_MODE_STRATEGIES.replace.matcherDigest({})).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/ttsr.test.ts b/packages/coding-agent/test/ttsr.test.ts index 34256491b..2053cdc9c 100644 --- a/packages/coding-agent/test/ttsr.test.ts +++ b/packages/coding-agent/test/ttsr.test.ts @@ -1,6 +1,7 @@ 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 { EDIT_MODE_STRATEGIES } from "@oh-my-pi/pi-coding-agent/edit"; import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr"; function makeRule(partial: Partial): Rule { @@ -273,6 +274,68 @@ describe("TtsrManager scope matching", () => { }); }); +describe("TtsrManager snapshot matching", () => { + it("matches source-level conditions against a tool digest where the raw patch grammar fails", () => { + const manager = new TtsrManager(); + const rule = makeRule({ + name: "ts-no-tiny-functions", + condition: ["\\{\\s*return [^;{}\\n]+;?\\s*\\}"], + scope: ["tool:edit(*.ts)"], + }); + manager.addRule(rule); + + const context = { + source: "tool" as const, + toolName: "edit", + filePaths: ["src/repo.ts"], + streamKey: "toolcall:tc-1", + }; + const patch = [ + "¶src/repo.ts#AB12", + "replace block 1:", + "+export async function isRepository(cwd: string): Promise {", + "+\treturn repo.isRepository(cwd);", + "+}", + "", + ].join("\n"); + + // Raw patch grammar: `+` body-row prefixes break source-level regexes. + expect(manager.checkDelta(patch, context)).toEqual([]); + + // The edit tool's digest of the same patch is real source text and matches. + const digest = EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: patch }); + expect(digest).toBe( + [ + "export async function isRepository(cwd: string): Promise {", + "\treturn repo.isRepository(cwd);", + "}", + ].join("\n"), + ); + expect(manager.checkSnapshot(digest as string, context)).toEqual([rule]); + }); + + it("replaces the scoped buffer instead of appending snapshots", () => { + const manager = new TtsrManager(); + const rule = makeRule({ + name: "no-as-any", + condition: ["as any"], + scope: ["tool:edit(*.ts)"], + }); + manager.addRule(rule); + + const context = { + source: "tool" as const, + toolName: "edit", + filePaths: ["src/main.ts"], + streamKey: "toolcall:tc-2", + }; + + expect(manager.checkSnapshot("const x = y as any;", context)).toEqual([rule]); + // A later digest without the pattern must not match stale buffered text. + expect(manager.checkSnapshot("const x = y as string;", context)).toEqual([]); + }); +}); + describe("TtsrManager repeat behavior", () => { const turnContext = { source: "text" as const };