diff --git a/bun.lock b/bun.lock index e59c4ada9..6cb6c1e6e 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 89eb0a886..c8450fba6 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 79e435397..00095b159 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -165,15 +165,17 @@ export interface AgentState { error?: string; } -export interface AgentToolResult { +export interface AgentToolResult { // 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; } // Callback for streaming tool execution updates -export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; +export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; /** Options passed to renderResult */ export interface RenderResultOptions { @@ -204,15 +206,15 @@ export interface AgentTool, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, - ) => Promise>; + ) => Promise>; /** Optional custom rendering for tool call display (returns UI component) */ renderCall?: (args: Static, theme: TTheme) => unknown; /** Optional custom rendering for tool result display (returns UI component) */ - renderResult?: (result: AgentToolResult, options: RenderResultOptions, theme: TTheme) => unknown; + renderResult?: (result: AgentToolResult, options: RenderResultOptions, theme: TTheme) => unknown; } // AgentContext is like Context but uses AgentTool diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 1047d7314..b78c49ca9 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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 - ? Record extends ApiOptionsMap - ? true - : ["ApiOptionsMap is missing some KnownApi values", Exclude] - : ["ApiOptionsMap doesn't extend Record"]; + ? Record extends ApiOptionsMap + ? true + : ["ApiOptionsMap is missing some KnownApi values", Exclude] + : ["ApiOptionsMap doesn't extend Record"]; 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 { +export interface ToolResultMessage { role: "toolResult"; toolCallId: string; toolName: string; @@ -193,6 +193,7 @@ export interface ToolResultMessage { details?: TDetails; isError: boolean; timestamp: number; // Unix timestamp in milliseconds + $normative?: TInput; } export type Message = UserMessage | AssistantMessage | ToolResultMessage; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 157553ef5..a6f8ee78a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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' diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cf41f7703..94b0dc881 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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 { @@ -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; + }; + 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): Promise { + 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 }; + 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 { 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): 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; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 8cf085aa7..72c0132d6 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -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): Promise { + 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 }; + 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 diff --git a/packages/coding-agent/src/core/tools/patch/applicator.ts b/packages/coding-agent/src/core/tools/patch/applicator.ts index d6b22968a..acbffaaa9 100644 --- a/packages/coding-agent/src/core/tools/patch/applicator.ts +++ b/packages/coding-agent/src/core/tools/patch/applicator.ts @@ -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 { + 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 { const { cwd, dryRun = false, diff --git a/packages/coding-agent/src/core/tools/patch/index.ts b/packages/coding-agent/src/core/tools/patch/index.ts index 6d07c3f1b..21599a525 100644 --- a/packages/coding-agent/src/core/tools/patch/index.ts +++ b/packages/coding-agent/src/core/tools/patch/index.ts @@ -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 { +export class EditTool implements AgentTool { 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, + _onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, - ): Promise> { + ): Promise> { const batchRequest = getLspBatchRequest(context?.toolCall); // ───────────────────────────────────────────────────────────────── @@ -303,10 +296,17 @@ export class EditTool implements AgentTool { + 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" }); + }); +}); diff --git a/packages/react-edit-benchmark/generate.ts b/packages/react-edit-benchmark/generate.ts index 56b4a5078..6ac9d4db5 100644 --- a/packages/react-edit-benchmark/generate.ts +++ b/packages/react-edit-benchmark/generate.ts @@ -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 { return Bun.file(filePath).text(); } @@ -550,23 +544,11 @@ interface TarEntry { } async function writeTarball(entries: TarEntry[], outputPath: string): Promise { - return new Promise((resolve, reject) => { - const packer = pack(); - const output = createWriteStream(outputPath); - const gzip = createGzip(); - - packer.pipe(gzip).pipe(output); - + const data: Record = {}; 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[] { diff --git a/packages/react-edit-benchmark/index.ts b/packages/react-edit-benchmark/index.ts index 9638785d6..e6e894c97 100644 --- a/packages/react-edit-benchmark/index.ts +++ b/packages/react-edit-benchmark/index.ts @@ -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 }); diff --git a/packages/react-edit-benchmark/package.json b/packages/react-edit-benchmark/package.json index adf66a31a..6b8c40231 100644 --- a/packages/react-edit-benchmark/package.json +++ b/packages/react-edit-benchmark/package.json @@ -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" diff --git a/packages/react-edit-benchmark/runner.ts b/packages/react-edit-benchmark/runner.ts index 38f45c8ec..051eda5a7 100644 --- a/packages/react-edit-benchmark/runner.ts +++ b/packages/react-edit-benchmark/runner.ts @@ -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> { - 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 Promise }>(); @@ -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); } diff --git a/packages/react-edit-benchmark/tarball.ts b/packages/react-edit-benchmark/tarball.ts index 8bb34c1a3..034037eff 100644 --- a/packages/react-edit-benchmark/tarball.ts +++ b/packages/react-edit-benchmark/tarball.ts @@ -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 { - 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 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 { const prefix = `fixtures/${taskId}/${type}/`; - await new Promise((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()); + } } diff --git a/packages/react-edit-benchmark/tasks.ts b/packages/react-edit-benchmark/tasks.ts index 57b14c6c5..932baa09d 100644 --- a/packages/react-edit-benchmark/tasks.ts +++ b/packages/react-edit-benchmark/tasks.ts @@ -142,9 +142,7 @@ export async function validateFixtures(fixturesPath?: string): Promise { +export async function validateFixturesFromDir(fixturesPath: string): Promise { 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) : []; diff --git a/packages/react-edit-benchmark/test/verify.test.ts b/packages/react-edit-benchmark/test/verify.test.ts index 3276d43a9..ae6a86561 100644 --- a/packages/react-edit-benchmark/test/verify.test.ts +++ b/packages/react-edit-benchmark/test/verify.test.ts @@ -10,7 +10,7 @@ async function createTempDirs(): Promise<{ actualDir: string; cleanup: () => Promise; }> { - 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 }); diff --git a/packages/react-edit-benchmark/verify.ts b/packages/react-edit-benchmark/verify.ts index 59c4ca746..043da2568 100644 --- a/packages/react-edit-benchmark/verify.ts +++ b/packages/react-edit-benchmark/verify.ts @@ -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 { +export async function verifyExpectedFiles(expectedDir: string, actualDir: string): Promise { return verifyExpectedFileSubset(expectedDir, actualDir); }