feat(coding-agent): added normative patch generation for edit tool canonicalization

- Added normative patch generation for canonicalizing edit tool output.
- Implemented tool call argument rewriting for session history persistence.
- Enhanced patch applicator to support normalized patch input processing.
- Updated agent and AI types to include normative input parameters.
- Removed tar-stream dependency and replaced with Bun.Archive for benchmark package.
This commit is contained in:
can1357
2026-01-19 16:00:25 +01:00
parent 5ae70ed2dd
commit f06d289338
20 changed files with 328 additions and 209 deletions
+1 -15
View File
@@ -17,6 +17,7 @@
"bun-types": "^1.3.5",
"concurrently": "^9.2.1",
"lint-staged": "^16.2.7",
"prettier": "^3.8.0",
},
},
"packages/agent": {
@@ -117,7 +118,6 @@
"@oh-my-pi/pi-coding-agent": "workspace:*",
"diff": "^8.0.2",
"prettier": "^3.3.3",
"tar-stream": "^3.1.7",
},
"devDependencies": {
"@types/diff": "^7.0.2",
@@ -564,12 +564,8 @@
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
@@ -696,8 +692,6 @@
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
@@ -712,8 +706,6 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
@@ -1010,8 +1002,6 @@
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1032,10 +1022,6 @@
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
"text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="],
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
+2 -1
View File
@@ -29,7 +29,8 @@
"@typescript/native-preview": "^7.0.0-dev.20260102.1",
"bun-types": "^1.3.5",
"concurrently": "^9.2.1",
"lint-staged": "^16.2.7"
"lint-staged": "^16.2.7",
"prettier": "^3.8.0"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched"
+7 -5
View File
@@ -165,15 +165,17 @@ export interface AgentState {
error?: string;
}
export interface AgentToolResult<T> {
export interface AgentToolResult<T, TNormative extends TSchema = TSchema> {
// Content blocks supporting text and images
content: (TextContent | ImageContent)[];
// Details to be displayed in a UI or logged
details?: T;
/** Normative input for the tool result */
$normative?: Static<TNormative>;
}
// Callback for streaming tool execution updates
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
export type AgentToolUpdateCallback<T = any, TNormative extends TSchema = TSchema> = (partialResult: AgentToolResult<T, TNormative>) => void;
/** Options passed to renderResult */
export interface RenderResultOptions {
@@ -204,15 +206,15 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
context?: AgentToolContext,
) => Promise<AgentToolResult<TDetails>>;
) => Promise<AgentToolResult<TDetails, TParameters>>;
/** Optional custom rendering for tool call display (returns UI component) */
renderCall?: (args: Static<TParameters>, theme: TTheme) => unknown;
/** Optional custom rendering for tool result display (returns UI component) */
renderResult?: (result: AgentToolResult<TDetails>, options: RenderResultOptions, theme: TTheme) => unknown;
renderResult?: (result: AgentToolResult<TDetails, TParameters>, options: RenderResultOptions, theme: TTheme) => unknown;
}
// AgentContext is like Context but uses AgentTool
+6 -5
View File
@@ -54,10 +54,10 @@ export interface ApiOptionsMap {
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
type _CheckExhaustive =
ApiOptionsMap extends Record<Api, StreamOptions>
? Record<Api, StreamOptions> extends ApiOptionsMap
? true
: ["ApiOptionsMap is missing some KnownApi values", Exclude<Api, keyof ApiOptionsMap>]
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
? Record<Api, StreamOptions> extends ApiOptionsMap
? true
: ["ApiOptionsMap is missing some KnownApi values", Exclude<Api, keyof ApiOptionsMap>]
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
const _exhaustive: _CheckExhaustive = true;
// Helper type to get options for a specific API
@@ -185,7 +185,7 @@ export interface AssistantMessage {
timestamp: number; // Unix timestamp in milliseconds
}
export interface ToolResultMessage<TDetails = any> {
export interface ToolResultMessage<TDetails = any, TInput = unknown> {
role: "toolResult";
toolCallId: string;
toolName: string;
@@ -193,6 +193,7 @@ export interface ToolResultMessage<TDetails = any> {
details?: TDetails;
isError: boolean;
timestamp: number; // Unix timestamp in milliseconds
$normative?: TInput;
}
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
+6 -1
View File
@@ -1,9 +1,11 @@
# Changelog
## [Unreleased]
### Added
- Added normative patch generation to canonicalize edit tool output
- Added tool call argument rewriting for session history persistence
- Added comprehensive test coverage for tool call argument rewriting
- Added fallback variant generation for patch matching with trimmed context, collapsed duplicates, and single-line reduction
- Added comment-prefix normalization for improved fuzzy matching across different comment styles
- Added support for ellipsis (...) and ellipsis character (…) as context placeholders in patch hunks
@@ -32,6 +34,9 @@
### Changed
- Enhanced patch applicator to support normalized patch input processing
- Updated edit tool to include normative patch data in tool results
- Improved session manager with tool call argument rewriting capabilities
- Improved indentation adjustment to preserve original file context while fixing new lines
- Enhanced patch parser to handle implicit context lines without space prefixes
- Updated patch operation schema to use 'op' instead of 'operation' and 'rename' instead of 'moveTo'
+70 -31
View File
@@ -21,10 +21,10 @@ import { getAgentDbPath } from "../config";
import { theme } from "../modes/interactive/theme/theme";
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor";
import {
type CompactionResult,
calculateContextTokens,
collectEntriesForBranchSummary,
compact,
type CompactionResult,
estimateTokens,
generateBranchSummary,
prepareCompaction,
@@ -51,7 +51,7 @@ import { logger } from "./logger";
import type { BashExecutionMessage, CustomMessage } from "./messages";
import type { ModelRegistry } from "./model-registry";
import { parseModelString } from "./model-resolver";
import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates";
import { expandPromptTemplate, parseCommandArgs, type PromptTemplate } from "./prompt-templates";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
import type { SettingsManager, SkillsSettings } from "./settings-manager";
import type { Skill, SkillWarning } from "./skills";
@@ -68,12 +68,12 @@ export type AgentSessionEvent =
| AgentEvent
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
| {
type: "auto_compaction_end";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
}
type: "auto_compaction_end";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
}
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| { type: "ttsr_triggered"; rules: Rule[] }
@@ -174,12 +174,12 @@ const noOpUIContext: ExtensionUIContext = {
select: async (_title, _options, _dialogOptions) => undefined,
confirm: async (_title, _message, _dialogOptions) => false,
input: async (_title, _placeholder, _dialogOptions) => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
notify: () => { },
setStatus: () => { },
setWidget: () => { },
setTitle: () => { },
custom: async () => undefined as never,
setEditorText: () => {},
setEditorText: () => { },
getEditorText: () => "",
editor: async () => undefined,
get theme() {
@@ -188,9 +188,9 @@ const noOpUIContext: ExtensionUIContext = {
getAllThemes: () => [],
getTheme: () => undefined,
setTheme: (_theme) => ({ success: false, error: "UI not available" }),
setFooter: () => {},
setHeader: () => {},
setEditorComponent: () => {},
setFooter: () => { },
setHeader: () => { },
setEditorComponent: () => { },
};
async function cleanupSshResources(): Promise<void> {
@@ -399,7 +399,7 @@ export class AgentSession {
timestamp: Date.now(),
});
}
this.agent.continue().catch(() => {});
this.agent.continue().catch(() => { });
}, 50);
return;
}
@@ -432,6 +432,18 @@ export class AgentSession {
if (event.message.role === "assistant") {
this._lastAssistantMessage = event.message;
}
if (event.message.role === "toolResult") {
const { $normative, toolCallId } = event.message as {
toolName?: string;
toolCallId?: string;
details?: unknown;
$normative?: Record<string, unknown>;
};
if ($normative && toolCallId) {
await this._rewriteToolCallArgs(toolCallId, $normative);
}
}
}
// Check auto-retry and auto-compaction after agent completes
@@ -513,6 +525,33 @@ export class AgentSession {
return undefined;
}
/** Rewrite tool call arguments in agent state and persisted session history. */
private async _rewriteToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<void> {
let updated = false;
const messages = this.agent.state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
const assistantMsg = msg as AssistantMessage;
if (!Array.isArray(assistantMsg.content)) continue;
for (const block of assistantMsg.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
if (toolCall.id === toolCallId) {
toolCall.arguments = args;
updated = true;
break;
}
}
if (updated) break;
}
if (updated) {
await this.sessionManager.rewriteAssistantToolCallArgs(toolCallId, args);
}
}
/** Emit extension events based on agent events */
private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
if (!this._extensionRunner) return;
@@ -783,8 +822,8 @@ export class AgentSession {
if (!this.model) {
throw new Error(
"No model selected.\n\n" +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
"Then use /model to select a model.",
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
"Then use /model to select a model.",
);
}
@@ -793,7 +832,7 @@ export class AgentSession {
if (!apiKey) {
throw new Error(
`No API key found for ${this.model.provider}.\n\n` +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
);
}
@@ -1813,7 +1852,7 @@ export class AgentSession {
content: [{ type: "text", text: reminder }],
timestamp: Date.now(),
});
this.agent.continue().catch(() => {});
this.agent.continue().catch(() => { });
}
private _getModelKey(model: Model<any>): string {
@@ -2064,7 +2103,7 @@ export class AgentSession {
}
setTimeout(() => {
this.agent.continue().catch(() => {});
this.agent.continue().catch(() => { });
}, 100);
}
} catch (error) {
@@ -2345,13 +2384,13 @@ export class AgentSession {
try {
const result = options?.operations
? await executeBashWithOperations(command, process.cwd(), options.operations, {
onChunk,
signal: this._bashAbortController.signal,
})
onChunk,
signal: this._bashAbortController.signal,
})
: await executeBashCommand(command, {
onChunk,
signal: this._bashAbortController.signal,
});
onChunk,
signal: this._bashAbortController.signal,
});
this.recordBashResult(command, result, options);
return result;
@@ -2689,9 +2728,9 @@ export class AgentSession {
typeof targetEntry.content === "string"
? targetEntry.content
: targetEntry.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
} else {
// Non-user message: leaf = selected node
newLeafId = targetId;
@@ -1386,6 +1386,36 @@ export class SessionManager {
return entry.id;
}
/**
* Rewrite tool call arguments in the most recent assistant message containing the toolCallId.
* Returns true if a tool call was updated.
*/
async rewriteAssistantToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<boolean> {
let updated = false;
for (let i = this.fileEntries.length - 1; i >= 0; i--) {
const entry = this.fileEntries[i];
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
const message = entry.message as { content?: unknown };
if (!Array.isArray(message.content)) continue;
for (const block of message.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
if (toolCall.id === toolCallId) {
toolCall.arguments = args;
updated = true;
break;
}
}
if (updated) break;
}
if (updated && this.persist && this.sessionFile) {
await this._rewriteFile();
}
return updated;
}
/**
* Append a custom message entry (for extensions) that participates in LLM context.
* @param customType Hook identifier for filtering on reload
@@ -19,8 +19,16 @@ import {
stripBom,
} from "./normalize";
import { normalizeCreateContent, parseHunks } from "./parser";
import type { ApplyPatchOptions, ApplyPatchResult, ContextLineResult, DiffHunk, FileSystem, PatchInput } from "./types";
import { ApplyPatchError } from "./types";
import type {
ApplyPatchOptions,
ApplyPatchResult,
ContextLineResult,
DiffHunk,
FileSystem,
NormalizedPatchInput,
PatchInput,
} from "./types";
import { ApplyPatchError, normalizePatchInput } from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// Default File System
@@ -953,6 +961,18 @@ function applyHunksToContent(
* Apply a patch operation to the filesystem.
*/
export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
const normalized = normalizePatchInput(input);
return applyNormalizedPatch(normalized, options);
}
/**
* Apply a normalized patch operation to the filesystem.
* @internal
*/
async function applyNormalizedPatch(
input: NormalizedPatchInput,
options: ApplyPatchOptions,
): Promise<ApplyPatchResult> {
const {
cwd,
dryRun = false,
@@ -8,10 +8,10 @@
* The mode is determined by the `edit.patchMode` setting.
*/
import { mkdir } from "node:fs/promises";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { StringEnum } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import { mkdir } from "node:fs/promises";
import patchDescription from "../../../prompts/tools/patch.md" with { type: "text" };
import replaceDescription from "../../../prompts/tools/replace.md" with { type: "text" };
import { renderPromptTemplate } from "../../prompt-templates";
@@ -28,6 +28,7 @@ import { applyPatch } from "./applicator";
import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
import { buildNormativeUpdateInput } from "./normative";
import { type EditToolDetails, getLspBatchRequest } from "./shared";
// Internal imports
import type { FileSystem, Operation, PatchInput } from "./types";
@@ -48,7 +49,7 @@ export {
findContextLine,
findMatch as findEditMatch,
findMatch,
seekSequence,
seekSequence
} from "./fuzzy";
// Normalization
@@ -57,7 +58,7 @@ export {
detectLineEnding,
normalizeToLF,
restoreLineEndings,
stripBom,
stripBom
} from "./normalize";
// Parsing
@@ -69,22 +70,12 @@ export type {
ApplyPatchOptions,
ApplyPatchResult,
ContextLineResult,
DiffError,
DiffError as EditDiffError,
DiffHunk,
DiffHunk as UpdateChunk,
DiffHunk as UpdateFileChunk,
DiffResult,
DiffResult as EditDiffResult,
FileChange,
FileSystem,
FuzzyMatch as EditMatch,
FuzzyMatch,
MatchOutcome as EditMatchOutcome,
MatchOutcome,
DiffError, DiffHunk, DiffResult, DiffError as EditDiffError, DiffResult as EditDiffResult, FuzzyMatch as EditMatch, MatchOutcome as EditMatchOutcome, FileChange,
FileSystem, FuzzyMatch, MatchOutcome,
Operation,
PatchInput,
SequenceSearchResult,
SequenceSearchResult, DiffHunk as UpdateChunk,
DiffHunk as UpdateFileChunk
} from "./types";
// Types
// Legacy aliases for backwards compatibility
@@ -118,8 +109,8 @@ const patchEditSchema = Type.Object({
),
});
type ReplaceParams = { path: string; oldText: string; newText: string; all?: boolean };
type PatchParams = { path: string; op?: string; rename?: string; diff?: string };
export type ReplaceParams = { path: string; oldText: string; newText: string; all?: boolean };
export type PatchParams = { path: string; op?: string; rename?: string; diff?: string };
// ═══════════════════════════════════════════════════════════════════════════
// LSP FileSystem for patch mode
@@ -188,16 +179,18 @@ class LspFileSystem implements FileSystem {
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
type TInput = typeof replaceEditSchema | typeof patchEditSchema;
/**
* Edit tool implementation.
*
* Creates replace-mode or patch-mode behavior based on session settings.
*/
export class EditTool implements AgentTool<typeof replaceEditSchema | typeof patchEditSchema, EditToolDetails> {
export class EditTool implements AgentTool<TInput> {
public readonly name = "edit";
public readonly label = "Edit";
public readonly description: string;
public readonly parameters: typeof replaceEditSchema | typeof patchEditSchema;
public readonly parameters: TInput;
private readonly session: ToolSession;
private readonly patchMode: boolean;
@@ -270,9 +263,9 @@ export class EditTool implements AgentTool<typeof replaceEditSchema | typeof pat
_toolCallId: string,
params: ReplaceParams | PatchParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<EditToolDetails>,
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
context?: AgentToolContext,
): Promise<AgentToolResult<EditToolDetails>> {
): Promise<AgentToolResult<EditToolDetails, TInput>> {
const batchRequest = getLspBatchRequest(context?.toolCall);
// ─────────────────────────────────────────────────────────────────
@@ -303,10 +296,17 @@ export class EditTool implements AgentTool<typeof replaceEditSchema | typeof pat
// Generate diff for display
let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
let normative: PatchInput | undefined;
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
normative = buildNormativeUpdateInput({
path,
rename: effRename,
oldContent: result.change.oldContent,
newContent: result.change.newContent,
});
}
let resultText: string;
@@ -341,6 +341,7 @@ export class EditTool implements AgentTool<typeof replaceEditSchema | typeof pat
op,
rename: effRename,
},
$normative: normative,
};
}
@@ -0,0 +1,49 @@
/**
* Normalize applied patch output into a canonical edit tool payload.
*/
import { generateUnifiedDiffString } from "./diff";
import { normalizeToLF, stripBom } from "./normalize";
import { PatchInput } from "./types";
export interface NormativePatchOptions {
path: string;
rename?: string;
oldContent: string;
newContent: string;
contextLines?: number;
anchor?: string | string[];
}
/** Normative patch input is the MongoDB-style update variant */
function applyAnchors(diff: string, anchors: string[] | undefined): string {
if (!anchors || anchors.length === 0) {
return diff;
}
const lines = diff.split("\n");
let anchorIndex = 0;
for (let i = 0; i < lines.length; i++) {
if (!lines[i].startsWith("@@")) continue;
const anchor = anchors[anchorIndex];
if (anchor !== undefined) {
lines[i] = anchor.trim().length === 0 ? "@@" : `@@ ${anchor}`;
anchorIndex++;
}
}
return lines.join("\n");
}
export function buildNormativeUpdateInput(options: NormativePatchOptions): PatchInput {
const normalizedOld = normalizeToLF(stripBom(options.oldContent).text);
const normalizedNew = normalizeToLF(stripBom(options.newContent).text);
const diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, options.contextLines ?? 3);
const anchors = typeof options.anchor === "string" ? [options.anchor] : options.anchor;
const diff = applyAnchors(diffResult.diff, anchors);
return {
path: options.path,
op: "update",
rename: options.rename,
diff,
};
}
@@ -82,6 +82,23 @@ export interface PatchInput {
diff?: string;
}
/** Normalized patch input used internally by the applicator. */
export interface NormalizedPatchInput {
path: string;
op: Operation;
rename?: string;
diff?: string;
}
export function normalizePatchInput(input: PatchInput): NormalizedPatchInput {
return {
path: input.path,
op: input.op ?? "update",
rename: input.rename,
diff: input.diff,
};
}
/** A single hunk/chunk in a diff */
export interface DiffHunk {
/** Context line to narrow down position (e.g., class/method definition) */
@@ -0,0 +1,52 @@
import { describe, expect, it } from "bun:test";
import type { AssistantMessage, ToolCall } from "@oh-my-pi/pi-ai";
import { SessionManager } from "../../src/core/session-manager";
function buildAssistantMessage(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: "test",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
};
}
describe("SessionManager tool call rewrite", () => {
it("rewrites assistant toolCall arguments in context", async () => {
const session = SessionManager.inMemory();
const toolCallId = "toolu_rewrite_1";
const toolCall = {
type: "toolCall",
id: toolCallId,
name: "edit",
arguments: { path: "file.ts", op: "update", diff: "@@\n-old\n+new" },
} satisfies ToolCall;
session.appendMessage(buildAssistantMessage([toolCall]));
const updated = await session.rewriteAssistantToolCallArgs(toolCallId, {
path: "file.ts",
op: "update",
diff: "@@\n-old\n+newer",
});
expect(updated).toBe(true);
const ctx = session.buildSessionContext();
const assistant = ctx.messages.find((m) => m.role === "assistant") as AssistantMessage;
const updatedCall = assistant.content.find((b) => b.type === "toolCall") as ToolCall;
expect(updatedCall.arguments).toEqual({ path: "file.ts", op: "update", diff: "@@\n-old\n+newer" });
});
});
+4 -22
View File
@@ -20,11 +20,9 @@
*/
import { $ } from "bun";
import { createWriteStream, existsSync, mkdirSync, readdirSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import { parseArgs } from "node:util";
import { createGzip } from "node:zlib";
import { pack } from "tar-stream";
import { ALL_MUTATIONS, CATEGORY_MAP, type Mutation, type MutationInfo } from "./mutations";
const SCRIPT_DIR = import.meta.dir;
@@ -161,10 +159,6 @@ function collectFiles(reactDir: string): string[] {
return candidates.sort();
}
function readFile(filePath: string): string {
return Bun.file(filePath).text() as unknown as string;
}
async function readFileAsync(filePath: string): Promise<string> {
return Bun.file(filePath).text();
}
@@ -550,23 +544,11 @@ interface TarEntry {
}
async function writeTarball(entries: TarEntry[], outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const packer = pack();
const output = createWriteStream(outputPath);
const gzip = createGzip();
packer.pipe(gzip).pipe(output);
const data: Record<string, string> = {};
for (const entry of entries) {
packer.entry({ name: entry.name }, entry.content);
data[entry.name] = entry.content;
}
packer.finalize();
output.on("close", resolve);
output.on("error", reject);
gzip.on("error", reject);
});
await Bun.Archive.write(outputPath, data, { compress: "gzip" });
}
function buildCaseEntries(result: CaseResult, reactDir: string): TarEntry[] {
+1 -1
View File
@@ -96,7 +96,7 @@ function resolveExtractedDir(tempDir: string): string {
}
async function extractTarGz(archivePath: string): Promise<{ dir: string; cleanupDir: string }> {
const tempDir = await mkdtemp(join(tmpdir(), "edit-bench-fixtures-"));
const tempDir = await mkdtemp(join(tmpdir(), "reach-benchmark-fixtures-"));
const result = Bun.spawnSync(["tar", "-xzf", archivePath, "-C", tempDir]);
if (!result.success) {
await rm(tempDir, { recursive: true, force: true });
+1 -2
View File
@@ -16,8 +16,7 @@
"@oh-my-pi/pi-agent-core": "workspace:*",
"@oh-my-pi/pi-coding-agent": "workspace:*",
"diff": "^8.0.2",
"prettier": "^3.3.3",
"tar-stream": "^3.1.7"
"prettier": "^3.3.3"
},
"devDependencies": {
"@types/diff": "^7.0.2"
+10 -7
View File
@@ -7,14 +7,15 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { RpcClient } from "@oh-my-pi/pi-coding-agent";
import { appendFile, cp, mkdtemp, readdir, rm } from "node:fs/promises";
import { appendFile, cp, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatDirectory } from "./formatter";
import { extractTaskFiles, type EditTask } from "./tasks";
import { verifyExpectedFileSubset, verifyExpectedFiles } from "./verify";
const ROOT_DIR = await mkdtemp("reb-");
const TMP = await mkdtemp(join(tmpdir(), "reach-benchmark-"));
export interface BenchmarkConfig {
@@ -147,7 +148,8 @@ async function getExpectedDir(task: EditTask): Promise<{ dir: string; cleanup: (
return { dir: task.expectedDir, cleanup: async () => {} };
}
if (task.tarballPath) {
const tempDir = await mkdtemp(join(ROOT_DIR, `expected-${task.id}-`));
const tempDir = join(TMP, `expected-${task.id}-${crypto.randomUUID()}`);
await mkdir(tempDir, { recursive: true });
await extractTaskFiles(task.tarballPath, task.id, tempDir, "expected");
return {
dir: tempDir,
@@ -188,7 +190,7 @@ async function runSingleTask(
totalInputChars: 0,
};
const logFile = join(ROOT_DIR, `run-${task.id}-${runIndex}.jsonl`);
const logFile = join(TMP, `run-${task.id}-${runIndex}.jsonl`);
const logEvent = async (event: unknown) => {
await appendFile(logFile, JSON.stringify(event) + "\n");
};
@@ -406,7 +408,7 @@ async function runBatchedTask(
totalInputChars: 0,
};
const logFile = join(ROOT_DIR, `run-${task.id}-${runIndex}.jsonl`);
const logFile = join(TMP, `run-${task.id}-${runIndex}.jsonl`);
const logEvent = async (event: unknown) => {
await appendFile(logFile, JSON.stringify(event) + "\n");
};
@@ -740,7 +742,8 @@ async function runBatch(
cliPath: string,
onProgress?: (event: ProgressEvent) => void,
): Promise<Array<{ task: EditTask; result: TaskRunResult }>> {
const workDir = await mkdtemp(join(ROOT_DIR, "batch-"));
const workDir = join(TMP, `batch-${crypto.randomUUID()}`);
await mkdir(workDir, { recursive: true });
const results: Array<{ task: EditTask; result: TaskRunResult }> = [];
let client: RpcClient | null = null;
const expectedDirs = new Map<string, { dir: string; cleanup: () => Promise<void> }>();
@@ -836,7 +839,7 @@ export async function runTask(
try {
for (let i = 0; i < config.runsPerTask; i++) {
const tempDir = await mkdtemp(join(ROOT_DIR, `${task.id}-`));
const tempDir = await mkdtemp(join(TMP, `${task.id}-`));
tempDirs.push(tempDir);
await copyFixtures(task, tempDir);
}
+22 -83
View File
@@ -1,12 +1,10 @@
/**
* Tarball utilities for reading fixtures directly from .tar.gz archives.
* Uses Bun.Archive for native tar.gz handling.
*/
import { createReadStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { join, dirname, basename } from "node:path";
import { createGunzip } from "node:zlib";
import { extract, type Headers } from "tar-stream";
export interface TarballTask {
id: string;
@@ -35,30 +33,17 @@ interface ParsedTarballTask {
}
export async function readTarball(tarballPath: string): Promise<TarballEntry[]> {
return new Promise((resolve, reject) => {
const entries: TarballEntry[] = [];
const extractor = extract();
const bytes = await Bun.file(tarballPath).arrayBuffer();
const archive = new Bun.Archive(bytes);
const files = await archive.files();
extractor.on("entry", (header: Headers, stream, next) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => {
if (header.type === "file") {
entries.push({
path: header.name,
content: Buffer.concat(chunks),
});
}
next();
});
stream.resume();
});
const entries: TarballEntry[] = [];
for (const [path, file] of files) {
const content = Buffer.from(await file.arrayBuffer());
entries.push({ path, content });
}
extractor.on("finish", () => resolve(entries));
extractor.on("error", reject);
createReadStream(tarballPath).pipe(createGunzip()).pipe(extractor);
});
return entries;
}
function parseTarballEntries(entries: TarballEntry[]): {
@@ -170,9 +155,7 @@ export async function loadTasksFromTarball(tarballPath: string): Promise<Tarball
const entries = await readTarball(tarballPath);
const { tasks, issues } = parseTarballEntries(entries);
if (issues.length > 0) {
const details = issues
.map((issue) => `- ${issue.taskId}: ${issue.message}`)
.join("\n");
const details = issues.map((issue) => `- ${issue.taskId}: ${issue.message}`).join("\n");
throw new Error(`Fixture tarball validation failed:\n${details}`);
}
@@ -195,62 +178,18 @@ export async function extractTaskFiles(
): Promise<void> {
const prefix = `fixtures/${taskId}/${type}/`;
await new Promise<void>((resolve, reject) => {
const extractor = extract();
let failed = false;
const fail = (err: Error): void => {
if (failed) return;
failed = true;
reject(err);
extractor.destroy(err);
};
const bytes = await Bun.file(tarballPath).arrayBuffer();
const archive = new Bun.Archive(bytes);
const files = await archive.files();
extractor.on("entry", async (header: Headers, stream, next) => {
if (header.type === "file" && header.name.startsWith(prefix)) {
const relativePath = header.name.slice(prefix.length);
const destPath = join(destDir, relativePath);
for (const [path, file] of files) {
if (!path.startsWith(prefix)) continue;
try {
await mkdir(dirname(destPath), { recursive: true });
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
fail(error);
return;
}
const relativePath = path.slice(prefix.length);
if (!relativePath) continue;
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("error", (err) => {
fail(err instanceof Error ? err : new Error(String(err)));
});
stream.on("end", () => {
Bun.write(destPath, Buffer.concat(chunks))
.then(() => {
if (!failed) {
next();
}
})
.catch((err) => {
const error = err instanceof Error ? err : new Error(String(err));
fail(new Error(`Failed to write ${destPath}: ${error.message}`));
});
});
stream.resume();
} else {
stream.resume();
next();
}
});
extractor.on("finish", () => {
if (!failed) {
resolve();
}
});
extractor.on("error", (err) => {
fail(err instanceof Error ? err : new Error(String(err)));
});
createReadStream(tarballPath).pipe(createGunzip()).pipe(extractor);
});
const destPath = join(destDir, relativePath);
await mkdir(dirname(destPath), { recursive: true });
await Bun.write(destPath, await file.arrayBuffer());
}
}
+2 -6
View File
@@ -142,9 +142,7 @@ export async function validateFixtures(fixturesPath?: string): Promise<FixtureVa
return validateFixturesFromDir(fixturesPath);
}
export async function validateFixturesFromDir(
fixturesPath: string,
): Promise<FixtureValidationIssue[]> {
export async function validateFixturesFromDir(fixturesPath: string): Promise<FixtureValidationIssue[]> {
const entries = readdirSync(fixturesPath, { withFileTypes: true });
const issues: FixtureValidationIssue[] = [];
@@ -171,9 +169,7 @@ export async function validateFixturesFromDir(
issues.push({ taskId, message: "expected directory is missing" });
}
const inputFiles = statSync(inputDir, { throwIfNoEntry: false })?.isDirectory()
? listFiles(inputDir)
: [];
const inputFiles = statSync(inputDir, { throwIfNoEntry: false })?.isDirectory() ? listFiles(inputDir) : [];
const expectedFiles = statSync(expectedDir, { throwIfNoEntry: false })?.isDirectory()
? listFiles(expectedDir)
: [];
@@ -10,7 +10,7 @@ async function createTempDirs(): Promise<{
actualDir: string;
cleanup: () => Promise<void>;
}> {
const root = await mkdtemp(join(tmpdir(), "react-edit-verify-"));
const root = await mkdtemp(join(tmpdir(), "reach-benchmark-verify-"));
const expectedDir = join(root, "expected");
const actualDir = join(root, "actual");
await mkdir(expectedDir, { recursive: true });
+1 -4
View File
@@ -92,10 +92,7 @@ function createCompactDiff(expected: string, actual: string, contextLines = 3):
return output.join("\n");
}
export async function verifyExpectedFiles(
expectedDir: string,
actualDir: string,
): Promise<VerificationResult> {
export async function verifyExpectedFiles(expectedDir: string, actualDir: string): Promise<VerificationResult> {
return verifyExpectedFileSubset(expectedDir, actualDir);
}