fix(coding-agent): split per-file TTSR digests for multi-file edits

PR #3648 review (codex): the first iteration emitted every envelope
path alongside one combined digest, so a multi-file payload that added
`: any` to a README.md hunk and merely touched src/ok.ts would surface
a *.ts path in the TTSR match context and trip the bundled
tool:edit(*.ts) ts-no-any rule on text that belonged to the Markdown
hunk — aborting valid edits under interruptMode:always.

Add a per-file matcherEntries(args) hook on AgentTool / EditStreaming-
Strategy returning [{ path, digest }] entries, one per touched file
(same-path sections/hunks merged):
- replace / patch: one entry from the top-level path + matcherDigest
- hashline: regex-split by [path#TAG] section, body added-lines per
  entry (tolerant of streaming partial payloads)
- apply_patch: expandApplyPatchToPreviewEntries grouped by path

AgentSession.#checkTtsrStream / #checkTtsrAstStream now prefer
matcherEntries and iterate per-file with isolated filePaths + streamKey,
so each file's buffer and repeat-tracking are independent. Tools
without matcherEntries keep the existing combined matcherDigest +
matcherPaths path.
This commit is contained in:
roboomp
2026-06-27 11:22:32 +00:00
parent 33e425594e
commit ae87caeb2a
7 changed files with 333 additions and 6 deletions
+1
View File
@@ -5,6 +5,7 @@
### Added
- Added optional `AgentTool.matcherPaths(args)` hook: tools whose streamed arguments embed target file paths inside the wire payload (patch envelope markers, hashline section headers) can surface those paths so path-scoped stream matchers (e.g. TTSR `tool:edit(*.ts)` globs) match without a top-level `path` / `paths` argument. Returns `undefined` (or an empty list) to fall back to the caller's existing top-level argument scan.
- Added optional `AgentTool.matcherEntries(args)` hook: returns one `{ path, digest }` entry per file a (potentially partial) streamed call touches, so path-scoped stream matchers evaluate each file in isolation. Multi-file payloads (multi-section hashline, multi-hunk apply_patch) MUST split here — otherwise a combined digest from a sibling Markdown hunk would leak into the `.ts` path scope and trip rules like `tool:edit(*.ts)` `ts-no-any` on text that does not belong to a TypeScript file. Takes precedence over `matcherDigest` + `matcherPaths` when present; returns `undefined` (or empty) to fall back to the combined hooks.
### Removed
+11
View File
@@ -643,6 +643,17 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
*/
matcherPaths?: (args: unknown) => readonly string[] | undefined;
/**
* Per-file projection of a (potentially partial) streamed call, pairing each
* touched file path with the digest of only the lines added to that file.
* Path-scoped stream matchers (TTSR) evaluate each entry in isolation, so a
* scoped rule like `tool:edit(*.ts)` never fires on text that actually
* belongs to a sibling Markdown hunk in a multi-file payload. Takes
* precedence over {@link matcherDigest} + {@link matcherPaths} when present;
* returns `undefined` (or empty) to fall back to the combined hooks.
*/
matcherEntries?: (args: unknown) => readonly { path: string; digest: string }[] | undefined;
/** Capability tier declaration used by approval gates. Omitted means "exec". */
approval?: ToolApproval;
+1
View File
@@ -27,6 +27,7 @@
- Fixed marketplace-installed plugins appearing in both the npm plugin list and the OMP extension-package status provider. ([#3628](https://github.com/can1357/oh-my-pi/issues/3628))
- Fixed inconsistent OpenRouter prompt-cache hits on `/advisor` turns: `AgentSession.#buildAdvisorRuntime` (`packages/coding-agent/src/session/agent-session.ts`) constructed the advisor `Agent` without the provider-shaping options the SDK installs on the main agent — the `streamFn` wrapper that applies `providers.openrouterVariant`, `providers.antigravityEndpoint`, `providers.maxInFlightRequests`, and `model.loopGuard.*`; the `onPayload`/`onResponse`/`onSseEvent` hooks; the shared `providerSessionState` map; `transformProviderContext` (snapcompact, secret obfuscation, image clamping); and a stable `promptCacheKey`. Advisor turns therefore dropped the OpenRouter sticky-routing variant suffix, used a different `prompt_cache_key` than the main turn, and skipped the per-session provider hooks — producing intermittent OpenRouter response-cache misses across consecutive advisor calls. The advisor now reuses the same wrapper through a new shared `createSettingsAwareStreamFn` helper (`packages/coding-agent/src/session/settings-stream-fn.ts`) and inherits every provider hook the main agent does. ([#3639](https://github.com/can1357/oh-my-pi/issues/3639))
- Fixed hashline (and `apply_patch`) edit streams skipping path-scoped TTSR rules (e.g. the bundled `ts-no-any` rule scoped to `tool:edit(*.ts)`). `AgentSession.#getTtsrToolMatchContext` only scanned top-level `path` / `paths` arguments, so for an `edit` call whose only path lived inside the hashline `[demo.ts#TAG]` section header (or an `apply_patch` `*** Update File: …` marker), the TTSR match context arrived without any `filePaths` and the path glob never matched. The edit tool now exposes a mode-aware `matcherPaths(args)` companion to `matcherDigest(args)`: `replace` / `patch` reuse the top-level `path`, `hashline` parses `[path#TAG]` section headers, and `apply_patch` parses `*** Add/Update/Delete File:` envelope markers (all tolerant of streaming partial payloads). The session consults `tool.matcherPaths` first and falls back to the generic top-level argument scan for tools that don't implement it. ([#3646](https://github.com/can1357/oh-my-pi/issues/3646))
- Fixed multi-file hashline / `apply_patch` edit streams leaking added lines across file scopes when evaluated against path-scoped TTSR rules. The first iteration of #3646's fix combined every section/hunk into one digest while emitting every envelope path, so a multi-file payload that added `: any` to a `README.md` hunk and merely touched `src/ok.ts` would surface a `*.ts` path alongside the combined digest and trip the bundled `tool:edit(*.ts)` `ts-no-any` rule on text that actually belonged to the Markdown hunk — aborting/retrying valid edits under `interruptMode: always`. The edit tool now exposes a per-file `matcherEntries(args)` hook (companion to `matcherDigest` + `matcherPaths`): hashline splits by `[path#TAG]` section, `apply_patch` splits by envelope marker via `expandApplyPatchToPreviewEntries`, same-path sections/hunks merge into one entry, and `AgentSession.#checkTtsrStream` / `#checkTtsrAstStream` iterate per file with isolated `filePaths` + `streamKey`. ([#3648](https://github.com/can1357/oh-my-pi/pull/3648))
## [16.2.1] - 2026-06-27
+11
View File
@@ -407,6 +407,17 @@ export class EditTool implements AgentTool<TInput> {
return EDIT_MODE_STRATEGIES[this.mode].matcherPaths(args);
}
/**
* Per-file projection of the streamed args, splitting multi-section
* hashline / multi-hunk apply_patch payloads into one (path, digest) entry
* per touched file. Path-scoped stream matchers (TTSR) then evaluate each
* file in isolation, so a `tool:edit(*.ts)` rule never fires on text that
* actually belongs to a sibling Markdown hunk.
*/
matcherEntries(args: unknown): readonly { path: string; digest: string }[] | undefined {
return EDIT_MODE_STRATEGIES[this.mode].matcherEntries(args);
}
async execute(
_toolCallId: string,
params: EditParams,
+106
View File
@@ -52,6 +52,17 @@ export interface StreamingDiffContext {
isStreaming?: boolean;
}
/**
* Per-file projection of a streamed edit payload. Pairs one target file path
* with the digest of only the lines added to that file, so path-scoped stream
* matchers (TTSR) evaluate each file in isolation — a `tool:edit(*.ts)` rule
* never fires on text that actually belongs to a sibling `README.md` hunk.
*/
export interface EditMatcherEntry {
readonly path: string;
readonly digest: string;
}
export interface EditStreamingStrategy<Args = unknown> {
/**
* Return the args restricted to edits that are "complete enough" to
@@ -85,6 +96,18 @@ export interface EditStreamingStrategy<Args = unknown> {
* Returns `undefined` (or an empty list) when no paths are recoverable.
*/
matcherPaths(args: Args): readonly string[] | undefined;
/**
* Per-file projection of the (potentially partial) args: one entry per
* touched file pairing the path with the digest of only the lines added to
* that file. Multi-file payloads (multi-section hashline / multi-hunk
* apply_patch) MUST split here so callers can evaluate each file under its
* own path scope instead of leaking added lines from one file into the
* other's match context. Same-path sections / hunks are merged into one
* entry. Returns `undefined` (or empty) when no per-file split is
* recoverable yet — the caller falls back to {@link matcherDigest} +
* {@link matcherPaths}.
*/
matcherEntries(args: Args): readonly EditMatcherEntry[] | undefined;
}
// -----------------------------------------------------------------------------
@@ -237,6 +260,65 @@ function extractApplyPatchEnvelopePaths(input: string): string[] {
return paths;
}
/**
* Split a (possibly partial) hashline buffer into one matcher entry per
* touched file: pair the section header path with the added lines from that
* section's body, merging sections that target the same file into one entry.
* Header-line regex (not `Patch.parse`) so a mid-typed trailing op still
* yields entries for completed sections.
*/
function splitHashlinePerFile(input: string): EditMatcherEntry[] {
const headerRe = /^\s*\[([^\]\r\n]+?)(?:#[0-9a-fA-F]{4})?\]\s*$/gm;
const sections: { path: string; headerStart: number; bodyStart: number }[] = [];
let match: RegExpExecArray | null = headerRe.exec(input);
while (match !== null) {
const candidate = stripApplyPatchPathNoise(match[1]).trim();
if (candidate.length > 0) {
sections.push({ path: candidate, headerStart: match.index, bodyStart: headerRe.lastIndex });
}
match = headerRe.exec(input);
}
if (sections.length === 0) return [];
const byPath = new Map<string, string>();
for (let i = 0; i < sections.length; i++) {
const { path: sectionPath, bodyStart } = sections[i];
const bodyEnd = i + 1 < sections.length ? sections[i + 1].headerStart : input.length;
const added = extractAddedLines(input.slice(bodyStart, bodyEnd), false);
if (added.length === 0) continue;
const existing = byPath.get(sectionPath);
byPath.set(sectionPath, existing === undefined ? added : `${existing}\n${added}`);
}
return Array.from(byPath, ([path, digest]) => ({ path, digest }));
}
/**
* Split a (possibly partial) apply_patch envelope into one matcher entry per
* touched file. Same-path hunks are merged into one entry. Falls back to the
* streaming-tolerant parser when the envelope hasn't reached `*** End Patch`.
*/
function splitApplyPatchPerFile(input: string): EditMatcherEntry[] {
let entries: ApplyPatchEntry[];
try {
entries = expandApplyPatchToEntries({ input });
} catch {
try {
entries = expandApplyPatchToPreviewEntries({ input });
} catch {
return [];
}
}
const byPath = new Map<string, string>();
for (const entry of entries) {
if (typeof entry.diff !== "string") continue;
const added = extractAddedLines(entry.diff, false);
if (added.length === 0) continue;
const existing = byPath.get(entry.path);
byPath.set(entry.path, existing === undefined ? added : `${existing}\n${added}`);
}
return Array.from(byPath, ([path, digest]) => ({ path, digest }));
}
// -----------------------------------------------------------------------------
// Strategies
// -----------------------------------------------------------------------------
@@ -285,6 +367,12 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
matcherPaths(args) {
return typeof args?.path === "string" && args.path.length > 0 ? [args.path] : undefined;
},
matcherEntries(args) {
const path = args?.path;
if (typeof path !== "string" || path.length === 0) return undefined;
const digest = replaceStrategy.matcherDigest(args);
return digest === undefined ? undefined : [{ path, digest }];
},
};
interface PatchArgs {
@@ -330,6 +418,12 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
matcherPaths(args) {
return typeof args?.path === "string" && args.path.length > 0 ? [args.path] : undefined;
},
matcherEntries(args) {
const path = args?.path;
if (typeof path !== "string" || path.length === 0) return undefined;
const digest = patchStrategy.matcherDigest(args);
return digest === undefined ? undefined : [{ path, digest }];
},
};
interface HashlineArgs {
@@ -495,6 +589,12 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
const paths = extractHashlineHeaderPaths(input);
return paths.length > 0 ? paths : undefined;
},
matcherEntries(args) {
const input = args?.input;
if (typeof input !== "string" || input.length === 0) return undefined;
const entries = splitHashlinePerFile(input);
return entries.length > 0 ? entries : undefined;
},
};
interface ApplyPatchArgs {
@@ -559,6 +659,12 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
const paths = extractApplyPatchEnvelopePaths(input);
return paths.length > 0 ? paths : undefined;
},
matcherEntries(args) {
const input = args?.input;
if (typeof input !== "string" || input.length === 0) return undefined;
const entries = splitApplyPatchPerFile(input);
return entries.length > 0 ? entries : undefined;
},
};
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
replace: replaceStrategy as EditStreamingStrategy<unknown>,
@@ -3808,6 +3808,14 @@ export class AgentSession {
if (!manager) {
return [];
}
const entries = this.#resolveTtsrMatcherEntries(toolCall);
if (entries) {
const matches: Rule[] = [];
for (const entry of entries) {
matches.push(...manager.checkSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path)));
}
return matches;
}
const digest = this.#resolveTtsrMatcherDigest(toolCall);
if (digest !== undefined) {
return manager.checkSnapshot(digest, matchContext);
@@ -3817,14 +3825,44 @@ export class AgentSession {
/** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
#resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
if (!toolCall) {
return undefined;
}
const tool = this.#resolveTtsrTool(toolCall);
return tool?.matcherDigest?.(toolCall?.arguments ?? {});
}
/**
* Per-file split of a streamed call (one entry per touched file paired with
* the digest of only that file's added lines). Lets {@link #checkTtsrStream}
* and {@link #checkTtsrAstStream} evaluate each file in isolation so a
* path-scoped rule like `tool:edit(*.ts)` never fires on text that belongs
* to a sibling Markdown hunk in a multi-file payload.
*/
#resolveTtsrMatcherEntries(toolCall: ToolCall | undefined): readonly { path: string; digest: string }[] | undefined {
const tool = this.#resolveTtsrTool(toolCall);
const entries = tool?.matcherEntries?.(toolCall?.arguments ?? {});
return entries && entries.length > 0 ? entries : undefined;
}
#resolveTtsrTool(toolCall: ToolCall | undefined) {
if (!toolCall) return undefined;
const tools = this.agent.state.tools;
const tool =
return (
tools.find(t => t.name === toolCall.name) ??
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
return tool?.matcherDigest?.(toolCall.arguments ?? {});
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name)
);
}
/**
* Replace `matchContext`'s `filePaths` + `streamKey` so a per-file entry
* gets its own glob-eligible path and its own TTSR buffer/repeat tracking
* (each file's stream is independent inside the same tool call).
*/
#perFileTtsrContext(base: TtsrMatchContext, filePath: string): TtsrMatchContext {
const filePaths = this.#normalizeTtsrPathCandidates(filePath);
return {
...base,
filePaths: filePaths.length > 0 ? filePaths : [filePath],
streamKey: base.streamKey ? `${base.streamKey}#${filePath}` : undefined,
};
}
/**
@@ -3839,6 +3877,16 @@ export class AgentSession {
if (!manager) {
return [];
}
const entries = this.#resolveTtsrMatcherEntries(toolCall);
if (entries) {
const matches: Rule[] = [];
for (const entry of entries) {
matches.push(
...(await manager.checkAstSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path))),
);
}
return matches;
}
const digest = this.#resolveTtsrMatcherDigest(toolCall);
if (digest === undefined) {
return [];
@@ -109,6 +109,64 @@ describe("EDIT_MODE_STRATEGIES.matcherPaths", () => {
});
});
describe("EDIT_MODE_STRATEGIES.matcherEntries", () => {
it("replace + patch return one (path, digest) entry from the top-level path", () => {
expect(
EDIT_MODE_STRATEGIES.replace.matcherEntries({ path: "src/foo.ts", edits: [{ new_text: "x = 1" }] }),
).toEqual([{ path: "src/foo.ts", digest: "x = 1" }]);
expect(
EDIT_MODE_STRATEGIES.patch.matcherEntries({ path: "src/bar.ts", edits: [{ op: "update", diff: "@@\n+y" }] }),
).toEqual([{ path: "src/bar.ts", digest: "y" }]);
});
it("hashline splits multi-section payloads into one entry per file", () => {
const input = [
"[src/a.ts#ABCD]",
"SWAP 1.=1:",
"+const a = 1;",
"[README.md#EF01]",
"SWAP 1.=1:",
"+# Heading",
"[src/a.ts#1234]",
"SWAP 2.=2:",
"+const c = 3;",
"",
].join("\n");
expect(EDIT_MODE_STRATEGIES.hashline.matcherEntries({ input })).toEqual([
// Same-path sections are merged into one entry, preserving order.
{ path: "src/a.ts", digest: "const a = 1;\nconst c = 3;" },
{ path: "README.md", digest: "# Heading" },
]);
});
it("apply_patch splits multi-hunk payloads into one entry per file", () => {
const input = [
"*** Begin Patch",
"*** Update File: src/a.ts",
"@@",
"-foo",
"+const a = 1;",
"*** Update File: README.md",
"@@",
"-old",
"+# Heading",
"*** End Patch",
"",
].join("\n");
const entries = EDIT_MODE_STRATEGIES.apply_patch.matcherEntries({ input });
expect(entries).toEqual([
{ path: "src/a.ts", digest: "const a = 1;" },
{ path: "README.md", digest: "# Heading" },
]);
});
it("returns undefined when no entries are recoverable yet", () => {
expect(EDIT_MODE_STRATEGIES.hashline.matcherEntries({ input: "" })).toBeUndefined();
expect(EDIT_MODE_STRATEGIES.apply_patch.matcherEntries({ input: "*** Begin Patch\n" })).toBeUndefined();
expect(EDIT_MODE_STRATEGIES.replace.matcherEntries({})).toBeUndefined();
});
});
/**
* Integration: a hashline edit payload whose only path lives in the
* `[demo.ts#TAG]` section header must trigger the bundled `ts-no-any` rule
@@ -184,4 +242,95 @@ describe("hashline edit + path-scoped TTSR (regression: #3646)", () => {
});
expect(matches).toEqual([]);
});
it("multi-file hashline isolates a .md hunk's `: any` from a sibling .ts entry", async () => {
// PR review (#3648): a multi-file payload that adds `: any` only to a
// Markdown hunk MUST NOT trip the TS-only `tool:edit(*.ts)` rule. Per-file
// matchers pair each path with its own digest.
const manager = await makeManager();
const input = [
"[README.md#ABCD]",
"SWAP 1.=1:",
`+${VIOLATING_LINE}`,
"[src/ok.ts#EF01]",
"SWAP 1.=1:",
"+export const ok = 1;",
"",
].join("\n");
const entries = EDIT_MODE_STRATEGIES.hashline.matcherEntries({ input });
expect(entries?.map(e => e.path)).toEqual(["README.md", "src/ok.ts"]);
const allMatches: string[] = [];
for (const entry of entries ?? []) {
const matches = manager.checkSnapshot(entry.digest, {
source: "tool",
toolName: "edit",
filePaths: [entry.path],
streamKey: `toolcall:test#${entry.path}`,
});
allMatches.push(...matches.map(r => r.name));
}
expect(allMatches).toEqual([]);
});
it("multi-file hashline fires only on the .ts entry when the .ts entry carries `: any`", async () => {
const manager = await makeManager();
const input = [
"[README.md#ABCD]",
"SWAP 1.=1:",
"+# Heading",
"[src/bad.ts#EF01]",
"SWAP 1.=1:",
`+${VIOLATING_LINE}`,
"",
].join("\n");
const entries = EDIT_MODE_STRATEGIES.hashline.matcherEntries({ input });
const matchesByPath = new Map<string, string[]>();
for (const entry of entries ?? []) {
const matches = manager.checkSnapshot(entry.digest, {
source: "tool",
toolName: "edit",
filePaths: [entry.path],
streamKey: `toolcall:test2#${entry.path}`,
});
matchesByPath.set(
entry.path,
matches.map(r => r.name),
);
}
expect(matchesByPath.get("README.md")).toEqual([]);
expect(matchesByPath.get("src/bad.ts")).toEqual(["ts-no-any"]);
});
it("multi-file apply_patch isolates a .md hunk's `: any` from a sibling .ts hunk", async () => {
const manager = await makeManager();
const input = [
"*** Begin Patch",
"*** Update File: README.md",
"@@",
"-old",
`+${VIOLATING_LINE}`,
"*** Update File: src/ok.ts",
"@@",
"-old",
"+export const ok = 1;",
"*** End Patch",
"",
].join("\n");
const entries = EDIT_MODE_STRATEGIES.apply_patch.matcherEntries({ input });
const allMatches: string[] = [];
for (const entry of entries ?? []) {
const matches = manager.checkSnapshot(entry.digest, {
source: "tool",
toolName: "edit",
filePaths: [entry.path],
streamKey: `toolcall:test3#${entry.path}`,
});
allMatches.push(...matches.map(r => r.name));
}
expect(allMatches).toEqual([]);
});
});