diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index 1ce0aef7c..a24f0a916 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -5,13 +5,13 @@ Use it for any merge: single file, feature branch, or full release sync. ## Last Sync Point -**Commit:** `55769e75` -**Date:** 2026-01-18 +**Commit:** `3635e45f` +**Date:** 2026-01-25 When starting a new sync, generate patches from this commit forward: ```bash -git format-patch 6730b4fa..HEAD --stdout > changes.patch +git format-patch 3635e45f..HEAD --stdout > changes.patch ``` ## 0) Define the scope diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e6cf52efe..f2c0af123 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Added +- Added Azure OpenAI Responses provider with deployment mapping and resource-based base URL support + +### Changed +- Added OpenRouter routing preferences for OpenAI-compatible completions + +### Fixed +- Defaulted Google tool call arguments to empty objects when providers omit args +- Guarded Responses/Codex streaming deltas against missing content parts and handled arguments.done events + ## [8.2.1] - 2026-01-24 ### Fixed diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 90c64eccc..431c0b3f0 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -2,6 +2,7 @@ import "./utils/migrate-env"; export * from "./models"; export * from "./providers/anthropic"; +export * from "./providers/azure-openai-responses"; export * from "./providers/cursor"; export * from "./providers/google"; export * from "./providers/google-gemini-cli"; diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts new file mode 100644 index 000000000..ba5659a42 --- /dev/null +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -0,0 +1,688 @@ +import type OpenAI from "openai"; +import { AzureOpenAI } from "openai"; +import type { + Tool as OpenAITool, + ResponseCreateParamsStreaming, + ResponseFunctionToolCall, + ResponseInput, + ResponseInputContent, + ResponseInputImage, + ResponseInputText, + ResponseOutputMessage, + ResponseReasoningItem, +} from "openai/resources/responses/responses"; +import { calculateCost } from "../models"; +import { getEnvApiKey } from "../stream"; +import type { + Api, + AssistantMessage, + Context, + ImageContent, + Model, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { parseStreamingJson } from "../utils/json-parse"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; +import { transformMessages } from "./transform-messages"; + +const DEFAULT_AZURE_API_VERSION = "v1"; + +function parseDeploymentNameMap(value: string | undefined): Map { + const map = new Map(); + if (!value) return map; + for (const entry of value.split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const [modelId, deploymentName] = trimmed.split("=", 2); + if (!modelId || !deploymentName) continue; + map.set(modelId.trim(), deploymentName.trim()); + } + return map; +} + +function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions): string { + if (options?.azureDeploymentName) { + return options.azureDeploymentName; + } + const mappedDeployment = parseDeploymentNameMap(process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(model.id); + return mappedDeployment || model.id; +} + +// Azure OpenAI Responses-specific options +export interface AzureOpenAIResponsesOptions extends StreamOptions { + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; + reasoningSummary?: "auto" | "detailed" | "concise" | null; + azureApiVersion?: string; + azureResourceName?: string; + azureBaseUrl?: string; + azureDeploymentName?: string; +} + +/** + * Generate function for Azure OpenAI Responses API + */ +export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"> = ( + model: Model<"azure-openai-responses">, + context: Context, + options?: AzureOpenAIResponsesOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + // Start async processing + (async () => { + const startTime = Date.now(); + let firstTokenTime: number | undefined; + const deploymentName = resolveDeploymentName(model, options); + + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "azure-openai-responses" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + // Create Azure OpenAI client + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const client = createClient(model, apiKey, options); + const params = buildParams(model, context, options, deploymentName); + options?.onPayload?.(params); + const openaiStream = await client.responses.create( + params, + options?.signal ? { signal: options.signal } : undefined, + ); + stream.push({ type: "start", partial: output }); + + let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null; + let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + + for await (const event of openaiStream) { + // Handle output item start + if (event.type === "response.output_item.added") { + if (!firstTokenTime) firstTokenTime = Date.now(); + const item = event.item; + if (item.type === "reasoning") { + currentItem = item; + currentBlock = { type: "thinking", thinking: "" }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "message") { + currentItem = item; + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "function_call") { + currentItem = item; + currentBlock = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: {}, + partialJson: item.arguments || "", + }; + output.content.push(currentBlock); + stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); + } + } + // Handle reasoning summary deltas + else if (event.type === "response.reasoning_summary_part.added") { + if (currentItem && currentItem.type === "reasoning") { + currentItem.summary = currentItem.summary || []; + currentItem.summary.push(event.part); + } + } else if (event.type === "response.reasoning_summary_text.delta") { + if ( + currentItem && + currentItem.type === "reasoning" && + currentBlock && + currentBlock.type === "thinking" + ) { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + currentBlock.thinking += event.delta; + lastPart.text += event.delta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } + // Add a new line between summary parts (hack...) + else if (event.type === "response.reasoning_summary_part.done") { + if ( + currentItem && + currentItem.type === "reasoning" && + currentBlock && + currentBlock.type === "thinking" + ) { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + currentBlock.thinking += "\n\n"; + lastPart.text += "\n\n"; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: "\n\n", + partial: output, + }); + } + } + } + // Handle text output deltas + else if (event.type === "response.content_part.added") { + if (currentItem && currentItem.type === "message") { + currentItem.content = currentItem.content || []; + // Filter out ReasoningText, only accept output_text and refusal + if (event.part.type === "output_text" || event.part.type === "refusal") { + currentItem.content.push(event.part); + } + } + } else if (event.type === "response.output_text.delta") { + if (currentItem && currentItem.type === "message" && currentBlock && currentBlock.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart && lastPart.type === "output_text") { + currentBlock.text += event.delta; + lastPart.text += event.delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } else if (event.type === "response.refusal.delta") { + if (currentItem && currentItem.type === "message" && currentBlock && currentBlock.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart && lastPart.type === "refusal") { + currentBlock.text += event.delta; + lastPart.refusal += event.delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } + // Handle function call argument deltas + else if (event.type === "response.function_call_arguments.delta") { + if ( + currentItem && + currentItem.type === "function_call" && + currentBlock && + currentBlock.type === "toolCall" + ) { + currentBlock.partialJson += event.delta; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + // Handle function call arguments done (some providers send this instead of deltas) + else if (event.type === "response.function_call_arguments.done") { + if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") { + currentBlock.partialJson = event.arguments; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + } + } + // Handle output item completion + else if (event.type === "response.output_item.done") { + const item = event.item; + + if (item.type === "reasoning" && currentBlock && currentBlock.type === "thinking") { + currentBlock.thinking = item.summary?.map(s => s.text).join("\n\n") || ""; + currentBlock.thinkingSignature = JSON.stringify(item); + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + currentBlock = null; + } else if (item.type === "message" && currentBlock && currentBlock.type === "text") { + currentBlock.text = item.content.map(c => (c.type === "output_text" ? c.text : c.refusal)).join(""); + currentBlock.textSignature = item.id; + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + currentBlock = null; + } else if (item.type === "function_call") { + const args = + currentBlock?.type === "toolCall" && currentBlock.partialJson + ? JSON.parse(currentBlock.partialJson) + : JSON.parse(item.arguments); + const toolCall: ToolCall = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: args, + }; + currentBlock = null; + stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); + } + } + // Handle completion + else if (event.type === "response.completed") { + const response = event.response; + if (response?.usage) { + const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; + output.usage = { + // OpenAI includes cached tokens in input_tokens, so subtract to get non-cached input + input: (response.usage.input_tokens || 0) - cachedTokens, + output: response.usage.output_tokens || 0, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: response.usage.total_tokens || 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + } + calculateCost(model, output.usage); + // Map status to stop reason + output.stopReason = mapStopReason(response?.status); + if (output.content.some(b => b.type === "toolCall") && output.stopReason === "stop") { + output.stopReason = "toolUse"; + } + } + // Handle errors + else if (event.type === "error") { + throw new Error(`Error Code ${event.code}: ${event.message}` || "Unknown error"); + } else if (event.type === "response.failed") { + throw new Error("Unknown error"); + } + } + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unkown error ocurred"); + } + + output.duration = Date.now() - startTime; + if (firstTokenTime) output.ttft = firstTokenTime - startTime; + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) delete (block as { index?: number }).index; + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = formatErrorMessageWithRetryAfter(error); + output.duration = Date.now() - startTime; + if (firstTokenTime) output.ttft = firstTokenTime - startTime; + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +function normalizeAzureBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function buildDefaultBaseUrl(resourceName: string): string { + return `https://${resourceName}.openai.azure.com/openai/v1`; +} + +function resolveAzureConfig( + model: Model<"azure-openai-responses">, + options?: AzureOpenAIResponsesOptions, +): { baseUrl: string; apiVersion: string } { + const apiVersion = options?.azureApiVersion || process.env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION; + + const baseUrl = options?.azureBaseUrl?.trim() || process.env.AZURE_OPENAI_BASE_URL?.trim() || undefined; + const resourceName = options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME; + + let resolvedBaseUrl = baseUrl; + + if (!resolvedBaseUrl && resourceName) { + resolvedBaseUrl = buildDefaultBaseUrl(resourceName); + } + + if (!resolvedBaseUrl && model.baseUrl) { + resolvedBaseUrl = model.baseUrl; + } + + if (!resolvedBaseUrl) { + throw new Error( + "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.", + ); + } + + return { + baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl), + apiVersion, + }; +} + +function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { + if (!apiKey) { + if (!process.env.AZURE_OPENAI_API_KEY) { + throw new Error( + "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", + ); + } + apiKey = process.env.AZURE_OPENAI_API_KEY; + } + + const headers = { ...(model.headers ?? {}) }; + + if (options?.headers) { + Object.assign(headers, options.headers); + } + + const { baseUrl, apiVersion } = resolveAzureConfig(model, options); + + return new AzureOpenAI({ + apiKey, + apiVersion, + dangerouslyAllowBrowser: true, + defaultHeaders: headers, + baseURL: baseUrl, + }); +} + +function buildParams( + model: Model<"azure-openai-responses">, + context: Context, + options: AzureOpenAIResponsesOptions | undefined, + deploymentName: string, +) { + const messages = convertMessages(model, context, true); + + const params: ResponseCreateParamsStreaming = { + model: deploymentName, + input: messages, + stream: true, + prompt_cache_key: options?.sessionId, + }; + + if (options?.maxTokens) { + params.max_output_tokens = options?.maxTokens; + } + + if (options?.temperature !== undefined) { + params.temperature = options?.temperature; + } + + if (context.tools) { + params.tools = convertTools(context.tools); + } + + if (model.reasoning) { + if (options?.reasoningEffort || options?.reasoningSummary) { + params.reasoning = { + effort: options?.reasoningEffort || "medium", + summary: options?.reasoningSummary || "auto", + }; + params.include = ["reasoning.encrypted_content"]; + } else { + if (model.name.toLowerCase().startsWith("gpt-5")) { + // Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7 + messages.push({ + role: "developer", + content: [ + { + type: "input_text", + text: "# Juice: 0 !important", + }, + ], + }); + } + } + } + + return params; +} + +function normalizeResponsesToolCallId(id: string): { callId: string; itemId: string } { + const [callId, itemId] = id.split("|"); + if (callId && itemId) { + return { callId, itemId }; + } + const hash = Bun.hash.xxHash64(id).toString(36); + return { callId: `call_${hash}`, itemId: `item_${hash}` }; +} + +function convertMessages( + model: Model<"azure-openai-responses">, + context: Context, + strictResponsesPairing: boolean, +): ResponseInput { + const messages: ResponseInput = []; + const knownCallIds = new Set(); + + const transformedMessages = transformMessages(context.messages, model); + + if (context.systemPrompt) { + const role = model.reasoning ? "developer" : "system"; + messages.push({ + role, + content: sanitizeSurrogates(context.systemPrompt), + }); + } + + let msgIndex = 0; + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + // Skip empty user messages + if (!msg.content || msg.content.trim() === "") continue; + messages.push({ + role: "user", + content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }], + }); + } else { + const content: ResponseInputContent[] = msg.content.map((item): ResponseInputContent => { + if (item.type === "text") { + return { + type: "input_text", + text: sanitizeSurrogates(item.text), + } satisfies ResponseInputText; + } + return { + type: "input_image", + detail: "auto", + image_url: `data:${item.mimeType};base64,${item.data}`, + } satisfies ResponseInputImage; + }); + // Filter out images if model doesn't support them, and empty text blocks + let filteredContent = !model.input.includes("image") + ? content.filter(c => c.type !== "input_image") + : content; + filteredContent = filteredContent.filter(c => { + if (c.type === "input_text") { + return c.text.trim().length > 0; + } + return true; // Keep non-text content (images) + }); + if (filteredContent.length === 0) continue; + messages.push({ + role: "user", + content: filteredContent, + }); + } + } else if (msg.role === "assistant") { + const output: ResponseInput = []; + const assistantMsg = msg as AssistantMessage; + + // Check if this message is from a different model (same provider, different model ID). + // For such messages, tool call IDs with fc_ prefix need to be stripped to avoid + // OpenAI's reasoning/function_call pairing validation errors. + const isDifferentModel = + assistantMsg.model !== model.id && + assistantMsg.provider === model.provider && + assistantMsg.api === model.api; + + for (const block of msg.content) { + // Do not submit thinking blocks if the completion had an error (i.e. abort) + if (block.type === "thinking" && msg.stopReason !== "error") { + if (block.thinkingSignature) { + const reasoningItem = JSON.parse(block.thinkingSignature); + output.push(reasoningItem); + } + } else if (block.type === "text") { + const textBlock = block as TextContent; + // OpenAI requires id to be max 64 characters + let msgId = textBlock.textSignature; + if (!msgId) { + msgId = `msg_${msgIndex}`; + } else if (msgId.length > 64) { + msgId = `msg_${Bun.hash.xxHash64(msgId).toString(36)}`; + } + output.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }], + status: "completed", + id: msgId, + } satisfies ResponseOutputMessage); + // Do not submit toolcall blocks if the completion had an error (i.e. abort) + } else if (block.type === "toolCall" && msg.stopReason !== "error") { + const toolCall = block as ToolCall; + const normalized = normalizeResponsesToolCallId(toolCall.id); + const callId = normalized.callId; + // For different-model messages, set id to undefined to avoid pairing validation. + // OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items. + // By omitting the id, we avoid triggering that validation (like cross-provider does). + let itemId: string | undefined = normalized.itemId; + if (isDifferentModel && itemId?.startsWith("fc_")) { + itemId = undefined; + } + knownCallIds.add(normalized.callId); + output.push({ + type: "function_call", + id: itemId, + call_id: callId, + name: toolCall.name, + arguments: JSON.stringify(toolCall.arguments), + }); + } + } + if (output.length === 0) continue; + messages.push(...output); + } else if (msg.role === "toolResult") { + // Extract text and image content + const textResult = msg.content + .filter(c => c.type === "text") + .map(c => (c as { text: string }).text) + .join("\n"); + const hasImages = msg.content.some(c => c.type === "image"); + const normalized = normalizeResponsesToolCallId(msg.toolCallId); + if (strictResponsesPairing && !knownCallIds.has(normalized.callId)) { + continue; + } + + // Always send function_call_output with text (or placeholder if only images) + const hasText = textResult.length > 0; + messages.push({ + type: "function_call_output", + call_id: normalized.callId, + output: sanitizeSurrogates(hasText ? textResult : "(see attached image)"), + }); + + // If there are images and model supports them, send a follow-up user message with images + if (hasImages && model.input.includes("image")) { + const contentParts: ResponseInputContent[] = []; + + // Add text prefix + contentParts.push({ + type: "input_text", + text: "Attached image(s) from tool result:", + } satisfies ResponseInputText); + + // Add images + for (const block of msg.content) { + if (block.type === "image") { + contentParts.push({ + type: "input_image", + detail: "auto", + image_url: `data:${(block as ImageContent).mimeType};base64,${(block as ImageContent).data}`, + } satisfies ResponseInputImage); + } + } + + messages.push({ + role: "user", + content: contentParts, + }); + } + } + msgIndex++; + } + + return messages; +} + +function convertTools(tools: Tool[]): OpenAITool[] { + return tools.map(tool => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters as Record, + strict: false, + })); +} + +function mapStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason { + if (!status) return "stop"; + switch (status) { + case "completed": + return "stop"; + case "incomplete": + return "length"; + case "failed": + case "cancelled": + return "error"; + // These two are wonky ... + case "in_progress": + case "queued": + return "stop"; + default: { + const _exhaustive: never = status; + throw new Error(`Unhandled stop reason: ${_exhaustive}`); + } + } +} diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 45e681fc4..51be113f8 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -179,7 +179,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( type: "toolCall", id: toolCallId, name: part.functionCall.name || "", - arguments: part.functionCall.args as Record, + arguments: (part.functionCall.args ?? {}) as Record, ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), }; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 31c427035..c1bfdadf5 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -74,26 +74,12 @@ const CODEX_MAX_RETRIES = 2; const CODEX_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); const CODEX_RETRY_DELAY_MS = 500; -/** Fast deterministic hash to shorten long strings */ -function shortHash(str: string): string { - let h1 = 0xdeadbeef; - let h2 = 0x41c6ce57; - for (let i = 0; i < str.length; i++) { - const ch = str.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); - return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36); -} - function normalizeResponsesToolCallId(id: string): { callId: string; itemId: string } { const [callId, itemId] = id.split("|"); if (callId && itemId) { return { callId, itemId }; } - const hash = shortHash(id); + const hash = Bun.hash.xxHash64(id).toString(36); return { callId: `call_${hash}`, itemId: `item_${hash}` }; } @@ -298,6 +284,9 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } } else if (eventType === "response.output_text.delta") { if (currentItem && currentItem.type === "message" && currentBlock?.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } const lastPart = currentItem.content[currentItem.content.length - 1]; if (lastPart && lastPart.type === "output_text") { const delta = (rawEvent as { delta?: string }).delta || ""; @@ -313,6 +302,9 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } } else if (eventType === "response.refusal.delta") { if (currentItem && currentItem.type === "message" && currentBlock?.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } const lastPart = currentItem.content[currentItem.content.length - 1]; if (lastPart && lastPart.type === "refusal") { const delta = (rawEvent as { delta?: string }).delta || ""; @@ -338,6 +330,14 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" partial: output, }); } + } else if (eventType === "response.function_call_arguments.done") { + if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") { + const args = (rawEvent as { arguments?: string }).arguments; + if (typeof args === "string") { + currentBlock.partialJson = args; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + } + } } else if (eventType === "response.output_item.done") { const item = rawEvent.item as ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall; if (item.type === "reasoning" && currentBlock?.type === "thinking") { @@ -622,7 +622,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex if (!msgId) { msgId = `msg_${msgIndex}`; } else if (msgId.length > 64) { - msgId = `msg_${shortHash(msgId)}`; + msgId = `msg_${Bun.hash.xxHash64(msgId).toString(36)}`; } output.push({ type: "message", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 763171afe..3c3a7c48c 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -50,6 +50,10 @@ function normalizeMistralToolId(id: string, isMistral: boolean): string { return normalized; } +type ResolvedOpenAICompat = Required> & { + openRouterRouting?: OpenAICompat["openRouterRouting"]; +}; + /** * Check if conversation messages contain tool calls or tool results. * This is needed because Anthropic (via proxy) requires the tools param @@ -429,6 +433,11 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio params.reasoning_effort = options.reasoningEffort; } + // OpenRouter provider routing preferences + if (model.baseUrl.includes("openrouter.ai") && compat.openRouterRouting) { + (params as { provider?: unknown }).provider = compat.openRouterRouting; + } + return params; } @@ -468,7 +477,7 @@ function maybeAddOpenRouterAnthropicCacheControl( export function convertMessages( model: Model<"openai-completions">, context: Context, - compat: Required, + compat: ResolvedOpenAICompat, ): ChatCompletionMessageParam[] { const params: ChatCompletionMessageParam[] = []; @@ -718,7 +727,7 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto * Provider takes precedence over URL-based detection since it's explicitly configured. * Returns a fully resolved OpenAICompat object with all fields set. */ -function detectCompat(model: Model<"openai-completions">): Required { +function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompat { const provider = model.provider; const baseUrl = model.baseUrl; @@ -753,6 +762,7 @@ function detectCompat(model: Model<"openai-completions">): Required): Required): Required { +function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompat { const detected = detectCompat(model); if (!model.compat) return detected; @@ -776,5 +786,6 @@ function getCompat(model: Model<"openai-completions">): Required { requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, requiresMistralToolIds: model.compat.requiresMistralToolIds ?? detected.requiresMistralToolIds, thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat, + openRouterRouting: model.compat.openRouterRouting ?? detected.openRouterRouting, }; } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 488966ed0..aed0bdf85 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -31,20 +31,6 @@ import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { transformMessages } from "./transform-messages"; -/** Fast deterministic hash to shorten long strings */ -function shortHash(str: string): string { - let h1 = 0xdeadbeef; - let h2 = 0x41c6ce57; - for (let i = 0; i < str.length; i++) { - const ch = str.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); - return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36); -} - // OpenAI Responses-specific options export interface OpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -195,6 +181,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( } } else if (event.type === "response.output_text.delta") { if (currentItem && currentItem.type === "message" && currentBlock && currentBlock.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } const lastPart = currentItem.content[currentItem.content.length - 1]; if (lastPart && lastPart.type === "output_text") { currentBlock.text += event.delta; @@ -209,6 +198,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( } } else if (event.type === "response.refusal.delta") { if (currentItem && currentItem.type === "message" && currentBlock && currentBlock.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } const lastPart = currentItem.content[currentItem.content.length - 1]; if (lastPart && lastPart.type === "refusal") { currentBlock.text += event.delta; @@ -449,7 +441,7 @@ function normalizeResponsesToolCallId(id: string): { callId: string; itemId: str if (callId && itemId) { return { callId, itemId }; } - const hash = shortHash(id); + const hash = Bun.hash.xxHash64(id).toString(36); return { callId: `call_${hash}`, itemId: `item_${hash}` }; } @@ -542,7 +534,7 @@ function convertMessages( if (!msgId) { msgId = `msg_${msgIndex}`; } else if (msgId.length > 64) { - msgId = `msg_${shortHash(msgId)}`; + msgId = `msg_${Bun.hash.xxHash64(msgId).toString(36)}`; } output.push({ type: "message", diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 0b35081bc..75ecf4270 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -9,26 +9,12 @@ function normalizeToolCallId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40); } -/** Fast deterministic hash to shorten long strings */ -function shortHash(str: string): string { - let h1 = 0xdeadbeef; - let h2 = 0x41c6ce57; - for (let i = 0; i < str.length; i++) { - const ch = str.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); - return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36); -} - function normalizeResponsesToolCallId(id: string): string { const [callId, itemId] = id.split("|"); if (callId && itemId) { return id; } - const hash = shortHash(id); + const hash = Bun.hash.xxHash64(id).toString(36); return `call_${hash}|item_${hash}`; } @@ -36,7 +22,10 @@ export function transformMessages(messages: Message[], model: // Build a map of original tool call IDs to normalized IDs for github-copilot cross-API switches const toolCallIdMap = new Map(); const skippedToolCallIds = new Set(); - const needsResponsesToolCallIds = model.api === "openai-responses" || model.api === "openai-codex-responses"; + const needsResponsesToolCallIds = + model.api === "openai-responses" || + model.api === "openai-codex-responses" || + model.api === "azure-openai-responses"; // First pass: transform messages (thinking blocks, tool call ID normalization) const transformed = messages.flatMap((msg): Message[] => { diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index fedc3af88..c54becd8e 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { supportsXhigh } from "./models"; import { type BedrockOptions, streamBedrock } from "./providers/amazon-bedrock"; import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic"; +import { type AzureOpenAIResponsesOptions, streamAzureOpenAIResponses } from "./providers/azure-openai-responses"; import { type CursorOptions, streamCursor } from "./providers/cursor"; import { type GoogleOptions, streamGoogle } from "./providers/google"; import { @@ -108,6 +109,7 @@ export function getEnvApiKey(provider: any): string | undefined { minimax: "MINIMAX_API_KEY", opencode: "OPENCODE_API_KEY", cursor: "CURSOR_ACCESS_TOKEN", + "azure-openai-responses": "AZURE_OPENAI_API_KEY", }; const envVar = envMap[provider]; @@ -144,6 +146,9 @@ export function stream( case "openai-responses": return streamOpenAIResponses(model as Model<"openai-responses">, context, providerOptions as any); + case "azure-openai-responses": + return streamAzureOpenAIResponses(model as Model<"azure-openai-responses">, context, providerOptions as any); + case "openai-codex-responses": return streamOpenAICodexResponses(model as Model<"openai-codex-responses">, context, providerOptions as any); @@ -345,6 +350,12 @@ function mapOptionsForApi( reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning), } satisfies OpenAIResponsesOptions; + case "azure-openai-responses": + return { + ...base, + reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning), + } satisfies AzureOpenAIResponsesOptions; + case "openai-codex-responses": return { ...base, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index dafc6a5d7..688d7caef 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,6 +1,7 @@ import type { TSchema } from "@sinclair/typebox"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; +import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses"; import type { CursorOptions } from "./providers/cursor"; import type { DeleteArgs, @@ -33,6 +34,7 @@ export type Api = | "openai-completions" | "openai-responses" | "openai-codex-responses" + | "azure-openai-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" @@ -46,6 +48,7 @@ export interface ApiOptionsMap { "openai-completions": OpenAICompletionsOptions; "openai-responses": OpenAIResponsesOptions; "openai-codex-responses": OpenAICodexResponsesOptions; + "azure-openai-responses": AzureOpenAIResponsesOptions; "google-generative-ai": GoogleOptions; "google-gemini-cli": GoogleGeminiCliOptions; "google-vertex": GoogleVertexOptions; @@ -289,6 +292,20 @@ export interface OpenAICompat { requiresMistralToolIds?: boolean; /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }. Default: "openai". */ thinkingFormat?: "openai" | "zai"; + /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ + openRouterRouting?: OpenRouterRouting; +} + +/** + * OpenRouter provider routing preferences. + * Controls which upstream providers OpenRouter routes requests to. + * @see https://openrouter.ai/docs/provider-routing + */ +export interface OpenRouterRouting { + /** List of provider slugs to exclusively use for this request (e.g., ["amazon-bedrock", "anthropic"]). */ + only?: string[]; + /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ + order?: string[]; } // Model interface for the unified model system diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index a63ad8a6d..c903a3f19 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -23,6 +23,7 @@ const compat: Required = { requiresThinkingAsText: false, requiresMistralToolIds: false, thinkingFormat: "openai", + openRouterRouting: {}, }; function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0dbdb6e8f..4fc2fdf5d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +### Added +- Added extension API to set working/loading messages during streaming +- Added task worker propagation of context files, skills, and prompt templates +- Added subagent option to skip Python preflight checks when Python tooling is unused + +### Changed +- Merged patch application warnings into edit tool diagnostics output +- Cached Python prelude docs for subagent workers to avoid repeated warmups +- Simplified image placeholders inserted on paste to match Claude-style markers + +### Fixed +- Rewrote empty or corrupted session files to restore valid headers +- Improved patch applicator ambiguity errors with match previews and overlap detection + ## [8.3.0] - 2026-01-25 ### Changed diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index bcb4444be..37fccbbe1 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -19,12 +19,18 @@ import type { AuthStorage } from "../session/auth-storage"; const Ajv = (AjvModule as any).default || AjvModule; +const OpenRouterRoutingSchema = Type.Object({ + only: Type.Optional(Type.Array(Type.String())), + order: Type.Optional(Type.Array(Type.String())), +}); + // Schema for OpenAI compatibility settings const OpenAICompatSchema = Type.Object({ supportsStore: Type.Optional(Type.Boolean()), supportsDeveloperRole: Type.Optional(Type.Boolean()), supportsReasoningEffort: Type.Optional(Type.Boolean()), maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), + openRouterRouting: Type.Optional(OpenRouterRoutingSchema), }); // Schema for custom model definition @@ -36,6 +42,7 @@ const ModelDefinitionSchema = Type.Object({ Type.Literal("openai-completions"), Type.Literal("openai-responses"), Type.Literal("openai-codex-responses"), + Type.Literal("azure-openai-responses"), Type.Literal("anthropic-messages"), Type.Literal("google-generative-ai"), Type.Literal("google-vertex"), @@ -63,6 +70,7 @@ const ProviderConfigSchema = Type.Object({ Type.Literal("openai-completions"), Type.Literal("openai-responses"), Type.Literal("openai-codex-responses"), + Type.Literal("azure-openai-responses"), Type.Literal("anthropic-messages"), Type.Literal("google-generative-ai"), Type.Literal("google-vertex"), diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index cf977336d..5626b9bb3 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -85,6 +85,7 @@ const noOpUIContext: ExtensionUIContext = { input: async (_title, _placeholder, _dialogOptions) => undefined, notify: () => {}, setStatus: () => {}, + setWorkingMessage: () => {}, setWidget: () => {}, setFooter: () => {}, setHeader: () => {}, diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 01857c0b1..58664c77a 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -69,6 +69,9 @@ export interface ExtensionUIContext { /** Set status text in the footer/status bar. Pass undefined to clear. */ setStatus(key: string, text: string | undefined): void; + /** Set the working/loading message shown during streaming. Call with no argument to restore default. */ + setWorkingMessage(message?: string): void; + /** Set a widget to display above the editor. Accepts string array or component factory. */ setWidget(key: string, content: string[] | undefined): void; setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; diff --git a/packages/coding-agent/src/ipy/executor.ts b/packages/coding-agent/src/ipy/executor.ts index c5c248c7b..caa52b827 100644 --- a/packages/coding-agent/src/ipy/executor.ts +++ b/packages/coding-agent/src/ipy/executor.ts @@ -187,6 +187,10 @@ export function getPreludeDocs(): PreludeHelper[] { return cachedPreludeDocs ?? []; } +export function setPreludeDocsCache(docs: PreludeHelper[]): void { + cachedPreludeDocs = docs; +} + export function resetPreludeDocsCache(): void { cachedPreludeDocs = null; } diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 820466766..d22a35621 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -68,6 +68,7 @@ export class EventController { getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(this.ctx.loadingAnimation); + this.ctx.applyPendingWorkingMessage(); this.ctx.ui.requestRender(); break; diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 2ef36a91d..d5cb8d623 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -30,6 +30,7 @@ export class ExtensionUiController { input: (title, placeholder, _dialogOptions) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), setStatus: (key, text) => this.setHookStatus(key, text), + setWorkingMessage: message => this.ctx.setWorkingMessage(message), setWidget: (key, content) => this.setHookWidget(key, content), setTitle: title => setTerminalTitle(title), custom: (factory, _options) => this.showHookCustom(factory), @@ -389,6 +390,7 @@ export class ExtensionUiController { input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined, notify: () => {}, setStatus: () => {}, + setWorkingMessage: () => {}, setWidget: () => {}, setTitle: () => {}, custom: async () => undefined as never, diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index b4fe7be26..5e5d2a6eb 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -541,9 +541,9 @@ export class InputController { data: imageData.data, mimeType: imageData.mimeType, }); - // Insert styled placeholder at cursor like Claude does + // Insert placeholder at cursor like Claude does const imageNum = this.ctx.pendingImages.length; - const placeholder = theme.bold(theme.underline(`[Image #${imageNum}]`)); + const placeholder = `[Image #${imageNum}]`; this.ctx.editor.insertText(`${placeholder} `); this.ctx.ui.requestRender(); return true; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 2fdb06c9a..08c500e7f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -102,6 +102,8 @@ export class InteractiveMode implements InteractiveModeContext { public loadingAnimation: Loader | undefined = undefined; public autoCompactionLoader: Loader | undefined = undefined; public retryLoader: Loader | undefined = undefined; + private pendingWorkingMessage: string | undefined; + private readonly defaultWorkingMessage = `Working${theme.format.ellipsis} (esc to interrupt)`; public autoCompactionEscapeHandler?: () => void; public retryEscapeHandler?: () => void; public unsubscribe?: () => void; @@ -160,6 +162,7 @@ export class InteractiveMode implements InteractiveModeContext { this.statusContainer = new Container(); this.todoContainer = new Container(); this.editor = new CustomEditor(getEditorTheme()); + this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor()); this.editor.onAutocompleteCancel = () => { this.ui.requestRender(true); }; @@ -538,6 +541,33 @@ export class InteractiveMode implements InteractiveModeContext { this.uiHelpers.showWarning(message); } + setWorkingMessage(message?: string): void { + if (message === undefined) { + this.pendingWorkingMessage = undefined; + if (this.loadingAnimation) { + this.loadingAnimation.setMessage(this.defaultWorkingMessage); + } + return; + } + + if (this.loadingAnimation) { + this.loadingAnimation.setMessage(message); + return; + } + + this.pendingWorkingMessage = message; + } + + applyPendingWorkingMessage(): void { + if (this.pendingWorkingMessage === undefined) { + return; + } + + const message = this.pendingWorkingMessage; + this.pendingWorkingMessage = undefined; + this.setWorkingMessage(message); + } + showNewVersionNotification(newVersion: string): void { this.uiHelpers.showNewVersionNotification(newVersion); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index a508d3f55..f47cb4991 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -187,6 +187,10 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcExtensionUIRequest); } + setWorkingMessage(_message?: string): void { + // Not supported in RPC mode + } + setWidget(key: string, content: unknown): void { // Only support string arrays in RPC mode - factory functions are ignored if (content === undefined || Array.isArray(content)) { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index f6a6a82b0..c2389f095 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -111,6 +111,8 @@ export interface InteractiveModeContext { queueCompactionMessage(text: string, mode: "steer" | "followUp"): void; flushCompactionQueue(options?: { willRetry?: boolean }): Promise; flushPendingBashComponents(): void; + setWorkingMessage(message?: string): void; + applyPendingWorkingMessage(): void; isKnownSlashCommand(text: string): boolean; addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void; renderSessionContext( diff --git a/packages/coding-agent/src/patch/applicator.ts b/packages/coding-agent/src/patch/applicator.ts index 36a0e36b7..ebfb73c81 100644 --- a/packages/coding-agent/src/patch/applicator.ts +++ b/packages/coding-agent/src/patch/applicator.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { resolveToCwd } from "../tools/path-utils"; -import { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch, seekSequence } from "./fuzzy"; +import { DEFAULT_FUZZY_THRESHOLD, findClosestSequenceMatch, findContextLine, findMatch, seekSequence } from "./fuzzy"; import { adjustIndentation, convertLeadingTabsToSpaces, @@ -67,9 +67,12 @@ interface Replacement { newLines: string[]; } +type HunkVariantKind = "trim-common" | "dedupe-shared" | "collapse-repeated" | "single-line"; + interface HunkVariant { oldLines: string[]; newLines: string[]; + kind: HunkVariantKind; } // ═══════════════════════════════════════════════════════════════════════════ @@ -272,7 +275,7 @@ function trimCommonContext(oldLines: string[], newLines: string[]): HunkVariant if (trimmedOld.length === 0 && trimmedNew.length === 0) { return undefined; } - return { oldLines: trimmedOld, newLines: trimmedNew }; + return { oldLines: trimmedOld, newLines: trimmedNew, kind: "trim-common" }; } function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]): HunkVariant | undefined { @@ -297,7 +300,7 @@ function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]): if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) { return undefined; } - return { oldLines: collapsedOld, newLines: collapsedNew }; + return { oldLines: collapsedOld, newLines: collapsedNew, kind: "dedupe-shared" }; } function collapseRepeatedBlocks(oldLines: string[], newLines: string[]): HunkVariant | undefined { @@ -339,7 +342,7 @@ function collapseRepeatedBlocks(oldLines: string[], newLines: string[]): HunkVar if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) { return undefined; } - return { oldLines: collapsedOld, newLines: collapsedNew }; + return { oldLines: collapsedOld, newLines: collapsedNew, kind: "collapse-repeated" }; } function reduceToSingleLineChange(oldLines: string[], newLines: string[]): HunkVariant | undefined { @@ -352,12 +355,12 @@ function reduceToSingleLineChange(oldLines: string[], newLines: string[]): HunkV } } if (changedIndex === undefined) return undefined; - return { oldLines: [oldLines[changedIndex]], newLines: [newLines[changedIndex]] }; + return { oldLines: [oldLines[changedIndex]], newLines: [newLines[changedIndex]], kind: "single-line" }; } function buildFallbackVariants(hunk: DiffHunk): HunkVariant[] { const variants: HunkVariant[] = []; - const base: HunkVariant = { oldLines: hunk.oldLines, newLines: hunk.newLines }; + const base: HunkVariant = { oldLines: hunk.oldLines, newLines: hunk.newLines, kind: "trim-common" }; const trimmed = trimCommonContext(base.oldLines, base.newLines); if (trimmed) variants.push(trimmed); @@ -387,6 +390,11 @@ function buildFallbackVariants(hunk: DiffHunk): HunkVariant[] { }); } +function filterFallbackVariants(variants: HunkVariant[], allowAggressive: boolean): HunkVariant[] { + if (allowAggressive) return variants; + return variants.filter(variant => variant.kind !== "collapse-repeated" && variant.kind !== "single-line"); +} + function findContextRelativeMatch( lines: string[], patternLine: string, @@ -414,6 +422,47 @@ function findContextRelativeMatch( return undefined; } +const AMBIGUITY_HINT_WINDOW = 200; +const MATCH_PREVIEW_CONTEXT = 2; +const MATCH_PREVIEW_MAX_LEN = 80; + +function formatSequenceMatchPreview(lines: string[], startIdx: number): string { + const start = Math.max(0, startIdx - MATCH_PREVIEW_CONTEXT); + const end = Math.min(lines.length, startIdx + MATCH_PREVIEW_CONTEXT + 1); + const previewLines = lines.slice(start, end); + return previewLines + .map((line, i) => { + const num = start + i + 1; + const truncated = + line.length > MATCH_PREVIEW_MAX_LEN ? `${line.slice(0, MATCH_PREVIEW_MAX_LEN - 3)}...` : line; + return ` ${num} | ${truncated}`; + }) + .join("\n"); +} + +function formatSequenceMatchPreviews( + lines: string[], + matchIndices: number[] | undefined, + matchCount: number | undefined, +): string | undefined { + if (!matchIndices || matchIndices.length === 0) return undefined; + const previews = matchIndices.map(index => formatSequenceMatchPreview(lines, index)); + const moreMsg = + matchCount && matchCount > matchIndices.length ? ` (showing first ${matchIndices.length} of ${matchCount})` : ""; + return `${previews.join("\n\n")}${moreMsg}`; +} + +function chooseHintedMatch( + matchIndices: number[] | undefined, + hintIndex: number | undefined, + window: number, +): number | undefined { + if (!matchIndices || matchIndices.length === 0 || hintIndex === undefined) return undefined; + const candidates = matchIndices.filter(index => Math.abs(index - hintIndex) <= window); + if (candidates.length === 1) return candidates[0]; + return undefined; +} + /** Get hint index from hunk's line number */ function getHunkHintIndex(hunk: DiffHunk, currentIndex: number): number | undefined { if (hunk.oldStartLine === undefined) return undefined; @@ -458,11 +507,17 @@ function findHierarchicalContext( if (hintStart >= currentStart) { const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy }); if (hintedResult.index !== undefined) { - return { ...hintedResult, matchCount: 1 }; + return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] }; } } } - return { index: undefined, confidence: result.confidence, matchCount: result.matchCount }; + return { + index: undefined, + confidence: result.confidence, + matchCount: result.matchCount, + matchIndices: result.matchIndices, + strategy: result.strategy, + }; } if (result.index === undefined) { @@ -471,7 +526,7 @@ function findHierarchicalContext( if (hintStart >= currentStart) { const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy }); if (hintedResult.index !== undefined) { - return { ...hintedResult, matchCount: 1 }; + return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] }; } } } @@ -494,17 +549,27 @@ function findHierarchicalContext( const inner = spaceParts[spaceParts.length - 1]; const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy }); if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) { - return { index: undefined, confidence: outerResult.confidence, matchCount: outerResult.matchCount }; + return { + index: undefined, + confidence: outerResult.confidence, + matchCount: outerResult.matchCount, + matchIndices: outerResult.matchIndices, + strategy: outerResult.strategy, + }; } if (outerResult.index !== undefined) { const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy }); if (innerResult.index !== undefined) { return innerResult.matchCount && innerResult.matchCount > 1 - ? { ...innerResult, matchCount: 1 } + ? { ...innerResult, matchCount: 1, matchIndices: [innerResult.index] } : innerResult; } if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) { - return { ...innerResult, matchCount: 1 }; + return { + ...innerResult, + matchCount: 1, + matchIndices: innerResult.index !== undefined ? [innerResult.index] : innerResult.matchIndices, + }; } } } @@ -516,7 +581,7 @@ function findHierarchicalContext( const hintStart = Math.max(0, lineHint - 1); const hintedResult = findContextLine(lines, context, hintStart, { allowFuzzy }); if (hintedResult.index !== undefined) { - return { ...hintedResult, matchCount: 1 }; + return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] }; } } @@ -547,7 +612,13 @@ function findHierarchicalContext( const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy }); if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) { - return { index: undefined, confidence: outerResult.confidence, matchCount: outerResult.matchCount }; + return { + index: undefined, + confidence: outerResult.confidence, + matchCount: outerResult.matchCount, + matchIndices: outerResult.matchIndices, + strategy: outerResult.strategy, + }; } if (outerResult.index === undefined) { @@ -556,10 +627,16 @@ function findHierarchicalContext( const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy }); if (innerResult.index !== undefined) { - return innerResult.matchCount && innerResult.matchCount > 1 ? { ...innerResult, matchCount: 1 } : innerResult; + return innerResult.matchCount && innerResult.matchCount > 1 + ? { ...innerResult, matchCount: 1, matchIndices: [innerResult.index] } + : innerResult; } if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) { - return { ...innerResult, matchCount: 1 }; + return { + ...innerResult, + matchCount: 1, + matchIndices: innerResult.index !== undefined ? [innerResult.index] : innerResult.matchIndices, + }; } } @@ -620,6 +697,7 @@ function attemptSequenceFallback( currentIndex: number, lineHint: number | undefined, allowFuzzy: boolean, + allowAggressiveFallbacks: boolean, ): number | undefined { if (hunk.oldLines.length === 0) return undefined; const matchHint = getHunkHintIndex(hunk, currentIndex); @@ -642,7 +720,7 @@ function attemptSequenceFallback( return fallbackResult.index; } - for (const variant of buildFallbackVariants(hunk)) { + for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) { if (variant.oldLines.length === 0) continue; const variantResult = findSequenceWithHint( lines, @@ -669,7 +747,7 @@ function applyCharacterMatch( hunk: DiffHunk, fuzzyThreshold: number, allowFuzzy: boolean, -): string { +): { content: string; warnings: string[] } { const oldText = hunk.oldLines.join("\n"); const newText = hunk.newLines.join("\n"); @@ -725,10 +803,18 @@ function applyCharacterMatch( // Adjust indentation to match what was actually found const adjustedNewText = adjustIndentation(normalizedOldText, matchOutcome.match.actualText, newText); + const warnings: string[] = []; + if (matchOutcome.dominantFuzzy && matchOutcome.match) { + const similarity = Math.round(matchOutcome.match.confidence * 100); + warnings.push( + `Dominant fuzzy match selected in ${path} near line ${matchOutcome.match.startLine} (${similarity}% similar).`, + ); + } + // Apply the replacement const before = normalizedContent.substring(0, matchOutcome.match.startIndex); const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length); - return before + adjustedNewText + after; + return { content: before + adjustedNewText + after, warnings }; } function applyTrailingNewlinePolicy(content: string, hadFinalNewline: boolean): string { @@ -746,8 +832,9 @@ function computeReplacements( path: string, hunks: DiffHunk[], allowFuzzy: boolean, -): Replacement[] { +): { replacements: Replacement[]; warnings: string[] } { const replacements: Replacement[] = []; + const warnings: string[] = []; let lineIndex = 0; for (const hunk of hunks) { @@ -763,6 +850,7 @@ function computeReplacements( ); } const lineHint = hunk.oldStartLine; + const allowAggressiveFallbacks = hunk.changeContext !== undefined || lineHint !== undefined || hunk.isEndOfFile; if (lineHint !== undefined && hunk.changeContext === undefined && !hunk.hasContextLines) { lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1)); } @@ -775,16 +863,26 @@ function computeReplacements( contextIndex = idx; if (idx === undefined || (result.matchCount !== undefined && result.matchCount > 1)) { - const fallback = attemptSequenceFallback(originalLines, hunk, lineIndex, lineHint, allowFuzzy); + const fallback = attemptSequenceFallback( + originalLines, + hunk, + lineIndex, + lineHint, + allowFuzzy, + allowAggressiveFallbacks, + ); if (fallback !== undefined) { lineIndex = fallback; } else if (result.matchCount !== undefined && result.matchCount > 1) { const displayContext = hunk.changeContext.includes("\n") ? hunk.changeContext.split("\n").pop() : hunk.changeContext; + const previews = formatSequenceMatchPreviews(originalLines, result.matchIndices, result.matchCount); + const strategyHint = result.strategy ? ` Matching strategy: ${result.strategy}.` : ""; + const previewText = previews ? `\n\n${previews}` : ""; throw new ApplyPatchError( - `Found ${result.matchCount} matches for context '${displayContext}' in ${path}. ` + - `Add more surrounding context or additional @@ anchors to make it unique.`, + `Found ${result.matchCount} matches for context '${displayContext}' in ${path}.${strategyHint}` + + `${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`, ); } else { const displayContext = hunk.changeContext.includes("\n") @@ -875,7 +973,7 @@ function computeReplacements( } if (searchResult.index === undefined || (searchResult.matchCount ?? 0) > 1) { - for (const variant of buildFallbackVariants(hunk)) { + for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) { if (variant.oldLines.length === 0) continue; const variantResult = findSequenceWithHint( originalLines, @@ -895,7 +993,7 @@ function computeReplacements( } if (searchResult.index === undefined && contextIndex !== undefined) { - for (const variant of buildFallbackVariants(hunk)) { + for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) { if (variant.oldLines.length !== 1 || variant.newLines.length !== 1) continue; const removedLine = variant.oldLines[0]; const hasSharedDuplicate = hunk.newLines.some(line => line.trim() === removedLine.trim()); @@ -929,11 +1027,38 @@ function computeReplacements( } } + if ((searchResult.matchCount ?? 0) > 1) { + const hintIndex = matchHint ?? (lineHint ? lineHint - 1 : undefined); + const hinted = chooseHintedMatch(searchResult.matchIndices, hintIndex, AMBIGUITY_HINT_WINDOW); + if (hinted !== undefined) { + searchResult = { ...searchResult, index: hinted, matchCount: 1 }; + } + } + if (searchResult.index === undefined) { if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) { + const previews = formatSequenceMatchPreviews( + originalLines, + searchResult.matchIndices, + searchResult.matchCount, + ); + const strategyHint = searchResult.strategy ? ` Matching strategy: ${searchResult.strategy}.` : ""; + const previewText = previews ? `\n\n${previews}` : ""; throw new ApplyPatchError( - `Found ${searchResult.matchCount} matches for the text in ${path}. ` + - `Add more surrounding context or additional @@ anchors to make it unique.`, + `Found ${searchResult.matchCount} matches for the text in ${path}.${strategyHint}` + + `${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`, + ); + } + const closest = findClosestSequenceMatch(originalLines, pattern, { + start: lineIndex, + eof: hunk.isEndOfFile, + }); + if (closest.index !== undefined && closest.confidence > 0) { + const similarity = Math.round(closest.confidence * 100); + const preview = formatSequenceMatchPreview(originalLines, closest.index); + throw new ApplyPatchError( + `Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}\n\n` + + `Closest match (${similarity}% similar) near line ${closest.index + 1}:\n${preview}`, ); } throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}`); @@ -941,11 +1066,23 @@ function computeReplacements( const found = searchResult.index; + if (searchResult.strategy === "fuzzy-dominant") { + const similarity = Math.round(searchResult.confidence * 100); + warnings.push(`Dominant fuzzy match selected in ${path} near line ${found + 1} (${similarity}% similar).`); + } + // Reject if match is ambiguous (prefix/substring matching found multiple matches) if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) { + const previews = formatSequenceMatchPreviews( + originalLines, + searchResult.matchIndices, + searchResult.matchCount, + ); + const strategyHint = searchResult.strategy ? ` Matching strategy: ${searchResult.strategy}.` : ""; + const previewText = previews ? `\n\n${previews}` : ""; throw new ApplyPatchError( - `Found ${searchResult.matchCount} matches for the text in ${path}. ` + - `Add more surrounding context or additional @@ anchors to make it unique.`, + `Found ${searchResult.matchCount} matches for the text in ${path}.${strategyHint}` + + `${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`, ); } @@ -990,7 +1127,27 @@ function computeReplacements( // Sort by start index replacements.sort((a, b) => a.startIndex - b.startIndex); - return replacements; + for (let i = 1; i < replacements.length; i++) { + const prev = replacements[i - 1]; + const next = replacements[i]; + const prevEnd = prev.startIndex + prev.oldLen; + if (next.startIndex < prevEnd) { + const formatRange = (replacement: Replacement): string => { + if (replacement.oldLen === 0) { + return `${replacement.startIndex + 1} (insertion)`; + } + return `${replacement.startIndex + 1}-${replacement.startIndex + replacement.oldLen}`; + }; + const prevRange = formatRange(prev); + const nextRange = formatRange(next); + throw new ApplyPatchError( + `Overlapping hunks detected in ${path} at lines ${prevRange} and ${nextRange}. ` + + `Split hunks or add more context to avoid overlap.`, + ); + } + } + + return { replacements, warnings }; } /** @@ -1018,7 +1175,7 @@ function applyHunksToContent( hunks: DiffHunk[], fuzzyThreshold: number, allowFuzzy: boolean, -): string { +): { content: string; warnings: string[] } { const hadFinalNewline = originalContent.endsWith("\n"); // Detect simple replace pattern: single hunk, no @@ context, no context lines, has old lines to match @@ -1032,8 +1189,8 @@ function applyHunksToContent( hunk.oldStartLine === undefined && // No line hint to use for positioning !hunk.isEndOfFile // No EOF targeting (prefer end of file) ) { - const content = applyCharacterMatch(originalContent, path, hunk, fuzzyThreshold, allowFuzzy); - return applyTrailingNewlinePolicy(content, hadFinalNewline); + const { content, warnings } = applyCharacterMatch(originalContent, path, hunk, fuzzyThreshold, allowFuzzy); + return { content: applyTrailingNewlinePolicy(content, hadFinalNewline), warnings }; } } @@ -1048,7 +1205,7 @@ function applyHunksToContent( strippedTrailingEmpty = true; } - const replacements = computeReplacements(originalLines, path, hunks, allowFuzzy); + const { replacements, warnings } = computeReplacements(originalLines, path, hunks, allowFuzzy); const newLines = applyReplacements(originalLines, replacements); // Restore the trailing empty element if we stripped it @@ -1060,12 +1217,12 @@ function applyHunksToContent( // Preserve original trailing newline behavior if (hadFinalNewline && !content.endsWith("\n")) { - return `${content}\n`; + return { content: `${content}\n`, warnings }; } if (!hadFinalNewline && content.endsWith("\n")) { - return content.slice(0, -1); + return { content: content.slice(0, -1), warnings }; } - return content; + return { content, warnings }; } // ═══════════════════════════════════════════════════════════════════════════ @@ -1178,7 +1335,13 @@ async function applyNormalizedPatch( throw new ApplyPatchError("Diff contains no hunks"); } - const newContent = applyHunksToContent(normalizedContent, input.path, hunks, fuzzyThreshold, allowFuzzy); + const { content: newContent, warnings } = applyHunksToContent( + normalizedContent, + input.path, + hunks, + fuzzyThreshold, + allowFuzzy, + ); const finalContent = bom + restoreLineEndings(newContent, lineEnding); const destPath = input.rename ? resolvePath(input.rename) : absolutePath; const isMove = Boolean(input.rename) && destPath !== absolutePath; @@ -1204,6 +1367,7 @@ async function applyNormalizedPatch( oldContent: originalContent, newContent: finalContent, }, + warnings: warnings.length > 0 ? warnings : undefined, }; } diff --git a/packages/coding-agent/src/patch/fuzzy.ts b/packages/coding-agent/src/patch/fuzzy.ts index 8687fd2bc..b435db532 100644 --- a/packages/coding-agent/src/patch/fuzzy.ts +++ b/packages/coding-agent/src/patch/fuzzy.ts @@ -5,7 +5,7 @@ * fallback strategies for finding text in files. */ import { countLeadingWhitespace, normalizeForFuzzy, normalizeUnicode } from "./normalize"; -import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceSearchResult } from "./types"; +import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceMatchStrategy, SequenceSearchResult } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // Constants @@ -135,6 +135,7 @@ function computeLineOffsets(lines: string[]): number[] { interface BestFuzzyMatchResult { best?: FuzzyMatch; aboveThresholdCount: number; + secondBestScore: number; } function findBestFuzzyMatchCore( @@ -148,6 +149,7 @@ function findBestFuzzyMatchCore( let best: FuzzyMatch | undefined; let bestScore = -1; + let secondBestScore = -1; let aboveThresholdCount = 0; for (let start = 0; start <= contentLines.length - targetLines.length; start++) { @@ -164,6 +166,7 @@ function findBestFuzzyMatchCore( } if (score > bestScore) { + secondBestScore = bestScore; bestScore = score; best = { actualText: windowLines.join("\n"), @@ -171,10 +174,12 @@ function findBestFuzzyMatchCore( startLine: start + 1, confidence: score, }; + } else if (score > secondBestScore) { + secondBestScore = score; } } - return { best, aboveThresholdCount }; + return { best, aboveThresholdCount, secondBestScore }; } function findBestFuzzyMatch(content: string, target: string, threshold: number): BestFuzzyMatchResult { @@ -182,10 +187,10 @@ function findBestFuzzyMatch(content: string, target: string, threshold: number): const targetLines = target.split("\n"); if (targetLines.length === 0 || target.length === 0) { - return { aboveThresholdCount: 0 }; + return { aboveThresholdCount: 0, secondBestScore: 0 }; } if (targetLines.length > contentLines.length) { - return { aboveThresholdCount: 0 }; + return { aboveThresholdCount: 0, secondBestScore: 0 }; } const offsets = computeLineOffsets(contentLines); @@ -257,14 +262,25 @@ export function findMatch( // Try fuzzy match const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD; - const { best, aboveThresholdCount } = findBestFuzzyMatch(content, target, threshold); + const { best, aboveThresholdCount, secondBestScore } = findBestFuzzyMatch(content, target, threshold); if (!best) { return {}; } - if (options.allowFuzzy && best.confidence >= threshold && aboveThresholdCount === 1) { - return { match: best, closest: best }; + if (options.allowFuzzy && best.confidence >= threshold) { + if (aboveThresholdCount === 1) { + return { match: best, closest: best }; + } + const dominantDelta = 0.08; + const dominantMin = 0.97; + if ( + aboveThresholdCount > 1 && + best.confidence >= dominantMin && + best.confidence - secondBestScore >= dominantDelta + ) { + return { match: best, closest: best, fuzzyMatches: aboveThresholdCount, dominantFuzzy: true }; + } } return { closest: best, fuzzyMatches: aboveThresholdCount }; @@ -360,7 +376,7 @@ export function seekSequence( const allowFuzzy = options?.allowFuzzy ?? true; // Empty pattern matches immediately if (pattern.length === 0) { - return { index: start, confidence: 1.0 }; + return { index: start, confidence: 1.0, strategy: "exact" }; } // Pattern longer than available content cannot match @@ -376,35 +392,35 @@ export function seekSequence( // Pass 1: Exact match for (let i = from; i <= to; i++) { if (matchesAt(lines, pattern, i, (a, b) => a === b)) { - return { index: i, confidence: 1.0 }; + return { index: i, confidence: 1.0, strategy: "exact" }; } } // Pass 2: Trailing whitespace stripped for (let i = from; i <= to; i++) { if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) { - return { index: i, confidence: 0.99 }; + return { index: i, confidence: 0.99, strategy: "trim-trailing" }; } } // Pass 3: Both leading and trailing whitespace stripped for (let i = from; i <= to; i++) { if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) { - return { index: i, confidence: 0.98 }; + return { index: i, confidence: 0.98, strategy: "trim" }; } } // Pass 3b: Comment-prefix normalized match for (let i = from; i <= to; i++) { if (matchesAt(lines, pattern, i, (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b))) { - return { index: i, confidence: 0.975 }; + return { index: i, confidence: 0.975, strategy: "comment-prefix" }; } } // Pass 4: Normalize unicode punctuation for (let i = from; i <= to; i++) { if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) { - return { index: i, confidence: 0.97 }; + return { index: i, confidence: 0.97, strategy: "unicode" }; } } @@ -416,14 +432,16 @@ export function seekSequence( { let firstMatch: number | undefined; let matchCount = 0; + const matchIndices: number[] = []; for (let i = from; i <= to; i++) { if (matchesAt(lines, pattern, i, lineStartsWithPattern)) { if (firstMatch === undefined) firstMatch = i; matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } } if (matchCount > 0) { - return { index: firstMatch, confidence: 0.965, matchCount }; + return { index: firstMatch, confidence: 0.965, matchCount, matchIndices, strategy: "prefix" }; } } @@ -431,14 +449,16 @@ export function seekSequence( { let firstMatch: number | undefined; let matchCount = 0; + const matchIndices: number[] = []; for (let i = from; i <= to; i++) { if (matchesAt(lines, pattern, i, lineIncludesPattern)) { if (firstMatch === undefined) firstMatch = i; matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } } if (matchCount > 0) { - return { index: firstMatch, confidence: 0.94, matchCount }; + return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" }; } } @@ -464,16 +484,22 @@ export function seekSequence( // Pass 7: Fuzzy matching - find best match above threshold let bestIndex: number | undefined; let bestScore = 0; + let secondBestScore = 0; let matchCount = 0; + const matchIndices: number[] = []; for (let i = searchStart; i <= maxStart; i++) { const score = fuzzyScoreAt(lines, pattern, i); if (score >= SEQUENCE_FUZZY_THRESHOLD) { matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } if (score > bestScore) { + secondBestScore = bestScore; bestScore = score; bestIndex = i; + } else if (score > secondBestScore) { + secondBestScore = score; } } @@ -483,16 +509,31 @@ export function seekSequence( const score = fuzzyScoreAt(lines, pattern, i); if (score >= SEQUENCE_FUZZY_THRESHOLD) { matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } if (score > bestScore) { + secondBestScore = bestScore; bestScore = score; bestIndex = i; + } else if (score > secondBestScore) { + secondBestScore = score; } } } if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) { - return { index: bestIndex, confidence: bestScore, matchCount }; + const dominantDelta = 0.08; + const dominantMin = 0.97; + if (matchCount > 1 && bestScore >= dominantMin && bestScore - secondBestScore >= dominantDelta) { + return { + index: bestIndex, + confidence: bestScore, + matchCount: 1, + matchIndices, + strategy: "fuzzy-dominant", + }; + } + return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" }; } // Pass 8: Character-based fuzzy matching via findMatch @@ -510,13 +551,59 @@ export function seekSequence( const matchedContent = contentText.substring(0, matchOutcome.match.startIndex); const lineIndex = start + matchedContent.split("\n").length - 1; const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches ?? 1; - return { index: lineIndex, confidence: matchOutcome.match.confidence, matchCount: fallbackMatchCount }; + return { + index: lineIndex, + confidence: matchOutcome.match.confidence, + matchCount: fallbackMatchCount, + strategy: "character", + }; } const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches; return { index: undefined, confidence: bestScore, matchCount: fallbackMatchCount }; } +export function findClosestSequenceMatch( + lines: string[], + pattern: string[], + options?: { start?: number; eof?: boolean }, +): { index: number | undefined; confidence: number; strategy: SequenceMatchStrategy } { + if (pattern.length === 0) { + return { index: options?.start ?? 0, confidence: 1, strategy: "exact" }; + } + if (pattern.length > lines.length) { + return { index: undefined, confidence: 0, strategy: "fuzzy" }; + } + + const start = options?.start ?? 0; + const eof = options?.eof ?? false; + const maxStart = lines.length - pattern.length; + const searchStart = eof && lines.length >= pattern.length ? maxStart : start; + + let bestIndex: number | undefined; + let bestScore = 0; + + for (let i = searchStart; i <= maxStart; i++) { + const score = fuzzyScoreAt(lines, pattern, i); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + + if (eof && searchStart > start) { + for (let i = start; i < searchStart; i++) { + const score = fuzzyScoreAt(lines, pattern, i); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + } + + return { index: bestIndex, confidence: bestScore, strategy: "fuzzy" }; +} + /** * Find a context line in the file using progressive matching strategies. * @@ -537,14 +624,16 @@ export function findContextLine( { let firstMatch: number | undefined; let matchCount = 0; + const matchIndices: number[] = []; for (let i = startFrom; i < lines.length; i++) { if (lines[i] === context) { if (firstMatch === undefined) firstMatch = i; matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } } if (matchCount > 0) { - return { index: firstMatch, confidence: 1.0, matchCount }; + return { index: firstMatch, confidence: 1.0, matchCount, matchIndices, strategy: "exact" }; } } @@ -552,14 +641,16 @@ export function findContextLine( { let firstMatch: number | undefined; let matchCount = 0; + const matchIndices: number[] = []; for (let i = startFrom; i < lines.length; i++) { if (lines[i].trim() === trimmedContext) { if (firstMatch === undefined) firstMatch = i; matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } } if (matchCount > 0) { - return { index: firstMatch, confidence: 0.99, matchCount }; + return { index: firstMatch, confidence: 0.99, matchCount, matchIndices, strategy: "trim" }; } } @@ -568,14 +659,16 @@ export function findContextLine( { let firstMatch: number | undefined; let matchCount = 0; + const matchIndices: number[] = []; for (let i = startFrom; i < lines.length; i++) { if (normalizeUnicode(lines[i]) === normalizedContext) { if (firstMatch === undefined) firstMatch = i; matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } } if (matchCount > 0) { - return { index: firstMatch, confidence: 0.98, matchCount }; + return { index: firstMatch, confidence: 0.98, matchCount, matchIndices, strategy: "unicode" }; } } @@ -588,15 +681,17 @@ export function findContextLine( if (contextNorm.length > 0) { let firstMatch: number | undefined; let matchCount = 0; + const matchIndices: number[] = []; for (let i = startFrom; i < lines.length; i++) { const lineNorm = normalizeForFuzzy(lines[i]); if (lineNorm.startsWith(contextNorm)) { if (firstMatch === undefined) firstMatch = i; matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } } if (matchCount > 0) { - return { index: firstMatch, confidence: 0.96, matchCount }; + return { index: firstMatch, confidence: 0.96, matchCount, matchIndices, strategy: "prefix" }; } } @@ -613,10 +708,17 @@ export function findContextLine( allSubstringMatches.push({ index: i, ratio }); } } + const matchIndices = allSubstringMatches.slice(0, 5).map(match => match.index); // If exactly one substring match, accept it regardless of ratio if (allSubstringMatches.length === 1) { - return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: 1 }; + return { + index: allSubstringMatches[0].index, + confidence: 0.94, + matchCount: 1, + matchIndices, + strategy: "substring", + }; } // Multiple matches: filter by ratio to disambiguate @@ -629,13 +731,19 @@ export function findContextLine( } } if (matchCount > 0) { - return { index: firstMatch, confidence: 0.94, matchCount }; + return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" }; } // If we had substring matches but none passed ratio filter, // return ambiguous result so caller knows matches exist if (allSubstringMatches.length > 1) { - return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: allSubstringMatches.length }; + return { + index: allSubstringMatches[0].index, + confidence: 0.94, + matchCount: allSubstringMatches.length, + matchIndices, + strategy: "substring", + }; } } @@ -643,12 +751,14 @@ export function findContextLine( let bestIndex: number | undefined; let bestScore = 0; let matchCount = 0; + const matchIndices: number[] = []; for (let i = startFrom; i < lines.length; i++) { const lineNorm = normalizeForFuzzy(lines[i]); const score = similarity(lineNorm, contextNorm); if (score >= CONTEXT_FUZZY_THRESHOLD) { matchCount++; + if (matchIndices.length < 5) matchIndices.push(i); } if (score > bestScore) { bestScore = score; @@ -657,7 +767,7 @@ export function findContextLine( } if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) { - return { index: bestIndex, confidence: bestScore, matchCount }; + return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" }; } if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) { diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 8f145cd0b..82744b24d 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -167,6 +167,27 @@ class LspFileSystem implements FileSystem { } } +function mergeDiagnosticsWithWarnings( + diagnostics: FileDiagnosticsResult | undefined, + warnings: string[], +): FileDiagnosticsResult | undefined { + if (warnings.length === 0) return diagnostics; + const warningMessages = warnings.map(warning => `patch: ${warning}`); + if (!diagnostics) { + return { + server: "patch", + messages: warningMessages, + summary: `Patch warnings: ${warnings.length}`, + errored: false, + }; + } + return { + ...diagnostics, + messages: [...warningMessages, ...diagnostics.messages], + summary: `${diagnostics.summary}; Patch warnings: ${warnings.length}`, + }; +} + // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ @@ -319,9 +340,11 @@ export class EditTool implements AgentTool { const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal); diagnostics ??= flushedDiagnostics; } + const patchWarnings = result.warnings ?? []; + const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings); const meta = outputMeta() - .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) + .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) .get(); return { @@ -329,7 +352,7 @@ export class EditTool implements AgentTool { details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, - diagnostics, + diagnostics: mergedDiagnostics, op, rename: effRename, meta, diff --git a/packages/coding-agent/src/patch/parser.ts b/packages/coding-agent/src/patch/parser.ts index ff09c5a5f..602bf4830 100644 --- a/packages/coding-agent/src/patch/parser.ts +++ b/packages/coding-agent/src/patch/parser.ts @@ -328,6 +328,11 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: for (let i = startIndex; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); + const nextLine = lines[i + 1]; + + if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) { + break; + } if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) { if (parsedLines === 0) { diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts index e562aa7e2..cfba97b80 100644 --- a/packages/coding-agent/src/patch/types.ts +++ b/packages/coding-agent/src/patch/types.ts @@ -46,9 +46,23 @@ export interface MatchOutcome { occurrencePreviews?: string[]; /** Number of fuzzy matches above threshold */ fuzzyMatches?: number; + /** True when a dominant fuzzy match was accepted despite multiple candidates */ + dominantFuzzy?: boolean; } /** Result of a sequence search */ +export type SequenceMatchStrategy = + | "exact" + | "trim-trailing" + | "trim" + | "comment-prefix" + | "unicode" + | "prefix" + | "substring" + | "fuzzy" + | "fuzzy-dominant" + | "character"; + export interface SequenceSearchResult { /** Starting line index of the match (0-indexed) */ index: number | undefined; @@ -56,9 +70,15 @@ export interface SequenceSearchResult { confidence: number; /** Number of matches at the same confidence level (for ambiguity detection) */ matchCount?: number; + /** Sample of matching indices (0-indexed, up to a small limit) */ + matchIndices?: number[]; + /** Matching strategy used */ + strategy?: SequenceMatchStrategy; } /** Result of a context line search */ +export type ContextMatchStrategy = "exact" | "trim" | "unicode" | "prefix" | "substring" | "fuzzy"; + export interface ContextLineResult { /** Index of the matching line (0-indexed) */ index: number | undefined; @@ -66,6 +86,10 @@ export interface ContextLineResult { confidence: number; /** Number of matches at the same confidence level (for ambiguity detection) */ matchCount?: number; + /** Sample of matching indices (0-indexed, up to a small limit) */ + matchIndices?: number[]; + /** Matching strategy used */ + strategy?: ContextMatchStrategy; } // ═══════════════════════════════════════════════════════════════════════════ @@ -133,6 +157,7 @@ export interface FileChange { /** Result of applying a patch */ export interface ApplyPatchResult { change: FileChange; + warnings?: string[]; } /** Options for applying a patch */ diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index cf0c9ab5d..be25735cf 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -166,6 +166,8 @@ export interface CreateAgentSessionOptions { /** Enable LSP integration (tool, formatting, diagnostics, warmup). Default: true */ enableLsp?: boolean; + /** Skip Python kernel availability check and prelude warmup */ + skipPythonPreflight?: boolean; /** Tool names explicitly requested (enables disabled-by-default tools) */ toolNames?: string[]; @@ -734,6 +736,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} cwd, hasUI: options.hasUI ?? false, enableLsp, + skipPythonPreflight: options.skipPythonPreflight, + contextFiles, + skills, eventBus, outputSchema: options.outputSchema, requireCompleteTool: options.requireCompleteTool, @@ -989,6 +994,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const promptTemplates = options.promptTemplates ?? (await discoverPromptTemplates(cwd, agentDir)); time("discoverPromptTemplates"); + toolSession.promptTemplates = promptTemplates; const slashCommands = options.slashCommands ?? (await discoverSlashCommands(cwd)); time("discoverSlashCommands"); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 6da86e4f9..91ea557e1 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -205,6 +205,7 @@ const noOpUIContext: ExtensionUIContext = { input: async (_title, _placeholder, _dialogOptions) => undefined, notify: () => {}, setStatus: () => {}, + setWorkingMessage: () => {}, setWidget: () => {}, setTitle: () => {}, custom: async () => undefined as never, diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 0b23078d7..d92d0c5e5 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1027,6 +1027,9 @@ export class SessionManager { const explicitPath = this.sessionFile; this._newSessionSync(); this.sessionFile = explicitPath; // preserve explicit path from --session flag + await this._rewriteFile(); + this.flushed = true; + return; } } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 0be740813..f4ec793d6 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -5,10 +5,13 @@ */ import path from "node:path"; import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { ToolSession } from ".."; +import type { PromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates"; +import type { Skill } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; +import { getPreludeDocs } from "@oh-my-pi/pi-coding-agent/ipy/executor"; +import { checkPythonKernelAvailability } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; +import type { ContextFileEntry, ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelString, parseModelPattern } from "../config/model-resolver"; -import { checkPythonKernelAvailability } from "../ipy/kernel"; import { LspTool } from "../lsp"; import type { LspParams } from "../lsp/types"; import { callTool } from "../mcp/client"; @@ -57,6 +60,9 @@ export interface ExecutorOptions { persistArtifacts?: boolean; artifactsDir?: string; eventBus?: EventBus; + contextFiles?: ContextFileEntry[]; + skills?: Skill[]; + promptTemplates?: PromptTemplate[]; mcpManager?: MCPManager; authStorage?: AuthStorage; modelRegistry?: ModelRegistry; @@ -307,6 +313,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise 0 ? pythonPreludeDocs : undefined; let worker: Worker; try { @@ -692,6 +700,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise renderTemplate(context, t)); + const contextFiles = this.session.contextFiles; + const skills = this.session.skills; + const promptTemplates = this.session.promptTemplates; // Initialize progress for all tasks for (let i = 0; i < tasksWithContext.length; i++) { @@ -427,6 +430,9 @@ export class TaskTool implements AgentTool 0) { + setPreludeDocsCache(payload.pythonPreludeDocs); + } // Helper to check abort status - throws if aborted to exit early const checkAbort = (): void => { @@ -589,6 +593,9 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload): ? `You will work under this working tree: ${payload.worktree}. CRITICAL: Do not touch the original repository; only make changes inside this worktree.` : ""; + const skipPythonPreflight = + payload.pythonToolProxy === true || + (Array.isArray(payload.toolNames) && !payload.toolNames.includes("python")); const { session } = await createAgentSession({ cwd: payload.worktree ?? payload.cwd, authStorage, @@ -599,6 +606,9 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload): toolNames: payload.toolNames, outputSchema: payload.outputSchema, requireCompleteTool: true, + contextFiles: payload.contextFiles, + skills: payload.skills, + promptTemplates: payload.promptTemplates, // Append system prompt (equivalent to CLI's --append-system-prompt) systemPrompt: defaultPrompt => `${defaultPrompt}\n\n${payload.systemPrompt}\n\n${worktreeNotice}\n\n${completionInstruction}`, @@ -607,6 +617,7 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload): // Pass spawn restrictions to nested tasks spawns: payload.spawnsEnv, enableLsp: enableLsp && !lspProxyEnabled, + skipPythonPreflight, // Disable local MCP discovery if using proxy tools enableMCP: !payload.mcpTools, // Add proxy tools diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 55d77a991..2d46d0065 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -1,4 +1,6 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import type { PromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates"; +import type { Skill } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; import { logger } from "@oh-my-pi/pi-utils"; import type { BashInterceptorRule } from "../config/settings-manager"; import type { InternalUrlRouter } from "../internal-urls"; @@ -94,12 +96,26 @@ export { WriteTool, type WriteToolDetails } from "./write"; /** Tool type (AgentTool from pi-ai) */ export type Tool = AgentTool; +export type ContextFileEntry = { + path: string; + content: string; + depth?: number; +}; + /** Session context for tool factories */ export interface ToolSession { /** Current working directory */ cwd: string; /** Whether UI is available */ hasUI: boolean; + /** Skip Python kernel availability check and warmup */ + skipPythonPreflight?: boolean; + /** Pre-loaded context files (AGENTS.md, etc) */ + contextFiles?: ContextFileEntry[]; + /** Pre-loaded skills */ + skills?: Skill[]; + /** Pre-loaded prompt templates */ + promptTemplates?: PromptTemplate[]; /** Whether LSP integrations are enabled */ enableLsp?: boolean; /** Event bus for tool/extension communication */ @@ -219,10 +235,12 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P const enableLsp = session.enableLsp ?? true; const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined; const pythonMode = getPythonModeFromEnv() ?? session.settings?.getPythonToolMode?.() ?? "ipy-only"; + const skipPythonPreflight = session.skipPythonPreflight === true; let pythonAvailable = true; const shouldCheckPython = + !skipPythonPreflight && pythonMode !== "bash-only" && - (requestedTools === undefined || requestedTools.includes("python") || pythonMode === "ipy-only"); + (requestedTools === undefined || requestedTools.includes("python")); const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test"; const skipPythonWarm = isTestEnv || process.env.OMP_PYTHON_SKIP_CHECK === "1"; if (shouldCheckPython) { diff --git a/packages/coding-agent/test/core/apply-patch-regression.test.ts b/packages/coding-agent/test/core/apply-patch-regression.test.ts index b8cc6cdec..42fbf5dfc 100644 --- a/packages/coding-agent/test/core/apply-patch-regression.test.ts +++ b/packages/coding-agent/test/core/apply-patch-regression.test.ts @@ -1549,3 +1549,250 @@ describe("regression: bench edit failures (2026-01-19)", () => { expect(await Bun.file(filePath).text()).toContain("const value = 100;"); }); }); + +describe("regression: trailing context lines don't delete file content", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), `trailing-context-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("context lines cannot cause collateral deletion via fuzzy match", async () => { + const filePath = path.join(tempDir, "file.ts"); + // File has extra content between what the diff shows as context + await Bun.write( + filePath, + `function outer() { + function inner() { + // This is an important comment + return 1; + } +} +`, + ); + + // Diff shows context that skips the comment - should this fail or work? + // The expected behavior is: match the context lines, only delete - lines + // Since there are no - lines between inner() and return 1, nothing should be deleted + await applyPatch( + { + path: "file.ts", + op: "update", + diff: `@@ function outer + function outer() { + function inner() { +- return 1; ++ return 42; + } + }`, + }, + { cwd: tempDir }, + ); + + const result = await Bun.file(filePath).text(); + expect(result).toContain("return 42;"); + // The comment should still be there! + expect(result).toContain("// This is an important comment"); + }); + + test("unprefixed blank line between changes and trailing context", async () => { + const filePath = path.join(tempDir, "terminal.ts"); + await Bun.write( + filePath, + `export class Example { + private field = false; + + get value(): boolean { + return this.field; + } +} +`, + ); + + // Blank line has NO prefix (model might emit this) + // The implementation treats unprefixed blank lines as context + await applyPatch( + { + path: "terminal.ts", + op: "update", + diff: `@@ export class Example + export class Example { + private field = false; ++ private other = true; + + get value(): boolean {`, + }, + { cwd: tempDir }, + ); + + const result = await Bun.file(filePath).text(); + expect(result).toContain("private other = true;"); + expect(result).toContain("return this.field;"); + }); + + test("two-hunk diff with trailing getter context preserves getter body", async () => { + const filePath = path.join(tempDir, "terminal.ts"); + await Bun.write( + filePath, + `export class ProcessTerminal implements Terminal { + private wasRaw = false; + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private _kittyProtocolActive = false; + private stdinBuffer?: StdinBuffer; + private stdinDataHandler?: (data: string) => void; + + get kittyProtocolActive(): boolean { + return this._kittyProtocolActive; + } + + private safeWrite(data: string): void { + try { + process.stdout.write(data); + } catch (err) { + // EIO means terminal is dead - exit gracefully instead of crashing + if (err && typeof err === "object" && (err as { code?: string }).code === "EIO") { + process.exit(1); + } + throw err; + } + } +} +`, + ); + + await applyPatch( + { + path: "terminal.ts", + op: "update", + diff: `@@ export class ProcessTerminal implements Terminal { + export class ProcessTerminal implements Terminal { + \tprivate wasRaw = false; + \tprivate inputHandler?: (data: string) => void; + \tprivate resizeHandler?: () => void; + \tprivate _kittyProtocolActive = false; + \tprivate stdinBuffer?: StdinBuffer; + \tprivate stdinDataHandler?: (data: string) => void; ++\tprivate dead = false; + + \tget kittyProtocolActive(): boolean { + +@@ private safeWrite(data: string): void { + \tprivate safeWrite(data: string): void { ++\t\tif (this.dead) return; + \t\ttry { + \t\t\tprocess.stdout.write(data); + \t\t} catch (err) { +-\t\t\t// EIO means terminal is dead - exit gracefully instead of crashing ++\t\t\t// EIO means terminal is dead - mark dead and skip all future writes + \t\t\tif (err && typeof err === "object" && (err as { code?: string }).code === "EIO") { +-\t\t\t\tprocess.exit(1); ++\t\t\t\tthis.dead = true; ++\t\t\t\treturn; + \t\t\t} + \t\t\tthrow err; + \t\t} + \t}`, + }, + { cwd: tempDir }, + ); + + const result = await Bun.file(filePath).text(); + expect(result).toContain("private dead = false;"); + expect(result).toContain("return this._kittyProtocolActive;"); + expect(result).toContain("if (this.dead) return;"); + expect(result).toContain("mark dead and skip all future writes"); + expect(result).toContain("this.dead = true;"); + }); + + test("context anchor duplicated as first context line preserves file content", async () => { + const filePath = path.join(tempDir, "terminal.ts"); + // Original file - exact structure from user's report + await Bun.write( + filePath, + `export class ProcessTerminal implements Terminal { + private wasRaw = false; + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private _kittyProtocolActive = false; + private stdinBuffer?: StdinBuffer; + private stdinDataHandler?: (data: string) => void; + + get kittyProtocolActive(): boolean { + return this._kittyProtocolActive; + } +} +`, + ); + + // The anchor line and first context line are IDENTICAL + // This is the exact pattern from the user's failing case + await applyPatch( + { + path: "terminal.ts", + op: "update", + diff: `@@ export class ProcessTerminal implements Terminal { + export class ProcessTerminal implements Terminal { + private wasRaw = false; + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private _kittyProtocolActive = false; + private stdinBuffer?: StdinBuffer; + private stdinDataHandler?: (data: string) => void; ++ private dead = false; + + get kittyProtocolActive(): boolean {`, + }, + { cwd: tempDir }, + ); + + const result = await Bun.file(filePath).text(); + expect(result).toContain("private dead = false;"); + expect(result).toContain("return this._kittyProtocolActive;"); + }); + + test("adding field with getter as trailing context preserves getter body", async () => { + const filePath = path.join(tempDir, "terminal.ts"); + // Original file has a getter with a body + await Bun.write( + filePath, + `export class ProcessTerminal { + private _kittyProtocolActive = false; + private stdinDataHandler?: (data: string) => void; + + get kittyProtocolActive(): boolean { + return this._kittyProtocolActive; + } +} +`, + ); + + // Hunk adds a new field, with getter declaration as trailing context + // The getter body should NOT be affected + await applyPatch( + { + path: "terminal.ts", + op: "update", + diff: `@@ export class ProcessTerminal + export class ProcessTerminal { + private _kittyProtocolActive = false; + private stdinDataHandler?: (data: string) => void; ++ private dead = false; + + get kittyProtocolActive(): boolean {`, + }, + { cwd: tempDir }, + ); + + const result = await Bun.file(filePath).text(); + // The new field should be added + expect(result).toContain("private dead = false;"); + // The getter body should still be there! + expect(result).toContain("return this._kittyProtocolActive;"); + }); +}); diff --git a/packages/pi-utils/src/postmortem.ts b/packages/pi-utils/src/postmortem.ts index a91a8a574..bda885683 100644 --- a/packages/pi-utils/src/postmortem.ts +++ b/packages/pi-utils/src/postmortem.ts @@ -67,6 +67,15 @@ function runCleanup(reason: Reason): Promise { // Worker thread: exit only (workers use self.addEventListener for exceptions) let inspectorOpened = false; +function formatFatalError(label: string, err: Error): string { + const name = err.name || "Error"; + const message = err.message || "(no message)"; + const stack = err.stack || ""; + const stackLines = stack.split("\n").slice(1); + const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : ""; + return `\n[${label}] ${name}: ${message}${formattedStack}\n`; +} + if (isMainThread) { process .on("SIGINT", async () => { @@ -81,12 +90,14 @@ if (isMainThread) { process.stderr.write(`Inspector opened: ${url}\n`); }) .on("uncaughtException", async err => { + process.stderr.write(formatFatalError("Uncaught Exception", err)); logger.error("Uncaught exception", { err, stack: err.stack }); await runCleanup(Reason.UNCAUGHT_EXCEPTION); process.exit(1); }) .on("unhandledRejection", async reason => { const err = reason instanceof Error ? reason : new Error(String(reason)); + process.stderr.write(formatFatalError("Unhandled Rejection", err)); logger.error("Unhandled rejection", { err, stack: err.stack }); await runCleanup(Reason.UNHANDLED_REJECTION); process.exit(1); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d76c3462e..c0876dbb7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,8 +2,12 @@ ## [Unreleased] +### Changed +- Added Ctrl+Backspace as a delete-word-backward keybinding and improved modified backspace matching + ### Fixed - Terminal gracefully handles write failures by marking dead instead of exiting the process +- Reserved cursor space for zero padding and corrected end-of-line cursor rendering to prevent wrap glitches ## [8.2.0] - 2026-01-24 ### Added diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 0a7bf9478..d713ce31c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -273,8 +273,9 @@ export class Editor implements Component, Focusable { private theme: EditorTheme; private useTerminalCursor = false; - // Store last render width for cursor navigation - private lastWidth: number = 80; + // Store last layout width for cursor navigation + private lastLayoutWidth: number = 80; + private paddingXOverride: number | undefined; private maxHeight?: number; private scrollOffset: number = 0; @@ -352,6 +353,10 @@ export class Editor implements Component, Focusable { this.scrollOffset = 0; } + setPaddingX(paddingX: number): void { + this.paddingXOverride = Math.max(0, paddingX); + } + setHistoryStorage(storage: HistoryStorage): void { this.historyStorage = storage; const recent = storage.getRecent(100); @@ -382,15 +387,13 @@ export class Editor implements Component, Focusable { } private isOnFirstVisualLine(): boolean { - const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX()); - const visualLines = this.buildVisualLineMap(contentWidth); + const visualLines = this.buildVisualLineMap(this.lastLayoutWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); return currentVisualLine === 0; } private isOnLastVisualLine(): boolean { - const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX()); - const visualLines = this.buildVisualLineMap(contentWidth); + const visualLines = this.buildVisualLineMap(this.lastLayoutWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); return currentVisualLine === visualLines.length - 1; } @@ -430,26 +433,31 @@ export class Editor implements Component, Focusable { } private getEditorPaddingX(): number { - const padding = this.theme.editorPaddingX ?? 2; - return Math.max(1, padding); + const padding = this.paddingXOverride ?? this.theme.editorPaddingX ?? 2; + return Math.max(0, padding); } private getContentWidth(width: number, paddingX: number): number { return Math.max(0, width - 2 * (paddingX + 1)); } + private getLayoutWidth(width: number, paddingX: number): number { + const contentWidth = this.getContentWidth(width, paddingX); + return Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0)); + } + private getVisibleContentHeight(contentLines: number): number { if (this.maxHeight === undefined) return contentLines; return Math.max(1, this.maxHeight - 2); } - private updateScrollOffset(contentWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void { + private updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void { if (layoutLines.length <= visibleHeight) { this.scrollOffset = 0; return; } - const visualLines = this.buildVisualLineMap(contentWidth); + const visualLines = this.buildVisualLineMap(layoutWidth); const cursorLine = this.findCurrentVisualLine(visualLines); if (cursorLine < this.scrollOffset) { this.scrollOffset = cursorLine; @@ -462,26 +470,23 @@ export class Editor implements Component, Focusable { } render(width: number): string[] { - // Store width for cursor navigation - this.lastWidth = width; + const paddingX = this.getEditorPaddingX(); + const contentAreaWidth = this.getContentWidth(width, paddingX); + const layoutWidth = this.getLayoutWidth(width, paddingX); + this.lastLayoutWidth = layoutWidth; // Box-drawing characters for rounded corners const box = this.theme.symbols.boxRound; - const paddingX = this.getEditorPaddingX(); const borderWidth = paddingX + 1; const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`); const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`); const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${" ".repeat(Math.max(0, paddingX - 1))}`); - const bottomRight = this.borderColor( - `${" ".repeat(Math.max(0, paddingX - 1))}${box.horizontal}${box.bottomRight}`, - ); const horizontal = this.borderColor(box.horizontal); // Layout the text - const contentAreaWidth = this.getContentWidth(width, paddingX); - const layoutLines = this.layoutText(contentAreaWidth); + const layoutLines = this.layoutText(layoutWidth); const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length); - this.updateScrollOffset(contentAreaWidth, layoutLines, visibleContentHeight); + this.updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight); const visibleLayoutLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + visibleContentHeight); const result: string[] = []; @@ -513,15 +518,22 @@ export class Editor implements Component, Focusable { for (const layoutLine of visibleLayoutLines) { let displayText = layoutLine.text; let displayWidth = visibleWidth(layoutLine.text); + let cursorInPadding = false; // Add cursor if this line has it - if (!this.useTerminalCursor && layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { + const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined; + const marker = emitCursorMarker ? CURSOR_MARKER : ""; + + if (hasCursor && this.useTerminalCursor) { + if (marker) { + const before = displayText.slice(0, layoutLine.cursorPos); + const after = displayText.slice(layoutLine.cursorPos); + displayText = before + marker + after; + } + } else if (hasCursor && !this.useTerminalCursor) { const before = displayText.slice(0, layoutLine.cursorPos); const after = displayText.slice(layoutLine.cursorPos); - // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) - const marker = emitCursorMarker ? CURSOR_MARKER : ""; - if (after.length > 0) { // Cursor is on a character (grapheme) - replace it with highlighted version // Get the first grapheme from 'after' @@ -532,26 +544,13 @@ export class Editor implements Component, Focusable { displayText = before + marker + cursor + restAfter; // displayWidth stays the same - we're replacing, not adding } else { - // Cursor is at the end - add thin blinking bar cursor + // Cursor is at the end - add thin cursor glyph const cursorChar = this.theme.symbols.inputCursor; const cursor = `\x1b[5m${cursorChar}\x1b[0m`; displayText = before + marker + cursor; displayWidth += visibleWidth(cursorChar); - if (displayWidth > lineContentWidth) { - // Line is at full width - use reverse video on last grapheme if possible - // or just show cursor at the end without adding space - const beforeGraphemes = [...segmenter.segment(before)]; - if (beforeGraphemes.length > 0) { - const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || ""; - const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`; - // Rebuild 'before' without the last grapheme - const beforeWithoutLast = beforeGraphemes - .slice(0, -1) - .map(g => g.segment) - .join(""); - displayText = beforeWithoutLast + marker + cursor; - displayWidth -= 1; // Back to original width (reverse video replaces, doesn't add) - } + if (displayWidth > lineContentWidth && paddingX > 0) { + cursorInPadding = true; } } } @@ -560,11 +559,16 @@ export class Editor implements Component, Focusable { const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1]; const padding = " ".repeat(Math.max(0, lineContentWidth - displayWidth)); + const rightPaddingWidth = Math.max(0, paddingX - (cursorInPadding ? 1 : 0)); if (isLastLine) { - result.push(`${bottomLeft}${displayText}${padding}${bottomRight}`); + const bottomRightPadding = Math.max(0, paddingX - 1 - (cursorInPadding ? 1 : 0)); + const bottomRightAdjusted = this.borderColor( + `${" ".repeat(bottomRightPadding)}${box.horizontal}${box.bottomRight}`, + ); + result.push(`${bottomLeft}${displayText}${padding}${bottomRightAdjusted}`); } else { const leftBorder = this.borderColor(`${box.vertical}${" ".repeat(paddingX)}`); - const rightBorder = this.borderColor(`${" ".repeat(paddingX)}${box.vertical}`); + const rightBorder = this.borderColor(`${" ".repeat(rightPaddingWidth)}${box.vertical}`); result.push(leftBorder + displayText + padding + rightBorder); } } @@ -583,12 +587,12 @@ export class Editor implements Component, Focusable { const paddingX = this.getEditorPaddingX(); const borderWidth = paddingX + 1; - const contentWidth = this.getContentWidth(width, paddingX); - if (contentWidth <= 0) return null; + const layoutWidth = this.getLayoutWidth(width, paddingX); + if (layoutWidth <= 0) return null; - const layoutLines = this.layoutText(contentWidth); + const layoutLines = this.layoutText(layoutWidth); const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length); - this.updateScrollOffset(contentWidth, layoutLines, visibleContentHeight); + this.updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight); for (let i = 0; i < layoutLines.length; i++) { if (i < this.scrollOffset || i >= this.scrollOffset + visibleContentHeight) continue; @@ -598,7 +602,7 @@ export class Editor implements Component, Focusable { const lineWidth = visibleWidth(layoutLine.text); const isCursorAtLineEnd = layoutLine.cursorPos === layoutLine.text.length; - if (isCursorAtLineEnd && lineWidth >= contentWidth && layoutLine.text.length > 0) { + if (isCursorAtLineEnd && lineWidth >= layoutWidth && layoutLine.text.length > 0) { const graphemes = [...segmenter.segment(layoutLine.text)]; const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; const lastWidth = visibleWidth(lastGrapheme) || 1; @@ -1686,7 +1690,7 @@ export class Editor implements Component, Focusable { private moveCursor(deltaLine: number, deltaCol: number): void { this.resetKillSequence(); - const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX()); + const contentWidth = this.lastLayoutWidth; if (deltaLine !== 0) { // Build visual line map for navigation diff --git a/packages/tui/src/editor-component.ts b/packages/tui/src/editor-component.ts index b59e681d5..87659d738 100644 --- a/packages/tui/src/editor-component.ts +++ b/packages/tui/src/editor-component.ts @@ -62,4 +62,7 @@ export interface EditorComponent extends Component { /** Border color function */ borderColor?: (str: string) => string; + + /** Set horizontal padding */ + setPaddingX?(padding: number): void; } diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index ee801537c..38c7bc888 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -60,7 +60,7 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required = { // Deletion deleteCharBackward: "backspace", deleteCharForward: "delete", - deleteWordBackward: ["ctrl+w", "alt+backspace"], + deleteWordBackward: ["ctrl+w", "alt+backspace", "ctrl+backspace"], deleteWordForward: ["alt+delete", "alt+d"], deleteToLineStart: "ctrl+u", deleteToLineEnd: "ctrl+k", diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index b15a54906..ed095258a 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -796,7 +796,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (modifier === 0) { return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); } - return matchesKittySequence(data, CODEPOINTS.backspace, modifier); + return ( + matchesKittySequence(data, CODEPOINTS.backspace, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier) + ); case "insert": if (modifier === 0) { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 173f5524b..2bebbd343 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -559,14 +559,38 @@ describe("Editor component", () => { // Cursor should be at end (after B) const lines = editor.render(width); - // The cursor (blinking thin bar) should be visible + // The cursor (blinking bar) should be visible const contentLine = lines[1]!; - expect(contentLine.includes("\x1b[5m|")).toBeTruthy(); + expect(contentLine.includes("\x1b[5m")).toBeTruthy(); // Line should still be correct width expect(visibleWidth(contentLine)).toBe(width); }); + it("shows cursor at end before wrap and wraps on next char", () => { + for (const paddingX of [0, 1]) { + const editor = new Editor({ ...defaultEditorTheme, editorPaddingX: paddingX }); + const width = 20; + const contentWidth = width - 2 * (paddingX + 1); + const layoutWidth = Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0)); + const cursorToken = `\x1b[5m${defaultEditorTheme.symbols.inputCursor}\x1b[0m`; + + for (let i = 0; i < layoutWidth; i++) { + editor.handleInput("a"); + } + + let lines = editor.render(width); + let contentLines = lines.length > 2 ? lines.slice(1, -1) : lines.slice(1); + expect(contentLines.length).toBe(1); + expect(contentLines[0]!.endsWith(cursorToken)).toBeTruthy(); + + editor.handleInput("a"); + lines = editor.render(width); + contentLines = lines.length > 2 ? lines.slice(1, -1) : lines.slice(1); + expect(contentLines.length).toBe(2); + } + }); + it("does not exceed terminal width with emoji at wrap boundary", () => { const editor = new Editor(defaultEditorTheme); const width = 11;