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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user