From dfcdf1809838a46224a7d642ed3d3854a26b13f3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:07:56 +0200 Subject: [PATCH 01/53] fix(coding-agent): fixed image handling during compaction and follow-up prompt submission - Passed pending and input images into compaction queueing for steering and follow-up flows. - Delivered queued follow-up/steer prompts with stored images via session.prompt and followUp. - Normalized empty image arrays to undefined and cleared stale pending image state. - Added regression tests for image-backed compaction queueing and follow-up forwarding. --- packages/coding-agent/CHANGELOG.md | 7 +- .../src/modes/controllers/input-controller.ts | 30 ++-- .../src/modes/interactive-mode.ts | 4 +- packages/coding-agent/src/modes/types.ts | 3 +- .../src/modes/utils/ui-helpers.ts | 17 ++- .../input-controller-compaction-image.test.ts | 129 ++++++++++++++++++ .../input-controller-followup-image.test.ts | 117 ++++++++++++++++ .../test/input-controller-keybindings.test.ts | 4 +- 8 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 packages/coding-agent/test/input-controller-compaction-image.test.ts create mode 100644 packages/coding-agent/test/input-controller-followup-image.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dfed3a648..5d1af05c6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Fixed + +- Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows +- Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages +- Fixed clipboard-pasted images being rejected when steering or following up during compaction. Instead of bailing with "Retry after it completes to send images", the message and its images are now queued via `queueCompactionMessage` and forwarded to the session (steer/follow-up/prompt) when the compaction queue flushes. ## [15.10.4] - 2026-06-08 @@ -9702,4 +9707,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 78ea6edee..937bd31b0 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -397,11 +397,8 @@ export class InputController { // Queue input during compaction if (this.ctx.session.isCompacting) { - if (this.ctx.pendingImages.length > 0) { - this.ctx.showStatus("Compaction in progress. Retry after it completes to send images."); - return; - } - this.ctx.queueCompactionMessage(text, "steer"); + const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined; + this.ctx.queueCompactionMessage(text, "steer", images); return; } @@ -636,7 +633,8 @@ export class InputController { // the queued entry is later re-parsed into a skill invocation is a // separate concern owned by the compaction-resume path. if (this.ctx.session.isCompacting) { - this.ctx.queueCompactionMessage(text, "followUp"); + const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + this.ctx.queueCompactionMessage(text, "followUp", images); return; } @@ -657,11 +655,20 @@ export class InputController { return; } + // Forward any pending clipboard-pasted images alongside the queued text; + // otherwise the follow-up would drop the image (mirrors the Enter/steer path). + const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + if (this.ctx.session.isStreaming) { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); - await this.ctx.withLocalSubmission(text, () => - this.ctx.session.prompt(text, { streamingBehavior: "followUp" }), + this.ctx.editor.imageLinks = undefined; + this.ctx.pendingImages = []; + this.ctx.pendingImageLinks = []; + await this.ctx.withLocalSubmission( + text, + () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }), + { imageCount: images?.length ?? 0 }, ); this.ctx.updatePendingMessagesDisplay(); this.ctx.ui.requestRender(); @@ -671,7 +678,12 @@ export class InputController { // Not streaming — just submit normally this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); - await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text)); + this.ctx.editor.imageLinks = undefined; + this.ctx.pendingImages = []; + this.ctx.pendingImageLinks = []; + await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), { + imageCount: images?.length ?? 0, + }); } restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 266516354..39e03443b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -2806,8 +2806,8 @@ export class InteractiveMode implements InteractiveModeContext { this.#uiHelpers.updatePendingMessagesDisplay(); } - queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { - this.#uiHelpers.queueCompactionMessage(text, mode); + queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void { + this.#uiHelpers.queueCompactionMessage(text, mode, images); } flushCompactionQueue(options?: { willRetry?: boolean }): Promise { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index a30aa242f..c1efbcda6 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -35,6 +35,7 @@ import type { Theme } from "./theme/theme"; export type CompactionQueuedMessage = { text: string; mode: "steer" | "followUp"; + images?: ImageContent[]; }; export type SubmittedUserInput = { @@ -180,7 +181,7 @@ export interface InteractiveModeContext { showNewVersionNotification(newVersion: string): void; clearEditor(): void; updatePendingMessagesDisplay(): void; - queueCompactionMessage(text: string, mode: "steer" | "followUp"): void; + queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void; flushCompactionQueue(options?: { willRetry?: boolean }): Promise; flushPendingBashComponents(): void; flushPendingModelSwitch(): Promise; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index ce35dc02c..fc356a8bf 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -630,12 +630,18 @@ export class UiHelpers { } } - queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { - this.ctx.compactionQueuedMessages.push({ text, mode } as CompactionQueuedMessage); + queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void { + const queuedImages = images && images.length > 0 ? images : undefined; + this.ctx.compactionQueuedMessages.push({ text, mode, images: queuedImages } as CompactionQueuedMessage); this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); + this.ctx.editor.imageLinks = undefined; + this.ctx.pendingImages = []; + this.ctx.pendingImageLinks = []; this.ctx.updatePendingMessagesDisplay(); - this.ctx.showStatus("Queued message for after compaction"); + this.ctx.showStatus( + queuedImages ? "Queued message with image for after compaction" : "Queued message for after compaction", + ); } async #deliverQueuedMessage(message: CompactionQueuedMessage): Promise { @@ -644,7 +650,9 @@ export class UiHelpers { return; } await this.ctx.withLocalSubmission(message.text, () => - message.mode === "followUp" ? this.ctx.session.followUp(message.text) : this.ctx.session.steer(message.text), + message.mode === "followUp" + ? this.ctx.session.followUp(message.text, message.images) + : this.ctx.session.steer(message.text, message.images), ); } @@ -738,6 +746,7 @@ export class UiHelpers { const promptPromise = this.ctx.session .prompt(firstPrompt.text, { streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer", + images: firstPrompt.images, }) .catch((error: unknown) => { disposeFirstPrompt(); diff --git a/packages/coding-agent/test/input-controller-compaction-image.test.ts b/packages/coding-agent/test/input-controller-compaction-image.test.ts new file mode 100644 index 000000000..14526b109 --- /dev/null +++ b/packages/coding-agent/test/input-controller-compaction-image.test.ts @@ -0,0 +1,129 @@ +/** + * Images attached during compaction must survive the compaction queue. + * + * Previously, typing a steer/follow-up message with a pending clipboard image + * while the session was compacting was rejected outright ("Retry after it + * completes to send images"). Now `queueCompactionMessage` carries the images, + * and `flushCompactionQueue` forwards them to the session on delivery. + * + * Contracts defended here: + * - `queueCompactionMessage(text, mode, images)` stores the images on the + * queued entry and consumes the pending-image state (so the next message + * does not resend them). + * - On flush, the first queued prompt forwards its images via `session.prompt`. + * - On a `willRetry` flush, a queued follow-up forwards its images via + * `session.followUp` (the `#deliverQueuedMessage` path). + */ + +import { beforeAll, describe, expect, mock, test } from "bun:test"; +import type { ImageContent } from "@oh-my-pi/pi-ai"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { CompactionQueuedMessage, InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; + +beforeAll(() => { + initTheme(); +}); + +type PromptOpts = { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] } | undefined; + +function makeCtx(initialQueue: CompactionQueuedMessage[] = []) { + const promptCalls: Array<{ text: string; opts: PromptOpts }> = []; + const steerCalls: Array<{ text: string; images?: ImageContent[] }> = []; + const followUpCalls: Array<{ text: string; images?: ImageContent[] }> = []; + + const session = { + isStreaming: false, + isCompacting: false, + extensionRunner: undefined, + customCommands: [] as Array<{ command: { name: string } }>, + getQueuedMessages: () => ({ steering: [] as string[], followUp: [] as string[] }), + clearQueue: () => ({ steering: [] as string[], followUp: [] as string[] }), + prompt: mock(async (text: string, opts?: PromptOpts): Promise => { + promptCalls.push({ text, opts }); + }), + steer: mock(async (text: string, images?: ImageContent[]): Promise => { + steerCalls.push({ text, images }); + }), + followUp: mock(async (text: string, images?: ImageContent[]): Promise => { + followUpCalls.push({ text, images }); + }), + }; + + const ctx = { + session, + compactionQueuedMessages: [...initialQueue], + pendingImages: [] as ImageContent[], + pendingImageLinks: [] as (string | undefined)[], + pendingMessagesContainer: { clear: () => {}, addChild: () => {}, removeChild: () => {} }, + editor: { + addToHistory: () => {}, + setText: () => {}, + getText: () => "", + imageLinks: undefined as (string | undefined)[] | undefined, + }, + keybindings: { getDisplayString: () => "Alt+Up" }, + fileSlashCommands: new Set(), + locallySubmittedUserSignatures: new Set(), + isKnownSlashCommand: (text: string) => text.startsWith("/"), + recordLocalSubmission: () => () => {}, + async withLocalSubmission(_text: string, fn: () => Promise): Promise { + return await fn(); + }, + updatePendingMessagesDisplay: () => {}, + showError: () => {}, + showStatus: () => {}, + } as unknown as InteractiveModeContext; + + return { ctx, session, promptCalls, steerCalls, followUpCalls }; +} + +const img = (data: string): ImageContent => ({ type: "image", mimeType: "image/png", data }); + +describe("compaction queue image forwarding", () => { + test("queueCompactionMessage stores images and consumes pending-image state", () => { + const image = img("aGVsbG8="); + const { ctx } = makeCtx(); + ctx.pendingImages = [image]; + ctx.pendingImageLinks = ["clipboard"]; + ctx.editor.imageLinks = ["clipboard"]; + + new UiHelpers(ctx).queueCompactionMessage("look at this screenshot", "steer", [image]); + + expect(ctx.compactionQueuedMessages).toEqual([ + { text: "look at this screenshot", mode: "steer", images: [image] }, + ]); + // Pending state is consumed so the next message does not resend the image. + expect(ctx.pendingImages).toEqual([]); + expect(ctx.pendingImageLinks).toEqual([]); + expect(ctx.editor.imageLinks).toBeUndefined(); + }); + + test("empty image list is normalized to undefined on the queued entry", () => { + const { ctx } = makeCtx(); + new UiHelpers(ctx).queueCompactionMessage("no images here", "followUp", []); + expect(ctx.compactionQueuedMessages).toEqual([{ text: "no images here", mode: "followUp", images: undefined }]); + }); + + test("flush forwards the first queued prompt's images via session.prompt", async () => { + const image = img("d29ybGQ="); + const { ctx, promptCalls } = makeCtx([{ text: "describe this", mode: "steer", images: [image] }]); + + await new UiHelpers(ctx).flushCompactionQueue({ willRetry: false }); + await Promise.resolve(); + await Promise.resolve(); + + expect(promptCalls).toHaveLength(1); + expect(promptCalls[0].text).toBe("describe this"); + expect(promptCalls[0].opts?.images).toEqual([image]); + }); + + test("willRetry flush forwards a follow-up's images via session.followUp", async () => { + const image = img("Zm9v"); + const { ctx, followUpCalls } = makeCtx([{ text: "and this one", mode: "followUp", images: [image] }]); + + await new UiHelpers(ctx).flushCompactionQueue({ willRetry: true }); + + expect(followUpCalls).toEqual([{ text: "and this one", images: [image] }]); + }); +}); diff --git a/packages/coding-agent/test/input-controller-followup-image.test.ts b/packages/coding-agent/test/input-controller-followup-image.test.ts new file mode 100644 index 000000000..5410fd763 --- /dev/null +++ b/packages/coding-agent/test/input-controller-followup-image.test.ts @@ -0,0 +1,117 @@ +/** + * Regression: queuing a follow-up message (Ctrl+Enter / `app.message.followUp`) + * with a pending clipboard-pasted image must forward the image to + * `session.prompt`. Previously `handleFollowUp` ignored `pendingImages`, so the + * queued message reached the model as text only and the image was silently + * dropped. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { ImageContent } from "@oh-my-pi/pi-ai"; +import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; + +interface StubEditor { + setText: (text: string) => void; + getText: () => string; + addToHistory: (text: string) => void; + imageLinks?: unknown; +} +interface PromptOptionsLike { + streamingBehavior?: "steer" | "followUp"; + images?: ImageContent[]; +} + +function createContext(opts: { isStreaming: boolean; pendingImages: ImageContent[] }) { + let editorText = ""; + const editor: StubEditor = { + setText(text) { + editorText = text; + }, + getText() { + return editorText; + }, + addToHistory: vi.fn(), + }; + const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {}); + const updatePendingMessagesDisplay = vi.fn(); + const requestRender = vi.fn(); + + const ctx = { + editor, + ui: { requestRender }, + skillCommands: new Map(), + session: { + isStreaming: opts.isStreaming, + isCompacting: false, + isBashRunning: false, + isEvalRunning: false, + extensionRunner: undefined, + prompt, + }, + pendingImages: opts.pendingImages, + pendingImageLinks: opts.pendingImages.map(() => undefined), + loopModeEnabled: false, + compactionQueuedMessages: [], + locallySubmittedUserSignatures: new Set(), + updatePendingMessagesDisplay, + withLocalSubmission: async (_text: string, fn: () => unknown) => fn(), + } as unknown as InteractiveModeContext; + + return { ctx, editor, prompt }; +} + +describe("InputController.handleFollowUp image forwarding", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("forwards pending images to session.prompt while streaming and clears them", async () => { + const image: ImageContent = { type: "image", mimeType: "image/png", data: "aGVsbG8=" }; + const { ctx, editor, prompt } = createContext({ isStreaming: true, pendingImages: [image] }); + + const controller = new InputController(ctx); + editor.setText("[Image #1] look at this"); + await controller.handleFollowUp(); + + expect(prompt).toHaveBeenCalledTimes(1); + const call = prompt.mock.calls[0]; + if (!call) throw new Error("expected session.prompt to be called"); + expect(call[0]).toBe("[Image #1] look at this"); + expect(call[1]?.streamingBehavior).toBe("followUp"); + expect(call[1]?.images).toEqual([image]); + + // Pending image state is consumed so the next message does not resend it. + expect(ctx.pendingImages).toEqual([]); + expect(ctx.pendingImageLinks).toEqual([]); + }); + + it("forwards pending images when not streaming", async () => { + const image: ImageContent = { type: "image", mimeType: "image/png", data: "d29ybGQ=" }; + const { ctx, editor, prompt } = createContext({ isStreaming: false, pendingImages: [image] }); + + const controller = new InputController(ctx); + editor.setText("[Image #1] describe it"); + await controller.handleFollowUp(); + + expect(prompt).toHaveBeenCalledTimes(1); + const call = prompt.mock.calls[0]; + if (!call) throw new Error("expected session.prompt to be called"); + expect(call[1]?.images).toEqual([image]); + expect(call[1]?.streamingBehavior).toBeUndefined(); + expect(ctx.pendingImages).toEqual([]); + }); + + it("omits images when none are pending", async () => { + const { ctx, editor, prompt } = createContext({ isStreaming: true, pendingImages: [] }); + + const controller = new InputController(ctx); + editor.setText("just text"); + await controller.handleFollowUp(); + + expect(prompt).toHaveBeenCalledTimes(1); + const call = prompt.mock.calls[0]; + if (!call) throw new Error("expected session.prompt to be called"); + expect(call[1]?.images).toBeUndefined(); + expect(call[1]?.streamingBehavior).toBe("followUp"); + }); +}); diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 4982c354e..8a2d0b36c 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -237,8 +237,8 @@ describe("InputController keybinding setup", () => { await controller.handleFollowUp(); expect(ctx.locallySubmittedUserSignatures.has("plain idle submit\u00000")).toBe(true); - // Idle submit calls prompt() with no streamingBehavior. - expect(spies.prompt).toHaveBeenCalledWith("plain idle submit"); + // Idle submit calls prompt() with no streamingBehavior (images forwarded, undefined here). + expect(spies.prompt).toHaveBeenCalledWith("plain idle submit", { images: undefined }); }); it("removes the signature when an idle follow-up submission rejects", async () => { From f6b0a690ab8c00619d32ded9486d6d4c8acb4d65 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:11:18 +0200 Subject: [PATCH 02/53] perf(tui): optimized bracketed paste processing for large inputs - Reworked `StdinBuffer` to collect bracketed-paste chunks incrementally and flush them once the end marker is found, while carrying an overlap tail so split markers are still detected. - Simplified pasted-text sanitization in `Editor` to use a single regex pass that strips control characters while preserving newlines. - Added tests covering split-marker boundaries and large chunked pastes to verify the new paste assembly behavior. --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/components/editor.ts | 9 ++- packages/tui/src/stdin-buffer.ts | 84 ++++++++++++++------------ packages/tui/test/editor.test.ts | 7 +++ packages/tui/test/stdin-buffer.test.ts | 46 ++++++++++++++ 5 files changed, 108 insertions(+), 42 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d9ae11fb4..faa86be6f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed pasting large text lagging the prompt for hundreds of milliseconds before the `[paste #N …]` placeholder appeared. `StdinBuffer` assembled bracketed pastes by re-concatenating and re-scanning the entire accumulated buffer on every incoming stdin chunk (`#pasteBuffer += chunk; indexOf(END)`), which is O(n²) in the paste size and dominates when the terminal/PTY delivers the paste in many small reads (SSH, tmux, slow hosts) — a 1 MB paste at 1 KB chunks cost ~33 ms and 5 MB ~740 ms. Chunks are now collected in an array and joined once when the end marker arrives, with a short overlap tail carried across chunk boundaries so a marker split between two reads is still detected without rescanning, making assembly O(n) (~1 ms for 5 MB). The `Editor` paste cleaner also dropped its `split("").filter().join("")` per-code-unit array allocation in favor of a single control-character regex pass (~20× faster on large pastes). + ## [15.10.4] - 2026-06-08 ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index e252a1ea0..3f7a9fd76 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1627,11 +1627,10 @@ export class Editor implements Component, Focusable { // Convert tabs to spaces (4 spaces per tab) const tabExpandedText = cleanText.replace(/\t/g, " "); - // Filter out non-printable characters except newlines - let filteredText = tabExpandedText - .split("") - .filter(char => char === "\n" || char.charCodeAt(0) >= 32) - .join(""); + // Strip control characters except newline (tabs already expanded above, + // CRs already normalized). Single regex pass instead of split/filter/join + // to avoid allocating a per-code-unit array for large pastes. + let filteredText = tabExpandedText.replace(/[\x00-\x09\x0B-\x1F]/g, ""); // If pasting a file path (starts with /, ~, or .) and the character before // the cursor is a word character, prepend a space for better readability diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index def17e4f2..c5189a885 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -265,7 +265,8 @@ export class StdinBuffer extends EventEmitter { #timeout?: NodeJS.Timeout; readonly #timeoutMs: number; #pasteMode: boolean = false; - #pasteBuffer: string = ""; + #pasteChunks: string[] = []; + #pasteOverlap: string = ""; #pendingKittyPrintableCodepoint: number | undefined; constructor(options: StdinBufferOptions = {}) { @@ -302,24 +303,9 @@ export class StdinBuffer extends EventEmitter { this.#buffer += str; if (this.#pasteMode) { - this.#pasteBuffer += this.#buffer; + const chunk = this.#buffer; this.#buffer = ""; - - const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END); - if (endIndex !== -1) { - const pastedContent = this.#pasteBuffer.slice(0, endIndex); - const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); - - this.#pasteMode = false; - this.#pasteBuffer = ""; - this.#pendingKittyPrintableCodepoint = undefined; - - this.emit("paste", pastedContent); - - if (remaining.length > 0) { - this.process(remaining); - } - } + this.#consumePasteChunk(chunk); return; } @@ -335,25 +321,12 @@ export class StdinBuffer extends EventEmitter { this.#pendingKittyPrintableCodepoint = undefined; this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length); - this.#pasteMode = true; - this.#pasteBuffer = this.#buffer; + const firstChunk = this.#buffer; this.#buffer = ""; - - const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END); - if (endIndex !== -1) { - const pastedContent = this.#pasteBuffer.slice(0, endIndex); - const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); - - this.#pasteMode = false; - this.#pasteBuffer = ""; - this.#pendingKittyPrintableCodepoint = undefined; - - this.emit("paste", pastedContent); - - if (remaining.length > 0) { - this.process(remaining); - } - } + this.#pasteMode = true; + this.#pasteChunks = []; + this.#pasteOverlap = ""; + this.#consumePasteChunk(firstChunk); return; } @@ -375,6 +348,42 @@ export class StdinBuffer extends EventEmitter { } } + /** + * Consume one chunk of paste-mode input. Chunks are accumulated in an array + * and only joined once the end marker arrives, so a large paste delivered in + * many small terminal reads stays O(total) instead of the O(total^2) cost of + * re-concatenating and rescanning the whole buffer on every chunk. A short + * overlap tail (end-marker length - 1) is carried across chunk boundaries so + * a marker split between two reads is still detected without rescanning. + */ + #consumePasteChunk(chunk: string): void { + const probe = this.#pasteOverlap + chunk; + if (probe.indexOf(BRACKETED_PASTE_END) === -1) { + this.#pasteChunks.push(chunk); + const keep = BRACKETED_PASTE_END.length - 1; + this.#pasteOverlap = probe.length > keep ? probe.slice(probe.length - keep) : probe; + return; + } + + // End marker arrived: join once and split at its first occurrence, + // matching the prior indexOf-from-start semantics exactly. + const flat = this.#pasteChunks.length > 0 ? `${this.#pasteChunks.join("")}${chunk}` : chunk; + const endIndex = flat.indexOf(BRACKETED_PASTE_END); + const pastedContent = flat.slice(0, endIndex); + const remaining = flat.slice(endIndex + BRACKETED_PASTE_END.length); + + this.#pasteMode = false; + this.#pasteChunks = []; + this.#pasteOverlap = ""; + this.#pendingKittyPrintableCodepoint = undefined; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + #emitDataSequence(sequence: string): void { const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined; if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) { @@ -409,7 +418,8 @@ export class StdinBuffer extends EventEmitter { } this.#buffer = ""; this.#pasteMode = false; - this.#pasteBuffer = ""; + this.#pasteChunks = []; + this.#pasteOverlap = ""; this.#pendingKittyPrintableCodepoint = undefined; } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 7a1506974..29f7d1130 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1821,6 +1821,13 @@ describe("Editor component", () => { expect(editor.getCursor()).toEqual({ line: 0, col: 2 }); }); + it("strips control characters from pasted text but keeps newlines", () => { + const editor = new Editor(defaultEditorTheme); + // BEL (\x07) and NUL (\x00) must be removed; the newline must survive. + editor.handleInput("\x1b[200~a\x07b\x00c\ndef\x1b[201~"); + expect(editor.getText()).toBe("abc\ndef"); + }); + it("undoes the last paste when a transient #undo trigger is executed", () => { const editor = new Editor(defaultEditorTheme); diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 6c740b6ae..d88c1a42f 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -310,6 +310,52 @@ describe("StdinBuffer", () => { expect(emittedPaste).toEqual(["Hello \u4e16\u754c \u{1f389}"]); expect(emittedSequences).toEqual([]); }); + + it("assembles paste when the end marker is split across chunks", () => { + processInput("\x1b[200~hello world\x1b[201"); + expect(emittedPaste).toEqual([]); + + processInput("~"); + expect(emittedPaste).toEqual(["hello world"]); + expect(emittedSequences).toEqual([]); + }); + + it("assembles paste when the start and end markers arrive one byte at a time", () => { + for (const ch of "\x1b[200~ab\x1b[201~") { + processInput(ch); + } + expect(emittedPaste).toEqual(["ab"]); + expect(emittedSequences).toEqual([]); + }); + + it("preserves trailing input after a boundary-split end marker", () => { + processInput("\x1b[200~paste\x1b"); + processInput("[201~x"); + expect(emittedPaste).toEqual(["paste"]); + expect(emittedSequences).toEqual(["x"]); + }); + + it("does not end the paste on a partial end-marker prefix in the body", () => { + // Body contains the first five bytes of the end marker but no `~`. + processInput("\x1b[200~before\x1b[201"); + expect(emittedPaste).toEqual([]); + + processInput("after\x1b[201~"); + expect(emittedPaste).toEqual(["before\x1b[201after"]); + expect(emittedSequences).toEqual([]); + }); + + it("reconstructs a large paste delivered in many small chunks", () => { + const content = "0123456789abcdef".repeat(8192); // 128 KB + processInput("\x1b[200~"); + for (let i = 0; i < content.length; i += 64) { + processInput(content.slice(i, i + 64)); + } + processInput("\x1b[201~"); + + expect(emittedPaste).toEqual([content]); + expect(emittedSequences).toEqual([]); + }); }); describe("Destroy", () => { From 070422bb954179f3dc1dc5857555ec29f4df1421 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 11:18:31 +0000 Subject: [PATCH 03/53] fix(ai): corrected Zhipu API key placeholder Zhipu Coding Plan keys are formatted '.' (no 'sk-' prefix), so the OAuth login prompt's 'sk-...' placeholder misled users into thinking their pasted key was malformed. Also extended the SECRET_PATTERNS redaction example in docs/skills/authoring-hooks.md with the Zhipu shape and a comment noting the list is not exhaustive, so hook authors referencing the example do not silently miss Zhipu keys. Fixes #2106 --- docs/skills/authoring-hooks.md | 6 +++++- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/utils/oauth/zhipu.ts | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/skills/authoring-hooks.md b/docs/skills/authoring-hooks.md index 37218e30f..65f9678ca 100644 --- a/docs/skills/authoring-hooks.md +++ b/docs/skills/authoring-hooks.md @@ -179,11 +179,15 @@ export default function rmRfBlocker(omp: HookAPI): void { ```ts import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; -// Matches common API key patterns: sk-..., pk-..., AKIA..., ghp_..., etc. +// Common API-key shapes. Not exhaustive — providers using bespoke formats +// (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.) +// need their own entries. const SECRET_PATTERNS = [ /\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g, /\bAKIA[A-Z0-9]{16}\b/g, /\bghp_[a-zA-Z0-9]{36}\b/g, + // Zhipu / GLM Coding Plan: `.` (no `sk-` prefix). + /\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g, /\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g, ]; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1ebaa4a4d..1a7bea079 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the Zhipu Coding Plan login prompt advertising a misleading `sk-...` placeholder. Zhipu API keys are formatted `.` (no `sk-` prefix), so the placeholder now matches the actual format instead of suggesting the wrong shape. ([#2106](https://github.com/can1357/oh-my-pi/issues/2106)) + ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/ai/src/utils/oauth/zhipu.ts b/packages/ai/src/utils/oauth/zhipu.ts index 14c766ed3..06f315a5e 100644 --- a/packages/ai/src/utils/oauth/zhipu.ts +++ b/packages/ai/src/utils/oauth/zhipu.ts @@ -36,7 +36,7 @@ export async function loginZhipuCodingPlan(options: OAuthController): Promise.", }); if (options.signal?.aborted) { From 2f643451ee666d9a2ef6bd56a5134a2f685fb141 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:32:44 +0200 Subject: [PATCH 04/53] fix(hashline): fixed compact diff preview to omit removed lines and collapse long added runs - Modified buildCompactDiffPreview to omit removed lines from output while preserving removal counts for offset tracking. - Added logic to collapse long contiguous added runs with +... elision marker, keeping configurable edge lines on each side via new maxAddedRunContext option. - Removed context-gap placeholder rows (... or ...) from model-facing preview, relying on line-number jumps to convey elision. - Reorganized test suite by moving core contract tests to dedicated core-contracts.test.ts file and diff-preview tests to diff-preview.test.ts for better separation of concerns. --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/test/core/hashline.test.ts | 770 +----------------- packages/hashline/CHANGELOG.md | 14 + packages/hashline/src/diff-preview.ts | 107 ++- packages/hashline/src/types.ts | 6 +- packages/hashline/test/core-contracts.test.ts | 298 +++++++ packages/hashline/test/diff-preview.test.ts | 58 ++ 7 files changed, 463 insertions(+), 791 deletions(-) create mode 100644 packages/hashline/test/core-contracts.test.ts create mode 100644 packages/hashline/test/diff-preview.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d1af05c6..650e74a91 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows - Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages - Fixed clipboard-pasted images being rejected when steering or following up during compaction. Instead of bailing with "Retry after it completes to send images", the message and its images are now queued via `queueCompactionMessage` and forwarded to the session (steer/follow-up/prompt) when the compaction queue flushes. +- Fixed edit tool result previews to show only current-file lines and collapse long inserted blocks instead of echoing removed content. ## [15.10.4] - 2026-06-08 diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 2f82d6e84..eff611f4e 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -3,84 +3,20 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { - applyEdits, - buildCompactDiffPreview as buildCompactHashlineDiffPreview, - detectLineEnding, - type Edit, - InMemorySnapshotStore as FileReadCache, - Filesystem, formatHashlineHeader, + InMemorySnapshotStore as FileReadCache, MismatchError as HashlineMismatchError, - NotFoundError, - Patch, - Patcher, - type PatchSection, - parsePatch as parseHashline, - Recovery, - type SplitOptions, - type WriteResult, } from "@oh-my-pi/hashline"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { canonicalSnapshotKey, type ExecuteHashlineSingleOptions, executeHashlineSingle, - generateDiffString, getFileSnapshotStore as getFileReadCache, hashlineEditParamsSchema, } from "@oh-my-pi/pi-coding-agent/edit"; import * as z from "zod/v4"; -/** - * The test bodies use a small adapter over the package API so production code - * can use the package names directly while assertions stay compact. - */ -function applyHashlineEdits( - text: string, - edits: readonly Edit[], -): { - text: string; - lines: string; - firstChangedLine?: number; - warnings?: string[]; -} { - const r = applyEdits(text, [...edits]); - return { ...r, lines: r.text }; -} - -interface SectionView { - path: string; - fileHash?: string; - diff: string; -} -function toSectionView(section: PatchSection): SectionView { - return section.fileHash !== undefined - ? { path: section.path, fileHash: section.fileHash, diff: section.diff } - : { path: section.path, diff: section.diff }; -} -function splitHashlineInput(input: string, options: SplitOptions = {}): SectionView { - return toSectionView(Patch.parseSingle(input, options)); -} -function splitHashlineInputs(input: string, options: SplitOptions = {}): SectionView[] { - return Patch.parse(input, options).sections.map(toSectionView); -} - -function tryRecoverHashlineWithCache(args: { - cache: FileReadCache; - absolutePath: string; - currentText: string; - tag: string; - edits: readonly Edit[]; -}): { text: string; lines: string; firstChangedLine: number | undefined; warnings: string[] } | null { - const recovered = new Recovery(args.cache).tryRecover({ - path: args.absolutePath, - currentText: args.currentText, - fileHash: args.tag, - edits: args.edits, - }); - return recovered ? { ...recovered, lines: recovered.text } : null; -} - import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; beforeAll(async () => { @@ -89,8 +25,6 @@ beforeAll(async () => { }); const repl = (text: string): string => `+${text}`; -const outputSep = ":"; -const outputSepRe = ":"; function tag(line: number, _content: string): string { return `${line}`; @@ -106,9 +40,6 @@ function recordFullSnapshot(cache: FileReadCache, filePath: string, fullText: st function snapshotHead(cache: FileReadCache, filePath: string) { return cache.head(canonicalSnapshotKey(filePath)); } -function snapshotByHash(cache: FileReadCache, filePath: string, hash: string) { - return cache.byHash(canonicalSnapshotKey(filePath), hash); -} function header(filePath: string, tag: string): string { return formatHashlineHeader(filePath, tag); @@ -118,9 +49,6 @@ function sameLineRange(anchor: string): string { return `replace ${anchor}..${anchor}:`; } -function applyDiff(content: string, diff: string): string { - return applyHashlineEdits(content, parseHashline(diff).edits).lines; -} async function withTempDir(fn: (tempDir: string) => Promise): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-edit-")); @@ -156,487 +84,11 @@ function hashlineExecuteOptions( }; } -class PolicyFilesystem extends Filesystem { - #files = new Map(); - #blocked = new Set(); - constructor(initial: Iterable, blocked: Iterable) { - super(); - for (const [filePath, content] of initial) this.#files.set(filePath, content); - for (const filePath of blocked) this.#blocked.add(filePath); - } - async readText(filePath: string): Promise { - const content = this.#files.get(filePath); - if (content === undefined) throw new NotFoundError(filePath); - return content; - } - async preflightWrite(filePath: string): Promise { - if (this.#blocked.has(filePath)) throw new Error(`blocked write: ${filePath}`); - } - async writeText(filePath: string, content: string): Promise { - this.#files.set(filePath, content); - return { text: content }; - } - get(filePath: string): string | undefined { - return this.#files.get(filePath); - } -} - -describe("hashline normalization", () => { - it("preserves the first newline style when restoring mixed-ending files", () => { - expect(detectLineEnding("a\r\nb\nc")).toBe("\r\n"); - expect(detectLineEnding("a\nb\r\nc")).toBe("\n"); - }); -}); - -describe("hashline parser — range-anchor syntax", () => { - it("keeps parsed edits reusable across different target snapshots", () => { - const section = Patch.parseSingle(["[a.ts]", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n")); - - expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail"); - expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); - }); - - const content = "aaa\nbbb\nccc"; - - it("inserts payload before/after a Lid, and at insert head:/insert tail:", () => { - const diff = [ - `insert before ${tag(2, "bbb")}:`, - repl("before b"), - `insert after ${tag(2, "bbb")}:`, - repl("after b"), - "insert head:", - repl("top"), - "insert tail:", - repl("tail"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); - }); - - it("inserts after the final line without falling off the file", () => { - const diff = [`insert after ${tag(3, "ccc")}:`, repl("tail")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail"); - }); - - it("deletes a line or range via delete hunks", () => { - expect(applyDiff(content, `delete ${tag(2, "bbb")}`)).toBe("aaa\nccc"); - expect(applyDiff(content, `delete ${tag(2, "bbb")}..${tag(3, "ccc")}`)).toBe("aaa"); - }); - - it("replaces a line with one blank when given an explicit empty replace payload", () => { - const explicit = [`${sameLineRange(tag(2, "bbb"))}`, repl("")].join("\n"); - expect(applyDiff(content, explicit)).toBe("aaa\n\nccc"); - }); - - it("replaces one line or an inclusive range with payload lines", () => { - const single = [`${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); - expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc"); - - const range = [`replace ${tag(2, "bbb")}..${tag(3, "ccc")}:`, repl("BBB"), repl("CCC")].join("\n"); - expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC"); - }); - - it("rejects bare single-number hunk headers", () => { - const anchor = tag(2, "bbb"); - expect(() => parseHashline(`${anchor}\n${repl("BBB")}`)).toThrow(/hunk headers need a verb/); - }); - - it("delete hunk deletes the range entirely", () => { - const anchor = tag(2, "bbb"); - expect(applyDiff(content, `delete ${anchor}`)).toBe("aaa\nccc"); - expect(applyDiff(content, `delete ${anchor}..${tag(3, "ccc")}`)).toBe("aaa"); - }); - - it("rejects orphan inline-anchor shapes from old format", () => { - const anchor = tag(2, "bbb"); - for (const diff of [`${anchor}..${tag(3, "ccc")}=NEW`, "insert head:=NEW", "insert tail:=NEW"]) { - expect(() => parseHashline(diff)).toThrow(/payload line has no preceding hunk header/); - } - }); - - it("emits body rows in textual order", () => { - const diff = [ - `${sameLineRange(tag(2, "bbb"))}`, - repl("above 1"), - repl("above 2"), - repl("BBB"), - repl("below 1"), - repl("below 2"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nabove 1\nabove 2\nBBB\nbelow 1\nbelow 2\nccc"); - }); - - it("inserts around an anchor with explicit insert hunks", () => { - const diff = [ - `insert before ${tag(2, "bbb")}:`, - repl("before"), - `insert after ${tag(2, "bbb")}:`, - repl("after"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nbefore\nbbb\nafter\nccc"); - }); - - it("escapes literal leading payload sigils with literal rows", () => { - // `+` is the canonical sigil; payload rows like `+|literal` emit - // `|literal` verbatim. Same for `^literal` and `↓literal` — none of - // these are recognized sigils once they sit inside a `+TEXT` row. - const diff = [`${sameLineRange(tag(2, "bbb"))}`, repl("|literal"), repl("^literal"), repl("↓literal")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\n|literal\n^literal\n↓literal\nccc"); - }); - - it("accepts literal payload at virtual insert head:/insert tail: anchors", () => { - expect(applyDiff(content, ["insert head:", repl("HEAD")].join("\n"))).toBe("HEAD\naaa\nbbb\nccc"); - expect(applyDiff(content, ["insert tail:", repl("TAIL")].join("\n"))).toBe("aaa\nbbb\nccc\nTAIL"); - }); - - it("auto-pipes unprefixed payload continuation lines as literal text", () => { - const anchor = tag(2, "bbb"); - const { edits, warnings } = parseHashline(`${sameLineRange(anchor)}\n${repl("FIRST")}\nSECOND`); - expect(applyHashlineEdits("aaa\nbbb\nccc", edits).lines).toBe("aaa\nFIRST\nSECOND\nccc"); - expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); - }); - - it("preserves whitespace-bearing payload exactly", () => { - const anchor = tag(2, "bbb"); - const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; - expect(applyDiff(content, [`insert after ${anchor}:`, repl(payload)].join("\n"))).toBe( - `aaa\nbbb\n${payload}\nccc`, - ); - expect(applyDiff(content, [`insert before ${anchor}:`, repl(payload)].join("\n"))).toBe( - `aaa\n${payload}\nbbb\nccc`, - ); - }); - - it("keeps duplicated multiline replacement boundaries literal", () => { - const prefixSource = ["// one", "// two", "old();"].join("\n"); - const prefixDiff = [`${sameLineRange(tag(3, "old();"))}`, repl("// one"), repl("// two"), repl("new();")].join( - "\n", - ); - expect(applyDiff(prefixSource, prefixDiff)).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); - - const suffixSource = ["old();", "// one", "// two"].join("\n"); - const suffixDiff = [`${sameLineRange(tag(1, "old();"))}`, repl("new();"), repl("// one"), repl("// two")].join( - "\n", - ); - expect(applyDiff(suffixSource, suffixDiff)).toBe(["new();", "// one", "// two", "// one", "// two"].join("\n")); - }); - - it("de-duplicates structural replacement boundaries (balance-validated)", () => { - // `replace 1..1:` replaces `old();` but the payload also restates the `};` that - // survives at line 2 — a duplicate close that would unbalance braces. - const suffixSource = ["old();", "};"].join("\n"); - const suffixDiff = [`${sameLineRange(tag(1, "old();"))}`, repl("new();"), repl("};")].join("\n"); - expect(applyDiff(suffixSource, suffixDiff)).toBe(["new();", "};"].join("\n")); - - // Mirror case at the leading edge. - const prefixSource = ["};", "old();"].join("\n"); - const prefixDiff = [`${sameLineRange(tag(2, "old();"))}`, repl("};"), repl("new();")].join("\n"); - expect(applyDiff(prefixSource, prefixDiff)).toBe(["};", "new();"].join("\n")); - - const result = applyHashlineEdits(suffixSource, parseHashline(suffixDiff).edits); - expect(result.warnings?.some(w => /delimiter-balance/.test(w))).toBe(true); - }); - - it("keeps duplicated single non-structural replacement boundaries literal", () => { - const prefixSource = ["const X = …", "", "const LEGACY = {", " a: 1,", "}"].join("\n"); - const prefixDiff = [`replace ${tag(2, "")}..${tag(5, "}")}:`, repl("const X = …")].join("\n"); - expect(applyDiff(prefixSource, prefixDiff)).toBe(["const X = …", "const X = …"].join("\n")); - - const suffixSource = ["## Legacy", "", "stale content", "", "## Subagents"].join("\n"); - const suffixDiff = [`replace ${tag(1, "## Legacy")}..${tag(4, "")}:`, repl("## Subagents")].join("\n"); - expect(applyDiff(suffixSource, suffixDiff)).toBe(["## Subagents", "## Subagents"].join("\n")); - }); - - it("does not emit warnings for duplicated replacement boundaries", () => { - const source = ["// one", "// two", "old();"].join("\n"); - const diff = [`${sameLineRange(tag(3, "old();"))}`, repl("// one"), repl("// two"), repl("new();")].join("\n"); - - const result = applyHashlineEdits(source, parseHashline(diff).edits); - expect(result.lines).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); - expect(result.warnings).toBeUndefined(); - }); - - it("preserves a legitimate single-line replacement that happens to match an adjacent line", () => { - const source = ["foo", "bar", "baz"].join("\n"); - const diff = [`${sameLineRange(tag(2, "bar"))}`, repl("foo")].join("\n"); - - expect(applyDiff(source, diff)).toBe(["foo", "foo", "baz"].join("\n")); - }); - - it("keeps pure-insert payload that duplicates adjacent file context", () => { - const eofSource = ["aaa", "bbb", "ccc"].join("\n"); - const eofDiff = ["insert tail:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n"); - expect(applyDiff(eofSource, eofDiff)).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW"); - - const bofSource = ["aaa", "bbb", "ccc", "ddd"].join("\n"); - const bofDiff = ["insert head:", repl("NEW"), repl("aaa"), repl("bbb")].join("\n"); - expect(applyDiff(bofSource, bofDiff)).toBe("NEW\naaa\nbbb\naaa\nbbb\nccc\nddd"); - }); - - it("preserves duplicated structural pure-insert payload", () => { - const source = ["if ok {", " keep();", " }"].join("\n"); - const diff = ["insert tail:", repl(" added();"), repl(" }")].join("\n"); - - expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " }", " added();", " }"].join("\n")); - }); - - it("preserves an intentional non-structural duplicate for after insert", () => { - const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`insert after ${tag(2, "bbb")}:`, repl("bbb"), repl("NEW")].join("\n"); - - expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc"); - }); - - it("preserves an intentional non-structural duplicate for before insert", () => { - const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`insert before ${tag(2, "bbb")}:`, repl("NEW"), repl("bbb")].join("\n"); - - expect(applyDiff(source, diff)).toBe("aaa\nNEW\nbbb\nbbb\nccc"); - }); - - it("keeps a single structural pure-insert suffix when it preserves balance", () => { - const source = ["if outer {", "}"].join("\n"); - const diff = [`insert before ${tag(2, "}")}:`, repl("if inner {"), repl("}")].join("\n"); - - expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n")); - }); - - it("preserves payload text exactly", () => { - const diff = [ - `${sameLineRange(tag(2, "bbb"))}`, - repl(""), - repl("# not a header"), - repl("+ not an op"), - repl("\\ not an op"), - repl(" spaced"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\n\n# not a header\n+ not an op\n\\ not an op\n spaced\nccc"); - }); - - it("treats explicit empty replace payload rows as blank lines", () => { - const diff = [`${sameLineRange(tag(2, "bbb"))}`, repl("first"), repl(""), repl(""), repl("after")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc"); - }); - - it("skips markdown-comment lines immediately before an operation", () => { - const diff = [ - "# This is a comment line from a model explanation.", - "## Another comment line.", - `${sameLineRange(tag(2, "bbb"))}`, - repl("BBB"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc"); - }); - - it("does not skip comment lines when they are not immediately before an operation", () => { - const diff = ["# This is a stray comment.", "", `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); - expect(() => parseHashline(diff)).toThrow(/payload line has no preceding/); - }); - - it("preserves raw blank separators between ops", () => { - const diff = [ - `${sameLineRange(tag(1, "aaa"))}`, - repl("AAA"), - "", - "", - `${sameLineRange(tag(3, "ccc"))}`, - repl("CCC"), - ].join("\n"); - expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC"); - }); - - it("inserts explicit blank lines above and below an anchor", () => { - expect(applyDiff(content, `insert before ${tag(1, "aaa")}:\n${repl("")}`)).toBe("\naaa\nbbb\nccc"); - expect(applyDiff(content, `insert after ${tag(1, "aaa")}:\n${repl("")}`)).toBe("aaa\n\nbbb\nccc"); - }); - - it("rejects orphan payload lines with no preceding op", () => { - expect(() => parseHashline(repl("orphan")).edits).toThrow(/payload line has no preceding/); - }); - - it("treats empty replace bodies as deletes", () => { - expect(parseHashline(`${sameLineRange(tag(2, "bbb"))}`).edits).toEqual([ - { kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }, - ]); - expect(parseHashline(`delete ${tag(2, "bbb")}`).edits).toEqual([ - { kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }, - ]); - }); - - it("rejects `LINE:TEXT` copied verbatim from read output", () => { - const anchor = tag(2, "bbb"); - expect(() => parseHashline(`${sameLineRange(anchor)}:BBB`)).toThrow(/payload line has no preceding hunk header/); - expect(() => parseHashline(`${anchor}..${tag(3, "ccc")}:BBB`)).toThrow( - /payload line has no preceding hunk header/, - ); - }); - - it("rejects arrow replace syntax as an unrecognized payload line", () => { - expect(() => parseHashline(`2→\nBBB`).edits).toThrow(/payload line has no preceding/); - expect(() => parseHashline(`2-3→\nBBB`).edits).toThrow(/payload line has no preceding/); - }); - - it("preserves payload text containing arrow sigils after the leading payload sigil", () => { - const anchor = tag(2, "bbb"); - expect(applyDiff(content, `${sameLineRange(anchor)}\n${repl("bbb↑")}\n${repl("tail↓")}`)).toBe( - "aaa\nbbb↑\ntail↓\nccc", - ); - }); - - it("accepts insert head:/insert tail: inserts with literal payload rows", () => { - expect(applyDiff(content, `insert head:\n${repl("HEAD")}`)).toBe("HEAD\naaa\nbbb\nccc"); - expect(applyDiff(content, `insert tail:\n${repl("TAIL")}`)).toBe("aaa\nbbb\nccc\nTAIL"); - }); - - it("rejects two replace ops targeting the same single line", () => { - const diff = `${sameLineRange(tag(2, "bbb"))}\n${repl("BBB")}\n${sameLineRange(tag(2, "bbb"))}\n${repl("BBB2")}`; - expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted/); - }); - - it("rejects two replace ops covering the same range", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("OLD")}\n${repl("OLD2")}\nreplace ${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("NEW")}\n${repl("NEW2")}`; - expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted/); - }); - - it("still rejects two replace ops whose ranges partially overlap without containment", () => { - // 3-5 extends past the outer 2-4, so it is neither identical nor contained. - // The inner anchors still clash with the outer range's deletes and the - // post-hoc validator catches the overlap. - const diff = `replace ${tag(2, "bbb")}..${tag(4, "ddd")}:\n${repl("NEW1")}\nreplace ${tag(3, "ccc")}..${tag(5, "eee")}:\n${repl("NEW2")}`; - expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by another hunk on line 1/); - }); - - it("uses `|` payload lines inside a multi-line replacement", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(4, "ddd")}:\n${repl("line one")}\n${repl("line two")}\n${repl("line three")}`; - const { edits, warnings } = parseHashline(diff); - expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe( - "aaa\nline one\nline two\nline three\neee", - ); - expect(warnings).toEqual([]); - }); - - it("strips read-output `N:` line-number prefix from auto-piped bare body rows", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(4, "ddd")}:\n${repl("line one")}\n${tag(3, "ccc")}:line two`; - const { edits, warnings } = parseHashline(diff); - expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe("aaa\nline one\nline two\neee"); - expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); - }); - - it("treats `N:` outside the pending range as a separate op", () => { - const diff = `replace ${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("line one")}\n${sameLineRange(tag(5, "eee"))}\n${repl("line five")}`; - const { edits, warnings } = parseHashline(diff); - expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe( - "aaa\nline one\nddd\nline five\nfff", - ); - expect(warnings).toEqual([]); - }); - - it("accepts multiple literal rows before an anchor", () => { - const diff = `insert before ${tag(2, "bbb")}:\n${repl("X")}\n${repl("Y")}`; - expect(applyDiff(content, diff)).toBe("aaa\nX\nY\nbbb\nccc"); - }); - - it("accepts a replace alongside surrounding literal rows", () => { - const diff = `${sameLineRange(tag(2, "bbb"))}\n${repl("ABOVE")}\n${repl("NEW")}`; - expect(applyDiff(content, diff)).toBe("aaa\nABOVE\nNEW\nccc"); - }); -}); - -describe("hashline — snapshot tag binding", () => { - it("rejects line-hash anchors as unrecognized payload lines", () => { - expect(() => parseHashline(`2ab:\n${repl("BBB")}`).edits).toThrow(/payload line has no preceding/); - }); - - it("applies line-number edits without per-anchor hash validation", () => { - const diff = `${sameLineRange(tag(2, "bbb"))}\n${repl("BBB")}`; - expect(applyDiff("aaa\nbbb\nccc", diff)).toBe("aaa\nBBB\nccc"); - }); -}); - -describe("splitHashlineInput — bracket headers", () => { - it("extracts path, snapshot tag, and diff body from [path#tag] header", () => { - const input = [`[src/foo.ts#1A2B]`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); - expect(splitHashlineInput(input)).toEqual({ - path: "src/foo.ts", - fileHash: "1A2B", - diff: `${sameLineRange(tag(2, "bbb"))}\n${repl("BBB")}`, - }); - }); - - it("strips leading blank lines", () => { - expect(splitHashlineInput(`\n[foo.ts]\ninsert head:\n${repl("x")}`)).toEqual({ - path: "foo.ts", - diff: `insert head:\n${repl("x")}`, - }); - }); - - it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => { - const cwd = process.cwd(); - const absolute = path.join(cwd, "src", "foo.ts"); - expect(splitHashlineInput(`[${absolute}]\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); - }); - - it("uses explicit fallback path only when input has recognizable operations", () => { - expect(splitHashlineInput(`insert head:\n${repl("x")}`, { path: "a.ts" })).toEqual({ - path: "a.ts", - diff: `insert head:\n${repl("x")}`, - }); - expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/); - }); - - it("splits multiple edit sections", () => { - const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]", "insert tail:", repl("b")].join("\n"); - expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `insert head:\n${repl("a")}` }, - { path: "b.ts", diff: `insert tail:\n${repl("b")}` }, - ]); - }); - it("rejects a unified-diff hunk header on the first line as contamination", () => { - const input = ["@@ -1,3 +1,3 @@", "insert head:", repl("x")].join("\n"); - expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/); - }); - - it("rejects a unified-diff hunk header (`-N,M +N,M`)", () => { - const input = ["@@ -1,3 +1,3 @@", "insert head:", repl("x")].join("\n"); - expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/); - }); - - it("silently drops a trailing header with no operations", () => { - const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]"].join("\n"); - expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `insert head:\n${repl("a")}` }]); - }); -}); - -it("preflights write policy for every section before committing a batch", async () => { - const fixture = new PolicyFilesystem( - [ - ["a.ts", "aaa\n"], - ["b.ts", "bbb\n"], - ], - ["b.ts"], - ); - const snapshots = new FileReadCache(); - const aTag = recordFullSnapshot(snapshots, "a.ts", "aaa\n"); - const bTag = recordFullSnapshot(snapshots, "b.ts", "bbb\n"); - const input = [ - header("a.ts", aTag), - `${sameLineRange(tag(1, "aaa"))}`, - repl("AAA"), - header("b.ts", bTag), - `${sameLineRange(tag(1, "bbb"))}`, - repl("BBB"), - ].join("\n"); - - await expect(new Patcher({ fs: fixture, snapshots }).apply(Patch.parse(input))).rejects.toThrow( - /blocked write: b\.ts/, - ); - expect(fixture.get("a.ts")).toBe("aaa\n"); - expect(fixture.get("b.ts")).toBe("bbb\n"); -}); describe("hashline executor", () => { it("rejects file creation and directs to the write tool", async () => { @@ -664,6 +116,7 @@ describe("hashline executor", () => { }); }); + it("emits an actionable no-op diagnostic when the payload matches the file byte-for-byte", async () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); @@ -821,42 +274,6 @@ describe("hashlineEditParamsSchema — payload shape", () => { }); }); -describe("buildCompactHashlineDiffPreview — line numbers track post-edit positions", () => { - it("emits context lines against the new file's line numbers after a range expansion", () => { - const before = ["a1", "a2", "a3", "a4", "a5", "a6", "a7"].join("\n"); - const after = ["a1", "a2", "a3", "X", "Y", "Z", "a5", "a6", "a7"].join("\n"); - const { diff } = generateDiffString(before, after); - const preview = buildCompactHashlineDiffPreview(diff); - - // Walk the preview and verify every ` LINE:content` line matches what - // the file now has at that line number. - const newFileLines = after.split("\n"); - for (const line of preview.preview.split("\n")) { - if (!line.startsWith(" ")) continue; - // Skip context-elision markers ("...") which carry no real file content. - if (line.endsWith(`${outputSep}...`)) continue; - const match = new RegExp(`^\\s(\\d+)${outputSepRe}(.*)$`).exec(line); - expect(match).not.toBeNull(); - if (!match) continue; - const lineNum = Number(match[1]); - const content = match[2]; - expect(newFileLines[lineNum - 1]).toBe(content); - } - }); - - it("emits + and - lines with bare line numbers", () => { - const before = "alpha\nbeta\ngamma\n"; - const after = "alpha\nDELTA\nEPSILON\ngamma\n"; - const { diff } = generateDiffString(before, after); - const preview = buildCompactHashlineDiffPreview(diff); - - const additions = preview.preview.split("\n").filter(line => line.startsWith("+")); - expect(additions).toEqual([`+2${outputSep}DELTA`, `+3${outputSep}EPSILON`]); - - const removals = preview.preview.split("\n").filter(line => line.startsWith("-")); - expect(removals).toEqual([`-2${outputSep}beta`]); - }); -}); describe("hashline — anchor-stale recovery via read snapshot cache", () => { it("recovers when the file was modified out-of-band after a read", async () => { @@ -921,35 +338,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { }); }); - it("returns null from tryRecoverHashlineWithCache when applyPatch cannot land", () => { - const cache = new FileReadCache(); - const fakePath = "/tmp/__hashline-recovery-applypatch__.ts"; - const snapshotText = "alpha\nbeta\ngamma\ndelta\nepsilon"; - const snapshotTag = recordFullSnapshot(cache, fakePath, snapshotText); - // Live file is completely different — patch context cannot match even - // with fuzz tolerance. - const currentText = "totally\nunrelated\ncontent\nhere\nnow\n"; - const edits = parseHashline(`${sameLineRange(tag(2, "beta"))}\n${repl("BETA-MODEL")}`).edits; - - const recovered = tryRecoverHashlineWithCache({ - cache, - absolutePath: fakePath, - currentText, - edits, - tag: snapshotTag, - }); - expect(recovered).toBeNull(); - }); - - it("isolates caches across sessions", () => { - const a = new FileReadCache(); - const b = new FileReadCache(); - const fakePath = "/tmp/__hashline-cache-isolation__.ts"; - a.record(fakePath, "x\ny\nz\n"); - expect(a.head(fakePath)).not.toBeNull(); - expect(b.head(fakePath)).toBeNull(); - }); it("captures the post-edit result so the next edit can recover from anchors against it", async () => { await withTempDir(async tempDir => { @@ -1023,163 +412,8 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { }); }); - it("recovers from an older in-session snapshot even if the current file advanced again", () => { - const cache = new FileReadCache(); - const fakePath = "/tmp/__hashline-cache-ring-recovery__.ts"; - const v0Text = "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; - const v1Text = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; - const currentText = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nTRAILER\n"; - const v0Tag = recordFullSnapshot(cache, fakePath, v0Text); - recordFullSnapshot(cache, fakePath, v1Text); - - const recovered = tryRecoverHashlineWithCache({ - cache, - absolutePath: canonicalSnapshotKey(fakePath), - currentText, - tag: v0Tag, - edits: parseHashline(`replace 10..10:\n${repl("L10-EDITED")}`).edits, - }); - - expect(recovered).not.toBeNull(); - expect(recovered?.lines).toContain("L10-EDITED"); - }); - - it("retains older versions per path so stale tags still resolve", () => { - const cache = new FileReadCache(); - const fakePath = "/tmp/__hashline-cache-history__.ts"; - const oneTag = recordFullSnapshot(cache, fakePath, "one\n"); - const twoTag = recordFullSnapshot(cache, fakePath, "two\n"); - recordFullSnapshot(cache, fakePath, "three\n"); - expect(snapshotHead(cache, fakePath)?.text).toBe("three\n"); - expect(snapshotByHash(cache, fakePath, oneTag)?.text).toBe("one\n"); - expect(snapshotByHash(cache, fakePath, twoTag)?.text).toBe("two\n"); - }); - it("evicts the least-recently-used path beyond the LRU cap", () => { - const cache = new FileReadCache({ maxPaths: 4 }); - for (let i = 0; i < 6; i++) { - recordFullSnapshot(cache, `/tmp/file-${i}.ts`, `x${i}\n`); - } - // The two oldest paths aged out; the four most-recent survive. - expect(snapshotHead(cache, "/tmp/file-0.ts")).toBeNull(); - expect(snapshotHead(cache, "/tmp/file-1.ts")).toBeNull(); - expect(snapshotHead(cache, "/tmp/file-2.ts")?.text).toBe("x2\n"); - expect(snapshotHead(cache, "/tmp/file-5.ts")?.text).toBe("x5\n"); - }); }); -describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => { - const sentinel = "*** Abort"; - it("parser breaks at *** Abort silently (no warning)", () => { - const diff = [ - `insert after ${tag(1, "alpha")}:`, - repl("HELLO"), - sentinel, - `insert after ${tag(99, "junk")}:`, - repl("never"), - ].join("\n"); - const { edits, warnings } = parseHashline(diff); - expect(edits).toHaveLength(1); - expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); - // The "*** Abort" marker terminates parsing but no longer surfaces a - // warning: by the time the marker arrives the stream is already gone - // and the prior wording ("truncated mid-call") was speculative. - expect(warnings).toEqual([]); - }); - it("inserted sentinel from harmony-leak truncation: ops above are preserved", () => { - // Mirrors the exact shape harmony-leak emits inside a single section. - const diff = `insert after ${tag(1, "alpha")}:\n${repl("KEPT")}\n*** Abort\n`; - const { edits, warnings } = parseHashline(diff); - expect(edits).toHaveLength(1); - expect(edits[0]).toMatchObject({ text: "KEPT" }); - expect(warnings).toEqual([]); - }); - - it("splitter respects *** Abort like *** End Patch", () => { - const input = [ - `[a.ts]`, - `insert after ${tag(1, "alpha")}:`, - repl("a-payload"), - sentinel, - `[b.ts]`, - `insert after ${tag(1, "beta")}:`, - repl("never-emitted"), - ].join("\n"); - const sections = splitHashlineInputs(input); - expect(sections).toHaveLength(1); - expect(sections[0].path).toBe("a.ts"); - expect(sections[0].diff.includes("never-emitted")).toBe(false); - }); - - it("clean input without sentinel produces no warning", () => { - const diff = `insert after ${tag(1, "alpha")}:\n${repl("PAYLOAD")}\n`; - const { warnings } = parseHashline(diff); - expect(warnings).toEqual([]); - }); -}); - -describe("hashline parser — delete and empty-block semantics", () => { - it("inline delete deletes a single line", () => { - const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`[a.ts]\ndelete 2\n`); - expect(applyDiff(text, diff)).toBe("line1\nline3\n"); - }); - - it("inline delete deletes the range", () => { - const text = "line1\nline2\nline3\nline4\n"; - const { diff } = splitHashlineInput(`[a.ts]\ndelete 2..3\n`); - expect(applyDiff(text, diff)).toBe("line1\nline4\n"); - }); - - it("empty replace removes the range", () => { - const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`[a.ts]\nreplace 2..2:\n`); - expect(applyDiff(text, diff)).toBe("line1\nline3\n"); - }); - - it("`2..2=replacement` (old format) parses as orphan body, not as inline payload", () => { - const { diff } = splitHashlineInput(`[a.ts]\n2..2=replacement\n`); - expect(() => parseHashline(diff)).toThrow(/payload line has no preceding hunk header/); - }); - - it("explicit empty literal rows insert blank lines when the anchor is repeated", () => { - const text = "line1\nline2\nline3\n"; - const aboveDiff = splitHashlineInput(`[a.ts]\ninsert before 2:\n${repl("")}\n`).diff; - expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n"); - - const belowDiff = splitHashlineInput(`[a.ts]\ninsert after 2:\n${repl("")}\n`).diff; - expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n"); - }); -}); - -describe("hashline parser — explicit blank payload rows", () => { - it("raw blank lines between ops are ignored", () => { - const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); - }); - - it("empty replacement payload rows are appended as blank payload lines", () => { - const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n"); - }); - - it("`replace N..N:` followed by two empty replace rows replaces the line with two blanks", () => { - const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n"); - }); - - it("empty replace row inside payload between two content lines is preserved", () => { - const text = "a\nb\nc\n"; - const ops = `[a.ts]\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; - const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n"); - }); -}); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index d8c6c0b1c..401815e80 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +### Added + +- Added `maxAddedRunContext` option to control how many added lines are shown at each side of collapsed inserted runs, with `maxUnchangedRun` kept as a backward-compatible alias + +### Changed + +- Changed `buildCompactDiffPreview` to omit removed lines from the preview while preserving removal counts for offset tracking +- Changed `buildCompactDiffPreview` to collapse long contiguous added runs with a `+…` marker, keeping only the first and last `maxAddedRunContext` lines visible + +### Fixed + +- Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a `+N…` elision marker. +- Fixed `buildCompactDiffPreview` to drop context-gap placeholder rows (`...`/`…`) from the model-facing preview, relying on the jump in emitted line numbers to convey the elision. The placeholder was byte-identical to a genuine context line whose text is `...`, and wasted a row; the diff producer still emits the marker for the human-facing TUI diff. + ## [15.10.3] - 2026-06-08 ### Added diff --git a/packages/hashline/src/diff-preview.ts b/packages/hashline/src/diff-preview.ts index fdccb238c..e604c4cf6 100644 --- a/packages/hashline/src/diff-preview.ts +++ b/packages/hashline/src/diff-preview.ts @@ -1,49 +1,114 @@ /** * Re-number a unified diff that uses the `+|content` / * `-|content` / ` |content` line format into a compact - * preview that anchors every line to its post-edit position. Added lines, - * removed lines, and context lines all end up with a hashline-style anchor - * so a follow-up edit can reuse them directly. + * current-file preview. Removed lines are counted for stats and post-edit + * offset tracking, but omitted from the preview. Added and context lines are + * anchored to their post-edit positions so a follow-up edit can reuse visible + * concrete lines directly. Long contiguous added runs are summarized with a + * `+...` marker instead of echoing every inserted line. * * This is intentionally decoupled from the diff producer: anything that * emits the `|` shape works. */ import type { CompactDiffOptions, CompactDiffPreview } from "./types"; -export function buildCompactDiffPreview(diff: string, _options: CompactDiffOptions = {}): CompactDiffPreview { +const DEFAULT_ADDED_RUN_CONTEXT_LINES = 2; + +interface ParsedDiffLine { + kind: "+" | "-" | " "; + lineNumber: number; + content: string; +} + +interface AddedPreviewLine { + lineNumber: number; + text: string; +} + +function normalizeAddedRunContext(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_ADDED_RUN_CONTEXT_LINES; + return Math.max(1, Math.trunc(value)); +} + +function parseNumberedDiffLine(line: string): ParsedDiffLine | undefined { + const kind = line[0]; + if (kind !== "+" && kind !== "-" && kind !== " ") return undefined; + + const body = line.slice(1); + const sep = body.indexOf("|"); + if (sep === -1) return undefined; + + const lineNumber = Number.parseInt(body.slice(0, sep), 10); + if (!Number.isFinite(lineNumber)) return undefined; + + return { kind, lineNumber, content: body.slice(sep + 1) }; +} + +function appendAddedRun(output: string[], run: AddedPreviewLine[], edgeLines: number): void { + if (run.length === 0) return; + + const collapseThreshold = edgeLines * 2 + 1; + if (run.length <= collapseThreshold) { + for (const line of run) output.push(line.text); + return; + } + + for (let i = 0; i < edgeLines; i++) output.push(run[i].text); + output.push(`+${run[edgeLines].lineNumber}…`); + for (let i = run.length - edgeLines; i < run.length; i++) output.push(run[i].text); +} + +export function buildCompactDiffPreview(diff: string, options: CompactDiffOptions = {}): CompactDiffPreview { const lines = diff.length === 0 ? [] : diff.split("\n"); + const addedRunContext = normalizeAddedRunContext(options.maxAddedRunContext ?? options.maxUnchangedRun); let addedLines = 0; let removedLines = 0; + const formatted: string[] = []; + const addedRun: AddedPreviewLine[] = []; + + const flushAddedRun = (): void => { + appendAddedRun(formatted, addedRun, addedRunContext); + addedRun.length = 0; + }; // External diff producers number `+` lines with the post-edit line number, // `-` lines with the pre-edit line number, and context lines with the // pre-edit line number. To emit fresh line numbers usable for follow-up // edits, convert context-line numbers to post-edit positions by tracking // the running offset (added so far - removed so far) as we walk the diff. - const formatted = lines.map(line => { - const kind = line[0]; - if (kind !== "+" && kind !== "-" && kind !== " ") return line; + for (const line of lines) { + const parsed = parseNumberedDiffLine(line); + if (!parsed) { + flushAddedRun(); + formatted.push(line); + continue; + } - const body = line.slice(1); - const sep = body.indexOf("|"); - if (sep === -1) return line; - - const lineNumber = Number.parseInt(body.slice(0, sep), 10); - const content = body.slice(sep + 1); - - switch (kind) { - case "+": + switch (parsed.kind) { + case "+": { addedLines++; - return `+${lineNumber}:${content}`; + addedRun.push({ lineNumber: parsed.lineNumber, text: `+${parsed.lineNumber}:${parsed.content}` }); + break; + } case "-": + flushAddedRun(); removedLines++; - return `-${lineNumber}:${content}`; + break; default: { - const newLineNumber = lineNumber + addedLines - removedLines; - return ` ${newLineNumber}:${content}`; + flushAddedRun(); + // Context-gap placeholders (`...`/`…`) carry no real content and are + // byte-identical to a genuine context line whose text is "...". Drop + // them from the model-facing preview; the jump in emitted line + // numbers conveys the elision (matching how the diff producer already + // omits leading/trailing skips). + if (parsed.content === "..." || parsed.content === "…") break; + const newLineNumber = parsed.lineNumber + addedLines - removedLines; + formatted.push(` ${newLineNumber}:${parsed.content}`); + break; } } - }); + } + flushAddedRun(); return { preview: formatted.join("\n"), addedLines, removedLines }; } diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index 23c431211..9f1720e38 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -103,9 +103,11 @@ export interface CompactDiffPreview { removedLines: number; } -/** Optional knobs for {@link buildCompactDiffPreview}. Reserved for future use. */ +/** Optional knobs for {@link buildCompactDiffPreview}. */ export interface CompactDiffOptions { - /** Maximum entries kept on each side of an unchanged-context truncation (default 2). */ + /** Added lines kept on each side of a long added-run elision (default 2). */ + maxAddedRunContext?: number; + /** Back-compat alias for {@link maxAddedRunContext}. */ maxUnchangedRun?: number; } diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts new file mode 100644 index 000000000..5cefba09b --- /dev/null +++ b/packages/hashline/test/core-contracts.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "bun:test"; +import { + applyEdits, + detectLineEnding, + formatHashlineHeader, + InMemoryFilesystem, + InMemorySnapshotStore, + Patch, + Patcher, + parsePatch, + Recovery, + type Edit, + type PatchSection, + type SplitOptions, +} from "@oh-my-pi/hashline"; + +const repl = (text: string): string => `+${text}`; + +function tag(line: number): string { + return `${line}`; +} + +function sameLineRange(anchor: string): string { + return `replace ${anchor}..${anchor}:`; +} + +function applyDiff(content: string, diff: string): string { + return applyEdits(content, parsePatch(diff).edits).text; +} + +interface SectionView { + path: string; + fileHash?: string; + diff: string; +} + +function toSectionView(section: PatchSection): SectionView { + return section.fileHash !== undefined + ? { path: section.path, fileHash: section.fileHash, diff: section.diff } + : { path: section.path, diff: section.diff }; +} + +function splitHashlineInput(input: string, options: SplitOptions = {}): SectionView { + return toSectionView(Patch.parseSingle(input, options)); +} + +function splitHashlineInputs(input: string, options: SplitOptions = {}): SectionView[] { + return Patch.parse(input, options).sections.map(toSectionView); +} + +function tryRecoverHashline(args: { + cache: InMemorySnapshotStore; + path: string; + currentText: string; + tag: string; + edits: readonly Edit[]; +}): { text: string; firstChangedLine: number | undefined; warnings: string[] } | null { + return new Recovery(args.cache).tryRecover({ + path: args.path, + currentText: args.currentText, + fileHash: args.tag, + edits: args.edits, + }); +} + +class BlockingFilesystem extends InMemoryFilesystem { + #blocked = new Set(); + + constructor(initial: Iterable, blocked: Iterable) { + super(initial); + for (const filePath of blocked) this.#blocked.add(filePath); + } + + async preflightWrite(filePath: string): Promise { + if (this.#blocked.has(filePath)) throw new Error(`blocked write: ${filePath}`); + } +} + +describe("hashline normalization", () => { + it("preserves the first newline style when restoring mixed-ending files", () => { + expect(detectLineEnding("a\r\nb\nc")).toBe("\r\n"); + expect(detectLineEnding("a\nb\r\nc")).toBe("\n"); + }); +}); + +describe("hashline parser — range-anchor contracts", () => { + const content = "aaa\nbbb\nccc"; + + it("keeps parsed sections reusable across target snapshots", () => { + const section = Patch.parseSingle(["[a.ts]", `insert after ${tag(2)}:`, repl("tail")].join("\n")); + + expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail"); + expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); + }); + + it("applies replace/delete/insert operations against concrete anchors", () => { + const diff = [ + `insert before ${tag(2)}:`, + repl("before b"), + `insert after ${tag(2)}:`, + repl("after b"), + "insert head:", + repl("top"), + "insert tail:", + repl("tail"), + ].join("\n"); + expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); + expect(applyDiff(content, `delete ${tag(2)}`)).toBe("aaa\nccc"); + expect(applyDiff(content, `delete ${tag(2)}..${tag(3)}`)).toBe("aaa"); + expect(applyDiff(content, `${sameLineRange(tag(2))}\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc"); + }); + + it("inserts after the final line without falling off the file", () => { + expect(applyDiff(content, `insert after ${tag(3)}:\n${repl("tail")}`)).toBe("aaa\nbbb\nccc\ntail"); + }); + + it("preserves whitespace-bearing and sigil-leading payload exactly", () => { + const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; + expect(applyDiff(content, `insert after ${tag(2)}:\n${repl(payload)}`)).toBe(`aaa\nbbb\n${payload}\nccc`); + expect(applyDiff(content, `${sameLineRange(tag(2))}\n${repl("|literal")}\n${repl("^literal")}\n${repl("↓literal")}`)).toBe( + "aaa\n|literal\n^literal\n↓literal\nccc", + ); + }); + + it("strips copied read-output prefixes only inside pasted bare body rows", () => { + const diff = `replace ${tag(2)}..${tag(4)}:\n${repl("line one")}\n${tag(3)}:line two`; + const { edits, warnings } = parsePatch(diff); + expect(applyEdits("aaa\nbbb\nccc\nddd\neee", edits).text).toBe("aaa\nline one\nline two\neee"); + expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); + }); + + it("rejects overlapping replacement ranges", () => { + const diff = `replace ${tag(2)}..${tag(4)}:\n${repl("NEW1")}\nreplace ${tag(3)}..${tag(5)}:\n${repl("NEW2")}`; + expect(() => parsePatch(diff).edits).toThrow(/anchor line 3 is already targeted by another hunk on line 1/); + }); + + it("rejects obsolete line-hash anchors and applies line-number anchors without per-anchor hashes", () => { + expect(() => parsePatch(`2ab:\n${repl("BBB")}`).edits).toThrow(/payload line has no preceding/); + expect(applyDiff(content, `${sameLineRange(tag(2))}\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc"); + }); +}); + +describe("hashline input splitter", () => { + it("extracts path, snapshot tag, and diff body from bracket headers", () => { + const input = [`[src/foo.ts#1A2B]`, `${sameLineRange(tag(2))}`, repl("BBB")].join("\n"); + expect(splitHashlineInput(input)).toEqual({ + path: "src/foo.ts", + fileHash: "1A2B", + diff: `${sameLineRange(tag(2))}\n${repl("BBB")}`, + }); + }); + + it("normalizes leading blanks, cwd-relative paths, and explicit fallback paths", () => { + expect(splitHashlineInput(`\n[foo.ts]\ninsert head:\n${repl("x")}`)).toEqual({ + path: "foo.ts", + diff: `insert head:\n${repl("x")}`, + }); + + const cwd = process.cwd(); + const absolute = `${cwd}/src/foo.ts`; + expect(splitHashlineInput(`[${absolute}]\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); + expect(splitHashlineInput(`insert head:\n${repl("x")}`, { path: "a.ts" })).toEqual({ + path: "a.ts", + diff: `insert head:\n${repl("x")}`, + }); + expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/); + }); + + it("splits multiple sections and drops a trailing header without operations", () => { + const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]", "insert tail:", repl("b")].join("\n"); + expect(splitHashlineInputs(input)).toEqual([ + { path: "a.ts", diff: `insert head:\n${repl("a")}` }, + { path: "b.ts", diff: `insert tail:\n${repl("b")}` }, + ]); + expect(splitHashlineInputs(["[a.ts]", "insert head:", repl("a"), "[b.ts]"].join("\n"))).toEqual([ + { path: "a.ts", diff: `insert head:\n${repl("a")}` }, + ]); + }); + + it("rejects unified-diff hunk headers on the first line", () => { + const input = ["@@ -1,3 +1,3 @@", "insert head:", repl("x")].join("\n"); + expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/); + }); +}); + +describe("Patcher preflight", () => { + it("preflights write policy for every section before committing a batch", async () => { + const fixture = new BlockingFilesystem( + [ + ["a.ts", "aaa\n"], + ["b.ts", "bbb\n"], + ], + ["b.ts"], + ); + const snapshots = new InMemorySnapshotStore(); + const aTag = snapshots.record("a.ts", "aaa\n"); + const bTag = snapshots.record("b.ts", "bbb\n"); + const input = [ + formatHashlineHeader("a.ts", aTag), + `${sameLineRange(tag(1))}`, + repl("AAA"), + formatHashlineHeader("b.ts", bTag), + `${sameLineRange(tag(1))}`, + repl("BBB"), + ].join("\n"); + + try { + await new Patcher({ fs: fixture, snapshots }).apply(Patch.parse(input)); + throw new Error("expected blocked write"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/blocked write: b\.ts/); + } + expect(fixture.get("a.ts")).toBe("aaa\n"); + expect(fixture.get("b.ts")).toBe("bbb\n"); + }); +}); + +describe("Recovery", () => { + it("returns null when neither patch recovery nor replay can land", () => { + const cache = new InMemorySnapshotStore(); + const filePath = "/tmp/__hashline-recovery-applypatch__.ts"; + const snapshotText = "alpha\nbeta\ngamma\ndelta\nepsilon"; + const snapshotTag = cache.record(filePath, snapshotText); + + const recovered = tryRecoverHashline({ + cache, + path: filePath, + currentText: "totally\nunrelated\ncontent\nhere\nnow\n", + tag: snapshotTag, + edits: parsePatch(`${sameLineRange(tag(2))}\n${repl("BETA-MODEL")}`).edits, + }); + expect(recovered).toBeNull(); + }); + + it("recovers from an older in-session snapshot after the current file advanced", () => { + const cache = new InMemorySnapshotStore(); + const filePath = "/tmp/__hashline-cache-ring-recovery__.ts"; + const v0Text = "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; + const v1Text = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n"; + const currentText = "L1\nL2-EDITED\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nTRAILER\n"; + + const v0Tag = cache.record(filePath, v0Text); + cache.record(filePath, v1Text); + const recovered = tryRecoverHashline({ + cache, + path: filePath, + currentText, + tag: v0Tag, + edits: parsePatch(`replace 10..10:\n${repl("L10-EDITED")}`).edits, + }); + + expect(recovered).not.toBeNull(); + expect(recovered?.text).toContain("L10-EDITED"); + }); +}); + +describe("hashline abort sentinel", () => { + const sentinel = "*** Abort"; + + it("terminates parsing without surfacing a warning", () => { + const diff = [`insert after ${tag(1)}:`, repl("HELLO"), sentinel, `insert after ${tag(99)}:`, repl("never")].join("\n"); + const { edits, warnings } = parsePatch(diff); + expect(edits).toHaveLength(1); + expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); + expect(warnings).toEqual([]); + }); + + it("stops the input splitter before later sections", () => { + const input = ["[a.ts]", `insert after ${tag(1)}:`, repl("a-payload"), sentinel, "[b.ts]", `insert after ${tag(1)}:`, repl("never")].join("\n"); + const sections = splitHashlineInputs(input); + expect(sections).toHaveLength(1); + expect(sections[0].path).toBe("a.ts"); + expect(sections[0].diff.includes("never")).toBe(false); + }); +}); + +describe("hashline parser — delete and blank payload semantics", () => { + it("applies inline delete and empty replace operations", () => { + expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\ndelete 2\n").diff)).toBe("line1\nline3\n"); + expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\ndelete 2..3\n").diff)).toBe("line1\nline4\n"); + expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nreplace 2..2:\n").diff)).toBe("line1\nline3\n"); + }); + + it("treats old inline replacement syntax as orphan body", () => { + const { diff } = splitHashlineInput("[a.ts]\n2..2=replacement\n"); + expect(() => parsePatch(diff)).toThrow(/payload line has no preceding hunk header/); + }); + + it("preserves explicit blank replacement rows", () => { + const text = "a\nb\nc\nd\ne\n"; + const ops = `[a.ts]\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`; + expect(applyDiff(text, splitHashlineInput(ops).diff)).toBe("a\n\n\nc\nD\ne\n"); + + const embedded = `[a.ts]\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; + expect(applyDiff("a\nb\nc\n", splitHashlineInput(embedded).diff)).toBe("a\nfirst\n\nsecond\nc\n"); + }); +}); diff --git a/packages/hashline/test/diff-preview.test.ts b/packages/hashline/test/diff-preview.test.ts new file mode 100644 index 000000000..7c6544914 --- /dev/null +++ b/packages/hashline/test/diff-preview.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { buildCompactDiffPreview } from "@oh-my-pi/hashline"; + +describe("buildCompactDiffPreview", () => { + it("renders current lines and omits removed content while preserving counts", () => { + const preview = buildCompactDiffPreview([" 1|alpha", "-2|beta", "+2|DELTA", "+3|EPSILON", " 3|gamma"].join("\n")); + + expect(preview).toEqual({ + preview: [" 1:alpha", "+2:DELTA", "+3:EPSILON", " 4:gamma"].join("\n"), + addedLines: 2, + removedLines: 1, + }); + }); + + + it("renumbers context lines against the post-edit file after range expansion", () => { + const diff = [ + " 1|a1", + " 2|a2", + "-3|a3", + "-4|a4", + "+3|X", + "+4|Y", + "+5|Z", + " 5|a5", + " 6|a6", + " 7|a7", + ].join("\n"); + + const preview = buildCompactDiffPreview(diff); + + expect(preview.preview.split("\n").filter(line => line.startsWith(" "))).toEqual([ + " 1:a1", + " 2:a2", + " 6:a5", + " 7:a6", + " 8:a7", + ]); + }); + it("collapses long contiguous added runs to head, marker, and tail", () => { + const diff = Array.from({ length: 7 }, (_, index) => `+${10 + index}|line ${index + 1}`).join("\n"); + + const preview = buildCompactDiffPreview(diff); + + expect(preview.preview).toBe(["+10:line 1", "+11:line 2", "+12…", "+15:line 6", "+16:line 7"].join("\n")); + expect(preview.addedLines).toBe(7); + expect(preview.removedLines).toBe(0); + }); + + it("drops context-gap placeholders and conveys elision via the line-number jump", () => { + const preview = buildCompactDiffPreview( + [" 2|line 2", " 3|line 3", " 4|...", " 49|line 49", "+50|inserted"].join("\n"), + ); + + expect(preview.preview).toBe([" 2:line 2", " 3:line 3", " 49:line 49", "+50:inserted"].join("\n")); + expect(preview.addedLines).toBe(1); + }); +}); From 0a5e1438e7abe789b8b89d92b7423bb0d9b51546 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:32:56 +0200 Subject: [PATCH 05/53] fix(ai): fixed Anthropic stream parser emitting truncated tool calls after reconnect - When a transport drop mid-tool-call is followed by a transparent reconnect, the orphaned tool block never receives its `content_block_stop` event and survives with seed `{}` arguments, causing invalid tool dispatches downstream. The parser now detects unterminated tool blocks at stream end and routes them through the existing error path instead of emitting them as completed calls. - Added test coverage for the spliced reconnect scenario to verify the parser surfaces an error and prevents bogus tool calls from being dispatched. --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/providers/anthropic.ts | 16 +++++ .../ai/test/anthropic-stream-envelope.test.ts | 63 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1ebaa4a4d..8f24ebd43 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the Anthropic stream parser shipping a truncated tool call as a completed turn. When a transport drop cut the SSE stream mid-`tool_use` and a transparent reconnect spliced a fresh message envelope onto the same stream, the duplicate `message_start` was deduped but the orphaned tool block — which never received its `content_block_stop` — survived in the assistant message with its seed `{}` (or partially-parsed) arguments. The terminal stop signal from the reconnect then let it flow through as a normal tool call, so e.g. a `read` dispatched with `{}` failed downstream validation (`path: expected string, received undefined`). The parser now treats any tool block left open at stream end as a truncated envelope and routes it through the existing retry/error path instead of emitting bogus arguments. + ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 80331dc09..f18a1bb7a 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1700,6 +1700,22 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( throw createAnthropicStreamEnvelopeError("stream ended before terminal stop signal"); } + // An open tool_use block — one that never received its + // `content_block_stop` — means the stream was truncated mid-tool-call. + // In practice this is a transport drop that a transparent reconnect + // splices back together: the reconnect's `message_start` is deduped + // above, yet the orphaned block survives with its seed `{}` (or a + // partial-parse) arguments. Emitting it would dispatch a tool call the + // model never finished generating. Surface it as a truncated envelope so + // the existing retry/error path engages instead of shipping bogus args. + if ( + blocks.some( + block => block.type === "toolCall" && (block as { partialJson?: string }).partialJson !== undefined, + ) + ) { + throw createAnthropicStreamEnvelopeError("stream ended with an unterminated tool_use block"); + } + if (output.stopReason === "aborted" || output.stopReason === "error") { throw new Error(output.errorMessage ?? "An unknown error occurred"); } diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index dfe5d5c50..42ea9a377 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -199,6 +199,43 @@ function createMalformedToolUseEvents(): MockAnthropicEvent[] { ]; } +function createUnterminatedToolUseSplicedReconnectEvents(): MockAnthropicEvent[] { + return [ + { + type: "message_start", + message: { + id: "msg_tool_truncated", + usage: { input_tokens: 12, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }, + // Tool call begins streaming but the transport drops before any argument + // bytes — and before `content_block_stop` — arrive, so `arguments` is still + // the seed `{}`. + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tool_truncated", name: "lookup_weather", input: {} }, + }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "" } }, + // A transparent reconnect splices a fresh message envelope onto the same + // stream. The duplicate `message_start` is deduped, but the orphaned tool + // block above is never closed and the reconnect supplies the terminal stop. + { + type: "message_start", + message: { + id: "msg_reconnect", + usage: { input_tokens: 12, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { input_tokens: 12, output_tokens: 4, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + { type: "message_stop" }, + ]; +} + function countEvents(events: AssistantMessageEvent[], type: AssistantMessageEvent["type"]): number { return events.filter(event => event.type === type).length; } @@ -433,6 +470,32 @@ describe("anthropic stream envelope handling", () => { } expect("partialJson" in toolCall).toBe(false); }); + + it("surfaces an error instead of dispatching a tool call left open by a spliced reconnect", async () => { + let attempt = 0; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { + attempt += 1; + return createMockRequest(createUnterminatedToolUseSplicedReconnectEvents()) as never; + }); + + const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const events: AssistantMessageEvent[] = []; + for await (const event of stream) { + events.push(event); + } + const result = await stream.result(); + + // The tool call started streaming (replay-unsafe), so the truncated envelope + // cannot be retried — it must be surfaced as an error rather than emitting a + // completed turn whose tool call carries the seed `{}` arguments. + expect(attempt).toBe(1); + expect(countEvents(events, "toolcall_start")).toBe(1); + expect(countEvents(events, "toolcall_end")).toBe(0); + expect(countEvents(events, "done")).toBe(0); + expect(countEvents(events, "error")).toBe(1); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("unterminated tool_use block"); + }); it("parses raw SSE directly so unknown events do not fail Anthropic streams", async () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () => From d0d5b547ff33bc4d9be1de8f1cc4ffb66274bdc5 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:34:22 +0200 Subject: [PATCH 06/53] feat(editor): added atomic deletion and metadata display for image and paste markers - Added atomicTokenPattern support to treat image and paste markers as indivisible tokens, enabling atomic deletion on backspace and forward-delete instead of character-by-character removal. - Extended image marker format to include pixel dimensions [Image #N, WxH] and updated paste marker format to [Paste #N, +X lines] or [Paste #N, Y chars] with consistent comma-separated metadata. - Implemented accent styling for paste markers in editor rendering while preserving hyperlinks for image markers, improving visual distinction between marker types. - Refactored image reference handling to unified placeholder system supporting both image and paste markers with kind-aware rendering callbacks and regex patterns. --- packages/coding-agent/CHANGELOG.md | 11 ++ .../src/modes/components/custom-editor.ts | 18 ++- .../src/modes/components/user-message.ts | 9 +- .../src/modes/controllers/input-controller.ts | 16 ++- .../src/modes/image-references.ts | 20 ++-- .../coding-agent/test/hook-editor.test.ts | 4 +- .../components/user-message-keywords.test.ts | 21 ++++ .../test/modes/image-references.test.ts | 45 ++++++++ packages/tui/CHANGELOG.md | 8 ++ packages/tui/src/components/editor.ts | 109 ++++++++++++++---- packages/tui/test/editor.test.ts | 57 ++++++++- 11 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 packages/coding-agent/test/modes/image-references.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 650e74a91..d605c963a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] + +### Added + +- Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references +- Added pixel dimensions to pasted/loaded image placeholders in the prompt — the marker now reads `[Image #N, WxH]` (falling back to `[Image #N]` when the header can't be decoded). + +### Changed + +- Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded +- Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. + ### Fixed - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index f0742da2e..73e42aa46 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -1,6 +1,6 @@ import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui"; import type { AppKeybinding } from "../../config/keybindings"; -import { imageReferenceHyperlink, renderImageReferences } from "../image-references"; +import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; import { theme } from "../theme/theme"; @@ -76,16 +76,22 @@ export function extractBracketedImagePastePath(data: string): string | undefined export class CustomEditor extends Editor { imageLinks?: readonly (string | undefined)[]; + /** Treat image/paste markers as indivisible: a stray backspace deletes the whole token + * instead of corrupting `[Paste #1, +30 lines]` into plain text. */ + override atomicTokenPattern = PLACEHOLDER_REGEX; + /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make * pasted image placeholders visually distinct and hyperlink them once their blob file exists. */ decorateText = (text: string): string => - renderImageReferences(text, { + renderPlaceholders(text, { renderText: value => highlightMagicKeywords(value), - renderReference: (value, index) => - imageReferenceHyperlink(value, index, this.imageLinks, label => - theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`), - ), + renderReference: (value, kind, index) => + kind === "image" + ? imageReferenceHyperlink(value, index, this.imageLinks, label => + theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`), + ) + : theme.fg("accent", `\x1b[1m${value}\x1b[22m`), }); onEscape?: () => void; onClear?: () => void; diff --git a/packages/coding-agent/src/modes/components/user-message.ts b/packages/coding-agent/src/modes/components/user-message.ts index 6a1c8e81c..dc2614cf4 100644 --- a/packages/coding-agent/src/modes/components/user-message.ts +++ b/packages/coding-agent/src/modes/components/user-message.ts @@ -1,6 +1,6 @@ import { Container, Markdown } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import { imageReferenceHyperlink, renderImageReferences } from "../image-references"; +import { imageReferenceHyperlink, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; // OSC 133 shell integration: marks prompt zones for terminal multiplexers @@ -26,9 +26,12 @@ export class UserMessageComponent extends Container { : (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset)); const imageLabel = (value: string) => theme.fg("accent", `\x1b[1m\x1b[4m${value}\x1b[24m\x1b[22m`); const color = (value: string) => - renderImageReferences(value, { + renderPlaceholders(value, { renderText: baseText, - renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel), + renderReference: (label, kind, index) => + kind === "image" + ? imageReferenceHyperlink(label, index, imageLinks, imageLabel) + : theme.fg("accent", `\x1b[1m${label}\x1b[22m`), }); this.addChild( new Markdown(text, 1, 1, getMarkdownTheme(), { diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 937bd31b0..2a2aff7d3 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -729,10 +729,24 @@ export class InputController { this.ctx.pendingImageLinks.push(imageLink); this.ctx.editor.imageLinks = this.ctx.pendingImageLinks; const imageNum = this.ctx.pendingImages.length; - this.ctx.editor.insertText(`[Image #${imageNum}] `); + const dims = await this.#imageDimensions(imageData); + const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`; + this.ctx.editor.insertText(`${label} `); this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true }); } + /** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the + * header can't be decoded, so the caller falls back to a bare `[Image #N]`. */ + async #imageDimensions(image: ImageContent): Promise<{ width: number; height: number } | undefined> { + try { + const { width, height } = await new Bun.Image(Buffer.from(image.data, "base64")).metadata(); + if (width && height) return { width, height }; + } catch { + // Unknown/corrupt header — fall back to a bare label. + } + return undefined; + } + async #normalizeAndInsertPastedImage(image: ImageContent, unsupportedMessage: string): Promise { let imageData = await ensureSupportedImageInput(image); if (!imageData) { diff --git a/packages/coding-agent/src/modes/image-references.ts b/packages/coding-agent/src/modes/image-references.ts index 1d97e73ee..9dae460cf 100644 --- a/packages/coding-agent/src/modes/image-references.ts +++ b/packages/coding-agent/src/modes/image-references.ts @@ -3,30 +3,36 @@ import { logger } from "@oh-my-pi/pi-utils"; import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store"; import { fileHyperlink } from "../tui/hyperlink"; -const IMAGE_REFERENCE_REGEX = /\[Image #([1-9]\d*)\]/g; +/** Matches `[Image #N]`/`[Image #N, WxH]` and `[Paste #N, +X lines]`/`[Paste #N, Y chars]` tokens. + * Group 1 is the kind (`Image`/`Paste`), group 2 the 1-based index. The optional metadata + * tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */ +export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g; type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise; type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult; -export interface ImageReferenceRenderers { +export type PlaceholderKind = "image" | "paste"; + +export interface PlaceholderRenderers { renderText: (text: string) => string; - renderReference: (label: string, index: number) => string; + renderReference: (label: string, kind: PlaceholderKind, index: number) => string; } -export function renderImageReferences(text: string, renderers: ImageReferenceRenderers): string { - IMAGE_REFERENCE_REGEX.lastIndex = 0; +export function renderPlaceholders(text: string, renderers: PlaceholderRenderers): string { + PLACEHOLDER_REGEX.lastIndex = 0; let result = ""; let last = 0; let matched = false; for (;;) { - const match = IMAGE_REFERENCE_REGEX.exec(text); + const match = PLACEHOLDER_REGEX.exec(text); if (match === null) break; matched = true; if (match.index > last) { result += renderers.renderText(text.slice(last, match.index)); } - result += renderers.renderReference(match[0], Number(match[1])); + const kind: PlaceholderKind = match[1] === "Paste" ? "paste" : "image"; + result += renderers.renderReference(match[0], kind, Number(match[2])); last = match.index + match[0].length; } diff --git a/packages/coding-agent/test/hook-editor.test.ts b/packages/coding-agent/test/hook-editor.test.ts index e21752f43..961cd19f3 100644 --- a/packages/coding-agent/test/hook-editor.test.ts +++ b/packages/coding-agent/test/hook-editor.test.ts @@ -150,7 +150,7 @@ describe("HookEditorComponent default (hook) mode", () => { component.handleInput(`\x1b[200~${pasted}\x1b[201~`); - expect(renderText(component)).toContain("[paste #1 +11 lines]"); + expect(renderText(component)).toContain("[Paste #1, +11 lines]"); component.handleInput("\x1b[13;5u"); @@ -228,7 +228,7 @@ describe("HookEditorComponent prompt-style mode", () => { component.handleInput(`\x1b[200~${pasted}\x1b[201~`); - expect(renderText(component)).toContain("[paste #1 +11 lines]"); + expect(renderText(component)).toContain("[Paste #1, +11 lines]"); component.handleInput("\r"); diff --git a/packages/coding-agent/test/modes/components/user-message-keywords.test.ts b/packages/coding-agent/test/modes/components/user-message-keywords.test.ts index 4b962e0f3..b9c31ea53 100644 --- a/packages/coding-agent/test/modes/components/user-message-keywords.test.ts +++ b/packages/coding-agent/test/modes/components/user-message-keywords.test.ts @@ -107,4 +107,25 @@ describe("UserMessageComponent magic-keyword highlighting", () => { expect(raw).toContain("\x1b]8;id="); expect(raw).toContain("file:///tmp/abc123.png"); }); + + it("highlights paste markers in the draft editor without a hyperlink", () => { + const editor = new CustomEditor(getEditorTheme()); + editor.setText("see [Paste #1, +30 lines] now"); + const raw = editor.render(80).join("\n"); + expect(Bun.stripANSI(raw)).toContain("[Paste #1, +30 lines]"); + // The marker label is bold-wrapped (highlighted), unlike surrounding plain text. + expect(raw).toContain("\x1b[1m[Paste #1, +30 lines]"); + // Paste markers are not clickable, so no OSC-8 hyperlink is emitted (contrast with images). + expect(raw).not.toContain("\x1b]8;id="); + }); + + it("hyperlinks the metadata-bearing image marker format", () => { + const editor = new CustomEditor(getEditorTheme()); + editor.imageLinks = ["/tmp/omp-image.png"]; + editor.setText("see [Image #1, 800x600] now"); + const raw = editor.render(80).join("\n"); + expect(Bun.stripANSI(raw)).toContain("[Image #1, 800x600]"); + expect(raw).toContain("\x1b]8;id="); + expect(raw).toContain("file:///tmp/omp-image.png"); + }); }); diff --git a/packages/coding-agent/test/modes/image-references.test.ts b/packages/coding-agent/test/modes/image-references.test.ts new file mode 100644 index 000000000..00baba170 --- /dev/null +++ b/packages/coding-agent/test/modes/image-references.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { type PlaceholderKind, renderPlaceholders } from "../../src/modes/image-references"; + +function capture(text: string): { + out: string; + refs: Array<{ label: string; kind: PlaceholderKind; index: number }>; +} { + const refs: Array<{ label: string; kind: PlaceholderKind; index: number }> = []; + const out = renderPlaceholders(text, { + renderText: t => t, + renderReference: (label, kind, index) => { + refs.push({ label, kind, index }); + return `<${kind}:${index}>`; + }, + }); + return { out, refs }; +} + +describe("renderPlaceholders", () => { + it("classifies image and paste markers with their index and full label", () => { + const { out, refs } = capture("see [Image #1, 800x600] then [Paste #2, +30 lines] done"); + expect(refs).toEqual([ + { label: "[Image #1, 800x600]", kind: "image", index: 1 }, + { label: "[Paste #2, +30 lines]", kind: "paste", index: 2 }, + ]); + expect(out).toBe("see then done"); + }); + + it("matches the bare image form and the char-count paste form", () => { + expect(capture("[Image #3]").refs[0]).toMatchObject({ kind: "image", index: 3 }); + expect(capture("[Paste #4, 1500 chars]").refs[0]).toMatchObject({ kind: "paste", index: 4 }); + }); + + it("passes plain text straight through renderText with no references", () => { + const { out, refs } = capture("no markers here"); + expect(refs).toHaveLength(0); + expect(out).toBe("no markers here"); + }); + + it("does not treat an unterminated marker as a reference", () => { + // This is the half-eaten state atomic deletion prevents — it must render as plain text. + const { refs } = capture("[Paste #1, +30 lines"); + expect(refs).toHaveLength(0); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index faa86be6f..4542a6abb 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added `atomicTokenPattern` to `Editor`: when set to a global regex matching placeholder tokens such as `[Image #1, 800x600]` or `[Paste #2, +30 lines]`, a single backspace or forward-delete landing anywhere on a token removes the whole token instead of corrupting it into stray text. + +### Changed + +- Changed the large-paste placeholder label from `[paste #N +X lines]`/`[paste #N Y chars]` to `[Paste #N, +X lines]`/`[Paste #N, Y chars]`. + ### Fixed - Fixed pasting large text lagging the prompt for hundreds of milliseconds before the `[paste #N …]` placeholder appeared. `StdinBuffer` assembled bracketed pastes by re-concatenating and re-scanning the entire accumulated buffer on every incoming stdin chunk (`#pasteBuffer += chunk; indexOf(END)`), which is O(n²) in the paste size and dominates when the terminal/PTY delivers the paste in many small reads (SSH, tmux, slow hosts) — a 1 MB paste at 1 KB chunks cost ~33 ms and 5 MB ~740 ms. Chunks are now collected in an array and joined once when the end marker arrives, with a short overlap tail carried across chunk boundaries so a marker split between two reads is still detected without rescanning, making assembly O(n) (~1 ms for 5 MB). The `Editor` paste cleaner also dropped its `split("").filter().join("")` per-code-unit array allocation in favor of a single control-character regex pass (~20× faster on large pastes). diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 3f7a9fd76..7725f2a42 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -368,6 +368,15 @@ export class Editor implements Component, Focusable { #pastes: Map = new Map(); #pasteCounter: number = 0; + /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or + * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete + * landing on any character of a token removes the whole token instead of corrupting it into + * stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex` + * is never shared with the caller. */ + atomicTokenPattern: RegExp | undefined; + #atomicTokenSource: string | undefined; + #atomicTokenRe: RegExp | undefined; + // Bracketed paste mode buffering #pasteHandler = new BracketedPasteHandler(); @@ -1389,7 +1398,7 @@ export class Editor implements Component, Focusable { #expandPasteMarkers(text: string): string { let result = text; for (const [pasteId, pasteContent] of this.#pastes) { - const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); + const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g"); result = result.replace(markerRegex, () => pasteContent); } return result; @@ -1653,11 +1662,11 @@ export class Editor implements Component, Focusable { const pasteId = this.#pasteCounter; this.#pastes.set(pasteId, filteredText); - // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]" + // Insert marker like "[Paste #1, +123 lines]" or "[Paste #1, 1234 chars]" const marker = pastedLines.length > 10 - ? `[paste #${pasteId} +${pastedLines.length} lines]` - : `[paste #${pasteId} ${totalChars} chars]`; + ? `[Paste #${pasteId}, +${pastedLines.length} lines]` + : `[Paste #${pasteId}, ${totalChars} chars]`; this.#insertTextAtCursor(marker); return; @@ -1726,26 +1735,72 @@ export class Editor implements Component, Focusable { if (this.onSubmit) this.onSubmit(result); } + /** Resolve the compiled, global copy of `atomicTokenPattern`, rebuilt only when the source changes. */ + #getAtomicTokenRe(): RegExp | undefined { + const pattern = this.atomicTokenPattern; + if (pattern === undefined) { + this.#atomicTokenSource = undefined; + this.#atomicTokenRe = undefined; + return undefined; + } + if (pattern.source !== this.#atomicTokenSource) { + this.#atomicTokenSource = pattern.source; + this.#atomicTokenRe = new RegExp( + pattern.source, + pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`, + ); + } + return this.#atomicTokenRe; + } + + /** Find an atomic token on `line` whose span contains column `col` (`start <= col < end`). */ + #atomicTokenAt(line: string, col: number): { start: number; end: number } | undefined { + const re = this.#getAtomicTokenRe(); + if (re === undefined) return undefined; + re.lastIndex = 0; + for (;;) { + const match = re.exec(line); + if (match === null) break; + if (match[0].length === 0) { + re.lastIndex = match.index + 1; + continue; + } + const start = match.index; + const end = start + match[0].length; + if (col < start) break; + if (col < end) return { start, end }; + } + return undefined; + } + #handleBackspace(): void { this.#historyIndex = -1; // Exit history browsing mode this.#resetKillSequence(); this.#recordUndoState(); if (this.#state.cursorCol > 0) { - // Delete grapheme before cursor (handles emojis, combining characters, etc.) const line = this.#state.lines[this.#state.cursorLine] || ""; - const beforeCursor = line.slice(0, this.#state.cursorCol); + // An atomic placeholder token (image/paste marker) deletes as a unit, so a single + // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text. + const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1); + if (token !== undefined) { + this.#state.lines[this.#state.cursorLine] = line.slice(0, token.start) + line.slice(token.end); + this.#setCursorCol(token.start); + } else { + // Delete grapheme before cursor (handles emojis, combining characters, etc.) + const beforeCursor = line.slice(0, this.#state.cursorCol); - // Find the last grapheme in the text before cursor - const graphemes = [...segmenter.segment(beforeCursor)]; - const lastGrapheme = graphemes[graphemes.length - 1]; - const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + // Find the last grapheme in the text before cursor + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; - const before = line.slice(0, this.#state.cursorCol - graphemeLength); - const after = line.slice(this.#state.cursorCol); + const before = line.slice(0, this.#state.cursorCol - graphemeLength); + const after = line.slice(this.#state.cursorCol); - this.#state.lines[this.#state.cursorLine] = before + after; - this.#setCursorCol(this.#state.cursorCol - graphemeLength); + this.#state.lines[this.#state.cursorLine] = before + after; + this.#setCursorCol(this.#state.cursorCol - graphemeLength); + } } else if (this.#state.cursorLine > 0) { // Merge with previous line const currentLine = this.#state.lines[this.#state.cursorLine] || ""; @@ -2217,17 +2272,25 @@ export class Editor implements Component, Focusable { const currentLine = this.#state.lines[this.#state.cursorLine] || ""; if (this.#state.cursorCol < currentLine.length) { - // Delete grapheme at cursor position (handles emojis, combining characters, etc.) - const afterCursor = currentLine.slice(this.#state.cursorCol); + // An atomic placeholder token (image/paste marker) deletes as a unit. + const token = this.#atomicTokenAt(currentLine, this.#state.cursorCol); + if (token !== undefined) { + this.#state.lines[this.#state.cursorLine] = + currentLine.slice(0, token.start) + currentLine.slice(token.end); + this.#setCursorCol(token.start); + } else { + // Delete grapheme at cursor position (handles emojis, combining characters, etc.) + const afterCursor = currentLine.slice(this.#state.cursorCol); - // Find the first grapheme at cursor - const graphemes = [...segmenter.segment(afterCursor)]; - const firstGrapheme = graphemes[0]; - const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + // Find the first grapheme at cursor + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; - const before = currentLine.slice(0, this.#state.cursorCol); - const after = currentLine.slice(this.#state.cursorCol + graphemeLength); - this.#state.lines[this.#state.cursorLine] = before + after; + const before = currentLine.slice(0, this.#state.cursorCol); + const after = currentLine.slice(this.#state.cursorCol + graphemeLength); + this.#state.lines[this.#state.cursorLine] = before + after; + } } else if (this.#state.cursorLine < this.#state.lines.length - 1) { // At end of line - merge with next line const nextLine = this.#state.lines[this.#state.cursorLine + 1] || ""; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 29f7d1130..6431c6400 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2053,7 +2053,7 @@ describe("Editor component", () => { editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); - expect(editor.getText()).toMatch(/\[paste #\d+ \+\d+ lines\]/); + expect(editor.getText()).toMatch(/\[Paste #\d+, \+\d+ lines\]/); expect(editor.getExpandedText()).toBe(pastedText); }); @@ -2082,6 +2082,61 @@ describe("Editor component", () => { expect(submitted).toBe(pastedText); }); + + it("formats a large single-line paste as a char-count marker", () => { + const editor = new Editor(defaultEditorTheme); + const pastedText = "a".repeat(1500); + + editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); + + expect(editor.getText()).toMatch(/^\[Paste #\d+, 1500 chars\]$/); + expect(editor.getExpandedText()).toBe(pastedText); + }); + + it("deletes an entire paste marker on a single backspace when atomicTokenPattern is set", () => { + const editor = new Editor(defaultEditorTheme); + editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g; + const pastedText = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n"); + + editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); + expect(editor.getText()).toMatch(/^\[Paste #\d+, \+\d+ lines\]$/); + + // Cursor sits just after the marker; one backspace removes the whole token + // rather than corrupting it into stray `[Paste #1, +12 lines` text. + editor.handleInput("\x7f"); + expect(editor.getText()).toBe(""); + }); + + it("deletes an entire marker on forward-delete from its start", () => { + const editor = new Editor(defaultEditorTheme); + editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g; + editor.setText("[Image #1, 800x600]"); + + editor.handleInput("\x01"); // Ctrl+A → start of line + editor.handleInput("\x1b[3~"); // Delete (forward) + expect(editor.getText()).toBe(""); + }); + + it("removes only the marker and keeps surrounding text on atomic backspace", () => { + const editor = new Editor(defaultEditorTheme); + editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g; + editor.setText("a [Paste #1, +12 lines] b"); + + editor.handleInput("\x05"); // Ctrl+E → end of line + editor.handleInput("\x1b[D"); // left over 'b' + editor.handleInput("\x1b[D"); // left over ' ' — cursor now just after ']' + editor.handleInput("\x7f"); // Backspace deletes the whole marker + expect(editor.getText()).toBe("a b"); + }); + + it("deletes a marker character-by-character when no atomicTokenPattern is set", () => { + const editor = new Editor(defaultEditorTheme); + editor.setText("[Paste #1, +12 lines]"); + + editor.handleInput("\x05"); // Ctrl+E → end of line + editor.handleInput("\x7f"); // Backspace removes only the closing bracket + expect(editor.getText()).toBe("[Paste #1, +12 lines"); + }); }); describe("Korean NFC paste normalization", () => { From 2efd667916faed29644ac54a3966ee1db791c072 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:34:41 +0200 Subject: [PATCH 07/53] refactor(coding-agent): removed mid-skip placeholder from diff output - Removed the mid-skip placeholder line that was output when skipping lines in the middle of a diff, relying instead on the line number jump to convey the gap. Removed the corresponding placeholder-filtering logic from the hashline diff preview that was handling these placeholders. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/edit/diff.ts | 4 ++- .../coding-agent/test/core/hashline.test.ts | 21 ++---------- .../coding-agent/test/tools/edit-diff.test.ts | 5 ++- packages/hashline/CHANGELOG.md | 1 - packages/hashline/src/diff-preview.ts | 6 ---- packages/hashline/test/core-contracts.test.ts | 32 +++++++++++++------ packages/hashline/test/diff-preview.test.ts | 9 ------ 8 files changed, 33 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d605c963a..eddb0ce52 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,7 @@ - Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages - Fixed clipboard-pasted images being rejected when steering or following up during compaction. Instead of bailing with "Retry after it completes to send images", the message and its images are now queued via `queueCompactionMessage` and forwarded to the session (steer/follow-up/prompt) when the compaction queue flushes. - Fixed edit tool result previews to show only current-file lines and collapse long inserted blocks instead of echoing removed content. +- Fixed `generateDiffString` to omit the mid-skip `...` placeholder between two nearby edits, conveying the elided gap via the jump in line numbers instead (consistent with how leading/trailing context skips already render). The placeholder row was indistinguishable from a genuine `...` context line and wasted a row in compact previews. ## [15.10.4] - 2026-06-08 diff --git a/packages/coding-agent/src/edit/diff.ts b/packages/coding-agent/src/edit/diff.ts index 143b73795..de38be329 100644 --- a/packages/coding-agent/src/edit/diff.ts +++ b/packages/coding-agent/src/edit/diff.ts @@ -133,8 +133,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte newLineNum++; } + // Mid-skip placeholder is omitted too: the jump between the trailing + // number of the leading context and the leading number of the + // trailing context conveys the gap, just like leading/trailing skips. if (middleSkip > 0) { - output.push(formatNumberedDiffLine(" ", oldLineNum, "...")); oldLineNum += middleSkip; newLineNum += middleSkip; for (const line of linesToShow.slice(firstChunkLength)) { diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index eff611f4e..24c3036d7 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -3,8 +3,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { + type InMemorySnapshotStore as FileReadCache, formatHashlineHeader, - InMemorySnapshotStore as FileReadCache, MismatchError as HashlineMismatchError, } from "@oh-my-pi/hashline"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -15,9 +15,8 @@ import { getFileSnapshotStore as getFileReadCache, hashlineEditParamsSchema, } from "@oh-my-pi/pi-coding-agent/edit"; -import * as z from "zod/v4"; - import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import * as z from "zod/v4"; beforeAll(async () => { resetSettingsForTest(); @@ -49,7 +48,6 @@ function sameLineRange(anchor: string): string { return `replace ${anchor}..${anchor}:`; } - async function withTempDir(fn: (tempDir: string) => Promise): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-edit-")); try { @@ -84,12 +82,6 @@ function hashlineExecuteOptions( }; } - - - - - - describe("hashline executor", () => { it("rejects file creation and directs to the write tool", async () => { await withTempDir(async tempDir => { @@ -116,7 +108,6 @@ describe("hashline executor", () => { }); }); - it("emits an actionable no-op diagnostic when the payload matches the file byte-for-byte", async () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); @@ -274,7 +265,6 @@ describe("hashlineEditParamsSchema — payload shape", () => { }); }); - describe("hashline — anchor-stale recovery via read snapshot cache", () => { it("recovers when the file was modified out-of-band after a read", async () => { await withTempDir(async tempDir => { @@ -338,8 +328,6 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { }); }); - - it("captures the post-edit result so the next edit can recover from anchors against it", async () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); @@ -411,9 +399,4 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { expect(await Bun.file(filePath).text()).toBe(`${v1Lines.join("\n")}\n`); }); }); - - }); - - - diff --git a/packages/coding-agent/test/tools/edit-diff.test.ts b/packages/coding-agent/test/tools/edit-diff.test.ts index 8445e37c1..330d6c9eb 100644 --- a/packages/coding-agent/test/tools/edit-diff.test.ts +++ b/packages/coding-agent/test/tools/edit-diff.test.ts @@ -11,7 +11,10 @@ describe("generateDiffString", () => { const result = generateDiffString(oldLines.join("\n"), newLines.join("\n"), 2); const diffLines = result.diff.split("\n"); - expect(diffLines).toContain(" 5|..."); + // The mid-skip emits no placeholder row; the jump from the leading + // context (line 4) to the trailing context (line 16) conveys the gap. + expect(diffLines.some(line => line.endsWith("|...") || line.endsWith("|…"))).toBe(false); + expect(diffLines[diffLines.indexOf(" 4|line 4") + 1]).toBe(" 16|line 16"); expect(diffLines).toContain("-2|line 2"); expect(diffLines).toContain("+2|line 2 changed"); expect(diffLines).toContain("-18|line 18"); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 401815e80..6a39b08ed 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -14,7 +14,6 @@ ### Fixed - Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a `+N…` elision marker. -- Fixed `buildCompactDiffPreview` to drop context-gap placeholder rows (`...`/`…`) from the model-facing preview, relying on the jump in emitted line numbers to convey the elision. The placeholder was byte-identical to a genuine context line whose text is `...`, and wasted a row; the diff producer still emits the marker for the human-facing TUI diff. ## [15.10.3] - 2026-06-08 diff --git a/packages/hashline/src/diff-preview.ts b/packages/hashline/src/diff-preview.ts index e604c4cf6..be62c8f8b 100644 --- a/packages/hashline/src/diff-preview.ts +++ b/packages/hashline/src/diff-preview.ts @@ -96,12 +96,6 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption break; default: { flushAddedRun(); - // Context-gap placeholders (`...`/`…`) carry no real content and are - // byte-identical to a genuine context line whose text is "...". Drop - // them from the model-facing preview; the jump in emitted line - // numbers conveys the elision (matching how the diff producer already - // omits leading/trailing skips). - if (parsed.content === "..." || parsed.content === "…") break; const newLineNumber = parsed.lineNumber + addedLines - removedLines; formatted.push(` ${newLineNumber}:${parsed.content}`); break; diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts index 5cefba09b..bbd66d4ba 100644 --- a/packages/hashline/test/core-contracts.test.ts +++ b/packages/hashline/test/core-contracts.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it } from "bun:test"; import { applyEdits, detectLineEnding, + type Edit, formatHashlineHeader, InMemoryFilesystem, InMemorySnapshotStore, Patch, Patcher, + type PatchSection, parsePatch, Recovery, - type Edit, - type PatchSection, type SplitOptions, } from "@oh-my-pi/hashline"; @@ -117,9 +117,9 @@ describe("hashline parser — range-anchor contracts", () => { it("preserves whitespace-bearing and sigil-leading payload exactly", () => { const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; expect(applyDiff(content, `insert after ${tag(2)}:\n${repl(payload)}`)).toBe(`aaa\nbbb\n${payload}\nccc`); - expect(applyDiff(content, `${sameLineRange(tag(2))}\n${repl("|literal")}\n${repl("^literal")}\n${repl("↓literal")}`)).toBe( - "aaa\n|literal\n^literal\n↓literal\nccc", - ); + expect( + applyDiff(content, `${sameLineRange(tag(2))}\n${repl("|literal")}\n${repl("^literal")}\n${repl("↓literal")}`), + ).toBe("aaa\n|literal\n^literal\n↓literal\nccc"); }); it("strips copied read-output prefixes only inside pasted bare body rows", () => { @@ -259,7 +259,9 @@ describe("hashline abort sentinel", () => { const sentinel = "*** Abort"; it("terminates parsing without surfacing a warning", () => { - const diff = [`insert after ${tag(1)}:`, repl("HELLO"), sentinel, `insert after ${tag(99)}:`, repl("never")].join("\n"); + const diff = [`insert after ${tag(1)}:`, repl("HELLO"), sentinel, `insert after ${tag(99)}:`, repl("never")].join( + "\n", + ); const { edits, warnings } = parsePatch(diff); expect(edits).toHaveLength(1); expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); @@ -267,7 +269,15 @@ describe("hashline abort sentinel", () => { }); it("stops the input splitter before later sections", () => { - const input = ["[a.ts]", `insert after ${tag(1)}:`, repl("a-payload"), sentinel, "[b.ts]", `insert after ${tag(1)}:`, repl("never")].join("\n"); + const input = [ + "[a.ts]", + `insert after ${tag(1)}:`, + repl("a-payload"), + sentinel, + "[b.ts]", + `insert after ${tag(1)}:`, + repl("never"), + ].join("\n"); const sections = splitHashlineInputs(input); expect(sections).toHaveLength(1); expect(sections[0].path).toBe("a.ts"); @@ -278,8 +288,12 @@ describe("hashline abort sentinel", () => { describe("hashline parser — delete and blank payload semantics", () => { it("applies inline delete and empty replace operations", () => { expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\ndelete 2\n").diff)).toBe("line1\nline3\n"); - expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\ndelete 2..3\n").diff)).toBe("line1\nline4\n"); - expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nreplace 2..2:\n").diff)).toBe("line1\nline3\n"); + expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\ndelete 2..3\n").diff)).toBe( + "line1\nline4\n", + ); + expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nreplace 2..2:\n").diff)).toBe( + "line1\nline3\n", + ); }); it("treats old inline replacement syntax as orphan body", () => { diff --git a/packages/hashline/test/diff-preview.test.ts b/packages/hashline/test/diff-preview.test.ts index 7c6544914..babe6de24 100644 --- a/packages/hashline/test/diff-preview.test.ts +++ b/packages/hashline/test/diff-preview.test.ts @@ -46,13 +46,4 @@ describe("buildCompactDiffPreview", () => { expect(preview.addedLines).toBe(7); expect(preview.removedLines).toBe(0); }); - - it("drops context-gap placeholders and conveys elision via the line-number jump", () => { - const preview = buildCompactDiffPreview( - [" 2|line 2", " 3|line 3", " 4|...", " 49|line 49", "+50|inserted"].join("\n"), - ); - - expect(preview.preview).toBe([" 2:line 2", " 3:line 3", " 49:line 49", "+50:inserted"].join("\n")); - expect(preview.addedLines).toBe(1); - }); }); From 1f000575847d704511aefa883d997d1f3585766e Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:37:50 +0200 Subject: [PATCH 08/53] fix(tools): fixed browser helper timeout handling and stalled-op diagnostics - Tracked in-flight browser helpers with names and durations for timeout reporting. - Wrapped observe, screenshot, and extract in per-op deadlines capped at 20_000ms. - Updated timeout cancellation errors to list stalled helper names and elapsed time. --- packages/ai/src/providers/anthropic.ts | 3 +- packages/coding-agent/CHANGELOG.md | 5 + .../src/tools/browser/tab-worker.ts | 302 ++++++++++++------ .../test/tools/browser-op-tracking.test.ts | 34 ++ 4 files changed, 252 insertions(+), 92 deletions(-) create mode 100644 packages/coding-agent/test/tools/browser-op-tracking.test.ts diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index f18a1bb7a..20a5fad31 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1710,7 +1710,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( // the existing retry/error path engages instead of shipping bogus args. if ( blocks.some( - block => block.type === "toolCall" && (block as { partialJson?: string }).partialJson !== undefined, + block => + block.type === "toolCall" && (block as { partialJson?: string }).partialJson !== undefined, ) ) { throw createAnthropicStreamEnvelopeError("stream ended with an unterminated tool_use block"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eddb0ce52..ce715fbba 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,8 +12,13 @@ - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded - Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. +### Changed + +- Browser tool helpers (`tab.*`) are now individually tracked and time-bounded: when a `run` cell hits its budget, the timeout error names the still-running helper(s) and how long each has been stalled (e.g. `... (stalled on tab.screenshot({ selector: ".x" }) (29.9s))`) instead of the opaque `Browser code execution timed out after 30000ms`. Page-coupled helpers that should resolve quickly (`observe`, `screenshot`, `extract`) also fail fast with a named per-op error at `min(cellBudget, 20s)`, leaving budget for the rest of the cell, rather than silently consuming the whole budget. + ### Fixed +- Fixed `tab.screenshot({ selector })` hanging for the entire cell budget on continuously-animating pages (WebGL / `backdrop-filter` "glass" effects). The element-screenshot path no longer routes through puppeteer's `scrollIntoViewIfNeeded()`, whose `IntersectionObserver` promise can stall indefinitely under heavy rendering; it now does a single instant `scrollIntoView` and captures with `scrollIntoView: false` (relying on `captureBeyondViewport`), so off-screen elements are still captured without the stall. - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows - Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages - Fixed clipboard-pasted images being rejected when steering or following up during compaction. Instead of bailing with "Retry after it completes to send images", the message and its images are now queued via `queueCompactionMessage` and forwarded to the session (steer/follow-up/prompt) when the compaction queue flushes. diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index c1e2ad2ac..b5af3b6a3 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -8,6 +8,7 @@ import type { Browser, Dialog, ElementHandle, + ElementScreenshotOptions, HTTPResponse, KeyInput, Page, @@ -78,6 +79,14 @@ type DialogPolicy = "accept" | "dismiss"; type DragTarget = string | { readonly x: number; readonly y: number }; type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string }; +/** + * Per-op ceiling for puppeteer-internal helpers that should resolve quickly + * (`observe`, `screenshot`, `extract`). Kept below the default 30s cell budget so a + * single stalled helper fails fast with a named error and leaves budget for the rest + * of the cell. Effective cap is `min(cellBudget, QUICK_OP_TIMEOUT_MS)`. + */ +const QUICK_OP_TIMEOUT_MS = 20_000; + interface ScreenshotOptions { selector?: string; fullPage?: boolean; @@ -404,12 +413,36 @@ async function clickQueryHandlerText( ); } +export interface InflightOp { + label: string; + startedAt: number; +} + interface ActiveRun { id: string; ac: AbortController; displays: RunResultOk["displays"]; screenshots: ScreenshotResult[]; pendingTools: Map; + /** Helper invocations currently awaiting the page/network, keyed by op id. */ + inflight: Map; + opCounter: number; +} + +/** Human-readable label for a screenshot op, used in op tracking + timeout errors. */ +export function describeScreenshot(opts?: ScreenshotOptions): string { + if (opts?.selector) return `tab.screenshot({ selector: ${JSON.stringify(opts.selector)} })`; + if (opts?.fullPage) return "tab.screenshot({ fullPage: true })"; + return "tab.screenshot()"; +} + +/** Summarize still-running helpers (oldest first) so a cell timeout names what stalled. */ +export function describeInflight(inflight: Map): string { + const now = Date.now(); + return [...inflight.values()] + .sort((a, b) => a.startedAt - b.startedAt) + .map(op => `${op.label} (${((now - op.startedAt) / 1000).toFixed(1)}s)`) + .join(", "); } export class WorkerCore { @@ -560,13 +593,21 @@ export class WorkerCore { const signal = AbortSignal.any([timeoutSignal, ac.signal]); const displays: RunResultOk["displays"] = []; const screenshots: ScreenshotResult[] = []; - const active: ActiveRun = { id: msg.id, ac, displays, screenshots, pendingTools: new Map() }; + const active: ActiveRun = { + id: msg.id, + ac, + displays, + screenshots, + pendingTools: new Map(), + inflight: new Map(), + opCounter: 0, + }; this.#active = active; try { throwIfAborted(signal); const page = this.#requirePage(); const browser = this.#requireBrowser(); - const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots); + const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots, active); const runtime = this.#ensureRuntime(msg.session); runtime.setCwd(msg.session.cwd); runtime.setRunScope({ @@ -580,11 +621,16 @@ export class WorkerCore { }); const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers(); const onCancel = (): void => { - rejectCancel( - timeoutSignal.aborted - ? new ToolError(`Browser code execution timed out after ${msg.timeoutMs}ms`) - : new ToolAbortError(), - ); + if (timeoutSignal.aborted) { + const stalled = describeInflight(active.inflight); + rejectCancel( + new ToolError( + `Browser code execution timed out after ${msg.timeoutMs}ms${stalled ? ` (stalled on ${stalled})` : ""}`, + ), + ); + } else { + rejectCancel(new ToolAbortError()); + } // Cancel in-flight tool calls so user code's awaited proxies reject promptly. for (const pending of active.pendingTools.values()) { pending.reject(new ToolAbortError()); @@ -670,6 +716,39 @@ export class WorkerCore { else pending.reject(replyError(reply.error)); } + /** + * Wrap a tab helper so it (a) registers in the active run's in-flight map for + * timeout diagnostics and (b) honors an optional per-op deadline that fails fast + * with a named error instead of silently consuming the whole cell budget. Pass + * `Number.POSITIVE_INFINITY` for `perOpTimeoutMs` to bound the op only by the cell + * budget (used for `evaluate` running user code and for locator helpers that already + * carry puppeteer's own `.setTimeout(timeoutMs)`). + */ + async #runOp( + active: ActiveRun, + label: string, + cellSignal: AbortSignal, + perOpTimeoutMs: number, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const opId = active.opCounter++; + active.inflight.set(opId, { label, startedAt: Date.now() }); + const capped = Number.isFinite(perOpTimeoutMs) && perOpTimeoutMs > 0; + const opTimeout = capped ? AbortSignal.timeout(perOpTimeoutMs) : undefined; + const opSignal = opTimeout ? AbortSignal.any([cellSignal, opTimeout]) : cellSignal; + try { + return await fn(opSignal); + } catch (err) { + // Per-op deadline fired (not the cell budget, not an explicit abort) → named, actionable error. + if (opTimeout?.aborted && !cellSignal.aborted) { + throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms`); + } + throw err; + } finally { + active.inflight.delete(opId); + } + } + #createTabApi( name: string, timeoutMs: number, @@ -677,98 +756,125 @@ export class WorkerCore { session: SessionSnapshot, displays: RunResultOk["displays"], screenshots: ScreenshotResult[], + active: ActiveRun, ): TabApi { const page = this.#requirePage(); + const quickOpMs = Math.min(timeoutMs, QUICK_OP_TIMEOUT_MS); + const INF = Number.POSITIVE_INFINITY; + const op = (label: string, perOpMs: number, fn: (sig: AbortSignal) => Promise): Promise => + this.#runOp(active, label, signal, perOpMs, fn); return { name, page, signal, url: () => page.url(), - title: () => page.title(), - goto: async (url, opts) => { - this.#clearElementCache(); - await untilAborted(signal, () => + title: () => op("tab.title()", INF, sig => untilAborted(sig, () => page.title())), + goto: (url, opts) => + op(`tab.goto(${JSON.stringify(url)})`, INF, async sig => { + this.#clearElementCache(); // Default to "load" because dev servers with HMR/WS never reach networkidle. - page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }), - ); - }, - observe: opts => this.#collectObservation({ ...opts, signal }), - screenshot: async opts => await this.#captureScreenshot(session, displays, screenshots, signal, opts), - extract: async (format = "markdown") => { - const html = (await untilAborted(signal, () => page.content())) as string; - const result = await extractReadableFromHtml(html, page.url(), format); - if (!result) { - throw new ToolError(`tab.extract(${JSON.stringify(format)}) found no readable content on ${page.url()}`); - } - const content = format === "markdown" ? result.markdown : result.text; - if (!content) { - throw new ToolError( - `tab.extract(${JSON.stringify(format)}) produced empty ${format} content for ${page.url()}`, + await untilAborted(sig, () => + page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }), ); - } - return content; - }, - click: async selector => { - const resolved = normalizeSelector(selector); - if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, signal); - else await untilAborted(signal, () => page.locator(resolved).setTimeout(timeoutMs).click()); - }, - type: async (selector, text) => { - const handle = (await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), - )) as ElementHandle; - try { - await untilAborted(signal, () => handle.type(text, { delay: 0 })); - } finally { - await handle.dispose(); - } - }, - fill: async (selector, value) => { - await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value), - ); - }, - press: async (key, opts) => { - const selector = opts?.selector; - if (selector) await untilAborted(signal, () => page.focus(normalizeSelector(selector))); - await untilAborted(signal, () => page.keyboard.press(key)); - }, - scroll: async (deltaX, deltaY) => { - await untilAborted(signal, () => page.mouse.wheel({ deltaX, deltaY })); - }, - drag: async (from, to) => await this.#drag(from, to, signal), - waitFor: async selector => - (await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), - )) as ElementHandle, - evaluate: async (fn, ...args) => - (await untilAborted(signal, () => - typeof fn === "string" ? page.evaluate(fn) : page.evaluate(fn as (...a: unknown[]) => unknown, ...args), - )) as never, - scrollIntoView: async selector => { - const handle = (await untilAborted(signal, () => - page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), - )) as ElementHandle; - try { - await untilAborted(signal, () => - handle.evaluate(el => { - const target = el as unknown as { - scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void; - }; - target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); - }), - ); - } finally { - await handle.dispose().catch(() => undefined); - } - }, - select: async (selector, ...values) => await this.#select(selector, values, timeoutMs, signal), - uploadFile: async (selector, ...filePaths) => - await this.#uploadFile(selector, filePaths, timeoutMs, signal, session), - waitForUrl: async (pattern, opts) => await this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, signal), - waitForResponse: async (pattern, opts) => - await this.#waitForResponse(pattern, opts?.timeout ?? timeoutMs, signal), - id: async id => await this.#resolveCachedHandle(id), + }), + observe: opts => op("tab.observe()", quickOpMs, sig => this.#collectObservation({ ...opts, signal: sig })), + screenshot: opts => + op(describeScreenshot(opts), quickOpMs, sig => + this.#captureScreenshot(session, displays, screenshots, sig, opts), + ), + extract: (format = "markdown") => + op(`tab.extract(${JSON.stringify(format)})`, quickOpMs, async sig => { + const html = (await untilAborted(sig, () => page.content())) as string; + const result = await extractReadableFromHtml(html, page.url(), format); + if (!result) { + throw new ToolError( + `tab.extract(${JSON.stringify(format)}) found no readable content on ${page.url()}`, + ); + } + const content = format === "markdown" ? result.markdown : result.text; + if (!content) { + throw new ToolError( + `tab.extract(${JSON.stringify(format)}) produced empty ${format} content for ${page.url()}`, + ); + } + return content; + }), + click: selector => + op(`tab.click(${JSON.stringify(selector)})`, INF, async sig => { + const resolved = normalizeSelector(selector); + if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, sig); + else await untilAborted(sig, () => page.locator(resolved).setTimeout(timeoutMs).click()); + }), + type: (selector, text) => + op(`tab.type(${JSON.stringify(selector)})`, INF, async sig => { + const handle = (await untilAborted(sig, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle; + try { + await untilAborted(sig, () => handle.type(text, { delay: 0 })); + } finally { + await handle.dispose(); + } + }), + fill: (selector, value) => + op(`tab.fill(${JSON.stringify(selector)})`, INF, sig => + untilAborted(sig, () => page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value)), + ), + press: (key, opts) => + op(`tab.press(${JSON.stringify(key)})`, INF, async sig => { + const selector = opts?.selector; + if (selector) await untilAborted(sig, () => page.focus(normalizeSelector(selector))); + await untilAborted(sig, () => page.keyboard.press(key)); + }), + scroll: (deltaX, deltaY) => + op("tab.scroll()", INF, sig => untilAborted(sig, () => page.mouse.wheel({ deltaX, deltaY }))), + drag: (from, to) => op("tab.drag()", INF, sig => this.#drag(from, to, sig)), + waitFor: selector => + op( + `tab.waitFor(${JSON.stringify(selector)})`, + INF, + async sig => + (await untilAborted(sig, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle, + ), + evaluate: (fn, ...args) => + op("tab.evaluate()", INF, sig => + untilAborted(sig, () => + typeof fn === "string" + ? page.evaluate(fn) + : page.evaluate(fn as (...a: unknown[]) => unknown, ...args), + ), + ) as never, + scrollIntoView: selector => + op(`tab.scrollIntoView(${JSON.stringify(selector)})`, INF, async sig => { + const handle = (await untilAborted(sig, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle; + try { + await untilAborted(sig, () => + handle.evaluate(el => { + const target = el as unknown as { + scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void; + }; + target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); + }), + ); + } finally { + await handle.dispose().catch(() => undefined); + } + }), + select: (selector, ...values) => + op(`tab.select(${JSON.stringify(selector)})`, INF, sig => this.#select(selector, values, timeoutMs, sig)), + uploadFile: (selector, ...filePaths) => + op(`tab.uploadFile(${JSON.stringify(selector)})`, INF, sig => + this.#uploadFile(selector, filePaths, timeoutMs, sig, session), + ), + waitForUrl: (pattern, opts) => + op("tab.waitForUrl()", INF, sig => this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, sig)), + waitForResponse: (pattern, opts) => + op("tab.waitForResponse()", INF, sig => this.#waitForResponse(pattern, opts?.timeout ?? timeoutMs, sig)), + id: id => this.#resolveCachedHandle(id), }; } @@ -832,7 +938,21 @@ export class WorkerCore { )) as ElementHandle | null; if (!handle) throw new ToolError("Screenshot selector did not resolve to an element"); try { - buffer = (await untilAborted(signal, () => handle.screenshot({ type: "png" }))) as Buffer; + // Bring the element into view with a single instant scroll instead of puppeteer's + // scrollIntoViewIfNeeded(), whose IntersectionObserver promise can stall indefinitely + // on continuously-animating pages (WebGL / backdrop-filter "glass" effects). Best-effort. + await untilAborted(signal, () => + handle.evaluate(el => { + const target = el as unknown as { + scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void; + }; + target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); + }), + ).catch(() => undefined); + // scrollIntoView:false skips the same IntersectionObserver check inside screenshot(); + // captureBeyondViewport (puppeteer's default) still renders the clipped region. + const shotOpts: ElementScreenshotOptions = { type: "png", scrollIntoView: false }; + buffer = (await untilAborted(signal, () => handle.screenshot(shotOpts))) as Buffer; } finally { await handle.dispose().catch(() => undefined); } diff --git a/packages/coding-agent/test/tools/browser-op-tracking.test.ts b/packages/coding-agent/test/tools/browser-op-tracking.test.ts new file mode 100644 index 000000000..78830e3d7 --- /dev/null +++ b/packages/coding-agent/test/tools/browser-op-tracking.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { describeInflight, describeScreenshot, type InflightOp } from "../../src/tools/browser/tab-worker"; + +describe("browser op tracking — timeout diagnostics", () => { + it("labels a screenshot op by its distinguishing argument", () => { + expect(describeScreenshot({ selector: ".wb-paper-popover" })).toBe( + 'tab.screenshot({ selector: ".wb-paper-popover" })', + ); + expect(describeScreenshot({ fullPage: true })).toBe("tab.screenshot({ fullPage: true })"); + expect(describeScreenshot()).toBe("tab.screenshot()"); + expect(describeScreenshot({})).toBe("tab.screenshot()"); + }); + + it("names every still-running helper so a cell timeout is attributable", () => { + const now = Date.now(); + // Inserted newest-first to prove the summary sorts by start time, not insertion order. + const inflight = new Map([ + [1, { label: "tab.observe()", startedAt: now - 1_000 }], + [0, { label: 'tab.screenshot({ selector: ".x" })', startedAt: now - 3_000 }], + ]); + + const summary = describeInflight(inflight); + + // Oldest op (most likely the culprit) is listed first. + expect(summary.indexOf("tab.screenshot")).toBeLessThan(summary.indexOf("tab.observe")); + // Each op carries an elapsed-seconds annotation. + expect(summary).toMatch(/tab\.screenshot\(\{ selector: "\.x" \}\) \(\d+\.\d+s\)/); + expect(summary).toMatch(/tab\.observe\(\) \(\d+\.\d+s\)/); + }); + + it("returns an empty summary when nothing is in flight", () => { + expect(describeInflight(new Map())).toBe(""); + }); +}); From 660f620ed4632fa768a4cbc5f82ab65b4b6fac1c Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 13:41:03 +0200 Subject: [PATCH 09/53] =?UTF-8?q?refactor(hashline):=20simplified=20collap?= =?UTF-8?q?sed=20added-run=20marker=20to=20`+=E2=80=A6`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropped the first-hidden line number from the run marker. - Replaced the AddedPreviewLine struct with plain text strings. --- packages/hashline/CHANGELOG.md | 4 ++-- packages/hashline/src/diff-preview.ts | 21 ++++++++------------- packages/hashline/test/diff-preview.test.ts | 2 +- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 6a39b08ed..307af48cf 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -9,11 +9,11 @@ ### Changed - Changed `buildCompactDiffPreview` to omit removed lines from the preview while preserving removal counts for offset tracking -- Changed `buildCompactDiffPreview` to collapse long contiguous added runs with a `+…` marker, keeping only the first and last `maxAddedRunContext` lines visible +- Changed `buildCompactDiffPreview` to collapse long contiguous added runs with a bare `+…` marker, keeping only the first and last `maxAddedRunContext` lines visible (the surrounding line numbers convey how many were elided) ### Fixed -- Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a `+N…` elision marker. +- Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a bare `+…` elision marker. ## [15.10.3] - 2026-06-08 diff --git a/packages/hashline/src/diff-preview.ts b/packages/hashline/src/diff-preview.ts index be62c8f8b..951e8db91 100644 --- a/packages/hashline/src/diff-preview.ts +++ b/packages/hashline/src/diff-preview.ts @@ -5,7 +5,7 @@ * offset tracking, but omitted from the preview. Added and context lines are * anchored to their post-edit positions so a follow-up edit can reuse visible * concrete lines directly. Long contiguous added runs are summarized with a - * `+...` marker instead of echoing every inserted line. + * `+…` marker instead of echoing every inserted line. * * This is intentionally decoupled from the diff producer: anything that * emits the `|` shape works. @@ -20,11 +20,6 @@ interface ParsedDiffLine { content: string; } -interface AddedPreviewLine { - lineNumber: number; - text: string; -} - function normalizeAddedRunContext(value: number | undefined): number { if (value === undefined || !Number.isFinite(value)) return DEFAULT_ADDED_RUN_CONTEXT_LINES; return Math.max(1, Math.trunc(value)); @@ -44,18 +39,18 @@ function parseNumberedDiffLine(line: string): ParsedDiffLine | undefined { return { kind, lineNumber, content: body.slice(sep + 1) }; } -function appendAddedRun(output: string[], run: AddedPreviewLine[], edgeLines: number): void { +function appendAddedRun(output: string[], run: string[], edgeLines: number): void { if (run.length === 0) return; const collapseThreshold = edgeLines * 2 + 1; if (run.length <= collapseThreshold) { - for (const line of run) output.push(line.text); + for (const text of run) output.push(text); return; } - for (let i = 0; i < edgeLines; i++) output.push(run[i].text); - output.push(`+${run[edgeLines].lineNumber}…`); - for (let i = run.length - edgeLines; i < run.length; i++) output.push(run[i].text); + for (let i = 0; i < edgeLines; i++) output.push(run[i]); + output.push("+…"); + for (let i = run.length - edgeLines; i < run.length; i++) output.push(run[i]); } export function buildCompactDiffPreview(diff: string, options: CompactDiffOptions = {}): CompactDiffPreview { @@ -64,7 +59,7 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption let addedLines = 0; let removedLines = 0; const formatted: string[] = []; - const addedRun: AddedPreviewLine[] = []; + const addedRun: string[] = []; const flushAddedRun = (): void => { appendAddedRun(formatted, addedRun, addedRunContext); @@ -87,7 +82,7 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption switch (parsed.kind) { case "+": { addedLines++; - addedRun.push({ lineNumber: parsed.lineNumber, text: `+${parsed.lineNumber}:${parsed.content}` }); + addedRun.push(`+${parsed.lineNumber}:${parsed.content}`); break; } case "-": diff --git a/packages/hashline/test/diff-preview.test.ts b/packages/hashline/test/diff-preview.test.ts index babe6de24..8a72294ff 100644 --- a/packages/hashline/test/diff-preview.test.ts +++ b/packages/hashline/test/diff-preview.test.ts @@ -42,7 +42,7 @@ describe("buildCompactDiffPreview", () => { const preview = buildCompactDiffPreview(diff); - expect(preview.preview).toBe(["+10:line 1", "+11:line 2", "+12…", "+15:line 6", "+16:line 7"].join("\n")); + expect(preview.preview).toBe(["+10:line 1", "+11:line 2", "+…", "+15:line 6", "+16:line 7"].join("\n")); expect(preview.addedLines).toBe(7); expect(preview.removedLines).toBe(0); }); From 2de70263d8df81bbeaf411f4aa0bd43a22159911 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:00:17 +0200 Subject: [PATCH 10/53] refactor(coding-agent/session): switched session relocation notice to structured logging - Replaced the moved-session stderr write with a structured logger.info call. - Logged relocation metadata as `{ from, to }` fields for the re-rooting path. --- packages/coding-agent/src/session/session-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 82cff8bab..1d42c9f57 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -3558,7 +3558,7 @@ export class SessionManager { } const relocated = sourceCwdGone && (mostRecent === null || (mostRecentIsBreadcrumb && !hasCurrentCwdSession)); if (relocated) { - process.stderr.write(`Re-rooting moved session from ${resolvedBreadcrumbCwd} to ${resolvedCwd}.\n`); + logger.info("Re-rooting moved session", { from: resolvedBreadcrumbCwd, to: resolvedCwd }); const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage); await manager.moveTo(cwd, sessionDir); return manager; From b8eecfffdea285c4048cb98105477b18fe33f825 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:00:54 +0200 Subject: [PATCH 11/53] feat(coding-agent): added matching bracket context lines to read and edit previews - Added matching-bracket utilities to locate partner lines for visible spans. - Added read tool output to use displayContent text and startLine for bracket-aware previews. - Added matching-bracket context rows to generateDiffString and generateUnifiedDiffString. - Adjusted diff row insertion to deduplicate and keep contiguous changed groups together. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/edit/diff.ts | 93 +++++++ .../src/modes/components/read-tool-group.ts | 13 +- .../coding-agent/src/prompts/tools/read.md | 4 +- packages/coding-agent/src/tools/read.ts | 181 ++++++++++++-- .../src/utils/matching-brackets.ts | 228 ++++++++++++++++++ .../test/read-multi-range.test.ts | 51 ++++ .../coding-agent/test/tools/edit-diff.test.ts | 24 ++ packages/hashline/test/diff-preview.test.ts | 14 +- 9 files changed, 573 insertions(+), 36 deletions(-) create mode 100644 packages/coding-agent/src/utils/matching-brackets.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce715fbba..0c6b64273 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed +- Fixed read and edit previews to include the matching bracket line behind an ellipsis when a shown opening/closing bracket pairs outside the displayed range. - Fixed `tab.screenshot({ selector })` hanging for the entire cell budget on continuously-animating pages (WebGL / `backdrop-filter` "glass" effects). The element-screenshot path no longer routes through puppeteer's `scrollIntoViewIfNeeded()`, whose `IntersectionObserver` promise can stall indefinitely under heavy rendering; it now does a single instant `scrollIntoView` and captures with `scrollIntoView: false` (relying on `captureBeyondViewport`), so off-screen elements are still captured without the stall. - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows - Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages diff --git a/packages/coding-agent/src/edit/diff.ts b/packages/coding-agent/src/edit/diff.ts index de38be329..1cd9b11c9 100644 --- a/packages/coding-agent/src/edit/diff.ts +++ b/packages/coding-agent/src/edit/diff.ts @@ -6,6 +6,7 @@ */ import * as Diff from "diff"; import { resolveToCwd } from "../tools/path-utils"; +import { findMatchingBracketContextLines } from "../utils/matching-brackets"; import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace"; import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; import { readEditFileText } from "./read-file"; @@ -54,6 +55,94 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten return `${prefix}${lineNum}|${content}`; } +type DiffSource = "old" | "new"; + +interface ParsedNumberedDiffRow { + prefix: "+" | "-" | " "; + lineNumber: number; + content: string; + source: DiffSource; +} + +function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined { + const match = /^([+\- ])(\d+)\|(.*)$/s.exec(row); + if (!match) return undefined; + const prefix = match[1] as "+" | "-" | " "; + const lineNumber = Number.parseInt(match[2], 10); + if (!Number.isFinite(lineNumber)) return undefined; + return { + prefix, + lineNumber, + content: match[3] ?? "", + source: prefix === "+" ? "new" : "old", + }; +} + +function isDiffChangeRow(row: string | undefined): boolean { + return row !== undefined && (row.startsWith("+") || row.startsWith("-")); +} + +function adjustedContextInsertIndex(rows: readonly string[], index: number): number { + let start = index; + while (start > 0 && isDiffChangeRow(rows[start - 1])) start--; + let end = index; + while (end < rows.length && isDiffChangeRow(rows[end])) end++; + return index > start && index < end ? end : index; +} + +function insertBracketContextRows( + rows: string[], + source: DiffSource, + contextLines: ReadonlyMap, + seenRows: Set, +): void { + const context = [...contextLines].sort(([left], [right]) => left - right); + for (const [lineNumber, text] of context) { + const row = formatNumberedDiffLine(" ", lineNumber, text); + if (seenRows.has(row)) continue; + + let insertIndex = rows.length; + let previousSourceLine: number | undefined; + let nextSourceLine: number | undefined; + for (let i = 0; i < rows.length; i++) { + const parsed = parseNumberedDiffRow(rows[i]); + if (!parsed || parsed.source !== source) continue; + if (parsed.lineNumber < lineNumber) { + previousSourceLine = parsed.lineNumber; + continue; + } + nextSourceLine = parsed.lineNumber; + insertIndex = i; + break; + } + + const chunk: string[] = []; + if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push("..."); + chunk.push(row); + if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push("..."); + + const adjustedIndex = adjustedContextInsertIndex(rows, insertIndex); + rows.splice(adjustedIndex, 0, ...chunk); + for (const inserted of chunk) seenRows.add(inserted); + } +} + +function addMatchingBracketContextRows(rows: string[], oldLines: readonly string[], newLines: readonly string[]): void { + const oldVisible: number[] = []; + const newVisible: number[] = []; + const seenRows = new Set(rows); + + for (const row of rows) { + const parsed = parseNumberedDiffRow(row); + if (!parsed) continue; + if (parsed.source === "old") oldVisible.push(parsed.lineNumber); + else newVisible.push(parsed.lineNumber); + } + + insertBracketContextRows(rows, "old", findMatchingBracketContextLines(oldLines, oldVisible), seenRows); + insertBracketContextRows(rows, "new", findMatchingBracketContextLines(newLines, newVisible), seenRows); +} + /** * Generate a unified diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). @@ -162,6 +251,8 @@ export function generateDiffString(oldContent: string, newContent: string, conte } } + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n")); + return { diff: output.join("\n"), firstChangedLine }; } @@ -220,6 +311,8 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string } } + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n")); + return { diff: output.join("\n"), firstChangedLine }; } diff --git a/packages/coding-agent/src/modes/components/read-tool-group.ts b/packages/coding-agent/src/modes/components/read-tool-group.ts index c468b74c7..5225a42a6 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -54,6 +54,10 @@ type ReadToolResultDetails = { }; conflictCount?: number; displayReadTargets?: unknown; + displayContent?: { + text?: string; + startLine?: number; + }; meta?: { source?: { type?: string; @@ -373,10 +377,13 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined; entry.conflictCount = conflictCount; entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success"; - // Store the text content for preview/expanded display + // Store clean display content for preview/expanded display when the read + // tool provides it; fall back to model-facing text for legacy results. + const displayContent = + typeof details?.displayContent?.text === "string" ? details.displayContent.text : undefined; const textContent = result.content?.find(c => c.type === "text")?.text; - if (textContent !== undefined) { - entry.contentText = textContent; + if (displayContent !== undefined || textContent !== undefined) { + entry.contentText = displayContent ?? textContent; } this.#updateDisplay(); } diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 4bdb25d28..70a658f3e 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -18,8 +18,8 @@ Append `:` to `path`. The bare path falls back to the default mode. - `:50` / `:50-` — read from line 50 onward. - `:50-200` — lines 50–200 inclusive. - `:50+150` — 150 lines starting at line 50. -- `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines). -- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact bounds with no context padding. +- `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines; if an emitted bracket pairs with a line outside the window, that matching line is included behind an ellipsis). +- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact requested bounds plus matching bracket lines when a shown bracket's pair sits outside those bounds. - `:raw` — verbatim text; no anchors, no summary, no line prefixes. - `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order. - `:conflicts` — one-line-per-block index of every unresolved git merge conflict. diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 86124528f..e19e5587f 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -9,7 +9,12 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; -import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store"; +import { + canonicalSnapshotKey, + getFileSnapshotStore, + recordFileSnapshot, + SNAPSHOT_MAX_BYTES, +} from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; import { isNotebookPath, readEditableNotebookText } from "../edit/notebook"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -33,6 +38,11 @@ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading"; import { convertFileWithMarkit } from "../utils/markit"; +import { + buildLineEntriesWithMatchingBracketContext, + type LineEntry, + lineEntriesToPlainText, +} from "../utils/matching-brackets"; import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree"; import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "./archive-reader"; import { @@ -108,6 +118,15 @@ const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]); // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep; +async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise { + if (fileSize > SNAPSHOT_MAX_BYTES) return undefined; + try { + return normalizeToLF(await Bun.file(absolutePath).text()).split("\n"); + } catch { + return undefined; + } +} + function isRemoteMountPath(absolutePath: string): boolean { return absolutePath.startsWith(REMOTE_MOUNT_PREFIX); } @@ -174,6 +193,21 @@ function formatTextWithMode( return text; } +const BRACKET_CONTEXT_ELLIPSIS = "…"; + +function formatLineEntryWithMode(entry: LineEntry, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean): string { + if (entry.kind === "ellipsis") return BRACKET_CONTEXT_ELLIPSIS; + return formatSingleLine(entry.lineNumber, entry.text, shouldAddHashLines, shouldAddLineNumbers); +} + +function formatLineEntriesWithMode( + entries: readonly LineEntry[], + shouldAddHashLines: boolean, + shouldAddLineNumbers: boolean, +): string { + return entries.map(entry => formatLineEntryWithMode(entry, shouldAddHashLines, shouldAddLineNumbers)).join("\n"); +} + const BRACE_PAIRS: Record = { "{": "}", "(": ")", "[": "]" }; const BRACE_TAIL_TRAILING_RE = /^[;,)\]}]*$/; @@ -915,6 +949,21 @@ export class ReadTool implements AgentTool { emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; + const formatLineEntries = (entries: readonly LineEntry[], startNum: number): string => { + const firstLine = entries.find(entry => entry.kind === "line"); + details.displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum, + }; + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); + }; + const buildLineEntries = (endLineDisplay: number): LineEntry[] => + buildLineEntriesWithMatchingBracketContext(allLines, [ + { startLine: startLineDisplay, endLine: endLineDisplay }, + ]); let outputText: string; let truncationInfo: @@ -946,7 +995,12 @@ export class ReadTool implements AgentTool { options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines }, }; } else if (truncation.truncated) { - outputText = formatText(truncation.content, startLineDisplay); + const outputLines = truncation.outputLines ?? countTextLines(truncation.content); + const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1); + outputText = + options.raw === true + ? formatText(truncation.content, startLineDisplay) + : formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay); details.truncation = truncation; truncationInfo = { result: truncation, @@ -956,10 +1010,16 @@ export class ReadTool implements AgentTool { const remaining = allLines.length - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; - outputText = formatText(selectedContent, startLineDisplay); + outputText = + options.raw === true + ? formatText(selectedContent, startLineDisplay) + : formatLineEntries(buildLineEntries(endLine), startLineDisplay); outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`; } else { - outputText = formatText(truncation.content, startLineDisplay); + outputText = + options.raw === true + ? formatText(truncation.content, startLineDisplay) + : formatLineEntries(buildLineEntries(endLine), startLineDisplay); } resultBuilder.text(outputText); @@ -1011,21 +1071,37 @@ export class ReadTool implements AgentTool { if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl); if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal); - const parts: string[] = []; const outOfBounds: LineRange[] = []; + const visibleSpans: Array<{ startLine: number; endLine: number }> = []; + const rawParts: string[] = []; for (const range of ranges) { if (range.startLine > totalLines) { outOfBounds.push(range); continue; } const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines); - const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n"); - const formatted = formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers); - parts.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted); - if (hashContext) emittedHashlineHeader = true; + visibleSpans.push({ startLine: range.startLine, endLine: effectiveEnd }); + if (options.raw === true) { + rawParts.push(allLines.slice(range.startLine - 1, effectiveEnd).join("\n")); + } } - const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : ""; + let outputText = ""; + if (options.raw === true) { + outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : ""; + } else if (visibleSpans.length > 0) { + const entries = buildLineEntriesWithMatchingBracketContext(allLines, visibleSpans); + const firstLine = entries.find(entry => entry.kind === "line"); + if (firstLine?.kind === "line") { + details.displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine.lineNumber, + }; + } + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + outputText = hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted; + if (hashContext) emittedHashlineHeader = true; + } const notices: string[] = []; for (const range of outOfBounds) { const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`; @@ -1046,6 +1122,7 @@ export class ReadTool implements AgentTool { async #readLocalFileMultiRange( absolutePath: string, ranges: readonly LineRange[], + fileSize: number, parsed: ParsedSelector, displayMode: { hashLines: boolean; lineNumbers: boolean }, suffixResolution: { from: string; to: string } | undefined, @@ -1053,6 +1130,7 @@ export class ReadTool implements AgentTool { ): Promise<{ outputText: string; columnTruncated: number; + displayContent?: { text: string; startLine: number }; bridgeResult?: AgentToolResult; }> { const rawSelector = isRawSelector(parsed); @@ -1085,7 +1163,11 @@ export class ReadTool implements AgentTool { const blocks: string[] = []; const notices: string[] = []; + const visibleSpans: Array<{ startLine: number; endLine: number }> = []; + const displayLineByNumber = new Map(); + const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize); let columnTruncated = 0; + let displayContent: { text: string; startLine: number } | undefined; for (const range of ranges) { const rangeStart = range.startLine - 1; // 0-indexed @@ -1125,11 +1207,38 @@ export class ReadTool implements AgentTool { } if (cloned) displayLines = cloned; } - const blockText = displayLines.join("\n"); - blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + const endLine = range.startLine + Math.max(0, displayLines.length - 1); + visibleSpans.push({ startLine: range.startLine, endLine }); + for (let i = 0; i < displayLines.length; i++) { + displayLineByNumber.set(range.startLine + i, displayLines[i] ?? ""); + } + if (!fullLines || rawSelector) { + const blockText = displayLines.join("\n"); + blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + } } - let outputText = blocks.join("\n\n…\n\n"); + let outputText: string; + if (!rawSelector && fullLines && visibleSpans.length > 0) { + const entries = buildLineEntriesWithMatchingBracketContext(fullLines, visibleSpans, { + lineText: (lineNumber, sourceText) => { + const visibleText = displayLineByNumber.get(lineNumber); + if (visibleText !== undefined) return visibleText; + if (maxColumns <= 0) return sourceText; + const truncated = truncateLine(sourceText, maxColumns); + if (truncated.wasTruncated) columnTruncated = maxColumns; + return truncated.text; + }, + }); + const firstLine = entries.find(entry => entry.kind === "line"); + displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : (visibleSpans[0]?.startLine ?? 1), + }; + outputText = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + } else { + outputText = blocks.join("\n\n…\n\n"); + } if (shouldAddHashLines && outputText) { const tag = await recordFileSnapshot(this.session, absolutePath); if (tag) { @@ -1139,7 +1248,7 @@ export class ReadTool implements AgentTool { if (notices.length > 0) { outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n"); } - return { outputText, columnTruncated }; + return { outputText, columnTruncated, displayContent }; } async #readArchiveDirectory( @@ -1818,6 +1927,7 @@ export class ReadTool implements AgentTool { const multiResult = await this.#readLocalFileMultiRange( absolutePath, parsed.ranges, + fileSize, parsed, displayMode, suffixResolution, @@ -1826,7 +1936,7 @@ export class ReadTool implements AgentTool { if (multiResult.bridgeResult) return multiResult.bridgeResult; content = [{ type: "text", text: multiResult.outputText }]; sourcePath = absolutePath; - details = {}; + details = multiResult.displayContent ? { displayContent: multiResult.displayContent } : {}; if (multiResult.columnTruncated > 0) { columnTruncated = multiResult.columnTruncated; } @@ -1930,6 +2040,15 @@ export class ReadTool implements AgentTool { if (cloned) displayLines = cloned; } + const displayLineByNumber = new Map(); + for (let i = 0; i < displayLines.length; i++) { + displayLineByNumber.set(startLineDisplay + i, displayLines[i] ?? ""); + } + const bracketContextFullLines = rawSelector + ? undefined + : await readBracketContextFullLines(absolutePath, fileSize); + const displayedEndLine = startLineDisplay + Math.max(0, displayLines.length - 1); + const selectedContent = displayLines.join("\n"); const userLimitedLines = collectedLines.length; @@ -1979,6 +2098,32 @@ export class ReadTool implements AgentTool { emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; + const formatBracketAwareText = (): string | undefined => { + if (!bracketContextFullLines) return undefined; + const entries = buildLineEntriesWithMatchingBracketContext( + bracketContextFullLines, + [{ startLine: startLineDisplay, endLine: displayedEndLine }], + { + lineText: (lineNumber, sourceText) => { + const visibleText = displayLineByNumber.get(lineNumber); + if (visibleText !== undefined) return visibleText; + if (maxColumns <= 0) return sourceText; + const truncated = truncateLine(sourceText, maxColumns); + if (truncated.wasTruncated) columnTruncated = maxColumns; + return truncated.text; + }, + }, + ); + const firstLine = entries.find(entry => entry.kind === "line"); + capturedDisplayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startLineDisplay, + }; + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); + }; let outputText: string; @@ -2005,7 +2150,7 @@ export class ReadTool implements AgentTool { options: { direction: "head", startLine: startLineDisplay, totalFileLines }, }; } else if (truncation.truncated) { - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); details = { truncation }; sourcePath = absolutePath; truncationInfo = { @@ -2016,13 +2161,13 @@ export class ReadTool implements AgentTool { const remaining = totalFileLines - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`; details = {}; sourcePath = absolutePath; } else { // No truncation, no user limit exceeded - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); details = {}; sourcePath = absolutePath; } diff --git a/packages/coding-agent/src/utils/matching-brackets.ts b/packages/coding-agent/src/utils/matching-brackets.ts new file mode 100644 index 000000000..23105b99c --- /dev/null +++ b/packages/coding-agent/src/utils/matching-brackets.ts @@ -0,0 +1,228 @@ +const OPEN_TO_CLOSE: Record = { + "(": ")", + "[": "]", + "{": "}", +}; + +const CLOSE_TO_OPEN: Record = { + ")": "(", + "]": "[", + "}": "{", +}; + +export interface LineSpan { + startLine: number; + endLine: number; +} + +export type LineEntry = { kind: "line"; lineNumber: number; text: string; context: boolean } | { kind: "ellipsis" }; + +interface StackEntry { + opener: string; + lineNumber: number; + text: string; + visible: boolean; +} + +type ScannerMode = "code" | "single" | "double" | "template" | "blockComment"; + +function normalizeLineSpans(spans: readonly LineSpan[], totalLines: number): LineSpan[] { + if (totalLines <= 0) return []; + const normalized: LineSpan[] = []; + for (const span of spans) { + const startLine = Math.max(1, Math.trunc(span.startLine)); + const endLine = Math.min(totalLines, Math.trunc(span.endLine)); + if (endLine < startLine) continue; + normalized.push({ startLine, endLine }); + } + if (normalized.length <= 1) return normalized; + normalized.sort((left, right) => left.startLine - right.startLine || left.endLine - right.endLine); + const merged: LineSpan[] = []; + for (const span of normalized) { + const previous = merged[merged.length - 1]; + if (previous && span.startLine <= previous.endLine + 1) { + previous.endLine = Math.max(previous.endLine, span.endLine); + continue; + } + merged.push({ ...span }); + } + return merged; +} + +function visibleLineNumbers(spans: readonly LineSpan[]): Set { + const visible = new Set(); + for (const span of spans) { + for (let line = span.startLine; line <= span.endLine; line++) { + visible.add(line); + } + } + return visible; +} + +function hasEveryLineVisible(visible: ReadonlySet, totalLines: number): boolean { + return totalLines > 0 && visible.size >= totalLines; +} + +function findMatchingStackIndex(stack: readonly StackEntry[], opener: string): number { + for (let index = stack.length - 1; index >= 0; index--) { + if (stack[index].opener === opener) return index; + } + return -1; +} + +function isHashCommentStart(line: string, index: number): boolean { + if (line[index] !== "#") return false; + for (let i = 0; i < index; i++) { + const ch = line[i]; + if (ch !== " " && ch !== "\t") return false; + } + return true; +} + +export function findMatchingBracketContextLines( + fullLines: readonly string[], + visibleLinesInput: ReadonlySet | readonly number[], +): Map { + const visible = visibleLinesInput instanceof Set ? visibleLinesInput : new Set(visibleLinesInput); + const context = new Map(); + if (visible.size === 0 || hasEveryLineVisible(visible, fullLines.length)) return context; + + const stack: StackEntry[] = []; + let mode: ScannerMode = "code"; + let escaped = false; + + for (let lineIndex = 0; lineIndex < fullLines.length; lineIndex++) { + const lineNumber = lineIndex + 1; + const line = fullLines[lineIndex] ?? ""; + const lineVisible = visible.has(lineNumber); + let index = 0; + while (index < line.length) { + const ch = line[index]; + const next = index + 1 < line.length ? line[index + 1] : ""; + + if (mode === "blockComment") { + if (ch === "*" && next === "/") { + mode = "code"; + index += 2; + continue; + } + index++; + continue; + } + + if (mode === "single" || mode === "double" || mode === "template") { + if (escaped) { + escaped = false; + index++; + continue; + } + if (ch === "\\") { + escaped = true; + index++; + continue; + } + if ( + (mode === "single" && ch === "'") || + (mode === "double" && ch === '"') || + (mode === "template" && ch === "`") + ) { + mode = "code"; + } + index++; + continue; + } + + if (ch === "/" && next === "/") break; + if (ch === "/" && next === "*") { + mode = "blockComment"; + index += 2; + continue; + } + if (isHashCommentStart(line, index)) break; + if (ch === "'") { + mode = "single"; + escaped = false; + index++; + continue; + } + if (ch === '"') { + mode = "double"; + escaped = false; + index++; + continue; + } + if (ch === "`") { + mode = "template"; + escaped = false; + index++; + continue; + } + + if (OPEN_TO_CLOSE[ch]) { + stack.push({ opener: ch, lineNumber, text: line, visible: lineVisible }); + index++; + continue; + } + + const opener = CLOSE_TO_OPEN[ch]; + if (opener) { + const matchIndex = findMatchingStackIndex(stack, opener); + if (matchIndex !== -1) { + const [matched] = stack.splice(matchIndex); + if (matched) { + if (lineVisible && !matched.visible) context.set(matched.lineNumber, matched.text); + if (matched.visible && !lineVisible) context.set(lineNumber, line); + } + } + } + + index++; + } + + if (mode === "single" || mode === "double") { + mode = "code"; + escaped = false; + } + } + + for (const lineNumber of visible) context.delete(lineNumber); + return context; +} + +export function buildLineEntriesWithMatchingBracketContext( + fullLines: readonly string[], + visibleSpans: readonly LineSpan[], + options: { + lineText?: (lineNumber: number, sourceText: string, context: boolean) => string; + } = {}, +): LineEntry[] { + const spans = normalizeLineSpans(visibleSpans, fullLines.length); + const visible = visibleLineNumbers(spans); + const context = findMatchingBracketContextLines(fullLines, visible); + const allLines = new Set(visible); + for (const lineNumber of context.keys()) allLines.add(lineNumber); + + const sorted = [...allLines].sort((left, right) => left - right); + const entries: LineEntry[] = []; + let previousLine: number | undefined; + for (const lineNumber of sorted) { + if (previousLine !== undefined && lineNumber > previousLine + 1) { + entries.push({ kind: "ellipsis" }); + } + const sourceText = fullLines[lineNumber - 1] ?? ""; + const isContext = context.has(lineNumber); + entries.push({ + kind: "line", + lineNumber, + text: options.lineText?.(lineNumber, sourceText, isContext) ?? sourceText, + context: isContext, + }); + previousLine = lineNumber; + } + + return entries; +} + +export function lineEntriesToPlainText(entries: readonly LineEntry[], ellipsis = "…"): string { + return entries.map(entry => (entry.kind === "ellipsis" ? ellipsis : entry.text)).join("\n"); +} diff --git a/packages/coding-agent/test/read-multi-range.test.ts b/packages/coding-agent/test/read-multi-range.test.ts index 6072369f8..00fc40bf8 100644 --- a/packages/coding-agent/test/read-multi-range.test.ts +++ b/packages/coding-agent/test/read-multi-range.test.ts @@ -69,6 +69,57 @@ describe("read tool multi-range selector", () => { expect(text).toContain("…"); }); + it("includes the matching closing bracket line outside a forward range", async () => { + const filePath = path.join(tmpDir, "brackets.ts"); + await fs.writeFile( + filePath, + [ + "function outer() {", + " const one = 1;", + " const two = 2;", + " const three = 3;", + " const four = 4;", + " return one + two + three + four;", + "}", + "after();", + ].join("\n"), + ); + + const tool = new ReadTool(createSession(tmpDir)); + const text = textOutput(await tool.execute("call-bracket-close", { path: `${filePath}:1-1` })); + + expect(text).toContain("function outer() {"); + expect(text).toContain("…"); + expect(text).toContain("}"); + expect(text).not.toContain("const four"); + expect(text).not.toContain("return one + two"); + }); + + it("includes the matching opening bracket line outside a reverse range", async () => { + const filePath = path.join(tmpDir, "brackets.ts"); + await fs.writeFile( + filePath, + [ + "function outer() {", + " const one = 1;", + " const two = 2;", + " const three = 3;", + " const four = 4;", + " return one + two + three + four;", + "}", + "after();", + ].join("\n"), + ); + + const tool = new ReadTool(createSession(tmpDir)); + const text = textOutput(await tool.execute("call-bracket-open", { path: `${filePath}:7-7` })); + + expect(text.indexOf("function outer() {")).toBeLessThan(text.indexOf("}")); + expect(text).toContain("…"); + expect(text).not.toContain("const one = 1"); + expect(text).not.toContain("const four = 4"); + }); + it("merges overlapping ranges into a single contiguous block", async () => { const filePath = path.join(tmpDir, "numbered.txt"); await fs.writeFile(filePath, makeNumberedContent(20)); diff --git a/packages/coding-agent/test/tools/edit-diff.test.ts b/packages/coding-agent/test/tools/edit-diff.test.ts index 330d6c9eb..964540acc 100644 --- a/packages/coding-agent/test/tools/edit-diff.test.ts +++ b/packages/coding-agent/test/tools/edit-diff.test.ts @@ -22,4 +22,28 @@ describe("generateDiffString", () => { expect(diffLines).not.toContain(" 8|line 8"); expect(diffLines).not.toContain(" 12|line 12"); }); + + it("adds an elided matching bracket line when context stops before the closer", () => { + const oldLines = [ + "function outer() {", + " const value = 1;", + " const two = 2;", + " const three = 3;", + " const four = 4;", + " return value + two + three + four;", + "}", + ]; + const newLines = [...oldLines]; + newLines[0] = "function renamed() {"; + + const result = generateDiffString(oldLines.join("\n"), newLines.join("\n"), 1); + const diffLines = result.diff.split("\n"); + + expect(diffLines).toContain("-1|function outer() {"); + expect(diffLines).toContain("+1|function renamed() {"); + expect(diffLines).toContain("..."); + expect(diffLines).toContain(" 7|}"); + expect(diffLines).not.toContain(" 5| const four = 4;"); + expect(diffLines).not.toContain(" 6| return value + two + three + four;"); + }); }); diff --git a/packages/hashline/test/diff-preview.test.ts b/packages/hashline/test/diff-preview.test.ts index 8a72294ff..8450d0905 100644 --- a/packages/hashline/test/diff-preview.test.ts +++ b/packages/hashline/test/diff-preview.test.ts @@ -12,20 +12,8 @@ describe("buildCompactDiffPreview", () => { }); }); - it("renumbers context lines against the post-edit file after range expansion", () => { - const diff = [ - " 1|a1", - " 2|a2", - "-3|a3", - "-4|a4", - "+3|X", - "+4|Y", - "+5|Z", - " 5|a5", - " 6|a6", - " 7|a7", - ].join("\n"); + const diff = [" 1|a1", " 2|a2", "-3|a3", "-4|a4", "+3|X", "+4|Y", "+5|Z", " 5|a5", " 6|a6", " 7|a7"].join("\n"); const preview = buildCompactDiffPreview(diff); From 641a6ec205fabab2e9bca824eaf2b3b209a03cda Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:13:50 +0200 Subject: [PATCH 12/53] fix(agent): excluded buffered tool call after capped batch - Sliced cloned content through the capping tool call's index. - Prevented executing a started-but-unfinished call beyond the cap. --- packages/agent/src/agent-loop.ts | 6 +- packages/agent/test/agent-loop.test.ts | 104 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 4e697ed4a..bfb2d708c 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -451,10 +451,10 @@ function normalizeMaxToolCallsPerTurn(value: number | undefined): number | undef return normalized > 0 ? normalized : undefined; } -function cloneAssistantMessageForToolCallCap(message: AssistantMessage): AssistantMessage { +function cloneAssistantMessageForToolCallCap(message: AssistantMessage, throughContentIndex: number): AssistantMessage { return { ...message, - content: message.content.map(block => { + content: message.content.slice(0, throughContentIndex + 1).map(block => { if (block.type === "toolCall") { return { ...block, arguments: structuredClone(block.arguments) }; } @@ -973,7 +973,7 @@ async function streamAssistantResponse( if (event.type === "toolcall_end" && maxToolCallsPerTurn !== undefined) { completedToolCalls++; if (completedToolCalls >= maxToolCallsPerTurn) { - cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage); + cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage, event.contentIndex); toolCallCapAbortController?.abort(); const capped = await finishCappedAssistantMessage(); if (capped) return capped; diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 908a568ef..3ea5537bd 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -485,6 +485,110 @@ describe("agentLoop with AgentMessage", () => { ]); }); + it("does not execute an unfinished tool call already buffered after a capped batch", async () => { + const toolSchema = z.object({ path: z.string() }); + const executed: string[] = []; + const tool: AgentTool = { + name: "read", + label: "Read", + description: "Read path", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.path); + return { + content: [{ type: "text", text: params.path }], + details: { path: params.path }, + }; + }, + }; + + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + const mock = createMockModel(); + let modelCalls = 0; + + type ReadToolCall = Extract; + + const makeToolCall = (index: number, args: Record): ReadToolCall => ({ + type: "toolCall", + id: `tool-${index}`, + name: "read", + arguments: args, + }); + + const streamFn: StreamFn = (_model, _llmContext, _options) => { + modelCalls++; + const stream = new AssistantMessageEventStream(); + if (modelCalls > 1) { + queueMicrotask(() => { + const done = createAssistantMessage([{ type: "text", text: "done" }], "stop"); + stream.push({ type: "start", partial: done }); + stream.push({ type: "text_start", contentIndex: 0, partial: done }); + stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial: done }); + stream.push({ type: "text_end", contentIndex: 0, content: "done", partial: done }); + stream.push({ type: "done", reason: "stop", message: done }); + }); + return stream; + } + + queueMicrotask(() => { + const completed = Array.from({ length: 4 }, (_, index) => + makeToolCall(index + 1, { path: `file-${index + 1}` }), + ); + const partial = createAssistantMessage([...completed], "stop"); + stream.push({ type: "start", partial }); + for (let index = 0; index < completed.length; index++) { + const toolCall = completed[index]!; + stream.push({ type: "toolcall_start", contentIndex: index, partial }); + stream.push({ + type: "toolcall_delta", + contentIndex: index, + delta: JSON.stringify(toolCall.arguments), + partial, + }); + stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial }); + } + + // Simulate the provider event producer running ahead after the 4th + // `toolcall_end`: the same `partial` object is mutated to include a + // started-but-not-ended 5th call whose seed arguments are `{}`. + const unfinished = makeToolCall(5, {}); + partial.content.push(unfinished); + stream.push({ type: "toolcall_start", contentIndex: 4, partial }); + stream.push({ type: "toolcall_delta", contentIndex: 4, delta: "", partial }); + }); + return stream; + }; + + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + maxToolCallsPerTurn: 4, + }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("read many")], context, config, undefined, streamFn); + for await (const event of stream) { + events.push(event); + } + + expect(executed).toEqual(["file-1", "file-2", "file-3", "file-4"]); + expect(modelCalls).toBe(2); + + const batchedTurn = events.find( + (event): event is Extract => + event.type === "turn_end" && event.message.role === "assistant" && event.toolResults.length === 4, + ); + expect(batchedTurn).toBeDefined(); + if (batchedTurn?.message.role !== "assistant") return; + expect(batchedTurn.message.content.filter(block => block.type === "toolCall")).toHaveLength(4); + expect(batchedTurn.toolResults.map(result => result.toolCallId).sort()).toEqual([ + "tool-1", + "tool-2", + "tool-3", + "tool-4", + ]); + }); + it("injects and strips intent when intent tracing is enabled", async () => { const toolSchema = z.object({ value: z.string() }); const executedParams: Record[] = []; From 0753a7a4330ebe25a487a724b4d7e932a4b4180e Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:15:46 +0200 Subject: [PATCH 13/53] feat(agent): removed max tool-call caps from agent loop and session flow - Removed `maxToolCallsPerTurn` from `AgentOptions`, `AgentLoopConfig`, and config serialization. - Removed stream-loop cap enforcement, including the `toolcall_end` abort path and capped assistant messages. - Removed Anthropic Opus 4.8 batch-cap resolver and agent-session sync logic from coding-agent. - Updated tests and changelogs to align with uncapped tool-call behavior and dropped cap-specific cases. --- packages/agent/CHANGELOG.md | 3 + packages/agent/src/agent-loop.ts | 74 +----- packages/agent/src/agent.ts | 15 -- packages/agent/src/types.ts | 7 - packages/agent/test/agent-loop.test.ts | 220 +----------------- packages/coding-agent/CHANGELOG.md | 5 +- .../coding-agent/src/session/agent-session.ts | 18 -- .../agent-session-message-pipeline.test.ts | 43 +--- 8 files changed, 12 insertions(+), 373 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 2d896b6ee..687e1cb1f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Removed + +- Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls ## [15.10.3] - 2026-06-08 diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index bfb2d708c..3f56c7949 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -445,26 +445,6 @@ interface StepCounter { count: number; } -function normalizeMaxToolCallsPerTurn(value: number | undefined): number | undefined { - if (value === undefined || !Number.isFinite(value)) return undefined; - const normalized = Math.trunc(value); - return normalized > 0 ? normalized : undefined; -} - -function cloneAssistantMessageForToolCallCap(message: AssistantMessage, throughContentIndex: number): AssistantMessage { - return { - ...message, - content: message.content.slice(0, throughContentIndex + 1).map(block => { - if (block.type === "toolCall") { - return { ...block, arguments: structuredClone(block.arguments) }; - } - return { ...block }; - }), - stopReason: "toolUse", - errorMessage: undefined, - errorStatus: undefined, - }; -} /** * Resolve aside entries at the moment the loop is about to inject them. Each entry @@ -761,18 +741,11 @@ async function streamAssistantResponse( const dynamicReasoning = config.getReasoning?.(); const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model); const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined; - const maxToolCallsPerTurn = normalizeMaxToolCallsPerTurn(config.maxToolCallsPerTurn); - const toolCallCapAbortController = maxToolCallsPerTurn === undefined ? undefined : new AbortController(); - const requestSignals: AbortSignal[] = []; - if (signal) requestSignals.push(signal); - if (harmonyAbortController) requestSignals.push(harmonyAbortController.signal); - if (toolCallCapAbortController) requestSignals.push(toolCallCapAbortController.signal); - const requestSignal = - requestSignals.length === 0 - ? undefined - : requestSignals.length === 1 - ? requestSignals[0] - : AbortSignal.any(requestSignals); + const requestSignal = harmonyAbortController + ? signal + ? AbortSignal.any([signal, harmonyAbortController.signal]) + : harmonyAbortController.signal + : signal; const effectiveTemperature = harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature; const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice; @@ -846,26 +819,6 @@ async function streamAssistantResponse( let addedPartial = false; const responseIterator = response[Symbol.asyncIterator](); - let completedToolCalls = 0; - let cappedMessage: AssistantMessage | undefined; - let capFinalized = false; - - const finishCappedAssistantMessage = async (): Promise => { - if (!cappedMessage) return undefined; - responseIterator.return?.()?.catch(() => {}); - if (!capFinalized) { - if (addedPartial) { - context.messages[context.messages.length - 1] = cappedMessage; - } else { - context.messages.push(cappedMessage); - stream.push({ type: "message_start", message: { ...cappedMessage } }); - } - stream.push({ type: "message_end", message: cappedMessage }); - await finishChat(cappedMessage); - capFinalized = true; - } - return cappedMessage; - }; // Set up a single abort race: register the abort listener once for the whole // stream and reuse the same race promise for every iterator.next() instead of @@ -898,10 +851,6 @@ async function streamAssistantResponse( if (abortRacePromise) { const result = await Promise.race([responseIterator.next(), abortRacePromise]); if (result === ABORTED) { - if (toolCallCapAbortController?.signal.aborted) { - const capped = await finishCappedAssistantMessage(); - if (capped) return capped; - } responseIterator.return?.()?.catch(() => {}); const aborted = emitAbortedAssistantMessage( partialMessage, @@ -919,10 +868,6 @@ async function streamAssistantResponse( next = await responseIterator.next(); } if (requestSignal?.aborted) { - if (toolCallCapAbortController?.signal.aborted) { - const capped = await finishCappedAssistantMessage(); - if (capped) return capped; - } const aborted = emitAbortedAssistantMessage( partialMessage, addedPartial, @@ -970,15 +915,6 @@ async function streamAssistantResponse( assistantMessageEvent: event, message: { ...partialMessage }, }); - if (event.type === "toolcall_end" && maxToolCallsPerTurn !== undefined) { - completedToolCalls++; - if (completedToolCalls >= maxToolCallsPerTurn) { - cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage, event.contentIndex); - toolCallCapAbortController?.abort(); - const capped = await finishCappedAssistantMessage(); - if (capped) return capped; - } - } } break; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 3e6a5dfb1..b1f042372 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -110,11 +110,6 @@ export interface AgentOptions { */ interruptMode?: "immediate" | "wait"; - /** - * Maximum completed tool calls to accept from one streamed assistant turn before - * executing the batch. Undefined disables batching. - */ - maxToolCallsPerTurn?: number; /** * API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic") @@ -288,7 +283,6 @@ export class Agent { #steeringMode: "all" | "one-at-a-time"; #followUpMode: "all" | "one-at-a-time"; #interruptMode: "immediate" | "wait"; - #maxToolCallsPerTurn?: number; #sessionId?: string; #promptCacheKey?: string; #metadata?: Record; @@ -350,7 +344,6 @@ export class Agent { this.#steeringMode = opts.steeringMode || "one-at-a-time"; this.#followUpMode = opts.followUpMode || "one-at-a-time"; this.#interruptMode = opts.interruptMode || "immediate"; - this.#maxToolCallsPerTurn = opts.maxToolCallsPerTurn; this.streamFn = opts.streamFn || streamSimple; this.#sessionId = opts.sessionId; this.#promptCacheKey = opts.promptCacheKey; @@ -588,13 +581,6 @@ export class Agent { this.#maxRetryDelayMs = value; } - get maxToolCallsPerTurn(): number | undefined { - return this.#maxToolCallsPerTurn; - } - - set maxToolCallsPerTurn(value: number | undefined) { - this.#maxToolCallsPerTurn = value; - } get state(): AgentState { return this.#state; @@ -967,7 +953,6 @@ export class Agent { serviceTier: this.#serviceTier, hideThinkingSummary: this.#hideThinkingSummary, interruptMode: this.#interruptMode, - maxToolCallsPerTurn: this.#maxToolCallsPerTurn, sessionId: this.#sessionId, promptCacheKey: this.#promptCacheKey, metadata: this.#metadataResolver ? undefined : this.#metadata, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 52db396e0..cde0fd5bd 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -47,13 +47,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions { */ interruptMode?: "immediate" | "wait"; - /** - * Maximum completed tool calls to accept from one streamed assistant turn before - * cutting the provider stream and executing that batch. The cap is enforced on - * `toolcall_end` so every executed call has complete arguments. Undefined disables - * batching. - */ - maxToolCallsPerTurn?: number; /** * Optional session identifier forwarded to LLM providers. diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 3ea5537bd..4570e29c7 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -7,7 +7,6 @@ import type { AgentMessage, AgentTool, AgentToolContext, - StreamFn, ToolCallContext, } from "@oh-my-pi/pi-agent-core/types"; import type { AssistantMessage, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; @@ -133,7 +132,7 @@ describe("agentLoop with AgentMessage", () => { tools: [], }; const mock = createMockModel(); - const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 }; + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; const controller = new AbortController(); // The mock provider would reject without a configured response; we want the // agent's abort path to kick in before any event is emitted. Use a raw stream @@ -164,7 +163,7 @@ describe("agentLoop with AgentMessage", () => { tools: [], }; const mock = createMockModel(); - const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 }; + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; const controller = new AbortController(); const streamFn = () => new AssistantMessageEventStream(); @@ -373,221 +372,6 @@ describe("agentLoop with AgentMessage", () => { } }); - it("cuts a streamed assistant turn after the configured completed tool-call batch", async () => { - const toolSchema = z.object({ value: z.string() }); - const executed: string[] = []; - const tool: AgentTool = { - name: "echo", - label: "Echo", - description: "Echo tool", - parameters: toolSchema, - async execute(_toolCallId, params) { - executed.push(params.value); - return { - content: [{ type: "text", text: `echoed: ${params.value}` }], - details: { value: params.value }, - }; - }, - }; - - const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; - const mock = createMockModel(); - let modelCalls = 0; - let firstRequestSignal: AbortSignal | undefined; - - const makeToolCall = (index: number): AssistantMessage["content"][number] => ({ - type: "toolCall", - id: `tool-${index}`, - name: "echo", - arguments: { value: String(index) }, - }); - const makeMessage = (count: number, stopReason: AssistantMessage["stopReason"] = "stop") => - createAssistantMessage( - Array.from({ length: count }, (_, index) => makeToolCall(index + 1)), - stopReason, - ); - - const streamFn: StreamFn = (_model, _llmContext, options) => { - modelCalls++; - const stream = new AssistantMessageEventStream(); - if (modelCalls > 1) { - queueMicrotask(() => { - const done = createAssistantMessage([{ type: "text", text: "done" }], "stop"); - stream.push({ type: "start", partial: done }); - stream.push({ type: "text_start", contentIndex: 0, partial: done }); - stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial: done }); - stream.push({ type: "text_end", contentIndex: 0, content: "done", partial: done }); - stream.push({ type: "done", reason: "stop", message: done }); - }); - return stream; - } - - queueMicrotask(async () => { - firstRequestSignal = options?.signal; - stream.push({ type: "start", partial: makeMessage(0) }); - for (let index = 1; index <= 10; index++) { - if (options?.signal?.aborted) { - const aborted = createAssistantMessage([], "aborted"); - stream.push({ type: "error", reason: "aborted", error: aborted }); - return; - } - const partial = makeMessage(index); - const toolCall = partial.content[index - 1]; - if (toolCall?.type !== "toolCall") throw new Error("Expected tool call"); - stream.push({ type: "toolcall_start", contentIndex: index - 1, partial }); - stream.push({ - type: "toolcall_delta", - contentIndex: index - 1, - delta: JSON.stringify(toolCall.arguments), - partial, - }); - stream.push({ type: "toolcall_end", contentIndex: index - 1, toolCall, partial }); - await Bun.sleep(0); - } - stream.push({ type: "done", reason: "toolUse", message: makeMessage(10, "toolUse") }); - }); - return stream; - }; - - const config: AgentLoopConfig = { - model: mock.model, - convertToLlm: identityConverter, - maxToolCallsPerTurn: 8, - }; - - const events: AgentEvent[] = []; - const stream = agentLoop([createUserMessage("echo many")], context, config, undefined, streamFn); - for await (const event of stream) { - events.push(event); - } - - expect(executed).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]); - expect(firstRequestSignal?.aborted).toBe(true); - expect(modelCalls).toBe(2); - - const batchedTurn = events.find( - (event): event is Extract => - event.type === "turn_end" && event.toolResults.length === 8, - ); - expect(batchedTurn).toBeDefined(); - if (batchedTurn?.message.role !== "assistant") return; - expect(batchedTurn.message.stopReason).toBe("toolUse"); - expect(batchedTurn.message.content.filter(block => block.type === "toolCall")).toHaveLength(8); - expect(batchedTurn.toolResults.map(result => result.toolCallId).sort()).toEqual([ - "tool-1", - "tool-2", - "tool-3", - "tool-4", - "tool-5", - "tool-6", - "tool-7", - "tool-8", - ]); - }); - - it("does not execute an unfinished tool call already buffered after a capped batch", async () => { - const toolSchema = z.object({ path: z.string() }); - const executed: string[] = []; - const tool: AgentTool = { - name: "read", - label: "Read", - description: "Read path", - parameters: toolSchema, - async execute(_toolCallId, params) { - executed.push(params.path); - return { - content: [{ type: "text", text: params.path }], - details: { path: params.path }, - }; - }, - }; - - const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; - const mock = createMockModel(); - let modelCalls = 0; - - type ReadToolCall = Extract; - - const makeToolCall = (index: number, args: Record): ReadToolCall => ({ - type: "toolCall", - id: `tool-${index}`, - name: "read", - arguments: args, - }); - - const streamFn: StreamFn = (_model, _llmContext, _options) => { - modelCalls++; - const stream = new AssistantMessageEventStream(); - if (modelCalls > 1) { - queueMicrotask(() => { - const done = createAssistantMessage([{ type: "text", text: "done" }], "stop"); - stream.push({ type: "start", partial: done }); - stream.push({ type: "text_start", contentIndex: 0, partial: done }); - stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial: done }); - stream.push({ type: "text_end", contentIndex: 0, content: "done", partial: done }); - stream.push({ type: "done", reason: "stop", message: done }); - }); - return stream; - } - - queueMicrotask(() => { - const completed = Array.from({ length: 4 }, (_, index) => - makeToolCall(index + 1, { path: `file-${index + 1}` }), - ); - const partial = createAssistantMessage([...completed], "stop"); - stream.push({ type: "start", partial }); - for (let index = 0; index < completed.length; index++) { - const toolCall = completed[index]!; - stream.push({ type: "toolcall_start", contentIndex: index, partial }); - stream.push({ - type: "toolcall_delta", - contentIndex: index, - delta: JSON.stringify(toolCall.arguments), - partial, - }); - stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial }); - } - - // Simulate the provider event producer running ahead after the 4th - // `toolcall_end`: the same `partial` object is mutated to include a - // started-but-not-ended 5th call whose seed arguments are `{}`. - const unfinished = makeToolCall(5, {}); - partial.content.push(unfinished); - stream.push({ type: "toolcall_start", contentIndex: 4, partial }); - stream.push({ type: "toolcall_delta", contentIndex: 4, delta: "", partial }); - }); - return stream; - }; - - const config: AgentLoopConfig = { - model: mock.model, - convertToLlm: identityConverter, - maxToolCallsPerTurn: 4, - }; - - const events: AgentEvent[] = []; - const stream = agentLoop([createUserMessage("read many")], context, config, undefined, streamFn); - for await (const event of stream) { - events.push(event); - } - - expect(executed).toEqual(["file-1", "file-2", "file-3", "file-4"]); - expect(modelCalls).toBe(2); - - const batchedTurn = events.find( - (event): event is Extract => - event.type === "turn_end" && event.message.role === "assistant" && event.toolResults.length === 4, - ); - expect(batchedTurn).toBeDefined(); - if (batchedTurn?.message.role !== "assistant") return; - expect(batchedTurn.message.content.filter(block => block.type === "toolCall")).toHaveLength(4); - expect(batchedTurn.toolResults.map(result => result.toolCallId).sort()).toEqual([ - "tool-1", - "tool-2", - "tool-3", - "tool-4", - ]); - }); it("injects and strips intent when intent tracing is enabled", async () => { const toolSchema = z.object({ value: z.string() }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c6b64273..528518627 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references @@ -9,11 +8,9 @@ ### Changed +- Changed tool-call batching so Anthropic `claude-opus-4-8` no longer enforces a special 4-call batch cap and now uses the session default tool-call limit - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded - Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. - -### Changed - - Browser tool helpers (`tab.*`) are now individually tracked and time-bounded: when a `run` cell hits its budget, the timeout error names the still-running helper(s) and how long each has been stalled (e.g. `... (stalled on tab.screenshot({ selector: ".x" }) (29.9s))`) instead of the opaque `Browser code execution timed out after 30000ms`. Page-coupled helpers that should resolve quickly (`observe`, `screenshot`, `extract`) also fail fast with a named per-op error at `min(cellBudget, 20s)`, leaving budget for the rest of the cell, rather than silently consuming the whole budget. ### Fixed diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2a3204943..ab922e6ed 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -531,15 +531,6 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin } const IRC_REPLY_MAX_BYTES = 4096; -export const ANTHROPIC_TOOL_CALL_BATCH_CAP = 4; -const CLAUDE_OPUS_4_8_MODEL_ID = /(?:^|[./_-])claude-opus-4[.-]8\b/i; - -export function resolveToolCallBatchCapForModel(model: Model | undefined): number | undefined { - if (!model) return undefined; - return model.provider === "anthropic" && CLAUDE_OPUS_4_8_MODEL_ID.test(model.id) - ? ANTHROPIC_TOOL_CALL_BATCH_CAP - : undefined; -} /** * Collapse degenerate IRC ephemeral replies before they hit the relay. @@ -1102,9 +1093,6 @@ export class AgentSession { this.#flushPendingAgentEnd(); } - #syncToolCallBatchCap(model: Model | undefined = this.model): void { - this.agent.maxToolCallsPerTurn = resolveToolCallBatchCapForModel(model); - } #flushPendingAgentEnd(): void { const pending = this.#pendingAgentEndEmit; @@ -1224,7 +1212,6 @@ export class AgentSession { this.#agentId = config.agentId; this.#agentRegistry = config.agentRegistry; this.#providerSessionId = config.providerSessionId; - this.#syncToolCallBatchCap(); this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => { const event: AgentEvent = { type: "message_update", @@ -6913,7 +6900,6 @@ export class AgentSession { this.#closeProviderSessionsForModelSwitch(currentModel, model); } this.agent.setModel(model); - this.#syncToolCallBatchCap(model); // Re-evaluate append-only context mode — provider or setting may have changed this.#syncAppendOnlyContext(model); @@ -9109,7 +9095,6 @@ export class AgentSession { this.#setModelWithProviderSessionReset(match); } else { this.agent.setModel(match); - this.#syncToolCallBatchCap(match); } } } @@ -9192,9 +9177,6 @@ export class AgentSession { this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; if (previousModel) { this.agent.setModel(previousModel); - this.#syncToolCallBatchCap(previousModel); - } else { - this.#syncToolCallBatchCap(undefined); } this.#thinkingLevel = previousThinkingLevel; this.#autoThinking = previousAutoThinking; diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 5ec856b57..7eed5e7d0 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -10,12 +10,7 @@ import { } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { - AgentSession, - type AgentSessionEvent, - ANTHROPIC_TOOL_CALL_BATCH_CAP, - resolveToolCallBatchCapForModel, -} from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { convertToLlm, wrapSteeringForModel } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { createAssistantMessage } from "./helpers/agent-session-setup"; @@ -55,42 +50,6 @@ describe("AgentSession message pipeline", () => { } }); - it("enables the tool-call batch cap only for Anthropic Claude Opus 4.8 models", () => { - const baseModel: Model = { - id: "gpt-5", - name: "GPT-5", - api: "openai-responses", - provider: "openai", - baseUrl: "", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - }; - const anthropicOpus48: Model = { - ...baseModel, - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - api: "anthropic", - provider: "anthropic", - }; - - expect(resolveToolCallBatchCapForModel(anthropicOpus48)).toBe(ANTHROPIC_TOOL_CALL_BATCH_CAP); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4.8" })).toBe( - ANTHROPIC_TOOL_CALL_BATCH_CAP, - ); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-8-20260530" })).toBe( - ANTHROPIC_TOOL_CALL_BATCH_CAP, - ); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, provider: "openrouter" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-sonnet-4-8" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-7" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-9" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...anthropicOpus48, id: "claude-opus-4-80" })).toBeUndefined(); - expect(resolveToolCallBatchCapForModel(baseModel)).toBeUndefined(); - expect(resolveToolCallBatchCapForModel({ ...baseModel, provider: "openai-codex" })).toBeUndefined(); - }); it("applies transformContext before convertToLlm", async () => { const inputMessages: AgentMessage[] = [{ role: "user", content: "hello", timestamp: Date.now() }]; From 1a6d0a16f2cc556700c9500170aab5b3ae896f90 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:16:42 +0200 Subject: [PATCH 14/53] fix(coding-agent/modes): fixed shimmer timing to stay aligned across task row lengths - Synchronized shimmer progression by switching `shimmer.ts` to a shared 2-second phase sweep, with crest/head position interpolated across the full visible span. - Updated classic and KITT intensity calculations so shimmer starts at index 0 and reaches index length-1 together for any task row length. - Adjusted shimmer tests to assert crest boundary and phase alignment behavior across short and long strings. --- packages/coding-agent/CHANGELOG.md | 6 +- .../coding-agent/src/modes/theme/shimmer.ts | 45 +++++----- .../test/modes/theme/shimmer.test.ts | 83 +++++++++---------- 3 files changed, 66 insertions(+), 68 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 528518627..670528061 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,13 +8,13 @@ ### Changed -- Changed tool-call batching so Anthropic `claude-opus-4-8` no longer enforces a special 4-call batch cap and now uses the session default tool-call limit - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded - Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. - Browser tool helpers (`tab.*`) are now individually tracked and time-bounded: when a `run` cell hits its budget, the timeout error names the still-running helper(s) and how long each has been stalled (e.g. `... (stalled on tab.screenshot({ selector: ".x" }) (29.9s))`) instead of the opaque `Browser code execution timed out after 30000ms`. Page-coupled helpers that should resolve quickly (`observe`, `screenshot`, `extract`) also fail fast with a named per-op error at `min(cellBudget, 20s)`, leaving budget for the rest of the cell, rather than silently consuming the whole budget. ### Fixed +- Fixed task-row shimmer timing so every running description starts its highlight on the first character together and reaches the last character together, regardless of text length. - Fixed read and edit previews to include the matching bracket line behind an ellipsis when a shown opening/closing bracket pairs outside the displayed range. - Fixed `tab.screenshot({ selector })` hanging for the entire cell budget on continuously-animating pages (WebGL / `backdrop-filter` "glass" effects). The element-screenshot path no longer routes through puppeteer's `scrollIntoViewIfNeeded()`, whose `IntersectionObserver` promise can stall indefinitely under heavy rendering; it now does a single instant `scrollIntoView` and captures with `scrollIntoView: false` (relying on `captureBeyondViewport`), so off-screen elements are still captured without the stall. - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows @@ -23,6 +23,10 @@ - Fixed edit tool result previews to show only current-file lines and collapse long inserted blocks instead of echoing removed content. - Fixed `generateDiffString` to omit the mid-skip `...` placeholder between two nearby edits, conveying the elided gap via the jump in line numbers instead (consistent with how leading/trailing context skips already render). The placeholder row was indistinguishable from a genuine `...` context line and wasted a row in compact previews. +### Removed + +- Removed the special Anthropic `claude-opus-4-8` tool-call batch cap; sessions no longer abort an in-flight provider stream after a fixed number of completed tool calls. + ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/coding-agent/src/modes/theme/shimmer.ts b/packages/coding-agent/src/modes/theme/shimmer.ts index cdc9fdc1b..7832f33f4 100644 --- a/packages/coding-agent/src/modes/theme/shimmer.ts +++ b/packages/coding-agent/src/modes/theme/shimmer.ts @@ -1,17 +1,13 @@ import { isSettingsInitialized, settings } from "../../config/settings"; import type { Theme, ThemeColor } from "./theme"; -// ─── Animation velocity ────────────────────────────────────────────────────── -// Band/head travel speed in border cells per second. Driving position by a fixed -// velocity — instead of dividing a fixed sweep duration by the (length-derived) -// period — makes smoothness independent of message length: at the loader's -// default 30fps redraw cadence the band advances ≤1 cell per frame for any -// string, so it never visibly steps. Sweep/round-trip durations now scale with -// length. Keep ≤ the animated redraw fps (loader RENDER_INTERVAL_MS = 1000/30). -const SHIMMER_SPEED_CELLS_PER_S = 30; +// ─── Animation phase ───────────────────────────────────────────────────────── +// Every shimmered string shares the same sweep phase. The bright point is +// interpolated from the first visible cell to the last, so short and long task +// rows start together and arrive at their final character together. +const SHIMMER_SWEEP_MS = 2000; // ─── Classic sweep tunables ────────────────────────────────────────────────── -const CLASSIC_PADDING = 10; const CLASSIC_BAND_HALF_WIDTH = 6; // ─── KITT scanner tunables ─────────────────────────────────────────────────── @@ -105,15 +101,21 @@ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette return out; } +function phaseFor(time: number): number { + const phase = (time % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS; + return phase < 0 ? phase + 1 : phase; +} + +function lerp(start: number, end: number, phase: number): number { + return start + (end - start) * phase; +} + // ─── Intensity profiles ────────────────────────────────────────────────────── -/** Smooth cosine bump sweeping left → right with edge padding. */ +/** Smooth cosine bump sweeping left → right across the visible text. */ function classicIntensity(time: number, index: number, length: number): number { - const period = length + CLASSIC_PADDING * 2; - // Fixed-velocity, un-floored band position: advancing at a constant - // cells/second (not period / fixed-sweep) keeps the per-frame step ≤1 cell at - // the default cadence for any length, so long messages are no steppier. - const pos = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % period; - const dist = Math.abs(index + CLASSIC_PADDING - pos); + const range = length - 1; + const pos = range <= 0 ? 0 : lerp(0, range, phaseFor(time)); + const dist = Math.abs(index - pos); if (dist >= CLASSIC_BAND_HALF_WIDTH) return 0; return 0.5 * (1 + Math.cos((Math.PI * dist) / CLASSIC_BAND_HALF_WIDTH)); } @@ -126,13 +128,10 @@ function classicIntensity(time: number, index: number, length: number): number { function kittIntensity(time: number, index: number, length: number): number { const range = length - 1; if (range <= 0) return 1; - // Fixed head velocity: a triangle ping-pong over a 2*range round trip at a - // constant cells/second, so the bright head advances ≤1 cell per frame at the - // default cadence regardless of bar length. Round-trip duration scales with length. - const cycleCells = 2 * range; - const sweep = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % cycleCells; - const goingRight = sweep < range; - const head = goingRight ? sweep : cycleCells - sweep; + const phase = phaseFor(time); + const goingRight = phase < 0.5; + const sweep = goingRight ? phase * 2 : (1 - phase) * 2; + const head = lerp(0, range, sweep); const delta = index - head; const abs = delta < 0 ? -delta : delta; if (abs <= KITT_HEAD_HALF) return 1; diff --git a/packages/coding-agent/test/modes/theme/shimmer.test.ts b/packages/coding-agent/test/modes/theme/shimmer.test.ts index 750d3a5b0..cd2e09793 100644 --- a/packages/coding-agent/test/modes/theme/shimmer.test.ts +++ b/packages/coding-agent/test/modes/theme/shimmer.test.ts @@ -29,21 +29,31 @@ const probe: ShimmerPalette = { }; /** - * Index of the first visible cell painted with the crest (high, code 33) color, - * or undefined when the band sits in the padding and no cell is lit. Walks the + * Visible cell indexes painted with the crest (high, code 33) color. Walks the * coalesced `ESC[m` runs that {@link shimmerText} emits. */ -function crestStart(rendered: string): number | undefined { +function highIndices(rendered: string): number[] { + const indices: number[] = []; const run = /\x1b\[(\d+)m([^\x1b]*)/g; let idx = 0; let m: RegExpExecArray | null = run.exec(rendered); while (m !== null) { const len = [...m[2]].length; - if (m[1] === "33" && len > 0) return idx; + if (m[1] === "33") { + for (let i = 0; i < len; i++) indices.push(idx + i); + } idx += len; m = run.exec(rendered); } - return undefined; + return indices; +} + +function crestCenter(rendered: string): number | undefined { + const indices = highIndices(rendered); + const first = indices[0]; + const last = indices[indices.length - 1]; + if (first === undefined || last === undefined) return undefined; + return (first + last) / 2; } describe("shimmerText", () => { @@ -53,9 +63,8 @@ describe("shimmerText", () => { it("uses a supplied raw ANSI color for the shimmer crest", () => { vi.spyOn(settingsModule, "isSettingsInitialized").mockReturnValue(false); - // t chosen so the fixed-velocity band (30 cells/s) crest sits on the char: - // pos = (333/1000)*30 ≈ 10 = CLASSIC_PADDING, i.e. centered on index 0. - vi.spyOn(Date, "now").mockReturnValue(333); + // At the beginning of the shared sweep the crest is on the first cell. + vi.spyOn(Date, "now").mockReturnValue(0); const rendered = shimmerText("x", testTheme, { low: "dim", @@ -69,8 +78,9 @@ describe("shimmerText", () => { }); }); -describe("shimmer band velocity", () => { +describe("shimmer phase alignment", () => { const FRAME_MS = 1000 / 30; + const SWEEP_MS = 2000; let nowMs = 0; beforeEach(() => { @@ -83,45 +93,30 @@ describe("shimmer band velocity", () => { vi.restoreAllMocks(); }); - function crestTrack(length: number, startMs: number, frames: number): (number | undefined)[] { - const text = "x".repeat(length); - const out: (number | undefined)[] = []; - for (let i = 0; i < frames; i++) { - nowMs = startMs + i * FRAME_MS; - out.push(crestStart(shimmerText(text, testTheme, probe))); - } - return out; + function renderLength(length: number): string { + return shimmerText("x".repeat(length), testTheme, probe); } - it("advances the crest by at most one cell per 30fps frame", () => { - // L=40 → period 60 cells; at 30 cells/s that is a 2s sweep (60 frames). - // 75 frames covers a full sweep plus the padding gap into the next one. - const track = crestTrack(40, 0, 75); - let compared = 0; - for (let i = 1; i < track.length; i++) { - const a = track[i - 1]; - const b = track[i]; - if (a === undefined || b === undefined) continue; // skip the padding gap - expect(Math.abs(b - a)).toBeLessThanOrEqual(1); - compared++; + it("starts every string at the first cell and lands at the last cell", () => { + for (const length of [8, 40, 80]) { + nowMs = 0; + expect(highIndices(renderLength(length))).toContain(0); + + nowMs = SWEEP_MS - 1; + expect(highIndices(renderLength(length))).toContain(length - 1); } - // Fail loudly rather than vacuously pass if the crest were never detected. - expect(compared).toBeGreaterThan(20); }); - it("moves the crest at a length-independent speed", () => { - // Starting where the crest enters at index 0 (pos = CLASSIC_PADDING = 10), - // the crest must travel the same number of cells over a fixed wall-clock - // window regardless of string length — the contract of fixed-velocity - // sweeping (a longer message must not shimmer faster). - const startMs = (10 / 30) * 1000; // pos = 10 cells → crest at index 0 - const span = (track: (number | undefined)[]): number => { - const def = track.filter((v): v is number => v !== undefined); - return def.length ? def[def.length - 1] - def[0] : 0; - }; - const shortSpan = span(crestTrack(20, startMs, 10)); - const longSpan = span(crestTrack(60, startMs, 10)); - expect(shortSpan).toBeGreaterThan(0); - expect(Math.abs(shortSpan - longSpan)).toBeLessThanOrEqual(1); + it("keeps the crest at the same normalized position across string lengths", () => { + nowMs = 15 * FRAME_MS; + const shortCenter = crestCenter(renderLength(20)); + const longCenter = crestCenter(renderLength(60)); + + expect(shortCenter).toBeDefined(); + expect(longCenter).toBeDefined(); + const shortPhase = shortCenter! / (20 - 1); + const longPhase = longCenter! / (60 - 1); + + expect(Math.abs(shortPhase - longPhase)).toBeLessThanOrEqual(0.02); }); }); From 462c2b749cf14d78db168dd6c32830ed3cabbe8b Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:22:01 +0200 Subject: [PATCH 15/53] fix(coding-agent/task): show agent type in task result header Append the dispatched agent type to the task result frame header so it reads `Task 16 agents: Reviewer` instead of just `Task 16 agents`. --- packages/agent/src/agent-loop.ts | 1 - packages/agent/src/agent.ts | 2 -- packages/agent/src/types.ts | 1 - packages/agent/test/agent-loop.test.ts | 1 - packages/coding-agent/src/session/agent-session.ts | 1 - packages/coding-agent/src/task/render.ts | 8 +++++++- .../test/agent-session-message-pipeline.test.ts | 1 - 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 3f56c7949..052b72afb 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -445,7 +445,6 @@ interface StepCounter { count: number; } - /** * Resolve aside entries at the moment the loop is about to inject them. Each entry * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index b1f042372..4a339a1c3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -110,7 +110,6 @@ export interface AgentOptions { */ interruptMode?: "immediate" | "wait"; - /** * API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic") */ @@ -581,7 +580,6 @@ export class Agent { this.#maxRetryDelayMs = value; } - get state(): AgentState { return this.#state; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index cde0fd5bd..5777a9b82 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -47,7 +47,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions { */ interruptMode?: "immediate" | "wait"; - /** * Optional session identifier forwarded to LLM providers. * Used by providers that support session-based caching (e.g., OpenAI Codex). diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 4570e29c7..c761ae5a4 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -372,7 +372,6 @@ describe("agentLoop with AgentMessage", () => { } }); - it("injects and strips intent when intent tracing is enabled", async () => { const toolSchema = z.object({ value: z.string() }); const executedParams: Record[] = []; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ab922e6ed..7d56f4228 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1093,7 +1093,6 @@ export class AgentSession { this.#flushPendingAgentEnd(); } - #flushPendingAgentEnd(): void { const pending = this.#pendingAgentEndEmit; if (!pending) return; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 8e3e600e6..1a77bdf94 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -1102,11 +1102,17 @@ export function renderResult( const isError = aborted || failed; const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0); const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success"; + // Surface the dispatched agent type (e.g. `Reviewer`) alongside the count so + // the header reads `Task 16 agents: Reviewer`. All tasks in one call share a + // single `agent` type (top-level param), so one label covers the whole batch. + const agentName = args?.agent?.trim(); + const countLabel = agentCount > 0 ? `${agentCount} ${agentCount === 1 ? "agent" : "agents"}` : undefined; + const metaLabel = countLabel ? (agentName ? `${countLabel}: ${agentName}` : countLabel) : agentName; const header = renderStatusLine( { icon, title: "Task", - meta: agentCount > 0 ? [`${agentCount} ${agentCount === 1 ? "agent" : "agents"}`] : undefined, + meta: metaLabel ? [metaLabel] : undefined, }, theme, ); diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 7eed5e7d0..8ac1f9b20 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -50,7 +50,6 @@ describe("AgentSession message pipeline", () => { } }); - it("applies transformContext before convertToLlm", async () => { const inputMessages: AgentMessage[] = [{ role: "user", content: "hello", timestamp: Date.now() }]; const transformedMessages: AgentMessage[] = [ From 1e5017bf7d5891f285adcf6b2fd5dff553c72266 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:29:59 +0200 Subject: [PATCH 16/53] feat: enabled block-boundary context support across read and diff previews - Added tree-sitter `enclosing_block_boundaries` API with line range models. - Added N-API `enclosingBlockBoundaries` bridge and exported JS declarations. - Replaced matching-bracket context resolution with source-aware block context in read and diff flows. - Passed source path through diff/read generators to surface native block boundary previews. --- crates/pi-ast/src/block.rs | 198 +++++++++++++++++- crates/pi-natives/src/block.rs | 48 +++++ packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/edit/diff.ts | 33 ++- .../coding-agent/src/edit/hashline/diff.ts | 2 +- .../coding-agent/src/edit/hashline/execute.ts | 2 +- packages/coding-agent/src/edit/modes/patch.ts | 8 +- .../coding-agent/src/edit/modes/replace.ts | 2 +- .../coding-agent/src/prompts/tools/read.md | 4 +- packages/coding-agent/src/tools/read.ts | 40 ++-- ...{matching-brackets.ts => block-context.ts} | 102 ++++++++- .../test/read-multi-range.test.ts | 31 +++ .../coding-agent/test/tools/edit-diff.test.ts | 3 +- packages/natives/CHANGELOG.md | 4 + packages/natives/native/index.d.ts | 32 +++ packages/natives/native/index.js | 1 + 16 files changed, 464 insertions(+), 48 deletions(-) rename packages/coding-agent/src/utils/{matching-brackets.ts => block-context.ts} (62%) diff --git a/crates/pi-ast/src/block.rs b/crates/pi-ast/src/block.rs index 567e9f95f..fd551d9a6 100644 --- a/crates/pi-ast/src/block.rs +++ b/crates/pi-ast/src/block.rs @@ -8,10 +8,12 @@ //! full span; pointing at a continuation line or a lone closing delimiter //! resolves to nothing. +use std::collections::BTreeSet; + use anyhow::{Result, anyhow}; use ast_grep_core::tree_sitter::LanguageExt; use serde::{Deserialize, Serialize}; -use tree_sitter::{Parser, Point}; +use tree_sitter::{Parser, Point, TreeCursor}; use crate::summary::{node_content_end_line, node_start_line, resolve_language}; @@ -111,6 +113,141 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result> })) } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct LineRange { + /// 1-indexed inclusive first visible line. + pub start_line: u32, + /// 1-indexed inclusive last visible line. + pub end_line: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnclosingBoundaryOptions { + /// Source code to inspect. + pub code: String, + /// Language alias (e.g. "rust", "typescript") used before path inference. + pub lang: Option, + /// File path used to infer language by extension when `lang` is omitted. + pub path: Option, + /// 1-indexed inclusive visible line ranges (the lines actually shown). + pub ranges: Vec, +} + +/// Sort, drop invalid, and merge adjacent/overlapping ranges so visibility +/// tests can binary-search a non-overlapping list. +fn normalize_ranges(mut ranges: Vec) -> Vec { + ranges.retain(|range| range.start_line > 0 && range.end_line >= range.start_line); + ranges.sort_by(|a, b| { + a.start_line + .cmp(&b.start_line) + .then(a.end_line.cmp(&b.end_line)) + }); + let mut merged: Vec = Vec::with_capacity(ranges.len()); + for range in ranges { + if let Some(last) = merged.last_mut() + && range.start_line <= last.end_line.saturating_add(1) + { + last.end_line = last.end_line.max(range.end_line); + continue; + } + merged.push(range); + } + merged +} + +fn is_visible(merged: &[LineRange], line: u32) -> bool { + merged + .binary_search_by(|range| { + if line < range.start_line { + std::cmp::Ordering::Greater + } else if line > range.end_line { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} + +/// Depth-first walk collecting boundary lines from every multi-line named node +/// that straddles a visible-range edge. A single reused [`TreeCursor`] keeps +/// the traversal allocation-free. +fn collect_boundaries(cursor: &mut TreeCursor<'_>, merged: &[LineRange], out: &mut BTreeSet) { + let node = cursor.node(); + // Skip the whole-file root: its only "boundary" is EOF, never a useful + // matching line (mirrors `block_range_at` excluding the root). + if node.is_named() && node.parent().is_some() { + let start = node_start_line(node); + let end = node_content_end_line(node); + if end > start { + let start_visible = is_visible(merged, start); + let end_visible = is_visible(merged, end); + // Opener shown, closer off-window → surface the closer (and vice + // versa). A node fully inside or fully outside the window adds + // nothing. + if start_visible && !end_visible { + out.insert(end); + } else if end_visible && !start_visible { + out.insert(start); + } + } + } + if cursor.goto_first_child() { + loop { + collect_boundaries(cursor, merged, out); + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } +} + +/// Generalize "show the matching bracket" to every tree-sitter block: for each +/// multi-line named node whose span crosses the visible window, return the +/// boundary line sitting *outside* that window. +/// +/// - node opens on a visible line but closes past the window → its closing line +/// - node closes on a visible line but opens before the window → its opening +/// line +/// +/// Because the trigger is an endpoint *inside* the window, the result is +/// bounded by the window size (not nesting depth), exactly like a bracket scan +/// — but it also covers indentation languages (Python) and uses real syntactic +/// spans. +/// +/// Returns `None` when the language is unrecognized or the source fails to +/// parse / carries a syntax error (caller falls back to a lexical bracket +/// scan); `Some(sorted unique boundary lines)` otherwise (possibly empty). +pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result>> { + let EnclosingBoundaryOptions { code, lang, path, ranges } = options; + let merged = normalize_ranges(ranges); + if code.is_empty() || merged.is_empty() { + return Ok(Some(Vec::new())); + } + let Some(language) = resolve_language(lang.as_deref(), path.as_deref()) else { + return Ok(None); + }; + let mut parser = Parser::new(); + parser + .set_language(&language.get_ts_language()) + .map_err(|err| anyhow!("Failed to load tree-sitter language: {err}"))?; + let Some(tree) = parser.parse(&code, None) else { + return Ok(None); + }; + let root = tree.root_node(); + // A file-level syntax error makes error-recovery spans unreliable; defer to + // the lexical scanner rather than emit boundaries off a broken tree. + if root.has_error() { + return Ok(None); + } + + let mut boundaries = BTreeSet::new(); + let mut cursor = root.walk(); + collect_boundaries(&mut cursor, &merged, &mut boundaries); + Ok(Some(boundaries.into_iter().collect())) +} + #[cfg(test)] mod tests { use super::*; @@ -220,4 +357,63 @@ mod tests { let code = "struct A;\nstruct B {\n x: u32,\n}\n"; assert_eq!(resolve(code, "r.rs", 2), Some(BlockRange { start_line: 2, end_line: 4 })); } + + fn boundaries(code: &str, path: &str, ranges: &[(u32, u32)]) -> Option> { + enclosing_block_boundaries(EnclosingBoundaryOptions { + code: code.to_string(), + lang: None, + path: Some(path.to_string()), + ranges: ranges + .iter() + .map(|&(start_line, end_line)| LineRange { start_line, end_line }) + .collect(), + }) + .expect("boundary resolution succeeds") + } + + const TS_FN: &str = "function outer() {\n const a = 1;\n const b = 2;\n const c = 3;\n \ + return a + b + c;\n}\nafter();\n"; + + #[test] + fn surfaces_closing_brace_for_visible_opener() { + // Window is the opening line only; its block closes on line 6. + assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 1)]), Some(vec![6])); + } + + #[test] + fn surfaces_opening_brace_for_visible_closer() { + // Window is the closing line only; its block opens on line 1. + assert_eq!(boundaries(TS_FN, "x.ts", &[(6, 6)]), Some(vec![1])); + } + + #[test] + fn interior_only_window_adds_no_boundary() { + // Neither the opener (1) nor the closer (6) is visible, so the bracket + // scan would add nothing — and neither do we. + assert_eq!(boundaries(TS_FN, "x.ts", &[(3, 4)]), Some(vec![])); + } + + #[test] + fn whole_file_window_adds_no_boundary() { + assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 7)]), Some(vec![])); + } + + #[test] + fn python_indentation_block_uses_syntactic_span() { + // Python has no closing delimiter — the def's span ends at the last + // body line. Showing the `def` header surfaces that end line. + let code = "def greet(name):\n a = 1\n b = 2\n return a + b\n"; + assert_eq!(boundaries(code, "g.py", &[(1, 1)]), Some(vec![4])); + } + + #[test] + fn syntax_error_falls_back_to_none() { + let code = "function broken() {\n if (y) {\n"; + assert_eq!(boundaries(code, "b.ts", &[(1, 1)]), None); + } + + #[test] + fn unrecognized_language_falls_back_to_none() { + assert_eq!(boundaries(TS_FN, "x.unknownext", &[(1, 1)]), None); + } } diff --git a/crates/pi-natives/src/block.rs b/crates/pi-natives/src/block.rs index 88d942693..4bdd14e59 100644 --- a/crates/pi-natives/src/block.rs +++ b/crates/pi-natives/src/block.rs @@ -45,3 +45,51 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result> .map(|range| range.map(Into::into)) .map_err(|error| Error::from_reason(error.to_string())) } + +#[napi(object)] +pub struct LineRange { + /// 1-indexed inclusive first visible line. + pub start_line: u32, + /// 1-indexed inclusive last visible line. + pub end_line: u32, +} + +#[napi(object)] +pub struct EnclosingBoundaryOptions { + /// Source code to inspect. + pub code: String, + /// Language alias (e.g. "rust", "typescript") used before path inference. + pub lang: Option, + /// File path used to infer language by extension when `lang` is omitted. + pub path: Option, + /// 1-indexed inclusive visible line ranges (the lines actually shown). + pub ranges: Vec, +} + +/// Matching-bracket context for an arbitrary tree-sitter language. +/// +/// For each multi-line named node whose span crosses the visible window, return +/// the boundary line sitting *outside* that window (the closer when the opener +/// is shown, the opener when the closer is shown). Covers brace and indentation +/// languages alike using real syntactic spans. +/// +/// Returns `null` when the language is unrecognized or the source fails to +/// parse / carries a syntax error (caller should fall back to a lexical scan); +/// a sorted, unique list of 1-indexed boundary lines otherwise. +#[napi] +pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result>> { + pi_ast::block::enclosing_block_boundaries(pi_ast::block::EnclosingBoundaryOptions { + code: options.code, + lang: options.lang, + path: options.path, + ranges: options + .ranges + .into_iter() + .map(|range| pi_ast::block::LineRange { + start_line: range.start_line, + end_line: range.end_line, + }) + .collect(), + }) + .map_err(|error| Error::from_reason(error.to_string())) +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 670528061..a3ba00a7d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,7 +15,7 @@ ### Fixed - Fixed task-row shimmer timing so every running description starts its highlight on the first character together and reaches the last character together, regardless of text length. -- Fixed read and edit previews to include the matching bracket line behind an ellipsis when a shown opening/closing bracket pairs outside the displayed range. +- Fixed read and edit previews to surface the enclosing syntactic block's off-window boundary line (behind an ellipsis) when a shown line opens or closes a block whose other end falls outside the displayed range. Powered by a new tree-sitter `enclosingBlockBoundaries` native, so it covers brace languages and indentation languages (Python) using real syntactic spans, with a lexical bracket scan as fallback for unparseable sources. - Fixed `tab.screenshot({ selector })` hanging for the entire cell budget on continuously-animating pages (WebGL / `backdrop-filter` "glass" effects). The element-screenshot path no longer routes through puppeteer's `scrollIntoViewIfNeeded()`, whose `IntersectionObserver` promise can stall indefinitely under heavy rendering; it now does a single instant `scrollIntoView` and captures with `scrollIntoView: false` (relying on `captureBeyondViewport`), so off-screen elements are still captured without the stall. - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows - Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages diff --git a/packages/coding-agent/src/edit/diff.ts b/packages/coding-agent/src/edit/diff.ts index 1cd9b11c9..6759f5ae3 100644 --- a/packages/coding-agent/src/edit/diff.ts +++ b/packages/coding-agent/src/edit/diff.ts @@ -6,7 +6,7 @@ */ import * as Diff from "diff"; import { resolveToCwd } from "../tools/path-utils"; -import { findMatchingBracketContextLines } from "../utils/matching-brackets"; +import { type BlockContextSource, findBlockContextLines } from "../utils/block-context"; import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace"; import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; import { readEditFileText } from "./read-file"; @@ -127,7 +127,12 @@ function insertBracketContextRows( } } -function addMatchingBracketContextRows(rows: string[], oldLines: readonly string[], newLines: readonly string[]): void { +function addMatchingBracketContextRows( + rows: string[], + oldLines: readonly string[], + newLines: readonly string[], + source: BlockContextSource, +): void { const oldVisible: number[] = []; const newVisible: number[] = []; const seenRows = new Set(rows); @@ -139,15 +144,20 @@ function addMatchingBracketContextRows(rows: string[], oldLines: readonly string else newVisible.push(parsed.lineNumber); } - insertBracketContextRows(rows, "old", findMatchingBracketContextLines(oldLines, oldVisible), seenRows); - insertBracketContextRows(rows, "new", findMatchingBracketContextLines(newLines, newVisible), seenRows); + insertBracketContextRows(rows, "old", findBlockContextLines(oldLines, oldVisible, source), seenRows); + insertBracketContextRows(rows, "new", findBlockContextLines(newLines, newVisible, source), seenRows); } /** * Generate a unified diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). */ -export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult { +export function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 2, + source: BlockContextSource = {}, +): DiffResult { const parts = Diff.diffLines(oldContent, newContent); const output: string[] = []; @@ -251,7 +261,7 @@ export function generateDiffString(oldContent: string, newContent: string, conte } } - addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n")); + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source); return { diff: output.join("\n"), firstChangedLine }; } @@ -280,7 +290,12 @@ export interface ReplaceResult { * Generate a unified diff string without file headers. * Returns both the diff string and the first changed line number (in the new file). */ -export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult { +export function generateUnifiedDiffString( + oldContent: string, + newContent: string, + contextLines = 3, + source: BlockContextSource = {}, +): DiffResult { const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines }); const output: string[] = []; let firstChangedLine: number | undefined; @@ -311,7 +326,7 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string } } - addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n")); + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source); return { diff: output.join("\n"), firstChangedLine }; } @@ -900,7 +915,7 @@ export async function computeEditDiff( }; } - return generateDiffString(normalizedContent, result.content); + return generateDiffString(normalizedContent, result.content, undefined, { path }); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } diff --git a/packages/coding-agent/src/edit/hashline/diff.ts b/packages/coding-agent/src/edit/hashline/diff.ts index 534aa43ef..fe3fecdda 100644 --- a/packages/coding-agent/src/edit/hashline/diff.ts +++ b/packages/coding-agent/src/edit/hashline/diff.ts @@ -230,7 +230,7 @@ export async function computeHashlineSectionDiff( if (options.streaming) return buildStreamingSectionDiff(section, normalized); const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options }); if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` }; - return generateDiffString(normalized, result.text); + return generateDiffString(normalized, result.text, undefined, { path: section.path }); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index dffdd61c3..54d091c94 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -97,7 +97,7 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR }; } - const diff = generateDiffString(result.before, result.after); + const diff = generateDiffString(result.before, result.after, undefined, { path: result.path }); const preview = buildCompactDiffPreview(diff.diff); const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index 96d8a6a24..2734734f1 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -1571,7 +1571,9 @@ export async function computePatchDiff( if (!normalizedOld && !normalizedNew) { return { diff: "", firstChangedLine: undefined }; } - return generateUnifiedDiffString(normalizedOld, normalizedNew); + return generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, { + path: result.change.newPath ?? result.change.path, + }); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } @@ -1785,7 +1787,9 @@ export async function executePatchSingle( if (result.change.type === "update" && result.change.oldContent && result.change.newContent) { const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text); const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text); - diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew); + diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, undefined, { + path: result.change.newPath ?? result.change.path, + }); } let resultText: string; diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index be3fde872..4784bd75d 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -1078,7 +1078,7 @@ export async function executeReplaceSingle( ); invalidateFsScanAfterWrite(absolutePath); - const diffResult = generateDiffString(normalizedContent, result.content); + const diffResult = generateDiffString(normalizedContent, result.content, undefined, { path }); const resultText = result.count > 1 ? `Successfully replaced ${result.count} occurrences in ${path}.` diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 70a658f3e..4bdb25d28 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -18,8 +18,8 @@ Append `:` to `path`. The bare path falls back to the default mode. - `:50` / `:50-` — read from line 50 onward. - `:50-200` — lines 50–200 inclusive. - `:50+150` — 150 lines starting at line 50. -- `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines; if an emitted bracket pairs with a line outside the window, that matching line is included behind an ellipsis). -- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact requested bounds plus matching bracket lines when a shown bracket's pair sits outside those bounds. +- `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines). +- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact bounds with no context padding. - `:raw` — verbatim text; no anchors, no summary, no line prefixes. - `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order. - `:conflicts` — one-line-per-block index of every unresolved git merge conflict. diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index e19e5587f..9b39f4b97 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -35,14 +35,10 @@ import { } from "../session/streaming-output"; import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui"; import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block"; +import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading"; import { convertFileWithMarkit } from "../utils/markit"; -import { - buildLineEntriesWithMatchingBracketContext, - type LineEntry, - lineEntriesToPlainText, -} from "../utils/matching-brackets"; import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree"; import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "./archive-reader"; import { @@ -961,9 +957,9 @@ export class ReadTool implements AgentTool { return prependHashlineHeader(formatted, hashContext); }; const buildLineEntries = (endLineDisplay: number): LineEntry[] => - buildLineEntriesWithMatchingBracketContext(allLines, [ - { startLine: startLineDisplay, endLine: endLineDisplay }, - ]); + buildLineEntriesWithBlockContext(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], { + path: options.sourcePath, + }); let outputText: string; let truncationInfo: @@ -1090,7 +1086,7 @@ export class ReadTool implements AgentTool { if (options.raw === true) { outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : ""; } else if (visibleSpans.length > 0) { - const entries = buildLineEntriesWithMatchingBracketContext(allLines, visibleSpans); + const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath }); const firstLine = entries.find(entry => entry.kind === "line"); if (firstLine?.kind === "line") { details.displayContent = { @@ -1220,16 +1216,21 @@ export class ReadTool implements AgentTool { let outputText: string; if (!rawSelector && fullLines && visibleSpans.length > 0) { - const entries = buildLineEntriesWithMatchingBracketContext(fullLines, visibleSpans, { - lineText: (lineNumber, sourceText) => { - const visibleText = displayLineByNumber.get(lineNumber); - if (visibleText !== undefined) return visibleText; - if (maxColumns <= 0) return sourceText; - const truncated = truncateLine(sourceText, maxColumns); - if (truncated.wasTruncated) columnTruncated = maxColumns; - return truncated.text; + const entries = buildLineEntriesWithBlockContext( + fullLines, + visibleSpans, + { path: absolutePath }, + { + lineText: (lineNumber, sourceText) => { + const visibleText = displayLineByNumber.get(lineNumber); + if (visibleText !== undefined) return visibleText; + if (maxColumns <= 0) return sourceText; + const truncated = truncateLine(sourceText, maxColumns); + if (truncated.wasTruncated) columnTruncated = maxColumns; + return truncated.text; + }, }, - }); + ); const firstLine = entries.find(entry => entry.kind === "line"); displayContent = { text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), @@ -2100,9 +2101,10 @@ export class ReadTool implements AgentTool { }; const formatBracketAwareText = (): string | undefined => { if (!bracketContextFullLines) return undefined; - const entries = buildLineEntriesWithMatchingBracketContext( + const entries = buildLineEntriesWithBlockContext( bracketContextFullLines, [{ startLine: startLineDisplay, endLine: displayedEndLine }], + { path: absolutePath }, { lineText: (lineNumber, sourceText) => { const visibleText = displayLineByNumber.get(lineNumber); diff --git a/packages/coding-agent/src/utils/matching-brackets.ts b/packages/coding-agent/src/utils/block-context.ts similarity index 62% rename from packages/coding-agent/src/utils/matching-brackets.ts rename to packages/coding-agent/src/utils/block-context.ts index 23105b99c..5b450cf97 100644 --- a/packages/coding-agent/src/utils/matching-brackets.ts +++ b/packages/coding-agent/src/utils/block-context.ts @@ -1,3 +1,6 @@ +import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives"; +import { logger } from "@oh-my-pi/pi-utils"; + const OPEN_TO_CLOSE: Record = { "(": ")", "[": "]", @@ -15,6 +18,12 @@ export interface LineSpan { endLine: number; } +/** Where the source came from, so tree-sitter can pick a grammar. */ +export interface BlockContextSource { + path?: string; + lang?: string; +} + export type LineEntry = { kind: "line"; lineNumber: number; text: string; context: boolean } | { kind: "ellipsis" }; interface StackEntry { @@ -63,6 +72,57 @@ function hasEveryLineVisible(visible: ReadonlySet, totalLines: number): return totalLines > 0 && visible.size >= totalLines; } +/** Collapse a set of visible line numbers into sorted, merged inclusive spans. */ +function visibleSetToSpans(visible: ReadonlySet): LineSpan[] { + const sorted = [...visible].sort((left, right) => left - right); + const spans: LineSpan[] = []; + for (const line of sorted) { + const previous = spans[spans.length - 1]; + if (previous && line <= previous.endLine + 1) { + previous.endLine = line; + continue; + } + spans.push({ startLine: line, endLine: line }); + } + return spans; +} + +/** + * Tree-sitter-backed block boundaries. For each multi-line named node whose + * span crosses the visible window, the native side returns the boundary line + * outside that window (closer when the opener is shown, opener when the closer + * is shown). Returns `null` when the language is unrecognized or the source has + * a syntax error so the caller can fall back to a lexical bracket scan. + */ +function nativeBlockContext( + fullLines: readonly string[], + visible: ReadonlySet, + source: BlockContextSource, +): Map | null { + if (!source.path && !source.lang) return null; + const ranges = visibleSetToSpans(visible); + if (ranges.length === 0) return new Map(); + let boundaries: number[] | null; + try { + boundaries = enclosingBlockBoundaries({ + code: fullLines.join("\n"), + path: source.path, + lang: source.lang, + ranges, + }); + } catch (error) { + logger.debug("enclosingBlockBoundaries failed; using lexical bracket fallback", { error }); + return null; + } + if (boundaries === null) return null; + const context = new Map(); + for (const lineNumber of boundaries) { + if (visible.has(lineNumber)) continue; + context.set(lineNumber, fullLines[lineNumber - 1] ?? ""); + } + return context; +} + function findMatchingStackIndex(stack: readonly StackEntry[], opener: string): number { for (let index = stack.length - 1; index >= 0; index--) { if (stack[index].opener === opener) return index; @@ -79,14 +139,14 @@ function isHashCommentStart(line: string, index: number): boolean { return true; } -export function findMatchingBracketContextLines( - fullLines: readonly string[], - visibleLinesInput: ReadonlySet | readonly number[], -): Map { - const visible = visibleLinesInput instanceof Set ? visibleLinesInput : new Set(visibleLinesInput); +/** + * Lexical bracket-matching fallback for sources tree-sitter can't parse + * (unknown extensions, syntax errors). Pairs `()[]{}` while skipping strings + * and line/block comments, and reports the matching line when one endpoint is + * visible and the other is not. + */ +function lexicalBracketContext(fullLines: readonly string[], visible: ReadonlySet): Map { const context = new Map(); - if (visible.size === 0 || hasEveryLineVisible(visible, fullLines.length)) return context; - const stack: StackEntry[] = []; let mode: ScannerMode = "code"; let escaped = false; @@ -189,16 +249,40 @@ export function findMatchingBracketContextLines( return context; } -export function buildLineEntriesWithMatchingBracketContext( +/** + * Resolve the off-window boundary lines for a visible window: tree-sitter + * syntactic spans first (covers brace and indentation languages), falling back + * to a lexical bracket scan when the grammar is unavailable. Returns a map of + * `lineNumber → source text` for the lines to surface, never including a line + * already visible. + */ +export function findBlockContextLines( + fullLines: readonly string[], + visibleInput: ReadonlySet | readonly number[], + source: BlockContextSource = {}, +): Map { + const visible = visibleInput instanceof Set ? visibleInput : new Set(visibleInput); + if (visible.size === 0 || hasEveryLineVisible(visible, fullLines.length)) return new Map(); + return nativeBlockContext(fullLines, visible, source) ?? lexicalBracketContext(fullLines, visible); +} + +/** + * Build display entries for `visibleSpans` plus any off-window block-boundary + * lines, in source order, with `{ kind: "ellipsis" }` markers inserted across + * non-contiguous gaps. `options.lineText` lets callers substitute display text + * (e.g. column-truncated lines) for a given line number. + */ +export function buildLineEntriesWithBlockContext( fullLines: readonly string[], visibleSpans: readonly LineSpan[], + source: BlockContextSource = {}, options: { lineText?: (lineNumber: number, sourceText: string, context: boolean) => string; } = {}, ): LineEntry[] { const spans = normalizeLineSpans(visibleSpans, fullLines.length); const visible = visibleLineNumbers(spans); - const context = findMatchingBracketContextLines(fullLines, visible); + const context = findBlockContextLines(fullLines, visible, source); const allLines = new Set(visible); for (const lineNumber of context.keys()) allLines.add(lineNumber); diff --git a/packages/coding-agent/test/read-multi-range.test.ts b/packages/coding-agent/test/read-multi-range.test.ts index 00fc40bf8..975fe0ce2 100644 --- a/packages/coding-agent/test/read-multi-range.test.ts +++ b/packages/coding-agent/test/read-multi-range.test.ts @@ -120,6 +120,37 @@ describe("read tool multi-range selector", () => { expect(text).not.toContain("const four = 4"); }); + it("uses tree-sitter syntactic spans for indentation languages (Python)", async () => { + const filePath = path.join(tmpDir, "module.py"); + await fs.writeFile( + filePath, + [ + "def greet(name):", + " a = 1", + " b = 2", + " c = 3", + " d = 4", + " e = 5", + " f = 6", + " g = 7", + " return a + b + c + d + e + f + g + len(name)", + "trailing = 1", + ].join("\n"), + ); + + const tool = new ReadTool(createSession(tmpDir)); + // Read only the `def` header (expands by a few trailing context lines). + // Python has no closing delimiter, so a bracket scan would surface + // nothing; tree-sitter surfaces the def's last body line (9) as the + // block boundary, behind an ellipsis for the skipped middle. + const text = textOutput(await tool.execute("call-py-def", { path: `${filePath}:1-1` })); + + expect(text).toContain("def greet(name):"); + expect(text).toContain("…"); + expect(text).toContain("return a + b + c + d + e + f + g + len(name)"); + expect(text).not.toContain("trailing = 1"); + }); + it("merges overlapping ranges into a single contiguous block", async () => { const filePath = path.join(tmpDir, "numbered.txt"); await fs.writeFile(filePath, makeNumberedContent(20)); diff --git a/packages/coding-agent/test/tools/edit-diff.test.ts b/packages/coding-agent/test/tools/edit-diff.test.ts index 964540acc..aec46fd04 100644 --- a/packages/coding-agent/test/tools/edit-diff.test.ts +++ b/packages/coding-agent/test/tools/edit-diff.test.ts @@ -35,8 +35,7 @@ describe("generateDiffString", () => { ]; const newLines = [...oldLines]; newLines[0] = "function renamed() {"; - - const result = generateDiffString(oldLines.join("\n"), newLines.join("\n"), 1); + const result = generateDiffString(oldLines.join("\n"), newLines.join("\n"), 1, { path: "sample.ts" }); const diffLines = result.diff.split("\n"); expect(diffLines).toContain("-1|function outer() {"); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 964643d9d..64fc2dea5 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added the `enclosingBlockBoundaries` native API (with `EnclosingBoundaryOptions` and `LineRange` types) that returns, for a set of visible line ranges, the off-window boundary lines of every multi-line tree-sitter node whose span crosses the window — the closer when an opener is shown and the opener when a closer is shown. Covers brace and indentation languages (Python) via real syntactic spans; returns `null` for unrecognized languages or sources with syntax errors so callers can fall back to a lexical scan. + ## [15.10.2] - 2026-06-08 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index e8ee126e0..e462e5a0f 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -428,6 +428,31 @@ export declare enum Ellipsis { Omit = 2 } +/** + * Matching-bracket context for an arbitrary tree-sitter language. + * + * For each multi-line named node whose span crosses the visible window, return + * the boundary line sitting *outside* that window (the closer when the opener + * is shown, the opener when the closer is shown). Covers brace and indentation + * languages alike using real syntactic spans. + * + * Returns `null` when the language is unrecognized or the source fails to + * parse / carries a syntax error (caller should fall back to a lexical scan); + * a sorted, unique list of 1-indexed boundary lines otherwise. + */ +export declare function enclosingBlockBoundaries(options: EnclosingBoundaryOptions): Array | null + +export interface EnclosingBoundaryOptions { + /** Source code to inspect. */ + code: string + /** Language alias (e.g. "rust", "typescript") used before path inference. */ + lang?: string + /** File path used to infer language by extension when `lang` is omitted. */ + path?: string + /** 1-indexed inclusive visible line ranges (the lines actually shown). */ + ranges: Array +} + /** * Encode image bytes into a SIXEL escape sequence for terminal rendering. * @@ -907,6 +932,13 @@ export declare enum KeyEventType { Release = 3 } +export interface LineRange { + /** 1-indexed inclusive first visible line. */ + startLine: number + /** 1-indexed inclusive last visible line. */ + endLine: number +} + /** * Walk the workspace once and return tree entries plus AGENTS.md candidates. * diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 938b782a9..a694fcdbc 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -31,6 +31,7 @@ export const blockRangeAt = nativeBindings.blockRangeAt; export const copyToClipboard = nativeBindings.copyToClipboard; export const countTokens = nativeBindings.countTokens; export const detectMacOSAppearance = nativeBindings.detectMacOSAppearance; +export const enclosingBlockBoundaries = nativeBindings.enclosingBlockBoundaries; export const encodeSixel = nativeBindings.encodeSixel; export const executeShell = nativeBindings.executeShell; export const extractSegments = nativeBindings.extractSegments; From 0890b2be61f3250163dd12a8f5fc84e71e32675d Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:40:18 +0200 Subject: [PATCH 17/53] fix: fixed tool-call recovery, stream parsing, and image normalization flows - Fixed tool result validation to reject invalid blocks and append explicit error diagnostics. - Handled aborted and leaked tool calls by returning abort results and dropping partial leaked output. - Fixed Anthropic streaming by enforcing strict SSE parsing and content-block lifecycle checks. - Fixed image handling by normalizing model-context inputs and preserving images on resize failure. --- packages/agent/CHANGELOG.md | 9 + packages/agent/src/agent-loop.ts | 422 +++++++++---- packages/ai/CHANGELOG.md | 10 +- packages/ai/src/providers/anthropic.ts | 568 +++++++++++------- .../ai/test/anthropic-stream-envelope.test.ts | 19 +- packages/coding-agent/CHANGELOG.md | 10 +- .../coding-agent/src/session/agent-session.ts | 86 ++- .../coding-agent/src/utils/image-loading.ts | 32 +- .../test/image-input-normalization.test.ts | 32 +- 9 files changed, 799 insertions(+), 389 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 687e1cb1f..9d0472231 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,10 +1,19 @@ # Changelog ## [Unreleased] + ### Removed - Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls +### Fixed + +- Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block +- Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying +- Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors +- Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results +- Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls + ## [15.10.3] - 2026-06-08 ### Added diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 052b72afb..fc859bb0c 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -20,6 +20,7 @@ import { createHarmonyAuditEvent, detectHarmonyLeakInAssistantMessage, extractHarmonyRemoved, + recoverHarmonyToolCall, type HarmonyDetection, type HarmonyRecoveredToolCall, isHarmonyLeakMitigationTarget, @@ -32,7 +33,6 @@ import { finishChatSpan, finishExecuteToolSpan, finishInvokeAgentSpan, - fireOnRunEnd, PiGenAIAttr, recordSkippedTool, resolveTelemetry, @@ -68,6 +68,76 @@ class HarmonyLeakInterruption extends Error { } } +type AssistantContentBlock = AssistantMessage["content"][number]; +type AssistantToolCallBlock = Extract; +type CloneableRecord = Record; + +function cloneUnknown(value: unknown): unknown { + if (Array.isArray(value)) return value.map(cloneUnknown); + if (!value || typeof value !== "object") return value; + const source = value as CloneableRecord; + const out: CloneableRecord = {}; + for (const [key, child] of Object.entries(source)) { + out[key] = cloneUnknown(child); + } + return out; +} + +function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] { + return cloneUnknown(args) as AssistantToolCallBlock["arguments"]; +} + +function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock { + switch (block.type) { + case "text": + return { ...block }; + case "thinking": + return { ...block }; + case "redactedThinking": + return { ...block }; + case "toolCall": + return { ...block, arguments: cloneToolArguments(block.arguments) }; + } +} + +function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage { + return { + ...message, + content: message.content.map(snapshotAssistantContentBlock), + usage: { + ...message.usage, + cost: { ...message.usage.cost }, + }, + disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined, + }; +} + +function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent { + switch (event.type) { + case "start": + return { ...event, partial: snapshotAssistantMessage(event.partial) }; + case "text_start": + case "text_delta": + case "text_end": + case "thinking_start": + case "thinking_delta": + case "thinking_end": + case "toolcall_start": + case "toolcall_delta": + return { ...event, partial: snapshotAssistantMessage(event.partial) }; + case "toolcall_end": + return { + ...event, + toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock, + partial: snapshotAssistantMessage(event.partial), + }; + case "done": + return { ...event, message: snapshotAssistantMessage(event.message) }; + case "error": + return { ...event, error: snapshotAssistantMessage(event.error) }; + } +} + /** * Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback) * into a structurally valid {@link AgentToolResult}. @@ -77,7 +147,7 @@ class HarmonyLeakInterruption extends Error { * (missing `content` array → crash on reload). We coerce at the single boundary where untyped * results enter the agent loop, so every downstream consumer can rely on the type. */ -function coerceToolResult(raw: unknown): { result: AgentToolResult; malformed: boolean } { +function coerceToolResult(raw: unknown): { result: AgentToolResult; malformed: boolean } { const rawObj = raw && typeof raw === "object" ? (raw as Record) : null; const rawContent = rawObj?.content; const details = rawObj && "details" in rawObj ? rawObj.details : {}; @@ -98,8 +168,12 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult; malform } const content: AgentToolResult["content"] = []; + let invalidBlocks = 0; for (const block of rawContent) { - if (!block || typeof block !== "object" || !("type" in block)) continue; + if (!block || typeof block !== "object" || !("type" in block)) { + invalidBlocks++; + continue; + } if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") { content.push({ type: "text", text: sanitizeText((block as { text: string }).text) }); } else if ( @@ -108,9 +182,20 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult; malform typeof (block as { mimeType?: unknown }).mimeType === "string" ) { content.push(block as { type: "image"; data: string; mimeType: string }); + } else { + invalidBlocks++; } } - return { result: { content, details, ...(explicitError ? { isError: true } : {}) }, malformed: false }; + if (invalidBlocks > 0) { + content.push({ + type: "text", + text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`, + }); + } + return { + result: { content, details, ...(explicitError || invalidBlocks > 0 ? { isError: true } : {}) }, + malformed: invalidBlocks > 0, + }; } /** @@ -176,7 +261,7 @@ export function agentLoopContinue( (async () => { const newMessages: AgentMessage[] = []; - const currentContext: AgentContext = { ...context }; + const currentContext: AgentContext = { ...context, messages: [...context.messages] }; stream.push({ type: "agent_start" }); stream.push({ type: "turn_start" }); @@ -211,9 +296,6 @@ function buildAgentEndEvent( ): Extract { if (!telemetry) return { type: "agent_end", messages }; const snapshot = telemetry.collector.snapshot({ stepCount }); - if (telemetry.collector.markRunEnded()) { - fireOnRunEnd(telemetry, snapshot.summary, snapshot.coverage); - } return { type: "agent_end", messages, telemetry: snapshot.summary, coverage: snapshot.coverage }; } @@ -313,22 +395,26 @@ function normalizeMessagesForProvider( return messages; } - let changed = false; - const normalized = messages.map(message => { + let hasThinking = false; + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if (block.type === "thinking") { + hasThinking = true; + break; + } + } + if (hasThinking) break; + } + if (!hasThinking) return messages; + + return messages.map(message => { if (message.role !== "assistant" || !Array.isArray(message.content)) { return message; } - const filtered = message.content.filter(block => block.type !== "thinking"); - if (filtered.length === message.content.length) { - return message; - } - - changed = true; - return { ...message, content: filtered }; + return filtered.length === message.content.length ? message : { ...message, content: filtered }; }); - - return changed ? normalized : messages; } export const INTENT_FIELD = "_i"; @@ -552,6 +638,12 @@ async function runLoopBody( continue; } } + if (recovered) { + message = snapshotAssistantMessage(message); + currentContext.messages.push(message); + stream.push({ type: "message_start", message: snapshotAssistantMessage(message) }); + stream.push({ type: "message_end", message: snapshotAssistantMessage(message) }); + } newMessages.push(message); let steeringMessagesFromExecution: AgentMessage[] | undefined; @@ -640,6 +732,9 @@ async function runLoopBody( status: "skipped", }); } + if (message.stopReason === "length" && toolResults.length > 0) { + hasMoreToolCalls = true; + } } stream.push({ type: "turn_end", message, toolResults }); @@ -816,8 +911,27 @@ async function streamAssistantResponse( let partialMessage: AssistantMessage | null = null; let addedPartial = false; + const completedToolCallIds = new Set(); const responseIterator = response[Symbol.asyncIterator](); + const finishAbortedStream = async (): Promise => { + try { + await responseIterator.return?.(); + } catch { + // Provider cancellation failures cannot change the committed aborted message. + } + const aborted = emitAbortedAssistantMessage( + partialMessage, + addedPartial, + completedToolCallIds, + context, + config, + stream, + requestSignal, + ); + await finishChat(aborted); + return aborted; + }; // Set up a single abort race: register the abort listener once for the whole // stream and reuse the same race promise for every iterator.next() instead of @@ -826,16 +940,7 @@ async function streamAssistantResponse( let detachAbortListener: (() => void) | undefined; if (requestSignal) { if (requestSignal.aborted) { - const aborted = emitAbortedAssistantMessage( - partialMessage, - addedPartial, - context, - config, - stream, - requestSignal, - ); - await finishChat(aborted); - return aborted; + return await finishAbortedStream(); } const { promise, resolve } = Promise.withResolvers(); const onAbort = () => resolve(ABORTED); @@ -850,37 +955,51 @@ async function streamAssistantResponse( if (abortRacePromise) { const result = await Promise.race([responseIterator.next(), abortRacePromise]); if (result === ABORTED) { - responseIterator.return?.()?.catch(() => {}); - const aborted = emitAbortedAssistantMessage( - partialMessage, - addedPartial, - context, - config, - stream, - requestSignal, - ); - await finishChat(aborted); - return aborted; + return await finishAbortedStream(); } next = result; } else { next = await responseIterator.next(); } - if (requestSignal?.aborted) { - const aborted = emitAbortedAssistantMessage( - partialMessage, - addedPartial, - context, - config, - stream, - requestSignal, - ); - await finishChat(aborted); - return aborted; - } if (next.done) break; const event = next.value; + if (event.type === "done" || event.type === "error") { + let finalMessage = retainCompletedToolCalls(await response.result(), completedToolCallIds); + if (harmonyMitigationEnabled) { + const detection = detectHarmonyLeakInAssistantMessage(finalMessage); + if (detection) { + const recovered = recoverHarmonyToolCall(finalMessage, detection); + const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection); + if (addedPartial) { + emitDiscardedHarmonyPartial( + partialMessage, + stream, + `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`, + ); + context.messages.pop(); + addedPartial = false; + } + throw new HarmonyLeakInterruption(detection, removed, recovered); + } + } + finalMessage = snapshotAssistantMessage(finalMessage); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + } + if (!addedPartial) { + stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) }); + } + stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) }); + await finishChat(finalMessage); + return finalMessage; + } + if (requestSignal?.aborted) { + return await finishAbortedStream(); + } + // Yield to the event loop periodically to prevent busy-wait // when the LLM is streaming chunks faster than the loop can rest. await yieldIfDue(); @@ -890,7 +1009,7 @@ async function streamAssistantResponse( partialMessage = event.partial; context.messages.push(partialMessage); addedPartial = true; - stream.push({ type: "message_start", message: { ...partialMessage } }); + stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) }); break; case "text_start": @@ -903,63 +1022,48 @@ async function streamAssistantResponse( case "toolcall_delta": case "toolcall_end": if (partialMessage) { + if (event.type === "toolcall_end") { + completedToolCallIds.add(event.toolCall.id); + } partialMessage = event.partial; context.messages[context.messages.length - 1] = partialMessage; config.onAssistantMessageEvent?.(partialMessage, event); - if (signal?.aborted) { - continue; - } stream.push({ type: "message_update", - assistantMessageEvent: event, - message: { ...partialMessage }, + assistantMessageEvent: snapshotAssistantMessageEvent(event), + message: snapshotAssistantMessage(partialMessage), }); } break; - - case "done": - case "error": { - const finalMessage = await response.result(); - if (harmonyMitigationEnabled) { - const detection = detectHarmonyLeakInAssistantMessage(finalMessage); - if (detection) { - const removed = extractHarmonyRemoved(finalMessage, detection); - if (addedPartial) { - context.messages.pop(); - addedPartial = false; - } - throw new HarmonyLeakInterruption(detection, removed); - } - } - if (addedPartial) { - context.messages[context.messages.length - 1] = finalMessage; - } else { - context.messages.push(finalMessage); - } - if (!addedPartial) { - stream.push({ type: "message_start", message: { ...finalMessage } }); - } - stream.push({ type: "message_end", message: finalMessage }); - await finishChat(finalMessage); - return finalMessage; - } } } } finally { detachAbortListener?.(); } - const trailing = await response.result(); + let trailing = await response.result(); if (harmonyMitigationEnabled) { const detection = detectHarmonyLeakInAssistantMessage(trailing); if (detection) { + const recovered = recoverHarmonyToolCall(trailing, detection); + const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection); if (addedPartial) { + emitDiscardedHarmonyPartial( + partialMessage, + stream, + `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`, + ); context.messages.pop(); addedPartial = false; } - throw new HarmonyLeakInterruption(detection, extractHarmonyRemoved(trailing, detection)); + throw new HarmonyLeakInterruption(detection, removed, recovered); } } + trailing = snapshotAssistantMessage(trailing); + if (addedPartial) { + context.messages[context.messages.length - 1] = trailing; + stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) }); + } await finishChat(trailing); return trailing; }); @@ -973,6 +1077,30 @@ async function streamAssistantResponse( } } +function retainCompletedToolCalls(message: AssistantMessage, completedToolCallIds: ReadonlySet): AssistantMessage { + if (message.stopReason !== "error" && message.stopReason !== "aborted") return message; + let changed = false; + const content = message.content.filter(block => { + if (block.type !== "toolCall") return true; + const keep = completedToolCallIds.has(block.id); + if (!keep) changed = true; + return keep; + }); + return changed ? { ...message, content } : message; +} + +function emitDiscardedHarmonyPartial( + partialMessage: AssistantMessage | null, + stream: EventStream, + errorMessage: string, +): void { + if (!partialMessage) return; + stream.push({ + type: "message_end", + message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }), + }); +} + /** Resolve the human-readable reason an abort carried. A caller that aborts via * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error` * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the @@ -991,39 +1119,45 @@ export function abortReasonText(signal: AbortSignal | undefined): string { function emitAbortedAssistantMessage( partialMessage: AssistantMessage | null, addedPartial: boolean, + completedToolCallIds: ReadonlySet, context: AgentContext, config: AgentLoopConfig, stream: EventStream, requestSignal: AbortSignal | undefined, ): AssistantMessage { const errorMessage = abortReasonText(requestSignal); - const abortedMessage: AssistantMessage = partialMessage - ? { ...partialMessage, stopReason: "aborted", errorMessage } - : { - role: "assistant", - content: [], - api: config.model.api, - provider: config.model.provider, - model: config.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: "aborted", - errorMessage, - timestamp: Date.now(), - }; + const abortedMessage = snapshotAssistantMessage( + retainCompletedToolCalls( + partialMessage + ? { ...partialMessage, stopReason: "aborted", errorMessage } + : { + role: "assistant", + content: [], + api: config.model.api, + provider: config.model.provider, + model: config.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: "aborted", + errorMessage, + timestamp: Date.now(), + }, + completedToolCallIds, + ), + ); if (addedPartial) { context.messages[context.messages.length - 1] = abortedMessage; } else { context.messages.push(abortedMessage); - stream.push({ type: "message_start", message: { ...abortedMessage } }); + stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) }); } - stream.push({ type: "message_end", message: abortedMessage }); + stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) }); return abortedMessage; } @@ -1061,7 +1195,7 @@ async function executeToolCalls( : steeringAbortController.signal; const interruptState = { triggered: false }; let steeringMessages: AgentMessage[] | undefined; - let steeringCheck: Promise | null = null; + let steeringCheckTail: Promise = Promise.resolve(); const records = toolCalls.map(toolCall => ({ toolCall, @@ -1085,21 +1219,17 @@ async function executeToolCalls( if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) { return; } - if (steeringCheck) { - await steeringCheck; - return; - } - steeringCheck = (async () => { + const check = steeringCheckTail.then(async () => { + if (interruptState.triggered) return; const steering = await getSteeringMessages(); if (steering.length > 0) { steeringMessages = steering; interruptState.triggered = true; steeringAbortController.abort(); } - })().finally(() => { - steeringCheck = null; }); - await steeringCheck; + steeringCheckTail = check.catch(() => {}); + await check; }; const emitToolResult = (record: (typeof records)[number], result: AgentToolResult, isError: boolean): void => { @@ -1171,6 +1301,16 @@ async function executeToolCalls( } } record.args = argsForExecution; + if (toolSignal.aborted) { + record.skipped = true; + recordSkippedTool(telemetry, { + toolCallId: toolCall.id, + toolName: toolCall.name, + status: "aborted", + }); + emitToolResult(record, createToolSignalAbortedResult(toolSignal), true); + return; + } record.started = true; stream.push({ type: "tool_execution_start", @@ -1198,6 +1338,11 @@ async function executeToolCalls( await runInActiveSpan(toolSpan, async () => { try { if (!tool) throw new Error(`Tool ${toolCall.name} not found`); + if (toolSignal.aborted) { + result = createToolSignalAbortedResult(toolSignal); + isError = true; + return; + } let effectiveArgs: Record; try { @@ -1224,8 +1369,15 @@ async function executeToolCalls( throw new ToolCallBlockedError(beforeResult.reason); } } - // Reflect post-hook args so emitted tool results / afterToolCall see what actually executed. - record.args = effectiveArgs; + if (toolSignal.aborted) { + result = createToolSignalAbortedResult(toolSignal); + isError = true; + return; + } + const executionArgs = transformToolCallArguments + ? transformToolCallArguments(effectiveArgs, toolCall.name) + : effectiveArgs; + record.args = executionArgs; const toolContext = getToolContext ? getToolContext({ @@ -1237,14 +1389,14 @@ async function executeToolCalls( : undefined; const rawResult = await tool.execute( toolCall.id, - transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs, + executionArgs, toolSignal, partialResult => { stream.push({ type: "tool_execution_update", toolCallId: toolCall.id, toolName: toolCall.name, - args: effectiveArgs, + args: executionArgs, partialResult: coerceToolResult(partialResult).result, }); }, @@ -1262,7 +1414,7 @@ async function executeToolCalls( isError = true; } - if (afterToolCall) { + if (afterToolCall && !toolSignal.aborted) { try { const after = await afterToolCall( { @@ -1295,6 +1447,7 @@ async function executeToolCalls( }); const interrupted = interruptState.triggered; + const abortedDuringExecution = toolSignal.aborted && isError; if (interrupted) { record.skipped = true; emitToolResult(record, createSkippedToolResult(), true); @@ -1305,13 +1458,14 @@ async function executeToolCalls( const firstTextBlock = result.content?.[0]; const errorMessageForSpan = caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined; - const status = interrupted - ? "aborted" - : caughtError instanceof ToolCallBlockedError - ? "blocked" - : isError - ? "error" - : "ok"; + const status = + interrupted || abortedDuringExecution + ? "aborted" + : caughtError instanceof ToolCallBlockedError + ? "blocked" + : isError + ? "error" + : "ok"; finishExecuteToolSpan(telemetry, toolSpan, { result, isError, @@ -1417,6 +1571,14 @@ function createAbortedToolResult( return toolResultMessage; } +function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult { + const reason = abortReasonText(signal); + return { + content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }], + details: {}, + }; +} + function createSkippedToolResult(): AgentToolResult { return { content: [{ type: "text", text: "Skipped due to queued user message." }], diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b67b895bc..272a04f91 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,13 +1,17 @@ # Changelog ## [Unreleased] +### Changed + +- Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429 ### Fixed +- Fixed raw Anthropic SSE handling by parsing event frames with strict JSON parsing and matching event-type validation, surfacing malformed frames as stream errors instead of repairing them +- Fixed Anthropic stream envelope handling to reject duplicate `content_block_start` indexes and block deltas/stops for unopened blocks, preventing malformed envelope states from producing partial output +- Fixed Anthropic image conversion to normalize `image/jpg` to `image/jpeg` and emit a placeholder for unsupported image MIME types +- Fixed Anthropic thinking request preparation by clamping `max_tokens` to provider/model limits and adjusting thinking budgets to a valid value - Fixed the Anthropic stream parser shipping a truncated tool call as a completed turn. When a transport drop cut the SSE stream mid-`tool_use` and a transparent reconnect spliced a fresh message envelope onto the same stream, the duplicate `message_start` was deduped but the orphaned tool block — which never received its `content_block_stop` — survived in the assistant message with its seed `{}` (or partially-parsed) arguments. The terminal stop signal from the reconnect then let it flow through as a normal tool call, so e.g. a `read` dispatched with `{}` failed downstream validation (`path: expected string, received undefined`). The parser now treats any tool block left open at stream end as a truncated envelope and routes it through the existing retry/error path instead of emitting bogus arguments. - -### Fixed - - Fixed the Zhipu Coding Plan login prompt advertising a misleading `sk-...` placeholder. Zhipu API keys are formatted `.` (no `sk-` prefix), so the placeholder now matches the actual format instead of suggesting the wrong shape. ([#2106](https://github.com/can1357/oh-my-pi/issues/2106)) ## [15.10.4] - 2026-06-08 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 20a5fad31..652a854cd 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -57,7 +57,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream"; import { isFoundryEnabled } from "../utils/foundry"; import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector"; import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator"; -import { parseJsonWithRepair, parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse"; +import { parseStreamingJsonThrottled } from "../utils/json-parse"; import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { notifyProviderResponse } from "../utils/provider-response"; import { isCopilotTransientModelError } from "../utils/retry"; @@ -257,6 +257,26 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record; +type AnthropicImageMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + +function normalizeAnthropicImageMediaType(mimeType: string): AnthropicImageMediaType | undefined { + const normalized = mimeType.trim().toLowerCase(); + if (normalized === "image/jpg") return "image/jpeg"; + if ( + normalized === "image/jpeg" || + normalized === "image/png" || + normalized === "image/gif" || + normalized === "image/webp" + ) { + return normalized; + } + return undefined; +} + +function cloneAnthropicCacheControl(cacheControl: AnthropicCacheControl): AnthropicCacheControl { + return { ...cacheControl }; +} + type AnthropicOutputConfig = NonNullable; @@ -750,42 +770,67 @@ function convertContentBlocks( type: "image"; source: { type: "base64"; - media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + media_type: AnthropicImageMediaType; data: string; }; } > { - const textBlocks = content - .filter((block): block is TextContent => block.type === "text") - .map(block => block.text.toWellFormed()) - .filter(text => text.trim().length > 0); - const imageBlocks = content.filter((block): block is ImageContent => block.type === "image"); - const omittedImages = !supportsImages && imageBlocks.length > 0; - if (imageBlocks.length === 0 || !supportsImages) { - if (omittedImages) { - textBlocks.push(NON_VISION_IMAGE_PLACEHOLDER); - } - return textBlocks.join("\n").toWellFormed(); - } + const blocks: Array< + | { type: "text"; text: string } + | { + type: "image"; + source: { + type: "base64"; + media_type: AnthropicImageMediaType; + data: string; + }; + } + > = []; + let sawText = false; + let sawImage = false; - const blocks = [ - ...textBlocks.map(text => ({ - type: "text" as const, - text, - })), - ...imageBlocks.map(block => ({ - type: "image" as const, + for (const block of content) { + if (block.type === "text") { + const text = block.text.toWellFormed(); + if (text.trim().length === 0) continue; + sawText = true; + blocks.push({ type: "text", text }); + continue; + } + + if (!supportsImages) { + blocks.push({ type: "text", text: NON_VISION_IMAGE_PLACEHOLDER }); + continue; + } + + const mediaType = normalizeAnthropicImageMediaType(block.mimeType); + if (!mediaType) { + blocks.push({ type: "text", text: `[unsupported image: ${block.mimeType}]` }); + continue; + } + + sawImage = true; + blocks.push({ + type: "image", source: { - type: "base64" as const, - media_type: block.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", + type: "base64", + media_type: mediaType, data: block.data, }, - })), - ]; + }); + } - if (!textBlocks.length) { + if (!supportsImages) { + return blocks + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map(block => block.text) + .join("\n") + .toWellFormed(); + } + + if (sawImage && !sawText) { blocks.unshift({ - type: "text" as const, + type: "text", text: "(see attached image)", }); } @@ -887,6 +932,16 @@ type FoundryTlsOptions = { key?: string; }; +const foundryTlsOptionsCache = new Map(); + +function foundryTlsOptionsCacheKey(): string { + return JSON.stringify([ + $env.NODE_EXTRA_CA_CERTS ?? null, + $env.CLAUDE_CODE_CLIENT_CERT ?? null, + $env.CLAUDE_CODE_CLIENT_KEY ?? null, + ]); +} + function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: string): string | undefined { if (model.provider === "github-copilot") { return normalizeAnthropicBaseUrl(resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl); @@ -975,6 +1030,9 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl if (model.provider !== "anthropic") return undefined; if (!isFoundryEnabled()) return undefined; + const cacheKey = foundryTlsOptionsCacheKey(); + if (foundryTlsOptionsCache.has(cacheKey)) return foundryTlsOptionsCache.get(cacheKey); + const ca = resolvePemValue($env.NODE_EXTRA_CA_CERTS, "NODE_EXTRA_CA_CERTS"); const cert = resolvePemValue($env.CLAUDE_CODE_CLIENT_CERT, "CLAUDE_CODE_CLIENT_CERT"); const key = resolvePemValue($env.CLAUDE_CODE_CLIENT_KEY, "CLAUDE_CODE_CLIENT_KEY"); @@ -987,7 +1045,9 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl if (ca) options.ca = [...tls.rootCertificates, ca]; if (cert) options.cert = cert; if (key) options.key = key; - return Object.keys(options).length > 0 ? options : undefined; + const resolved = Object.keys(options).length > 0 ? options : undefined; + foundryTlsOptionsCache.set(cacheKey, resolved); + return resolved; } function buildClaudeCodeTlsFetchOptions( @@ -1037,14 +1097,8 @@ const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet = new Set([ ]); /** - * Anthropic keepalive `ping` events carry no message content, but they prove the - * upstream connection is alive during long server-side gaps (extended thinking, - * slow tool execution). They are normally dropped before reaching the consumer; - * we instead surface them as lightweight markers so the idle watchdog - * (`iterateWithIdleTimeout`) resets its deadline on every ping. Without this, a - * connection that is demonstrably still streaming pings still trips - * "Anthropic stream stalled while waiting for the next event". The message-event - * branches in `streamAnthropic` match none of these markers, so they are ignored. + * Iterate over Anthropic SSE events from a raw Response, preserving ping events + * for liveness and rejecting malformed complete event envelopes. */ type RawMessagePingEvent = { type: "ping" }; type AnthropicStreamEvent = RawMessageStreamEvent | RawMessagePingEvent; @@ -1079,7 +1133,10 @@ async function* iterateAnthropicEvents( } try { - const event = parseJsonWithRepair(sse.data); + const event = JSON.parse(sse.data) as RawMessageStreamEvent; + if (event.type !== sse.event) { + throw new Error(`event type ${event.type} does not match SSE event ${sse.event}`); + } if (event.type === "message_start") { sawMessageStart = true; } else if (event.type === "message_stop") { @@ -1094,7 +1151,7 @@ async function* iterateAnthropicEvents( } } - if (sawMessageStart && !sawMessageEnd) { + if (sawMessageStart && !sawMessageEnd && !signal?.aborted) { throw createAnthropicStreamEnvelopeError("stream ended before message_stop"); } } @@ -1177,17 +1234,12 @@ function getAnthropicCompat( const PROVIDER_MAX_RETRIES = 3; const PROVIDER_BASE_DELAY_MS = 2000; -/** - * Check if an error from the Anthropic SDK is a rate-limit/transient error that - * should be retried before any content has been emitted. - * - * Includes malformed JSON stream-envelope parse errors seen from some - * Anthropic-compatible proxy endpoints. - */ /** Transient stream corruption errors where the response was truncated mid-JSON. */ function isTransientStreamParseError(error: unknown): boolean { if (!(error instanceof Error)) return false; - return /json parse error|unterminated string|unexpected end of json input/i.test(error.message); + return /unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i.test( + error.message, + ); } const ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:"; @@ -1235,6 +1287,8 @@ export function isProviderRetryableError(error: unknown, provider?: string): boo // `streamSimple` a/b/c policy), so surface them immediately instead of // burning the retry budget here. if (isUsageLimitError(error.message)) return false; + const status = extractHttpStatusFromError(error); + if (status !== undefined && status >= 400 && status < 500 && status !== 408 && status !== 429) return false; const msg = error.message.toLowerCase(); if ( isUnexpectedSocketCloseMessage(msg) || @@ -1268,13 +1322,12 @@ export type AnthropicUsageLike = { /** * Capture Anthropic's optional cache-creation TTL breakdown and server-tool-use - * counters into the harness Usage shape. Only sets fields that were reported, so - * a `message_delta` that omits `cache_creation` does not clobber the breakdown - * established at `message_start`. + * counters into the harness Usage shape. Omitted/null fields are no-ops; explicit + * zero-valued objects clear prior extras from earlier stream usage snapshots. */ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLike): void { const cacheCreation = source.cache_creation; - if (cacheCreation) { + if (cacheCreation != null) { const fiveMinute = cacheCreation.ephemeral_5m_input_tokens ?? 0; const oneHour = cacheCreation.ephemeral_1h_input_tokens ?? 0; if (fiveMinute > 0 || oneHour > 0) { @@ -1282,10 +1335,12 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi ...(fiveMinute > 0 ? { ephemeral5m: fiveMinute } : {}), ...(oneHour > 0 ? { ephemeral1h: oneHour } : {}), }; + } else { + delete usage.cttl; } } const serverToolUse = source.server_tool_use; - if (serverToolUse) { + if (serverToolUse != null) { const webSearch = serverToolUse.web_search_requests ?? 0; const webFetch = serverToolUse.web_fetch_requests ?? 0; if (webSearch > 0 || webFetch > 0) { @@ -1293,6 +1348,8 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi ...(webSearch > 0 ? { webSearch } : {}), ...(webFetch > 0 ? { webFetch } : {}), }; + } else { + delete usage.server; } } } @@ -1466,6 +1523,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let sawEvent = false; let sawMessageStart = false; let sawTerminalEnvelope = false; + let sawMessageStop = false; + const openBlocks = new Map< + number, + { contentIndex: number; kind: "text" | "thinking" | "redactedThinking" | "toolCall" } + >(); const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, { idleTimeoutMs, @@ -1508,6 +1570,12 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } if (event.type === "content_block_start") { + if (sawTerminalEnvelope) { + throw createAnthropicStreamEnvelopeError(`received ${event.type} after terminal stop signal`); + } + if (openBlocks.has(event.index)) { + throw createAnthropicStreamEnvelopeError(`duplicate content_block_start index ${event.index}`); + } if (!firstTokenTime) firstTokenTime = Date.now(); if (event.content_block.type === "text") { streamedReplayUnsafeContent = true; @@ -1517,12 +1585,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( index: event.index, }; output.content.push(block); + const contentIndex = output.content.length - 1; + openBlocks.set(event.index, { contentIndex, kind: "text" }); stream.push({ type: "text_start", - contentIndex: output.content.length - 1, + contentIndex, partial: output, }); } else if (event.content_block.type === "thinking") { + streamedReplayUnsafeContent = true; const block: Block = { type: "thinking", thinking: "", @@ -1530,18 +1601,25 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( index: event.index, }; output.content.push(block); + const contentIndex = output.content.length - 1; + openBlocks.set(event.index, { contentIndex, kind: "thinking" }); stream.push({ type: "thinking_start", - contentIndex: output.content.length - 1, + contentIndex, partial: output, }); } else if (event.content_block.type === "redacted_thinking") { + streamedReplayUnsafeContent = true; const block: Block = { type: "redactedThinking", data: event.content_block.data, index: event.index, }; output.content.push(block); + openBlocks.set(event.index, { + contentIndex: output.content.length - 1, + kind: "redactedThinking", + }); } else if (event.content_block.type === "tool_use") { streamedReplayUnsafeContent = true; const block: Block = { @@ -1555,92 +1633,115 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( index: event.index, }; output.content.push(block); + const contentIndex = output.content.length - 1; + openBlocks.set(event.index, { contentIndex, kind: "toolCall" }); stream.push({ type: "toolcall_start", - contentIndex: output.content.length - 1, + contentIndex, partial: output, }); } } else if (event.type === "content_block_delta") { + if (sawTerminalEnvelope) { + throw createAnthropicStreamEnvelopeError(`received ${event.type} after terminal stop signal`); + } + const openBlock = openBlocks.get(event.index); + if (!openBlock) { + throw createAnthropicStreamEnvelopeError(`received content_block_delta for unopened index ${event.index}`); + } + const block = blocks[openBlock.contentIndex]; if (event.delta.type === "text_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "text") { - block.text += event.delta.text; - stream.push({ - type: "text_delta", - contentIndex: index, - delta: event.delta.text, - partial: output, - }); + if (openBlock.kind !== "text" || block?.type !== "text") { + throw createAnthropicStreamEnvelopeError(`received text_delta for ${openBlock.kind} block`); } + streamedReplayUnsafeContent = true; + block.text += event.delta.text; + stream.push({ + type: "text_delta", + contentIndex: openBlock.contentIndex, + delta: event.delta.text, + partial: output, + }); } else if (event.delta.type === "thinking_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "thinking") { - block.thinking += event.delta.thinking; - stream.push({ - type: "thinking_delta", - contentIndex: index, - delta: event.delta.thinking, - partial: output, - }); + if (openBlock.kind !== "thinking" || block?.type !== "thinking") { + throw createAnthropicStreamEnvelopeError(`received thinking_delta for ${openBlock.kind} block`); } + streamedReplayUnsafeContent = true; + block.thinking += event.delta.thinking; + stream.push({ + type: "thinking_delta", + contentIndex: openBlock.contentIndex, + delta: event.delta.thinking, + partial: output, + }); } else if (event.delta.type === "input_json_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "toolCall") { - block.partialJson += event.delta.partial_json; - const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0); - if (throttled) { - block.arguments = throttled.value; - block.lastParseLen = throttled.parsedLen; - } - stream.push({ - type: "toolcall_delta", - contentIndex: index, - delta: event.delta.partial_json, - partial: output, - }); + if (openBlock.kind !== "toolCall" || block?.type !== "toolCall") { + throw createAnthropicStreamEnvelopeError(`received input_json_delta for ${openBlock.kind} block`); } + streamedReplayUnsafeContent = true; + block.partialJson += event.delta.partial_json; + const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0); + if (throttled) { + block.arguments = throttled.value; + block.lastParseLen = throttled.parsedLen; + } + stream.push({ + type: "toolcall_delta", + contentIndex: openBlock.contentIndex, + delta: event.delta.partial_json, + partial: output, + }); } else if (event.delta.type === "signature_delta") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block && block.type === "thinking") { - block.thinkingSignature = block.thinkingSignature || ""; - block.thinkingSignature += event.delta.signature; + if (openBlock.kind !== "thinking" || block?.type !== "thinking") { + throw createAnthropicStreamEnvelopeError(`received signature_delta for ${openBlock.kind} block`); } + streamedReplayUnsafeContent = true; + block.thinkingSignature = block.thinkingSignature || ""; + block.thinkingSignature += event.delta.signature; } } else if (event.type === "content_block_stop") { - const index = blocks.findIndex(b => b.index === event.index); - const block = blocks[index]; - if (block) { - delete (block as { index?: number }).index; - if (block.type === "text") { - stream.push({ - type: "text_end", - contentIndex: index, - content: block.text, - partial: output, - }); - } else if (block.type === "thinking") { - stream.push({ - type: "thinking_end", - contentIndex: index, - content: block.thinking, - partial: output, - }); - } else if (block.type === "toolCall") { - block.arguments = parseStreamingJson(block.partialJson); - delete (block as { partialJson?: string }).partialJson; - delete (block as { lastParseLen?: number }).lastParseLen; - stream.push({ - type: "toolcall_end", - contentIndex: index, - toolCall: block, - partial: output, - }); - } + if (sawTerminalEnvelope) { + throw createAnthropicStreamEnvelopeError(`received ${event.type} after terminal stop signal`); + } + const openBlock = openBlocks.get(event.index); + if (!openBlock) { + throw createAnthropicStreamEnvelopeError(`received content_block_stop for unopened index ${event.index}`); + } + const block = blocks[openBlock.contentIndex]; + if (!block || block.type !== openBlock.kind) { + throw createAnthropicStreamEnvelopeError(`content_block_stop kind mismatch for index ${event.index}`); + } + openBlocks.delete(event.index); + delete (block as { index?: number }).index; + if (block.type === "text") { + streamedReplayUnsafeContent = true; + stream.push({ + type: "text_end", + contentIndex: openBlock.contentIndex, + content: block.text, + partial: output, + }); + } else if (block.type === "thinking") { + streamedReplayUnsafeContent = true; + stream.push({ + type: "thinking_end", + contentIndex: openBlock.contentIndex, + content: block.thinking, + partial: output, + }); + } else if (block.type === "toolCall") { + streamedReplayUnsafeContent = true; + const finalJson = + block.partialJson.length > 0 ? block.partialJson : JSON.stringify(block.arguments ?? {}); + block.arguments = JSON.parse(finalJson) as ToolCall["arguments"]; + delete (block as { partialJson?: string }).partialJson; + delete (block as { lastParseLen?: number }).lastParseLen; + stream.push({ + type: "toolcall_end", + contentIndex: openBlock.contentIndex, + toolCall: block, + partial: output, + }); } } else if (event.type === "message_delta") { const rawStopReason = event.delta.stop_reason; @@ -1683,6 +1784,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( calculateCost(model, output.usage); } else if (event.type === "message_stop") { sawTerminalEnvelope = true; + sawMessageStop = true; } } @@ -1696,25 +1798,18 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( if (!sawEvent || !sawMessageStart) { throw createAnthropicStreamEnvelopeError("stream ended before message_start"); } - if (!sawTerminalEnvelope) { - throw createAnthropicStreamEnvelopeError("stream ended before terminal stop signal"); + if (!sawMessageStop) { + throw createAnthropicStreamEnvelopeError("stream ended before message_stop"); } - - // An open tool_use block — one that never received its - // `content_block_stop` — means the stream was truncated mid-tool-call. - // In practice this is a transport drop that a transparent reconnect - // splices back together: the reconnect's `message_start` is deduped - // above, yet the orphaned block survives with its seed `{}` (or a - // partial-parse) arguments. Emitting it would dispatch a tool call the - // model never finished generating. Surface it as a truncated envelope so - // the existing retry/error path engages instead of shipping bogus args. - if ( - blocks.some( - block => - block.type === "toolCall" && (block as { partialJson?: string }).partialJson !== undefined, - ) - ) { - throw createAnthropicStreamEnvelopeError("stream ended with an unterminated tool_use block"); + if (openBlocks.size > 0) { + const firstOpenBlock = openBlocks.entries().next().value; + if (firstOpenBlock) { + const [openIndex, openBlock] = firstOpenBlock; + throw createAnthropicStreamEnvelopeError( + `stream ended with an unterminated ${openBlock.kind} block at index ${openIndex}`, + ); + } + throw createAnthropicStreamEnvelopeError("stream ended with an unterminated content block"); } if (output.stopReason === "aborted" || output.stopReason === "error") { @@ -1849,12 +1944,11 @@ function applyClaudeCodeSystemCache( blocks: AnthropicSystemBlock[], cacheControl: AnthropicCacheControl | undefined, ): number { - if (!cacheControl || blocks.length <= 2) return 0; - blocks[2] = { ...blocks[2], cache_control: cacheControl }; - if (blocks.length === 3) return 1; + if (!cacheControl || blocks.length === 0) return 0; const lastIndex = blocks.length - 1; - blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; - return 2; + if (blocks[lastIndex].cache_control != null) return 0; + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) }; + return 1; } export function buildAnthropicSystemBlocks( @@ -1891,8 +1985,8 @@ export function buildAnthropicSystemBlocks( blocks.push({ type: "text", text: prompt }); } const lastIndex = blocks.length - 1; - if (cacheControl && lastIndex >= 0) { - blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; + if (cacheControl && lastIndex >= 0 && blocks[lastIndex].cache_control == null) { + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) }; } return blocks.length > 0 ? blocks : undefined; } @@ -2009,7 +2103,6 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; } - // OpenCode Zen's Anthropic-compatible gateway accepts bearer auth only; // leaving apiKey set lets the client add X-Api-Key, which upstream Alibaba rejects. if (model.provider === "opencode-zen") { @@ -2025,9 +2118,16 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A }; } + const authorizationHeader = getHeaderCaseInsensitive(defaultHeaders, "Authorization"); + const shouldSuppressClientApiKey = + !oauthToken && + !isAnthropicApiBaseUrl(baseUrl) && + typeof authorizationHeader === "string" && + /^Bearer\s+/i.test(authorizationHeader); + return { isOAuthToken: oauthToken, - apiKey: oauthToken ? null : apiKey, + apiKey: oauthToken || shouldSuppressClientApiKey ? null : apiKey, authToken: oauthToken ? apiKey : undefined, baseURL: baseUrl, maxRetries: 5, @@ -2052,6 +2152,7 @@ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming) if (toolChoice.type !== "any" && toolChoice.type !== "tool") return; delete params.thinking; + delete params.context_management; const outputConfig = params.output_config as AnthropicOutputConfig | undefined; if (!outputConfig) return; @@ -2068,11 +2169,20 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: const budgetTokens = thinking.budget_tokens ?? 0; if (budgetTokens <= 0) return; - const maxTokens = params.max_tokens ?? 0; - const requiredMaxTokens = budgetTokens + OUTPUT_FALLBACK_BUFFER; - if (maxTokens < requiredMaxTokens) { - params.max_tokens = Math.min(requiredMaxTokens, model.maxTokens); + const maxAllowedTokens = Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, model.maxTokens); + const currentMaxTokens = Math.min(params.max_tokens ?? maxAllowedTokens, maxAllowedTokens); + const raisedMaxTokens = Math.min(Math.max(currentMaxTokens, budgetTokens + OUTPUT_FALLBACK_BUFFER), maxAllowedTokens); + params.max_tokens = raisedMaxTokens; + + if (budgetTokens + OUTPUT_FALLBACK_BUFFER <= raisedMaxTokens) return; + + const clampedBudget = raisedMaxTokens - OUTPUT_FALLBACK_BUFFER; + if (clampedBudget <= 0) { + throw new Error( + `Anthropic thinking budget requires max_tokens greater than ${OUTPUT_FALLBACK_BUFFER}; got ${raisedMaxTokens}`, + ); } + thinking.budget_tokens = clampedBudget; } type CacheControlBlock = { @@ -2082,39 +2192,35 @@ type CacheControlBlock = { function applyCacheControlToLastBlock( blocks: T[], cacheControl: AnthropicCacheControl, -): void { - if (blocks.length === 0) return; +): boolean { + if (blocks.length === 0) return false; const lastIndex = blocks.length - 1; - blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; + if (blocks[lastIndex].cache_control != null) return false; + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) }; + return true; } function applyCacheControlToLastTextBlock( blocks: Array, cacheControl: AnthropicCacheControl, -): void { - if (blocks.length === 0) return; +): boolean { + if (blocks.length === 0) return false; for (let i = blocks.length - 1; i >= 0; i--) { if (blocks[i].type === "text") { - blocks[i] = { ...blocks[i], cache_control: cacheControl }; - return; + if (blocks[i].cache_control != null) return false; + blocks[i] = { ...blocks[i], cache_control: cloneAnthropicCacheControl(cacheControl) }; + return true; } } - applyCacheControlToLastBlock(blocks, cacheControl); + return false; } function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: AnthropicCacheControl): void { if (!cacheControl) return; - // Skip if cache_control breakpoints were already placed externally on messages. - for (const message of params.messages) { - if (Array.isArray(message.content)) { - if ((message.content as Array).some(b => b.cache_control != null)) - return; - } - } - const MAX_CACHE_BREAKPOINTS = 4; - let cacheBreakpointsUsed = 0; + let cacheBreakpointsUsed = countCacheControlBreakpoints(params); + if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return; let isCCLayout = false; if (params.system && Array.isArray(params.system) && params.system.length > 0) { @@ -2122,9 +2228,12 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: params.system.length >= 3 && (params.system[0] as { text?: string }).text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true; if (isCCLayout) { - cacheBreakpointsUsed += applyClaudeCodeSystemCache(params.system as AnthropicSystemBlock[], cacheControl); - } else { - applyCacheControlToLastBlock(params.system, cacheControl); + const placed = Math.min( + MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed, + applyClaudeCodeSystemCache(params.system as AnthropicSystemBlock[], cacheControl), + ); + cacheBreakpointsUsed += placed; + } else if (applyCacheControlToLastBlock(params.system, cacheControl)) { cacheBreakpointsUsed++; } } @@ -2137,14 +2246,19 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: const message = params.messages[i]; if (!message) continue; if (typeof message.content === "string") { - message.content = [{ type: "text", text: message.content, cache_control: cacheControl }]; + message.content = [ + { type: "text", text: message.content, cache_control: cloneAnthropicCacheControl(cacheControl) }, + ]; cacheBreakpointsUsed++; } else if (Array.isArray(message.content) && message.content.length > 0) { - applyCacheControlToLastTextBlock( - message.content as Array, - cacheControl, - ); - cacheBreakpointsUsed++; + if ( + applyCacheControlToLastTextBlock( + message.content as Array, + cacheControl, + ) + ) { + cacheBreakpointsUsed++; + } } } } @@ -2157,7 +2271,9 @@ function normalizeCacheControlBlockTtl(block: CacheControlBlock, seenFiveMinute: return; } if (seenFiveMinute.value) { - delete cacheControl.ttl; + const normalized = cloneAnthropicCacheControl(cacheControl); + delete normalized.ttl; + block.cache_control = normalized; } } @@ -2322,7 +2438,7 @@ function buildParams( }); // Pre-compute tools. - let tools: ReturnType | undefined; + let tools: AnthropicWireTool[] | undefined; if (context.tools) { tools = convertTools( context.tools, @@ -2385,11 +2501,11 @@ function buildParams( // metadata → max_tokens → thinking → context_management → output_config → stream. const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertAnthropicMessages(context.messages, model, isOAuthToken), + messages: convertAnthropicMessages(context.messages, model, isOAuthToken, baseUrl), ...(systemBlocks && { system: systemBlocks }), ...(tools !== undefined && { tools }), ...(metadata && { metadata }), - max_tokens: Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, options?.maxTokens || model.maxTokens), + max_tokens: Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, model.maxTokens, options?.maxTokens || model.maxTokens), ...(thinking && { thinking }), ...(contextManagement && { context_management: contextManagement }), ...(outputConfig && { output_config: outputConfig }), @@ -2397,8 +2513,8 @@ function buildParams( }; // Opus 4.7+ rejects non-default sampling parameters with 400 error. - const allowSamplingParams = !hasOpus47ApiRestrictions(model.id); - if (allowSamplingParams && options?.temperature !== undefined && !options?.thinkingEnabled) { + const allowSamplingParams = !hasOpus47ApiRestrictions(model.id) && !params.thinking; + if (allowSamplingParams && options?.temperature !== undefined) { params.temperature = options.temperature; } if (allowSamplingParams && options?.topP !== undefined) { @@ -2476,9 +2592,8 @@ function isZaiAnthropicEndpoint(model: Model<"anthropic-messages">): boolean { * arguments (#2005). Known non-signing hosts are also preserved for * compatibility. */ -function shouldReplayUnsignedThinking(model: Model<"anthropic-messages">): boolean { +function shouldReplayUnsignedThinking(model: Model<"anthropic-messages">, baseUrl: string | undefined): boolean { if (model.provider === "zai" || model.provider === "deepseek") return true; - const baseUrl = model.baseUrl; if (baseUrl) { try { const hostname = new URL(baseUrl).hostname.toLowerCase(); @@ -2514,12 +2629,13 @@ export function convertAnthropicMessages( messages: Message[], model: Model<"anthropic-messages">, isOAuthToken: boolean, + baseUrl = resolveAnthropicBaseUrl(model), ): AnthropicMessageParam[] { - const params: AnthropicMessageParam[] = []; // Indices of params emitted from `developer` messages. After the main pass, // the ones whose placement satisfies Anthropic's mid-conversation rules are // upgraded from the `user` role to the authoritative `system` role. const developerParamIndices: number[] = []; + const params: AnthropicMessageParam[] = []; const transformedMessages = transformMessages(messages, model, normalizeToolCallId); @@ -2578,7 +2694,7 @@ export function convertAnthropicMessages( } if (block.thinking.trim().length === 0) continue; if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) { - if (shouldReplayUnsignedThinking(model)) { + if (shouldReplayUnsignedThinking(model, baseUrl)) { blocks.push({ type: "thinking", thinking: block.thinking.toWellFormed(), @@ -2728,6 +2844,7 @@ function isJsonSchemaArrayNode(schema: Record): boolean { const t = schema.type; if (t === "array") return true; if (Array.isArray(t) && t.includes("array") && !t.includes("object")) return true; + if (schema.items !== undefined || Array.isArray(schema.prefixItems)) return true; return false; } @@ -2754,6 +2871,14 @@ function pickAnthropicScalarType(type: unknown): string | undefined { } return undefined; } +function pickAnthropicEffectiveScalarType(schema: Record): string | undefined { + const explicit = pickAnthropicScalarType(schema.type); + if (explicit) return explicit; + if (isRecord(schema.properties)) return "object"; + if (schema.items !== undefined || Array.isArray(schema.prefixItems)) return "array"; + return undefined; +} + function anthropicPerTypeKeep(scalarType: string | undefined): Set | undefined { switch (scalarType) { @@ -2768,14 +2893,6 @@ function anthropicPerTypeKeep(scalarType: string | undefined): Set | und } } -/** - * Per-schema-object memoization slot for the normalized Anthropic tool form. We stamp - * the result onto the host via a `Symbol` property (mirroring `utils/schema/stamps.ts`) - * instead of using a `WeakMap`: it's a single hidden-class slot, so warm reads are - * direct property access and write-once cycles resolve to the in-progress result. - */ -const kAnthropicToolNormal = Symbol("pi.schema.anthropic.toolNormal"); - /** * Normalize a JSON Schema node for Anthropic tool `input_schema`. * @@ -2796,20 +2913,20 @@ const kAnthropicToolNormal = Symbol("pi.schema.anthropic.toolNormal"); * pass downstream demotes those shapes to non-strict instead of fabricating a closed * object, so callers like the resolve tool keep working open-map semantics. */ -export function normalizeAnthropicToolSchema(schema: unknown): unknown { - if (Array.isArray(schema)) return schema.map(entry => normalizeAnthropicToolSchema(entry)); +function normalizeAnthropicToolSchemaNode( + schema: unknown, + cache: WeakMap, Record>, +): unknown { + if (Array.isArray(schema)) return schema.map(entry => normalizeAnthropicToolSchemaNode(entry, cache)); if (!isRecord(schema)) return schema; - const slot = schema as Record | undefined>; - const existing = slot[kAnthropicToolNormal]; + const existing = cache.get(schema); if (existing !== undefined) return existing; const result: Record = {}; - // Pre-stamp before recursion so cyclic schemas resolve to the in-progress object - // (mirrors the WeakMap-set-before-recurse pattern the original implementation used). - Object.defineProperty(schema, kAnthropicToolNormal, { value: result, writable: true, configurable: true }); + cache.set(schema, result); - const scalarType = pickAnthropicScalarType(schema.type); + const scalarType = pickAnthropicEffectiveScalarType(schema); const perTypeKeep = anthropicPerTypeKeep(scalarType); const spill: Array<[string, unknown]> = []; @@ -2848,12 +2965,12 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { const sourceProperties = result.properties as Record; for (const propName in sourceProperties) { if (!Object.hasOwn(sourceProperties, propName)) continue; - normalizedProperties[propName] = normalizeAnthropicToolSchema(sourceProperties[propName]); + normalizedProperties[propName] = normalizeAnthropicToolSchemaNode(sourceProperties[propName], cache); } result.properties = normalizedProperties; } if (isRecord(result.additionalProperties)) { - const normalized = normalizeAnthropicToolSchema(result.additionalProperties); + const normalized = normalizeAnthropicToolSchemaNode(result.additionalProperties, cache); if (isRecord(normalized) && Object.keys(normalized).length === 0) { result.additionalProperties = true; } else { @@ -2861,17 +2978,17 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { } } if (Array.isArray(result.items)) { - result.items = result.items.map(item => normalizeAnthropicToolSchema(item)); + result.items = result.items.map(item => normalizeAnthropicToolSchemaNode(item, cache)); } else if (isRecord(result.items)) { - result.items = normalizeAnthropicToolSchema(result.items); + result.items = normalizeAnthropicToolSchemaNode(result.items, cache); } if (Array.isArray(result.prefixItems)) { - result.prefixItems = result.prefixItems.map(item => normalizeAnthropicToolSchema(item)); + result.prefixItems = result.prefixItems.map(item => normalizeAnthropicToolSchemaNode(item, cache)); } for (const key of COMBINATOR_KEYS) { const variants = result[key]; if (Array.isArray(variants)) { - result[key] = variants.map(variant => normalizeAnthropicToolSchema(variant)); + result[key] = variants.map(variant => normalizeAnthropicToolSchemaNode(variant, cache)); } } for (const defsKey of ["$defs", "definitions"] as const) { @@ -2881,7 +2998,7 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { const sourceDefs = definitions as Record; for (const name in sourceDefs) { if (!Object.hasOwn(sourceDefs, name)) continue; - normalizedDefs[name] = normalizeAnthropicToolSchema(sourceDefs[name]); + normalizedDefs[name] = normalizeAnthropicToolSchemaNode(sourceDefs[name], cache); } result[defsKey] = normalizedDefs; } @@ -2890,6 +3007,10 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { return result; } +export function normalizeAnthropicToolSchema(schema: unknown): unknown { + return normalizeAnthropicToolSchemaNode(schema, new WeakMap()); +} + type AnthropicToolSchemaPlan = { inputSchema: AnthropicToolInputSchema; strict: boolean; @@ -2910,6 +3031,25 @@ function hasNullVariant(schema: Record): boolean { if (Array.isArray(schema.type) && schema.type.includes("null")) return true; return Array.isArray(schema.anyOf) && schema.anyOf.some(variant => isRecord(variant) && variant.type === "null"); } +function hasAnthropicSchemaDefiningKeyword(schema: Record): boolean { + if ( + schema.type !== undefined || + schema.properties !== undefined || + schema.additionalProperties !== undefined || + schema.items !== undefined || + schema.prefixItems !== undefined || + schema.enum !== undefined || + schema.const !== undefined || + schema.$ref !== undefined + ) { + return true; + } + for (const key of COMBINATOR_KEYS) { + if (schema[key] !== undefined) return true; + } + return schema.$defs !== undefined || schema.definitions !== undefined; +} + function makeAnthropicNullableSchema(schema: unknown, budget: AnthropicStrictBudget): unknown | undefined { if (isRecord(schema)) { @@ -2948,6 +3088,8 @@ function normalizeAnthropicStrictSchemaNode( const cached = cache.get(schema); if (cached) return cached; + if (!hasAnthropicSchemaDefiningKeyword(schema)) return undefined; + // Strict tool use only supports closed objects. Open maps stay available on // the non-strict schema plan instead of producing an Anthropic 400. if (isJsonSchemaObjectNode(schema) && schema.additionalProperties !== false) { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 42ea9a377..a10fc8df6 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -457,11 +457,11 @@ describe("anthropic stream envelope handling", () => { expect(attempt).toBe(1); expect(countEvents(events, "toolcall_start")).toBe(1); expect(countEvents(events, "toolcall_delta")).toBe(1); - expect(countEvents(events, "toolcall_end")).toBe(1); + expect(countEvents(events, "toolcall_end")).toBe(0); expect(countEvents(events, "error")).toBe(1); expect(countEvents(events, "done")).toBe(0); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("stream ended before terminal stop signal"); + expect(result.errorMessage).toContain("Unterminated string"); const toolCall = result.content[0]; expect(toolCall?.type).toBe("toolCall"); @@ -494,7 +494,7 @@ describe("anthropic stream envelope handling", () => { expect(countEvents(events, "done")).toBe(0); expect(countEvents(events, "error")).toBe(1); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("unterminated tool_use block"); + expect(result.errorMessage).toContain("unterminated toolCall block"); }); it("parses raw SSE directly so unknown events do not fail Anthropic streams", async () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( @@ -541,7 +541,7 @@ describe("anthropic stream envelope handling", () => { expect(result.content).toEqual([{ type: "text", text: "partial" }]); }); - it("repairs malformed JSON in raw SSE event data before parsing", async () => { + it("surfaces malformed raw SSE event JSON instead of repairing protocol frames", async () => { const malformedTextDelta = '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"line\\qbreak"}}'; const successEvents = createTextSuccessEvents("unused"); @@ -556,13 +556,16 @@ describe("anthropic stream envelope handling", () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); - for await (const _ of stream) { - // drain stream + const events: AssistantMessageEvent[] = []; + for await (const event of stream) { + events.push(event); } const result = await stream.result(); - expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "line\\qbreak" }]); + expect(countEvents(events, "error")).toBe(1); + expect(countEvents(events, "done")).toBe(0); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("Could not parse Anthropic SSE event content_block_delta"); }); it("surfaces a refusal fallback message when stop_details is null", async () => { const refusalEvents: MockAnthropicEvent[] = [ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a3ba00a7d..3411f6528 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references @@ -8,10 +9,15 @@ ### Changed +- Normalized image content before it enters model context so attached images are downscaled and preprocessed for prompts, steering messages, follow-ups, and custom agent messages - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded - Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. - Browser tool helpers (`tab.*`) are now individually tracked and time-bounded: when a `run` cell hits its budget, the timeout error names the still-running helper(s) and how long each has been stalled (e.g. `... (stalled on tab.screenshot({ selector: ".x" }) (29.9s))`) instead of the opaque `Browser code execution timed out after 30000ms`. Page-coupled helpers that should resolve quickly (`observe`, `screenshot`, `extract`) also fail fast with a named per-op error at `min(cellBudget, 20s)`, leaving budget for the rest of the cell, rather than silently consuming the whole budget. +### Removed + +- Removed the special Anthropic `claude-opus-4-8` tool-call batch cap; sessions no longer abort an in-flight provider stream after a fixed number of completed tool calls. + ### Fixed - Fixed task-row shimmer timing so every running description starts its highlight on the first character together and reaches the last character together, regardless of text length. @@ -23,10 +29,6 @@ - Fixed edit tool result previews to show only current-file lines and collapse long inserted blocks instead of echoing removed content. - Fixed `generateDiffString` to omit the mid-skip `...` placeholder between two nearby edits, conveying the elided gap via the jump in line numbers instead (consistent with how leading/trailing context skips already render). The placeholder row was indistinguishable from a genuine `...` context line and wasted a row in compact previews. -### Removed - -- Removed the special Anthropic `claude-opus-4-8` tool-call batch cap; sessions no longer abort an in-flight provider stream after a fixed number of completed tool calls. - ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 7d56f4228..f4ea08104 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -215,6 +215,7 @@ import { parseCommandArgs } from "../utils/command-args"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions"; +import { normalizeModelContextImages } from "../utils/image-loading"; import { buildNamedToolChoice } from "../utils/tool-choice"; import type { AuthStorage } from "./auth-storage"; import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge"; @@ -4272,6 +4273,24 @@ export class AgentSession { }; } + async #normalizeMessageContentImages( + content: string | (TextContent | ImageContent)[], + ): Promise { + if (typeof content === "string") return content; + const images = content.filter((part): part is ImageContent => part.type === "image"); + if (images.length === 0) return content; + const normalizedImages = await normalizeModelContextImages(images); + if (!normalizedImages) return content; + let imageIndex = 0; + return content.map(part => (part.type === "image" ? normalizedImages[imageIndex++]! : part)); + } + + async #normalizeAgentMessageImages(message: T): Promise { + const content = await this.#normalizeMessageContentImages(message.content); + if (content === message.content) return message; + return { ...message, content } as T; + } + /** * Send a prompt to the agent. * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming @@ -4371,10 +4390,11 @@ export class AgentSession { const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force"); const eagerTodoPrelude = !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined; + const normalizedImages = await normalizeModelContextImages(options?.images); const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; - if (options?.images) { - userContent.push(...options.images); + if (normalizedImages) { + userContent.push(...normalizedImages); } const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user"); @@ -4391,6 +4411,7 @@ export class AgentSession { try { await this.#promptWithMessage(message, expandedText, { ...options, + images: normalizedImages, prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined, appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined, }); @@ -4533,7 +4554,9 @@ export class AgentSession { useHashLines: resolveFileDisplayMode(this).hashLines, snapshotStore: getFileSnapshotStore(this), }); - messages.push(...fileMentionMessages); + for (const fileMentionMessage of fileMentionMessages) { + messages.push(await this.#normalizeAgentMessageImages(fileMentionMessage)); + } } const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText); @@ -4549,15 +4572,17 @@ export class AgentSession { const promptAttribution: "user" | "agent" | undefined = "attribution" in message ? message.attribution : undefined; for (const msg of result.messages) { - messages.push({ - role: "custom", - customType: msg.customType, - content: msg.content, - display: msg.display, - details: msg.details, - attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"), - timestamp: Date.now(), - }); + messages.push( + await this.#normalizeAgentMessageImages({ + role: "custom", + customType: msg.customType, + content: msg.content, + display: msg.display, + details: msg.details, + attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"), + timestamp: Date.now(), + }), + ); } } @@ -4765,11 +4790,12 @@ export class AgentSession { * Internal: Queue a steering message (already expanded, no extension command check). */ async #queueSteer(text: string, images?: ImageContent[]): Promise { + const normalizedImages = await normalizeModelContextImages(images); const displayText = text || (images && images.length > 0 ? "[Image]" : ""); this.#steeringMessages.push({ text: displayText }); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; - if (images && images.length > 0) { - content.push(...images); + if (normalizedImages && normalizedImages.length > 0) { + content.push(...normalizedImages); } this.agent.steer({ role: "user", @@ -4784,11 +4810,12 @@ export class AgentSession { * Internal: Queue a follow-up message (already expanded, no extension command check). */ async #queueFollowUp(text: string, images?: ImageContent[]): Promise { + const normalizedImages = await normalizeModelContextImages(images); const displayText = text || (images && images.length > 0 ? "[Image]" : ""); this.#followUpMessages.push({ text: displayText }); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; - if (images && images.length > 0) { - content.push(...images); + if (normalizedImages && normalizedImages.length > 0) { + content.push(...normalizedImages); } this.agent.followUp({ role: "user", @@ -4932,16 +4959,17 @@ export class AgentSession { attribution: message.attribution ?? "agent", timestamp: Date.now(), }; + const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); if (this.isStreaming) { if (options?.deliverAs === "nextTurn") { - this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false); + this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false); return; } if (options?.deliverAs === "followUp") { - this.agent.followUp(appMessage); + this.agent.followUp(normalizedAppMessage); } else { - this.agent.steer(appMessage); + this.agent.steer(normalizedAppMessage); } return; } @@ -4949,16 +4977,16 @@ export class AgentSession { if (options?.deliverAs === "nextTurn") { if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { - this.#queueHiddenNextTurnMessage(appMessage, false); + this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return; } - await this.agent.prompt(appMessage); + await this.agent.prompt(normalizedAppMessage); return; } - this.agent.appendMessage(appMessage); + this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( - message.customType, - message.content, + normalizedAppMessage.customType, + normalizedAppMessage.content, message.display, message.details, message.attribution ?? "agent", @@ -4968,17 +4996,17 @@ export class AgentSession { if (options?.triggerTurn) { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { - this.#queueHiddenNextTurnMessage(appMessage, false); + this.#queueHiddenNextTurnMessage(normalizedAppMessage, false); return; } - await this.agent.prompt(appMessage); + await this.agent.prompt(normalizedAppMessage); return; } - this.agent.appendMessage(appMessage); + this.agent.appendMessage(normalizedAppMessage); this.sessionManager.appendCustomMessageEntry( - message.customType, - message.content, + normalizedAppMessage.customType, + normalizedAppMessage.content, message.display, message.details, message.attribution ?? "agent", diff --git a/packages/coding-agent/src/utils/image-loading.ts b/packages/coding-agent/src/utils/image-loading.ts index 9f172188d..4be0a534e 100644 --- a/packages/coding-agent/src/utils/image-loading.ts +++ b/packages/coding-agent/src/utils/image-loading.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils"; import { resolveReadPath } from "../tools/path-utils"; -import { formatDimensionNote, resizeImage } from "./image-resize"; +import { formatDimensionNote, resizeImage, type ImageResizeOptions } from "./image-resize"; export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024; export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES; @@ -50,6 +50,36 @@ export async function ensureSupportedImageInput(image: ImageContent): Promise { + if (!images || images.length === 0) return undefined; + const normalized: ImageContent[] = []; + for (const image of images) { + try { + const resized = await resizeImage(image, options?.resize); + normalized.push({ type: "image", data: resized.data, mimeType: resized.mimeType }); + } catch { + // Preserve existing caller behavior for decode/resize failures: keep the + // user's image block rather than dropping it from the turn. + normalized.push(image); + } + } + return normalized; +} + export async function loadImageInput(options: LoadImageInputOptions): Promise { const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES; const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd); diff --git a/packages/coding-agent/test/image-input-normalization.test.ts b/packages/coding-agent/test/image-input-normalization.test.ts index 8e4a0ca2a..39bdddd99 100644 --- a/packages/coding-agent/test/image-input-normalization.test.ts +++ b/packages/coding-agent/test/image-input-normalization.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { ensureSupportedImageInput } from "../src/utils/image-loading"; +import { ensureSupportedImageInput, normalizeModelContextImages } from "../src/utils/image-loading"; // 1x1 red PNG (69 bytes). Bun.Image sniffs format from bytes, so we can pass // this with a non-supported MIME type and the conversion path runs over the @@ -7,6 +7,17 @@ import { ensureSupportedImageInput } from "../src/utils/image-loading"; const RED_1X1_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"; +async function makeRedPng(width: number, height: number): Promise { + const seed = Buffer.from(RED_1X1_PNG_BASE64, "base64"); + const upscaled = await new Bun.Image(seed).resize(width, height, { filter: "nearest" }).png().bytes(); + return Buffer.from(upscaled).toBase64(); +} + +async function dimensions(image: { data: string }): Promise<{ width: number; height: number }> { + const metadata = await new Bun.Image(Buffer.from(image.data, "base64")).metadata(); + return { width: metadata.width, height: metadata.height }; +} + describe("ensureSupportedImageInput", () => { test("passes supported mime types through unchanged", async () => { const input = { type: "image" as const, data: RED_1X1_PNG_BASE64, mimeType: "image/png" }; @@ -41,3 +52,22 @@ describe("ensureSupportedImageInput", () => { expect(result).toBeNull(); }); }); + +describe("normalizeModelContextImages", () => { + test("downscales multiple large images before model context", async () => { + const wide = { type: "image" as const, data: await makeRedPng(2000, 1500), mimeType: "image/png" }; + const tall = { type: "image" as const, data: await makeRedPng(1200, 2200), mimeType: "image/png" }; + + const result = await normalizeModelContextImages([wide, tall]); + + expect(result).toHaveLength(2); + expect(result?.[0]?.type).toBe("image"); + expect(result?.[1]?.type).toBe("image"); + const wideDims = await dimensions(result![0]!); + const tallDims = await dimensions(result![1]!); + expect(wideDims.width).toBeLessThanOrEqual(1568); + expect(wideDims.height).toBeLessThanOrEqual(1568); + expect(tallDims.width).toBeLessThanOrEqual(1568); + expect(tallDims.height).toBeLessThanOrEqual(1568); + }); +}); From 421ff27eb623b4d64b1cc008e67fcbdb7c4443c2 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:44:13 +0200 Subject: [PATCH 18/53] fix(ai): scoped Anthropic fallback state and fixed iterator early-close cleanup - Scoped Anthropic provider session state by base URL and model so strict-tools/fast-mode fallback state no longer leaks across unrelated endpoints or models, and fast-mode re-arming clears all matching scoped entries. - Dropped stale strict fallback error messages after successful retries and only enabled adaptive thinking display for models that advertise support, avoiding unsupported-model 400 errors. - Updated idle stream iteration to close the upstream iterator on non-continuing exits (including consumer early break) and added coverage for upstream closure behavior. --- packages/ai/src/providers/anthropic.ts | 51 +++++++++++++++---- packages/ai/src/utils/idle-iterator.ts | 11 ++++ .../ai/test/stream-timeout-defaults.test.ts | 29 +++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 652a854cd..a78b49bd3 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -314,16 +314,29 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState { return state; } +/** + * Key the sticky strict-tools / fast-mode learning per endpoint+model. A + * grammar-too-large 400 or a fast-mode rejection is specific to the model (its + * tool grammar / entitlement) and the endpoint (direct Anthropic vs a gateway / + * Foundry / Bedrock proxy), so it MUST NOT bleed onto unrelated anthropic-messages + * requests in the same session. NUL separates the two components so neither can + * forge the boundary. + */ +function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string { + return `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:${baseUrl}\u0000${modelId}`; +} + function getAnthropicProviderSessionState( providerSessionState: Map | undefined, + baseUrl: string, + modelId: string, ): AnthropicProviderSessionState | undefined { if (!providerSessionState) return undefined; - const existing = providerSessionState.get(ANTHROPIC_PROVIDER_SESSION_STATE_KEY) as - | AnthropicProviderSessionState - | undefined; + const key = anthropicProviderSessionStateKey(baseUrl, modelId); + const existing = providerSessionState.get(key) as AnthropicProviderSessionState | undefined; if (existing) return existing; const created = createAnthropicProviderSessionState(); - providerSessionState.set(ANTHROPIC_PROVIDER_SESSION_STATE_KEY, created); + providerSessionState.set(key, created); return created; } @@ -338,10 +351,14 @@ export function clearAnthropicFastModeFallback( providerSessionState: Map | undefined, ): void { if (!providerSessionState) return; - const state = providerSessionState.get(ANTHROPIC_PROVIDER_SESSION_STATE_KEY) as - | AnthropicProviderSessionState - | undefined; - if (state) state.fastModeDisabled = false; + // Fast mode is re-armed session-wide (user toggled `/fast on`), so clear the + // sticky flag on every per-endpoint/model Anthropic entry — plus the legacy + // unscoped key — rather than a single shared object. + const prefix = `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:`; + for (const [key, value] of providerSessionState) { + if (key !== ANTHROPIC_PROVIDER_SESSION_STATE_KEY && !key.startsWith(prefix)) continue; + (value as AnthropicProviderSessionState).fastModeDisabled = false; + } } function isAnthropicStrictGrammarTooLargeError(error: unknown): boolean { @@ -1434,7 +1451,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const baseUrl = resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ?? "https://api.anthropic.com"; - const providerSessionState = getAnthropicProviderSessionState(options?.providerSessionState); + const providerSessionState = getAnthropicProviderSessionState(options?.providerSessionState, baseUrl, model.id); let disableStrictTools = (providerSessionState?.strictToolsDisabled ?? false) || (model.compat?.disableStrictTools ?? false); let strictFallbackErrorMessage: string | undefined; @@ -1899,6 +1916,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } } + // A strict-tools / grammar-too-large fallback (or a transient retry that ran + // after one) stamps `strictFallbackErrorMessage` onto `output.errorMessage` for + // the *discarded* attempt. Reaching here means a later attempt succeeded + // (the in-loop guard throws on error/aborted before this break), so drop the + // stale 400 text — otherwise a successful turn ships a phantom error string to + // telemetry/UI. Only the exact stale value is cleared, never a real refusal message. + if (output.errorMessage !== undefined && output.errorMessage === strictFallbackErrorMessage) { + output.errorMessage = undefined; + } output.duration = Date.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; if (dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority") { @@ -2466,8 +2492,11 @@ function buildParams( const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" }; // Starting with Claude Opus 4.7, adaptive thinking content is omitted from the // response by default. Opt into summarized reasoning so thinking deltas keep - // streaming with human-readable content for callers that rely on it. - if (options.thinkingDisplay !== undefined || supportsAdaptiveThinkingDisplay(model.id)) { + // streaming with human-readable content for callers that rely on it. The + // `display` field is gated strictly on model support: Opus 4.6 / Sonnet 4.6+ + // reject it with a 400, so an explicit `thinkingDisplay` MUST NOT force it onto + // a model that can't accept it (a hidden-thinking toggle must never break the request). + if (supportsAdaptiveThinkingDisplay(model.id)) { adaptive.display = options.thinkingDisplay ?? "summarized"; } thinking = adaptive; diff --git a/packages/ai/src/utils/idle-iterator.ts b/packages/ai/src/utils/idle-iterator.ts index d19b5cd57..f1c5345b0 100644 --- a/packages/ai/src/utils/idle-iterator.ts +++ b/packages/ai/src/utils/idle-iterator.ts @@ -124,8 +124,11 @@ export async function* iterateWithIdleTimeout( firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined; const abortSignal = options.abortSignal; const iterator = iterable[Symbol.asyncIterator](); + let iteratorClosed = false; const closeIterator = (): void => { + if (iteratorClosed) return; + iteratorClosed = true; const returnPromise = iterator.return?.(); if (returnPromise) { void returnPromise.catch(() => {}); @@ -212,6 +215,12 @@ export async function* iterateWithIdleTimeout( racers.push(promise); } + // Tracks whether this iteration handed an item to the consumer and resumed + // normally. Any other exit — internal throw, `done` return, or the consumer + // abandoning us via `.return()`/`.throw()` at the `yield` below — must close + // the upstream iterator so the underlying SSE body / SDK stream (and its + // socket) is released instead of being left suspended. + let continuing = false; try { const outcome = await Promise.race(racers); if (outcome.kind === "abort") { @@ -247,7 +256,9 @@ export async function* iterateWithIdleTimeout( lastProgressAt = Date.now(); } yield item; + continuing = true; } finally { + if (!continuing) closeIterator(); if (timer !== undefined) clearTimeout(timer); // Resolve dangling promises so the racers don't leak (Promise.race is one-shot). resolveTimeout?.({ kind: "timeout" }); diff --git a/packages/ai/test/stream-timeout-defaults.test.ts b/packages/ai/test/stream-timeout-defaults.test.ts index 5920ef978..ee22a94e4 100644 --- a/packages/ai/test/stream-timeout-defaults.test.ts +++ b/packages/ai/test/stream-timeout-defaults.test.ts @@ -227,4 +227,33 @@ describe("iterateWithIdleTimeout", () => { await Bun.sleep(20); expect(firstItemTimedOut).toBe(false); }); + + it("closes the upstream iterator when the consumer breaks early", async () => { + let upstreamClosed = false; + async function* source(): AsyncGenerator { + try { + let n = 0; + while (true) { + await Bun.sleep(1); + yield `item-${n++}`; + } + } finally { + // Runs only if the wrapper forwards `.return()` to us. + upstreamClosed = true; + } + } + + for await (const _item of iterateWithIdleTimeout(source(), { + idleTimeoutMs: 1_000, + errorMessage: "idle timeout", + })) { + break; // abandon the wrapper after the first item + } + + // The wrapper must propagate the consumer's early termination to the source + // so the underlying SSE body / SDK stream (and its socket) is released + // instead of being left suspended. + await Bun.sleep(5); + expect(upstreamClosed).toBe(true); + }); }); From 7a76d3b7049aa969d8a9fcafd105b4a92c684dc3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:44:21 +0200 Subject: [PATCH 19/53] fix(ai/providers): removed the short-circuit that skipped prefixing - Removed the short-circuit that skipped prefixing when a tool name already started with the Claude prefix. - Updated `applyClaudeToolPrefix` to always prepend the transport prefix for non-builtin tools, avoiding underscore-stripping on return. - This kept tool names like `_foo` stable across the send/receive cycle for lookup compatibility. --- packages/ai/src/providers/anthropic.ts | 6 +++++- packages/ai/test/anthropic-alignment.test.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a78b49bd3..49583f0c7 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -661,7 +661,11 @@ const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "t export const applyClaudeToolPrefix = (name: string): string => { if (!claudeToolPrefix) return name; if (ANTHROPIC_BUILTIN_TOOL_NAMES.has(name.toLowerCase())) return name; - if (name.toLowerCase().startsWith(claudeToolPrefix.toLowerCase())) return name; + // Always prepend (no "already prefixed" short-circuit): the prefix is a wire + // transport detail applied once to internal tool names, and `stripClaudeToolPrefix` + // removes exactly one prefix on receive. Skipping names that already start with the + // prefix would make a tool literally named `_foo` lose its leading underscore on the + // return trip (`_foo` → wire `_foo` → strip → `foo`), so the agent loop can't find it. return `${claudeToolPrefix}${name}`; }; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 6ccc00eda..7449f9da1 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -1528,8 +1528,15 @@ describe("Anthropic request fingerprint alignment", () => { const name = "Read"; const prefixed = applyClaudeToolPrefix(name); expect(prefixed).toBe(`${claudeToolPrefix}${name}`); - expect(applyClaudeToolPrefix(prefixed)).toBe(prefixed); // idempotent expect(stripClaudeToolPrefix(prefixed)).toBe(name); // roundtrip + + // The prefix codec is injective, NOT idempotent: an internal tool name that + // already starts with the prefix gets a second one so it survives the return + // trip. Skipping it would strip a real leading underscore and lose the tool. + const underscored = `${claudeToolPrefix}foo`; + const underscoredWire = applyClaudeToolPrefix(underscored); + expect(underscoredWire).toBe(`${claudeToolPrefix}${underscored}`); + expect(stripClaudeToolPrefix(underscoredWire)).toBe(underscored); }); }); From 934d44732c8803c13de3fcf8f7ee19bd3f2dedd2 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:47:28 +0200 Subject: [PATCH 20/53] fix(coding-agent/tools): serialized ask tool batch execution to prevent selector clobbering - Set AskTool concurrency to exclusive so the tool runner executes ask calls serially. - Updated the changelog to document the hanging ask failure when multiple asks were triggered concurrently in one batch. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tools/ask.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3411f6528..58880e0e3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -28,6 +28,7 @@ - Fixed clipboard-pasted images being rejected when steering or following up during compaction. Instead of bailing with "Retry after it completes to send images", the message and its images are now queued via `queueCompactionMessage` and forwarded to the session (steer/follow-up/prompt) when the compaction queue flushes. - Fixed edit tool result previews to show only current-file lines and collapse long inserted blocks instead of echoing removed content. - Fixed `generateDiffString` to omit the mid-skip `...` placeholder between two nearby edits, conveying the elided gap via the jump in line numbers instead (consistent with how leading/trailing context skips already render). The placeholder row was indistinguishable from a genuine `...` context line and wasted a row in compact previews. +- Fixed the `ask` tool hanging when the model emitted two `ask` calls in one tool batch. The interactive selector is a single shared UI surface (`ExtensionUiController.showHookSelector` has no queue and overwrites `ctx.hookSelector` on each call), so concurrent asks clobbered each other: the second stole focus and orphaned the first, whose select promise hung until the user aborted the whole turn (surfacing a stray `Ask input was cancelled`). `ask` now declares `concurrency: "exclusive"`, so the agent loop serializes the batch and each question's selector runs to completion. ## [15.10.4] - 2026-06-08 diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 5ff09d52d..33f16cd00 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -407,6 +407,12 @@ export class AskTool implements AgentTool { readonly description: string; readonly parameters = askSchema; readonly strict = true; + // Run alone in its tool batch. The interactive selector/editor is a single + // shared UI surface (`ExtensionUiController.showHookSelector` has no queue and + // overwrites `ctx.hookSelector` on each call), so two concurrent `ask` calls + // would clobber each other: the second steals focus and orphans the first, + // whose promise then hangs until the user aborts the whole turn. + readonly concurrency = "exclusive"; readonly loadMode = "discoverable"; constructor(private readonly session: ToolSession) { From a84da2a61ec13cf7572326b8490cd4f0680e6ab2 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:51:57 +0200 Subject: [PATCH 21/53] feat(coding-agent): added AST-condition matching for interrupt flow rule handling - Added astCondition to rule frontmatter parsing and rule metadata, with AST-grep normalization. - Updated TTSR bucketing so astCondition-only rules are treated as interruptible matches. - Added ts-redundant-clear-guard as a built-in JS/TS tool rule for guarded clear* calls. - Added AST snapshot matching in agent sessions with per-stream cache throttling and cleanup. --- crates/pi-natives/src/ast.rs | 145 +++++++++++ packages/coding-agent/CHANGELOG.md | 7 +- .../src/capability/rule-buckets.ts | 6 +- packages/coding-agent/src/capability/rule.ts | 11 +- .../src/discovery/builtin-rules/index.ts | 2 + .../builtin-rules/ts-redundant-clear-guard.md | 75 ++++++ .../coding-agent/src/discovery/helpers.ts | 3 +- packages/coding-agent/src/export/ttsr.ts | 118 ++++++++- .../coding-agent/src/session/agent-session.ts | 229 +++++++++++------- .../test/capability/rule-buckets.test.ts | 13 + .../test/discovery/builtin-defaults.test.ts | 13 +- packages/coding-agent/test/ttsr.test.ts | 110 +++++++++ packages/natives/native/index.d.ts | 50 ++++ packages/natives/native/index.js | 1 + 14 files changed, 682 insertions(+), 101 deletions(-) create mode 100644 packages/coding-agent/src/discovery/builtin-rules/ts-redundant-clear-guard.md diff --git a/crates/pi-natives/src/ast.rs b/crates/pi-natives/src/ast.rs index c62212c7e..541b4ab32 100644 --- a/crates/pi-natives/src/ast.rs +++ b/crates/pi-natives/src/ast.rs @@ -128,6 +128,45 @@ pub struct AstFindResult { pub parse_errors: Option>, } +/// Options for `astMatch`: run ast-grep patterns against an in-memory source +/// string instead of files on disk. +#[napi(object)] +pub struct AstMatchOptions<'env> { + /// Source code to match against (parsed in memory, never read from disk). + pub source: String, + /// Language of `source` (required; e.g. "ts", "tsx", "rust", "python"). + pub lang: String, + /// ast-grep patterns to search for (OR across patterns). + pub patterns: Vec, + /// Rule selector for multi-rule ast-grep configurations. + pub selector: Option, + /// Pattern strictness; defaults to smart matching when omitted. + pub strictness: Option, + /// Maximum matches to return after `offset` (default applies when omitted). + pub limit: Option, + /// Number of leading matches to skip before applying `limit`. + pub offset: Option, + /// When true, include meta-variable bindings per match. + pub include_meta: Option, + /// Optional cancellation handle (library-specific). + pub signal: Option>, + /// Wall-clock timeout for the worker task in milliseconds. + pub timeout_ms: Option, +} + +/// Result of an in-memory `astMatch` run. +#[napi(object)] +pub struct AstMatchResult { + /// Page of matches after sort, offset, and limit. + pub matches: Vec, + /// Total matches found before paging (can exceed `matches.length`). + pub total_matches: u32, + /// True when results were truncated by `limit`. + pub limit_reached: bool, + /// Non-fatal parse or pattern-compile errors collected during the run. + pub parse_errors: Option>, +} + /// Options for `astEdit`: rewrite rules, scan scope, safety limits, and /// dry-run. #[napi(object)] @@ -688,6 +727,112 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Promise { }) } +/// Match ast-grep patterns against an in-memory source string; returns a +/// promise resolved on a worker thread. +/// +/// This is the file-free counterpart to [`ast_grep`]: callers that already hold +/// the source (streaming buffers, generated code, editor contents) avoid a +/// temp-file round trip. `lang` is required since there is no path to infer it +/// from. +#[napi] +pub fn ast_match(options: AstMatchOptions<'_>) -> task::Promise { + let AstMatchOptions { + source, + lang, + patterns, + selector, + strictness, + limit, + offset, + include_meta, + signal, + timeout_ms, + } = options; + + let ct = task::CancelToken::new(timeout_ms, signal); + let normalized_limit = limit.unwrap_or(DEFAULT_FIND_LIMIT).max(1); + let normalized_offset = offset.unwrap_or(0); + + task::blocking("ast_match", ct, move |ct| { + let patterns = normalize_pattern_list(Some(patterns))?; + let strictness = resolve_strictness(strictness); + let include_meta = include_meta.unwrap_or(false); + let lang_str = lang.trim(); + if lang_str.is_empty() { + return Err(Error::from_reason("`lang` is required for ast_match".to_string())); + } + let language = resolve_supported_lang(lang_str)?; + + let mut parse_errors = Vec::new(); + let mut compiled_patterns = Vec::with_capacity(patterns.len()); + for pattern in &patterns { + ct.heartbeat()?; + match compile_pattern(pattern, selector.as_deref(), &strictness, language) { + Ok(compiled) => compiled_patterns.push(compiled), + Err(err) => parse_errors.push(format!("{pattern}: {err}")), + } + } + + let mut all_matches = Vec::new(); + let mut total_matches = 0u32; + if !compiled_patterns.is_empty() { + let ast = language.ast_grep(&source); + if ast.root().dfs().any(|node| node.is_error()) { + parse_errors.push("parse error (syntax tree contains error nodes)".to_string()); + } + for pattern in &compiled_patterns { + ct.heartbeat()?; + for matched in ast.root().find_all(pattern.clone()) { + ct.heartbeat()?; + total_matches = total_matches.saturating_add(1); + let range = matched.range(); + let start = matched.start_pos(); + let end = matched.end_pos(); + let meta_variables = if include_meta { + Some(HashMap::::from(matched.get_env().clone())) + } else { + None + }; + all_matches.push(AstFindMatch { + path: String::new(), + text: matched.text().into_owned(), + byte_start: to_u32(range.start), + byte_end: to_u32(range.end), + start_line: to_u32(start.line().saturating_add(1)), + start_column: to_u32(start.column(matched.get_node()).saturating_add(1)), + end_line: to_u32(end.line().saturating_add(1)), + end_column: to_u32(end.column(matched.get_node()).saturating_add(1)), + meta_variables, + }); + } + } + } + + all_matches.sort_by(|left, right| { + left + .start_line + .cmp(&right.start_line) + .then(left.start_column.cmp(&right.start_column)) + .then(left.end_line.cmp(&right.end_line)) + .then(left.end_column.cmp(&right.end_column)) + .then(left.byte_start.cmp(&right.byte_start)) + .then(left.byte_end.cmp(&right.byte_end)) + }); + + let visible_matches = + all_matches.into_iter().skip(normalized_offset as usize).collect::>(); + let limit_reached = visible_matches.len() > normalized_limit as usize; + let matches = visible_matches.into_iter().take(normalized_limit as usize).collect::>(); + + Ok(AstMatchResult { + matches, + total_matches, + limit_reached, + parse_errors: (!parse_errors.is_empty()).then_some(parse_errors), + }) + }) +} + /// Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns /// a promise. #[napi] diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 58880e0e3..5fe6b37bd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,14 +1,16 @@ # Changelog ## [Unreleased] - ### Added +- Added `astCondition` to TTSR rule frontmatter as a syntax-aware alternative to regex `condition`, enabling AST-based matching for edit/write tool snapshots +- Added a built-in `ts-redundant-clear-guard` rule that flags redundant guards around `clearTimeout`, `clearInterval`, and `clearImmediate` calls - Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references - Added pixel dimensions to pasted/loaded image placeholders in the prompt — the marker now reads `[Image #N, WxH]` (falling back to `[Image #N]` when the header can't be decoded). ### Changed +- Changed TTSR rule bucketing and matching so rules with only `astCondition` are treated as TTSR rules and evaluated in the interrupt flow using reconstructed edit/write source snapshots - Normalized image content before it enters model context so attached images are downscaled and preprocessed for prompts, steering messages, follow-ups, and custom agent messages - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded - Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. @@ -28,7 +30,8 @@ - Fixed clipboard-pasted images being rejected when steering or following up during compaction. Instead of bailing with "Retry after it completes to send images", the message and its images are now queued via `queueCompactionMessage` and forwarded to the session (steer/follow-up/prompt) when the compaction queue flushes. - Fixed edit tool result previews to show only current-file lines and collapse long inserted blocks instead of echoing removed content. - Fixed `generateDiffString` to omit the mid-skip `...` placeholder between two nearby edits, conveying the elided gap via the jump in line numbers instead (consistent with how leading/trailing context skips already render). The placeholder row was indistinguishable from a genuine `...` context line and wasted a row in compact previews. -- Fixed the `ask` tool hanging when the model emitted two `ask` calls in one tool batch. The interactive selector is a single shared UI surface (`ExtensionUiController.showHookSelector` has no queue and overwrites `ctx.hookSelector` on each call), so concurrent asks clobbered each other: the second stole focus and orphaned the first, whose select promise hung until the user aborted the whole turn (surfacing a stray `Ask input was cancelled`). `ask` now declares `concurrency: "exclusive"`, so the agent loop serializes the batch and each question's selector runs to completion. +- Fixed concurrent interactive dialogs clobbering each other on the shared editor surface. `ExtensionUiController` presents the selector / input / editor modals by swapping a component into the single `editorContainer` and stealing focus, with no serialization — so a second `select`/`input`/`editor` request (from a hook, extension, the `ask` tool, or an internal flow) opened while one was already up would clear the container and re-focus, orphaning the first dialog. Its promise then hung until the caller's signal aborted (surfacing a stray `Ask input was cancelled` on top of the answered call). These modals are now serialized through `#presentDialog`: at most one shows at a time and the rest queue (FIFO); a queued request whose signal aborts before its turn resolves `undefined` and is never shown. The first dialog is still presented synchronously, so single-dialog timing is unchanged. +- Fixed the `ask` tool potentially hanging when the model emitted two `ask` calls in one tool batch. `ask` now declares `concurrency: "exclusive"`, so the agent loop serializes the batch and each question's selector runs to completion before the next starts, instead of racing for the shared selector surface. ## [15.10.4] - 2026-06-08 diff --git a/packages/coding-agent/src/capability/rule-buckets.ts b/packages/coding-agent/src/capability/rule-buckets.ts index 16afdc307..0ffd99dfe 100644 --- a/packages/coding-agent/src/capability/rule-buckets.ts +++ b/packages/coding-agent/src/capability/rule-buckets.ts @@ -6,7 +6,7 @@ * manager, and splits the rest into the always-apply and rulebook buckets. * * Bucket precedence (matches docs/rulebook-matching-pipeline.md §5): - * 1. TTSR — non-empty `condition` that `TtsrManager.addRule` accepts + * 1. TTSR — non-empty `condition`/`astCondition` that `TtsrManager.addRule` accepts * 2. always — `alwaysApply === true` * 3. rulebook — has a `description` */ @@ -49,7 +49,9 @@ export function bucketRules( if (disabled.has(rule.name)) continue; if (!includeBuiltin && rule._source?.provider === BUILTIN_DEFAULTS_PROVIDER_ID) continue; - const isTtsrRule = rule.condition && rule.condition.length > 0 ? ttsrManager.addRule(rule) : false; + const hasTtsrCondition = + (rule.condition && rule.condition.length > 0) || (rule.astCondition && rule.astCondition.length > 0); + const isTtsrRule = hasTtsrCondition ? ttsrManager.addRule(rule) : false; if (isTtsrRule) continue; if (rule.alwaysApply === true) { alwaysApplyRules.push(rule); diff --git a/packages/coding-agent/src/capability/rule.ts b/packages/coding-agent/src/capability/rule.ts index 0b5d8d2b3..cdce087d3 100644 --- a/packages/coding-agent/src/capability/rule.ts +++ b/packages/coding-agent/src/capability/rule.ts @@ -26,6 +26,8 @@ export interface RuleFrontmatter { alwaysApply?: boolean; /** New key for TTSR match conditions. */ condition?: string | string[]; + /** TTSR match condition(s) expressed as ast-grep patterns (edit/write streams only). */ + astCondition?: string | string[]; /** New key for TTSR stream scope. */ scope?: string | string[]; /** Per-rule TTSR interrupt mode override. */ @@ -51,6 +53,8 @@ export interface Rule { description?: string; /** Regex condition(s) that can trigger TTSR interruption. */ condition?: string[]; + /** ast-grep pattern condition(s) that can trigger TTSR interruption (edit/write streams only). */ + astCondition?: string[]; /** Optional stream scope tokens (for example: text, thinking, tool:edit(*.ts)). */ scope?: string[]; /** Per-rule TTSR interrupt mode override (falls back to global ttsr.interruptMode). */ @@ -188,10 +192,14 @@ function isLikelyFileGlob(value: string): boolean { * - legacy `ttsr_trigger` / `ttsrTrigger` are accepted as a `condition` fallback * - condition tokens that look like file globs become scope shorthands: * `*.rs` => `tool:edit(*.rs)`, `tool:write(*.rs)` and a catch-all condition `.*` + * - `astCondition` holds ast-grep patterns and is kept verbatim (no glob inference) */ -export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick { +export function parseRuleConditionAndScope( + frontmatter: RuleFrontmatter, +): Pick { const rawCondition = frontmatter.condition ?? frontmatter.ttsr_trigger ?? frontmatter.ttsrTrigger; const parsedCondition = normalizeRuleField(rawCondition); + const astCondition = normalizeRuleField(frontmatter.astCondition); const parsedScope = normalizeScopeField(frontmatter.scope); const inferredScope: string[] = []; @@ -213,6 +221,7 @@ export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick 0 ? Array.from(new Set(condition)) : undefined, + astCondition, scope: scope.length > 0 ? Array.from(new Set(scope)) : undefined, }; } diff --git a/packages/coding-agent/src/discovery/builtin-rules/index.ts b/packages/coding-agent/src/discovery/builtin-rules/index.ts index 3f0ce1c77..6188c36dc 100644 --- a/packages/coding-agent/src/discovery/builtin-rules/index.ts +++ b/packages/coding-agent/src/discovery/builtin-rules/index.ts @@ -22,6 +22,7 @@ import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" }; import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" }; import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" }; import tsPromiseWithResolvers from "./ts-promise-with-resolvers.md" with { type: "text" }; +import tsRedundantClearGuard from "./ts-redundant-clear-guard.md" with { type: "text" }; import tsSetMap from "./ts-set-map.md" with { type: "text" }; /** A bundled rule's stable name and raw markdown (frontmatter + body). */ @@ -46,5 +47,6 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [ { name: "ts-no-return-type", content: tsNoReturnType }, { name: "ts-no-tiny-functions", content: tsNoTinyFunctions }, { name: "ts-promise-with-resolvers", content: tsPromiseWithResolvers }, + { name: "ts-redundant-clear-guard", content: tsRedundantClearGuard }, { name: "ts-set-map", content: tsSetMap }, ]; diff --git a/packages/coding-agent/src/discovery/builtin-rules/ts-redundant-clear-guard.md b/packages/coding-agent/src/discovery/builtin-rules/ts-redundant-clear-guard.md new file mode 100644 index 000000000..4c4a7fec7 --- /dev/null +++ b/packages/coding-agent/src/discovery/builtin-rules/ts-redundant-clear-guard.md @@ -0,0 +1,75 @@ +--- +description: Do not guard clearTimeout/clearInterval/clearImmediate with a truthiness or null/undefined check — they accept null and undefined +scope: "tool:edit(*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}), tool:write(*.{ts,tsx,js,jsx,mts,cts,mjs,cjs})" +interruptMode: never +astCondition: + - "if ($X) clearTimeout($X)" + - "if ($X) { clearTimeout($X) }" + - "if ($X) clearInterval($X)" + - "if ($X) { clearInterval($X) }" + - "if ($X) clearImmediate($X)" + - "if ($X) { clearImmediate($X) }" + - "if ($X !== null) clearTimeout($X)" + - "if ($X !== null) { clearTimeout($X) }" + - "if ($X !== null) clearInterval($X)" + - "if ($X !== null) { clearInterval($X) }" + - "if ($X !== null) clearImmediate($X)" + - "if ($X !== null) { clearImmediate($X) }" + - "if ($X != null) clearTimeout($X)" + - "if ($X != null) { clearTimeout($X) }" + - "if ($X != null) clearInterval($X)" + - "if ($X != null) { clearInterval($X) }" + - "if ($X != null) clearImmediate($X)" + - "if ($X != null) { clearImmediate($X) }" + - "if ($X !== undefined) clearTimeout($X)" + - "if ($X !== undefined) { clearTimeout($X) }" + - "if ($X !== undefined) clearInterval($X)" + - "if ($X !== undefined) { clearInterval($X) }" + - "if ($X !== undefined) clearImmediate($X)" + - "if ($X !== undefined) { clearImmediate($X) }" + - "if ($X != undefined) clearTimeout($X)" + - "if ($X != undefined) { clearTimeout($X) }" + - "if ($X != undefined) clearInterval($X)" + - "if ($X != undefined) { clearInterval($X) }" + - "if ($X != undefined) clearImmediate($X)" + - "if ($X != undefined) { clearImmediate($X) }" +--- + +**Do not guard `clearTimeout` / `clearInterval` / `clearImmediate` with a truthiness or `null`/`undefined` check.** Per the WHATWG/Node timers spec these functions are no-ops when handed `null`, `undefined`, or any value that doesn't correspond to a live timer. The guard adds a redundant branch that the reader must still reason about. + +## Why it's wrong + +- The branch can never change behavior — clearing a missing/`null`/`undefined` handle does nothing. +- Extra branches inflate the code and hide the one line that matters. +- It signals a misunderstanding of the timer API to future readers. + +## Avoid + +```ts +if (this.timer) clearTimeout(this.timer); +if (handle !== null) clearInterval(handle); +if (id != undefined) { + clearImmediate(id); +} +``` + +## Use + +```ts +clearTimeout(this.timer); +clearInterval(handle); +clearImmediate(id); +``` + +## When a guard *is* warranted + +Keep the check only when the body does more than clear — e.g. it also reassigns the handle or runs other cleanup: + +```ts +if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; // extra work → guard is not purely redundant +} +``` + +This rule only fires when the clear call is the sole statement in the guarded branch, so those legitimate cases are left alone. diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index ac6e92ad0..830c1d717 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -163,7 +163,7 @@ export function buildRuleFromMarkdown( }, ): Rule { const { frontmatter, body } = parseFrontmatter(content, { source: filePath }); - const { condition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter); + const { condition, astCondition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter); let globs: string[] | undefined; if (Array.isArray(frontmatter.globs)) { @@ -186,6 +186,7 @@ export function buildRuleFromMarkdown( alwaysApply: frontmatter.alwaysApply === true, description: typeof frontmatter.description === "string" ? frontmatter.description : undefined, condition, + astCondition, scope, interruptMode, _source: source, diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index 8dba7a399..7655df200 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -5,6 +5,8 @@ * the agent's output. When a match occurs, the stream is aborted, the rule is * injected as a system reminder, and the request is retried. */ +import * as path from "node:path"; +import { AstMatchStrictness, astMatch } from "@oh-my-pi/pi-natives"; import { logger } from "@oh-my-pi/pi-utils"; import type { Rule } from "../capability/rule"; import type { TtsrSettings } from "../config/settings"; @@ -38,6 +40,8 @@ interface TtsrScope { interface TtsrEntry { rule: Rule; conditions: RegExp[]; + /** ast-grep pattern strings; matched only against edit/write tool snapshots. */ + astConditions: string[]; scope: TtsrScope; globalPathGlobs?: Bun.Glob[]; } @@ -70,6 +74,8 @@ export class TtsrManager { readonly #rules = new Map(); readonly #injectionRecords = new Map(); readonly #buffers = new Map(); + /** Last snapshot evaluated for AST conditions, keyed by stream key, to dedupe matcher runs. */ + readonly #lastAstSnapshots = new Map(); #messageCount = 0; constructor(settings?: TtsrSettings) { @@ -302,7 +308,8 @@ export class TtsrManager { } const conditions = this.#compileConditions(rule); - if (conditions.length === 0) { + const astConditions = (rule.astCondition ?? []).map(pattern => pattern.trim()).filter(p => p.length > 0); + if (conditions.length === 0 && astConditions.length === 0) { return false; } @@ -318,6 +325,7 @@ export class TtsrManager { this.#rules.set(rule.name, { rule, conditions, + astConditions, scope, globalPathGlobs, }); @@ -325,6 +333,7 @@ export class TtsrManager { logger.debug("TTSR rule registered", { ruleName: rule.name, conditions: rule.condition, + astConditions: rule.astCondition, scope: rule.scope, globs: rule.globs, }); @@ -359,6 +368,112 @@ export class TtsrManager { return this.#matchBuffer(snapshot, context); } + /** Derive an ast-grep language alias from candidate paths (bare extension, e.g. "ts"), if any. */ + #deriveLang(filePaths: string[] | undefined): string | undefined { + for (const filePath of filePaths ?? []) { + const ext = path.extname(this.#normalizePath(filePath)); + if (ext.length > 1) { + return ext.slice(1).toLowerCase(); + } + } + return undefined; + } + + /** + * Evaluate ast-grep `astCondition` rules against a reconstructed tool snapshot. + * + * Only edit/write tool streams reach here (AST conditions need a language, which + * we infer from the file extension on the tool's path argument). The snapshot is + * matched in memory by the native engine (`astMatch`), so this is async and + * intentionally throttled: identical consecutive snapshots (the common case when + * only non-source arguments change between deltas) are skipped. + */ + async checkAstSnapshot(snapshot: string, context: TtsrMatchContext): Promise { + if (!this.#settings.enabled || context.source !== "tool") { + return []; + } + + const lang = this.#deriveLang(context.filePaths); + if (!lang) { + return []; + } + + const candidates: TtsrEntry[] = []; + for (const [name, entry] of this.#rules) { + if (entry.astConditions.length === 0) { + continue; + } + if ( + !this.#canTrigger(name) || + !this.#matchesScope(entry, context) || + !this.#matchesGlobalPaths(entry, context) + ) { + continue; + } + candidates.push(entry); + } + if (candidates.length === 0) { + return []; + } + + // Throttle: skip re-running the matcher when the source content is unchanged. + const bufferKey = this.#bufferKey(context); + if (this.#lastAstSnapshots.get(bufferKey) === snapshot) { + return []; + } + this.#lastAstSnapshots.set(bufferKey, snapshot); + + const matches: Rule[] = []; + for (const entry of candidates) { + if (await this.#astConditionsMatch(entry.astConditions, snapshot, lang)) { + matches.push(entry.rule); + logger.debug("TTSR ast condition matched", { + ruleName: entry.rule.name, + astConditions: entry.rule.astCondition, + toolName: context.toolName, + filePaths: context.filePaths, + }); + } + } + return matches; + } + + async #astConditionsMatch(patterns: string[], source: string, lang: string): Promise { + try { + const result = await astMatch({ + patterns, + source, + lang, + strictness: AstMatchStrictness.Smart, + limit: 1, + }); + if (result.parseErrors && result.parseErrors.length > 0) { + logger.debug("TTSR ast match reported parse errors", { parseErrors: result.parseErrors }); + } + return result.totalMatches > 0; + } catch (error) { + logger.warn("TTSR ast match failed, treating as no match", { + patterns, + lang, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + /** True when any registered rule carries ast-grep conditions. */ + hasAstRules(): boolean { + if (!this.#settings.enabled) { + return false; + } + for (const entry of this.#rules.values()) { + if (entry.astConditions.length > 0) { + return true; + } + } + return false; + } + #matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] { if (!this.#settings.enabled) { return []; @@ -435,6 +550,7 @@ export class TtsrManager { /** Reset stream buffers (called on new turn). */ resetBuffer(): void { this.#buffers.clear(); + this.#lastAstSnapshots.clear(); } /** Check if any TTSR rules are registered. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f4ea08104..b3c5d630f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1674,89 +1674,18 @@ export class AgentSession { } if (matchContext && "delta" in assistantEvent) { + const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined; const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall); - if (matches.length > 0) { - // Decide first: a non-interrupting tool-source match attaches to the - // specific tool call's result instead of driving a loop-wide follow-up. - const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext); - const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext); - if (perToolId) { - this.#addPerToolTtsrInjections(perToolId, matches); - this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); - } else { - // Queue rules for injection; mark as injected only after successful enqueue. - this.#addPendingTtsrInjections(matches); - - if (shouldInterrupt) { - // Abort the stream immediately — do not gate on extension callbacks - this.#ttsrAbortPending = true; - this.#ensureTtsrResumePromise(); - this.agent.abort(this.#formatTtsrAbortReason(matches)); - // Notify extensions (fire-and-forget, does not block abort) - this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); - // Schedule retry after a short delay - const retryToken = ++this.#ttsrRetryToken; - const generation = this.#promptGeneration; - const targetMessageTimestamp = - event.message.role === "assistant" ? event.message.timestamp : undefined; - this.#schedulePostPromptTask( - async () => { - if (this.#ttsrRetryToken !== retryToken) { - this.#resolveTtsrResume(); - return; - } - - const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp); - if ( - !this.#ttsrAbortPending || - this.#promptGeneration !== generation || - targetAssistantIndex === -1 - ) { - this.#ttsrAbortPending = false; - this.#pendingTtsrInjections = []; - this.#perToolTtsrInjections.clear(); - this.#resolveTtsrResume(); - return; - } - this.#ttsrAbortPending = false; - this.#perToolTtsrInjections.clear(); - const ttsrSettings = this.#ttsrManager?.getSettings(); - if (ttsrSettings?.contextMode === "discard") { - // Remove the partial/aborted assistant turn from agent state - this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex)); - } - // Inject TTSR rules as system reminder before retry - const injection = this.#getTtsrInjectionContent(); - if (injection) { - const details = { rules: injection.rules.map(rule => rule.name) }; - this.agent.appendMessage({ - role: "custom", - customType: "ttsr-injection", - content: injection.content, - display: false, - details, - attribution: "agent", - timestamp: Date.now(), - }); - this.sessionManager.appendCustomMessageEntry( - "ttsr-injection", - injection.content, - false, - details, - "agent", - ); - this.#markTtsrInjected(details.rules); - } - try { - await this.agent.continue(); - } catch { - this.#resolveTtsrResume(); - } - }, - { delayMs: 50 }, - ); - return; - } + if (matches.length > 0 && this.#handleTtsrMatches(matches, matchContext, targetMessageTimestamp)) { + return; + } + // ast-grep `astCondition` rules match against the reconstructed edit/write + // snapshot, which only exists for tool argument streams. The native worker + // call is async, so this path is awaited and self-throttled by the manager. + if (matchContext.source === "tool" && this.#ttsrManager?.hasAstRules()) { + const astMatches = await this.#checkTtsrAstStream(matchContext, streamingToolCall); + if (astMatches.length > 0 && this.#handleTtsrMatches(astMatches, matchContext, targetMessageTimestamp)) { + return; } } } @@ -2428,19 +2357,134 @@ export class AgentSession { if (!manager) { return []; } - if (toolCall) { - const tools = this.agent.state.tools; - const tool = - tools.find(t => t.name === toolCall.name) ?? - tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name); - const digest = tool?.matcherDigest?.(toolCall.arguments ?? {}); - if (digest !== undefined) { - return manager.checkSnapshot(digest, matchContext); - } + const digest = this.#resolveTtsrMatcherDigest(toolCall); + if (digest !== undefined) { + return manager.checkSnapshot(digest, matchContext); } return manager.checkDelta(delta, matchContext); } + /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */ + #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined { + if (!toolCall) { + return undefined; + } + const tools = this.agent.state.tools; + const tool = + tools.find(t => t.name === toolCall.name) ?? + tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name); + return tool?.matcherDigest?.(toolCall.arguments ?? {}); + } + + /** + * Match ast-grep `astCondition` rules against the reconstructed tool snapshot. + * + * Only edit/write tool streams expose a `matcherDigest`, which is the real source + * the call introduces; AST matching needs that (and a language inferred from the + * path argument), so non-digest streams never produce AST matches. + */ + async #checkTtsrAstStream(matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Promise { + const manager = this.#ttsrManager; + if (!manager) { + return []; + } + const digest = this.#resolveTtsrMatcherDigest(toolCall); + if (digest === undefined) { + return []; + } + return manager.checkAstSnapshot(digest, matchContext); + } + + /** + * Route TTSR matches to either a per-tool injection or a stream-interrupting + * retry. Returns true when the stream was aborted and the caller should stop + * processing this event. + */ + #handleTtsrMatches( + matches: Rule[], + matchContext: TtsrMatchContext, + targetMessageTimestamp: number | undefined, + ): boolean { + // Decide first: a non-interrupting tool-source match attaches to the + // specific tool call's result instead of driving a loop-wide follow-up. + const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext); + const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext); + if (perToolId) { + this.#addPerToolTtsrInjections(perToolId, matches); + this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); + return false; + } + + // Queue rules for injection; mark as injected only after successful enqueue. + this.#addPendingTtsrInjections(matches); + if (!shouldInterrupt) { + return false; + } + + // Abort the stream immediately — do not gate on extension callbacks + this.#ttsrAbortPending = true; + this.#ensureTtsrResumePromise(); + this.agent.abort(this.#formatTtsrAbortReason(matches)); + // Notify extensions (fire-and-forget, does not block abort) + this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); + // Schedule retry after a short delay + const retryToken = ++this.#ttsrRetryToken; + const generation = this.#promptGeneration; + this.#schedulePostPromptTask( + async () => { + if (this.#ttsrRetryToken !== retryToken) { + this.#resolveTtsrResume(); + return; + } + + const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp); + if (!this.#ttsrAbortPending || this.#promptGeneration !== generation || targetAssistantIndex === -1) { + this.#ttsrAbortPending = false; + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + this.#resolveTtsrResume(); + return; + } + this.#ttsrAbortPending = false; + this.#perToolTtsrInjections.clear(); + const ttsrSettings = this.#ttsrManager?.getSettings(); + if (ttsrSettings?.contextMode === "discard") { + // Remove the partial/aborted assistant turn from agent state + this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex)); + } + // Inject TTSR rules as system reminder before retry + const injection = this.#getTtsrInjectionContent(); + if (injection) { + const details = { rules: injection.rules.map(rule => rule.name) }; + this.agent.appendMessage({ + role: "custom", + customType: "ttsr-injection", + content: injection.content, + display: false, + details, + attribution: "agent", + timestamp: Date.now(), + }); + this.sessionManager.appendCustomMessageEntry( + "ttsr-injection", + injection.content, + false, + details, + "agent", + ); + this.#markTtsrInjected(details.rules); + } + try { + await this.agent.continue(); + } catch { + this.#resolveTtsrResume(); + } + }, + { delayMs: 50 }, + ); + return true; + } + /** Extract path-like arguments from tool call payload for TTSR glob matching. */ #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) { @@ -4579,7 +4623,8 @@ export class AgentSession { content: msg.content, display: msg.display, details: msg.details, - attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"), + attribution: + msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"), timestamp: Date.now(), }), ); diff --git a/packages/coding-agent/test/capability/rule-buckets.test.ts b/packages/coding-agent/test/capability/rule-buckets.test.ts index c2b1a6a6c..dfcd6d31a 100644 --- a/packages/coding-agent/test/capability/rule-buckets.test.ts +++ b/packages/coding-agent/test/capability/rule-buckets.test.ts @@ -16,6 +16,7 @@ function makeRule(partial: Partial): Rule { alwaysApply: partial.alwaysApply, description: partial.description, condition: partial.condition, + astCondition: partial.astCondition, scope: partial.scope, interruptMode: partial.interruptMode, _source: partial._source ?? source("native"), @@ -34,6 +35,18 @@ describe("bucketRules", () => { expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" }).map(r => r.name)).toEqual(["no-foo"]); }); + it("registers an ast-only rule as TTSR and excludes it from rulebook/always buckets", () => { + const mgr = new TtsrManager(); + const ttsr = makeRule({ name: "no-console", astCondition: ["console.log($A)"], description: "blocks console" }); + + const { rulebookRules, alwaysApplyRules } = bucketRules([ttsr], mgr); + + expect(rulebookRules).toHaveLength(0); + expect(alwaysApplyRules).toHaveLength(0); + expect(mgr.hasRules()).toBe(true); + expect(mgr.hasAstRules()).toBe(true); + }); + it("splits non-TTSR rules into always-apply and rulebook by metadata", () => { const mgr = new TtsrManager(); const sticky = makeRule({ name: "sticky", alwaysApply: true, description: "sticky desc" }); diff --git a/packages/coding-agent/test/discovery/builtin-defaults.test.ts b/packages/coding-agent/test/discovery/builtin-defaults.test.ts index 9a36ca360..6fe44b039 100644 --- a/packages/coding-agent/test/discovery/builtin-defaults.test.ts +++ b/packages/coding-agent/test/discovery/builtin-defaults.test.ts @@ -26,6 +26,7 @@ const EXPECTED_RULE_NAMES = [ "ts-no-return-type", "ts-no-tiny-functions", "ts-promise-with-resolvers", + "ts-redundant-clear-guard", "ts-set-map", ].sort(); @@ -52,14 +53,22 @@ describe("builtin-defaults rule provider", () => { expect(rules.every(r => r._source.provider === BUILTIN_DEFAULTS_PROVIDER_ID)).toBe(true); }); - it("parses every bundled rule as a TTSR rule (non-empty condition and scope)", async () => { + it("parses every bundled rule as a TTSR rule (non-empty condition/astCondition and scope)", async () => { const rules = await loadBuiltinRules(); for (const rule of rules) { - expect(rule.condition?.length, `${rule.name} condition`).toBeGreaterThan(0); + const conditionCount = (rule.condition?.length ?? 0) + (rule.astCondition?.length ?? 0); + expect(conditionCount, `${rule.name} condition/astCondition`).toBeGreaterThan(0); expect(rule.scope?.length, `${rule.name} scope`).toBeGreaterThan(0); } }); + it("bundles ast-grep conditions for the redundant-clear-guard rule", async () => { + const rules = await loadBuiltinRules(); + const rule = rules.find(r => r.name === "ts-redundant-clear-guard"); + expect(rule?.condition).toBeUndefined(); + expect(rule?.astCondition?.length).toBeGreaterThan(0); + }); + it("parses YAML list-form conditions from the embedded text", async () => { const rules = await loadBuiltinRules(); const lazylock = rules.find(r => r.name === "rs-lazylock"); diff --git a/packages/coding-agent/test/ttsr.test.ts b/packages/coding-agent/test/ttsr.test.ts index 4f04ad3e0..96d9adca9 100644 --- a/packages/coding-agent/test/ttsr.test.ts +++ b/packages/coding-agent/test/ttsr.test.ts @@ -25,6 +25,7 @@ function makeRule(partial: Partial): Rule { alwaysApply: partial.alwaysApply, description: partial.description, condition: partial.condition, + astCondition: partial.astCondition, scope: partial.scope, _source: partial._source ?? { provider: "test", @@ -100,6 +101,26 @@ describe("parseRuleConditionAndScope", () => { expect(parsed.condition).toEqual([".*"]); expect(parsed.scope).toEqual(["tool:edit(*.rs)", "tool:write(*.rs)"]); }); + + it("normalizes astCondition strings and arrays without glob inference", () => { + expect(parseRuleConditionAndScope({ astCondition: "console.log($A)" }).astCondition).toEqual(["console.log($A)"]); + const parsed = parseRuleConditionAndScope({ + astCondition: ["console.log($A)", "debugger"], + }); + expect(parsed.astCondition).toEqual(["console.log($A)", "debugger"]); + // AST patterns never drive scope inference, and absent regex stays absent. + expect(parsed.condition).toBeUndefined(); + expect(parsed.scope).toBeUndefined(); + }); + + it("carries astCondition alongside a regex condition", () => { + const parsed = parseRuleConditionAndScope({ + condition: "TODO", + astCondition: "console.log($A)", + }); + expect(parsed.condition).toEqual(["TODO"]); + expect(parsed.astCondition).toEqual(["console.log($A)"]); + }); }); describe("TtsrManager scope matching", () => { @@ -394,6 +415,95 @@ describe("TtsrManager snapshot matching", () => { }); }); +describe("TtsrManager ast condition matching", () => { + const editContext = { + source: "tool" as const, + toolName: "edit", + filePaths: ["src/main.ts"], + streamKey: "toolcall:ast-1", + }; + + it("registers and reports ast-only rules without a regex condition", () => { + const manager = new TtsrManager(); + const rule = makeRule({ name: "no-console", astCondition: ["console.log($A)"] }); + + expect(manager.addRule(rule)).toBe(true); + expect(manager.hasRules()).toBe(true); + expect(manager.hasAstRules()).toBe(true); + }); + + it("does not report ast rules when only regex conditions are registered", () => { + const manager = new TtsrManager(); + manager.addRule(makeRule({ name: "regex-only", condition: ["TODO"], scope: ["text"] })); + expect(manager.hasAstRules()).toBe(false); + }); + + it("matches an ast pattern against a reconstructed source snapshot", async () => { + const manager = new TtsrManager(); + const rule = makeRule({ + name: "no-console", + astCondition: ["console.log($A)"], + scope: ["tool:edit(*.ts)"], + }); + manager.addRule(rule); + + const matches = await manager.checkAstSnapshot('function greet() {\n\tconsole.log("hi");\n}', editContext); + expect(matches).toEqual([rule]); + }); + + it("does not match when the ast pattern is absent", async () => { + const manager = new TtsrManager(); + manager.addRule(makeRule({ name: "no-console", astCondition: ["console.log($A)"] })); + + const matches = await manager.checkAstSnapshot("function greet() {\n\treturn 1;\n}", editContext); + expect(matches).toEqual([]); + }); + + it("infers language from the file extension and isolates other languages", async () => { + const manager = new TtsrManager(); + manager.addRule(makeRule({ name: "no-console", astCondition: ["console.log($A)"], scope: ["tool:edit(*.ts)"] })); + + // A `.rs` path is out of the rule's tool scope, so the TS pattern never runs. + const rustMatches = await manager.checkAstSnapshot('println!("{}", x);', { + ...editContext, + filePaths: ["src/main.rs"], + streamKey: "toolcall:ast-rs", + }); + expect(rustMatches).toEqual([]); + }); + + it("skips ast evaluation when no file path is available to infer a language", async () => { + const manager = new TtsrManager(); + manager.addRule(makeRule({ name: "no-console", astCondition: ["console.log($A)"] })); + + const matches = await manager.checkAstSnapshot('console.log("hi");', { + source: "tool", + toolName: "edit", + streamKey: "toolcall:ast-nopath", + }); + expect(matches).toEqual([]); + }); + + it("evaluates ast conditions only once for an unchanged snapshot", async () => { + const manager = new TtsrManager(); + const rule = makeRule({ name: "no-console", astCondition: ["console.log($A)"] }); + manager.addRule(rule); + const snapshot = 'console.log("hi");'; + + // First evaluation matches; the throttle returns nothing for the identical re-check. + expect(await manager.checkAstSnapshot(snapshot, editContext)).toEqual([rule]); + expect(await manager.checkAstSnapshot(snapshot, editContext)).toEqual([]); + }); + + it("returns no ast matches when ttsr is disabled", async () => { + const manager = ttsrManager({ enabled: false }); + manager.addRule(makeRule({ name: "no-console", astCondition: ["console.log($A)"] })); + + expect(manager.hasAstRules()).toBe(false); + expect(await manager.checkAstSnapshot('console.log("hi");', editContext)).toEqual([]); + }); +}); + describe("TtsrManager repeat behavior", () => { const turnContext = { source: "text" as const }; diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index e462e5a0f..1d652b8c2 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -228,6 +228,56 @@ export interface AstFindResult { */ export declare function astGrep(options: AstFindOptions): Promise +/** + * Match ast-grep patterns against an in-memory source string; returns a + * promise resolved on a worker thread. + * + * This is the file-free counterpart to [`ast_grep`]: callers that already hold + * the source (streaming buffers, generated code, editor contents) avoid a + * temp-file round trip. `lang` is required since there is no path to infer it + * from. + */ +export declare function astMatch(options: AstMatchOptions): Promise + +/** + * Options for `astMatch`: run ast-grep patterns against an in-memory source + * string instead of files on disk. + */ +export interface AstMatchOptions { + /** Source code to match against (parsed in memory, never read from disk). */ + source: string + /** Language of `source` (required; e.g. "ts", "tsx", "rust", "python"). */ + lang: string + /** ast-grep patterns to search for (OR across patterns). */ + patterns: Array + /** Rule selector for multi-rule ast-grep configurations. */ + selector?: string + /** Pattern strictness; defaults to smart matching when omitted. */ + strictness?: AstMatchStrictness + /** Maximum matches to return after `offset` (default applies when omitted). */ + limit?: number + /** Number of leading matches to skip before applying `limit`. */ + offset?: number + /** When true, include meta-variable bindings per match. */ + includeMeta?: boolean + /** Optional cancellation handle (library-specific). */ + signal?: unknown + /** Wall-clock timeout for the worker task in milliseconds. */ + timeoutMs?: number +} + +/** Result of an in-memory `astMatch` run. */ +export interface AstMatchResult { + /** Page of matches after sort, offset, and limit. */ + matches: Array + /** Total matches found before paging (can exceed `matches.length`). */ + totalMatches: number + /** True when results were truncated by `limit`. */ + limitReached: boolean + /** Non-fatal parse or pattern-compile errors collected during the run. */ + parseErrors?: Array +} + /** ast-grep pattern strictness (controls how patterns match syntax). */ export declare enum AstMatchStrictness { /** Match at the concrete syntax tree level. */ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index a694fcdbc..18a5d4b3d 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -27,6 +27,7 @@ export const __piNativesV15_10_4 = nativeBindings.__piNativesV15_10_4; export const applyBashFixups = nativeBindings.applyBashFixups; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; +export const astMatch = nativeBindings.astMatch; export const blockRangeAt = nativeBindings.blockRangeAt; export const copyToClipboard = nativeBindings.copyToClipboard; export const countTokens = nativeBindings.countTokens; From cf352b7277653cdd32d6cb049b72835230c80316 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:57:44 +0200 Subject: [PATCH 22/53] fix(coding-agent/modes): serialized hook dialogs to prevent overlapping modal surfaces - Serialized hook selector, input, and editor dialogs to prevent overlapping surfaces. - Queued dialog requests FIFO and skipped requests aborted before they could be shown. - Added a shared #presentDialog path for modal focus and hide lifecycle. --- .../controllers/extension-ui-controller.ts | 270 ++++++++++-------- .../coding-agent/test/hook-editor.test.ts | 72 +++++ 2 files changed, 215 insertions(+), 127 deletions(-) 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 31d6e7cc0..3b6abe82e 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,11 @@ export class ExtensionUiController { #extensionTerminalInputUnsubscribers = new Set<() => void>(); #hookWidgetsAbove = new Map(); #hookWidgetsBelow = new Map(); + // Single-file dialog surface (`editorContainer` + focus) is shared by the + // selector / input / editor modals, so only one may be presented at a time; + // the rest queue. See `#presentDialog`. + #dialogActive = false; + #dialogQueue: Array<() => void> = []; constructor(private ctx: InteractiveModeContext) {} /** @@ -528,58 +533,47 @@ export class ExtensionUiController { dialogOptions?: InteractiveSelectorDialogOptions, extra?: { slider?: HookSelectorSlider }, ): Promise { - const { promise, finish, attachAbort } = this.#createHookDialogState( - () => this.hideHookSelector(), - dialogOptions?.signal, - ); - const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); - this.ctx.hookSelector = new HookSelectorComponent( - title, - options, - option => { - this.hideHookSelector(); - finish(option); - }, - () => { - this.hideHookSelector(); - finish(undefined); - }, - { - onLeft: dialogOptions?.onLeft - ? () => { - this.hideHookSelector(); - dialogOptions.onLeft?.(); - finish(undefined); - } - : undefined, - onRight: dialogOptions?.onRight - ? () => { - this.hideHookSelector(); - dialogOptions.onRight?.(); - finish(undefined); - } - : undefined, - onExternalEditor: dialogOptions?.onExternalEditor, - helpText: dialogOptions?.helpText, - initialIndex: dialogOptions?.initialIndex, - timeout: dialogOptions?.timeout, - onTimeout: dialogOptions?.onTimeout, - tui: this.ctx.ui, - outline: dialogOptions?.outline, - disabledIndices: dialogOptions?.disabledIndices, - selectionMarker: dialogOptions?.selectionMarker, - checkedIndices: dialogOptions?.checkedIndices, - markableCount: dialogOptions?.markableCount, - maxVisible, - slider: extra?.slider, - }, - ); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookSelector); - this.ctx.ui.setFocus(this.ctx.hookSelector); - this.ctx.ui.requestRender(); - attachAbort(); - return promise; + return this.#presentDialog(dialogOptions?.signal, settle => { + const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); + this.ctx.hookSelector = new HookSelectorComponent( + title, + options, + option => settle(option), + () => settle(undefined), + { + onLeft: dialogOptions?.onLeft + ? () => { + dialogOptions.onLeft?.(); + settle(undefined); + } + : undefined, + onRight: dialogOptions?.onRight + ? () => { + dialogOptions.onRight?.(); + settle(undefined); + } + : undefined, + onExternalEditor: dialogOptions?.onExternalEditor, + helpText: dialogOptions?.helpText, + initialIndex: dialogOptions?.initialIndex, + timeout: dialogOptions?.timeout, + onTimeout: dialogOptions?.onTimeout, + tui: this.ctx.ui, + outline: dialogOptions?.outline, + disabledIndices: dialogOptions?.disabledIndices, + selectionMarker: dialogOptions?.selectionMarker, + checkedIndices: dialogOptions?.checkedIndices, + markableCount: dialogOptions?.markableCount, + maxVisible, + slider: extra?.slider, + }, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookSelector); + this.ctx.ui.setFocus(this.ctx.hookSelector); + this.ctx.ui.requestRender(); + return () => this.hideHookSelector(); + }); } /** * Hide the hook selector. @@ -609,33 +603,24 @@ export class ExtensionUiController { placeholder?: string, dialogOptions?: ExtensionUIDialogOptions, ): Promise { - const { promise, finish, attachAbort } = this.#createHookDialogState( - () => this.hideHookInput(), - dialogOptions?.signal, - ); - this.ctx.hookInput = new HookInputComponent( - title, - placeholder, - value => { - this.hideHookInput(); - finish(value); - }, - () => { - this.hideHookInput(); - finish(undefined); - }, - { - timeout: dialogOptions?.timeout, - onTimeout: dialogOptions?.onTimeout, - tui: this.ctx.ui, - }, - ); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookInput); - this.ctx.ui.setFocus(this.ctx.hookInput); - this.ctx.ui.requestRender(); - attachAbort(); - return promise; + return this.#presentDialog(dialogOptions?.signal, settle => { + this.ctx.hookInput = new HookInputComponent( + title, + placeholder, + value => settle(value), + () => settle(undefined), + { + timeout: dialogOptions?.timeout, + onTimeout: dialogOptions?.onTimeout, + tui: this.ctx.ui, + }, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookInput); + this.ctx.ui.setFocus(this.ctx.hookInput); + this.ctx.ui.requestRender(); + return () => this.hideHookInput(); + }); } /** @@ -659,31 +644,21 @@ export class ExtensionUiController { dialogOptions?: ExtensionUIDialogOptions, editorOptions?: { promptStyle?: boolean }, ): Promise { - const { promise, finish, attachAbort } = this.#createHookDialogState( - () => this.hideHookEditor(), - dialogOptions?.signal, - ); - this.ctx.hookEditor = new HookEditorComponent( - this.ctx.ui, - title, - prefill, - value => { - this.hideHookEditor(); - finish(value); - }, - () => { - this.hideHookEditor(); - finish(undefined); - }, - editorOptions, - ); - - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookEditor); - this.ctx.ui.setFocus(this.ctx.hookEditor); - this.ctx.ui.requestRender(); - attachAbort(); - return promise; + return this.#presentDialog(dialogOptions?.signal, settle => { + this.ctx.hookEditor = new HookEditorComponent( + this.ctx.ui, + title, + prefill, + value => settle(value), + () => settle(undefined), + editorOptions, + ); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookEditor); + this.ctx.ui.setFocus(this.ctx.hookEditor); + this.ctx.ui.requestRender(); + return () => this.hideHookEditor(); + }); } /** @@ -834,37 +809,78 @@ export class ExtensionUiController { } } - #createHookDialogState( - hide: () => void, + /** + * Present a modal dialog on the shared editor surface, serializing against any + * dialog already open. `present` builds the component, swaps it into + * `editorContainer`, steals focus, and returns a `hide` closure; it is invoked + * with a single `settle` callback that the component fires on submit/cancel. + * + * Because selector / input / editor all clear `editorContainer` and re-focus, + * showing a second one while the first is open would orphan the first — its + * promise would hang until the caller's signal aborts. So at most one dialog is + * presented at a time and the rest queue (FIFO). `settle` (or an abort) hides + * the current dialog and hands the surface to the next queued request. A request + * whose signal aborts before its turn resolves `undefined` and is never shown. + */ + #presentDialog( signal: AbortSignal | undefined, - ): { - promise: Promise; - finish: (value: string | undefined) => void; - attachAbort: () => void; - } { - const { promise, resolve } = Promise.withResolvers(); + present: (settle: (value: string | undefined) => void) => () => void, + ): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; - const onAbort = () => { - hide(); - if (!settled) { - settled = true; - resolve(undefined); - } - }; - const finish = (value: string | undefined) => { + let started = false; + let hide: (() => void) | undefined; + + function onAbort(): void { + settle(undefined); + } + + const settle = (value: string | undefined): void => { if (settled) return; settled = true; signal?.removeEventListener("abort", onAbort); + if (started) { + hide?.(); + this.#dialogActive = false; + this.#advanceDialogQueue(); + } resolve(value); }; - const attachAbort = () => { - if (!signal) return; - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); + + const startPresentation = (): void => { + if (settled) { + // Aborted before its turn arrived — never present, hand off the surface. + this.#advanceDialogQueue(); + return; + } + started = true; + this.#dialogActive = true; + try { + hide = present(settle); + } catch (error) { + settled = true; + signal?.removeEventListener("abort", onAbort); + this.#dialogActive = false; + reject(error); + this.#advanceDialogQueue(); } }; - return { promise, finish, attachAbort }; + + if (signal?.aborted) { + resolve(undefined); + return promise; + } + signal?.addEventListener("abort", onAbort, { once: true }); + + if (this.#dialogActive) { + this.#dialogQueue.push(startPresentation); + } else { + startPresentation(); + } + return promise; + } + + #advanceDialogQueue(): void { + this.#dialogQueue.shift()?.(); } } diff --git a/packages/coding-agent/test/hook-editor.test.ts b/packages/coding-agent/test/hook-editor.test.ts index 961cd19f3..0e3eb5019 100644 --- a/packages/coding-agent/test/hook-editor.test.ts +++ b/packages/coding-agent/test/hook-editor.test.ts @@ -412,3 +412,75 @@ describe("ExtensionUiController hook editor abort", () => { expect(result).toBeDefined(); }); }); + +describe("ExtensionUiController dialog serialization", () => { + type SelectorController = { + showHookSelector: ( + title: string, + options: string[], + dialogOptions?: { signal?: AbortSignal }, + ) => Promise; + }; + + it("queues a second selector instead of clobbering the open one", async () => { + const { ctx, editor, editorContainer } = createControllerContext(); + const controller = new ExtensionUiController(ctx) as unknown as SelectorController; + + const abortA = new AbortController(); + const abortB = new AbortController(); + + const promiseA = controller.showHookSelector("A", ["a1", "a2"], { signal: abortA.signal }); + // First dialog is presented synchronously on the shared surface. + const componentA = ctx.hookSelector; + expect(componentA).toBeDefined(); + expect(editorContainer.children).toEqual([componentA]); + + const promiseB = controller.showHookSelector("B", ["b1", "b2"], { signal: abortB.signal }); + // The second request must NOT swap itself into the surface while A is open — + // that orphaning is exactly the hang this serialization fixes. + expect(ctx.hookSelector).toBe(componentA); + expect(editorContainer.children).toEqual([componentA]); + + // Resolving A hands the surface to the queued B. + abortA.abort(); + await Bun.sleep(0); + expect(await promiseA).toBeUndefined(); + const componentB = ctx.hookSelector; + expect(componentB).toBeDefined(); + expect(componentB).not.toBe(componentA); + expect(editorContainer.children).toEqual([componentB]); + + // Resolving B restores the core editor. + abortB.abort(); + await Bun.sleep(0); + expect(await promiseB).toBeUndefined(); + expect(ctx.hookSelector).toBeUndefined(); + expect(editorContainer.children).toEqual([editor]); + }); + + it("never presents a queued selector whose signal aborts before its turn", async () => { + const { ctx, editor, editorContainer } = createControllerContext(); + const controller = new ExtensionUiController(ctx) as unknown as SelectorController; + + const abortA = new AbortController(); + const abortB = new AbortController(); + + const promiseA = controller.showHookSelector("A", ["a1"], { signal: abortA.signal }); + const componentA = ctx.hookSelector; + const promiseB = controller.showHookSelector("B", ["b1"], { signal: abortB.signal }); + + // Abort the queued B before A releases the surface. + abortB.abort(); + expect(await promiseB).toBeUndefined(); + // A is untouched and still owns the surface. + expect(ctx.hookSelector).toBe(componentA); + expect(editorContainer.children).toEqual([componentA]); + + // When A resolves, the skipped B must not be shown — surface returns to editor. + abortA.abort(); + await Bun.sleep(0); + expect(await promiseA).toBeUndefined(); + expect(ctx.hookSelector).toBeUndefined(); + expect(editorContainer.children).toEqual([editor]); + }); +}); From 16cb096eed30528c1106b3f303b21e654fde4744 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:58:09 +0200 Subject: [PATCH 23/53] fix(ai): fixed Anthropic CCH anchoring and agent tool interruption handling - Deferred non-interrupting asides to the outer drain when a turn was finishing, preventing an extra model turn from firing before queued follow-ups. - Coerced post-hook tool results before emission and only marked interrupted tool calls as skipped when execution failed, preserving completed call outcomes. - Reworked Anthropic CCH patching with native `Buffer.indexOf`, and logged a warning while sending the body unchanged when the billing placeholder could not be anchored. --- packages/agent/src/agent-loop.ts | 32 +++++++--- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/anthropic.ts | 58 ++++++++----------- .../ai/test/anthropic-stream-envelope.test.ts | 22 ++++--- 4 files changed, 63 insertions(+), 50 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index fc859bb0c..c463525a8 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -740,8 +740,16 @@ async function runLoopBody( stream.push({ type: "turn_end", message, toolResults }); const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []); - const asides = resolveAsides(await config.getAsideMessages?.()); - pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering; + if (hasMoreToolCalls) { + // Mid-work: fold any non-interrupting asides into the next turn alongside steering. + const asides = resolveAsides(await config.getAsideMessages?.()); + pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering; + } else { + // Stop boundary: only steering (live user input) forces another turn here. Leave + // asides for the outer drain below so a passive aside can't trigger an extra model + // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together. + pendingMessages = steering; + } } // Agent would stop here. Drain non-interrupting asides + follow-up messages. @@ -1428,12 +1436,17 @@ async function executeToolCalls( toolSignal, ); if (after) { - result = { + // Re-normalize the post-hook result: `afterToolCall` is untyped user/extension + // code and may return malformed `content` (non-array / invalid blocks), which + // would otherwise be persisted verbatim and corrupt the session — the same + // hazard `coerceToolResult` guards on the execute path. + const coerced = coerceToolResult({ content: after.content ?? result.content, details: after.details ?? result.details, isError: after.isError ?? result.isError, - }; - isError = after.isError ?? isError; + }); + result = coerced.result; + isError = coerced.malformed || (after.isError ?? isError); } } catch (e) { caughtError = e; @@ -1448,10 +1461,15 @@ async function executeToolCalls( const interrupted = interruptState.triggered; const abortedDuringExecution = toolSignal.aborted && isError; - if (interrupted) { + if (interrupted && isError) { + // Steering/abort fired AND this tool failed — it was cut off before producing a + // usable result, so report it as skipped. record.skipped = true; emitToolResult(record, createSkippedToolResult(), true); } else { + // No interrupt, or the tool finished (successfully or with a genuine error) before + // the interrupt landed. Keep its real result: a completed tool already ran its side + // effects, so the model must see what actually happened rather than a false "skipped". emitToolResult(record, result, isError); } @@ -1459,7 +1477,7 @@ async function executeToolCalls( const errorMessageForSpan = caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined; const status = - interrupted || abortedDuringExecution + (interrupted && isError) || abortedDuringExecution ? "aborted" : caughtError instanceof ToolCallBlockedError ? "blocked" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 272a04f91..b8621b3cf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed - Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429 +- Optimized the Anthropic `cch` attestation patch to locate the billing-header placeholder with native `Buffer.indexOf` (memmem) instead of a hand-rolled byte loop. The marker sits ~99% through the body (`messages` serializes before `system`), so the old scan walked almost the entire payload; output bytes are unchanged but the patch is ~7.5x faster (563µs -> 75µs on a 1MB body). ### Fixed diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 49583f0c7..ec7c35c99 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -536,37 +536,28 @@ const CCH_PLACEHOLDER = cchEncoder.encode(CCH_PLACEHOLDER_STR); const BILLING_SYSTEM_MARKER = cchEncoder.encode(`"system":[{"type":"text","text":"${CLAUDE_BILLING_HEADER_PREFIX}`); const CCH_BILLING_SEARCH_WINDOW = 150; -function patchCch(body: Uint8Array): Uint8Array { - // Find the combined system[0] + billing-header prefix marker. - let markerIdx = -1; - outer: for (let i = 0; i <= body.length - BILLING_SYSTEM_MARKER.length; i++) { - for (let j = 0; j < BILLING_SYSTEM_MARKER.length; j++) { - if (body[i + j] !== BILLING_SYSTEM_MARKER[j]) continue outer; - } - markerIdx = i; - break; - } - if (markerIdx === -1) return body; // no CC billing header injected +function patchCch(body: Uint8Array): boolean { + // Zero-copy Buffer view over the same memory; its `indexOf` is a native memmem, + // ~7.5x faster than a hand-rolled byte loop here — the marker sits ~99% through + // the body because `messages` serializes before `system`, so a JS scan would + // walk almost the entire payload (benchmarked: 563µs -> 75µs on a 1MB body). + const view = Buffer.from(body.buffer, body.byteOffset, body.byteLength); - // Scan at most CCH_BILLING_SEARCH_WINDOW bytes after the marker for the placeholder. + // Find the combined system[0] + billing-header prefix marker. + const markerIdx = view.indexOf(BILLING_SYSTEM_MARKER); + if (markerIdx === -1) return false; // no CC billing header injected + + // Placeholder must sit within CCH_BILLING_SEARCH_WINDOW bytes after the marker. const searchFrom = markerIdx + BILLING_SYSTEM_MARKER.length; - const searchTo = Math.min(searchFrom + CCH_BILLING_SEARCH_WINDOW, body.length - CCH_PLACEHOLDER.length); - let idx = -1; - outer2: for (let i = searchFrom; i <= searchTo; i++) { - for (let j = 0; j < CCH_PLACEHOLDER.length; j++) { - if (body[i + j] !== CCH_PLACEHOLDER[j]) continue outer2; - } - idx = i; - break; - } - if (idx === -1) return body; // placeholder not within the billing header value + const idx = view.indexOf(CCH_PLACEHOLDER, searchFrom); + if (idx === -1 || idx - searchFrom > CCH_BILLING_SEARCH_WINDOW) return false; // Hash the body with the placeholder in place (matches CC's in-place behaviour). const h = Bun.hash.xxHash64(body, CCH_SEED); const cch = (h & 0xfffffn).toString(16).padStart(5, "0"); for (let i = 0; i < 5; i++) body[idx + 4 + i] = cch.charCodeAt(i); - return body; + return true; } type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; @@ -575,8 +566,15 @@ function wrapFetchForCch(base: FetchFn): FetchFn { return (input, init) => { if (init?.body && typeof init.body === "string" && init.body.includes(CCH_PLACEHOLDER_STR)) { const encoded = cchEncoder.encode(init.body); - const patched = patchCch(encoded); - return base(input, { ...init, body: patched }); + if (!patchCch(encoded)) { + // The OAuth billing placeholder is present but we couldn't anchor it to + // system[0] — e.g. an `onPayload` hook reordered the first system block's keys + // so BILLING_SYSTEM_MARKER no longer matches. Send the body as-is (cch stays + // `00000`, the prior behaviour) rather than failing the request, but surface the + // fingerprint regression instead of letting it ship silently. + logger.warn("anthropic: cch billing placeholder present but not patched; sending unattested request"); + } + return base(input, { ...init, body: encoded }); } return base(input, init); }; @@ -1919,16 +1917,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( firstTokenTime = undefined; } } - - // A strict-tools / grammar-too-large fallback (or a transient retry that ran - // after one) stamps `strictFallbackErrorMessage` onto `output.errorMessage` for - // the *discarded* attempt. Reaching here means a later attempt succeeded - // (the in-loop guard throws on error/aborted before this break), so drop the - // stale 400 text — otherwise a successful turn ships a phantom error string to - // telemetry/UI. Only the exact stale value is cleared, never a real refusal message. - if (output.errorMessage !== undefined && output.errorMessage === strictFallbackErrorMessage) { - output.errorMessage = undefined; - } output.duration = Date.now() - startTime; if (firstTokenTime) output.ttft = firstTokenTime - startTime; if (dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority") { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index a10fc8df6..f1099751e 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -34,6 +34,18 @@ const cityObjectSchema = { type MockAnthropicEvent = Record; type MockAnthropicStream = AsyncIterable; + +// Provider session state is keyed per endpoint+model (`anthropic-messages:\0`), +// with a legacy unscoped `anthropic-messages` key still honored. Look up the strict-tools +// flag without depending on the exact key shape. +function anthropicStrictToolsDisabled(map: Map): boolean | undefined { + for (const [key, value] of map) { + if (key === "anthropic-messages" || key.startsWith("anthropic-messages:")) { + return (value as { strictToolsDisabled?: boolean }).strictToolsDisabled; + } + } + return undefined; +} type MockAnthropicRequest = { withResponse(): Promise<{ data: MockAnthropicStream; @@ -381,10 +393,7 @@ describe("anthropic stream envelope handling", () => { expect(countEvents(events, "done")).toBe(1); expect(countEvents(events, "error")).toBe(0); expect(strictFlags).toEqual([[true], [false]]); - expect( - (providerSessionState.get("anthropic-messages") as { strictToolsDisabled?: boolean } | undefined) - ?.strictToolsDisabled, - ).toBe(true); + expect(anthropicStrictToolsDisabled(providerSessionState)).toBe(true); const nextStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test", providerSessionState }); const nextEvents: AssistantMessageEvent[] = []; @@ -434,10 +443,7 @@ describe("anthropic stream envelope handling", () => { expect(countEvents(events, "error")).toBe(1); expect(countEvents(events, "done")).toBe(0); expect(strictFlags).toEqual([[true]]); - expect( - (providerSessionState.get("anthropic-messages") as { strictToolsDisabled?: boolean } | undefined) - ?.strictToolsDisabled, - ).toBe(false); + expect(anthropicStrictToolsDisabled(providerSessionState)).toBe(false); }); it("does not retry malformed envelopes after partial tool-call content starts streaming", async () => { From 3d5572a0fc1fa9dbb1625e1d53695db076aba571 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 14:59:20 +0200 Subject: [PATCH 24/53] test(natives): documented AST conditions and expanded astMatch tests - Updated `docs/ttsr-injection-lifecycle.md` to document `astCondition` behavior, including registration rules and tool-stream matching. - Extended `packages/natives/test/native.test.ts` with new `astMatch` coverage for Smart matching, metavariable consistency, parse errors, and empty-language rejection. --- docs/rulebook-matching-pipeline.md | 14 ++++--- docs/ttsr-injection-lifecycle.md | 13 ++++-- packages/agent/src/agent-loop.ts | 7 +++- packages/ai/src/providers/anthropic.ts | 38 ++++++++++++----- .../coding-agent/src/utils/image-loading.ts | 2 +- packages/natives/test/native.test.ts | 42 +++++++++++++++++++ 6 files changed, 93 insertions(+), 23 deletions(-) diff --git a/docs/rulebook-matching-pipeline.md b/docs/rulebook-matching-pipeline.md index edf7889dd..089652fe7 100644 --- a/docs/rulebook-matching-pipeline.md +++ b/docs/rulebook-matching-pipeline.md @@ -38,6 +38,7 @@ interface Rule { alwaysApply?: boolean; description?: string; condition?: string[]; + astCondition?: string[]; scope?: string[]; interruptMode?: "never" | "prose-only" | "tool-only" | "always"; _source: SourceMeta; @@ -183,16 +184,16 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl 1. Drop rules listed in `ttsr.disabledRules`. 2. Drop rules from the `builtin-defaults` provider when `ttsr.builtinRules === false`. -3. Register rules with non-empty `condition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only. +3. Register rules with a non-empty `condition` or `astCondition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only. 4. Put remaining `alwaysApply === true` rules into `alwaysApplyRules`. 5. Put remaining rules with `description` into `rulebookRules`. ### Bucket behavior -- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets. +- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` (regex) or `astCondition` (ast-grep patterns) that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets. - **Always-apply bucket**: `alwaysApply === true`, not TTSR. Full content injected into system prompt. Resolvable via `rule://`. - **Rulebook bucket**: must have description, must not be TTSR, must not be `alwaysApply`. Listed in system prompt by name+description; content read on demand via `rule://`. -- A rule with both `condition` and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply. +- A rule with both a trigger condition and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply. - A rule with both `alwaysApply` and `description` goes to always-apply only (not rulebook). ## 6. How metadata affects runtime surfaces @@ -219,10 +220,11 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl - **Full rule content is auto-injected into the system prompt** (before the rulebook rules section). - Rule is also addressable via `rule://` for re-reading. -### `condition`, `scope`, and `interruptMode` +### `condition`, `astCondition`, `scope`, and `interruptMode` -- `condition` is the current TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing. -- `scope` narrows TTSR matching scope. A condition token that looks like a file glob becomes `tool:edit()` and `tool:write()` scope entries plus catch-all condition `.*`. +- `condition` is the regex TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing. +- `astCondition` is the ast-grep trigger field: a string or list of structural patterns, kept verbatim (no glob inference). It only matches on edit/write tool streams, where the language is inferred from the file path. A rule may set `condition`, `astCondition`, or both. +- `scope` narrows TTSR matching scope. A `condition` token that looks like a file glob becomes `tool:edit()` and `tool:write()` scope entries plus catch-all condition `.*`; `astCondition` tokens never trigger this shorthand. - `interruptMode` can override the global TTSR interrupt mode for the rule. ## 7. System prompt inclusion path diff --git a/docs/ttsr-injection-lifecycle.md b/docs/ttsr-injection-lifecycle.md index 375c2c374..6f6a63682 100644 --- a/docs/ttsr-injection-lifecycle.md +++ b/docs/ttsr-injection-lifecycle.md @@ -43,12 +43,18 @@ const { rulebookRules, alwaysApplyRules } = bucketRules( Registration is skipped when: -- `rule.condition` is absent or all condition regexes fail to compile +- both `rule.condition` (regex) and `rule.astCondition` (ast-grep patterns) are absent, or every regex condition fails to compile and there are no AST conditions - a rule with the same `rule.name` was already registered in this manager - the rule scope excludes all monitored streams Invalid regex conditions and unreachable scopes are logged as warnings and ignored; session startup continues. If a TTSR rule defines `globs`, those globs are compiled as a global file-path gate for matching. +### AST conditions (`astCondition`) + +A rule may carry `astCondition`: a list of [ast-grep](https://ast-grep.github.io/) patterns (OR'd, same as regex `condition`), matched structurally instead of textually. A repeated metavariable inside one pattern requires both occurrences to be equal (`if ($X) clearTimeout($X)` matches but `if ($X) clearTimeout($Y)` does not). + +AST conditions only evaluate on **edit/write tool-argument streams** — they need a language, which is inferred from the file extension on the tool's path argument, and they match against the tool's reconstructed source snapshot (`matcherDigest`), not the raw wire delta. Matching is performed in memory by the native `astMatch` engine (no temp files) with Smart strictness. Streams without a usable file path (prose, thinking, path-less tool calls) skip AST conditions entirely. A rule may mix `condition` and `astCondition`; the regex paths keep working on every scope while AST paths apply only to those tool streams. + ### Setting caveat `TtsrSettings.enabled` is loaded into the manager but is not currently checked in runtime gating. If TTSR rules exist, matching still runs. @@ -69,9 +75,10 @@ When assistant updates arrive and rules exist: - monitor `text_delta`, `thinking_delta`, and `toolcall_delta` - append delta into a source/tool scoped manager buffer -- call `checkDelta(delta, matchContext)` +- call `checkDelta(delta, matchContext)` (synchronous regex matching) +- for edit/write tool streams, when `hasAstRules()` is true, `await checkAstSnapshot(snapshot, matchContext)` (asynchronous AST matching) -`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, condition, and repeat policy checks. +`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, regex condition, and repeat policy checks. `checkAstSnapshot()` applies the same scope/path/repeat gates, then runs each candidate rule's `astCondition` patterns against the snapshot via the native `astMatch` engine. It is throttled per stream key: an identical consecutive snapshot (common when only non-source arguments change between deltas) is skipped without re-running the matcher. Both paths feed their matches through the same trigger-decision handler. ## 3. Trigger decision and immediate abort path diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index c463525a8..921b2a982 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -20,10 +20,10 @@ import { createHarmonyAuditEvent, detectHarmonyLeakInAssistantMessage, extractHarmonyRemoved, - recoverHarmonyToolCall, type HarmonyDetection, type HarmonyRecoveredToolCall, isHarmonyLeakMitigationTarget, + recoverHarmonyToolCall, signalListLabel, } from "./harmony-leak"; import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector"; @@ -1085,7 +1085,10 @@ async function streamAssistantResponse( } } -function retainCompletedToolCalls(message: AssistantMessage, completedToolCallIds: ReadonlySet): AssistantMessage { +function retainCompletedToolCalls( + message: AssistantMessage, + completedToolCallIds: ReadonlySet, +): AssistantMessage { if (message.stopReason !== "error" && message.stopReason !== "aborted") return message; let changed = false; const content = message.content.filter(block => { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ec7c35c99..758e0c289 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -277,7 +277,6 @@ function cloneAnthropicCacheControl(cacheControl: AnthropicCacheControl): Anthro return { ...cacheControl }; } - type AnthropicOutputConfig = NonNullable; const ANTHROPIC_STOP_SEQUENCES_MAX = 4; @@ -1453,7 +1452,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const baseUrl = resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ?? "https://api.anthropic.com"; - const providerSessionState = getAnthropicProviderSessionState(options?.providerSessionState, baseUrl, model.id); + const providerSessionState = getAnthropicProviderSessionState( + options?.providerSessionState, + baseUrl, + model.id, + ); let disableStrictTools = (providerSessionState?.strictToolsDisabled ?? false) || (model.compat?.disableStrictTools ?? false); let strictFallbackErrorMessage: string | undefined; @@ -1666,7 +1669,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } const openBlock = openBlocks.get(event.index); if (!openBlock) { - throw createAnthropicStreamEnvelopeError(`received content_block_delta for unopened index ${event.index}`); + throw createAnthropicStreamEnvelopeError( + `received content_block_delta for unopened index ${event.index}`, + ); } const block = blocks[openBlock.contentIndex]; if (event.delta.type === "text_delta") { @@ -1683,7 +1688,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (event.delta.type === "thinking_delta") { if (openBlock.kind !== "thinking" || block?.type !== "thinking") { - throw createAnthropicStreamEnvelopeError(`received thinking_delta for ${openBlock.kind} block`); + throw createAnthropicStreamEnvelopeError( + `received thinking_delta for ${openBlock.kind} block`, + ); } streamedReplayUnsafeContent = true; block.thinking += event.delta.thinking; @@ -1695,7 +1702,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (event.delta.type === "input_json_delta") { if (openBlock.kind !== "toolCall" || block?.type !== "toolCall") { - throw createAnthropicStreamEnvelopeError(`received input_json_delta for ${openBlock.kind} block`); + throw createAnthropicStreamEnvelopeError( + `received input_json_delta for ${openBlock.kind} block`, + ); } streamedReplayUnsafeContent = true; block.partialJson += event.delta.partial_json; @@ -1712,7 +1721,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (event.delta.type === "signature_delta") { if (openBlock.kind !== "thinking" || block?.type !== "thinking") { - throw createAnthropicStreamEnvelopeError(`received signature_delta for ${openBlock.kind} block`); + throw createAnthropicStreamEnvelopeError( + `received signature_delta for ${openBlock.kind} block`, + ); } streamedReplayUnsafeContent = true; block.thinkingSignature = block.thinkingSignature || ""; @@ -1724,11 +1735,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } const openBlock = openBlocks.get(event.index); if (!openBlock) { - throw createAnthropicStreamEnvelopeError(`received content_block_stop for unopened index ${event.index}`); + throw createAnthropicStreamEnvelopeError( + `received content_block_stop for unopened index ${event.index}`, + ); } const block = blocks[openBlock.contentIndex]; if (!block || block.type !== openBlock.kind) { - throw createAnthropicStreamEnvelopeError(`content_block_stop kind mismatch for index ${event.index}`); + throw createAnthropicStreamEnvelopeError( + `content_block_stop kind mismatch for index ${event.index}`, + ); } openBlocks.delete(event.index); delete (block as { index?: number }).index; @@ -2189,7 +2204,10 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: const maxAllowedTokens = Math.min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, model.maxTokens); const currentMaxTokens = Math.min(params.max_tokens ?? maxAllowedTokens, maxAllowedTokens); - const raisedMaxTokens = Math.min(Math.max(currentMaxTokens, budgetTokens + OUTPUT_FALLBACK_BUFFER), maxAllowedTokens); + const raisedMaxTokens = Math.min( + Math.max(currentMaxTokens, budgetTokens + OUTPUT_FALLBACK_BUFFER), + maxAllowedTokens, + ); params.max_tokens = raisedMaxTokens; if (budgetTokens + OUTPUT_FALLBACK_BUFFER <= raisedMaxTokens) return; @@ -2900,7 +2918,6 @@ function pickAnthropicEffectiveScalarType(schema: Record): stri return undefined; } - function anthropicPerTypeKeep(scalarType: string | undefined): Set | undefined { switch (scalarType) { case "object": @@ -3071,7 +3088,6 @@ function hasAnthropicSchemaDefiningKeyword(schema: Record): boo return schema.$defs !== undefined || schema.definitions !== undefined; } - function makeAnthropicNullableSchema(schema: unknown, budget: AnthropicStrictBudget): unknown | undefined { if (isRecord(schema)) { if (hasNullVariant(schema)) return schema; diff --git a/packages/coding-agent/src/utils/image-loading.ts b/packages/coding-agent/src/utils/image-loading.ts index 4be0a534e..c643b626f 100644 --- a/packages/coding-agent/src/utils/image-loading.ts +++ b/packages/coding-agent/src/utils/image-loading.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils"; import { resolveReadPath } from "../tools/path-utils"; -import { formatDimensionNote, resizeImage, type ImageResizeOptions } from "./image-resize"; +import { formatDimensionNote, type ImageResizeOptions, resizeImage } from "./image-resize"; export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024; export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES; diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index f659d765a..832997e22 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -3,6 +3,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { + AstMatchStrictness, + astMatch, executeShell, FileType, fuzzyFind, @@ -674,4 +676,44 @@ describe("pi-natives", () => { assertion.stop(); }); }); + + describe("astMatch", () => { + it("matches a pattern against an in-memory source string", async () => { + const result = await astMatch({ + source: 'function greet() {\n\tconsole.log("hi");\n}', + lang: "ts", + patterns: ["console.log($MSG)"], + strictness: AstMatchStrictness.Smart, + includeMeta: true, + }); + expect(result.totalMatches).toBe(1); + expect(result.matches[0]?.text).toBe('console.log("hi")'); + expect(result.matches[0]?.metaVariables?.MSG).toBe('"hi"'); + }); + + it("enforces metavariable equality within a pattern", async () => { + const same = await astMatch({ + source: "if (x) clearTimeout(x);", + lang: "ts", + patterns: ["if ($X) clearTimeout($X)"], + }); + const diff = await astMatch({ + source: "if (x) clearTimeout(y);", + lang: "ts", + patterns: ["if ($X) clearTimeout($X)"], + }); + expect(same.totalMatches).toBe(1); + expect(diff.totalMatches).toBe(0); + }); + + it("reports parse errors for incomplete source without throwing", async () => { + const result = await astMatch({ source: "console.log(", lang: "ts", patterns: ["console.log($A)"] }); + expect(result.totalMatches).toBe(0); + expect(result.parseErrors?.length).toBeGreaterThan(0); + }); + + it("rejects an empty language", async () => { + await expect(astMatch({ source: "const a = 1;", lang: " ", patterns: ["const $A = $B"] })).rejects.toThrow(); + }); + }); }); From 89e2dd095282a1c32219d9adeaa2217954629fc2 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:03:16 +0200 Subject: [PATCH 25/53] fix(coding-agent/modes): fixed shimmer animations to use fixed cells-per-second velocity - Reworked classic and KITT shimmer intensity math to drive the scan head by a constant 30 cells/second. - Introduced length-aware travel periods for classic padding and KITT ping-pong motion while removing the prior fixed-duration phase helpers. - Updated shimmer tests to validate crest movement stays within one cell per 30fps frame and is consistent across text lengths. --- .../coding-agent/src/modes/theme/shimmer.ts | 45 +++++----- .../test/modes/theme/shimmer.test.ts | 83 ++++++++++--------- 2 files changed, 67 insertions(+), 61 deletions(-) diff --git a/packages/coding-agent/src/modes/theme/shimmer.ts b/packages/coding-agent/src/modes/theme/shimmer.ts index 7832f33f4..cdc9fdc1b 100644 --- a/packages/coding-agent/src/modes/theme/shimmer.ts +++ b/packages/coding-agent/src/modes/theme/shimmer.ts @@ -1,13 +1,17 @@ import { isSettingsInitialized, settings } from "../../config/settings"; import type { Theme, ThemeColor } from "./theme"; -// ─── Animation phase ───────────────────────────────────────────────────────── -// Every shimmered string shares the same sweep phase. The bright point is -// interpolated from the first visible cell to the last, so short and long task -// rows start together and arrive at their final character together. -const SHIMMER_SWEEP_MS = 2000; +// ─── Animation velocity ────────────────────────────────────────────────────── +// Band/head travel speed in border cells per second. Driving position by a fixed +// velocity — instead of dividing a fixed sweep duration by the (length-derived) +// period — makes smoothness independent of message length: at the loader's +// default 30fps redraw cadence the band advances ≤1 cell per frame for any +// string, so it never visibly steps. Sweep/round-trip durations now scale with +// length. Keep ≤ the animated redraw fps (loader RENDER_INTERVAL_MS = 1000/30). +const SHIMMER_SPEED_CELLS_PER_S = 30; // ─── Classic sweep tunables ────────────────────────────────────────────────── +const CLASSIC_PADDING = 10; const CLASSIC_BAND_HALF_WIDTH = 6; // ─── KITT scanner tunables ─────────────────────────────────────────────────── @@ -101,21 +105,15 @@ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette return out; } -function phaseFor(time: number): number { - const phase = (time % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS; - return phase < 0 ? phase + 1 : phase; -} - -function lerp(start: number, end: number, phase: number): number { - return start + (end - start) * phase; -} - // ─── Intensity profiles ────────────────────────────────────────────────────── -/** Smooth cosine bump sweeping left → right across the visible text. */ +/** Smooth cosine bump sweeping left → right with edge padding. */ function classicIntensity(time: number, index: number, length: number): number { - const range = length - 1; - const pos = range <= 0 ? 0 : lerp(0, range, phaseFor(time)); - const dist = Math.abs(index - pos); + const period = length + CLASSIC_PADDING * 2; + // Fixed-velocity, un-floored band position: advancing at a constant + // cells/second (not period / fixed-sweep) keeps the per-frame step ≤1 cell at + // the default cadence for any length, so long messages are no steppier. + const pos = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % period; + const dist = Math.abs(index + CLASSIC_PADDING - pos); if (dist >= CLASSIC_BAND_HALF_WIDTH) return 0; return 0.5 * (1 + Math.cos((Math.PI * dist) / CLASSIC_BAND_HALF_WIDTH)); } @@ -128,10 +126,13 @@ function classicIntensity(time: number, index: number, length: number): number { function kittIntensity(time: number, index: number, length: number): number { const range = length - 1; if (range <= 0) return 1; - const phase = phaseFor(time); - const goingRight = phase < 0.5; - const sweep = goingRight ? phase * 2 : (1 - phase) * 2; - const head = lerp(0, range, sweep); + // Fixed head velocity: a triangle ping-pong over a 2*range round trip at a + // constant cells/second, so the bright head advances ≤1 cell per frame at the + // default cadence regardless of bar length. Round-trip duration scales with length. + const cycleCells = 2 * range; + const sweep = ((time / 1000) * SHIMMER_SPEED_CELLS_PER_S) % cycleCells; + const goingRight = sweep < range; + const head = goingRight ? sweep : cycleCells - sweep; const delta = index - head; const abs = delta < 0 ? -delta : delta; if (abs <= KITT_HEAD_HALF) return 1; diff --git a/packages/coding-agent/test/modes/theme/shimmer.test.ts b/packages/coding-agent/test/modes/theme/shimmer.test.ts index cd2e09793..750d3a5b0 100644 --- a/packages/coding-agent/test/modes/theme/shimmer.test.ts +++ b/packages/coding-agent/test/modes/theme/shimmer.test.ts @@ -29,31 +29,21 @@ const probe: ShimmerPalette = { }; /** - * Visible cell indexes painted with the crest (high, code 33) color. Walks the + * Index of the first visible cell painted with the crest (high, code 33) color, + * or undefined when the band sits in the padding and no cell is lit. Walks the * coalesced `ESC[m` runs that {@link shimmerText} emits. */ -function highIndices(rendered: string): number[] { - const indices: number[] = []; +function crestStart(rendered: string): number | undefined { const run = /\x1b\[(\d+)m([^\x1b]*)/g; let idx = 0; let m: RegExpExecArray | null = run.exec(rendered); while (m !== null) { const len = [...m[2]].length; - if (m[1] === "33") { - for (let i = 0; i < len; i++) indices.push(idx + i); - } + if (m[1] === "33" && len > 0) return idx; idx += len; m = run.exec(rendered); } - return indices; -} - -function crestCenter(rendered: string): number | undefined { - const indices = highIndices(rendered); - const first = indices[0]; - const last = indices[indices.length - 1]; - if (first === undefined || last === undefined) return undefined; - return (first + last) / 2; + return undefined; } describe("shimmerText", () => { @@ -63,8 +53,9 @@ describe("shimmerText", () => { it("uses a supplied raw ANSI color for the shimmer crest", () => { vi.spyOn(settingsModule, "isSettingsInitialized").mockReturnValue(false); - // At the beginning of the shared sweep the crest is on the first cell. - vi.spyOn(Date, "now").mockReturnValue(0); + // t chosen so the fixed-velocity band (30 cells/s) crest sits on the char: + // pos = (333/1000)*30 ≈ 10 = CLASSIC_PADDING, i.e. centered on index 0. + vi.spyOn(Date, "now").mockReturnValue(333); const rendered = shimmerText("x", testTheme, { low: "dim", @@ -78,9 +69,8 @@ describe("shimmerText", () => { }); }); -describe("shimmer phase alignment", () => { +describe("shimmer band velocity", () => { const FRAME_MS = 1000 / 30; - const SWEEP_MS = 2000; let nowMs = 0; beforeEach(() => { @@ -93,30 +83,45 @@ describe("shimmer phase alignment", () => { vi.restoreAllMocks(); }); - function renderLength(length: number): string { - return shimmerText("x".repeat(length), testTheme, probe); + function crestTrack(length: number, startMs: number, frames: number): (number | undefined)[] { + const text = "x".repeat(length); + const out: (number | undefined)[] = []; + for (let i = 0; i < frames; i++) { + nowMs = startMs + i * FRAME_MS; + out.push(crestStart(shimmerText(text, testTheme, probe))); + } + return out; } - it("starts every string at the first cell and lands at the last cell", () => { - for (const length of [8, 40, 80]) { - nowMs = 0; - expect(highIndices(renderLength(length))).toContain(0); - - nowMs = SWEEP_MS - 1; - expect(highIndices(renderLength(length))).toContain(length - 1); + it("advances the crest by at most one cell per 30fps frame", () => { + // L=40 → period 60 cells; at 30 cells/s that is a 2s sweep (60 frames). + // 75 frames covers a full sweep plus the padding gap into the next one. + const track = crestTrack(40, 0, 75); + let compared = 0; + for (let i = 1; i < track.length; i++) { + const a = track[i - 1]; + const b = track[i]; + if (a === undefined || b === undefined) continue; // skip the padding gap + expect(Math.abs(b - a)).toBeLessThanOrEqual(1); + compared++; } + // Fail loudly rather than vacuously pass if the crest were never detected. + expect(compared).toBeGreaterThan(20); }); - it("keeps the crest at the same normalized position across string lengths", () => { - nowMs = 15 * FRAME_MS; - const shortCenter = crestCenter(renderLength(20)); - const longCenter = crestCenter(renderLength(60)); - - expect(shortCenter).toBeDefined(); - expect(longCenter).toBeDefined(); - const shortPhase = shortCenter! / (20 - 1); - const longPhase = longCenter! / (60 - 1); - - expect(Math.abs(shortPhase - longPhase)).toBeLessThanOrEqual(0.02); + it("moves the crest at a length-independent speed", () => { + // Starting where the crest enters at index 0 (pos = CLASSIC_PADDING = 10), + // the crest must travel the same number of cells over a fixed wall-clock + // window regardless of string length — the contract of fixed-velocity + // sweeping (a longer message must not shimmer faster). + const startMs = (10 / 30) * 1000; // pos = 10 cells → crest at index 0 + const span = (track: (number | undefined)[]): number => { + const def = track.filter((v): v is number => v !== undefined); + return def.length ? def[def.length - 1] - def[0] : 0; + }; + const shortSpan = span(crestTrack(20, startMs, 10)); + const longSpan = span(crestTrack(60, startMs, 10)); + expect(shortSpan).toBeGreaterThan(0); + expect(Math.abs(shortSpan - longSpan)).toBeLessThanOrEqual(1); }); }); From eae8b932efe473f7b1cce8cb9914fb5acd010421 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:07:26 +0200 Subject: [PATCH 26/53] fix: fixed incomplete tool-call abort handling and image content guards - Normalized image-content preprocessing in agent sessions now returns early when `content` is missing or not a string/array, avoiding invalid normalization paths. - Updated agent-loop tests to verify partial tool calls are dropped when an assistant aborts before `toolcall_end`, with run completion reported via an assistant `stopReason` of `aborted`. - Adjusted Anthropic alignment expectations to reflect a single trailing cache-control breakpoint on the final system block. --- packages/agent/CHANGELOG.md | 1 + packages/agent/test/agent-loop.test.ts | 31 +++++++++++-------- packages/ai/CHANGELOG.md | 1 + packages/ai/test/anthropic-alignment.test.ts | 6 ++-- .../coding-agent/src/session/agent-session.ts | 10 ++++-- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 9d0472231..91d427c9b 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed agent-loop stream invariants so `agentLoopContinue` no longer mutates the caller's message array, emitted assistant events snapshot mutable provider content, terminal provider events win over late abort signals, transformed tool arguments are reflected consistently in hooks/events, and successful run-end telemetry fires from the same finalization path as failures. - Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block - Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying - Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index c761ae5a4..2f230c840 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -517,7 +517,7 @@ describe("agentLoop with AgentMessage", () => { expect(turnEndEvent.toolResults.map(result => result.toolCallId)).toEqual(["tool-2", "tool-1"]); }); - it("emits an explicit warning toolResult when assistant aborts after issuing tool calls", async () => { + it("drops incomplete tool calls when assistant aborts before toolcall_end", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], messages: [], @@ -528,8 +528,10 @@ describe("agentLoop with AgentMessage", () => { const mock = createMockModel(); const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; - // Custom stream: emit a partial start with a tool call, abort, then push done. - // The mock provider doesn't model "abort between start and done"; do it inline. + // Custom stream: emit a partial assistant that already contains a tool + // call, then abort before any `toolcall_end` event proves that the args + // completed. The agent must not synthesize a toolResult for that partial + // call; replaying it would preserve unsafe/incomplete arguments. const streamFn = () => { const stream = new AssistantMessageEventStream(); queueMicrotask(() => { @@ -556,16 +558,16 @@ describe("agentLoop with AgentMessage", () => { (e): e is Extract => e.type === "message_end" && e.message.role === "toolResult", ); - expect(toolResultEvent).toBeDefined(); - if (toolResultEvent?.message.role !== "toolResult") return; - expect(toolResultEvent.message.isError).toBe(true); - expect(toolResultEvent.message.toolCallId).toBe("tool-1"); - expect(toolResultEvent.message.content[0]?.type).toBe("text"); - if (toolResultEvent.message.content[0]?.type === "text") { - const text = toolResultEvent.message.content[0].text; - expect(text).toContain("Tool execution was aborted"); - expect(text).not.toContain("Tool execution was aborted.:"); - } + expect(toolResultEvent).toBeUndefined(); + + const assistantEnd = events.find( + (e): e is Extract => + e.type === "message_end" && e.message.role === "assistant", + ); + expect(assistantEnd).toBeDefined(); + if (assistantEnd?.message.role !== "assistant") return; + expect(assistantEnd.message.stopReason).toBe("aborted"); + expect(assistantEnd.message.content.some(block => block.type === "toolCall")).toBe(false); }); it("should skip remaining tool calls when steering is queued", async () => { @@ -1194,6 +1196,9 @@ describe("agentLoopContinue with AgentMessage", () => { ], stopReason: "length", }, + { + content: ["ok, I will split the write into smaller chunks"], + }, ], }); const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b8621b3cf..3a2942813 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed Anthropic stream envelope handling to reject duplicate `content_block_start` indexes and block deltas/stops for unopened blocks, preventing malformed envelope states from producing partial output - Fixed Anthropic image conversion to normalize `image/jpg` to `image/jpeg` and emit a placeholder for unsupported image MIME types - Fixed Anthropic thinking request preparation by clamping `max_tokens` to provider/model limits and adjusting thinking budgets to a valid value +- Fixed Anthropic request shaping around forced tool choice, unsigned thinking replay, prompt-cache marker placement, non-Anthropic bearer gateways, Foundry TLS loading, and strict tool-schema normalization so malformed or incompatible request payloads are rejected locally or shaped consistently before streaming - Fixed the Anthropic stream parser shipping a truncated tool call as a completed turn. When a transport drop cut the SSE stream mid-`tool_use` and a transparent reconnect spliced a fresh message envelope onto the same stream, the duplicate `message_start` was deduped but the orphaned tool block — which never received its `content_block_stop` — survived in the assistant message with its seed `{}` (or partially-parsed) arguments. The terminal stop signal from the reconnect then let it flow through as a normal tool call, so e.g. a `read` dispatched with `{}` failed downstream validation (`path: expected string, received undefined`). The parser now treats any tool block left open at stream end as a truncated envelope and routes it through the existing retry/error path instead of emitting bogus arguments. - Fixed the Zhipu Coding Plan login prompt advertising a misleading `sk-...` placeholder. Zhipu API keys are formatted `.` (no `sk-` prefix), so the placeholder now matches the actual format instead of suggesting the wrong shape. ([#2106](https://github.com/can1357/oh-my-pi/issues/2106)) diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 7449f9da1..a460dbafd 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -184,7 +184,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(hiddenUtility.defaultHeaders["anthropic-beta"]).toContain("redact-thinking-2026-02-12"); }); - it("matches CC system-block layout: billing and instruction uncached, context cached in order", () => { + it("matches CC system-block layout: billing and instruction uncached, single breakpoint on the last context block", () => { // We mimic Claude Code's billing+instruction system layout but do NOT emit // the `scope: "global"` field that CC attaches to its middle breakpoint — // `prompt-caching-scope-2026-01-05` only works against canonical @@ -201,10 +201,12 @@ describe("Anthropic request fingerprint alignment", () => { expect(blocks?.[0].cache_control).toBeUndefined(); expect(blocks?.[1].text).toBe(claudeCodeSystemInstruction); expect(blocks?.[1].cache_control).toBeUndefined(); + // Only the LAST system block carries the cache breakpoint: a single trailing + // `cache_control` caches the entire system prefix as one entry, conserving the + // 4-breakpoint budget (`enforceCacheControlLimit`) for message-level caching. expect(blocks?.[2]).toEqual({ type: "text", text: "Use citations when possible", - cache_control: { type: "ephemeral" }, }); expect(blocks?.[3]).toEqual({ type: "text", diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b3c5d630f..7c52203c2 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -33,6 +33,7 @@ import { resolveTelemetry, ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; + import { AGGRESSIVE_SHAKE_CONFIG, AUTO_HANDOFF_THRESHOLD_FOCUS, @@ -4330,9 +4331,12 @@ export class AgentSession { } async #normalizeAgentMessageImages(message: T): Promise { - const content = await this.#normalizeMessageContentImages(message.content); - if (content === message.content) return message; - return { ...message, content } as T; + if (!("content" in message)) return message; + const content = message.content; + if (typeof content !== "string" && !Array.isArray(content)) return message; + const normalized = await this.#normalizeMessageContentImages(content as string | (TextContent | ImageContent)[]); + if (normalized === content) return message; + return { ...message, content: normalized } as T; } /** From c0b457a64395696f0ff38c1568c5c56ecd871606 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:12:58 +0200 Subject: [PATCH 27/53] test(agent): updated run-end test to validate telemetry warning hook handling - Clarified the `onRunEnd` documentation to note warning emission through `onTelemetryWarning` with `console.warn` fallback. - Updated the non-fatal `onRunEnd` test to collect warnings via `onTelemetryWarning` instead of overriding `console.warn`. - Added a short async pause in the test to let run-loop warning callbacks flush before asserting their presence. --- packages/agent/src/telemetry.ts | 3 +- packages/agent/test/run-summary.test.ts | 57 +++++++++++++------------ 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/agent/src/telemetry.ts b/packages/agent/src/telemetry.ts index 8655ccb6c..510eed286 100644 --- a/packages/agent/src/telemetry.ts +++ b/packages/agent/src/telemetry.ts @@ -1869,7 +1869,8 @@ export function finishInvokeAgentSpan( /** * Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws - are caught and logged via `console.warn` — telemetry callbacks NEVER turn a + * are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn` + * when no hook is set) — telemetry callbacks NEVER turn a * successful agent run into a failed one. Idempotent at the call site via * {@link AgentRunCollector.markRunEnded}; callers must check that before * calling this helper. diff --git a/packages/agent/test/run-summary.test.ts b/packages/agent/test/run-summary.test.ts index 6b6511b28..6629ae41c 100644 --- a/packages/agent/test/run-summary.test.ts +++ b/packages/agent/test/run-summary.test.ts @@ -503,37 +503,38 @@ describe("aggregateAgentRunSummaries / aggregateAgentRunCoverage", () => { describe("onRunEnd is non-fatal", () => { it("swallows thrown errors and still resolves agentLoop().result() normally", async () => { const tracer = new RecordingTracer(); - const warnings: unknown[][] = []; - const realWarn = console.warn; - console.warn = (...args: unknown[]) => { - warnings.push(args); - }; - try { - const mock = createMockModel({ responses: [{ content: ["ok"] }] }); - const stream = agentLoop( - [createUserMessage("hi")], - { systemPrompt: ["sys"], messages: [], tools: [] }, - { - model: mock.model, - convertToLlm: identityConverter, - telemetry: { - tracer, - onRunEnd: () => { - throw new Error("user code is buggy"); - }, + const warnings: { code: string; message: string }[] = []; + const mock = createMockModel({ responses: [{ content: ["ok"] }] }); + const stream = agentLoop( + [createUserMessage("hi")], + { systemPrompt: ["sys"], messages: [], tools: [] }, + { + model: mock.model, + convertToLlm: identityConverter, + telemetry: { + tracer, + onRunEnd: () => { + throw new Error("user code is buggy"); + }, + // The failure is surfaced through the telemetry-warning channel, not a + // rejection. `console.warn` is only the no-hook fallback inside + // `emitTelemetryWarning`, so capture via the hook for a deterministic assert. + onTelemetryWarning: warning => { + warnings.push({ code: warning.code, message: warning.message }); }, }, - undefined, - mock.stream, - ); - const messages = await stream.result(); - expect(messages.length).toBe(2); - } finally { - console.warn = realWarn; - } - // The wrapper must surface the failure via console.warn, not via rejection. + }, + undefined, + mock.stream, + ); + const messages = await stream.result(); + expect(messages.length).toBe(2); + // `fireOnRunEnd` can run in `runLoop`'s finally, after `result()` resolves — flush + // the trailing microtasks of the fire-and-forget agent loop before asserting. + await Bun.sleep(5); + expect(warnings.length).toBeGreaterThanOrEqual(1); - expect(String(warnings[0][0])).toContain("onRunEnd"); + expect(warnings.some(w => w.code === "on_run_end_failed" && w.message.includes("onRunEnd"))).toBe(true); }); }); From bf6b80e57530b15068dc0efd8fe1c22fbfa5c727 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:18:54 +0200 Subject: [PATCH 28/53] feat(discovery): added built-in test timers rule to defaults - Added a new built-in TTSR rule `ts-no-test-timers.md` that flags `Bun.sleep`, `setTimeout`, and `setInterval` usage in `*.test.ts` files. - Registered `ts-no-test-timers` in the built-in rules index so it ships with default discovery providers. - Updated builtin-defaults tests to enforce rule-name uniqueness and verify the new rule only matches in `*.test.ts` scopes. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/discovery/builtin-rules/index.ts | 2 + .../builtin-rules/ts-no-test-timers.md | 55 +++++++++++++++++ .../test/discovery/builtin-defaults.test.ts | 60 ++++++++++++------- 4 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 packages/coding-agent/src/discovery/builtin-rules/ts-no-test-timers.md diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5fe6b37bd..f78f3c739 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ - Added `astCondition` to TTSR rule frontmatter as a syntax-aware alternative to regex `condition`, enabling AST-based matching for edit/write tool snapshots - Added a built-in `ts-redundant-clear-guard` rule that flags redundant guards around `clearTimeout`, `clearInterval`, and `clearImmediate` calls +- Added a built-in `ts-no-test-timers` rule that flags real timers (`Bun.sleep`, `setTimeout`, `setInterval`) in `*.test.ts` files, steering toward fake timers (`vi.useFakeTimers()` / `vi.advanceTimersByTime()`) - Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references - Added pixel dimensions to pasted/loaded image placeholders in the prompt — the marker now reads `[Image #N, WxH]` (falling back to `[Image #N]` when the header can't be decoded). diff --git a/packages/coding-agent/src/discovery/builtin-rules/index.ts b/packages/coding-agent/src/discovery/builtin-rules/index.ts index 6188c36dc..d8d545ffc 100644 --- a/packages/coding-agent/src/discovery/builtin-rules/index.ts +++ b/packages/coding-agent/src/discovery/builtin-rules/index.ts @@ -20,6 +20,7 @@ import tsNoAny from "./ts-no-any.md" with { type: "text" }; import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" }; import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" }; import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" }; +import tsNoTestTimers from "./ts-no-test-timers.md" with { type: "text" }; import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" }; import tsPromiseWithResolvers from "./ts-promise-with-resolvers.md" with { type: "text" }; import tsRedundantClearGuard from "./ts-redundant-clear-guard.md" with { type: "text" }; @@ -45,6 +46,7 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [ { name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers }, { name: "ts-no-dynamic-import", content: tsNoDynamicImport }, { name: "ts-no-return-type", content: tsNoReturnType }, + { name: "ts-no-test-timers", content: tsNoTestTimers }, { name: "ts-no-tiny-functions", content: tsNoTinyFunctions }, { name: "ts-promise-with-resolvers", content: tsPromiseWithResolvers }, { name: "ts-redundant-clear-guard", content: tsRedundantClearGuard }, diff --git a/packages/coding-agent/src/discovery/builtin-rules/ts-no-test-timers.md b/packages/coding-agent/src/discovery/builtin-rules/ts-no-test-timers.md new file mode 100644 index 000000000..121fec38a --- /dev/null +++ b/packages/coding-agent/src/discovery/builtin-rules/ts-no-test-timers.md @@ -0,0 +1,55 @@ +--- +description: Do not use real timers (Bun.sleep, setTimeout, setInterval) in tests — drive time with fake timers instead +condition: + - "Bun\\.sleep\\(" + - "\\bsetInterval\\(" + - "\\bsetTimeout\\(" +scope: "tool:edit(*.test.ts), tool:write(*.test.ts)" +interruptMode: never +--- + +**Do not reach for real wall-clock timers in test files.** `Bun.sleep(...)`, `setTimeout(...)`, and `setInterval(...)` tie a test's duration to real time: they slow the suite on every run, and any delay tuned to "long enough" eventually races on a loaded machine and flakes. + +## Why it's wrong + +- Real delays add fixed latency to every invocation; CI pays it on every run. +- A sleep sized to mask a race is a guess — the race resurfaces under load. +- A fixed wait hides *what* you are waiting for, so a failure points at a timeout instead of the real cause. + +## Avoid + +```typescript +test("debounce fires once", async () => { + const fn = debounce(handler, 100); + fn(); + await Bun.sleep(150); // real delay — slow and timing-dependent + expect(handler).toHaveBeenCalledTimes(1); +}); +``` + +## Use + +Drive time deterministically with fake timers: + +```typescript +import { expect, test, vi } from "bun:test"; + +test("debounce fires once", () => { + vi.useFakeTimers(); + const fn = debounce(handler, 100); + fn(); + vi.advanceTimersByTime(150); // advance the clock, no real wait + expect(handler).toHaveBeenCalledTimes(1); +}); +``` + +When the code under test resolves a promise or emits an event, await that signal directly instead of guessing a duration: + +```typescript +await once(emitter, "done"); // await the real event +const value = await pending; // await the promise the code already exposes +``` + +## Exceptions + +An integration test that deliberately exercises real timer behavior against the platform clock may need a genuine delay. Keep it rare, and add a short comment naming why deterministic time control will not work. diff --git a/packages/coding-agent/test/discovery/builtin-defaults.test.ts b/packages/coding-agent/test/discovery/builtin-defaults.test.ts index 6fe44b039..ee978574e 100644 --- a/packages/coding-agent/test/discovery/builtin-defaults.test.ts +++ b/packages/coding-agent/test/discovery/builtin-defaults.test.ts @@ -10,25 +10,7 @@ import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule, ruleCapability } from "@oh-my- import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types"; // Register all discovery providers as a side effect. import "@oh-my-pi/pi-coding-agent/discovery"; - -const EXPECTED_RULE_NAMES = [ - "rs-box-leak", - "rs-future-prelude", - "rs-lazylock", - "rs-match-ergonomics", - "rs-parking-lot", - "rs-result-type", - "ts-bare-catch", - "ts-import-type", - "ts-no-any", - "ts-no-deprecated-leftovers", - "ts-no-dynamic-import", - "ts-no-return-type", - "ts-no-tiny-functions", - "ts-promise-with-resolvers", - "ts-redundant-clear-guard", - "ts-set-map", -].sort(); +import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr"; function ruleProvider() { const cap = getCapability(ruleCapability.id); @@ -46,11 +28,13 @@ async function loadBuiltinRules(): Promise { } describe("builtin-defaults rule provider", () => { - it("loads exactly the bundled default rule set, all attributed to the provider", async () => { + it("loads the bundled default rule set, all attributed to the provider", async () => { const rules = await loadBuiltinRules(); - const names = rules.map(r => r.name).sort(); - expect(names).toEqual(EXPECTED_RULE_NAMES); + expect(rules.length).toBeGreaterThan(0); expect(rules.every(r => r._source.provider === BUILTIN_DEFAULTS_PROVIDER_ID)).toBe(true); + const names = rules.map(r => r.name); + // Name-based dedup is first-wins, so a duplicate would be silently shadowed. + expect(new Set(names).size).toBe(names.length); }); it("parses every bundled rule as a TTSR rule (non-empty condition/astCondition and scope)", async () => { @@ -81,6 +65,38 @@ describe("builtin-defaults rule provider", () => { expect(rules.find(r => r.name === "ts-set-map")?.interruptMode).toBe("never"); }); + it("fires the no-test-timers rule on real timers in *.test.ts but not plain *.ts", async () => { + const rules = await loadBuiltinRules(); + const rule = rules.find(r => r.name === "ts-no-test-timers"); + if (!rule) throw new Error("ts-no-test-timers rule missing"); + + const manager = new TtsrManager(); + expect(manager.addRule(rule)).toBe(true); + + for (const snippet of ["await Bun.sleep(10)", "setTimeout(fn, 0)", "setInterval(fn, 5)"]) { + manager.resetBuffer(); + const matches = manager.checkDelta(snippet, { + source: "tool", + toolName: "write", + filePaths: ["packages/x/test/foo.test.ts"], + }); + expect( + matches.map(r => r.name), + snippet, + ).toEqual(["ts-no-test-timers"]); + } + + // Same content in a non-test file is out of scope. + manager.resetBuffer(); + expect( + manager.checkDelta("await Bun.sleep(10)", { + source: "tool", + toolName: "write", + filePaths: ["packages/x/src/foo.ts"], + }), + ).toEqual([]); + }); + it("is the lowest-priority rule provider so user/project rules override defaults", () => { const { cap, provider } = ruleProvider(); const others = cap.providers.filter(p => p.id !== BUILTIN_DEFAULTS_PROVIDER_ID); From d18539a320fc1ba8ef93fe272930a2a228c0badc Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:26:40 +0200 Subject: [PATCH 29/53] fix(agent): fixed stalled abort handling and Anthropic stream gaps - Stopped aborted runs from waiting on provider iterator cleanup. - Ran afterToolCall for completed executions after a run aborts. - Ignored unknown Anthropic content blocks while preserving known ones. - Kept sampling params and tool-result caching for disabled thinking. --- packages/agent/CHANGELOG.md | 4 +- packages/agent/src/agent-loop.ts | 11 +- packages/agent/test/agent-loop.test.ts | 105 +++++++++++++++++- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/anthropic.ts | 15 ++- packages/ai/test/anthropic-alignment.test.ts | 60 ++++++++++ .../ai/test/anthropic-stream-envelope.test.ts | 56 ++++++++++ packages/coding-agent/CHANGELOG.md | 1 + 8 files changed, 248 insertions(+), 8 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 91d427c9b..6d7634df9 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,13 +1,15 @@ # Changelog ## [Unreleased] - ### Removed - Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls ### Fixed +- Fixed stalled aborted assistant responses so the run now stops without waiting for provider iterator cleanup and returns the aborted message promptly +- Fixed `afterToolCall` handling so it now runs for completed tool executions even after a run is aborted so tool post-processing still applies +- Fixed `agentLoopDetailed().detailed()` so run telemetry and coverage are captured before `stream.result()` resolves. - Fixed agent-loop stream invariants so `agentLoopContinue` no longer mutates the caller's message array, emitted assistant events snapshot mutable provider content, terminal provider events win over late abort signals, transformed tool arguments are reflected consistently in hooks/events, and successful run-end telemetry fires from the same finalization path as failures. - Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block - Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 921b2a982..afded1216 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -33,6 +33,7 @@ import { finishChatSpan, finishExecuteToolSpan, finishInvokeAgentSpan, + fireOnRunEnd, PiGenAIAttr, recordSkippedTool, resolveTelemetry, @@ -296,6 +297,9 @@ function buildAgentEndEvent( ): Extract { if (!telemetry) return { type: "agent_end", messages }; const snapshot = telemetry.collector.snapshot({ stepCount }); + if (telemetry.collector.markRunEnded()) { + fireOnRunEnd(telemetry, snapshot.summary, snapshot.coverage); + } return { type: "agent_end", messages, telemetry: snapshot.summary, coverage: snapshot.coverage }; } @@ -924,7 +928,8 @@ async function streamAssistantResponse( const responseIterator = response[Symbol.asyncIterator](); const finishAbortedStream = async (): Promise => { try { - await responseIterator.return?.(); + const cleanup = responseIterator.return?.(); + if (cleanup) void cleanup.catch(() => {}); } catch { // Provider cancellation failures cannot change the committed aborted message. } @@ -1345,6 +1350,7 @@ async function executeToolCalls( let result: AgentToolResult = { content: [], details: {} }; let isError = false; let caughtError: unknown; + let completedToolExecution = false; await runInActiveSpan(toolSpan, async () => { try { @@ -1413,6 +1419,7 @@ async function executeToolCalls( }, toolContext, ); + completedToolExecution = true; const coerced = coerceToolResult(rawResult); result = coerced.result; if (coerced.malformed || result.isError) isError = true; @@ -1425,7 +1432,7 @@ async function executeToolCalls( isError = true; } - if (afterToolCall && !toolSignal.aborted) { + if (afterToolCall && (!toolSignal.aborted || completedToolExecution)) { try { const after = await afterToolCall( { diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 2f230c840..d91641162 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { agentLoop, agentLoopContinue, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop"; +import { agentLoop, agentLoopContinue, agentLoopDetailed, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentContext, AgentEvent, @@ -9,7 +9,7 @@ import type { AgentToolContext, ToolCallContext, } from "@oh-my-pi/pi-agent-core/types"; -import type { AssistantMessage, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import * as z from "zod/v4"; @@ -55,6 +55,24 @@ describe("agentLoop with AgentMessage", () => { expect(eventTypes).toContain("agent_end"); }); + it("returns detailed telemetry when awaiting detailed() directly", async () => { + const context: AgentContext = { + systemPrompt: ["You are helpful."], + messages: [], + tools: [], + }; + const mock = createMockModel({ responses: [{ content: ["Hi there!"] }] }); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + + const { detailed } = agentLoopDetailed([createUserMessage("Hello")], context, config, undefined, mock.stream); + const result = await detailed(); + + expect(result.messages).toHaveLength(2); + expect(result.telemetry?.stepCount).toBe(1); + expect(result.telemetry?.chats.total).toBe(1); + expect(result.coverage?.modelsUsed).toEqual([mock.model.id]); + }); + it("retries when harmony leakage reaches the committed assistant message (openai-codex)", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], @@ -156,6 +174,40 @@ describe("agentLoop with AgentMessage", () => { expect(events.map(event => event.type)).toContain("agent_end"); }); + it("does not wait for provider iterator cleanup when aborting a stalled response", async () => { + const context: AgentContext = { + systemPrompt: ["You are helpful."], + messages: [], + tools: [], + }; + const mock = createMockModel(); + const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + const controller = new AbortController(); + let returnCalled = false; + const streamFn = () => + ({ + result: () => Promise.withResolvers().promise, + [Symbol.asyncIterator]: () => ({ + next: () => Promise.withResolvers>().promise, + return: () => { + returnCalled = true; + return Promise.withResolvers>().promise; + }, + }), + }) as AssistantMessageEventStream; + + const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn); + queueMicrotask(() => controller.abort("stop now")); + const messages = await stream.result(); + + expect(returnCalled).toBe(true); + const finalMessage = messages[messages.length - 1]; + expect(finalMessage.role).toBe("assistant"); + if (finalMessage.role !== "assistant") throw new Error("Expected assistant message"); + expect(finalMessage.stopReason).toBe("aborted"); + expect(finalMessage.errorMessage).toBe("stop now"); + }); + it("surfaces a custom abort reason on the synthesized aborted message", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], @@ -1077,6 +1129,55 @@ describe("agentLoopContinue with AgentMessage", () => { } }); + it("runs afterToolCall for a completed result even when the run aborts before the hook", async () => { + const toolSchema = z.object({ value: z.string() }); + const controller = new AbortController(); + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + controller.abort("stop after tool"); + return { + content: [{ type: "text", text: `original: ${params.value}` }], + details: { value: params.value }, + }; + }, + }; + + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + let hookSawAbortedSignal = false; + const mock = createMockModel({ + responses: [ + { content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + afterToolCall: async (_context, signal) => { + hookSawAbortedSignal = signal?.aborted === true; + return { content: [{ type: "text", text: "rewritten after abort" }] }; + }, + }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("echo")], context, config, controller.signal, mock.stream); + for await (const event of stream) { + events.push(event); + } + + expect(hookSawAbortedSignal).toBe(true); + const toolEnd = events.find(e => e.type === "tool_execution_end"); + expect(toolEnd).toBeDefined(); + if (toolEnd?.type === "tool_execution_end") { + expect(toolEnd.isError).toBe(false); + expect(toolEnd.result.content).toEqual([{ type: "text", text: "rewritten after abort" }]); + } + }); + it("surfaces afterToolCall errors as a tool error result", async () => { const toolSchema = z.object({ value: z.string() }); const tool: AgentTool = { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3a2942813..f42857c26 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Changed - Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429 @@ -8,6 +9,9 @@ ### Fixed +- Preserved streaming responses when Anthropic emits unrecognized content_block envelopes by ignoring unknown blocks and continuing to emit known content +- Applied cache control to the most recent tool result block when building Anthropic OAuth payloads without a preceding text block, enabling ephemeral caching for tool-result-only messages +- Kept Anthropic sampling parameters (temperature, top_p, top_k) when thinking is explicitly disabled - Fixed raw Anthropic SSE handling by parsing event frames with strict JSON parsing and matching event-type validation, surfacing malformed frames as stream errors instead of repairing them - Fixed Anthropic stream envelope handling to reject duplicate `content_block_start` indexes and block deltas/stops for unopened blocks, preventing malformed envelope states from producing partial output - Fixed Anthropic image conversion to normalize `image/jpg` to `image/jpeg` and emit a placeholder for unsupported image MIME types diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 758e0c289..bf9e8fe9b 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1548,7 +1548,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let sawMessageStop = false; const openBlocks = new Map< number, - { contentIndex: number; kind: "text" | "thinking" | "redactedThinking" | "toolCall" } + { contentIndex: number; kind: "text" | "thinking" | "redactedThinking" | "toolCall" | "ignored" } >(); const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, { @@ -1662,6 +1662,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( contentIndex, partial: output, }); + } else { + openBlocks.set(event.index, { contentIndex: -1, kind: "ignored" }); } } else if (event.type === "content_block_delta") { if (sawTerminalEnvelope) { @@ -1673,6 +1675,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( `received content_block_delta for unopened index ${event.index}`, ); } + if (openBlock.kind === "ignored") continue; const block = blocks[openBlock.contentIndex]; if (event.delta.type === "text_delta") { if (openBlock.kind !== "text" || block?.type !== "text") { @@ -1739,6 +1742,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( `received content_block_stop for unopened index ${event.index}`, ); } + if (openBlock.kind === "ignored") { + openBlocks.delete(event.index); + continue; + } const block = blocks[openBlock.contentIndex]; if (!block || block.type !== openBlock.kind) { throw createAnthropicStreamEnvelopeError( @@ -2248,7 +2255,7 @@ function applyCacheControlToLastTextBlock( return true; } } - return false; + return applyCacheControlToLastBlock(blocks, cacheControl); } function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: AnthropicCacheControl): void { @@ -2552,7 +2559,9 @@ function buildParams( }; // Opus 4.7+ rejects non-default sampling parameters with 400 error. - const allowSamplingParams = !hasOpus47ApiRestrictions(model.id) && !params.thinking; + const thinkingType = params.thinking?.type; + const allowSamplingParams = + !hasOpus47ApiRestrictions(model.id) && (thinkingType === undefined || thinkingType === "disabled"); if (allowSamplingParams && options?.temperature !== undefined) { params.temperature = options.temperature; } diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index a460dbafd..7ed9fddf2 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -237,6 +237,50 @@ describe("Anthropic request fingerprint alignment", () => { }); }); + it("caches tool-result-only user messages in OAuth request payloads", async () => { + const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { + systemPrompt: ["Stay concise."], + messages: [ + { role: "user", content: "Use the tool", timestamp: Date.now() }, + { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "lookup", arguments: {} }], + api: "anthropic-messages", + provider: "anthropic", + model: ANTHROPIC_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: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "lookup", + content: [{ type: "text", text: "large tool output" }], + details: {}, + isError: false, + timestamp: Date.now(), + }, + ], + })) as { messages?: Array<{ content?: Array<{ type?: string; cache_control?: unknown }> | string }> }; + + const messages = payload.messages ?? []; + const lastContent = messages[messages.length - 1]?.content; + expect(Array.isArray(lastContent)).toBe(true); + expect(Array.isArray(lastContent) ? lastContent[0]?.type : undefined).toBe("tool_result"); + expect(Array.isArray(lastContent) ? lastContent[0]?.cache_control : undefined).toEqual({ + type: "ephemeral", + ttl: "1h", + }); + }); + it("clamps requested max_tokens to Claude Code's 64k cap when the model ceiling is higher", async () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, id: "claude-opus-4-8", name: "Claude Opus 4.8", maxTokens: 128_000 }, @@ -1309,6 +1353,22 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.thinking).toEqual({ type: "disabled" }); }); + it("keeps sampling params when reasoning is explicitly disabled", async () => { + const payload = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { thinkingEnabled: false, temperature: 0.2, topP: 0.3, topK: 4 }, + )) as { thinking?: { type?: string }; temperature?: number; top_p?: number; top_k?: number }; + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.temperature).toBe(0.2); + expect(payload.top_p).toBe(0.3); + expect(payload.top_k).toBe(4); + }); + it("drops temperature and sampling params for Opus 4.7 without enabled thinking", async () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, id: "claude-opus-4-7", name: "Claude Opus 4.7" }, diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index f1099751e..87d3b09e1 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -330,6 +330,62 @@ describe("anthropic stream envelope handling", () => { expect(result.content).toEqual([{ type: "text", text: "hello" }]); }); + it("ignores unknown content block envelopes while preserving known blocks", async () => { + const events: MockAnthropicEvent[] = [ + { + type: "message_start", + message: { + id: "msg_unknown_block", + usage: { + input_tokens: 12, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srv_1", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' }, + }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "hello" } }, + { type: "content_block_stop", index: 1 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 12, + output_tokens: 4, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + { type: "message_stop" }, + ]; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => createMockRequest(events) as never); + + const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + const observed: AssistantMessageEvent[] = []; + for await (const event of stream) { + observed.push(event); + } + const result = await stream.result(); + + expect(countEvents(observed, "error")).toBe(0); + expect(countEvents(observed, "done")).toBe(1); + expect(result.stopReason).toBe("stop"); + expect(result.responseId).toBe("msg_unknown_block"); + expect(result.content).toEqual([{ type: "text", text: "hello" }]); + }); + it("retries malformed envelopes before content starts without duplicating streamed text events", async () => { let attempt = 0; vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f78f3c739..2e2ab9921 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `astCondition` to TTSR rule frontmatter as a syntax-aware alternative to regex `condition`, enabling AST-based matching for edit/write tool snapshots From c36532d7de8167033b1626c0457522b9869d5262 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:27:13 +0200 Subject: [PATCH 30/53] feat(coding-agent/cli): added manager-specific self-update support for Homebrew and mise - Added Homebrew and mise install-source detection in `resolveUpdateMethod` using prefix, bin-dir, and shim checks. - Added `runUpdateCommand` delegation to `updateViaHomebrew` and `updateViaMise` for manager installs. - Changed `--force` behavior for package managers to run `brew reinstall` and `mise install --force`. --- packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/cli/update-cli.ts | 154 ++++++++++++++++-- packages/coding-agent/test/update-cli.test.ts | 57 ++++++- 3 files changed, 197 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2e2ab9921..9a5eea106 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added Homebrew and mise package-manager update paths to the self-update command so installations launched from those tools are updated through their native workflows +- Added detection of Homebrew and mise install locations so self-update chooses the manager-specific updater when the active `omp` binary comes from a package-manager-managed path - Added `astCondition` to TTSR rule frontmatter as a syntax-aware alternative to regex `condition`, enabling AST-based matching for edit/write tool snapshots - Added a built-in `ts-redundant-clear-guard` rule that flags redundant guards around `clearTimeout`, `clearInterval`, and `clearImmediate` calls - Added a built-in `ts-no-test-timers` rule that flags real timers (`Bun.sleep`, `setTimeout`, `setInterval`) in `*.test.ts` files, steering toward fake timers (`vi.useFakeTimers()` / `vi.advanceTimersByTime()`) @@ -12,6 +14,7 @@ ### Changed +- Changed forced self-updates for Homebrew installs to run `brew reinstall` and for mise installs to run `mise install --force` after `mise upgrade` when `--force` is requested - Changed TTSR rule bucketing and matching so rules with only `astCondition` are treated as TTSR rules and evaluated in the interrupt flow using reconstructed edit/write source snapshots - Normalized image content before it enters model context so attached images are downscaled and preprocessed for prompts, steering messages, follow-ups, and custom agent messages - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 3dddd8317..1d70e3255 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -2,9 +2,10 @@ * Update CLI command handler. * * Handles `omp update` to check for and install updates. - * Uses bun if available, otherwise downloads binary from GitHub releases. + * Uses the installer that owns the active omp executable when it can be detected. */ import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { pipeline } from "node:stream/promises"; import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; @@ -14,6 +15,8 @@ import { theme } from "../modes/theme/theme"; const REPO = "can1357/oh-my-pi"; const PACKAGE = "@oh-my-pi/pi-coding-agent"; +const HOMEBREW_FORMULA = "can1357/tap/omp"; +const MISE_TOOL = "github:can1357/oh-my-pi"; /** * Official npm registry origin. * @@ -102,6 +105,46 @@ async function getBunGlobalBinDir(): Promise { } } +async function getHomebrewFormulaPrefix(): Promise { + if (!$which("brew")) return undefined; + for (const formula of [HOMEBREW_FORMULA, APP_NAME]) { + try { + const result = await $`brew --prefix ${formula}`.quiet().nothrow(); + if (result.exitCode !== 0) continue; + const output = result.text().trim(); + if (output.length > 0) return output; + } catch {} + } + return undefined; +} + +async function getMiseBinDirs(): Promise { + if (!$which("mise")) return []; + try { + const result = await $`mise bin-paths ${MISE_TOOL}`.quiet().nothrow(); + if (result.exitCode !== 0) return []; + return result + .text() + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0); + } catch { + return []; + } +} + +function getMiseDataDir(): string { + const override = process.env.MISE_DATA_DIR; + if (override && override.length > 0) return override; + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData && localAppData.length > 0) return path.join(localAppData, "mise"); + } + const xdgDataHome = process.env.XDG_DATA_HOME; + if (xdgDataHome && xdgDataHome.length > 0) return path.join(xdgDataHome, "mise"); + return path.join(os.homedir(), ".local", "share", "mise"); +} + function normalizePathForComparison(filePath: string): string { const normalized = path.normalize(filePath); if (process.platform === "win32") return normalized.toLowerCase(); @@ -129,34 +172,61 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean { // is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the // PATH-resolved omp path can refer to the same directory through different // strings. path.resolve does not traverse junctions/symlinks; realpath does. - // Resolve the file's parent directory to tolerate the file itself not yet - // existing (e.g. a fresh install path) while still catching link-traversed - // equality once the directory exists. - const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); + // Resolve both the file and its parent directory: the file catches manager + // links like Homebrew's `bin/omp -> Cellar/.../bin/omp`; the parent fallback + // still tolerates fresh install paths where the file does not exist yet. const dirReal = tryRealpath(path.resolve(directoryPath)); - if (!fileDir || !dirReal) return false; + if (!dirReal) return false; + const fileReal = tryRealpath(path.resolve(filePath)); + if (fileReal && isPathInDirectoryLexical(fileReal, dirReal)) return true; + const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); + if (!fileDir) return false; const resolvedFile = path.join(fileDir, path.basename(filePath)); return isPathInDirectoryLexical(resolvedFile, dirReal); } -type UpdateTarget = { method: "bun" } | { method: "binary"; path: string }; +type UpdateMethod = "brew" | "mise" | "bun" | "binary"; -function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { - if (!bunBinDir) return "binary"; - return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary"; +interface UpdateMethodResolutionOptions { + homebrewPrefix?: string; + miseBinDirs?: readonly string[]; + miseDataDir?: string; } -export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { - return resolveUpdateMethod(ompPath, bunBinDir); +type UpdateTarget = { method: "brew" } | { method: "mise" } | { method: "bun" } | { method: "binary"; path: string }; + +function resolveUpdateMethod( + ompPath: string, + bunBinDir: string | undefined, + options: UpdateMethodResolutionOptions = {}, +): UpdateMethod { + const { homebrewPrefix, miseBinDirs = [], miseDataDir } = options; + if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew"; + if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise"; + if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise"; + if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun"; + return "binary"; +} + +export function resolveUpdateMethodForTest( + ompPath: string, + bunBinDir: string | undefined, + options: UpdateMethodResolutionOptions = {}, +): UpdateMethod { + return resolveUpdateMethod(ompPath, bunBinDir, options); } async function resolveUpdateTarget(): Promise { const bunBinDir = await getBunGlobalBinDir(); + const homebrewPrefix = await getHomebrewFormulaPrefix(); + const miseAvailable = $which("mise") !== undefined; + const miseBinDirs = miseAvailable ? await getMiseBinDirs() : []; + const miseDataDir = miseAvailable ? getMiseDataDir() : undefined; const ompPath = resolveOmpPath(); if (ompPath) { - const method = resolveUpdateMethod(ompPath, bunBinDir); - if (method === "bun") return { method }; - return { method, path: ompPath }; + const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir }); + if (method === "binary") return { method, path: ompPath }; + return { method }; } if (bunBinDir) return { method: "bun" }; @@ -376,6 +446,18 @@ export function buildBunInstallArgs(expectedVersion: string, nativeTag: string = return args; } +export function buildHomebrewUpdateArgs(force: boolean): string[] { + return [force ? "reinstall" : "upgrade", HOMEBREW_FORMULA]; +} + +export function buildMiseUpgradeArgs(): string[] { + return ["upgrade", MISE_TOOL, "--bump"]; +} + +export function buildMiseForceInstallArgs(expectedVersion: string): string[] { + return ["install", "--force", `${MISE_TOOL}@${expectedVersion}`]; +} + /** * Update via bun package manager. */ @@ -390,6 +472,42 @@ async function updateViaBun(expectedVersion: string): Promise { await printVerification(expectedVersion); } +async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise { + console.log(chalk.dim("Updating Homebrew formulae...")); + const update = await $`brew update`.nothrow(); + if (update.exitCode !== 0) { + throw new Error(`brew update failed with exit code ${update.exitCode}`); + } + + console.log(chalk.dim("Updating via Homebrew...")); + const args = buildHomebrewUpdateArgs(force); + const result = await $`brew ${args}`.nothrow(); + if (result.exitCode !== 0) { + throw new Error(`brew ${args[0]} failed with exit code ${result.exitCode}`); + } + + await printVerification(expectedVersion); +} + +async function updateViaMise(expectedVersion: string, force: boolean): Promise { + console.log(chalk.dim("Updating via mise...")); + const args = buildMiseUpgradeArgs(); + const result = await $`mise ${args}`.nothrow(); + if (result.exitCode !== 0) { + throw new Error(`mise upgrade failed with exit code ${result.exitCode}`); + } + + if (force) { + const forceArgs = buildMiseForceInstallArgs(expectedVersion); + const forceResult = await $`mise ${forceArgs}`.nothrow(); + if (forceResult.exitCode !== 0) { + throw new Error(`mise install --force failed with exit code ${forceResult.exitCode}`); + } + } + + await printVerification(expectedVersion); +} + /** * Download a release binary to a target path, replacing an existing file. */ @@ -457,7 +575,11 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean }) // Choose update method based on the prioritized omp binary in PATH try { const target = await resolveUpdateTarget(); - if (target.method === "bun") { + if (target.method === "brew") { + await updateViaHomebrew(release.version, opts.force); + } else if (target.method === "mise") { + await updateViaMise(release.version, opts.force); + } else if (target.method === "bun") { await updateViaBun(release.version); } else { await updateViaBinaryAt(target.path, release.version); diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index cbc127aa5..65ab8a95f 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -2,7 +2,14 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { buildBunInstallArgs, replaceBinaryForUpdate, resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { + buildBunInstallArgs, + buildHomebrewUpdateArgs, + buildMiseForceInstallArgs, + buildMiseUpgradeArgs, + replaceBinaryForUpdate, + resolveUpdateMethodForTest, +} from "../src/cli/update-cli"; const tempDirs: string[] = []; @@ -33,6 +40,54 @@ describe("update-cli install target detection", () => { expect(method).toBe("binary"); }); + + it("uses Homebrew update when prioritized omp resolves into the Homebrew formula", async () => { + const dir = await makeTempDir(); + const prefix = path.join(dir, "opt", "omp"); + const linkedBin = path.join(dir, "bin"); + await fs.mkdir(path.join(prefix, "bin"), { recursive: true }); + await fs.mkdir(linkedBin, { recursive: true }); + await Bun.write(path.join(prefix, "bin", "omp"), "binary"); + await fs.symlink(path.join(prefix, "bin", "omp"), path.join(linkedBin, "omp")); + + const method = resolveUpdateMethodForTest(path.join(linkedBin, "omp"), "/Users/test/.bun/bin", { + homebrewPrefix: prefix, + }); + + expect(method).toBe("brew"); + }); + + it("uses mise update when prioritized omp is in an active mise bin path", () => { + const method = resolveUpdateMethodForTest( + "/Users/test/.local/share/mise/installs/github-can1357-oh-my-pi/latest/bin/omp", + undefined, + { + miseBinDirs: ["/Users/test/.local/share/mise/installs/github-can1357-oh-my-pi/latest/bin"], + }, + ); + + expect(method).toBe("mise"); + }); + + it("uses mise update when prioritized omp is a mise shim", () => { + const method = resolveUpdateMethodForTest("/Users/test/.local/share/mise/shims/omp", undefined, { + miseDataDir: "/Users/test/.local/share/mise", + }); + + expect(method).toBe("mise"); + }); +}); + +describe("update-cli package manager commands", () => { + it("targets the Homebrew tap formula and switches to reinstall for forced updates", () => { + expect(buildHomebrewUpdateArgs(false)).toEqual(["upgrade", "can1357/tap/omp"]); + expect(buildHomebrewUpdateArgs(true)).toEqual(["reinstall", "can1357/tap/omp"]); + }); + + it("targets the mise GitHub backend tool and force-reinstalls the checked version when requested", () => { + expect(buildMiseUpgradeArgs()).toEqual(["upgrade", "github:can1357/oh-my-pi", "--bump"]); + expect(buildMiseForceInstallArgs("15.10.5")).toEqual(["install", "--force", "github:can1357/oh-my-pi@15.10.5"]); + }); }); describe("update-cli bun install command", () => { From 850007070048ecb4ded58a2a48312f5b1d3c455c Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 15:02:18 +0000 Subject: [PATCH 31/53] fix(coding-agent): expanded @-imports in AGENTS.md/CLAUDE.md context files CLAUDE.md, AGENTS.md, GEMINI.md (and the other discovered context-file flavors) all carry the @-import convention every other agent ships: `@path/to/file` inside a memory file inlines that file's contents at launch. The discovery loaders previously read the file content verbatim and handed it straight to the system prompt builder, so a CLAUDE.md whose entire body is `@AGENTS.md` shipped a single literal `@AGENTS.md` line and Claude never saw the project rules. Added discovery/at-imports.ts implementing the Claude-Code semantics: relative paths resolve against the importing file's directory, `~/` expands to home, recursion stops at MAX_AT_IMPORT_DEPTH (5) with cycle detection, fenced code blocks and inline code spans are opaque so `npm install @types/node` and `git@github.com` round-trip verbatim, and missing files keep the literal @-token intact. Wired the expander into loadProjectContextFiles so every provider that registers under the context-file capability benefits without per-provider plumbing. Fixes #2111 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/discovery/at-imports.ts | 273 ++++++++++++++++++ packages/coding-agent/src/system-prompt.ts | 24 +- .../test/discovery/at-imports.test.ts | 161 +++++++++++ 4 files changed, 453 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/src/discovery/at-imports.ts create mode 100644 packages/coding-agent/test/discovery/at-imports.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dfed3a648..e779a07bc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Expanded `@path/to/file` import references in CLAUDE.md / AGENTS.md / GEMINI.md (and the other discovered context-file flavors) when loading them into the system prompt, matching the convention used by Claude Code, Goose, and other agents. Imports resolve relative to the importing file's directory, support `~/...`, recurse up to 5 hops, and are skipped inside fenced code blocks and inline code spans so technical examples like `npm install @types/node` survive intact ([#2111](https://github.com/can1357/oh-my-pi/issues/2111)). + ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/coding-agent/src/discovery/at-imports.ts b/packages/coding-agent/src/discovery/at-imports.ts new file mode 100644 index 000000000..17ea63b3b --- /dev/null +++ b/packages/coding-agent/src/discovery/at-imports.ts @@ -0,0 +1,273 @@ +/** + * @-import expansion for context files (AGENTS.md / CLAUDE.md / GEMINI.md / …). + * + * Other coding agents (Claude Code, Goose, Cline, …) treat `@path/to/file` + * references inside their markdown memory files as inline includes. omp + * loads the same files in their native shape, so this module performs the + * same expansion before content lands in the system prompt. + * + * Semantics mirror Claude Code's documented behavior: + * - `@` must sit at start of line or after whitespace (so `git@github.com` + * and `user@example.com` are not treated as imports). + * - Relative paths resolve against the importing file's directory, not the + * working directory. + * - `~/...` resolves to the user's home directory. + * - Imports inside fenced code blocks (` ``` ` / `~~~`) and inline code + * spans (`` `…` ``) are preserved verbatim so technical examples like + * `npm install @types/node` survive intact. + * - Recursive imports are followed up to {@link MAX_AT_IMPORT_DEPTH} hops; + * cycles are broken silently. + * - When the referenced file cannot be read, the original `@token` is + * left untouched and a debug log is emitted. + * + * @see https://docs.claude.com/en/docs/claude-code/memory#import-additional-files + */ +import * as os from "node:os"; +import * as path from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; +import { readFile } from "../capability/fs"; + +/** Maximum number of recursive `@`-import hops. Matches Claude Code's documented cap. */ +export const MAX_AT_IMPORT_DEPTH = 5; + +/** + * Matches a candidate `@import` token: a leading boundary (start-of-string + * or single whitespace char) and a token whose first character is path-like. + * + * The boundary character is captured separately so the slice arithmetic in + * {@link expandLine} aligns with the `@` position, not the whitespace. + */ +const AT_IMPORT_REGEX = /(^|[ \t])@([./~A-Za-z0-9_-][^\s]*)/g; + +/** + * Trailing characters stripped from a captured path token: sentence-ending + * punctuation, closing brackets, quotes. A lone trailing period is treated + * as sentence grammar (e.g. `See @AGENTS.md.`) — legitimate file extensions + * still match because the stripped set is anchored at the very end of the + * token, so `@AGENTS.md` keeps the `.md` (the `d` is not in the set). + */ +const TRAILING_PUNCT = /[.,;:!?)\]}"']+$/; + +export interface ExpandAtImportsOptions { + /** Maximum hop depth (default: {@link MAX_AT_IMPORT_DEPTH}). */ + maxDepth?: number; + /** Override the home directory used to resolve `~/...` (default: `os.homedir()`). */ + home?: string; +} + +/** + * Expand `@path/to/file` references in `content` against `filePath`'s directory. + * + * Returns the expanded text. When no imports match, the original string is + * returned unchanged. + */ +export async function expandAtImports( + content: string, + filePath: string, + options: ExpandAtImportsOptions = {}, +): Promise { + const maxDepth = options.maxDepth ?? MAX_AT_IMPORT_DEPTH; + const home = options.home ?? os.homedir(); + const absoluteSource = path.resolve(filePath); + const visited = new Set([absoluteSource]); + return await expand(content, path.dirname(absoluteSource), 0, maxDepth, home, visited); +} + +async function expand( + content: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + if (depth >= maxDepth) return content; + + const segments = splitMarkdownSegments(content); + const out: string[] = []; + for (const segment of segments) { + if (segment.kind === "code") { + out.push(segment.text); + continue; + } + out.push(await expandTextSegment(segment.text, baseDir, depth, maxDepth, home, visited)); + } + return out.join(""); +} + +async function expandTextSegment( + text: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + lines[i] = await expandLine(lines[i], baseDir, depth, maxDepth, home, visited); + } + return lines.join("\n"); +} + +async function expandLine( + line: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + if (!line.includes("@")) return line; + + const matches: Array<{ start: number; end: number; importPath: string }> = []; + for (const m of line.matchAll(AT_IMPORT_REGEX)) { + const matchIndex = m.index ?? 0; + const leading = m[1]; + const rawToken = m[2]; + const atPos = matchIndex + leading.length; + if (isInsideInlineCode(line, atPos)) continue; + + const trimmedToken = rawToken.replace(TRAILING_PUNCT, ""); + if (trimmedToken.length === 0) continue; + + matches.push({ + start: atPos, + end: atPos + 1 + trimmedToken.length, + importPath: trimmedToken, + }); + } + + if (matches.length === 0) return line; + + const parts: string[] = []; + let cursor = 0; + for (const m of matches) { + parts.push(line.slice(cursor, m.start)); + const expanded = await resolveAndExpand(m.importPath, baseDir, depth, maxDepth, home, visited); + parts.push(expanded ?? line.slice(m.start, m.end)); + cursor = m.end; + } + parts.push(line.slice(cursor)); + return parts.join(""); +} + +async function resolveAndExpand( + importPath: string, + baseDir: string, + depth: number, + maxDepth: number, + home: string, + visited: Set, +): Promise { + const resolved = resolveImportPath(importPath, baseDir, home); + if (visited.has(resolved)) { + logger.debug("@-import: skipping cyclic include", { path: resolved }); + return null; + } + + const content = await readFile(resolved); + if (content === null) { + logger.debug("@-import: file not found", { path: resolved }); + return null; + } + + // Visited is shared across the whole expansion tree to break cycles, + // even cycles that span multiple importing files. + visited.add(resolved); + return await expand(content, path.dirname(resolved), depth + 1, maxDepth, home, visited); +} + +function resolveImportPath(importPath: string, baseDir: string, home: string): string { + if (importPath === "~") return path.resolve(home); + if (importPath.startsWith("~/")) return path.resolve(home, importPath.slice(2)); + if (path.isAbsolute(importPath)) return path.resolve(importPath); + return path.resolve(baseDir, importPath); +} + +interface MarkdownSegment { + kind: "text" | "code"; + text: string; +} + +/** + * Split markdown into alternating text/code segments by tracking fenced + * code blocks. Inline code spans are handled per-line by {@link isInsideInlineCode}. + * + * A fence is recognized as a line whose first non-whitespace run is three or + * more backticks (or tildes). The closing fence must use the same character + * with at least as many marks as the opener. + */ +function splitMarkdownSegments(content: string): MarkdownSegment[] { + const segments: MarkdownSegment[] = []; + const lines = content.split("\n"); + let buffer: string[] = []; + let bufferKind: MarkdownSegment["kind"] = "text"; + let fenceChar = ""; + let fenceLen = 0; + + const flush = (): void => { + if (buffer.length === 0) return; + segments.push({ kind: bufferKind, text: buffer.join("") }); + buffer = []; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isLast = i === lines.length - 1; + // Re-attach each line's trailing newline so adjacent segments + // concatenate without losing the boundary `\n`. + const lineText = isLast ? line : `${line}\n`; + const fence = matchFence(line); + + if (fence && bufferKind === "text") { + flush(); + bufferKind = "code"; + buffer.push(lineText); + fenceChar = fence.char; + fenceLen = fence.len; + } else if (fence && bufferKind === "code" && fence.char === fenceChar && fence.len >= fenceLen) { + buffer.push(lineText); + flush(); + bufferKind = "text"; + fenceChar = ""; + fenceLen = 0; + } else { + buffer.push(lineText); + } + + if (isLast) flush(); + } + return segments; +} + +function matchFence(line: string): { char: string; len: number } | null { + let i = 0; + while (i < line.length && (line[i] === " " || line[i] === "\t")) i++; + const char = line[i]; + if (char !== "`" && char !== "~") return null; + let len = 0; + while (i + len < line.length && line[i + len] === char) len++; + if (len < 3) return null; + return { char, len }; +} + +/** + * Returns `true` when `position` falls inside an unclosed inline-code span on + * this line. Implemented as a backtick-parity scan so it handles repeated + * delimiters like `` `` literal ` backtick `` `` correctly enough for the + * "@-imports inside `code` should not expand" case. + */ +function isInsideInlineCode(line: string, position: number): boolean { + let inSpan = false; + let i = 0; + while (i < position && i < line.length) { + if (line[i] === "`") { + while (i < line.length && line[i] === "`") i++; + inSpan = !inSpan; + } else { + i++; + } + } + return inSpan; +} diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index c0d935a51..21dff32a2 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -10,6 +10,7 @@ import { contextFileCapability } from "./capability/context-file"; import { systemPromptCapability } from "./capability/system-prompt"; import type { SkillsSettings } from "./config/settings"; import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery"; +import { expandAtImports } from "./discovery/at-imports"; import { loadSkills, type Skill } from "./extensibility/skills"; import { hasObsidian } from "./internal-urls/vault-protocol"; import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" }; @@ -254,15 +255,20 @@ export async function loadProjectContextFiles( const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd }); - // Convert ContextFile items and preserve depth info - const files = result.items.map(item => { - const contextFile = item as ContextFile; - return { - path: contextFile.path, - content: contextFile.content, - depth: contextFile.depth, - }; - }); + // Materialize ContextFile items, expanding any `@path/to/file` includes + // in their content. The expansion uses the file's own directory as the + // resolution base so relative imports work the same way Claude Code, + // Goose, and other tools document. + const files = await Promise.all( + result.items.map(async item => { + const contextFile = item as ContextFile; + return { + path: contextFile.path, + content: await expandAtImports(contextFile.content, contextFile.path), + depth: contextFile.depth, + }; + }), + ); // Sort by depth (descending): higher depth (farther from cwd) comes first, // so files closer to cwd appear later and are more prominent diff --git a/packages/coding-agent/test/discovery/at-imports.test.ts b/packages/coding-agent/test/discovery/at-imports.test.ts new file mode 100644 index 000000000..7c3279e9f --- /dev/null +++ b/packages/coding-agent/test/discovery/at-imports.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import { expandAtImports, MAX_AT_IMPORT_DEPTH } from "@oh-my-pi/pi-coding-agent/discovery/at-imports"; + +/** + * Behavior contract for the @-import expander used by every AGENTS.md / + * CLAUDE.md / GEMINI.md loader. Each test names one externally-observable + * promise — relative-resolution, code-block opacity, cycle/depth caps, etc. + */ +describe("expandAtImports", () => { + let tmp: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-at-import-")); + }); + + afterEach(async () => { + clearFsCache(); + await fs.rm(tmp, { recursive: true, force: true }); + }); + + const writeFile = async (relPath: string, content: string): Promise => { + const abs = path.join(tmp, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, content); + return abs; + }; + + test("inlines the entire referenced file when the line is just @path", async () => { + // User's reported case from issue #2111 — CLAUDE.md with body `@AGENTS.md`. + const agents = await writeFile("AGENTS.md", "ALWAYS use uppercase letters."); + const claude = await writeFile("CLAUDE.md", "@AGENTS.md\n"); + + const expanded = await expandAtImports(await fs.readFile(claude, "utf8"), claude); + + expect(expanded.trim()).toBe("ALWAYS use uppercase letters."); + // Sanity: the actual file path resolved was the sibling, not anything else. + expect(await fs.readFile(agents, "utf8")).toBe("ALWAYS use uppercase letters."); + }); + + test("resolves relative paths against the importing file's directory, not cwd", async () => { + // Importing file lives in a subdir; the @-import must look for the + // target alongside the importer, not in process.cwd(). + await writeFile("rules/no-push.md", "NEVER push."); + const agentsPath = await writeFile("rules/AGENTS.md", "Rule: @./no-push.md\n"); + + const expanded = await expandAtImports("Rule: @./no-push.md\n", agentsPath); + + expect(expanded).toContain("Rule: NEVER push."); + expect(expanded).not.toContain("@./no-push.md"); + }); + + test("resolves ~/path against the home override", async () => { + const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-at-home-")); + try { + await fs.writeFile(path.join(fakeHome, "prefs.md"), "use 2 spaces"); + const expanded = await expandAtImports("See @~/prefs.md.\n", path.join(tmp, "AGENTS.md"), { + home: fakeHome, + }); + expect(expanded).toContain("See use 2 spaces"); + } finally { + await fs.rm(fakeHome, { recursive: true, force: true }); + } + }); + + test("nests imports recursively", async () => { + await writeFile("c.md", "LEAF."); + await writeFile("b.md", "B then @c.md\n"); + const a = await writeFile("a.md", "A then @b.md\n"); + + const expanded = await expandAtImports(await fs.readFile(a, "utf8"), a); + + expect(expanded).toContain("A then B then LEAF."); + }); + + test("caps recursion at MAX_AT_IMPORT_DEPTH hops", async () => { + // Build a chain longer than the depth cap. At depth=MAX, expand() + // short-circuits before resolving the file's own @-imports, so the + // content at that depth is included verbatim and the import token + // inside it (`@step${MAX+1}.md`) survives unexpanded. + const total = MAX_AT_IMPORT_DEPTH + 2; + for (let i = 0; i < total; i++) { + const body = i === total - 1 ? "TERMINAL" : `step-${i} -> @step${i + 1}.md`; + await writeFile(`step${i}.md`, `${body}\n`); + } + const entry = path.join(tmp, "step0.md"); + const expanded = await expandAtImports(await fs.readFile(entry, "utf8"), entry); + expect(expanded).toContain(`@step${MAX_AT_IMPORT_DEPTH + 1}.md`); + expect(expanded).not.toContain("TERMINAL"); + }); + + test("breaks cycles silently", async () => { + // `@` must follow whitespace or start-of-line to count as an import, + // so the loop bodies use a space before each reference. + const a = await writeFile("loop-a.md", "A: @loop-b.md\n"); + await writeFile("loop-b.md", "B: @loop-a.md\n"); + + const expanded = await expandAtImports(await fs.readFile(a, "utf8"), a); + + // The second hop sees the original importer in `visited` and bails, + // leaving its literal @-token but emitting all earlier text. + expect(expanded).toContain("A: B:"); + expect(expanded).toContain("@loop-a.md"); + }); + + test("leaves the original token untouched when the file is missing", async () => { + const source = path.join(tmp, "AGENTS.md"); + const expanded = await expandAtImports("See @./does-not-exist.md\n", source); + expect(expanded).toContain("@./does-not-exist.md"); + }); + + test("does not expand inside fenced code blocks", async () => { + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = ["Run this:", "```bash", "echo @./guide.md", "```", "Also see @./guide.md."].join("\n"); + + const expanded = await expandAtImports(input, source); + + // Inside the fence the @-token is preserved verbatim. + expect(expanded).toContain("echo @./guide.md"); + // Outside the fence it expands. + expect(expanded).toContain("Also see INLINED"); + }); + + test("does not expand inside inline code spans", async () => { + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = "Install via `npm i @./guide.md` and also @./guide.md.\n"; + + const expanded = await expandAtImports(input, source); + + expect(expanded).toContain("`npm i @./guide.md`"); + expect(expanded).toContain("also INLINED"); + }); + + test("ignores @ embedded mid-token like emails and SSH URLs", async () => { + // Guard against false positives that would otherwise spam debug logs + // or worse, leak filesystem reads triggered by user-supplied text. + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = "Ping me at me@example.com or use git@github.com:foo/bar.git for clones.\n"; + + const expanded = await expandAtImports(input, source); + + expect(expanded).toBe(input); + }); + + test("strips trailing sentence punctuation from the path", async () => { + // The trailing comma/period is sentence grammar, not part of the filename. + await writeFile("guide.md", "INLINED"); + const source = path.join(tmp, "AGENTS.md"); + const input = "See @./guide.md, and then continue.\n"; + + const expanded = await expandAtImports(input, source); + + expect(expanded).toContain("See INLINED, and then continue."); + }); +}); From d90f58abe3d2a502e72e8c8be90a4e096ae0a8ba Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 15:29:11 +0000 Subject: [PATCH 32/53] fix(ai): mark moonshot kimi-k2.x as reasoning + vision during discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The moonshot provider bundles only kimi-k2.5; users discovering kimi-k2.6 via MOONSHOT_API_KEY hit a silent stall on first turn because the discovery mapper only promoted ids containing 'thinking' to reasoning=true. k2.6 fell through with reasoning=false, the openai-completions z.ai branch was skipped, and the wire body went out with no `thinking` parameter at all. Moonshot K2.6 requires an explicit `thinking: {type}` field (the same native-API quirk #1838 documented when introducing `thinking.keep`). Without it, Moonshot held the stream open and the user saw 'Working...' forever. Mapper now recognizes every kimi-k2.x id (k2.5, k2.6, future versions) as reasoning + vision and stamps the default Moonshot effort metadata. With reasoning=true, buildParams emits the explicit thinking block — either `{type: 'disabled'}` by default or `{type: 'enabled', keep: 'all'}` when the user enables thinking — and Moonshot responds normally. Regression test covers the discovery mapper plus both wire-body shapes. Fixes #2113 --- packages/ai/CHANGELOG.md | 3 + .../ai/src/provider-models/openai-compat.ts | 17 +- packages/ai/test/issue-2113-repro.test.ts | 180 ++++++++++++++++++ 3 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 packages/ai/test/issue-2113-repro.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1ebaa4a4d..9c80647e9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- Fixed Moonshot `kimi-k2.6` (and any future `kimi-k2.x`) discovered via `MOONSHOT_API_KEY` stalling on first turn with no output. The `moonshotModelManagerOptions` discovery mapper only marked ids containing `"thinking"` as `reasoning: true`, so dynamic `kimi-k2.6` entries fell through with `reasoning: false`; the openai-completions z.ai branch was then skipped and the request reached Moonshot with no `thinking` parameter at all. Moonshot K2.6 requires an explicit `thinking: {type}` field (the same native-API wire shape #1838 introduced `thinking.keep` for), so the server held the stream silently. The mapper now stamps `reasoning: true`, vision input, and default `thinking` metadata on every `kimi-k2.x` id, restoring the explicit `thinking: {type: "disabled"|"enabled"}` wire body the Moonshot endpoint expects. ([#2113](https://github.com/can1357/oh-my-pi/issues/2113)) ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index c529fb135..25ca28964 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1916,12 +1916,23 @@ export function moonshotModelManagerOptions( const reference = references.get(defaults.id); const model = mapWithBundledReference(entry, defaults, reference); const id = model.id.toLowerCase(); - const isThinking = id.includes("thinking"); - const isVision = id.includes("vision") || id.includes("vl") || id.includes("k2.5"); + // Moonshot's K2.x family (K2.5, K2.6, kimi-k2-thinking, …) is reasoning-capable + // and vision-capable on the native API. Without these flags the openai-completions + // path skips the z.ai-format `thinking` block, and Moonshot K2.6 stalls on first + // turn because its endpoint expects an explicit `thinking: {type}` (#2113). Match + // the bundled K2.5 metadata for every K2.x id we discover. + const isKimiK2Reasoning = id.includes("thinking") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id); + const isVision = + id.includes("vision") || id.includes("vl") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id); return { ...model, - reasoning: isThinking || model.reasoning, + reasoning: isKimiK2Reasoning || model.reasoning, input: isVision ? ["text", "image"] : model.input, + thinking: + model.thinking ?? + (isKimiK2Reasoning + ? { mode: "effort", minLevel: Effort.Minimal, maxLevel: Effort.High } + : undefined), }; }, }), diff --git a/packages/ai/test/issue-2113-repro.test.ts b/packages/ai/test/issue-2113-repro.test.ts new file mode 100644 index 000000000..0d097f0c8 --- /dev/null +++ b/packages/ai/test/issue-2113-repro.test.ts @@ -0,0 +1,180 @@ +/** + * Issue #2113 — `kimik2 does not work on the latest omp` + * + * Reporter: with only `MOONSHOT_API_KEY` set, selecting `kimi-k2.6` and + * sending any text leaves the agent stuck on "Working..." with no output. + * + * Root cause: the `moonshot` provider only bundled `kimi-k2.5`, and the + * `moonshotModelManagerOptions` discovery mapper only promoted ids + * containing `"thinking"` to `reasoning: true`. `kimi-k2.6` fell through + * with `reasoning: false`, so the openai-completions `buildParams` z.ai + * branch was skipped entirely and Moonshot received a request with no + * `thinking` parameter — Moonshot K2.6 stalls under that shape (the same + * native-API quirk documented in the #1838 fix that introduced + * `thinking.keep` for K2.6). + * + * The fix marks every `kimi-k2.x` id as reasoning + vision in the + * moonshot discovery mapper and stamps default thinking metadata. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import { Effort } from "../src/effort"; +import { getBundledModel } from "../src/models"; +import { moonshotModelManagerOptions } from "../src/provider-models/openai-compat"; +import { streamOpenAICompletions } from "../src/providers/openai-completions"; +import type { AssistantMessage, Context, Model } from "../src/types"; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +function moonshotKimiModel(id: string, reasoning: boolean): Model<"openai-completions"> { + return { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + id, + reasoning, + }; +} + +function basicContext(): Context { + return { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }; +} + +function encodeSseChunks(chunks: ReadonlyArray>): string { + const lines = chunks.map(c => `data: ${JSON.stringify(c)}\n\n`); + lines.push("data: [DONE]\n\n"); + return lines.join(""); +} + +function buildMockMoonshotResponse(): Response { + const body = encodeSseChunks([ + { + id: "chatcmpl-k26-1", + object: "chat.completion.chunk", + created: 1, + model: "kimi-k2.6", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello!" }, finish_reason: null }], + }, + { + id: "chatcmpl-k26-1", + object: "chat.completion.chunk", + created: 1, + model: "kimi-k2.6", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ]); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +interface CapturedRequest { + url: string; + body: Record; +} + +async function runHiTurn( + model: Model<"openai-completions">, +): Promise<{ captured: CapturedRequest; assistant: AssistantMessage }> { + const captured: CapturedRequest = { url: "", body: {} }; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + captured.url = url; + const raw = typeof init?.body === "string" ? init.body : ""; + captured.body = raw ? (JSON.parse(raw) as Record) : {}; + return buildMockMoonshotResponse(); + }; + global.fetch = Object.assign(fetchImpl, { preconnect: originalFetch.preconnect }); + + const stream = streamOpenAICompletions(model, basicContext(), { apiKey: "test-key" }); + for await (const _ of stream) { + // drain until terminal event + } + const assistant = await stream.result(); + return { captured, assistant }; +} + +describe("issue #2113 — moonshot kimi-k2.6 discovery and wire format", () => { + it("moonshot discovery mapper marks kimi-k2.6 as reasoning + vision with thinking metadata", async () => { + const opts = moonshotModelManagerOptions({ apiKey: "test-key" }); + const fetchImpl = async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + expect(url).toContain("api.moonshot.ai/v1/models"); + const body = { + object: "list", + data: [ + { id: "kimi-k2.5", object: "model", owned_by: "moonshot" }, + { id: "kimi-k2.6", object: "model", owned_by: "moonshot" }, + { id: "kimi-k2-thinking", object: "model", owned_by: "moonshot" }, + ], + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + global.fetch = Object.assign(fetchImpl, { preconnect: originalFetch.preconnect }); + + const models = await opts.fetchDynamicModels?.(); + expect(models).toBeDefined(); + const byId = new Map(models?.map(m => [m.id, m])); + + const k25 = byId.get("kimi-k2.5"); + expect(k25?.reasoning).toBe(true); + expect(k25?.input).toEqual(["text", "image"]); + expect(k25?.thinking).toBeDefined(); + + const k26 = byId.get("kimi-k2.6"); + expect(k26?.reasoning).toBe(true); + expect(k26?.input).toEqual(["text", "image"]); + expect(k26?.thinking).toEqual({ mode: "effort", minLevel: Effort.Minimal, maxLevel: Effort.High }); + + const thinkingOnly = byId.get("kimi-k2-thinking"); + expect(thinkingOnly?.reasoning).toBe(true); + expect(thinkingOnly?.thinking).toBeDefined(); + }); + + it("wire body for moonshot kimi-k2.6 carries an explicit thinking parameter", async () => { + // The discovery mapper now stamps reasoning=true on Moonshot K2.6, so the + // openai-completions z.ai branch fires and emits `thinking: {type}`. Without + // this, Moonshot K2.6 stalls on first turn (the original #2113 symptom). + const model = moonshotKimiModel("kimi-k2.6", true); + const { captured, assistant } = await runHiTurn(model); + + expect(captured.url).toContain("api.moonshot.ai/v1/chat/completions"); + expect(captured.body.thinking).toEqual({ type: "disabled" }); + expect(assistant.errorMessage).toBeUndefined(); + const textBlock = assistant.content.find(b => b.type === "text"); + expect(textBlock).toBeDefined(); + }); + + it("wire body includes thinking.keep='all' when reasoning is explicitly requested", async () => { + const model = moonshotKimiModel("kimi-k2.6", true); + const captured: CapturedRequest = { url: "", body: {} }; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + captured.url = url; + const raw = typeof init?.body === "string" ? init.body : ""; + captured.body = raw ? (JSON.parse(raw) as Record) : {}; + return buildMockMoonshotResponse(); + }; + global.fetch = Object.assign(fetchImpl, { preconnect: originalFetch.preconnect }); + + const stream = streamOpenAICompletions(model, basicContext(), { + apiKey: "test-key", + reasoning: "high", + }); + for await (const _ of stream) { + // drain + } + + expect(captured.body.thinking).toEqual({ type: "enabled", keep: "all" }); + }); +}); From 05f2d0c75aea00e1f401b900a208062a29598e22 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:40:09 +0200 Subject: [PATCH 33/53] feat(ai): added Anthropic-compatible API key validation - Validated keys against the `/v1/messages` endpoint with base URL normalization. - Added `anthropic-messages` strategy to API key login config. - Emitted `message_stop` in stream timeout test fixtures. --- packages/ai/src/utils/oauth/api-key-login.ts | 23 +++++++- .../ai/src/utils/oauth/api-key-validation.ts | 54 +++++++++++++++++++ .../ai/test/anthropic-stream-timeout.test.ts | 1 + 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/utils/oauth/api-key-login.ts b/packages/ai/src/utils/oauth/api-key-login.ts index f68afa43f..8b589107f 100644 --- a/packages/ai/src/utils/oauth/api-key-login.ts +++ b/packages/ai/src/utils/oauth/api-key-login.ts @@ -6,7 +6,11 @@ * optionally validate it, and return the trimmed key. */ -import { validateApiKeyAgainstModelsEndpoint, validateOpenAICompatibleApiKey } from "./api-key-validation"; +import { + validateAnthropicCompatibleApiKey, + validateApiKeyAgainstModelsEndpoint, + validateOpenAICompatibleApiKey, +} from "./api-key-validation"; import type { OAuthController } from "./types"; type ChatCompletionsValidation = { @@ -16,6 +20,13 @@ type ChatCompletionsValidation = { model: string; }; +type AnthropicMessagesValidation = { + kind: "anthropic-messages"; + provider: string; + baseUrl: string; + model: string; +}; + type ModelsEndpointValidation = { kind: "models-endpoint"; provider: string; @@ -34,7 +45,7 @@ export type ApiKeyLoginConfig = { /** Placeholder string for the prompt (e.g. "sk-...", "csk-..."). */ placeholder: string; /** Validation strategy, or `null` to skip validation. */ - validation: ChatCompletionsValidation | ModelsEndpointValidation | null; + validation: ChatCompletionsValidation | AnthropicMessagesValidation | ModelsEndpointValidation | null; }; export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise { @@ -72,6 +83,14 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon model: config.validation.model, signal: options.signal, }); + } else if (config.validation.kind === "anthropic-messages") { + await validateAnthropicCompatibleApiKey({ + provider: config.validation.provider, + apiKey: trimmed, + baseUrl: config.validation.baseUrl, + model: config.validation.model, + signal: options.signal, + }); } else { await validateApiKeyAgainstModelsEndpoint({ provider: config.validation.provider, diff --git a/packages/ai/src/utils/oauth/api-key-validation.ts b/packages/ai/src/utils/oauth/api-key-validation.ts index baeacc590..f97aa1c1b 100644 --- a/packages/ai/src/utils/oauth/api-key-validation.ts +++ b/packages/ai/src/utils/oauth/api-key-validation.ts @@ -5,6 +5,14 @@ type OpenAICompatibleValidationOptions = { model: string; signal?: AbortSignal; }; +type AnthropicCompatibleValidationOptions = { + provider: string; + apiKey: string; + baseUrl: string; + model: string; + signal?: AbortSignal; +}; + type ModelListValidationOptions = { provider: string; @@ -15,6 +23,12 @@ type ModelListValidationOptions = { const VALIDATION_TIMEOUT_MS = 15_000; +function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + + /** * Validate an API key against an OpenAI-compatible chat completions endpoint. * @@ -56,6 +70,46 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa throw new Error(message); } +/** + * Validate an API key against an Anthropic-compatible messages endpoint. + */ +export async function validateAnthropicCompatibleApiKey(options: AnthropicCompatibleValidationOptions): Promise { + const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; + const baseUrl = normalizeAnthropicCompatibleBaseUrl(options.baseUrl); + + const response = await fetch(`${baseUrl}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": options.apiKey, + }, + body: JSON.stringify({ + model: options.model, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + signal, + }); + + if (response.ok) { + return; + } + + let details = ""; + try { + details = (await response.text()).trim(); + } catch { + // ignore body parse errors, status is enough + } + + const message = details + ? `${options.provider} API key validation failed (${response.status}): ${details}` + : `${options.provider} API key validation failed (${response.status})`; + throw new Error(message); +} + /** * Validate an API key against a provider models endpoint. * diff --git a/packages/ai/test/anthropic-stream-timeout.test.ts b/packages/ai/test/anthropic-stream-timeout.test.ts index 992fe5e59..4b001ffee 100644 --- a/packages/ai/test/anthropic-stream-timeout.test.ts +++ b/packages/ai/test/anthropic-stream-timeout.test.ts @@ -86,6 +86,7 @@ function createSuccessfulAnthropicEvents(text: string): MockAnthropicEvent[] { cache_creation_input_tokens: 0, }, }, + { type: "message_stop" }, ]; } From 8d13e5e8c9b1d16c1602cb6df0552bcfd0927061 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 15:46:09 +0200 Subject: [PATCH 34/53] chore(ai): updated model pricing and token limits - Halved input, output, and cache-read costs for one model. - Adjusted pricing and lowered maxTokens for two other models. - Removed extra blank lines in api-key-validation. --- packages/ai/src/models.json | 22 +++++++++---------- .../ai/src/utils/oauth/api-key-validation.ts | 2 -- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index f6dc1a083..78b211331 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -38413,9 +38413,9 @@ "image" ], "cost": { - "input": 0.6, - "output": 2.4, - "cacheRead": 0.12, + "input": 0.3, + "output": 1.2, + "cacheRead": 0.06, "cacheWrite": 0 }, "contextWindow": 512000, @@ -40159,13 +40159,13 @@ "image" ], "cost": { - "input": 0.684, - "output": 3.42, - "cacheRead": 0.144, + "input": 0.6799999999999999, + "output": 3.41, + "cacheRead": 0.33999999999999997, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 262142, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -43304,13 +43304,13 @@ "image" ], "cost": { - "input": 0.684, - "output": 3.42, - "cacheRead": 0.144, + "input": 0.6799999999999999, + "output": 3.41, + "cacheRead": 0.33999999999999997, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 262142, "thinking": { "mode": "effort", "minLevel": "minimal", diff --git a/packages/ai/src/utils/oauth/api-key-validation.ts b/packages/ai/src/utils/oauth/api-key-validation.ts index f97aa1c1b..e1879036f 100644 --- a/packages/ai/src/utils/oauth/api-key-validation.ts +++ b/packages/ai/src/utils/oauth/api-key-validation.ts @@ -13,7 +13,6 @@ type AnthropicCompatibleValidationOptions = { signal?: AbortSignal; }; - type ModelListValidationOptions = { provider: string; apiKey: string; @@ -28,7 +27,6 @@ function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string { return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; } - /** * Validate an API key against an OpenAI-compatible chat completions endpoint. * From aee59fc2c737b105102b979db90157a86ad56c58 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 16:09:34 +0200 Subject: [PATCH 35/53] fix(ai): degraded malformed Anthropic stream envelopes to best-effort - Logged and skipped malformed SSE frames instead of aborting the stream. - Finalized unterminated blocks and tool calls with recovered arguments. - Retained best-effort tool-call JSON rather than erroring the turn. --- packages/ai/src/providers/anthropic.ts | 135 ++++++++++-------- .../ai/test/anthropic-stream-envelope.test.ts | 59 ++++---- 2 files changed, 105 insertions(+), 89 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index bf9e8fe9b..3d9136fd9 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1116,7 +1116,8 @@ const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet = new Set([ /** * Iterate over Anthropic SSE events from a raw Response, preserving ping events - * for liveness and rejecting malformed complete event envelopes. + * for liveness. Malformed event envelopes are logged and skipped (non-fatal) + * rather than aborting the stream. */ type RawMessagePingEvent = { type: "ping" }; type AnthropicStreamEvent = RawMessageStreamEvent | RawMessagePingEvent; @@ -1153,7 +1154,7 @@ async function* iterateAnthropicEvents( try { const event = JSON.parse(sse.data) as RawMessageStreamEvent; if (event.type !== sse.event) { - throw new Error(`event type ${event.type} does not match SSE event ${sse.event}`); + reportAnthropicEnvelopeAnomaly(`event type ${event.type} does not match SSE event ${sse.event}`); } if (event.type === "message_start") { sawMessageStart = true; @@ -1163,14 +1164,14 @@ async function* iterateAnthropicEvents( yield event; } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error( - `Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`, + reportAnthropicEnvelopeAnomaly( + `could not parse SSE event ${sse.event}: ${message}; skipping frame; data=${sse.data}`, ); } } if (sawMessageStart && !sawMessageEnd && !signal?.aborted) { - throw createAnthropicStreamEnvelopeError("stream ended before message_stop"); + reportAnthropicEnvelopeAnomaly("stream ended before message_stop"); } } @@ -1266,6 +1267,17 @@ function createAnthropicStreamEnvelopeError(message: string): Error { return new Error(`${ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX} ${message}`); } +/** + * Log a malformed-stream-envelope anomaly without aborting the turn. The strict + * parser would `throw createAnthropicStreamEnvelopeError(...)` here; we instead + * surface a warning and let the caller skip the offending event (or finalize what + * already streamed) so a non-conforming endpoint degrades to best-effort content + * rather than failing the request. + */ +function reportAnthropicEnvelopeAnomaly(detail: string): void { + logger.warn(`anthropic: ignoring malformed stream envelope: ${detail}`); +} + const ANTHROPIC_PRE_MESSAGE_START_EVENT_TYPES = new Set([ "content_block_start", "content_block_delta", @@ -1497,6 +1509,30 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( const requestTimeoutMs = firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined; const blocks = output.content as Block[]; + const finalizeStreamBlock = (block: Block, contentIndex: number): void => { + delete (block as { index?: number }).index; + if (block.type === "text") { + stream.push({ type: "text_end", contentIndex, content: block.text, partial: output }); + } else if (block.type === "thinking") { + stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output }); + } else if (block.type === "toolCall") { + const finalJson = + block.partialJson.length > 0 ? block.partialJson : JSON.stringify(block.arguments ?? {}); + try { + block.arguments = JSON.parse(finalJson) as ToolCall["arguments"]; + } catch (parseError) { + // Non-fatal: keep the best-effort arguments recovered by the throttled streaming + // parser instead of failing the turn on malformed/truncated tool-argument JSON. + reportAnthropicEnvelopeAnomaly( + `tool_use ${block.id} arguments are not valid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, + ); + block.arguments = (block.arguments ?? {}) as ToolCall["arguments"]; + } + delete (block as { partialJson?: string }).partialJson; + delete (block as { lastParseLen?: number }).lastParseLen; + stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output }); + } + }; stream.push({ type: "start", partial: output }); // Retry loop for transient errors from the stream. // Provider-level transport/rate-limit failures: only before any streamed content starts. @@ -1593,10 +1629,12 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( if (event.type === "content_block_start") { if (sawTerminalEnvelope) { - throw createAnthropicStreamEnvelopeError(`received ${event.type} after terminal stop signal`); + reportAnthropicEnvelopeAnomaly(`received ${event.type} after terminal stop signal`); + continue; } if (openBlocks.has(event.index)) { - throw createAnthropicStreamEnvelopeError(`duplicate content_block_start index ${event.index}`); + reportAnthropicEnvelopeAnomaly(`duplicate content_block_start index ${event.index}`); + continue; } if (!firstTokenTime) firstTokenTime = Date.now(); if (event.content_block.type === "text") { @@ -1667,19 +1705,22 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } } else if (event.type === "content_block_delta") { if (sawTerminalEnvelope) { - throw createAnthropicStreamEnvelopeError(`received ${event.type} after terminal stop signal`); + reportAnthropicEnvelopeAnomaly(`received ${event.type} after terminal stop signal`); + continue; } const openBlock = openBlocks.get(event.index); if (!openBlock) { - throw createAnthropicStreamEnvelopeError( + reportAnthropicEnvelopeAnomaly( `received content_block_delta for unopened index ${event.index}`, ); + continue; } if (openBlock.kind === "ignored") continue; const block = blocks[openBlock.contentIndex]; if (event.delta.type === "text_delta") { if (openBlock.kind !== "text" || block?.type !== "text") { - throw createAnthropicStreamEnvelopeError(`received text_delta for ${openBlock.kind} block`); + reportAnthropicEnvelopeAnomaly(`received text_delta for ${openBlock.kind} block`); + continue; } streamedReplayUnsafeContent = true; block.text += event.delta.text; @@ -1691,9 +1732,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (event.delta.type === "thinking_delta") { if (openBlock.kind !== "thinking" || block?.type !== "thinking") { - throw createAnthropicStreamEnvelopeError( - `received thinking_delta for ${openBlock.kind} block`, - ); + reportAnthropicEnvelopeAnomaly(`received thinking_delta for ${openBlock.kind} block`); + continue; } streamedReplayUnsafeContent = true; block.thinking += event.delta.thinking; @@ -1705,9 +1745,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (event.delta.type === "input_json_delta") { if (openBlock.kind !== "toolCall" || block?.type !== "toolCall") { - throw createAnthropicStreamEnvelopeError( - `received input_json_delta for ${openBlock.kind} block`, - ); + reportAnthropicEnvelopeAnomaly(`received input_json_delta for ${openBlock.kind} block`); + continue; } streamedReplayUnsafeContent = true; block.partialJson += event.delta.partial_json; @@ -1724,9 +1763,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (event.delta.type === "signature_delta") { if (openBlock.kind !== "thinking" || block?.type !== "thinking") { - throw createAnthropicStreamEnvelopeError( - `received signature_delta for ${openBlock.kind} block`, - ); + reportAnthropicEnvelopeAnomaly(`received signature_delta for ${openBlock.kind} block`); + continue; } streamedReplayUnsafeContent = true; block.thinkingSignature = block.thinkingSignature || ""; @@ -1734,13 +1772,13 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } } else if (event.type === "content_block_stop") { if (sawTerminalEnvelope) { - throw createAnthropicStreamEnvelopeError(`received ${event.type} after terminal stop signal`); + reportAnthropicEnvelopeAnomaly(`received ${event.type} after terminal stop signal`); + continue; } const openBlock = openBlocks.get(event.index); if (!openBlock) { - throw createAnthropicStreamEnvelopeError( - `received content_block_stop for unopened index ${event.index}`, - ); + reportAnthropicEnvelopeAnomaly(`received content_block_stop for unopened index ${event.index}`); + continue; } if (openBlock.kind === "ignored") { openBlocks.delete(event.index); @@ -1748,42 +1786,12 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } const block = blocks[openBlock.contentIndex]; if (!block || block.type !== openBlock.kind) { - throw createAnthropicStreamEnvelopeError( - `content_block_stop kind mismatch for index ${event.index}`, - ); + reportAnthropicEnvelopeAnomaly(`content_block_stop kind mismatch for index ${event.index}`); + openBlocks.delete(event.index); + continue; } openBlocks.delete(event.index); - delete (block as { index?: number }).index; - if (block.type === "text") { - streamedReplayUnsafeContent = true; - stream.push({ - type: "text_end", - contentIndex: openBlock.contentIndex, - content: block.text, - partial: output, - }); - } else if (block.type === "thinking") { - streamedReplayUnsafeContent = true; - stream.push({ - type: "thinking_end", - contentIndex: openBlock.contentIndex, - content: block.thinking, - partial: output, - }); - } else if (block.type === "toolCall") { - streamedReplayUnsafeContent = true; - const finalJson = - block.partialJson.length > 0 ? block.partialJson : JSON.stringify(block.arguments ?? {}); - block.arguments = JSON.parse(finalJson) as ToolCall["arguments"]; - delete (block as { partialJson?: string }).partialJson; - delete (block as { lastParseLen?: number }).lastParseLen; - stream.push({ - type: "toolcall_end", - contentIndex: openBlock.contentIndex, - toolCall: block, - partial: output, - }); - } + finalizeStreamBlock(block, openBlock.contentIndex); } else if (event.type === "message_delta") { const rawStopReason = event.delta.stop_reason; if (rawStopReason) { @@ -1840,17 +1848,18 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( throw createAnthropicStreamEnvelopeError("stream ended before message_start"); } if (!sawMessageStop) { - throw createAnthropicStreamEnvelopeError("stream ended before message_stop"); + reportAnthropicEnvelopeAnomaly("stream ended before message_stop"); } if (openBlocks.size > 0) { - const firstOpenBlock = openBlocks.entries().next().value; - if (firstOpenBlock) { - const [openIndex, openBlock] = firstOpenBlock; - throw createAnthropicStreamEnvelopeError( + for (const [openIndex, openBlock] of openBlocks) { + reportAnthropicEnvelopeAnomaly( `stream ended with an unterminated ${openBlock.kind} block at index ${openIndex}`, ); + if (openBlock.kind === "ignored" || openBlock.contentIndex < 0) continue; + const danglingBlock = blocks[openBlock.contentIndex]; + if (danglingBlock) finalizeStreamBlock(danglingBlock, openBlock.contentIndex); } - throw createAnthropicStreamEnvelopeError("stream ended with an unterminated content block"); + openBlocks.clear(); } if (output.stopReason === "aborted" || output.stopReason === "error") { diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 87d3b09e1..01f45758b 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -502,7 +502,7 @@ describe("anthropic stream envelope handling", () => { expect(anthropicStrictToolsDisabled(providerSessionState)).toBe(false); }); - it("does not retry malformed envelopes after partial tool-call content starts streaming", async () => { + it("finalizes a tool call with malformed argument JSON as best-effort content instead of erroring", async () => { let attempt = 0; vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; @@ -519,21 +519,22 @@ describe("anthropic stream envelope handling", () => { expect(attempt).toBe(1); expect(countEvents(events, "toolcall_start")).toBe(1); expect(countEvents(events, "toolcall_delta")).toBe(1); - expect(countEvents(events, "toolcall_end")).toBe(0); - expect(countEvents(events, "error")).toBe(1); - expect(countEvents(events, "done")).toBe(0); - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("Unterminated string"); + expect(countEvents(events, "toolcall_end")).toBe(1); + expect(countEvents(events, "error")).toBe(0); + expect(countEvents(events, "done")).toBe(1); + expect(result.stopReason).toBe("stop"); const toolCall = result.content[0]; expect(toolCall?.type).toBe("toolCall"); if (toolCall?.type !== "toolCall") { - throw new Error("Expected toolCall content in terminal error payload"); + throw new Error("Expected toolCall content in degraded payload"); } + // Best-effort arguments recovered by the throttled streaming parser are retained. + expect(toolCall.arguments).toEqual({ city: "Par" }); expect("partialJson" in toolCall).toBe(false); }); - it("surfaces an error instead of dispatching a tool call left open by a spliced reconnect", async () => { + it("finalizes a tool call left open by a spliced reconnect instead of erroring", async () => { let attempt = 0; vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; @@ -547,16 +548,21 @@ describe("anthropic stream envelope handling", () => { } const result = await stream.result(); - // The tool call started streaming (replay-unsafe), so the truncated envelope - // cannot be retried — it must be surfaced as an error rather than emitting a - // completed turn whose tool call carries the seed `{}` arguments. + // Non-fatal: the unterminated tool block is finalized with its seed `{}` arguments and the + // turn completes rather than erroring. Downstream argument validation handles the incomplete call. expect(attempt).toBe(1); expect(countEvents(events, "toolcall_start")).toBe(1); - expect(countEvents(events, "toolcall_end")).toBe(0); - expect(countEvents(events, "done")).toBe(0); - expect(countEvents(events, "error")).toBe(1); - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("unterminated toolCall block"); + expect(countEvents(events, "toolcall_end")).toBe(1); + expect(countEvents(events, "done")).toBe(1); + expect(countEvents(events, "error")).toBe(0); + expect(result.stopReason).toBe("stop"); + + const toolCall = result.content[0]; + expect(toolCall?.type).toBe("toolCall"); + if (toolCall?.type !== "toolCall") { + throw new Error("Expected toolCall content in degraded payload"); + } + expect(toolCall.arguments).toEqual({}); }); it("parses raw SSE directly so unknown events do not fail Anthropic streams", async () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( @@ -581,7 +587,7 @@ describe("anthropic stream envelope handling", () => { expect(result.content).toEqual([{ type: "text", text: "hello" }]); }); - it("surfaces an error when a raw SSE stream closes before message_stop", async () => { + it("degrades to best-effort content when a raw SSE stream closes before message_stop", async () => { const incompleteFrames = createTextSuccessSseFrames("partial").filter( frame => !frame.includes("event: message_stop"), ); @@ -596,14 +602,13 @@ describe("anthropic stream envelope handling", () => { } const result = await stream.result(); - expect(countEvents(events, "error")).toBe(1); - expect(countEvents(events, "done")).toBe(0); - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("stream ended before message_stop"); + expect(countEvents(events, "error")).toBe(0); + expect(countEvents(events, "done")).toBe(1); + expect(result.stopReason).toBe("stop"); expect(result.content).toEqual([{ type: "text", text: "partial" }]); }); - it("surfaces malformed raw SSE event JSON instead of repairing protocol frames", async () => { + it("skips malformed raw SSE event frames and degrades to best-effort content", async () => { const malformedTextDelta = '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"line\\qbreak"}}'; const successEvents = createTextSuccessEvents("unused"); @@ -624,10 +629,12 @@ describe("anthropic stream envelope handling", () => { } const result = await stream.result(); - expect(countEvents(events, "error")).toBe(1); - expect(countEvents(events, "done")).toBe(0); - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("Could not parse Anthropic SSE event content_block_delta"); + // The unparseable content_block_delta frame is dropped; the surrounding text block streams + // empty and the turn completes normally. + expect(countEvents(events, "error")).toBe(0); + expect(countEvents(events, "done")).toBe(1); + expect(result.stopReason).toBe("stop"); + expect(result.content).toEqual([{ type: "text", text: "" }]); }); it("surfaces a refusal fallback message when stop_details is null", async () => { const refusalEvents: MockAnthropicEvent[] = [ From 09a81f4c59c627c9392fd3100d10f4b4444a1c75 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 16:12:26 +0200 Subject: [PATCH 36/53] fix(coding-agent): fixed append-only detection for in-place streaming line growth - Updated live transcript state derivation to treat a trailing-line extension with unchanged rows as append-only. - Replaced the prior append-grew heuristic with preserved-row checks so non-rewritten history is not marked volatile. - Added a regression test covering in-place streaming line growth and scrollback commit safety in transcript containers. --- .../modes/components/transcript-container.ts | 27 ++++++---- .../transcript-streaming-commit-repro.test.ts | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/transcript-streaming-commit-repro.test.ts diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 5765a3712..66c1910aa 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -71,9 +71,7 @@ function commonPrefixLength(prev: string[], cur: string[]): number { } function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number { - const prevLimit = prev.length - prefixLength; - const curLimit = cur.length - prefixLength; - const limit = Math.min(prevLimit, curLimit); + const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength); let i = 0; while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++; return i; @@ -95,14 +93,23 @@ function deriveLiveCommitState( const staticRender = prefixLength === previous.lines.length && prefixLength === current.length; if (!staticRender) { const suffixLength = commonSuffixLength(previous.lines, current, prefixLength); - const stablePreviousLength = prefixLength + suffixLength; - const appendGrew = - previous.lines.length > 0 && - current.length > previous.lines.length && - stablePreviousLength >= previous.lines.length; - if (appendGrew && !volatile) { + // Append-only growth never rewrites a row that may already have scrolled + // into native scrollback; it only grows the block at/near its tail. Three + // shapes qualify: a pure bottom append, an insertion above stable trailing + // chrome (a streaming tool's footer/border), and an in-place extension of + // the current line by one streamed token (line count unchanged). The first + // two preserve every previous row across a matching prefix + suffix; the + // last leaves a single divergent previous row that the current row merely + // lengthens. A divergent interior row that is genuinely rewritten means the + // block re-laid-out committed content — volatile, and never committed. + const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length; + const tailExtendedInPlace = + prefixLength + suffixLength === previous.lines.length - 1 && + prefixLength < current.length && + current[prefixLength]!.startsWith(previous.lines[prefixLength]!); + if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length && !volatile) { appendOnly = true; - } else if (stablePreviousLength < previous.lines.length) { + } else if (!preservedEveryRow && !tailExtendedInPlace) { volatile = true; appendOnly = false; } diff --git a/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts new file mode 100644 index 000000000..48d731e47 --- /dev/null +++ b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { type Component, TERMINAL } from "@oh-my-pi/pi-tui"; +import { TranscriptContainer } from "../src/modes/components/transcript-container"; + +type MutableTerminalInfo = { eagerEraseScrollbackRisk: boolean }; +const mutableTerminalInfo = TERMINAL as unknown as MutableTerminalInfo; + +async function withTerminalRisk(risk: boolean, run: () => T | Promise): Promise { + const saved = TERMINAL.eagerEraseScrollbackRisk; + mutableTerminalInfo.eagerEraseScrollbackRisk = risk; + try { + return await run(); + } finally { + mutableTerminalInfo.eagerEraseScrollbackRisk = saved; + } +} + +class MutableLiveBlock implements Component { + #lines: string[]; + constructor(lines: string[]) { + this.#lines = [...lines]; + } + render(width: number): string[] { + return this.#lines.map(line => line.slice(0, width)); + } + setLines(lines: string[]): void { + this.#lines = [...lines]; + } + isTranscriptBlockFinalized(): boolean { + return false; + } +} + +describe("transcript streaming commit (assistant text)", () => { + it("treats in-place growth of the trailing line as append-only", async () => { + await withTerminalRisk(true, () => { + const chat = new TranscriptContainer(); + // Models a streaming assistant reply: stable head rows plus a current + // line that grows token-by-token without adding a new row. + const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]); + chat.addChild(block); + + chat.render(80); + + block.setLines(["para one", "para two", "the quick brown fox"]); + chat.render(80); + // The head rows never changed; only the trailing line grew. Its scrolled- + // off head must be committable to native scrollback (tmux pane history). + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); + }); + }); +}); From c99902d9798bfdfc6cf442598d66b5141fbb4762 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 16:14:09 +0200 Subject: [PATCH 37/53] fix(hashline): stripped diff prefixes and normalized elision markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rendered added/current lines without `+`/space prefix padding. - Collapsed long added runs with a bare `…` marker instead of `+…`. - Normalized adjacent ASCII/Unicode elision markers to one `…`. --- .../test/tool-live-region-scrollback.test.ts | 17 +++++++++++++ packages/hashline/CHANGELOG.md | 5 ++-- packages/hashline/src/diff-preview.ts | 25 +++++++++++++------ packages/hashline/test/diff-preview.test.ts | 18 ++++++------- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/test/tool-live-region-scrollback.test.ts b/packages/coding-agent/test/tool-live-region-scrollback.test.ts index 34f052d02..2b919be9b 100644 --- a/packages/coding-agent/test/tool-live-region-scrollback.test.ts +++ b/packages/coding-agent/test/tool-live-region-scrollback.test.ts @@ -70,6 +70,23 @@ describe("transcript reactive commit boundary", () => { }); }); + it("treats in-place growth of the trailing line as append-only", async () => { + await withTerminalRisk(true, () => { + const chat = new TranscriptContainer(); + // Models a streaming assistant reply: stable head rows plus a current + // line that grows token-by-token without adding a new row — the dominant + // streaming shape, and the one a strict line-count-growth check missed, + // stranding the scrolled-off head outside tmux pane history. + const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]); + chat.addChild(block); + + chat.render(80); + block.setLines(["para one", "para two", "the quick brown fox"]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); + }); + }); + it("marks interior live re-layout volatile and defers commit", async () => { await withTerminalRisk(true, () => { const chat = new TranscriptContainer(); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 307af48cf..81ca4d5cb 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -9,11 +9,12 @@ ### Changed - Changed `buildCompactDiffPreview` to omit removed lines from the preview while preserving removal counts for offset tracking -- Changed `buildCompactDiffPreview` to collapse long contiguous added runs with a bare `+…` marker, keeping only the first and last `maxAddedRunContext` lines visible (the surrounding line numbers convey how many were elided) +- Changed `buildCompactDiffPreview` to collapse long contiguous added runs with a bare `…` marker, keeping only the first and last `maxAddedRunContext` lines visible (the surrounding line numbers convey how many were elided) ### Fixed -- Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a bare `+…` elision marker. +- Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a bare `…` elision marker. +- Fixed compact edit previews to render added/current lines without diff-prefix padding and normalize adjacent ASCII/Unicode elision markers to one `…`. ## [15.10.3] - 2026-06-08 diff --git a/packages/hashline/src/diff-preview.ts b/packages/hashline/src/diff-preview.ts index 951e8db91..08fc00f90 100644 --- a/packages/hashline/src/diff-preview.ts +++ b/packages/hashline/src/diff-preview.ts @@ -5,7 +5,7 @@ * offset tracking, but omitted from the preview. Added and context lines are * anchored to their post-edit positions so a follow-up edit can reuse visible * concrete lines directly. Long contiguous added runs are summarized with a - * `+…` marker instead of echoing every inserted line. + * `…` marker instead of echoing every inserted line. * * This is intentionally decoupled from the diff producer: anything that * emits the `|` shape works. @@ -14,6 +14,15 @@ import type { CompactDiffOptions, CompactDiffPreview } from "./types"; const DEFAULT_ADDED_RUN_CONTEXT_LINES = 2; +const PREVIEW_ELISION_MARKER = "…"; +const RAW_ELISION_MARKERS = new Set(["...", PREVIEW_ELISION_MARKER, `+${PREVIEW_ELISION_MARKER}`]); + +function appendPreviewLine(output: string[], line: string): void { + const normalized = RAW_ELISION_MARKERS.has(line) ? PREVIEW_ELISION_MARKER : line; + if (normalized === PREVIEW_ELISION_MARKER && output[output.length - 1] === PREVIEW_ELISION_MARKER) return; + output.push(normalized); +} + interface ParsedDiffLine { kind: "+" | "-" | " "; lineNumber: number; @@ -44,13 +53,13 @@ function appendAddedRun(output: string[], run: string[], edgeLines: number): voi const collapseThreshold = edgeLines * 2 + 1; if (run.length <= collapseThreshold) { - for (const text of run) output.push(text); + for (const text of run) appendPreviewLine(output, text); return; } - for (let i = 0; i < edgeLines; i++) output.push(run[i]); - output.push("+…"); - for (let i = run.length - edgeLines; i < run.length; i++) output.push(run[i]); + for (let i = 0; i < edgeLines; i++) appendPreviewLine(output, run[i]); + appendPreviewLine(output, PREVIEW_ELISION_MARKER); + for (let i = run.length - edgeLines; i < run.length; i++) appendPreviewLine(output, run[i]); } export function buildCompactDiffPreview(diff: string, options: CompactDiffOptions = {}): CompactDiffPreview { @@ -75,14 +84,14 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption const parsed = parseNumberedDiffLine(line); if (!parsed) { flushAddedRun(); - formatted.push(line); + appendPreviewLine(formatted, line); continue; } switch (parsed.kind) { case "+": { addedLines++; - addedRun.push(`+${parsed.lineNumber}:${parsed.content}`); + addedRun.push(`${parsed.lineNumber}:${parsed.content}`); break; } case "-": @@ -92,7 +101,7 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption default: { flushAddedRun(); const newLineNumber = parsed.lineNumber + addedLines - removedLines; - formatted.push(` ${newLineNumber}:${parsed.content}`); + appendPreviewLine(formatted, `${newLineNumber}:${parsed.content}`); break; } } diff --git a/packages/hashline/test/diff-preview.test.ts b/packages/hashline/test/diff-preview.test.ts index 8450d0905..322825ffa 100644 --- a/packages/hashline/test/diff-preview.test.ts +++ b/packages/hashline/test/diff-preview.test.ts @@ -6,7 +6,7 @@ describe("buildCompactDiffPreview", () => { const preview = buildCompactDiffPreview([" 1|alpha", "-2|beta", "+2|DELTA", "+3|EPSILON", " 3|gamma"].join("\n")); expect(preview).toEqual({ - preview: [" 1:alpha", "+2:DELTA", "+3:EPSILON", " 4:gamma"].join("\n"), + preview: ["1:alpha", "2:DELTA", "3:EPSILON", "4:gamma"].join("\n"), addedLines: 2, removedLines: 1, }); @@ -17,21 +17,21 @@ describe("buildCompactDiffPreview", () => { const preview = buildCompactDiffPreview(diff); - expect(preview.preview.split("\n").filter(line => line.startsWith(" "))).toEqual([ - " 1:a1", - " 2:a2", - " 6:a5", - " 7:a6", - " 8:a7", - ]); + expect(preview.preview.split("\n")).toEqual(["1:a1", "2:a2", "3:X", "4:Y", "5:Z", "6:a5", "7:a6", "8:a7"]); }); it("collapses long contiguous added runs to head, marker, and tail", () => { const diff = Array.from({ length: 7 }, (_, index) => `+${10 + index}|line ${index + 1}`).join("\n"); const preview = buildCompactDiffPreview(diff); - expect(preview.preview).toBe(["+10:line 1", "+11:line 2", "+…", "+15:line 6", "+16:line 7"].join("\n")); + expect(preview.preview).toBe(["10:line 1", "11:line 2", "…", "15:line 6", "16:line 7"].join("\n")); expect(preview.addedLines).toBe(7); expect(preview.removedLines).toBe(0); }); + + it("normalizes adjacent elision markers to one unicode marker", () => { + const preview = buildCompactDiffPreview([" 1|alpha", "...", "...", "…", " 20|omega"].join("\n")); + + expect(preview.preview).toBe(["1:alpha", "…", "20:omega"].join("\n")); + }); }); From f8d35c0603e96ca7b5bf9aad999077414c70c35e Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 16:31:52 +0200 Subject: [PATCH 38/53] fix(coding-agent): fixed rule:// resolution for triggered TTSR rules - Added `getRules()` to `TtsrManager` to expose registered TTSR rules in registration order. - Updated `createAgentSession` to append `ttsrManager.getRules()` to active rules so TTSR rules can be resolved through `rule://`. - Rewrote the rulebook matching docs to reflect that `rule://` now includes registered TTSR rules and why. --- docs/rulebook-matching-pipeline.md | 14 +++++++------- packages/coding-agent/src/export/ttsr.ts | 5 +++++ packages/coding-agent/src/sdk.ts | 6 +++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/rulebook-matching-pipeline.md b/docs/rulebook-matching-pipeline.md index 089652fe7..a0e930781 100644 --- a/docs/rulebook-matching-pipeline.md +++ b/docs/rulebook-matching-pipeline.md @@ -242,18 +242,18 @@ This is advisory/contextual: prompt text asks the model to read applicable rules ## 8. `rule://` internal URL behavior -`RuleProtocolHandler` is registered with: +`RuleProtocolHandler` resolves against the process-global active-rule snapshot +installed once per top-level session in `sdk.ts`: ```ts -new RuleProtocolHandler({ - getRules: () => [...rulebookRules, ...alwaysApplyRules], -}); +setActiveRules([...rulebookRules, ...alwaysApplyRules, ...ttsrManager.getRules()]); ``` Implications: -- `rule://` resolves against both **rulebookRules** and **alwaysApplyRules**. -- TTSR-only rules and rules with no description and no `alwaysApply` are not addressable via `rule://`. +- `rule://` resolves against **rulebookRules**, **alwaysApplyRules**, and **registered TTSR rules**. +- TTSR rules are bucketed out before rulebook/always, but `ttsrManager.getRules()` re-adds them to the snapshot so a triggered rule (e.g. a builtin) stays addressable for re-reading. +- Rules with no description, no `alwaysApply`, and no accepted TTSR condition are not addressable via `rule://`. - Resolution is exact name match. - Unknown names return error listing available rule names. - Returned content is raw `rule.content` (frontmatter stripped), content type `text/markdown`. @@ -262,5 +262,5 @@ Implications: 1. The rule providers currently loaded for `rules` are `native`, `agents`, `cursor`, `windsurf`, `cline`, and embedded `builtin-defaults`; provider files for other tools may parse other config formats but do not register rule loaders. 2. `globs` metadata is surfaced to prompt/UI and is used as a global path gate for TTSR matching, but it is not used to automatically select rulebook rules for `rule://`. -3. Rule selection for `rule://` includes rulebook and always-apply rules, but not TTSR-only rules. +3. Rule selection for `rule://` includes rulebook, always-apply, and registered TTSR rules (so a triggered TTSR rule can be re-read), but not rules that registered no condition and carry neither a description nor `alwaysApply`. 4. Discovery warnings (`loadCapability("rules").warnings`) are produced but `createAgentSession` does not currently surface/log them in this path. diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index 7655df200..c0f6425bc 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -561,6 +561,11 @@ export class TtsrManager { return this.#rules.size > 0; } + /** All rules currently registered for TTSR monitoring, in registration order. */ + getRules(): Rule[] { + return Array.from(this.#rules.values(), entry => entry.rule); + } + /** Increment message counter (call after each turn). */ incrementMessageCount(): void { this.#messageCount++; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 6e9f8caad..175f65ce5 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1427,7 +1427,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const getArtifactsDir = () => sessionManager.getArtifactsDir(); if (!options.parentTaskPrefix) { setActiveSkills(skills); - setActiveRules([...rulebookRules, ...alwaysApplyRules]); + // Include TTSR rules so `rule://` can resolve them too. They are + // registered with the manager and bucketed out before rulebook/always, + // so without this a TTSR-only rule (e.g. a triggered builtin) is not + // addressable and `rule://` reports "Available: none". + setActiveRules([...rulebookRules, ...alwaysApplyRules, ...ttsrManager.getRules()]); if (asyncJobManager) AsyncJobManager.setInstance(asyncJobManager); } const localProtocolOptions = options.localProtocolOptions ?? { From cfd72ffb400030ed070ac0d814d05570cc557ba2 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 16:58:19 +0200 Subject: [PATCH 39/53] fix(coding-agent): fixed eval helpers treating local:// as plain paths - Substituted injected on-disk roots for `local://` in read/write/append (Python and JS). - Pinned `local://` to the session's own root so eval writes land where reads resolve. - Rejected path traversal and unknown `scheme://` paths instead of creating junk `local:` dirs. - Added unit and integration tests covering resolution, guards, and plain-path passthrough. --- packages/coding-agent/CHANGELOG.md | 1 + .../__tests__/helpers-local-roots.test.ts | 58 ++++++++++++++++++ packages/coding-agent/src/eval/backend.ts | 15 +++++ .../src/eval/js/context-manager.ts | 6 +- packages/coding-agent/src/eval/js/executor.ts | 3 + packages/coding-agent/src/eval/js/index.ts | 3 +- .../src/eval/js/shared/helpers.ts | 59 +++++++++++++++++-- .../src/eval/js/shared/runtime.ts | 8 +++ .../coding-agent/src/eval/js/worker-core.ts | 1 + .../src/eval/js/worker-protocol.ts | 6 ++ packages/coding-agent/src/eval/py/executor.ts | 12 ++++ packages/coding-agent/src/eval/py/index.ts | 3 +- packages/coding-agent/src/eval/py/prelude.py | 47 +++++++++++++-- packages/coding-agent/src/eval/py/runner.py | 1 + .../src/internal-urls/local-protocol.ts | 13 ++++ .../coding-agent/src/prompts/tools/eval.md | 6 +- .../eval-workflow-helpers.integration.test.ts | 24 ++++++++ 17 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9a5eea106..631be0244 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -28,6 +28,7 @@ ### Fixed - Fixed task-row shimmer timing so every running description starts its highlight on the first character together and reaches the last character together, regardless of text length. +- Fixed the `eval` tool's `read`/`write`/`append` helpers (both Python and JS backends) treating `local://` (and other internal-URL) paths as plain filesystem paths. `pathlib.Path`/`path.resolve` collapse `local://x.md` to `local:/x.md`, so `write("local://x.md", …)` created a junk `local:` directory under the cwd instead of writing where `read local://x.md` resolves. The helpers now substitute injected on-disk roots for known schemes (currently `local://`, pinned to the session's own `local://` root), reject path traversal and unknown `scheme://` paths, and leave plain paths resolving against the cwd. - Fixed read and edit previews to surface the enclosing syntactic block's off-window boundary line (behind an ellipsis) when a shown line opens or closes a block whose other end falls outside the displayed range. Powered by a new tree-sitter `enclosingBlockBoundaries` native, so it covers brace languages and indentation languages (Python) using real syntactic spans, with a lexical bracket scan as fallback for unparseable sources. - Fixed `tab.screenshot({ selector })` hanging for the entire cell budget on continuously-animating pages (WebGL / `backdrop-filter` "glass" effects). The element-screenshot path no longer routes through puppeteer's `scrollIntoViewIfNeeded()`, whose `IntersectionObserver` promise can stall indefinitely under heavy rendering; it now does a single instant `scrollIntoView` and captures with `scrollIntoView: false` (relying on `captureBeyondViewport`), so off-screen elements are still captured without the stall. - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows diff --git a/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts b/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts new file mode 100644 index 000000000..b137c7c7d --- /dev/null +++ b/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts @@ -0,0 +1,58 @@ +import * as path from "node:path"; +import { describe, expect, it } from "bun:test"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { createHelpers, type HelperContext } from "../js/shared/helpers"; + +/** + * The eval helpers (`read`/`write`/`append`) must substitute injected on-disk + * roots for internal-URL schemes. Without it, `write("local://x.md")` hits a + * stdlib `path.resolve` that collapses `local://` to `local:/`, creating a junk + * `local:` directory under the cwd instead of landing where `read local://x.md` + * resolves. These lock the substitution contract and its guards. + */ +function makeCtx(cwd: string, roots: Record): HelperContext { + return { + cwd: () => cwd, + env: new Map(), + localRoots: () => roots, + emitStatus: () => {}, + }; +} + +describe("eval js helpers internal-url resolution", () => { + it("writes, reads, and appends local:// under the injected root", async () => { + using tmp = TempDir.createSync("@eval-helpers-local-"); + const root = path.join(tmp.path(), "local"); + const helpers = createHelpers(makeCtx(tmp.path(), { local: root })); + + const written = await helpers.writeFile("local://notes/merge-map.md", "hello"); + expect(written).toBe(path.join(root, "notes", "merge-map.md")); + expect(await Bun.file(written).text()).toBe("hello"); + expect(await helpers.read("local://notes/merge-map.md")).toBe("hello"); + + await helpers.append("local://notes/merge-map.md", " world"); + expect(await helpers.read("local://notes/merge-map.md")).toBe("hello world"); + + // Regression: no literal `local:` directory created under the cwd. + expect(await Bun.file(path.join(tmp.path(), "local:")).exists()).toBe(false); + expect(await Bun.file(path.join(tmp.path(), "local:", "notes", "merge-map.md")).exists()).toBe(false); + }); + + it("rejects traversal and schemes without an injected root", async () => { + using tmp = TempDir.createSync("@eval-helpers-guard-"); + const helpers = createHelpers(makeCtx(tmp.path(), { local: path.join(tmp.path(), "local") })); + + await expect(helpers.writeFile("local://../escape.md", "x")).rejects.toThrow(/traversal|escapes/i); + await expect(helpers.writeFile("memory://x.md", "x")).rejects.toThrow(/not supported/i); + await expect(helpers.read("https://example.com/page")).rejects.toThrow(/not supported/i); + }); + + it("leaves plain relative and absolute paths resolving against the cwd", async () => { + using tmp = TempDir.createSync("@eval-helpers-plain-"); + const helpers = createHelpers(makeCtx(tmp.path(), {})); + + const rel = await helpers.writeFile("foo/bar.txt", "bar"); + expect(rel).toBe(path.join(tmp.path(), "foo", "bar.txt")); + expect(await helpers.read("foo/bar.txt")).toBe("bar"); + }); +}); diff --git a/packages/coding-agent/src/eval/backend.ts b/packages/coding-agent/src/eval/backend.ts index ed8a85f12..c1938940c 100644 --- a/packages/coding-agent/src/eval/backend.ts +++ b/packages/coding-agent/src/eval/backend.ts @@ -1,3 +1,4 @@ +import { buildEvalUrlRoots, type LocalProtocolOptions } from "../internal-urls"; import type { ToolSession } from "../tools"; import type { EvalDisplayOutput, EvalLanguage, EvalStatusEvent } from "./types"; @@ -56,3 +57,17 @@ export interface ExecutorBackend { /** Execute one cell. Caller invokes once per cell and aggregates results. */ execute(code: string, opts: ExecutorBackendExecOptions): Promise; } + +/** + * Resolve the on-disk roots that the eval helpers substitute for internal-URL + * schemes (currently `local://`). Prefers the session's own + * {@link LocalProtocolOptions} — the exact mapping `read local://…` uses — so an + * eval `write("local://x")` and a later `read local://x` agree on the location. + */ +export function resolveEvalUrlRoots(session: ToolSession): Record { + const options: LocalProtocolOptions = session.localProtocolOptions ?? { + getArtifactsDir: () => session.getArtifactsDir?.() ?? null, + getSessionId: () => session.getSessionId?.() ?? null, + }; + return buildEvalUrlRoots(options); +} diff --git a/packages/coding-agent/src/eval/js/context-manager.ts b/packages/coding-agent/src/eval/js/context-manager.ts index d0025021f..8a7679935 100644 --- a/packages/coding-agent/src/eval/js/context-manager.ts +++ b/packages/coding-agent/src/eval/js/context-manager.ts @@ -68,6 +68,7 @@ export async function executeInVmContext(options: { sessionId: string; cwd: string; session: ToolSession; + localRoots?: Record; reset?: boolean; code: string; filename: string; @@ -100,7 +101,7 @@ export async function executeInVmContext(options: { } const session = await acquireSession( options.sessionKey, - { cwd: options.cwd, sessionId: options.sessionId }, + { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots }, options.timeoutMs, ); return await runOnce(session, options); @@ -132,6 +133,7 @@ async function runOnce( sessionId: string; cwd: string; session: ToolSession; + localRoots?: Record; code: string; filename: string; runState: VmRunState; @@ -171,7 +173,7 @@ async function runOnce( runId, code: options.code, filename: options.filename, - snapshot: { cwd: options.cwd, sessionId: options.sessionId }, + snapshot: { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots }, }); return await promise; } finally { diff --git a/packages/coding-agent/src/eval/js/executor.ts b/packages/coding-agent/src/eval/js/executor.ts index 430b38348..ac227f98e 100644 --- a/packages/coding-agent/src/eval/js/executor.ts +++ b/packages/coding-agent/src/eval/js/executor.ts @@ -24,6 +24,8 @@ export interface JsExecutorOptions { artifactPath?: string; artifactId?: string; session: ToolSession; + /** On-disk roots the helpers substitute for internal-URL schemes (e.g. `local://`). */ + localRoots?: Record; } export interface JsResult { @@ -96,6 +98,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi sessionId: options.sessionId, cwd: options.cwd ?? options.session.cwd, session: options.session, + localRoots: options.localRoots, reset: options.reset, code, filename: `js-cell-${crypto.randomUUID()}.js`, diff --git a/packages/coding-agent/src/eval/js/index.ts b/packages/coding-agent/src/eval/js/index.ts index 9d2c7bf30..3fc0d693b 100644 --- a/packages/coding-agent/src/eval/js/index.ts +++ b/packages/coding-agent/src/eval/js/index.ts @@ -1,5 +1,5 @@ import type { ToolSession } from "../../tools"; -import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend"; +import { type ExecutorBackend, type ExecutorBackendExecOptions, type ExecutorBackendResult, resolveEvalUrlRoots } from "../backend"; import { executeJs } from "./executor"; const JS_SESSION_PREFIX = "js:"; @@ -30,6 +30,7 @@ export default { onChunk: opts.onChunk, onStatus: opts.onStatus, session: opts.session, + localRoots: resolveEvalUrlRoots(opts.session), }); return { output: result.output, diff --git a/packages/coding-agent/src/eval/js/shared/helpers.ts b/packages/coding-agent/src/eval/js/shared/helpers.ts index aae45036f..0e8ac7aea 100644 --- a/packages/coding-agent/src/eval/js/shared/helpers.ts +++ b/packages/coding-agent/src/eval/js/shared/helpers.ts @@ -24,6 +24,12 @@ export interface HelperOptions { export interface HelperContext { cwd(): string; env: Map; + /** + * On-disk roots for internal-URL schemes the helpers accept (e.g. + * `{ local: "/…/artifacts/local" }`). A path like `local://x.md` is rewritten + * to `/x.md` before any filesystem op; unknown schemes are rejected. + */ + localRoots(): Record; emitStatus(event: JsStatusEvent): void; } @@ -66,7 +72,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle { if (!isWriteData(data)) { throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data"); } - const filePath = resolvePath(ctx, rawPath); + const filePath = resolveHelperPath(ctx, rawPath, "write"); if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) { await Bun.write(filePath, data); } else { @@ -76,7 +82,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle { return filePath; }, append: async (rawPath, content) => { - const target = resolvePath(ctx, rawPath); + const target = resolveHelperPath(ctx, rawPath, "write"); await Bun.write( target, `${await Bun.file(target) @@ -202,19 +208,60 @@ function getMergedEnv(ctx: HelperContext): Record { return merged; } +const INTERNAL_URL_RE = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i; + function resolvePath(ctx: HelperContext, value: string): string { if (path.isAbsolute(value)) return path.normalize(value); return path.resolve(ctx.cwd(), value); } +/** + * Map a raw helper path to an absolute filesystem path. Plain paths resolve + * against the cwd; an internal-URL whose scheme has an injected root (e.g. + * `local://`) is rewritten under that root; any other `scheme://` is rejected + * so we never silently create a literal `scheme:/` directory. + */ +function resolveHelperPath(ctx: HelperContext, rawPath: string, op: "read" | "write"): string { + const match = INTERNAL_URL_RE.exec(rawPath); + if (!match) return resolvePath(ctx, rawPath); + const scheme = match[1].toLowerCase(); + const root = ctx.localRoots()[scheme]; + if (!root) { + throw new ToolError(`Protocol paths are not supported by ${op}(): ${rawPath}`); + } + return resolveUnderRoot(scheme, root, match[2], rawPath); +} + +/** Resolve an internal-URL relative path under its root, mirroring the host + * local-protocol handler: decode, reject absolute/traversal, confine to root. */ +function resolveUnderRoot(scheme: string, root: string, rawRelative: string, rawPath: string): string { + let relative: string; + try { + relative = decodeURIComponent(rawRelative.replaceAll("\\", "/")); + } catch { + throw new ToolError(`Invalid URL encoding in ${scheme}:// path: ${rawPath}`); + } + const rootPath = path.resolve(root); + if (relative === "") return rootPath; + if (path.isAbsolute(relative)) { + throw new ToolError(`Absolute paths are not allowed in ${scheme}:// URLs: ${rawPath}`); + } + const normalized = path.normalize(relative); + if (normalized.startsWith("..") || normalized.includes("/../") || normalized.includes("/..")) { + throw new ToolError(`Path traversal (..) is not allowed in ${scheme}:// URLs: ${rawPath}`); + } + const resolved = path.resolve(rootPath, normalized); + if (resolved !== rootPath && !resolved.startsWith(`${rootPath}${path.sep}`)) { + throw new ToolError(`${scheme}:// path escapes its root: ${rawPath}`); + } + return resolved; +} + async function resolveRegularFile( ctx: HelperContext, rawPath: string, ): Promise<{ filePath: string; file: Bun.BunFile; size: number }> { - if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawPath)) { - throw new ToolError(`Protocol paths are not supported by read(): ${rawPath}`); - } - const filePath = resolvePath(ctx, rawPath); + const filePath = resolveHelperPath(ctx, rawPath, "read"); const file = Bun.file(filePath); const stat = await file.stat(); if (stat.isDirectory()) { diff --git a/packages/coding-agent/src/eval/js/shared/runtime.ts b/packages/coding-agent/src/eval/js/shared/runtime.ts index 51ccc21f2..fb5baa066 100644 --- a/packages/coding-agent/src/eval/js/shared/runtime.ts +++ b/packages/coding-agent/src/eval/js/shared/runtime.ts @@ -42,6 +42,11 @@ export interface RuntimeOptions { * via `setRunScope()` instead. */ extraGlobals?: Record; + /** + * On-disk roots the helpers substitute for internal-URL schemes (e.g. + * `{ local: "/…/artifacts/local" }`). Stable for the worker's lifetime. + */ + localRoots?: Record; } // Strict base64: characters from the standard alphabet plus optional `=` padding, and a @@ -126,15 +131,18 @@ export class JsRuntime { #env: Map; #als = new AsyncLocalStorage(); #moduleLoader: LocalModuleLoader; + #localRoots: Record; constructor(opts: RuntimeOptions) { this.#cwd = opts.initialCwd; this.sessionId = opts.sessionId; this.#env = new Map(); this.#moduleLoader = new LocalModuleLoader(this.sessionId); + this.#localRoots = opts.localRoots ?? {}; this.helpers = createHelpers({ cwd: () => this.#activeCwd(), env: this.#env, + localRoots: () => this.#localRoots, emitStatus: event => this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event }), }); this.#install(opts.extraGlobals); diff --git a/packages/coding-agent/src/eval/js/worker-core.ts b/packages/coding-agent/src/eval/js/worker-core.ts index 552e9af9a..3be139b19 100644 --- a/packages/coding-agent/src/eval/js/worker-core.ts +++ b/packages/coding-agent/src/eval/js/worker-core.ts @@ -71,6 +71,7 @@ export class WorkerCore { this.#runtime = new JsRuntime({ initialCwd: snapshot.cwd, sessionId: snapshot.sessionId, + localRoots: snapshot.localRoots, }); return this.#runtime; } diff --git a/packages/coding-agent/src/eval/js/worker-protocol.ts b/packages/coding-agent/src/eval/js/worker-protocol.ts index 713793e10..118aae9ff 100644 --- a/packages/coding-agent/src/eval/js/worker-protocol.ts +++ b/packages/coding-agent/src/eval/js/worker-protocol.ts @@ -5,6 +5,12 @@ export type { JsDisplayOutput } from "./shared/types"; export interface SessionSnapshot { cwd: string; sessionId: string; + /** + * On-disk roots the helpers substitute for internal-URL schemes + * (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`/`append` + * accept `local://…` paths instead of writing a literal `local:/` directory. + */ + localRoots?: Record; } export interface RunErrorPayload { diff --git a/packages/coding-agent/src/eval/py/executor.ts b/packages/coding-agent/src/eval/py/executor.ts index 37d1c1b05..c33a0b25c 100644 --- a/packages/coding-agent/src/eval/py/executor.ts +++ b/packages/coding-agent/src/eval/py/executor.ts @@ -56,6 +56,13 @@ export interface PythonExecutorOptions { /** Artifact path/id for full output storage */ artifactPath?: string; artifactId?: string; + /** + * On-disk roots the prelude helpers (`read`/`write`/`append`) substitute for + * internal-URL schemes (e.g. `{ local: "/…/artifacts/local" }`). Exported to + * the kernel as `PI_EVAL_LOCAL_ROOTS` (JSON) so `write("local://x")` lands + * where `read local://x` resolves instead of a literal `local:/` directory. + */ + localRoots?: Record; /** * ToolSession used to resolve host-side `tool.(args)` calls made from * the Python prelude's bridge proxy. When omitted, the bridge env vars are @@ -275,6 +282,7 @@ const MANAGED_KERNEL_ENV_KEYS = [ "PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION", + "PI_EVAL_LOCAL_ROOTS", ] as const; function buildKernelEnvPatch(options: { @@ -282,13 +290,16 @@ function buildKernelEnvPatch(options: { artifactsDir?: string; bridgeSessionId?: string; bridge?: { url: string; token: string }; + localRoots?: Record; }): KernelRuntimeEnv { + const localRoots = options.localRoots; return { PI_SESSION_FILE: options.sessionFile ?? null, PI_ARTIFACTS_DIR: options.artifactsDir ?? null, PI_TOOL_BRIDGE_URL: options.bridge?.url ?? null, PI_TOOL_BRIDGE_TOKEN: options.bridge?.token ?? null, PI_TOOL_BRIDGE_SESSION: options.bridge && options.bridgeSessionId ? options.bridgeSessionId : null, + PI_EVAL_LOCAL_ROOTS: localRoots && Object.keys(localRoots).length > 0 ? JSON.stringify(localRoots) : null, }; } @@ -297,6 +308,7 @@ function buildKernelEnv(options: { artifactsDir?: string; bridgeSessionId?: string; bridge?: { url: string; token: string }; + localRoots?: Record; }): Record | undefined { const patch = buildKernelEnvPatch(options); const env: Record = {}; diff --git a/packages/coding-agent/src/eval/py/index.ts b/packages/coding-agent/src/eval/py/index.ts index 4eed80e98..79614b841 100644 --- a/packages/coding-agent/src/eval/py/index.ts +++ b/packages/coding-agent/src/eval/py/index.ts @@ -1,5 +1,5 @@ import type { ToolSession } from "../../tools"; -import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend"; +import { type ExecutorBackend, type ExecutorBackendExecOptions, type ExecutorBackendResult, resolveEvalUrlRoots } from "../backend"; import { executePython, type PythonExecutorOptions } from "./executor"; import { checkPythonKernelAvailability } from "./kernel"; @@ -34,6 +34,7 @@ export default { kernelMode, sessionFile: opts.sessionFile, artifactsDir: opts.session.getArtifactsDir?.() ?? undefined, + localRoots: resolveEvalUrlRoots(opts.session), kernelOwnerId: opts.kernelOwnerId, reset: opts.reset, artifactPath: opts.artifactPath, diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index 744ef453c..0eb2ec942 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -3,7 +3,8 @@ from __future__ import annotations if "__omp_prelude_loaded__" not in globals(): __omp_prelude_loaded__ = True from pathlib import Path - import os, json, math + import os, json, math, re + from urllib.parse import unquote # __omp_display is injected by runner.py before the prelude executes; it # mirrors IPython's display() semantics with the same MIME bundle output. @@ -53,9 +54,47 @@ if "__omp_prelude_loaded__" not in globals(): _emit_status("env", key=key, value=val, action="get") return val + _OMP_INTERNAL_URL_RE = re.compile(r"^([a-z][a-z0-9+.-]*)://(.*)$", re.IGNORECASE) + + def _resolve_omp_path(path: str | Path) -> Path: + """Map a helper path to a real filesystem Path. + + A `scheme://…` whose scheme has an injected on-disk root (e.g. + `local://`, via PI_EVAL_LOCAL_ROOTS) is rewritten under that root so it + lands where `read local://…` resolves — not a literal `local:/` + directory under the cwd (which `Path("local://x")` collapses to). Plain + paths pass through unchanged; any other `scheme://` is rejected.""" + if not isinstance(path, str): + return Path(path) + match = _OMP_INTERNAL_URL_RE.match(path) + if not match: + return Path(path) + scheme = match.group(1).lower() + try: + roots = json.loads(os.environ.get("PI_EVAL_LOCAL_ROOTS") or "{}") + except (ValueError, TypeError): + roots = {} + root = roots.get(scheme) if isinstance(roots, dict) else None + if not root: + raise ValueError(f"Protocol paths are not supported by this helper: {path}") + relative = unquote(match.group(2).replace("\\", "/")) + # Mirror the host `path.resolve`/`resolveLocalUrlToPath`: normalize and + # make absolute WITHOUT realpath'ing symlinks (Path.resolve would turn + # /tmp into /private/tmp and diverge from the read-side resolution). + root_path = os.path.abspath(root) + if relative == "": + return Path(root_path) + rel_path = Path(relative) + if rel_path.is_absolute() or ".." in rel_path.parts: + raise ValueError(f"Unsafe {scheme}:// path (absolute or traversal): {path}") + resolved = os.path.abspath(os.path.join(root_path, relative)) + if resolved != root_path and not resolved.startswith(root_path + os.sep): + raise ValueError(f"{scheme}:// path escapes its root: {path}") + return Path(resolved) + def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str: """Read file contents. offset/limit are 1-indexed line numbers.""" - p = Path(path) + p = _resolve_omp_path(path) data = p.read_text(encoding="utf-8") lines = data.splitlines(keepends=True) if offset > 1 or limit is not None: @@ -69,7 +108,7 @@ if "__omp_prelude_loaded__" not in globals(): def write(path: str | Path, content: str) -> Path: """Write file contents (create parents).""" - p = Path(path) + p = _resolve_omp_path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content, encoding="utf-8") _emit_status("write", path=str(p), chars=len(content)) @@ -77,7 +116,7 @@ if "__omp_prelude_loaded__" not in globals(): def append(path: str | Path, content: str) -> Path: """Append to file.""" - p = Path(path) + p = _resolve_omp_path(path) p.parent.mkdir(parents=True, exist_ok=True) with p.open("a", encoding="utf-8") as f: f.write(content) diff --git a/packages/coding-agent/src/eval/py/runner.py b/packages/coding-agent/src/eval/py/runner.py index 25bbc11d9..ab6e2ac62 100644 --- a/packages/coding-agent/src/eval/py/runner.py +++ b/packages/coding-agent/src/eval/py/runner.py @@ -819,6 +819,7 @@ _MANAGED_ENV_KEYS = ( "PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION", + "PI_EVAL_LOCAL_ROOTS", ) diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index 503566cf5..5e262b04a 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -133,6 +133,19 @@ export function resolveLocalUrlToPath(input: string | InternalUrl, options: Loca return resolved; } +/** + * On-disk roots the eval helpers (`read`/`write`/`append`) substitute for + * internal-URL schemes so e.g. `write("local://x.md")` lands where a later + * `read local://x.md` resolves — instead of a literal `local:/` directory under + * the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to + * `local:/`). Keyed by scheme without the `://`. Currently only `local`, but the + * shape is a map so additional file-backed schemes can be added without + * re-plumbing the worker boundary. + */ +export function buildEvalUrlRoots(options: LocalProtocolOptions): Record { + return { local: resolveLocalRoot(options) }; +} + /** * Protocol handler for local:// URLs. * diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index 35d216690..2c82ebde9 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -29,11 +29,11 @@ display(value) → None print(value, ...) → None Print to the cell's text output. read(path, offset?=1, limit?=None) → str - Read file contents as text. offset/limit are 1-indexed line bounds. + Read file contents as text. offset/limit are 1-indexed line bounds. Accepts `local://…` (resolved to the session-local root, same place `read local://…` reads). write(path, content) → str - Write content to a file (creates parent directories). Returns the resolved path. + Write content to a file (creates parent directories). Returns the resolved path. Accepts `local://…` to persist artifacts across turns / share with subagents. append(path, content) → str - Append content to a file. Returns the resolved path. + Append content to a file. Returns the resolved path. Accepts `local://…`. tree(path?=".", max_depth?=3, show_hidden?=False) → str Render a directory tree. diff(a, b) → str diff --git a/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts b/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts index 7635576de..b82c4a4c4 100644 --- a/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts +++ b/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts @@ -5,6 +5,7 @@ * Gated by `PI_PYTHON_INTEGRATION=1` so CI without a real Python interpreter * (or sandboxes where subprocess spawning is restricted) does not fail. */ +import * as path from "node:path"; import { afterEach, describe, expect, it } from "bun:test"; import { disposeAllKernelSessions, executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor"; import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel"; @@ -102,4 +103,27 @@ describe.skipIf(!SHOULD_RUN)("python eval workflow helpers", () => { await kernel.shutdown(); } }); + + it("local:// helpers resolve under the injected root", async () => { + using tempDir = TempDir.createSync("@eval-workflow-local-roots-"); + const root = path.join(tempDir.path(), "artifacts", "local"); + const kernel = await PythonKernel.start({ cwd: tempDir.path() }); + try { + const code = [ + "p = write('local://notes/merge-map.md', 'hello')", + "print('WROTE', str(p))", + "append('local://notes/merge-map.md', ' world')", + "print('READ', read('local://notes/merge-map.md'))", + ].join("\n"); + const result = await executePythonWithKernel(kernel, code, { localRoots: { local: root } }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain(`WROTE ${path.join(root, "notes", "merge-map.md")}`); + expect(result.output).toContain("READ hello world"); + // Lands under the injected root — not a literal `local:` directory under cwd. + expect(await Bun.file(path.join(root, "notes", "merge-map.md")).text()).toBe("hello world"); + expect(await Bun.file(path.join(tempDir.path(), "local:")).exists()).toBe(false); + } finally { + await kernel.shutdown(); + } + }); }); From 522a7d0f5d3720ccce7c6b62e2e5104f91789430 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:09:00 +0200 Subject: [PATCH 40/53] fix(ai): scoped Claude device_id to install id and account - Derived device_id from both install id and account UUID via v2 hash domain. - Fell back to v1 install-only hash when no account UUID is present. - Threaded account UUID through Anthropic metadata user_id resolution. --- packages/ai/src/providers/anthropic.ts | 50 ++++++++++++++++--- packages/ai/test/anthropic-alignment.test.ts | 44 +++++++++++++++- .../coding-agent/src/session/agent-session.ts | 22 ++++---- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 3d9136fd9..8a007cfde 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -630,20 +630,48 @@ export function generateClaudeCloakingUserId(): string { return `user_${userHash}_account_${accountId}_session_${sessionId}`; } -function deriveClaudeDeviceIdFromInstallId(): string { - return nodeCrypto.createHash("sha256").update(`omp-claude-device-id-v1:${getInstallId()}`).digest("hex"); +const CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN = "omp-claude-device-id-v1:"; +const CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN = "omp-claude-device-id-v2"; + +export function deriveClaudeDeviceId(installId: string, accountId?: string): string { + const hash = nodeCrypto.createHash("sha256"); + if (accountId && accountId.length > 0) { + return hash.update(CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN).update("\0").update(installId).update("\0").update(accountId).digest("hex"); + } + return hash.update(CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN).update(installId).digest("hex"); } -function generateClaudeJsonUserId(sessionId?: string): string { - return JSON.stringify({ - device_id: deriveClaudeDeviceIdFromInstallId(), + +function readMetadataString(metadata: Record | undefined, key: string): string | undefined { + const value = metadata?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readAnthropicMetadataAccountId(metadata: Record | undefined): string | undefined { + return ( + readMetadataString(metadata, "account_uuid") ?? + readMetadataString(metadata, "accountId") ?? + readMetadataString(metadata, "account_id") + ); +} + +function deriveClaudeDeviceIdFromInstallId(accountId?: string): string { + return deriveClaudeDeviceId(getInstallId(), accountId); +} + +function generateClaudeJsonUserId(sessionId?: string, accountId?: string): string { + const userId: Record = { + device_id: deriveClaudeDeviceIdFromInstallId(accountId), session_id: sessionId ?? nodeCrypto.randomUUID().toLowerCase(), - }); + }; + if (accountId && accountId.length > 0) userId.account_uuid = accountId; + return JSON.stringify(userId); } function resolveAnthropicMetadataUserId( userId: unknown, isOAuthToken: boolean, sessionId?: string, + accountId?: string, ): string | undefined { if (typeof userId === "string") { if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) { @@ -652,7 +680,7 @@ function resolveAnthropicMetadataUserId( } if (!isOAuthToken) return undefined; - return generateClaudeJsonUserId(sessionId); + return generateClaudeJsonUserId(sessionId, accountId); } const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "text_editor", "computer"]); export const applyClaudeToolPrefix = (name: string): string => { @@ -2503,7 +2531,13 @@ function buildParams( } // Pre-compute metadata. - const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken, options?.sessionId); + const metadataAccountId = readAnthropicMetadataAccountId(options?.metadata); + const metadataUserId = resolveAnthropicMetadataUserId( + options?.metadata?.user_id, + isOAuthToken, + options?.sessionId, + metadataAccountId, + ); const metadata = metadataUserId ? { user_id: metadataUserId } : undefined; // Pre-compute thinking + output_config effort. diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 7ed9fddf2..5c4ee08a6 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -13,6 +13,7 @@ import { claudeCodeSystemInstruction, claudeCodeVersion, claudeToolPrefix, + deriveClaudeDeviceId, generateClaudeCloakingUserId, isClaudeCloakingUserId, mapStainlessArch, @@ -54,7 +55,7 @@ function createAbortedSignal(): AbortSignal { type CaptureAnthropicOptions = { isOAuth?: boolean; - metadata?: { user_id?: string }; + metadata?: { user_id?: string; account_uuid?: string; accountId?: string; account_id?: string }; thinkingEnabled?: boolean; reasoning?: Effort; temperature?: number; @@ -422,6 +423,18 @@ describe("Anthropic request fingerprint alignment", () => { expect(isClaudeCloakingUserId(userId)).toBe(true); }); + it("scopes derived Claude device IDs to the account when known", () => { + const installId = "test-install-id"; + const accountId = "12345678-1234-1234-1234-1234567890ab"; + const otherAccountId = "abcdefab-cdef-abcd-efab-cdefabcdef12"; + const deviceId = deriveClaudeDeviceId(installId, accountId); + + expect(deviceId).toMatch(/^[0-9a-f]{64}$/); + expect(deviceId).toBe(deriveClaudeDeviceId(installId, accountId)); + expect(deviceId).not.toBe(deriveClaudeDeviceId(installId, otherAccountId)); + expect(deviceId).not.toBe(deriveClaudeDeviceId(installId)); + }); + it("injects Claude Code JSON metadata.user_id for OAuth requests when missing", async () => { const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { systemPrompt: ["Stay concise."], @@ -453,6 +466,35 @@ describe("Anthropic request fingerprint alignment", () => { expect(firstUserId.device_id).toBe(secondUserId.device_id); }); + it("uses metadata account_uuid when generating OAuth device_id", async () => { + const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; + const accountId = "12345678-1234-1234-1234-1234567890ab"; + const otherAccountId = "abcdefab-cdef-abcd-efab-cdefabcdef12"; + const first = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { metadata: { account_uuid: accountId }, sessionId }, + )) as { metadata?: { user_id?: string } }; + const second = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi again", timestamp: Date.now() }], + }, + { metadata: { account_uuid: otherAccountId }, sessionId }, + )) as { metadata?: { user_id?: string } }; + const firstUserId = JSON.parse(first.metadata?.user_id ?? "{}") as { account_uuid?: string; device_id?: string }; + const secondUserId = JSON.parse(second.metadata?.user_id ?? "{}") as { account_uuid?: string; device_id?: string }; + + expect(firstUserId.account_uuid).toBe(accountId); + expect(secondUserId.account_uuid).toBe(otherAccountId); + expect(firstUserId.device_id).toMatch(/^[0-9a-f]{64}$/); + expect(firstUserId.device_id).not.toBe(secondUserId.device_id); + }); + it("uses the explicit session id for generated OAuth metadata", async () => { const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; const payload = (await captureAnthropicPayload( diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 7c52203c2..69d75c53b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13,7 +13,6 @@ * Modes use this class and add their own I/O layer on top. */ -import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -78,6 +77,7 @@ import type { import { calculateRateLimitBackoffMs, clearAnthropicFastModeFallback, + deriveClaudeDeviceId, Effort, getSupportedEfforts, isContextOverflow, @@ -606,14 +606,10 @@ function buildSessionMetadata( const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId); if (typeof accountUuid === "string" && accountUuid.length > 0) { userId.account_uuid = accountUuid; - // Claude Code's `device_id` is a stable 64-hex install identifier. Use - // omp's persistent install id as the root instead of deriving it from - // `account_uuid`: logging into a different Claude account on the same - // install should not make the device look new. - userId.device_id = crypto - .createHash("sha256") - .update(`omp-claude-device-id-v1:${getInstallId()}`) - .digest("hex"); + // Claude Code's `device_id` is a stable 64-hex account-scoped install + // identifier. Include both omp's persistent install id and the Claude + // account UUID so two accounts on the same install do not share a device. + userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid); } } return { user_id: JSON.stringify(userId) }; @@ -3024,10 +3020,10 @@ export class AgentSession { * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output: * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only * when an Anthropic OAuth credential with a known account UUID is loaded; - * `device_id` is derived from the persistent omp install id. Resolving live - * keeps the value in sync with auth-state changes (login/logout, token - * refresh that surfaces a new account uuid) without needing to re-call - * `#syncAgentSessionId()` on every such event. + * `device_id` is derived from both the persistent omp install id and that + * account UUID. Resolving live keeps the value in sync with auth-state changes + * (login/logout, token refresh that surfaces a new account UUID) without + * needing to re-call `#syncAgentSessionId()` on every such event. */ #syncAgentSessionId(sessionId?: string): void { const sid = this.#activeProviderSessionId(sessionId); From b5582278a0f5a19be6d02d157ba0d390d9799eb9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:13:29 +0200 Subject: [PATCH 41/53] feat(builtins): implemented wait for pids, -f, -n, and -p flags - Added process ID resolution and selectors for waited jobs. - Implemented terminate-only waits and next-job polling. - Recorded completed job IDs into the variable named by -p. - Added shell integration tests covering the new wait paths. --- crates/brush-builtins-vendored/src/wait.rs | 134 ++++++++++++++-- crates/brush-core-vendored/Cargo.toml | 2 + crates/brush-core-vendored/src/jobs.rs | 178 ++++++++++++++++++++- crates/pi-shell/src/shell.rs | 51 ++++++ 4 files changed, 344 insertions(+), 21 deletions(-) diff --git a/crates/brush-builtins-vendored/src/wait.rs b/crates/brush-builtins-vendored/src/wait.rs index 608be5d97..a9acb7e79 100644 --- a/crates/brush-builtins-vendored/src/wait.rs +++ b/crates/brush-builtins-vendored/src/wait.rs @@ -1,6 +1,12 @@ use std::io::Write; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error}; +use brush_core::{ + ExecutionExitCode, ExecutionResult, builtins, + env::{EnvironmentLookup, EnvironmentScope}, + int_utils, + jobs::{Job, JobSelector}, + variables::ShellValueLiteral, +}; use clap::Parser; /// Wait for jobs to terminate. @@ -31,37 +37,74 @@ impl builtins::Command for WaitCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.wait_for_terminate { - return error::unimp("wait -f"); - } - if self.wait_for_first_or_next { - return error::unimp("wait -n"); - } - if self.variable_to_receive_id.is_some() { - return error::unimp("wait -p"); + if let Some(variable) = &self.variable_to_receive_id { + context.shell.env_mut().unset(variable)?; } let mut result = ExecutionResult::success(); + if self.wait_for_first_or_next { + let selectors = match resolve_wait_selectors(&context, &self.ids)? { + WaitSelectorResolution::Selectors(selectors) => selectors, + WaitSelectorResolution::Failure(result) => return Ok(result), + }; + let Some(waited) = context.shell.jobs_mut().wait_next(&selectors).await? else { + return Ok(ExecutionExitCode::NotFound.into()); + }; + if let Some(variable) = &self.variable_to_receive_id { + assign_wait_variable(context.shell, variable, waited.identifier)?; + } + return Ok(waited.result); + } + + let mut waited_identifier = None; if !self.ids.is_empty() { for id in &self.ids { if id.starts_with('%') { // It's a job spec. if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) { - job.wait().await?; + waited_identifier = Some(job_identifier(job)); + result = if self.wait_for_terminate { + job.wait_for_termination().await? + } else { + job.wait().await? + }; } else { writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; result = ExecutionExitCode::GeneralError.into(); } + } else if let Ok(pid) = int_utils::parse::(id, 10) { + if let Some(job) = context.shell.jobs_mut().resolve_process_id(pid) { + waited_identifier = Some(pid.to_string()); + result = if self.wait_for_terminate { + job.wait_for_termination().await? + } else { + job.wait().await? + }; + } else { + writeln!( + context.stderr(), + "{}: pid {pid} is not a child of this shell", + context.command_name + )?; + + result = ExecutionExitCode::NotFound.into(); + } } else { - // It's a process ID. - return error::unimp("wait with process IDs"); + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; + + result = ExecutionExitCode::GeneralError.into(); } } } else { // Wait for all jobs. - let jobs = context.shell.jobs_mut().wait_all().await?; + let jobs = if self.wait_for_terminate { + context.shell.jobs_mut().wait_all_for_termination().await? + } else { + context.shell.jobs_mut().wait_all().await? + }; + waited_identifier = jobs.last().map(job_identifier); if context.shell.options().enable_job_control { for job in jobs { @@ -70,6 +113,71 @@ impl builtins::Command for WaitCommand { } } + if let (Some(variable), Some(identifier)) = (&self.variable_to_receive_id, waited_identifier) { + assign_wait_variable(context.shell, variable, identifier)?; + } + Ok(result) } } + +enum WaitSelectorResolution { + Selectors(Vec), + Failure(ExecutionResult), +} + +fn resolve_wait_selectors( + context: &brush_core::ExecutionContext<'_, SE>, + ids: &[String], +) -> Result { + let mut selectors = Vec::new(); + for id in ids { + if id.starts_with('%') { + if let Some(selector) = context.shell.jobs().resolve_job_spec_selector(id) { + selectors.push(selector); + } else { + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; + return Ok(WaitSelectorResolution::Failure( + ExecutionExitCode::GeneralError.into(), + )); + } + } else if let Ok(pid) = int_utils::parse::(id, 10) { + if context.shell.jobs().contains_process_id(pid) { + selectors.push(JobSelector::ProcessId(pid)); + } else { + writeln!( + context.stderr(), + "{}: pid {pid} is not a child of this shell", + context.command_name + )?; + return Ok(WaitSelectorResolution::Failure(ExecutionExitCode::NotFound.into())); + } + } else { + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?; + return Ok(WaitSelectorResolution::Failure( + ExecutionExitCode::GeneralError.into(), + )); + } + } + Ok(WaitSelectorResolution::Selectors(selectors)) +} + +fn assign_wait_variable( + shell: &mut brush_core::Shell, + name: &str, + value: String, +) -> Result<(), brush_core::Error> { + shell.env_mut().update_or_add( + name, + ShellValueLiteral::Scalar(value), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + ) +} + +fn job_identifier(job: &Job) -> String { + job + .representative_pid() + .map_or_else(|| job.id.to_string(), |pid| pid.to_string()) +} diff --git a/crates/brush-core-vendored/Cargo.toml b/crates/brush-core-vendored/Cargo.toml index e6d064bdd..14a497b40 100644 --- a/crates/brush-core-vendored/Cargo.toml +++ b/crates/brush-core-vendored/Cargo.toml @@ -148,6 +148,7 @@ features = [ "rt-multi-thread", "signal", "sync", + "time", ] [target.'cfg(target_family = "wasm")'.dependencies.tokio] @@ -156,6 +157,7 @@ features = [ "io-util", "macros", "rt", + "time", "sync", ] diff --git a/crates/brush-core-vendored/src/jobs.rs b/crates/brush-core-vendored/src/jobs.rs index 0a575b23c..226650542 100644 --- a/crates/brush-core-vendored/src/jobs.rs +++ b/crates/brush-core-vendored/src/jobs.rs @@ -1,6 +1,6 @@ //! Job management -use std::{collections::VecDeque, fmt::Display}; +use std::{collections::VecDeque, fmt::Display, time::Duration}; #[cfg(windows)] use std::os::windows::io::OwnedHandle; @@ -12,6 +12,35 @@ use crate::{ExecutionResult, error, processes, sys, trace_categories, traps}; pub(crate) type JobJoinHandle = tokio::task::JoinHandle>; pub(crate) type JobResult = (Job, Result); +const WAIT_NEXT_POLL_INTERVAL: Duration = Duration::from_millis(10); + +/// Selects a managed job by shell job ID or child process ID. +#[derive(Clone, Copy)] +pub enum JobSelector { + /// Shell-internal job ID. + JobId(usize), + /// Child process ID. + ProcessId(i32), +} + +/// Result returned when waiting for a single managed job. +pub struct WaitedJob { + /// Shell-internal job ID. + pub id: usize, + /// Process ID when known, otherwise the shell-internal job ID. + pub identifier: String, + /// Command line associated with the job. + pub command_line: String, + /// Exit status returned by the job. + pub result: ExecutionResult, +} + +impl WaitedJob { + fn from_job(job: Job, result: ExecutionResult, identifier: String) -> Self { + Self { id: job.id, identifier, command_line: job.command_line, result } + } +} + /// Manages the jobs that are currently managed by the shell. #[derive(Default)] pub struct JobManager { @@ -42,17 +71,21 @@ impl JobTask { } /// Waits for the task to complete. Returns the result of the wait. - pub async fn wait(&mut self) -> Result { + pub async fn wait( + &mut self, + wait_for_terminate: bool, + ) -> Result { match self { - Self::External(process) => { + Self::External(process) => loop { let wait_result = process.wait(None).await?; match wait_result { processes::ProcessWaitResult::Completed(output) => { - Ok(JobTaskWaitResult::Completed(output.into())) + break Ok(JobTaskWaitResult::Completed(output.into())); }, - processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped), + processes::ProcessWaitResult::Stopped if wait_for_terminate => {}, + processes::ProcessWaitResult::Stopped => break Ok(JobTaskWaitResult::Stopped), processes::ProcessWaitResult::Cancelled => { - Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130))) + break Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130))); }, } }, @@ -162,15 +195,111 @@ impl JobManager { } } + /// Tries to resolve the given job specification to a wait selector. + /// + /// # Arguments + /// + /// * `job_spec` - The job specification to resolve. + pub fn resolve_job_spec_selector(&self, job_spec: &str) -> Option { + let remainder = job_spec.strip_prefix('%')?; + + match remainder { + "%" | "+" => self.current_job().map(|job| JobSelector::JobId(job.id)), + "-" => self.prev_job().map(|job| JobSelector::JobId(job.id)), + s if s.chars().all(char::is_numeric) => { + let id = s.parse::().ok()?; + self + .jobs + .iter() + .any(|job| job.id == id) + .then_some(JobSelector::JobId(id)) + }, + _ => { + tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'"); + None + }, + } + } + + /// Returns whether a managed job contains the given process ID. + pub fn contains_process_id(&self, pid: i32) -> bool { + self.jobs.iter().any(|job| job.contains_process_id(pid)) + } + + + /// Tries to resolve the given process ID to a managed job. + /// + /// # Arguments + /// + /// * `pid` - The process ID to resolve. + pub fn resolve_process_id(&mut self, pid: i32) -> Option<&mut Job> { + self.jobs.iter_mut().find(|job| job.contains_process_id(pid)) + } + /// Waits for all managed jobs to complete. pub async fn wait_all(&mut self) -> Result, error::Error> { + self.wait_all_with_policy(false).await + } + + /// Waits for all managed jobs to terminate, ignoring stopped-state changes. + pub async fn wait_all_for_termination(&mut self) -> Result, error::Error> { + self.wait_all_with_policy(true).await + } + + async fn wait_all_with_policy( + &mut self, + wait_for_terminate: bool, + ) -> Result, error::Error> { for job in &mut self.jobs { - job.wait().await?; + job.wait_with_policy(wait_for_terminate).await?; } Ok(self.sweep_completed_jobs()) } + /// Waits for the next matching managed job to complete. + pub async fn wait_next( + &mut self, + selectors: &[JobSelector], + ) -> Result, error::Error> { + loop { + let mut found_candidate = false; + let mut i = 0; + while i != self.jobs.len() { + if !selectors.is_empty() + && !selectors + .iter() + .any(|selector| self.jobs[i].matches_selector(*selector)) + { + i += 1; + continue; + } + + found_candidate = true; + let identifier = self.jobs[i].wait_identifier(); + if let Some(result) = self.jobs[i].poll_done()? { + let job = self.jobs.remove(i); + return result.map(|result| Some(WaitedJob::from_job(job, result, identifier))); + } + if matches!(self.jobs[i].state, JobState::Done) { + let job = self.jobs.remove(i); + return Ok(Some(WaitedJob::from_job( + job, + ExecutionResult::success(), + identifier, + ))); + } + i += 1; + } + + if !found_candidate { + return Ok(None); + } + + tokio::time::sleep(WAIT_NEXT_POLL_INTERVAL).await; + } + } + /// Polls all managed jobs for completion. pub fn poll(&mut self) -> Result, error::Error> { let mut results = vec![]; @@ -371,10 +500,22 @@ impl Job { /// Waits for the job to complete. pub async fn wait(&mut self) -> Result { + self.wait_with_policy(false).await + } + + /// Waits for the job to terminate, ignoring stopped-state changes. + pub async fn wait_for_termination(&mut self) -> Result { + self.wait_with_policy(true).await + } + + async fn wait_with_policy( + &mut self, + wait_for_terminate: bool, + ) -> Result { let mut result = ExecutionResult::success(); while let Some(task) = self.tasks.back_mut() { - match task.wait().await? { + match task.wait(wait_for_terminate).await? { JobTaskWaitResult::Completed(execution_result) => { result = execution_result; self.tasks.pop_back(); @@ -457,6 +598,27 @@ impl Job { } } + fn matches_selector(&self, selector: JobSelector) -> bool { + match selector { + JobSelector::JobId(id) => self.id == id, + JobSelector::ProcessId(pid) => self.contains_process_id(pid), + } + } + + + fn contains_process_id(&self, pid: i32) -> bool { + self.tasks.iter().any(|task| match task { + JobTask::External(process) => process.pid().is_some_and(|process_pid| process_pid == pid), + JobTask::Internal(_) => false, + }) + } + + fn wait_identifier(&self) -> String { + self + .representative_pid() + .map_or_else(|| self.id.to_string(), |pid| pid.to_string()) + } + /// Tries to retrieve a "representative" pid for the job. pub fn representative_pid(&self) -> Option { for task in &self.tasks { diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 4de375c1c..65071ffa4 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -1918,6 +1918,57 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread")] + async fn wait_accepts_last_background_process_id() { + let options = ShellExecuteOptions { + command: "/bin/sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(), + ..Default::default() + }; + + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + + assert_eq!(result.exit_code, Some(7)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + + #[tokio::test(flavor = "multi_thread")] + async fn wait_n_p_records_completed_process_id() { + let options = ShellExecuteOptions { + command: "/bin/sh -c 'sleep 0.2; exit 42' & slow=$!; /bin/sh -c 'exit 13' & fast=$!; \ + wait -n -p hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" \ + -eq 13 ] && [ \"$hit\" = \"$fast\" ]" + .to_string(), + ..Default::default() + }; + + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + + assert_eq!(result.exit_code, Some(0)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + + #[tokio::test(flavor = "multi_thread")] + async fn wait_f_accepts_process_id() { + let options = ShellExecuteOptions { + command: "/bin/sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(), + ..Default::default() + }; + + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + + assert_eq!(result.exit_code, Some(5)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + #[tokio::test] async fn abort_state_signals_cancel_token() { let abort_state = ShellAbortState::default(); From 3169e30a27436e4a65d425e5fbc1c766851265dc Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:15:19 +0200 Subject: [PATCH 42/53] fix(ai): disabled stream obfuscation for openai-codex requests - Set stream_options.include_obfuscation to false on transformed requests. - Added stream_options to the request body type. --- packages/ai/src/providers/anthropic.ts | 8 +++++++- .../ai/src/providers/openai-codex/request-transformer.ts | 6 ++++++ packages/ai/test/openai-codex-stream.test.ts | 6 +++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 8a007cfde..61979d7b1 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -636,7 +636,13 @@ const CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN = "omp-claude-device-id-v2"; export function deriveClaudeDeviceId(installId: string, accountId?: string): string { const hash = nodeCrypto.createHash("sha256"); if (accountId && accountId.length > 0) { - return hash.update(CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN).update("\0").update(installId).update("\0").update(accountId).digest("hex"); + return hash + .update(CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN) + .update("\0") + .update(installId) + .update("\0") + .update(accountId) + .digest("hex"); } return hash.update(CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN).update(installId).digest("hex"); } diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index 91abe9dc5..df0e8be0d 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -40,6 +40,10 @@ export interface RequestBody { presence_penalty?: number; repetition_penalty?: number; reasoning?: Partial; + stream_options?: { + include_obfuscation?: boolean; + [key: string]: unknown; + }; text?: { verbosity?: "low" | "medium" | "high"; }; @@ -161,6 +165,8 @@ export async function transformRequestBody( ): Promise { body.store = false; body.stream = true; + body.stream_options ??= {}; + body.stream_options.include_obfuscation = false; if (body.input && Array.isArray(body.input)) { body.input = filterInput(body.input); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 3e7bc30f9..73486aa4d 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -435,7 +435,11 @@ describe("openai-codex streaming", () => { expect(outbound).toBeDefined(); expect(outbound.raw[0]).toMatch(/^: ws → /); expect(outbound.data.length).toBeGreaterThan(0); - expect(() => JSON.parse(outbound.data)).not.toThrow(); + const outboundRequest = JSON.parse(outbound.data) as Record; + expect(outboundRequest).toMatchObject({ + type: "response.create", + stream_options: { include_obfuscation: false }, + }); // Inbound frames mirror the Codex response sequence emitted by `emitCodexResponse`. expect(inbound.map(e => e.event)).toEqual([ From 4a85add1f1ff8343171aa2887ae9a0b5c8519a1e Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:25:53 +0200 Subject: [PATCH 43/53] feat(builtins): implemented previously stubbed builtin behaviors - Added fc editor mode, history -n/-r, and mapfile callbacks. - Implemented symbolic umask parsing, unset namerefs, and declare -I. - Rendered builtin man pages and replaced unimp errors with diagnostics. - Added regex/substring tests, nameref unset, and exec-in-subshell paths. --- crates/brush-builtins-vendored/Cargo.lock | 128 +++++-- crates/brush-builtins-vendored/src/cd.rs | 20 +- crates/brush-builtins-vendored/src/colon.rs | 7 +- .../brush-builtins-vendored/src/complete.rs | 58 +++- crates/brush-builtins-vendored/src/declare.rs | 28 +- crates/brush-builtins-vendored/src/enable.rs | 23 +- crates/brush-builtins-vendored/src/exec.rs | 73 +++- crates/brush-builtins-vendored/src/false_.rs | 7 +- crates/brush-builtins-vendored/src/fc.rs | 147 +++++++- crates/brush-builtins-vendored/src/history.rs | 316 +++++++++++++++++- crates/brush-builtins-vendored/src/jobs.rs | 41 ++- crates/brush-builtins-vendored/src/mapfile.rs | 91 +++-- crates/brush-builtins-vendored/src/read.rs | 18 +- crates/brush-builtins-vendored/src/true_.rs | 17 +- crates/brush-builtins-vendored/src/umask.rs | 115 ++++++- crates/brush-builtins-vendored/src/unset.rs | 30 +- crates/brush-core-vendored/src/builtins.rs | 134 +++++++- crates/brush-core-vendored/src/commands.rs | 15 +- crates/brush-core-vendored/src/error.rs | 4 + .../brush-core-vendored/src/extendedtests.rs | 20 +- crates/brush-core-vendored/src/interp.rs | 40 ++- crates/brush-core-vendored/src/jobs.rs | 17 +- crates/brush-core-vendored/src/prompt.rs | 9 +- crates/brush-core-vendored/src/shell/funcs.rs | 20 +- .../src/shell/initscripts.rs | 28 +- crates/brush-core-vendored/src/variables.rs | 60 ++-- 26 files changed, 1273 insertions(+), 193 deletions(-) diff --git a/crates/brush-builtins-vendored/Cargo.lock b/crates/brush-builtins-vendored/Cargo.lock index 4c7c70e9f..b40f9d459 100644 --- a/crates/brush-builtins-vendored/Cargo.lock +++ b/crates/brush-builtins-vendored/Cargo.lock @@ -80,7 +80,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -91,7 +91,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -239,8 +239,6 @@ dependencies = [ [[package]] name = "brush-core" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "967a82f3b1bf090db686d5fb86424b19725073828532a42f960d5a13bff14ecb" dependencies = [ "async-recursion", "async-trait", @@ -269,10 +267,12 @@ dependencies = [ "terminfo", "thiserror", "tokio", + "tokio-util", "tracing", "uuid", "uzers", "whoami", + "windows-sys 0.59.0", ] [[package]] @@ -480,7 +480,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -647,7 +647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1197,7 +1197,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1603,7 +1603,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1737,7 +1737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1802,7 +1802,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1812,7 +1812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1871,7 +1871,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1885,6 +1885,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2237,7 +2250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" dependencies = [ "windows-core 0.51.1", - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -2246,7 +2259,7 @@ version = "0.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -2308,6 +2321,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2323,13 +2345,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -2338,42 +2376,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/brush-builtins-vendored/src/cd.rs b/crates/brush-builtins-vendored/src/cd.rs index bb193724c..b551fb940 100644 --- a/crates/brush-builtins-vendored/src/cd.rs +++ b/crates/brush-builtins-vendored/src/cd.rs @@ -1,6 +1,6 @@ use std::{io::Write, path::PathBuf}; -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; use clap::Parser; /// Change the current shell working directory. @@ -19,11 +19,6 @@ pub(crate) struct CdCommand { #[arg(short = 'e')] exit_on_failed_cwd_resolution: bool, - /// Show file with extended attributes as a dir with extended - /// attributes. - #[arg(short = '@')] - file_with_xattr_as_dir: bool, - /// By default it is the value of the HOME shell variable. If `TARGET_DIR` is /// "-", it is converted to $OLDPWD. target_dir: Option, @@ -36,11 +31,6 @@ impl builtins::Command for CdCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - // TODO(cd): implement 'cd -@' - if self.file_with_xattr_as_dir { - return error::unimp("cd -@"); - } - let mut should_print = false; let mut target_dir = if let Some(target_dir) = &self.target_dir { // `cd -', equivalent to `cd $OLDPWD' @@ -72,11 +62,9 @@ impl builtins::Command for CdCommand { .options() .do_not_resolve_symlinks_when_changing_dir { - // -e is only relevant in physical mode. - if self.exit_on_failed_cwd_resolution { - return error::unimp("cd -e"); - } - + // -e is only relevant in physical mode. `canonicalize()` is the + // cwd-resolution step this implementation supports; failures already + // propagate as a non-zero result before updating PWD/OLDPWD. target_dir = context.shell.absolute_path(target_dir).canonicalize()?; } diff --git a/crates/brush-builtins-vendored/src/colon.rs b/crates/brush-builtins-vendored/src/colon.rs index bcc2289ce..a4cf1f310 100644 --- a/crates/brush-builtins-vendored/src/colon.rs +++ b/crates/brush-builtins-vendored/src/colon.rs @@ -1,4 +1,4 @@ -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; /// No-op command. pub(crate) struct ColonCommand {} @@ -13,7 +13,10 @@ impl builtins::SimpleCommand for ColonCommand { builtins::ContentType::DetailedHelp => Ok("Null command; always returns success.".into()), builtins::ContentType::ShortUsage => Ok(":: :".into()), builtins::ContentType::ShortDescription => Ok(": - Null command".into()), - builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + builtins::ContentType::ManPage => Ok( + "NAME\n : - Null command.\n\nSYNOPSIS\n :\n\nDESCRIPTION\n Null command.\n\n No effect; the command does nothing.\n\n Exit Status:\n Always succeeds.\n" + .into(), + ), } } diff --git a/crates/brush-builtins-vendored/src/complete.rs b/crates/brush-builtins-vendored/src/complete.rs index a2b2c7417..c837fded5 100644 --- a/crates/brush-builtins-vendored/src/complete.rs +++ b/crates/brush-builtins-vendored/src/complete.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, fmt::Write as _, io::Write}; use brush_core::{ ExecutionExitCode, ExecutionResult, builtins, completion::{self, CompleteAction, CompleteOption, Spec}, - error, escape, + escape, }; use clap::Parser; @@ -188,6 +188,30 @@ impl CommonCompleteCommandArgs { actions } + + fn has_completion_spec(&self) -> bool { + !self.options.is_empty() + || !self.actions.is_empty() + || self.glob_pattern.is_some() + || self.word_list.is_some() + || self.function_name.is_some() + || self.command.is_some() + || self.filter_pattern.is_some() + || self.prefix.is_some() + || self.suffix.is_some() + || self.action_alias + || self.action_builtin + || self.action_command + || self.action_directory + || self.action_exported + || self.action_file + || self.action_group + || self.action_job + || self.action_keyword + || self.action_service + || self.action_user + || self.action_variable + } } /// Configure programmable command completion. @@ -234,7 +258,7 @@ impl builtins::Command for CompleteCommand { || self.use_for_initial_word || self.names.is_empty() { - self.process_global(&mut context)?; + result = self.process_global(&mut context)?; } else { for name in &self.names { if !self.try_process_for_command(&mut context, name.as_str())? { @@ -251,7 +275,7 @@ impl CompleteCommand { fn process_global( &self, context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, - ) -> Result<(), brush_core::Error> { + ) -> Result { // Read options before taking mutable borrow on completion_config let extended_globbing = context.shell.options().extended_globbing; @@ -272,13 +296,20 @@ impl CompleteCommand { }; // Treat 'complete' with no options the same as 'complete -p'. - if self.print || (!self.remove && target_spec.is_none()) { + if self.print + || (!self.remove && target_spec.is_none() && !self.common_args.has_completion_spec()) + { if let Some(target_spec) = target_spec { if let Some(existing_spec) = target_spec { let existing_spec = existing_spec.clone(); Self::display_spec(context, Some(special_option_name), None, &existing_spec)?; } else { - return error::unimp("special spec not found"); + writeln!( + context.stderr(), + "complete: {}: no completion specification", + Self::special_spec_name(special_option_name) + )?; + return Ok(ExecutionResult::general_error()); } } else { for (command_name, spec) in context.shell.completion_config().iter() { @@ -297,11 +328,22 @@ impl CompleteCommand { let mut new_spec = Some(self.common_args.create_spec(extended_globbing)); std::mem::swap(&mut new_spec, target_spec); } else { - return error::unimp("set unspecified spec"); + debug_assert!(self.common_args.has_completion_spec()); + writeln!(context.stderr(), "complete: invalid usage")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); } } - Ok(()) + Ok(ExecutionResult::success()) + } + + fn special_spec_name(option_name: &str) -> &'static str { + match option_name { + "-D" => "_DefaultCmD_", + "-E" => "_EmptycmD_", + "-I" => "_InitialWorD_", + _ => "", + } } fn try_display_spec_for_command( @@ -510,7 +552,7 @@ impl builtins::Command for CompGenCommand { } }, completion::Answer::RestartCompletionProcess => { - return error::unimp("restart completion"); + return Ok(ExecutionResult::general_error()); }, } diff --git a/crates/brush-builtins-vendored/src/declare.rs b/crates/brush-builtins-vendored/src/declare.rs index 48123684b..6e425f66d 100644 --- a/crates/brush-builtins-vendored/src/declare.rs +++ b/crates/brush-builtins-vendored/src/declare.rs @@ -3,7 +3,6 @@ use std::{io::Write, sync::LazyLock}; use brush_core::{ ErrorKind, ExecutionResult, builtins, env::{self, EnvironmentLookup, EnvironmentScope}, - error, parser::ast, variables::{ self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable, @@ -132,10 +131,6 @@ impl builtins::Command for DeclareCommand { return Ok(ExecutionResult::general_error()); } - if self.locals_inherit_from_prev_scope { - return error::unimp("declare -I"); - } - let mut result = ExecutionResult::success(); if !self.declarations.is_empty() { for declaration in &self.declarations { @@ -303,7 +298,28 @@ impl DeclareCommand { ShellValueUnsetType::Untyped }; - let mut var = ShellVariable::new(ShellValue::Unset(unset_type)); + let mut var = if create_var_local + && (self.locals_inherit_from_prev_scope + || context.shell.options().local_vars_inherit_value_and_attrs) + { + if let Some(prev_var) = context + .shell + .env() + .get_using_policy(name.as_str(), EnvironmentLookup::Anywhere) + { + if prev_var.is_readonly() { + return Err(ErrorKind::ReadonlyVariable.into()); + } + + let mut var = prev_var.clone(); + var.unset_treat_as_nameref(); + var + } else { + ShellVariable::new(ShellValue::Unset(unset_type)) + } + } else { + ShellVariable::new(ShellValue::Unset(unset_type)) + }; self.apply_attributes_before_update(&mut var)?; diff --git a/crates/brush-builtins-vendored/src/enable.rs b/crates/brush-builtins-vendored/src/enable.rs index 8a6da9d54..e584ee76d 100644 --- a/crates/brush-builtins-vendored/src/enable.rs +++ b/crates/brush-builtins-vendored/src/enable.rs @@ -1,6 +1,6 @@ use std::io::Write; -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; use clap::Parser; use itertools::Itertools; @@ -44,15 +44,26 @@ impl builtins::Command for EnableCommand { ) -> Result { let mut result = ExecutionResult::success(); - if self.shared_object_path.is_some() { - return error::unimp("enable -f"); - } - if self.remove_loaded_builtin { - return error::unimp("enable -d"); + if let Some(shared_object_path) = &self.shared_object_path { + writeln!( + context.stderr(), + "{}: cannot open shared object {shared_object_path}: dynamic loading is not supported", + context.command_name + )?; + return Ok(ExecutionResult::general_error()); } if !self.names.is_empty() { for name in &self.names { + if self.remove_loaded_builtin { + if context.shell.builtins().contains_key(name) { + writeln!(context.stderr(), "{name}: not dynamically loaded")?; + } else { + writeln!(context.stderr(), "{name}: not a shell builtin")?; + } + result = ExecutionResult::general_error(); + continue; + } if let Some(builtin) = context.shell.builtin_mut(name) { builtin.disabled = self.disable; } else { diff --git a/crates/brush-builtins-vendored/src/exec.rs b/crates/brush-builtins-vendored/src/exec.rs index ed32ce4e5..cb6d35cc8 100644 --- a/crates/brush-builtins-vendored/src/exec.rs +++ b/crates/brush-builtins-vendored/src/exec.rs @@ -1,4 +1,7 @@ -use std::{borrow::Cow, os::unix::process::CommandExt}; +use std::{ + borrow::Cow, + os::unix::process::{CommandExt, ExitStatusExt}, +}; use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands}; use clap::Parser; @@ -47,7 +50,7 @@ impl builtins::Command for ExecCommand { // expectation of returning. if context.shell.is_subshell() { if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() { - return brush_core::error::unimp("exec with options in subshell not yet supported"); + return self.execute_external_in_subshell(context).await; } let cmd_cmd = crate::command::CommandCommand { @@ -58,16 +61,12 @@ impl builtins::Command for ExecCommand { return cmd_cmd.execute(context).await; } - let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0])); - - if self.exec_as_login { - argv0 = Cow::Owned(std::format!("-{argv0}")); - } + let argv0 = self.argv0(); let mut cmd = commands::compose_std_command( &context, &self.args[0], - argv0.as_str(), + argv0.as_ref(), &self.args[1..], self.empty_environment, )?; @@ -81,3 +80,61 @@ impl builtins::Command for ExecCommand { } } } + +impl ExecCommand { + fn argv0(&self) -> Cow<'_, str> { + let argv0 = self + .name_for_argv0 + .as_deref() + .unwrap_or_else(|| self.args[0].as_str()); + + if self.exec_as_login { + Cow::Owned(std::format!("-{argv0}")) + } else { + Cow::Borrowed(argv0) + } + } + + async fn execute_external_in_subshell( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let argv0 = self.argv0(); + let cmd = commands::compose_std_command( + &context, + &self.args[0], + argv0.as_ref(), + &self.args[1..], + self.empty_environment, + )?; + + let mut cmd = tokio::process::Command::from(cmd); + cmd.kill_on_drop(true); + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(spawn_err) => { + if spawn_err.kind() == std::io::ErrorKind::NotFound { + return Ok(ExecutionExitCode::NotFound.into()); + } + + return Err(ErrorKind::from(spawn_err).into()); + }, + }; + + let status = child.wait().await?; + + if let Some(code) = status.code() { + #[expect(clippy::cast_sign_loss)] + return Ok(ExecutionResult::new((code & 0xff) as u8)); + } + + if let Some(signal) = status.signal() { + #[expect(clippy::cast_sign_loss)] + return Ok(ExecutionResult::new((signal & 0xff) as u8 + 128)); + } + + tracing::error!("unhandled process exit"); + Ok(ExecutionExitCode::NotFound.into()) + } +} diff --git a/crates/brush-builtins-vendored/src/false_.rs b/crates/brush-builtins-vendored/src/false_.rs index 375fc1d02..8a5184ec2 100644 --- a/crates/brush-builtins-vendored/src/false_.rs +++ b/crates/brush-builtins-vendored/src/false_.rs @@ -1,4 +1,4 @@ -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; /// Return exit code 1. pub(crate) struct FalseCommand {} @@ -13,7 +13,10 @@ impl builtins::SimpleCommand for FalseCommand { builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()), builtins::ContentType::ShortUsage => Ok("false".into()), builtins::ContentType::ShortDescription => Ok("false - fail".into()), - builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + builtins::ContentType::ManPage => Ok( + "NAME\n false - Return an unsuccessful result.\n\nSYNOPSIS\n false\n\nDESCRIPTION\n Return an unsuccessful result.\n\n Exit Status:\n Always fails.\n\nSEE ALSO\n bash(1)\n" + .into(), + ), } } diff --git a/crates/brush-builtins-vendored/src/fc.rs b/crates/brush-builtins-vendored/src/fc.rs index 3cb209f91..f6483c160 100644 --- a/crates/brush-builtins-vendored/src/fc.rs +++ b/crates/brush-builtins-vendored/src/fc.rs @@ -1,4 +1,8 @@ -use std::io::Write; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; use brush_core::{ExecutionResult, builtins, error, history}; use clap::Parser; @@ -50,7 +54,7 @@ impl builtins::Command for FcCommand { return self.do_list(&context); } - error::unimp("fc editor mode is not yet implemented") + self.do_edit(context).await } } @@ -88,6 +92,96 @@ impl FcCommand { Ok(ExecutionResult::success()) } + async fn do_edit( + &self, + context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let history = context + .shell + .history() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + + let (first_idx, last_idx, reverse) = self.resolve_range(history)?; + let mut commands = String::new(); + let indices: Vec = if reverse { + (first_idx..=last_idx).rev().collect() + } else { + (first_idx..=last_idx).collect() + }; + + for idx in indices { + let item = history + .get(idx) + .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?; + commands.push_str(&item.command_line); + commands.push('\n'); + } + + let editor = self.editor_name(&context); + if editor.as_deref() != Some("-") { + let temp_file = FcTempFile::create()?; + fs::write(temp_file.path(), commands)?; + + let edit_cmd = format!( + "{} {}", + editor.as_deref().unwrap_or("vi"), + shell_quote_path(temp_file.path()) + ); + let source_info = brush_core::SourceInfo::from("(fc editor)"); + let edit_result = context + .shell + .run_string(edit_cmd, &source_info, &context.params) + .await?; + if !edit_result.is_success() { + return Ok(edit_result); + } + + commands = fs::read_to_string(temp_file.path())?; + } + + let history_mut = context + .shell + .history_mut() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + history_mut.remove_nth_item(history_mut.count().saturating_sub(1)); + + if commands.trim().is_empty() { + return Ok(ExecutionResult::success()); + } + + let source_info = brush_core::SourceInfo::from("(history)"); + let result = context + .shell + .run_string(commands.clone(), &source_info, &context.params) + .await?; + context.shell.add_to_history(commands.trim_end())?; + + Ok(result) + } + + fn editor_name( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Option { + if let Some(editor) = self.editor.as_ref().filter(|value| !value.is_empty()) { + return Some(editor.clone()); + } + + context + .shell + .env() + .get_str("FCEDIT", context.shell) + .filter(|value| !value.is_empty()) + .or_else(|| { + context + .shell + .env() + .get_str("EDITOR", context.shell) + .filter(|value| !value.is_empty()) + }) + .map(|value| value.into_owned()) + } + async fn do_execute( &self, context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, @@ -291,6 +385,55 @@ impl FcCommand { } } +struct FcTempFile { + path: PathBuf, +} + +impl FcTempFile { + fn create() -> Result { + let temp_dir = std::env::temp_dir(); + let process_id = std::process::id(); + + for attempt in 0_u32..100 { + let path = temp_dir.join(format!("brush-fc-{process_id}-{attempt}.sh")); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(_) => return Ok(Self { path }), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}, + Err(err) => return Err(err.into()), + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create a unique fc temporary file", + ) + .into()) + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for FcTempFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn shell_quote_path(path: &Path) -> String { + let mut quoted = String::from("'"); + for ch in path.to_string_lossy().chars() { + if ch == '\'' { + quoted.push_str("'\\''"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted +} + /// Returns the effective history count (excluding the fc command itself). fn effective_history_count(history: &history::History) -> usize { history.count().saturating_sub(1) diff --git a/crates/brush-builtins-vendored/src/history.rs b/crates/brush-builtins-vendored/src/history.rs index ce13134fe..e4aad4611 100644 --- a/crates/brush-builtins-vendored/src/history.rs +++ b/crates/brush-builtins-vendored/src/history.rs @@ -1,6 +1,6 @@ -use std::{io::Write, path::PathBuf}; +use std::{fs::File, io::Write, path::PathBuf}; -use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history}; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, history}; use clap::Parser; /// Query or manipulate the shell's command history. @@ -135,12 +135,24 @@ impl HistoryCommand { return Ok(ExecutionResult::success()); } - if self.append_rest_of_file_to_session.is_some() { - return error::unimp("history -n is not yet implemented"); + if let Some(read_option) = &self.append_rest_of_file_to_session { + if let Some(file_path) = + get_effective_history_file_path(config.default_history_file_path, read_option.as_ref()) + { + append_history_file_to_session(history, file_path, HistoryReadMode::Unread)?; + } + + return Ok(ExecutionResult::success()); } - if self.append_file_to_session.is_some() { - return error::unimp("history -r is not yet implemented"); + if let Some(read_option) = &self.append_file_to_session { + if let Some(file_path) = + get_effective_history_file_path(config.default_history_file_path, read_option.as_ref()) + { + append_history_file_to_session(history, file_path, HistoryReadMode::All)?; + } + + return Ok(ExecutionResult::success()); } if let Some(write_option) = &self.write_session_to_file { @@ -158,8 +170,8 @@ impl HistoryCommand { return Ok(ExecutionResult::success()); } - if self.expand_args.is_some() { - return error::unimp("history -p is not yet implemented"); + if let Some(args) = &self.expand_args { + return expand_history_args(history, args, stdout, stderr); } if let Some(args) = &self.append_args_to_session { @@ -179,6 +191,219 @@ impl HistoryCommand { } } +fn expand_history_args( + history: &history::History, + args: &[String], + mut stdout: impl Write, + mut stderr: impl Write, +) -> Result { + let mut result = ExecutionResult::success(); + + for arg in args { + match expand_history_arg(history, arg) { + Ok(expanded) => { + writeln!(stdout, "{expanded}")?; + }, + Err(()) => { + writeln!(stderr, "history: {arg}: history expansion failed")?; + result = ExecutionResult::general_error(); + }, + } + } + + Ok(result) +} + +fn expand_history_arg(history: &history::History, arg: &str) -> Result { + let chars: Vec = arg.chars().collect(); + let mut expanded = String::new(); + let mut i = 0; + + while i < chars.len() { + if chars[i] != '!' { + expanded.push(chars[i]); + i += 1; + continue; + } + + i += 1; + if i == chars.len() { + expanded.push('!'); + break; + } + + let event = match chars[i] { + '!' => { + i += 1; + latest_history_event(history)? + }, + '#' => { + i += 1; + let current_line = expanded.clone(); + expanded.push_str(¤t_line); + continue; + }, + ':' => latest_history_event(history)?, + '$' | '^' | '*' => { + let event = latest_history_event(history)?; + let selected = select_history_words(&event, chars[i], None)?; + i += 1; + expanded.push_str(&selected); + continue; + }, + '-' => { + i += 1; + let (offset, next_i) = parse_history_number(&chars, i).ok_or(())?; + i = next_i; + relative_history_event(history, offset)? + }, + '?' => { + i += 1; + let start = i; + while i < chars.len() && chars[i] != '?' { + i += 1; + } + let needle: String = chars[start..i].iter().collect(); + if i < chars.len() && chars[i] == '?' { + i += 1; + } + find_history_event(history, &needle, HistorySearchMode::Contains)? + }, + c if c.is_ascii_digit() => { + let (number, next_i) = parse_history_number(&chars, i).ok_or(())?; + i = next_i; + numbered_history_event(history, number)? + }, + c if is_history_event_char(c) => { + let start = i; + while i < chars.len() && is_history_event_char(chars[i]) { + i += 1; + } + let prefix: String = chars[start..i].iter().collect(); + find_history_event(history, &prefix, HistorySearchMode::Prefix)? + }, + _ => { + expanded.push('!'); + continue; + }, + }; + + if i < chars.len() && chars[i] == ':' { + i += 1; + if i == chars.len() { + return Err(()); + } + let selector = chars[i]; + i += 1; + let number = if selector.is_ascii_digit() { + let (number, next_i) = parse_history_number_from_first(&chars, i - 1); + i = next_i; + Some(number) + } else { + None + }; + let selected = select_history_words(&event, selector, number)?; + expanded.push_str(&selected); + } else { + expanded.push_str(&event); + } + } + + Ok(expanded) +} + +fn latest_history_event(history: &history::History) -> Result { + history + .iter() + .last() + .map(|item| item.command_line.clone()) + .ok_or(()) +} + +fn numbered_history_event(history: &history::History, number: usize) -> Result { + if number == 0 { + return Err(()); + } + + history + .get(number - 1) + .map(|item| item.command_line.clone()) + .ok_or(()) +} + +fn relative_history_event(history: &history::History, offset: usize) -> Result { + let count = history.count(); + if offset == 0 || offset > count { + return Err(()); + } + + numbered_history_event(history, count - offset + 1) +} + +enum HistorySearchMode { + Prefix, + Contains, +} + +fn find_history_event( + history: &history::History, + needle: &str, + mode: HistorySearchMode, +) -> Result { + let mut match_result = None; + for item in history.iter() { + let matches = match mode { + HistorySearchMode::Prefix => item.command_line.starts_with(needle), + HistorySearchMode::Contains => item.command_line.contains(needle), + }; + if matches { + match_result = Some(item.command_line.clone()); + } + } + + match_result.ok_or(()) +} + +fn select_history_words( + event: &str, + selector: char, + number: Option, +) -> Result { + let words: Vec<&str> = event.split_whitespace().collect(); + match selector { + '0'..='9' => { + let index = number.ok_or(())?; + words.get(index).map(|word| (*word).to_owned()).ok_or(()) + }, + '^' => words.get(1).map(|word| (*word).to_owned()).ok_or(()), + '$' => words.last().map(|word| (*word).to_owned()).ok_or(()), + '*' => Ok(words.get(1..).unwrap_or_default().join(" ")), + 'p' => Ok(event.to_owned()), + _ => Err(()), + } +} + +fn parse_history_number(chars: &[char], i: usize) -> Option<(usize, usize)> { + if i == chars.len() || !chars[i].is_ascii_digit() { + return None; + } + + Some(parse_history_number_from_first(chars, i)) +} + +fn parse_history_number_from_first(chars: &[char], mut i: usize) -> (usize, usize) { + let mut value = 0; + while i < chars.len() && chars[i].is_ascii_digit() { + value = value * 10 + chars[i].to_digit(10).unwrap_or_default() as usize; + i += 1; + } + (value, i) +} + +fn is_history_event_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/') +} + fn display_history( history: &history::History, config: &HistoryConfig, @@ -213,6 +438,30 @@ fn display_history( Ok(()) } +enum HistoryReadMode { + All, + Unread, +} + +fn append_history_file_to_session( + history: &mut history::History, + file_path: PathBuf, + mode: HistoryReadMode, +) -> Result<(), brush_core::Error> { + let file = File::open(file_path)?; + let imported_history = history::History::import(file)?; + let already_read_count = match mode { + HistoryReadMode::All => 0, + HistoryReadMode::Unread => history.iter().filter(|item| !item.dirty).count(), + }; + + for item in imported_history.iter().skip(already_read_count) { + history.add(item.clone())?; + } + + Ok(()) +} + fn get_effective_history_file_path( default_history_file_path: Option, option: Option<&String>, @@ -222,6 +471,12 @@ fn get_effective_history_file_path( #[cfg(test)] mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use anyhow::Result; use pretty_assertions::{assert_eq, assert_matches}; @@ -240,4 +495,49 @@ mod tests { Ok(()) } + + #[test] + fn test_append_history_file_to_session_reads_all_entries() -> Result<()> { + let file_path = write_temp_history("history-r", "one\ntwo\n")?; + let mut history = history::History::default(); + history.add(history::Item::new("local"))?; + + append_history_file_to_session(&mut history, file_path.clone(), HistoryReadMode::All)?; + + assert_eq!(history.count(), 3); + assert_eq!(history.get(0).map(|item| item.command_line.as_str()), Some("local")); + assert_eq!(history.get(1).map(|item| item.command_line.as_str()), Some("one")); + assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("two")); + assert_eq!(history.get(1).map(|item| item.dirty), Some(false)); + fs::remove_file(file_path)?; + + Ok(()) + } + + #[test] + fn test_append_history_file_to_session_reads_unread_entries_after_clean_history() -> Result<()> { + let initial_file_path = write_temp_history("history-n-initial", "one\ntwo\n")?; + let mut history = history::History::import(fs::File::open(&initial_file_path)?)?; + history.add(history::Item::new("local"))?; + + let updated_file_path = write_temp_history("history-n-updated", "one\ntwo\nthree\n")?; + append_history_file_to_session(&mut history, updated_file_path.clone(), HistoryReadMode::Unread)?; + + assert_eq!(history.count(), 4); + assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("local")); + assert_eq!(history.get(3).map(|item| item.command_line.as_str()), Some("three")); + assert_eq!(history.get(3).map(|item| item.dirty), Some(false)); + fs::remove_file(initial_file_path)?; + fs::remove_file(updated_file_path)?; + + Ok(()) + } + + fn write_temp_history(name: &str, contents: &str) -> Result { + let mut path = std::env::temp_dir(); + let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + path.push(format!("brush-{name}-{nanos}.history")); + fs::write(&path, contents)?; + Ok(path) + } } diff --git a/crates/brush-builtins-vendored/src/jobs.rs b/crates/brush-builtins-vendored/src/jobs.rs index e4dd52c76..0366a1970 100644 --- a/crates/brush-builtins-vendored/src/jobs.rs +++ b/crates/brush-builtins-vendored/src/jobs.rs @@ -1,6 +1,6 @@ use std::io::Write; -use brush_core::{ExecutionResult, builtins, error, jobs}; +use brush_core::{ExecutionResult, builtins, jobs}; use clap::Parser; /// Manage jobs. @@ -38,22 +38,31 @@ impl builtins::Command for JobsCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.also_show_pids { - return error::unimp("jobs -l"); - } if self.list_changed_only { - return error::unimp("jobs -n"); + for (job, result) in context.shell.jobs_mut().poll()? { + result?; + self.display_job(&context, &job)?; + } + return Ok(ExecutionResult::success()); } + let mut exit_code = ExecutionResult::success(); if self.job_specs.is_empty() { for job in &context.shell.jobs().jobs { self.display_job(&context, job)?; } } else { - return error::unimp("jobs with job specs"); + for job_spec in &self.job_specs { + if let Some(job) = resolve_job_spec(context.shell.jobs(), job_spec) { + self.display_job(&context, job)?; + } else { + writeln!(context.stderr(), "{}: no such job: {}", context.command_name, job_spec)?; + exit_code = ExecutionResult::general_error(); + } + } } - Ok(ExecutionResult::success()) + Ok(exit_code) } } @@ -74,6 +83,14 @@ impl JobsCommand { if let Some(pid) = job.representative_pid() { writeln!(context.stdout(), "{pid}")?; } + } else if self.also_show_pids { + write!(context.stdout(), "[{}]{:3}", job.id, job.annotation())?; + if let Some(pid) = job.representative_pid() { + write!(context.stdout(), "{pid}\t")?; + } else { + write!(context.stdout(), "\t")?; + } + writeln!(context.stdout(), "{}\t{}", job.state, job.command_line)?; } else { writeln!(context.stdout(), "{job}")?; } @@ -81,3 +98,13 @@ impl JobsCommand { Ok(()) } } + +fn resolve_job_spec<'a>(job_manager: &'a jobs::JobManager, job_spec: &str) -> Option<&'a jobs::Job> { + match job_manager.resolve_job_spec_selector(job_spec)? { + jobs::JobSelector::JobId(id) => job_manager.jobs.iter().find(|job| job.id == id), + jobs::JobSelector::ProcessId(pid) => job_manager + .jobs + .iter() + .find(|job| job.representative_pid().is_some_and(|job_pid| job_pid == pid)), + } +} diff --git a/crates/brush-builtins-vendored/src/mapfile.rs b/crates/brush-builtins-vendored/src/mapfile.rs index a896d9a63..779f60104 100644 --- a/crates/brush-builtins-vendored/src/mapfile.rs +++ b/crates/brush-builtins-vendored/src/mapfile.rs @@ -1,6 +1,6 @@ use std::io::{Read, Write}; -use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables}; +use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, escape, variables}; use clap::Parser; /// Read lines from standard input into an indexed array variable. @@ -48,11 +48,8 @@ impl builtins::Command for MapFileCommand { async fn execute( &self, - context: brush_core::ExecutionContext<'_, SE>, + mut context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.callback_group_size != 5000 || self.callback.is_some() { - return error::unimp("mapfile -C/-c is not yet implemented"); - } if let Some(origin) = self.origin { if origin < 0 { @@ -81,49 +78,39 @@ impl builtins::Command for MapFileCommand { .try_fd(self.fd) .ok_or_else(|| ErrorKind::BadFileDescriptor(self.fd))?; - // Read! - let results = self.read_entries(input_file)?; - - if let Some(origin) = self.origin { - // -O: preserve existing array, assign at offset. - for (elem_idx, (_key, value)) in results.0.into_iter().enumerate() { - // If the user is getting to wraparounds in *bash*, they got bigger problems. - #[allow(clippy::cast_possible_wrap)] - let elem_idx = elem_idx as i64; - context.shell.env_mut().update_or_add_array_element( - &self.array_var_name, - (elem_idx + origin).to_string(), - value, - |_| Ok(()), - env::EnvironmentLookup::Anywhere, - env::EnvironmentScope::Global, - )?; - } - } else { - // No -O: replace the entire variable (clears existing). + // Read and assign entries. When no origin is specified, bash clears the + // target array before reading; callbacks then see earlier assigned entries + // but not the entry that is currently being delivered to the callback. + if self.origin.is_none() { context.shell.env_mut().update_or_add( &self.array_var_name, - variables::ShellValueLiteral::Array(results), + variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![])), |_| Ok(()), env::EnvironmentLookup::Anywhere, env::EnvironmentScope::Global, )?; } + if let Some(result) = self.read_entries(input_file, &mut context).await? { + return Ok(result); + } + Ok(ExecutionResult::success()) } } impl MapFileCommand { - fn read_entries( + async fn read_entries( &self, mut input_file: brush_core::openfiles::OpenFile, - ) -> Result { + context: &mut brush_core::ExecutionContext<'_, SE>, + ) -> Result, brush_core::Error> { let _term_mode = setup_terminal_settings(&input_file)?; - let mut entries = vec![]; + let mut entry_count = 0usize; let mut read_count = 0; let max_count = self.max_count.try_into()?; + let callback_group_size: usize = self.callback_group_size.try_into()?; let delimiter = match &self.delimiter { Some(d) if d.is_empty() => b'\0', Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'), @@ -132,7 +119,7 @@ impl MapFileCommand { let mut buf = [0u8; 1]; - while max_count == 0 || entries.len() < max_count { + while max_count == 0 || entry_count < max_count { let mut line = vec![]; let mut saw_delimiter = false; @@ -168,14 +155,54 @@ impl MapFileCommand { } let line_str = String::from_utf8_lossy(&line).to_string(); + let array_index = self.origin.unwrap_or(0) + i64::try_from(entry_count)?; - entries.push((None, line_str)); + if let Some(callback) = &self.callback + && (entry_count + 1) % callback_group_size == 0 + { + let result = run_callback(callback, array_index, &line_str, context).await?; + if !result.is_normal_flow() { + return Ok(Some(result)); + } + } + + context.shell.env_mut().update_or_add_array_element( + &self.array_var_name, + array_index.to_string(), + line_str, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + entry_count += 1; } - Ok(variables::ArrayLiteral(entries)) + Ok(None) } } +async fn run_callback( + callback: &str, + array_index: i64, + line: &str, + context: &mut brush_core::ExecutionContext<'_, SE>, +) -> Result { + let index_arg = array_index.to_string(); + let index_arg = escape::quote_if_needed(&index_arg, escape::QuoteMode::SingleQuote); + let line_arg = escape::quote_if_needed(line, escape::QuoteMode::SingleQuote); + + let mut command = String::with_capacity(callback.len() + index_arg.len() + line_arg.len() + 2); + command.push_str(callback); + command.push(' '); + command.push_str(index_arg.as_ref()); + command.push(' '); + command.push_str(line_arg.as_ref()); + + let source_info = context.shell.call_stack().current_pos_as_source_info(); + context.shell.run_string(command, &source_info, &context.params).await +} + fn setup_terminal_settings( file: &brush_core::openfiles::OpenFile, ) -> Result, brush_core::Error> { diff --git a/crates/brush-builtins-vendored/src/read.rs b/crates/brush-builtins-vendored/src/read.rs index cfce2c1b0..f31128553 100644 --- a/crates/brush-builtins-vendored/src/read.rs +++ b/crates/brush-builtins-vendored/src/read.rs @@ -84,12 +84,6 @@ impl builtins::Command for ReadCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - if self.use_readline { - return error::unimp("read -e"); - } - if self.initial_text.is_some() { - return error::unimp("read -i"); - } // Validate timeout value if provided. if let Some(result) = self.validate_timeout(&context)? { @@ -105,6 +99,17 @@ impl builtins::Command for ReadCommand { let input_stream = context .try_fd(fd_num) .ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?; + // Bash only uses readline for `read -e` when reading from a terminal. + // For non-terminal input, `-e` and `-i` are accepted but do not affect + // the bytes read. `-i` only supplies initial text to readline, so without + // an available readline-backed terminal path it is likewise a no-op. + if self.use_readline && input_stream.is_terminal() { + return error::unimp(if self.initial_text.is_some() { + "read -e -i" + } else { + "read -e" + }); + } // Retrieve effective value of IFS for splitting. // We convert to owned String to release the borrow before the mutable borrow @@ -272,6 +277,7 @@ fn build_variable_fields( /// /// This enum clearly represents all possible outcomes of `read_line()`, /// making the contract with callers explicit. +#[derive(Debug)] enum ReadResult { /// Successfully read a complete line (delimiter or char limit reached). Line(String), diff --git a/crates/brush-builtins-vendored/src/true_.rs b/crates/brush-builtins-vendored/src/true_.rs index f75cd8d59..739b80779 100644 --- a/crates/brush-builtins-vendored/src/true_.rs +++ b/crates/brush-builtins-vendored/src/true_.rs @@ -1,8 +1,21 @@ -use brush_core::{ExecutionResult, builtins, error}; +use brush_core::{ExecutionResult, builtins}; /// No-op command. Same with :. pub(crate) struct TrueCommand {} +const MAN_PAGE: &str = "\ +TRUE(1) + +NAME + true - return a successful result + +SYNOPSIS + true + +DESCRIPTION + The true utility returns a successful exit status. +"; + impl builtins::SimpleCommand for TrueCommand { fn get_content( _name: &str, @@ -13,7 +26,7 @@ impl builtins::SimpleCommand for TrueCommand { builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()), builtins::ContentType::ShortUsage => Ok("true".into()), builtins::ContentType::ShortDescription => Ok("true - success".into()), - builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + builtins::ContentType::ManPage => Ok(MAN_PAGE.into()), } } diff --git a/crates/brush-builtins-vendored/src/umask.rs b/crates/brush-builtins-vendored/src/umask.rs index 76fb9298d..dff7552ae 100644 --- a/crates/brush-builtins-vendored/src/umask.rs +++ b/crates/brush-builtins-vendored/src/umask.rs @@ -33,7 +33,9 @@ impl builtins::Command for UmaskCommand { let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?; set_umask(parsed)?; } else { - return brush_core::error::unimp("umask setting mode from symbolic value"); + let current_umask = get_umask()?; + let parsed = parse_symbolic_umask(mode, current_umask)?; + set_umask(parsed)?; } } else { let umask = get_umask()?; @@ -74,6 +76,81 @@ cfg_if! { } } +fn parse_symbolic_umask(mode: &str, current_umask: u32) -> Result { + let mut umask = current_umask & 0o777; + let mut chars = mode.chars().peekable(); + let mut saw_clause = false; + + while chars.peek().is_some() { + saw_clause = true; + + let mut who_bits = 0; + while let Some(&ch) = chars.peek() { + let bits = match ch { + 'u' => 0o700, + 'g' => 0o070, + 'o' => 0o007, + 'a' => 0o777, + _ => break, + }; + who_bits |= bits; + chars.next(); + } + if who_bits == 0 { + who_bits = 0o777; + } + + loop { + let op = chars.next().ok_or(ErrorKind::InvalidUmask)?; + if !matches!(op, '+' | '-' | '=') { + return Err(ErrorKind::InvalidUmask.into()); + } + + let mut perm_bits = 0; + while let Some(&ch) = chars.peek() { + let bits = match ch { + 'r' => 0o444, + 'w' => 0o222, + 'x' => 0o111, + '+' | '-' | '=' | ',' => break, + _ => return Err(ErrorKind::InvalidUmask.into()), + }; + perm_bits |= bits & who_bits; + chars.next(); + } + + match op { + '+' => umask &= !perm_bits, + '-' => umask |= perm_bits, + '=' => { + umask |= who_bits; + umask &= !perm_bits; + } + _ => unreachable!(), + } + + match chars.peek() { + Some(',') => { + chars.next(); + if chars.peek().is_none() { + return Err(ErrorKind::InvalidUmask.into()); + } + break; + } + Some('+' | '-' | '=') => continue, + Some(_) => return Err(ErrorKind::InvalidUmask.into()), + None => break, + } + } + } + + if saw_clause { + Ok(umask as nix::sys::stat::mode_t) + } else { + Err(ErrorKind::InvalidUmask.into()) + } +} + fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> { // value of mode_t can be platform dependent let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?; @@ -96,3 +173,39 @@ fn symbolic_mask_from_bits(bits: u32) -> String { result } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(mode: &str, current_umask: u32) -> u32 { + parse_symbolic_umask(mode, current_umask).unwrap() as u32 + } + + #[test] + fn parses_symbolic_umask_assignments() { + assert_eq!(parse("u=rwx,g=rx,o=", 0o022), 0o027); + assert_eq!(parse("=r", 0o022), 0o333); + assert_eq!(parse("a=", 0o022), 0o777); + assert_eq!(parse("u=", 0o022), 0o722); + } + + #[test] + fn parses_symbolic_umask_incremental_ops() { + assert_eq!(parse("u+rw", 0o777), 0o177); + assert_eq!(parse("g-w", 0o022), 0o022); + assert_eq!(parse("+x", 0o022), 0o022); + assert_eq!(parse("u+r-w", 0o777), 0o377); + assert_eq!(parse("a+r,u-w", 0o777), 0o333); + } + + #[test] + fn rejects_invalid_symbolic_umasks() { + assert!(parse_symbolic_umask("", 0o022).is_err()); + assert!(parse_symbolic_umask("u", 0o022).is_err()); + assert!(parse_symbolic_umask("u+z", 0o022).is_err()); + assert!(parse_symbolic_umask("z+r", 0o022).is_err()); + assert!(parse_symbolic_umask("u=,", 0o022).is_err()); + assert!(parse_symbolic_umask("u,,g=r", 0o022).is_err()); + } +} diff --git a/crates/brush-builtins-vendored/src/unset.rs b/crates/brush-builtins-vendored/src/unset.rs index 38e952fe3..465022416 100644 --- a/crates/brush-builtins-vendored/src/unset.rs +++ b/crates/brush-builtins-vendored/src/unset.rs @@ -42,11 +42,12 @@ impl builtins::Command for UnsetCommand { &self, context: brush_core::ExecutionContext<'_, SE>, ) -> Result { - // - // TODO(nameref): implement nameref - // if self.name_interpretation.name_references { - return brush_core::error::unimp("unset: name references are not yet implemented"); + for name in &self.names { + unset_name_reference(context.shell, name)?; + } + + return Ok(ExecutionResult::success()); } let unspecified = self.name_interpretation.unspecified(); @@ -91,6 +92,27 @@ impl builtins::Command for UnsetCommand { Ok(ExecutionResult::success()) } } +fn unset_name_reference( + shell: &mut Shell, + name: &str, +) -> Result { + let Ok(brush_parser::word::Parameter::Named(name)) = + brush_parser::word::parse_parameter(name, &shell.parser_options()) + else { + return Ok(false); + }; + + if shell + .env() + .get(name.as_str()) + .is_some_and(|(_, var)| var.is_treated_as_nameref()) + { + shell.env_mut().unset(name.as_str()).map(|v| v.is_some()) + } else { + Ok(false) + } +} + fn unset_array_index( shell: &mut Shell, diff --git a/crates/brush-core-vendored/src/builtins.rs b/crates/brush-core-vendored/src/builtins.rs index 0dcf698da..602bc82cd 100644 --- a/crates/brush-core-vendored/src/builtins.rs +++ b/crates/brush-core-vendored/src/builtins.rs @@ -174,8 +174,138 @@ impl Registration { } } -fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result { - error::unimp("man page rendering is not yet implemented") +fn get_builtin_man_page(name: &str, command: &clap::Command) -> Result { + let mut man_page = String::new(); + + append_man_section(&mut man_page, "NAME"); + let description = command + .get_about() + .map_or_else(String::new, std::string::ToString::to_string); + if description.is_empty() { + man_page.push_str(name); + man_page.push('\n'); + } else { + man_page.push_str(name); + man_page.push_str(" - "); + man_page.push_str(&description); + man_page.push('\n'); + } + + append_man_section(&mut man_page, "SYNOPSIS"); + let mut usage_command = command.clone(); + let usage = usage_command.render_usage(); + man_page.push_str(&usage.to_string()); + man_page.push('\n'); + + if let Some(about) = command.get_long_about().or_else(|| command.get_about()) { + append_man_section(&mut man_page, "DESCRIPTION"); + man_page.push_str(&about.to_string()); + man_page.push('\n'); + } + + append_man_arguments_section(&mut man_page, command); + append_man_options_section(&mut man_page, command); + + Ok(man_page) +} + +fn append_man_section(buf: &mut String, title: &str) { + if !buf.is_empty() { + buf.push('\n'); + } + + buf.push_str(title); + buf.push('\n'); +} + +fn append_man_arguments_section(buf: &mut String, command: &clap::Command) { + let mut section_written = false; + for arg in command.get_positionals() { + if arg.is_hide_set() { + continue; + } + + if !section_written { + append_man_section(buf, "ARGUMENTS"); + section_written = true; + } + + write_man_arg_help(buf, &format_man_value_names(arg), arg); + } +} + +fn append_man_options_section(buf: &mut String, command: &clap::Command) { + let mut section_written = false; + for arg in command.get_opts() { + if arg.is_hide_set() { + continue; + } + + if !section_written { + append_man_section(buf, "OPTIONS"); + section_written = true; + } + + write_man_arg_help(buf, &format_man_option(arg), arg); + } +} + +fn write_man_arg_help(buf: &mut String, label: &str, arg: &clap::Arg) { + buf.push_str(" "); + buf.push_str(label); + buf.push('\n'); + if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) { + buf.push_str(" "); + buf.push_str(&help.to_string()); + buf.push('\n'); + } +} + +fn format_man_option(arg: &clap::Arg) -> String { + let mut option = String::new(); + if let Some(short) = arg.get_short() { + option.push('-'); + option.push(short); + } + + if let Some(long) = arg.get_long() { + if !option.is_empty() { + option.push_str(", "); + } + + option.push_str("--"); + option.push_str(long); + } + + let values = format_man_value_names(arg); + if !values.is_empty() { + if !option.is_empty() { + option.push(' '); + } + + option.push_str(&values); + } + + option +} + +fn format_man_value_names(arg: &clap::Arg) -> String { + let Some(value_names) = arg.get_value_names() else { + return String::new(); + }; + + let mut values = String::new(); + for name in value_names { + if !values.is_empty() { + values.push(' '); + } + + values.push('<'); + values.push_str(name); + values.push('>'); + } + + values } fn get_builtin_short_description(name: &str, command: &clap::Command) -> String { diff --git a/crates/brush-core-vendored/src/commands.rs b/crates/brush-core-vendored/src/commands.rs index 0cdf8d406..eef2c3244 100644 --- a/crates/brush-core-vendored/src/commands.rs +++ b/crates/brush-core-vendored/src/commands.rs @@ -814,8 +814,19 @@ pub(crate) async fn invoke_shell_function( // Handle control-flow. match result.next_control_flow { - ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => { - return error::unimp("break or continue returned from function invocation"); + ExecutionControlFlow::BreakLoop { .. } => { + writeln!( + context.params.stderr(context.shell), + "break: only meaningful in a `for', `while', or `until' loop" + )?; + result.next_control_flow = ExecutionControlFlow::Normal; + }, + ExecutionControlFlow::ContinueLoop { .. } => { + writeln!( + context.params.stderr(context.shell), + "continue: only meaningful in a `for', `while', or `until' loop" + )?; + result.next_control_flow = ExecutionControlFlow::Normal; }, ExecutionControlFlow::ReturnFromFunctionOrScript => { // It's now been handled. diff --git a/crates/brush-core-vendored/src/error.rs b/crates/brush-core-vendored/src/error.rs index 67b9da42b..9b673d0d9 100644 --- a/crates/brush-core-vendored/src/error.rs +++ b/crates/brush-core-vendored/src/error.rs @@ -29,6 +29,10 @@ pub enum ErrorKind { #[error("cannot assign list to array member")] AssigningListToArrayMember, + /// An attempt was made to assign an associative array value without using a subscript. + #[error("must use subscript when assigning associative array")] + AssociativeArrayMissingSubscript, + /// An attempt was made to convert an associative array to an indexed array. #[error("cannot convert associative array to indexed array")] ConvertingAssociativeArrayToIndexedArray, diff --git a/crates/brush-core-vendored/src/extendedtests.rs b/crates/brush-core-vendored/src/extendedtests.rs index 965397f2e..ec42d1c49 100644 --- a/crates/brush-core-vendored/src/extendedtests.rs +++ b/crates/brush-core-vendored/src/extendedtests.rs @@ -4,7 +4,7 @@ use brush_parser::ast; use crate::{ ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, extensions, - namedoptions, patterns, + namedoptions, patterns, regex, sys::{ fs::{MetadataExt, PathExt}, users, @@ -160,7 +160,13 @@ pub(crate) fn apply_unary_predicate_to_str( Ok(md.gid() == users::get_effective_gid()?) }, ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => { - error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead") + let path = shell.absolute_path(Path::new(operand)); + if !path.exists() { + return Ok(false); + } + + let md = path.metadata()?; + Ok(md.modified()? > md.accessed()?) }, ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => { let path = shell.absolute_path(Path::new(operand)); @@ -527,7 +533,15 @@ pub(crate) fn apply_binary_predicate_to_strs( }, ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right), ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right), - _ => error::unimp("unsupported test binary predicate"), + ast::BinaryPredicate::StringContainsSubstring => Ok(left.contains(right)), + ast::BinaryPredicate::StringMatchesRegex => { + let re = regex::compile_regex( + right.to_owned(), + shell.options().case_insensitive_conditionals, + true, + )?; + Ok(re.is_match(left)?) + }, } } diff --git a/crates/brush-core-vendored/src/interp.rs b/crates/brush-core-vendored/src/interp.rs index 408182333..5741f789b 100644 --- a/crates/brush-core-vendored/src/interp.rs +++ b/crates/brush-core-vendored/src/interp.rs @@ -1766,7 +1766,7 @@ async fn apply_assignment( existing_value.assign_at_index(array_index, s, assignment.append)?; }, ShellValueLiteral::Array(_) => { - return error::unimp("replacing an array item with an array"); + return Err(error::ErrorKind::AssigningListToArrayMember.into()); }, } } else { @@ -1796,7 +1796,7 @@ async fn apply_assignment( ShellValue::indexed_array_from_literals(ArrayLiteral(vec![(Some(array_index), s)])) }, ShellValueLiteral::Array(_) => { - return error::unimp("cannot assign list to array member"); + return Err(error::ErrorKind::AssigningListToArrayMember.into()); }, } } else { @@ -1917,7 +1917,10 @@ pub(crate) async fn setup_redirect( ast::IoFileRedirectKind::DuplicateInput => 0, ast::IoFileRedirectKind::DuplicateOutput => 1, _ => { - return error::unimp("unexpected redirect kind"); + return Err(error::ErrorKind::InternalError(format!( + "unexpected redirect kind for file descriptor target: {kind:?}" + )) + .into()); }, }; @@ -1937,7 +1940,10 @@ pub(crate) async fn setup_redirect( ast::IoFileRedirectKind::DuplicateInput => 0, ast::IoFileRedirectKind::DuplicateOutput => 1, _ => { - return error::unimp("unexpected redirect kind"); + return Err(error::ErrorKind::InternalError(format!( + "unexpected redirect kind for duplicate target: {kind:?}" + )) + .into()); }, }; @@ -2008,7 +2014,12 @@ pub(crate) async fn setup_redirect( params.open_files.set_fd(fd_num, target_file); }, - _ => return error::unimp("invalid process substitution"), + _ => { + return Err(error::ErrorKind::InternalError(format!( + "process substitution used with invalid redirect kind: {kind:?}" + )) + .into()); + }, } }, } @@ -2113,6 +2124,16 @@ fn setup_process_substitution( let mut child_params = params.clone(); child_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + // Starting at 63 (a.k.a. 64-1)--and decrementing--look for an + // available fd before starting the substitution command. + let mut candidate_fd_num = 63; + while params.open_files.contains_fd(candidate_fd_num) { + candidate_fd_num -= 1; + if candidate_fd_num == 0 { + return Err(error::ErrorKind::TooManyOpenFiles.into()); + } + } + // Set up pipe so we can connect to the command. let (reader, writer) = std::io::pipe()?; let (reader, writer) = (reader.into(), writer.into()); @@ -2139,15 +2160,6 @@ fn setup_process_substitution( .await; }); - // Starting at 63 (a.k.a. 64-1)--and decrementing--look for an - // available fd. - let mut candidate_fd_num = 63; - while params.open_files.contains_fd(candidate_fd_num) { - candidate_fd_num -= 1; - if candidate_fd_num == 0 { - return error::unimp("no available file descriptors"); - } - } Ok((candidate_fd_num, target_file)) } diff --git a/crates/brush-core-vendored/src/jobs.rs b/crates/brush-core-vendored/src/jobs.rs index 226650542..bc81dccab 100644 --- a/crates/brush-core-vendored/src/jobs.rs +++ b/crates/brush-core-vendored/src/jobs.rs @@ -226,7 +226,6 @@ impl JobManager { self.jobs.iter().any(|job| job.contains_process_id(pid)) } - /// Tries to resolve the given process ID to a managed job. /// /// # Arguments @@ -534,16 +533,17 @@ impl Job { /// Moves the job to execute in the background. pub fn move_to_background(&mut self) -> Result<(), error::Error> { - if matches!(self.state, JobState::Stopped) { - if let Some(pgid) = self.process_group_id() { + match &self.state { + JobState::Stopped => { + let pgid = self + .process_group_id() + .ok_or(error::ErrorKind::FailedToSendSignal)?; sys::signal::continue_process(pgid)?; self.state = JobState::Running; Ok(()) - } else { - Err(error::ErrorKind::FailedToSendSignal.into()) - } - } else { - error::unimp("move job to background") + }, + JobState::Running => Ok(()), + JobState::Unknown | JobState::Done => Err(error::ErrorKind::FailedToSendSignal.into()), } } @@ -605,7 +605,6 @@ impl Job { } } - fn contains_process_id(&self, pid: i32) -> bool { self.tasks.iter().any(|task| match task { JobTask::External(process) => process.pid().is_some_and(|process_pid| process_pid == pid), diff --git a/crates/brush-core-vendored/src/prompt.rs b/crates/brush-core-vendored/src/prompt.rs index 8dff6972d..06d4f32a2 100644 --- a/crates/brush-core-vendored/src/prompt.rs +++ b/crates/brush-core-vendored/src/prompt.rs @@ -71,7 +71,7 @@ fn format_prompt_piece( return error::unimp("prompt: current command number"); }, brush_parser::prompt::PromptPiece::CurrentHistoryNumber => { - return error::unimp("prompt: current history number"); + format_current_history_number(shell) }, brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?, brush_parser::prompt::PromptPiece::CurrentWorkingDirectory { tilde_replaced, basename } => { @@ -171,6 +171,13 @@ fn format_current_working_directory( working_dir_str } +fn format_current_history_number(shell: &Shell) -> String { + // Bash renders \! as the history number that will be assigned to the next + // interactive command. When command history is disabled, bash keeps this at + // 1 rather than rendering 0. + shell.history().map_or(1, |history| history.count() + 1).to_string() +} + fn format_time( datetime: &chrono::DateTime, format: &brush_parser::prompt::PromptTimeFormat, diff --git a/crates/brush-core-vendored/src/shell/funcs.rs b/crates/brush-core-vendored/src/shell/funcs.rs index 063199bd6..5f23b438b 100644 --- a/crates/brush-core-vendored/src/shell/funcs.rs +++ b/crates/brush-core-vendored/src/shell/funcs.rs @@ -1,7 +1,10 @@ //! Function support for shells. +use std::io::Write; + use crate::{ - ExecutionParameters, commands, error, extensions, functions, results::ExecutionWaitResult, + ExecutionParameters, commands, error, extensions, functions, jobs, + results::{ExecutionResult, ExecutionWaitResult}, }; impl crate::Shell { @@ -117,7 +120,20 @@ impl crate::Shell { match result.wait_with_cancel(params.cancel_token()).await? { ExecutionWaitResult::Completed(result) => Ok(result.exit_code.into()), - ExecutionWaitResult::Stopped(..) => error::unimp("stopped child from function invocation"), + ExecutionWaitResult::Stopped(child) => { + let result = ExecutionResult::stopped(); + let job = self.jobs_mut().add_as_current(jobs::Job::new( + [jobs::JobTask::External(child)], + name.to_owned(), + jobs::JobState::Stopped, + )); + let formatted = job.to_string(); + + // N.B. We use the '\r' to overwrite any ^Z output. + writeln!(params.stderr(self), "\r{formatted}")?; + + Ok(result.exit_code.into()) + }, } } } diff --git a/crates/brush-core-vendored/src/shell/initscripts.rs b/crates/brush-core-vendored/src/shell/initscripts.rs index a30e16d21..31f26fd5b 100644 --- a/crates/brush-core-vendored/src/shell/initscripts.rs +++ b/crates/brush-core-vendored/src/shell/initscripts.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; -use crate::{Shell, error, extensions, interp}; +use crate::{Shell, error, expansion, extensions, interp}; /// Behavior for loading profile files. #[derive(Default)] @@ -130,14 +130,24 @@ impl Shell { "BASH_ENV" }; - if self.env.is_set(env_var_name) { - // - // TODO(well-known-vars): look at $ENV/BASH_ENV; source its expansion if that - // file exists - // - return error::unimp( - "load config from $ENV/BASH_ENV for non-interactive, non-login shell", - ); + if let Some(config_path) = self.env_str(env_var_name) { + let config_path = config_path.into_owned(); + let options = expansion::ExpanderOptions { + brace_expand: false, + pathname_expand: false, + ..Default::default() + }; + let expanded_path = expansion::basic_expand_word_with_options( + self, + ¶ms, + config_path.as_str(), + &options, + ) + .await?; + + if !expanded_path.is_empty() { + self.source_if_exists(PathBuf::from(expanded_path), ¶ms).await?; + } } } } diff --git a/crates/brush-core-vendored/src/variables.rs b/crates/brush-core-vendored/src/variables.rs index a89c2409b..ea839f89a 100644 --- a/crates/brush-core-vendored/src/variables.rs +++ b/crates/brush-core-vendored/src/variables.rs @@ -437,10 +437,8 @@ impl ShellVariable { } Ok(()) }, - _ => { - tracing::error!("assigning to index {array_index} of {:?}", self.value); - error::unimp("assigning to index of non-array variable") - }, + ShellValue::Dynamic { .. } => Ok(()), + ShellValue::Unset(_) | ShellValue::String(_) => Err(error::ErrorKind::NotArray.into()), } } @@ -800,23 +798,45 @@ impl ShellValue { existing_values: &mut BTreeMap, literal_values: ArrayLiteral, ) -> Result<(), error::Error> { - let mut current_key = None; - for (key, value) in literal_values.0 { - if let Some(current_key) = current_key.take() { - if key.is_some() { - return error::unimp("misaligned keys/values in associative array literal"); - } else { - existing_values.insert(current_key, value); - } - } else if let Some(key) = key { - existing_values.insert(key, value); - } else { - current_key = Some(value); - } - } + let mut literal_values = literal_values.0.into_iter(); + let Some((first_key, first_value)) = literal_values.next() else { + return Ok(()); + }; - if let Some(current_key) = current_key { - existing_values.insert(current_key, String::new()); + if let Some(first_key) = first_key { + existing_values.insert(first_key, first_value); + + for (key, value) in literal_values { + let Some(key) = key else { + return Err(error::ErrorKind::AssociativeArrayMissingSubscript.into()); + }; + + existing_values.insert(key, value); + } + } else { + let mut current_key = Some(first_value); + for (key, value) in literal_values { + let value = if let Some(key) = key { + let mut word = String::with_capacity(key.len() + value.len() + 3); + word.push('['); + word.push_str(key.as_str()); + word.push_str("]="); + word.push_str(value.as_str()); + word + } else { + value + }; + + if let Some(key) = current_key.take() { + existing_values.insert(key, value); + } else { + current_key = Some(value); + } + } + + if let Some(current_key) = current_key { + existing_values.insert(current_key, String::new()); + } } Ok(()) From 347f857f0388f46505b6ecaf21fa5155614e4ed2 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:39:30 +0200 Subject: [PATCH 44/53] feat(eval): raised per-cell timeout ceiling from 600s to 3600s - Matched the `bash` tool's max in the Zod schema and runtime clamp. - Allowed heavy local-compute cells to request budgets above 10 minutes. - Updated docs and prompt to reflect the new 1-3600s range. --- docs/python-repl.md | 4 ++-- packages/coding-agent/CHANGELOG.md | 3 +++ packages/coding-agent/src/prompts/tools/eval.md | 2 +- packages/coding-agent/src/tools/eval.ts | 2 +- packages/coding-agent/src/tools/tool-timeouts.ts | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/python-repl.md b/docs/python-repl.md index 40f246a8c..710321f35 100644 --- a/docs/python-repl.md +++ b/docs/python-repl.md @@ -27,7 +27,7 @@ Tool params: language: "py" | "js"; code: string; title?: string; - timeout?: number; // seconds, clamped to 1..600, default 30. Inactivity budget — see "Cell timeout". + timeout?: number; // seconds, clamped to 1..3600, default 30. Inactivity budget — see "Cell timeout". reset?: boolean; // reset this cell's selected runtime before execution }>; } @@ -232,7 +232,7 @@ Output is streamed through `OutputSink` and may be persisted to artifact storage - **Python backend not available** — Check `eval.py`, `PI_PY`, and that `python`/`python3` is on PATH. If preflight fails and `eval.js` is enabled, use a `js` cell. - **No Python on PATH** — Install a system Python 3.8+ or place a venv at `~/.omp/python-env`. `omp setup python --check` reports the resolved interpreter. -- **Execution hangs then times out** — Increase tool `timeout` (max 600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request. +- **Execution hangs then times out** — Increase tool `timeout` (max 3600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request. - **stdin/input prompts in Python code** — `input()` is not supported; pass data programmatically. - **Working directory errors** — Tool validates `cwd` exists and is a directory before execution. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 631be0244..633154853 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,9 @@ - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded - Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. - Browser tool helpers (`tab.*`) are now individually tracked and time-bounded: when a `run` cell hits its budget, the timeout error names the still-running helper(s) and how long each has been stalled (e.g. `... (stalled on tab.screenshot({ selector: ".x" }) (29.9s))`) instead of the opaque `Browser code execution timed out after 30000ms`. Page-coupled helpers that should resolve quickly (`observe`, `screenshot`, `extract`) also fail fast with a named per-op error at `min(cellBudget, 20s)`, leaving budget for the rest of the cell, rather than silently consuming the whole budget. +- Changed provider tie-breaking to rank G0I with hosted aggregator providers, after OpenRouter and before Together. +- Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. +- Raised the `eval` tool's per-cell `timeout` ceiling from 600s to 3600s (matching `bash`), in both the Zod schema and the `TOOL_TIMEOUTS.eval` runtime clamp, so heavy local-compute cells can request budgets above 10 minutes. ### Removed diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index 2c82ebde9..cbd818631 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -8,7 +8,7 @@ Cell fields: - `language` — {{#if py}}`"py"` for the IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` for the persistent JavaScript VM{{/if}}. - `code` — cell body, verbatim. Newlines, quotes, and indentation are JSON-encoded; no fences, no headers. - `title` (optional) — short label shown in the transcript (e.g. `"imports"`, `"load config"`). -- `timeout` (optional) — per-cell wall-clock budget in seconds (1-600). Default 30. It bounds the cell's **own** work, but is paused while an `agent()`/`parallel()`/`completion()` call is in flight — so a long fanout or a slow completion runs to completion, while the cell itself is still bounded. Compute, `print`/stdout, `log()`/`phase()`, and ordinary tool calls all count against the budget; raise `timeout` for a cell that does heavy local work or long non-agent tool calls. +- `timeout` (optional) — per-cell wall-clock budget in seconds (1-3600). Default 30. It bounds the cell's **own** work, but is paused while an `agent()`/`parallel()`/`completion()` call is in flight — so a long fanout or a slow completion runs to completion, while the cell itself is still bounded. Compute, `print`/stdout, `log()`/`phase()`, and ordinary tool calls all count against the budget; raise `timeout` for a cell that does heavy local work or long non-agent tool calls. - `reset` (optional) — wipe this cell's language kernel before running.{{#ifAll py js}} Reset is per-language: a `py` cell's reset does not touch the JavaScript VM and vice versa.{{/ifAll}} **Work incrementally:** diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index 5f0faf0ef..67abe9e5e 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -30,7 +30,7 @@ const evalCellSchema = z.object({ language: z.enum(["py", "js"]).describe('runtime: "py" for the IPython kernel, "js" for the persistent JS VM'), code: z.string().describe("cell body, verbatim. Use top-level await freely."), title: z.string().optional().describe('short label shown in transcript (e.g. "imports", "load config")'), - timeout: z.number().int().min(1).max(600).optional().describe("per-cell timeout in seconds (1-600, default 30)"), + timeout: z.number().int().min(1).max(3600).optional().describe("per-cell timeout in seconds (1-3600, default 30)"), reset: z .boolean() .optional() diff --git a/packages/coding-agent/src/tools/tool-timeouts.ts b/packages/coding-agent/src/tools/tool-timeouts.ts index cbd6ddc54..8d5342b93 100644 --- a/packages/coding-agent/src/tools/tool-timeouts.ts +++ b/packages/coding-agent/src/tools/tool-timeouts.ts @@ -9,7 +9,7 @@ export interface ToolTimeoutConfig { export const TOOL_TIMEOUTS = { bash: { default: 300, min: 1, max: 3600 }, - eval: { default: 30, min: 1, max: 600 }, + eval: { default: 30, min: 1, max: 3600 }, browser: { default: 30, min: 1, max: 300 }, ssh: { default: 60, min: 1, max: 3600 }, fetch: { default: 20, min: 1, max: 45 }, From 5ba7180d488803be6d5d6871994cb8b949bd722a Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:48:42 +0200 Subject: [PATCH 45/53] feat(pi-shell): added nohup builtin that doesn't mask SIGHUP - Ran the operand as an ordinary descendant so it is reaped with the host instead of leaking as an orphan. - Propagated the command's exit status; reported missing operand and exit 125 with no operand. - Updated the bash tool prompt's daemon guidance away from nohup/setsid/disown detachment. - Switched shell renderer success icon/state to "done". --- crates/pi-shell/src/shell.rs | 137 ++++++++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tools/bash.ts | 4 +- packages/natives/CHANGELOG.md | 1 + 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 65071ffa4..15117353d 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -490,6 +490,7 @@ async fn create_session(config: &ShellConfig) -> Result { } shell.register_builtin("sleep", builtins::builtin::()); shell.register_builtin("timeout", builtins::builtin::()); + shell.register_builtin("nohup", builtins::builtin::()); let mut merged_path: Option = None; for (key, value) in std::env::vars() { @@ -1573,6 +1574,61 @@ impl builtins::Command for TimeoutCommand { } } } + +#[derive(Parser)] +#[command(disable_help_flag = true)] +struct NohupCommand { + #[arg(num_args = 0.., trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, +} + +impl builtins::Command for NohupCommand { + type Error = brush_core::Error; + + fn execute( + &self, + context: ExecutionContext<'_, SE>, + ) -> impl Future> + Send { + let command = self.command.clone(); + async move { + if context.is_cancelled() { + return Ok(ExecutionExitCode::Interrupted.into()); + } + // coreutils `nohup` with no operand fails with exit code 125. + if command.is_empty() { + let _ = writeln!(context.stderr(), "nohup: missing operand"); + return Ok(ExecutionResult::new(125)); + } + + // Deliberately *not* nohup: we neither ignore SIGHUP nor detach the + // child into a new session. The command runs as an ordinary brush + // descendant so it is reaped together with the host instead of + // lingering as an orphan once the host process goes away. Agents + // reach for `nohup` assuming the shell is one-shot; in this + // persistent embedded shell that assumption is wrong and the only + // effect of real `nohup` would be to leak background processes. + // + // coreutils `nohup` additionally redirects stdin from /dev/null and + // stdout/stderr to `nohup.out`, but *only* when those streams are + // terminals. The embedded host always hands commands a pipe with a + // /dev/null stdin, so none of that redirection ever applies here. + let mut command_line = String::new(); + for (idx, arg) in command.iter().enumerate() { + if idx > 0 { + command_line.push(' '); + } + command_line.push_str("e_arg(arg)); + } + + let params = context.params.clone(); + let source_info = SourceInfo::from("pi-natives:nohup"); + context + .shell + .run_string(command_line, &source_info, ¶ms) + .await + } + } +} fn parse_duration(input: &str) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { @@ -2180,4 +2236,85 @@ mod tests { assert_eq!(result.exit_code, Some(0), "command did not run to completion"); } + + /// The `nohup` builtin runs its operand command and surfaces that command's + /// own exit status — not nohup's (`125`/`126`/`127`) error codes. + #[tokio::test(flavor = "multi_thread")] + async fn nohup_builtin_propagates_command_exit_code() { + let options = ShellExecuteOptions { + command: "nohup /bin/sh -c 'exit 7'".to_string(), + ..Default::default() + }; + let result = execute_shell(options, None, CancelToken::default()) + .await + .expect("execute should succeed"); + assert_eq!(result.exit_code, Some(7)); + assert!(!result.cancelled); + assert!(!result.timed_out); + } + + /// `nohup` with no operand mirrors coreutils: a `missing operand` diagnostic + /// and exit code 125 (a nohup-level error, distinct from any command code). + #[tokio::test(flavor = "multi_thread")] + async fn nohup_builtin_without_command_reports_missing_operand() { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let options = ShellExecuteOptions { command: "nohup".to_string(), ..Default::default() }; + let result = execute_shell(options, Some(tx), CancelToken::default()) + .await + .expect("execute should succeed"); + assert_eq!(result.exit_code, Some(125)); + let mut out = String::new(); + while let Some(chunk) = rx.recv().await { + out.push_str(&chunk); + } + assert!( + out.contains("missing operand"), + "expected a missing-operand diagnostic, got: {out:?}" + ); + } + + /// The contract that makes this a *builtin* and not the external tool: the + /// child must **not** inherit `SIGHUP = SIG_IGN`. Real `nohup` masks SIGHUP + /// (and it survives `exec`), so a process launched through `/usr/bin/nohup` + /// reports `IGN` here; the builtin runs the command as an ordinary + /// descendant, so it reports `DFL` and dies with the host on hangup. The + /// probe needs `getsid`-style signal introspection, so it is gated on + /// `python3` (skipped, not failed, when absent — matching the embedded + /// session-detach e2e suite). + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn nohup_builtin_does_not_mask_sighup() { + let python_ok = std::process::Command::new("python3") + .arg("-c") + .arg("pass") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if !python_ok { + eprintln!("skipping nohup_builtin_does_not_mask_sighup: python3 unavailable"); + return; + } + + let probe = "import signal,sys; sys.stdout.write('IGN' if \ + signal.getsignal(signal.SIGHUP)==signal.SIG_IGN else 'DFL')"; + let (tx, mut rx) = mpsc::unbounded_channel::(); + let options = ShellExecuteOptions { + command: format!("nohup python3 -c \"{probe}\""), + ..Default::default() + }; + let result = execute_shell(options, Some(tx), CancelToken::default()) + .await + .expect("execute should succeed"); + assert_eq!(result.exit_code, Some(0)); + let mut out = String::new(); + while let Some(chunk) = rx.recv().await { + out.push_str(&chunk); + } + assert!( + out.contains("DFL") && !out.contains("IGN"), + "builtin nohup masked SIGHUP like the external tool (output: {out:?})", + ); + } } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 633154853..86670ed72 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Added a built-in `ts-no-test-timers` rule that flags real timers (`Bun.sleep`, `setTimeout`, `setInterval`) in `*.test.ts` files, steering toward fake timers (`vi.useFakeTimers()` / `vi.advanceTimersByTime()`) - Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references - Added pixel dimensions to pasted/loaded image placeholders in the prompt — the marker now reads `[Image #N, WxH]` (falling back to `[Image #N]` when the header can't be decoded). +- The bundled shell now treats `nohup` as a builtin: `nohup … &` runs the command without masking `SIGHUP` or detaching it, so agent-started daemons stay tied to this agent's lifetime instead of leaking as orphans when the agent exits. Updated the bash tool prompt's daemon guidance to match (dropped the `nohup … & / setsid … & / disown` detach recommendation in favor of a large `timeout` plus the persistent session). ### Changed diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index b07f3b829..3c7b46a31 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1151,7 +1151,7 @@ export function createShellRenderer(config: ShellRendererConfig) { const renderArgs = toBashRenderArgs(args, config); const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined; const isError = result.isError === true; - const icon = options.isPartial ? "pending" : isError ? "error" : "success"; + const icon = options.isPartial ? "pending" : isError ? "error" : "done"; const header = config.showHeader === false ? undefined @@ -1249,7 +1249,7 @@ export function createShellRenderer(config: ShellRendererConfig) { return outputBlock.render( { header, - state: options.isPartial ? "pending" : isError ? "error" : "success", + state: options.isPartial ? "pending" : isError ? "error" : "done", sections: [ { lines: options.isPartial diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 64fc2dea5..60bc9cb4d 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added the `enclosingBlockBoundaries` native API (with `EnclosingBoundaryOptions` and `LineRange` types) that returns, for a set of visible line ranges, the off-window boundary lines of every multi-line tree-sitter node whose span crosses the window — the closer when an opener is shown and the opener when a closer is shown. Covers brace and indentation languages (Python) via real syntactic spans; returns `null` for unrecognized languages or sources with syntax errors so callers can fall back to a lexical scan. +- Added a `nohup` shell builtin to the embedded `pi_shell`, shadowing the external `/usr/bin/nohup`. It runs its operand command and propagates that command's exit status (and reports `missing operand` / exit 125 with no operand), but deliberately does **not** mask `SIGHUP` or detach the child into a new session the way real `nohup` does. Agents reach for `nohup … &` assuming the shell is one-shot; in this persistent embedded shell that assumption is wrong and the only effect of real `nohup` was to leak background processes that outlived the host. The builtin keeps such commands as ordinary descendants so they are reaped with the host instead of surviving as orphans. ## [15.10.2] - 2026-06-08 From bda3102451a89b66048b97581cf7492c86138e98 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 18:17:31 +0200 Subject: [PATCH 46/53] ux(coding-agent): updated status glyphs and fixed extension model discovery refresh - Added status.done and tool.* symbols to theme mappings and presets. - Replaced generic success glyphs with contextual +/-, tool icons, and warnings. - Mapped tool/task/job completions to status.done or status.enabled with icon overrides. - Triggered runtime provider refresh after extension registration and warned on failure. --- packages/coding-agent/CHANGELOG.md | 7 +- packages/coding-agent/src/debug/index.ts | 12 +-- packages/coding-agent/src/edit/renderer.ts | 14 ++- .../__tests__/helpers-local-roots.test.ts | 2 +- packages/coding-agent/src/eval/js/index.ts | 7 +- packages/coding-agent/src/eval/py/index.ts | 7 +- packages/coding-agent/src/exa/render.ts | 2 +- .../src/extensibility/plugins/doctor.ts | 2 +- .../coding-agent/src/goals/tools/goal-tool.ts | 2 +- packages/coding-agent/src/lsp/render.ts | 14 +-- packages/coding-agent/src/mcp/render.ts | 8 +- .../src/modes/components/oauth-selector.ts | 4 +- .../src/modes/components/tool-execution.ts | 2 +- .../src/modes/components/welcome.ts | 2 +- .../controllers/mcp-command-controller.ts | 40 +++++--- .../controllers/ssh-command-controller.ts | 4 +- .../coding-agent/src/modes/theme/theme.ts | 96 ++++++++++++++++++- .../src/modes/utils/ui-helpers.ts | 2 +- .../coding-agent/src/prompts/tools/bash.md | 2 +- packages/coding-agent/src/sdk.ts | 10 ++ packages/coding-agent/src/task/render.ts | 20 ++-- packages/coding-agent/src/tools/ask.ts | 13 ++- .../src/tools/bash-interactive.ts | 2 +- packages/coding-agent/src/tools/bash.ts | 18 +++- .../coding-agent/src/tools/browser/render.ts | 7 +- packages/coding-agent/src/tools/debug.ts | 7 +- .../coding-agent/src/tools/eval-render.ts | 9 +- .../coding-agent/src/tools/gh-renderer.ts | 44 ++++++--- .../src/tools/inspect-image-renderer.ts | 17 +++- packages/coding-agent/src/tools/job.ts | 15 +-- .../coding-agent/src/tools/memory-render.ts | 24 ++++- .../coding-agent/src/tools/render-utils.ts | 4 +- packages/coding-agent/src/tools/resolve.ts | 2 +- packages/coding-agent/src/tools/review.ts | 2 +- packages/coding-agent/src/tools/ssh.ts | 5 +- packages/coding-agent/src/tools/todo.ts | 9 +- packages/coding-agent/src/tools/write.ts | 2 +- packages/coding-agent/src/tui/code-cell.ts | 2 +- .../coding-agent/src/web/search/render.ts | 20 ++-- .../eval-workflow-helpers.integration.test.ts | 3 +- .../test/interactive-mode-lsp-startup.test.ts | 2 +- .../test/mcp-render-status.test.ts | 4 +- .../coding-agent/test/read-tool-group.test.ts | 2 +- packages/coding-agent/test/tools/ask.test.ts | 9 +- 44 files changed, 360 insertions(+), 121 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 86670ed72..f66c46428 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added Homebrew and mise package-manager update paths to the self-update command so installations launched from those tools are updated through their native workflows @@ -12,6 +11,7 @@ - Added support for paste marker highlighting with accent styling (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) in the prompt editor, matching the visual treatment of image references - Added pixel dimensions to pasted/loaded image placeholders in the prompt — the marker now reads `[Image #N, WxH]` (falling back to `[Image #N]` when the header can't be decoded). - The bundled shell now treats `nohup` as a builtin: `nohup … &` runs the command without masking `SIGHUP` or detaching it, so agent-started daemons stay tied to this agent's lifetime instead of leaking as orphans when the agent exits. Updated the bash tool prompt's daemon guidance to match (dropped the `nohup … & / setsid … & / disown` detach recommendation in favor of a large `timeout` plus the persistent session). +- Added per-tool `tool.*` theme symbol keys (nerd/unicode/ascii presets) plus a quiet `status.done` glyph, so each tool's result header can carry a signature icon instead of a generic status mark ### Changed @@ -24,6 +24,10 @@ - Changed provider tie-breaking to rank G0I with hosted aggregator providers, after OpenRouter and before Together. - Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. - Raised the `eval` tool's per-cell `timeout` ceiling from 600s to 3600s (matching `bash`), in both the Zod schema and the `TOOL_TIMEOUTS.eval` runtime clamp, so heavy local-compute cells can request budgets above 10 minutes. +- Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. +- Reworked tool result-header glyphs to cut the overused success checkmark/dot: each tool now shows its own signature icon on success (terminal for bash, pencil for edit, magnifier/globe for search, plug for MCP, etc.; read keeps the read-group status dot), tools without a custom renderer fall back to a quiet `status.done` dot, and error/warning/pending states keep the universal cross/warning/spinner +- Changed steady-state health indicators (LSP server ready, OAuth logged-in, plugin-doctor checks) from a success checkmark to a colored `status.enabled` dot, so failures stand out instead of every line reading as a check +- Changed one-shot MCP/SSH/debug confirmation messages from a generic checkmark to contextual action glyphs (add/remove, connect/enable/disable toggles, reload, job-completed), reflecting what happened rather than just "success" ### Removed @@ -31,6 +35,7 @@ ### Fixed +- Fixed runtime model provider discovery so extension-registered providers are now refreshed after extension load and extension-supplied models appear without restarting - Fixed task-row shimmer timing so every running description starts its highlight on the first character together and reaches the last character together, regardless of text length. - Fixed the `eval` tool's `read`/`write`/`append` helpers (both Python and JS backends) treating `local://` (and other internal-URL) paths as plain filesystem paths. `pathlib.Path`/`path.resolve` collapse `local://x.md` to `local:/x.md`, so `write("local://x.md", …)` created a junk `local:` directory under the cwd instead of writing where `read local://x.md` resolves. The helpers now substitute injected on-disk roots for known schemes (currently `local://`, pinned to the session's own `local://` root), reject path traversal and unknown `scheme://` paths, and leave plain paths resolving against the cwd. - Fixed read and edit previews to surface the enclosing syntactic block's off-window boundary line (behind an ellipsis) when a shown line opens or closes a block whose other end falls outside the displayed range. Powered by a new tree-sitter `enclosingBlockBoundaries` native, so it covers brace languages and indentation languages (Python) using real syntactic spans, with a lexical bracket scan as fallback for unparseable sources. diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index 5def149a0..83c4c5964 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -204,7 +204,7 @@ export class DebugSelectorComponent extends Container { this.ctx.statusContainer.clear(); const block = new TranscriptBlock(); - block.addChild(new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0)); + block.addChild(new Text(theme.fg("success", `+ Performance report saved`), 1, 0)); block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0)); block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0)); this.ctx.present(block); @@ -261,7 +261,7 @@ export class DebugSelectorComponent extends Container { this.ctx.statusContainer.clear(); const block = new TranscriptBlock(); - block.addChild(new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0)); + block.addChild(new Text(theme.fg("success", `+ Report bundle saved`), 1, 0)); block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0)); block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0)); this.ctx.present(block); @@ -298,7 +298,7 @@ export class DebugSelectorComponent extends Container { this.ctx.statusContainer.clear(); const block = new TranscriptBlock(); - block.addChild(new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0)); + block.addChild(new Text(theme.fg("success", `+ Memory report saved`), 1, 0)); block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0)); block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0)); this.ctx.present(block); @@ -480,11 +480,7 @@ export class DebugSelectorComponent extends Container { this.ctx.present([ new Spacer(1), - new Text( - theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`), - 1, - 0, - ), + new Text(theme.fg("success", `- Cleared ${result.removed} artifact directories`), 1, 0), ]); } catch (err) { loader.stop(); diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index edaed012e..275bbb612 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -260,6 +260,7 @@ function renderEditHeader( uiTheme: Theme, options: { icon: "pending" | "success" | "error"; + iconOverride?: string; spinnerFrame?: number; op?: Operation; rawPath: string; @@ -279,8 +280,16 @@ function renderEditHeader( const formatted = formatEditDescription(options.rawPath, uiTheme, descriptionOptions); const suffix = `${options.statsSuffix ?? ""}${options.extraSuffix ?? ""}`; const buildHeader = (description: string): string => - renderStatusLine({ icon: options.icon, spinnerFrame: options.spinnerFrame, title, description }, uiTheme) + - suffix; + renderStatusLine( + { + icon: options.icon, + iconOverride: options.iconOverride, + spinnerFrame: options.spinnerFrame, + title, + description, + }, + uiTheme, + ) + suffix; const header = buildHeader(formatted.description); const overflow = visibleWidth(header) - editHeaderLabelBudget(width, uiTheme); @@ -633,6 +642,7 @@ function renderSingleFileResult( const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : ""; const header = renderEditHeader(width, uiTheme, { icon: isError ? "error" : "success", + iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined, op, rawPath, rename, diff --git a/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts b/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts index b137c7c7d..445144bfa 100644 --- a/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts +++ b/packages/coding-agent/src/eval/__tests__/helpers-local-roots.test.ts @@ -1,5 +1,5 @@ -import * as path from "node:path"; import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; import { TempDir } from "@oh-my-pi/pi-utils"; import { createHelpers, type HelperContext } from "../js/shared/helpers"; diff --git a/packages/coding-agent/src/eval/js/index.ts b/packages/coding-agent/src/eval/js/index.ts index 3fc0d693b..a107cf8d4 100644 --- a/packages/coding-agent/src/eval/js/index.ts +++ b/packages/coding-agent/src/eval/js/index.ts @@ -1,5 +1,10 @@ import type { ToolSession } from "../../tools"; -import { type ExecutorBackend, type ExecutorBackendExecOptions, type ExecutorBackendResult, resolveEvalUrlRoots } from "../backend"; +import { + type ExecutorBackend, + type ExecutorBackendExecOptions, + type ExecutorBackendResult, + resolveEvalUrlRoots, +} from "../backend"; import { executeJs } from "./executor"; const JS_SESSION_PREFIX = "js:"; diff --git a/packages/coding-agent/src/eval/py/index.ts b/packages/coding-agent/src/eval/py/index.ts index 79614b841..fa6f4cc9b 100644 --- a/packages/coding-agent/src/eval/py/index.ts +++ b/packages/coding-agent/src/eval/py/index.ts @@ -1,5 +1,10 @@ import type { ToolSession } from "../../tools"; -import { type ExecutorBackend, type ExecutorBackendExecOptions, type ExecutorBackendResult, resolveEvalUrlRoots } from "../backend"; +import { + type ExecutorBackend, + type ExecutorBackendExecOptions, + type ExecutorBackendResult, + resolveEvalUrlRoots, +} from "../backend"; import { executePython, type PythonExecutorOptions } from "./executor"; import { checkPythonKernelAvailability } from "./kernel"; diff --git a/packages/coding-agent/src/exa/render.ts b/packages/coding-agent/src/exa/render.ts index 7e339d211..79d2b5b95 100644 --- a/packages/coding-agent/src/exa/render.ts +++ b/packages/coding-agent/src/exa/render.ts @@ -93,7 +93,7 @@ export function renderExaResult( const cost = response.costDollars?.total; const time = response.searchTime; - const icon = formatStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme); + const icon = resultCount > 0 ? uiTheme.styledSymbol("tool.exa", "accent") : formatStatusIcon("warning", uiTheme); const metaParts = [formatCount("result", resultCount)]; if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`); diff --git a/packages/coding-agent/src/extensibility/plugins/doctor.ts b/packages/coding-agent/src/extensibility/plugins/doctor.ts index 5bd600c67..88c0f5b59 100644 --- a/packages/coding-agent/src/extensibility/plugins/doctor.ts +++ b/packages/coding-agent/src/extensibility/plugins/doctor.ts @@ -48,7 +48,7 @@ export function formatDoctorResults(checks: DoctorCheck[]): string { for (const check of checks) { const icon = check.status === "ok" - ? theme.status.success + ? theme.status.enabled : check.status === "warning" ? theme.status.warning : theme.status.error; diff --git a/packages/coding-agent/src/goals/tools/goal-tool.ts b/packages/coding-agent/src/goals/tools/goal-tool.ts index 94b9545bf..39505aed4 100644 --- a/packages/coding-agent/src/goals/tools/goal-tool.ts +++ b/packages/coding-agent/src/goals/tools/goal-tool.ts @@ -209,7 +209,7 @@ export const goalToolRenderer = { const header = renderStatusLine( { - icon: "success", + iconOverride: uiTheme.styledSymbol("tool.goal", "accent"), title: "Goal", description, badge: { label: goal.status, color: goalBadgeColor(goal.status) }, diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index f62a9e0dd..82fcb30b4 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -166,15 +166,17 @@ export function renderResult( } else if (result.details?.action === "diagnostics" && text === "OK") { label = "Diagnostics"; state = "success"; - bodyLines = [`${theme.styledSymbol("status.success", "success")} ${theme.fg("dim", "OK")}`]; + bodyLines = [`${theme.styledSymbol("tool.lsp", "accent")} ${theme.fg("dim", "OK")}`]; } else { label = "Response"; bodyLines = renderGeneric(text, lines, expanded, theme); } const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " "); - const status = isPartial ? "running" : result.isError ? "error" : "success"; - const icon = formatStatusIcon(status, theme, spinnerFrame); + const isSuccess = !isPartial && !result.isError; + const icon = isSuccess + ? theme.styledSymbol("tool.lsp", "accent") + : formatStatusIcon(isPartial ? "running" : "error", theme, spinnerFrame); const header = `${icon} LSP ${actionLabel}`; return outputBlock.render( @@ -325,7 +327,7 @@ function renderDiagnostics( ? theme.styledSymbol("status.error", "error") : warnCount > 0 ? theme.styledSymbol("status.warning", "warning") - : theme.styledSymbol("status.success", "success"); + : theme.styledSymbol("tool.lsp", "accent"); const meta: string[] = []; if (errorCount > 0) meta.push(`${errorCount} error${errorCount !== 1 ? "s" : ""}`); @@ -407,7 +409,7 @@ function renderDiagnostics( function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] { const refCount = Number.parseInt(refMatch[1], 10); const icon = - refCount > 0 ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.warning", "warning"); + refCount > 0 ? theme.styledSymbol("tool.lsp", "accent") : theme.styledSymbol("status.warning", "warning"); const locLines = lines.filter(l => /^\s*\S+:\d+:\d+/.test(l)); @@ -598,7 +600,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: hasError && !hasSuccess ? theme.styledSymbol("status.error", "error") : hasSuccess && !hasError - ? theme.styledSymbol("status.success", "success") + ? theme.styledSymbol("tool.lsp", "accent") : theme.styledSymbol("status.info", "accent"); if (expanded) { diff --git a/packages/coding-agent/src/mcp/render.ts b/packages/coding-agent/src/mcp/render.ts index 94b3be5db..68fcfa5dd 100644 --- a/packages/coding-agent/src/mcp/render.ts +++ b/packages/coding-agent/src/mcp/render.ts @@ -53,7 +53,13 @@ export function renderMCPResult( const lines: string[] = []; const isError = result.isError ?? result.details?.isError ?? false; const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP"; - lines.push(renderStatusLine({ icon: isError ? "error" : "success", title }, theme)); + const success = !isError; + lines.push( + renderStatusLine( + success ? { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title } : { icon: "error", title }, + theme, + ), + ); // Args section (when expanded) if (expanded && args && typeof args === "object" && Object.keys(args).length > 0) { diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index b837b4ce2..f21386f6e 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -179,10 +179,10 @@ export class OAuthSelectorComponent extends Container { return theme.fg("error", ` ${theme.status.error} invalid`) + source; } if (state === "valid") { - return theme.fg("success", ` ${theme.status.success} logged in`) + source; + return theme.fg("success", ` ${theme.status.enabled} logged in`) + source; } return this.#hasSelectableAuth(providerId) - ? theme.fg("success", ` ${theme.status.success} logged in`) + source + ? theme.fg("success", ` ${theme.status.enabled} logged in`) + source : ""; } diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 05fd735c8..e63785818 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -914,7 +914,7 @@ export class ToolExecutionComponent extends Container { */ #formatToolExecution(): string { const lines: string[] = []; - const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "success"; + const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "done"; lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme)); const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record) : null; diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index a690c36a4..1517cd333 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -185,7 +185,7 @@ export class WelcomeComponent implements Component { for (const server of this.lspServers) { const icon = server.status === "ready" - ? theme.styledSymbol("status.success", "success") + ? theme.styledSymbol("status.enabled", "success") : server.status === "connecting" ? theme.styledSymbol("status.pending", "muted") : theme.styledSymbol("status.error", "error"); diff --git a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts index c63e57162..2aec08749 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -802,7 +802,7 @@ export class MCPCommandController { await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools()); } if (state === "connected") { - block.setStatus(theme.fg("success", `✓ Connected to "${name}"`)); + block.setStatus(theme.fg("success", `${theme.status.enabled} Connected to "${name}"`)); } else if (state === "connecting") { block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`)); } else { @@ -873,10 +873,10 @@ export class MCPCommandController { // Show success message const scopeLabel = scope === "user" ? "user" : "project"; - const lines = ["", theme.fg("success", `✓ Added server "${name}" to ${scopeLabel} config`), ""]; + const lines = ["", theme.fg("success", `+ Added server "${name}" to ${scopeLabel} config`), ""]; if (isConnected) { - lines.push(theme.fg("success", `✓ Successfully connected to server`)); + lines.push(theme.fg("success", `${theme.status.enabled} Successfully connected to server`)); lines.push(""); } else if (isConnecting) { lines.push(theme.fg("muted", `◌ Server is connecting in background...`)); @@ -1096,7 +1096,7 @@ export class MCPCommandController { // Reload MCP manager await this.#reloadMCP(); - this.#showMessage(["", theme.fg("success", `✓ Removed server "${name}" from ${scope} config`), ""].join("\n")); + this.#showMessage(["", theme.fg("success", `- Removed server "${name}" from ${scope} config`), ""].join("\n")); } catch (error) { this.ctx.showError(`Failed to remove server: ${error instanceof Error ? error.message : String(error)}`); } @@ -1156,7 +1156,7 @@ export class MCPCommandController { const lines = [ "", - theme.fg("success", `✓ Successfully connected to "${name}"`), + theme.fg("success", `${theme.status.enabled} Successfully connected to "${name}"`), "", ` Server: ${connection.serverInfo.name} v${connection.serverInfo.version}`, ` Tools: ${tools.length}`, @@ -1243,12 +1243,18 @@ export class MCPCommandController { ? theme.fg("muted", "Connecting") : theme.fg("warning", "Not connected yet"); this.#showMessage( - ["", theme.fg("success", `✓ Enabled "${name}"`), "", ` Status: ${status}`, ""].join("\n"), + [ + "", + theme.fg("success", `${theme.status.enabled} Enabled "${name}"`), + "", + ` Status: ${status}`, + "", + ].join("\n"), ); } else { await this.ctx.mcpManager?.disconnectServer(name); await this.ctx.session.refreshMCPTools(this.ctx.mcpManager?.getTools() ?? []); - this.#showMessage(["", theme.fg("success", `✓ Disabled "${name}"`), ""].join("\n")); + this.#showMessage(["", theme.fg("muted", `${theme.status.disabled} Disabled "${name}"`), ""].join("\n")); } return; } @@ -1279,7 +1285,9 @@ export class MCPCommandController { const lines = [ "", - theme.fg("success", `✓ ${enabled ? "Enabled" : "Disabled"} "${name}" (${found.scope} config)`), + enabled + ? theme.fg("success", `${theme.status.enabled} Enabled "${name}" (${found.scope} config)`) + : theme.fg("muted", `${theme.status.disabled} Disabled "${name}" (${found.scope} config)`), ]; if (status) { lines.push(""); @@ -1317,7 +1325,7 @@ export class MCPCommandController { await this.#reloadMCP(); this.#showMessage( - ["", theme.fg("success", `✓ Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"), + ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"), ); } catch (error) { this.ctx.showError(`Failed to clear auth: ${error instanceof Error ? error.message : String(error)}`); @@ -1411,7 +1419,12 @@ export class MCPCommandController { await this.#reloadMCP(); const connectedCount = this.ctx.mcpManager?.getConnectedServers().length ?? 0; this.#showMessage( - ["", theme.fg("success", "✓ MCP reload complete"), ` Connected servers: ${connectedCount}`, ""].join("\n"), + [ + "", + theme.fg("success", `${theme.icon.loop} MCP reload complete`), + ` Connected servers: ${connectedCount}`, + "", + ].join("\n"), ); } catch (error) { this.ctx.showError(`Failed to reload MCP: ${error instanceof Error ? error.message : String(error)}`); @@ -1442,9 +1455,12 @@ export class MCPCommandController { await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools()); const serverTools = this.ctx.mcpManager.getTools().filter(t => t.mcpServerName === name); this.#showMessage( - ["\n", theme.fg("success", `✓ Reconnected to "${name}"`), ` Tools: ${serverTools.length}`, "\n"].join( + [ "\n", - ), + theme.fg("success", `${theme.status.enabled} Reconnected to "${name}"`), + ` Tools: ${serverTools.length}`, + "\n", + ].join("\n"), ); } else { this.ctx.showError(`Failed to reconnect to "${name}". Check server status and logs.`); diff --git a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts index fd835e19d..6b01508bf 100644 --- a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts @@ -209,7 +209,7 @@ export class SSHCommandController { const scopeLabel = scope === "user" ? "user" : "project"; const lines = [ "", - theme.fg("success", `✓ Added SSH host "${name}" to ${scopeLabel} config`), + theme.fg("success", `+ Added SSH host "${name}" to ${scopeLabel} config`), "", ` Host: ${host}`, ]; @@ -368,7 +368,7 @@ export class SSHCommandController { await this.ctx.session.refreshSshTool(); this.#showMessage( - ["", theme.fg("success", `✓ Removed SSH host "${name}" from ${scope} config`), ""].join("\n"), + ["", theme.fg("success", `- Removed SSH host "${name}" from ${scope} config`), ""].join("\n"), ); } catch (error) { this.ctx.showError(`Failed to remove host: ${error instanceof Error ? error.message : String(error)}`); diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index ed0d46d24..f9ebba809 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -43,6 +43,7 @@ export type SymbolKey = | "status.running" | "status.shadowed" | "status.aborted" + | "status.done" // Navigation | "nav.cursor" | "nav.selected" @@ -197,7 +198,29 @@ export type SymbolKey = | "tab.tools" | "tab.memory" | "tab.tasks" - | "tab.providers"; + | "tab.providers" + // Tool identity icons + | "tool.write" + | "tool.edit" + | "tool.bash" + | "tool.ssh" + | "tool.lsp" + | "tool.gh" + | "tool.webSearch" + | "tool.exa" + | "tool.browser" + | "tool.eval" + | "tool.debug" + | "tool.mcp" + | "tool.job" + | "tool.task" + | "tool.todo" + | "tool.memory" + | "tool.ask" + | "tool.resolve" + | "tool.review" + | "tool.inspectImage" + | "tool.goal"; type SymbolMap = Record; @@ -213,6 +236,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "status.running": "⟳", "status.shadowed": "◌", "status.aborted": "⏹", + "status.done": "•", // Navigation "nav.cursor": "❯", "nav.selected": "➤", @@ -368,6 +392,28 @@ const UNICODE_SYMBOLS: SymbolMap = { "tab.memory": "🧠", "tab.tasks": "📦", "tab.providers": "🌐", + // Tool identity icons (per-tool signature glyph on the success header) + "tool.write": "✎", + "tool.edit": "✎", + "tool.bash": "❯", + "tool.ssh": "⇄", + "tool.lsp": "💡", + "tool.gh": "⎇", + "tool.webSearch": "⌕", + "tool.exa": "🔭", + "tool.browser": "🌐", + "tool.eval": "▶", + "tool.debug": "🐞", + "tool.mcp": "🔌", + "tool.job": "⚙", + "tool.task": "⇶", + "tool.todo": "☑", + "tool.memory": "🧠", + "tool.ask": "?", + "tool.resolve": "✓", + "tool.review": "◉", + "tool.inspectImage": "🖼", + "tool.goal": "◎", }; const NERD_SYMBOLS: SymbolMap = { @@ -392,6 +438,8 @@ const NERD_SYMBOLS: SymbolMap = { "status.shadowed": "◐", // pick:  | alt:   "status.aborted": "\uf04d", + // pick: • | alt: ● · + "status.done": "•", // Navigation // pick:  | alt:   "nav.cursor": "\uf054", @@ -638,6 +686,28 @@ const NERD_SYMBOLS: SymbolMap = { "tab.memory": "󰧑", "tab.tasks": "󰐱", "tab.providers": "󰖟", + // Tool identity icons (per-tool signature glyph on the success header) + "tool.write": "\uEA7F", + "tool.edit": "\uEA73", + "tool.bash": "\uEBCA", + "tool.ssh": "\uEB3A", + "tool.lsp": "\uEA61", + "tool.gh": "\uEA84", + "tool.webSearch": "\uEB01", + "tool.exa": "\uEB68", + "tool.browser": "\uEAAE", + "tool.eval": "\uEBAF", + "tool.debug": "\uEAD8", + "tool.mcp": "\uEB2D", + "tool.job": "\uEBA2", + "tool.task": "\uEA7E", + "tool.todo": "\uEAB3", + "tool.memory": "\uEACE", + "tool.ask": "\uEAC7", + "tool.resolve": "\uEBB1", + "tool.review": "\uEA70", + "tool.inspectImage": "\uEAEA", + "tool.goal": "\uEBF8", }; const ASCII_SYMBOLS: SymbolMap = { @@ -652,6 +722,7 @@ const ASCII_SYMBOLS: SymbolMap = { "status.running": "[~]", "status.shadowed": "[/]", "status.aborted": "[-]", + "status.done": "*", // Navigation "nav.cursor": ">", "nav.selected": "->", @@ -805,6 +876,28 @@ const ASCII_SYMBOLS: SymbolMap = { "tab.memory": "[Y]", "tab.tasks": "[K]", "tab.providers": "[P]", + // Tool identity icons (per-tool signature glyph on the success header) + "tool.write": "+f", + "tool.edit": "~", + "tool.bash": "$", + "tool.ssh": "ssh", + "tool.lsp": "lsp", + "tool.gh": "gh", + "tool.webSearch": "web", + "tool.exa": "exa", + "tool.browser": "[w]", + "tool.eval": ">_", + "tool.debug": "dbg", + "tool.mcp": "<>", + "tool.job": "job", + "tool.task": ">>>", + "tool.todo": "[x]", + "tool.memory": "mem", + "tool.ask": "[?]", + "tool.resolve": "[v]", + "tool.review": "rev", + "tool.inspectImage": "[i]", + "tool.goal": "(o)", }; const SYMBOL_PRESETS: Record = { @@ -1485,6 +1578,7 @@ export class Theme { running: this.#symbols["status.running"], shadowed: this.#symbols["status.shadowed"], aborted: this.#symbols["status.aborted"], + done: this.#symbols["status.done"], }; } diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index fc356a8bf..3136296f8 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -161,7 +161,7 @@ export class UiHelpers { const typeLabel = job.type ? `[${job.type}]` : "[job]"; const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined; const line = [ - theme.fg("success", `${theme.status.success} Background job completed`), + theme.fg("success", `${theme.status.done} Background job completed`), theme.fg("dim", typeLabel), theme.fg("accent", jobId), duration ? theme.fg("dim", `(${duration})`) : undefined, diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index d45ad446e..776f1367c 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -29,7 +29,7 @@ Executes bash command in shell session for terminal operations like git, bun, ca - `timeout` (seconds) caps the **wall-clock duration** of the command. When it elapses the process is killed and the call returns with a timeout annotation. Range: `1`–`3600`s; default `300`s (see `clampTimeout("bash", …)` in `tool-timeouts.ts`). - `async: true` only defers **reporting** of the result — it does NOT disable, extend, or detach the timeout. A daemon started with `async: true` is still killed when `timeout` elapses, regardless of how long the agent waits before reading the result. -- For long-running daemons (dev servers, watchers): either pass an explicit large `timeout` (up to `3600`), or fully detach the process from this shell using `nohup … &` / `setsid … &` / `disown` so it survives independent of the bash call's lifecycle. +- For long-running daemons (dev servers, watchers): pass an explicit large `timeout` (up to `3600`). The shell session persists across calls, so a backgrounded job (`cmd &`) keeps running between bash calls on its own. {{/if}} {{#if autoBackgroundEnabled}} diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 175f65ce5..ac5ac5553 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1574,6 +1574,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } extensionsResult.runtime.pendingProviderRegistrations = []; } + // Discover runtime (extension) provider catalogs now that they are + // registered. The startup refreshInBackground() ran before extensions + // loaded, so dynamic extension providers are only discovered here. Runs in + // the background (cache-aware) so startup is never blocked on the fetch; the + // model list re-renders when the catalog arrives, like other dynamic providers. + void modelRegistry.refreshRuntimeProviders().catch(error => { + logger.warn("runtime provider discovery failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); // Retry session-model candidates now that extension providers are // registered. The initial restore runs before extensions load, so a role diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 1a77bdf94..89a44c37d 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -807,12 +807,15 @@ function renderReviewResult( // Verdict line const verdictColor = summary.overall_correctness === "correct" ? "success" : "error"; - const verdictIcon = summary.overall_correctness === "correct" ? theme.status.success : theme.status.error; + const isCorrect = summary.overall_correctness === "correct"; + const verdictIcon = isCorrect + ? theme.styledSymbol("tool.task", "accent") + : theme.fg(verdictColor, theme.status.error); lines.push( - `${continuePrefix} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${theme.fg( - verdictColor, - verdictIcon, - )} ${theme.fg("dim", `(${(summary.confidence * 100).toFixed(0)}% confidence)`)}`, + `${continuePrefix} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${verdictIcon} ${theme.fg( + "dim", + `(${(summary.confidence * 100).toFixed(0)}% confidence)`, + )}`, ); // Explanation preview (first ~80 chars when collapsed, full when expanded) @@ -913,7 +916,7 @@ function renderAgentResult( : needsWarning ? theme.status.warning : success - ? theme.status.success + ? theme.styledSymbol("tool.task", "accent") : theme.status.error; const iconColor = needsWarning ? "warning" : success ? "success" : mergeFailed ? "warning" : "error"; const statusText = aborted @@ -1082,7 +1085,10 @@ export function renderResult( if (!details) { const text = result.content.find(c => c.type === "text")?.text || ""; - const header = renderStatusLine({ icon: "success", title: "Task" }, theme); + const header = renderStatusLine( + { iconOverride: theme.styledSymbol("tool.task", "accent"), title: "Task" }, + theme, + ); return framedBlock(theme, width => ({ header, sections: [ diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 33f16cd00..3dd3be7da 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -96,7 +96,7 @@ const OTHER_OPTION = "Other (type your own)"; const RECOMMENDED_SUFFIX = " (Recommended)"; function getDoneOptionLabel(): string { - return `${theme.status.success} Done selecting`; + return `${theme.symbol("tool.ask")} Done selecting`; } /** Add "(Recommended)" suffix to the option at the given index if not already present */ @@ -627,9 +627,7 @@ interface AskRenderArgs { /** Render a custom free-text answer as a status line plus indented continuation rows. */ function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] { const lines = customInput.split("\n"); - const out: string[] = [ - ` ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`, - ]; + const out: string[] = [` ${uiTheme.styledSymbol("tool.ask", "accent")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`]; for (let i = 1; i < lines.length; i++) out.push(` ${uiTheme.fg("toolOutput", lines[i])}`); return out; } @@ -820,7 +818,12 @@ export const askToolRenderer = { const question = details.question; const hasSelection = details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0); - const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme); + const header = renderStatusLine( + hasSelection + ? { iconOverride: uiTheme.styledSymbol("tool.ask", "accent"), title: "Ask" } + : { icon: "warning", title: "Ask" }, + uiTheme, + ); const dOptions = details.options; const dSelected = details.selectedOptions; const dMulti = details.multi; diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index f8eac335d..6e51722fa 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -246,7 +246,7 @@ class BashInteractiveOverlayComponent implements Component { this.#state === "running" ? formatStatusIcon("running", this.uiTheme) : this.#state === "complete" && this.#exitCode === 0 - ? formatStatusIcon("success", this.uiTheme) + ? this.uiTheme.styledSymbol("tool.bash", "accent") : formatStatusIcon("warning", this.uiTheme); const title = this.uiTheme.fg("accent", "Console"); const statusBadge = `${this.uiTheme.fg("dim", this.uiTheme.format.bracketLeft)}${this.#stateText()}${this.uiTheme.fg("dim", this.uiTheme.format.bracketRight)}`; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 3c7b46a31..0d2125748 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1151,11 +1151,23 @@ export function createShellRenderer(config: ShellRendererConfig) { const renderArgs = toBashRenderArgs(args, config); const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined; const isError = result.isError === true; - const icon = options.isPartial ? "pending" : isError ? "error" : "done"; + const isPartial = options.isPartial === true; + const success = !isPartial && !isError; const header = config.showHeader === false ? undefined - : renderStatusLine({ icon, title: config.resolveTitle(args, options) }, uiTheme); + : renderStatusLine( + success + ? { + iconOverride: uiTheme.styledSymbol("tool.bash", "accent"), + title: config.resolveTitle(args, options), + } + : { + icon: isPartial ? "pending" : "error", + title: config.resolveTitle(args, options), + }, + uiTheme, + ); const details = result.details; const outputBlock = new CachedOutputBlock(); @@ -1249,7 +1261,7 @@ export function createShellRenderer(config: ShellRendererConfig) { return outputBlock.render( { header, - state: options.isPartial ? "pending" : isError ? "error" : "done", + state: options.isPartial ? "pending" : isError ? "error" : "success", sections: [ { lines: options.isPartial diff --git a/packages/coding-agent/src/tools/browser/render.ts b/packages/coding-agent/src/tools/browser/render.ts index e2b84bb61..b2172d486 100644 --- a/packages/coding-agent/src/tools/browser/render.ts +++ b/packages/coding-agent/src/tools/browser/render.ts @@ -146,7 +146,7 @@ function renderOpenOrCloseLine( const action = (details?.action ?? args.action ?? "open") as "open" | "close" | "run"; const status = cellStatus(isPartial, isError); const icon = - status === "complete" ? "success" : status === "error" ? "error" : status === "running" ? "running" : "pending"; + status === "complete" ? "done" : status === "error" ? "error" : status === "running" ? "running" : "pending"; let title: string; if (action === "close") { @@ -163,7 +163,10 @@ function renderOpenOrCloseLine( const url = details?.url ?? args.url; if (url) meta.push(shortenPath(url)); - const header = renderStatusLine({ icon, title, meta }, theme); + const header = + status === "complete" + ? renderStatusLine({ iconOverride: theme.styledSymbol("tool.browser", "accent"), title, meta }, theme) + : renderStatusLine({ icon, title, meta }, theme); if (!output) return new Text(header, 0, 0); const outputLines = output.split("\n").map(line => theme.fg("toolOutput", replaceTabs(line))); return new Text([header, ...outputLines].join("\n"), 0, 0); diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 61a009d2c..6dcf2b9b2 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -594,8 +594,11 @@ export const debugToolRenderer = { return markFramedBlockComponent({ render(width: number): string[] { const action = (args?.action ?? result.details?.action ?? "debug").replaceAll("_", " "); - const status = options.isPartial ? "running" : result.isError ? "error" : "success"; - const header = `${formatStatusIcon(status, theme, options.spinnerFrame)} Debug ${action}`; + const success = !options.isPartial && !result.isError; + const statusIcon = success + ? theme.styledSymbol("tool.debug", "accent") + : formatStatusIcon(options.isPartial ? "running" : "error", theme, options.spinnerFrame); + const header = `${statusIcon} Debug ${action}`; const summaryLines = result.details?.snapshot ? formatSessionSnapshot(result.details.snapshot).map(line => replaceTabs(line)) : []; diff --git a/packages/coding-agent/src/tools/eval-render.ts b/packages/coding-agent/src/tools/eval-render.ts index 71730469b..adc8288ab 100644 --- a/packages/coding-agent/src/tools/eval-render.ts +++ b/packages/coding-agent/src/tools/eval-render.ts @@ -172,7 +172,7 @@ function renderAgentProgressEvents(events: EvalStatusEvent[], theme: Theme, spin const status = agentEventStatus(event.status); const iconStatus = status === "completed" - ? "success" + ? "done" : status === "failed" ? "error" : status === "aborted" @@ -182,10 +182,13 @@ function renderAgentProgressEvents(events: EvalStatusEvent[], theme: Theme, spin : "running"; const iconColor = status === "completed" ? "success" : status === "failed" || status === "aborted" ? "error" : "accent"; - const icon = formatStatusIcon(iconStatus, theme, status === "running" ? spinnerFrame : undefined); + const icon = + status === "completed" + ? theme.styledSymbol("tool.eval", "accent") + : theme.fg(iconColor, formatStatusIcon(iconStatus, theme, status === "running" ? spinnerFrame : undefined)); const id = eventString(event.id) ?? "agent"; - let line = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", theme.bold(id))}`; + let line = `${prefix} ${icon} ${theme.fg("accent", theme.bold(id))}`; if (status === "failed" || status === "aborted") { line += ` ${formatBadge(status, iconColor, theme)}`; diff --git a/packages/coding-agent/src/tools/gh-renderer.ts b/packages/coding-agent/src/tools/gh-renderer.ts index d34d8d9f0..1d703e701 100644 --- a/packages/coding-agent/src/tools/gh-renderer.ts +++ b/packages/coding-agent/src/tools/gh-renderer.ts @@ -163,8 +163,8 @@ function getJobStateVisual( ): { iconRaw: string; iconColor: ToolUIColor; textColor: ThemeColor } { if (job.conclusion && SUCCESS_CONCLUSIONS.has(job.conclusion)) { return { - iconRaw: theme.status.success, - iconColor: "success", + iconRaw: theme.symbol("tool.gh"), + iconColor: "accent", textColor: "success", }; } @@ -327,14 +327,21 @@ function renderFallbackComponent( const title = formatOpTitle(args.op); const meta = buildOpMeta(args); const isError = result.isError === true; - const status: ToolUIStatus = isError ? "error" : text ? "success" : "warning"; + const success = !isError && Boolean(text); const header = renderStatusLine( - { - icon: status, - title, - titleColor: isError ? "error" : "accent", - meta, - }, + success + ? { + iconOverride: theme.styledSymbol("tool.gh", "accent"), + title, + titleColor: "accent", + meta, + } + : { + icon: isError ? "error" : "warning", + title, + titleColor: isError ? "error" : "accent", + meta, + }, theme, ); @@ -438,12 +445,19 @@ export const githubToolRenderer = { if (watch) { const isError = result.isError === true; const header = renderStatusLine( - { - icon: isError ? "error" : "success", - title: "GitHub Run Watch", - titleColor: isError ? "error" : "accent", - meta: [getWatchHeader(watch)], - }, + isError + ? { + icon: "error", + title: "GitHub Run Watch", + titleColor: "error", + meta: [getWatchHeader(watch)], + } + : { + iconOverride: uiTheme.styledSymbol("tool.gh", "accent"), + title: "GitHub Run Watch", + titleColor: "accent", + meta: [getWatchHeader(watch)], + }, uiTheme, ); return framedBlock(uiTheme, width => { diff --git a/packages/coding-agent/src/tools/inspect-image-renderer.ts b/packages/coding-agent/src/tools/inspect-image-renderer.ts index 604a04d52..d2ff44e31 100644 --- a/packages/coding-agent/src/tools/inspect-image-renderer.ts +++ b/packages/coding-agent/src/tools/inspect-image-renderer.ts @@ -53,12 +53,19 @@ export const inspectImageToolRenderer = { const details = result.details; const rawPath = details?.imagePath ?? args?.path ?? ""; const pathDisplay = rawPath ? shortenPath(rawPath) : "image"; + const success = !result.isError; const header = renderStatusLine( - { - icon: result.isError ? "error" : "success", - title: "Inspect", - description: pathDisplay, - }, + success + ? { + iconOverride: uiTheme.styledSymbol("tool.inspectImage", "accent"), + title: "Inspect", + description: pathDisplay, + } + : { + icon: "error", + title: "Inspect", + description: pathDisplay, + }, uiTheme, ); diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index c0f326d5d..070d17710 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -355,7 +355,7 @@ const PREVIEW_LINE_WIDTH = 80; function statusToIcon(status: JobSnapshot["status"]): ToolUIStatus { switch (status) { case "completed": - return "success"; + return "done"; case "failed": return "error"; case "cancelled": @@ -468,11 +468,14 @@ export const jobToolRenderer = { itemType: "job", renderItem: job => { const lines: string[] = []; - const icon = formatStatusIcon( - statusToIcon(job.status), - uiTheme, - job.status === "running" ? options.spinnerFrame : undefined, - ); + const icon = + job.status === "completed" + ? uiTheme.styledSymbol("tool.job", "accent") + : formatStatusIcon( + statusToIcon(job.status), + uiTheme, + job.status === "running" ? options.spinnerFrame : undefined, + ); const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme); const idText = uiTheme.fg("muted", job.id); const rawLabelLines = (job.label || "(no label)").split(/\r?\n/); diff --git a/packages/coding-agent/src/tools/memory-render.ts b/packages/coding-agent/src/tools/memory-render.ts index 2430eef6c..30adfae43 100644 --- a/packages/coding-agent/src/tools/memory-render.ts +++ b/packages/coding-agent/src/tools/memory-render.ts @@ -49,10 +49,11 @@ function queryHeader( icon: ToolUIStatus, theme: Theme, meta?: string[], + iconOverride?: string, ): string { const trimmed = replaceTabs((query ?? "").trim()); const description = trimmed ? truncateToWidth(trimmed, 80, Ellipsis.Unicode) : undefined; - return renderStatusLine({ icon, title, description, meta }, theme); + return renderStatusLine({ icon, iconOverride, title, description, meta }, theme); } function retainComponent(contents: string[], header: string, getExpanded: () => boolean, theme: Theme): Component { @@ -96,7 +97,11 @@ export const retainToolRenderer = { // trailing period so it reads cleanly as a status meta segment. const summary = resultText(result).replace(/\.$/, ""); const header = renderStatusLine( - { icon: "success", title: "Retain", meta: summary ? [summary] : undefined }, + { + iconOverride: theme.styledSymbol("tool.memory", "accent"), + title: "Retain", + meta: summary ? [summary] : undefined, + }, theme, ); return retainComponent(contents, header, () => options.expanded, theme); @@ -121,9 +126,11 @@ export const recallToolRenderer = { const text = resultText(result); const match = text.match(/^Found (\d+) relevant/); const found = match ? Number(match[1]) : 0; - const icon: ToolUIStatus = found > 0 ? "success" : "warning"; const meta = [found > 0 ? `${found} found` : "no matches"]; - const header = queryHeader("Recall", args?.query, icon, theme, meta); + const header = + found > 0 + ? queryHeader("Recall", args?.query, "success", theme, meta, theme.styledSymbol("tool.memory", "accent")) + : queryHeader("Recall", args?.query, "warning", theme, meta); if (found === 0) { return new Text(header, 0, 0); } @@ -163,7 +170,14 @@ export const reflectToolRenderer = { if (result.isError) { return new Text(formatErrorMessage(resultText(result) || "Reflect failed", theme), 0, 0); } - const header = queryHeader("Reflect", args?.query, "success", theme); + const header = queryHeader( + "Reflect", + args?.query, + "success", + theme, + undefined, + theme.styledSymbol("tool.memory", "accent"), + ); const answer = resultText(result); const answerLines = answer.split("\n").filter(line => line.trim().length > 0); return createCachedComponent( diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index 7e7ab979e..65dc8daf7 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -133,6 +133,8 @@ export function formatStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFram switch (status) { case "success": return theme.styledSymbol("status.success", "success"); + case "done": + return theme.styledSymbol("status.done", "success"); case "error": return theme.styledSymbol("status.error", "error"); case "warning": @@ -276,7 +278,7 @@ export function formatCodeFrameLine( // Tool UI Helpers // ============================================================================= -export type ToolUIStatus = "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted"; +export type ToolUIStatus = "success" | "done" | "error" | "warning" | "info" | "pending" | "running" | "aborted"; export type ToolUIColor = "success" | "error" | "warning" | "accent" | "muted"; export interface ToolUITitleOptions { diff --git a/packages/coding-agent/src/tools/resolve.ts b/packages/coding-agent/src/tools/resolve.ts index 15f22d406..4caf26626 100644 --- a/packages/coding-agent/src/tools/resolve.ts +++ b/packages/coding-agent/src/tools/resolve.ts @@ -241,7 +241,7 @@ export const resolveToolRenderer = { const isApply = action === "apply" && !result.isError; const isFailedApply = action === "apply" && result.isError; const bgColor = result.isError ? "error" : isApply ? "success" : "warning"; - const icon = isApply ? uiTheme.status.success : uiTheme.status.error; + const icon = isApply ? uiTheme.styledSymbol("tool.resolve", "accent") : uiTheme.status.error; const verb = isApply ? "Accept" : isFailedApply ? "Failed" : "Discard"; const separator = ": "; const separatorIndex = label.indexOf(separator); diff --git a/packages/coding-agent/src/tools/review.ts b/packages/coding-agent/src/tools/review.ts index 05b597322..0aa021ebe 100644 --- a/packages/coding-agent/src/tools/review.ts +++ b/packages/coding-agent/src/tools/review.ts @@ -169,7 +169,7 @@ export const reportFindingTool: AgentTool c.type === "text")?.text ?? ""; const outputBlock = new CachedOutputBlock(); diff --git a/packages/coding-agent/src/tools/todo.ts b/packages/coding-agent/src/tools/todo.ts index bc5530977..b50c69c9c 100644 --- a/packages/coding-agent/src/tools/todo.ts +++ b/packages/coding-agent/src/tools/todo.ts @@ -881,7 +881,14 @@ export const todoToolRenderer = { keys.add(task.content); } const allTasks = phases.flatMap(phase => phase.tasks); - const header = renderStatusLine({ icon: "success", title: "Todo", meta: [`${allTasks.length} tasks`] }, uiTheme); + const header = renderStatusLine( + { + iconOverride: uiTheme.styledSymbol("tool.todo", "accent"), + title: "Todo", + meta: [`${allTasks.length} tasks`], + }, + uiTheme, + ); if (allTasks.length === 0) { const fallback = result.content?.find(content => content.type === "text")?.text ?? "No todos"; return new Text(`${header}\n ${uiTheme.fg("dim", fallback)}`, 0, 0); diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 1ad1a3cb9..6848060a2 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -1081,7 +1081,7 @@ export const writeToolRenderer = { : ""; const header = renderStatusLine( { - icon: "success", + iconOverride: uiTheme.styledSymbol("tool.write", "accent"), title: "Write", description: `${langIcon} ${pathDisplay}${lineSuffix}${execSuffix}`, }, diff --git a/packages/coding-agent/src/tui/code-cell.ts b/packages/coding-agent/src/tui/code-cell.ts index 76fd38655..0e26c116d 100644 --- a/packages/coding-agent/src/tui/code-cell.ts +++ b/packages/coding-agent/src/tui/code-cell.ts @@ -50,7 +50,7 @@ function formatHeader(options: CodeCellOptions, theme: Theme): { title: string; if (status) { const icon = formatStatusIcon( status === "complete" - ? "success" + ? "done" : status === "error" ? "error" : status === "warning" diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index 2e802f606..5c3f4e5a9 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -117,13 +117,21 @@ export function renderSearchResult( : searchQueries[0] ? truncateToWidth(searchQueries[0], 80) : undefined; + const success = sourceCount > 0; const header = renderStatusLine( - { - icon: sourceCount > 0 ? "success" : "warning", - title: "Web Search", - description: providerLabel, - meta: [formatCount("source", sourceCount)], - }, + success + ? { + iconOverride: theme.styledSymbol("tool.webSearch", "accent"), + title: "Web Search", + description: providerLabel, + meta: [formatCount("source", sourceCount)], + } + : { + icon: "warning", + title: "Web Search", + description: providerLabel, + meta: [formatCount("source", sourceCount)], + }, theme, ); diff --git a/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts b/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts index b82c4a4c4..12954ff84 100644 --- a/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts +++ b/packages/coding-agent/test/core/eval-workflow-helpers.integration.test.ts @@ -5,8 +5,9 @@ * Gated by `PI_PYTHON_INTEGRATION=1` so CI without a real Python interpreter * (or sandboxes where subprocess spawning is restricted) does not fail. */ -import * as path from "node:path"; + import { afterEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; import { disposeAllKernelSessions, executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor"; import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel"; import { TempDir } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts index be7ba76bc..d2d8fac8e 100644 --- a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts @@ -116,7 +116,7 @@ describe("InteractiveMode LSP startup welcome banner", () => { expect(requestRenderSpy).toHaveBeenCalled(); expect(showStatusSpy).not.toHaveBeenCalled(); - expect(findServerLine()).toContain(theme.status.success); + expect(findServerLine()).toContain(theme.status.enabled); expect(findServerLine()).not.toContain(theme.status.pending); }); }); diff --git a/packages/coding-agent/test/mcp-render-status.test.ts b/packages/coding-agent/test/mcp-render-status.test.ts index c2a81f19d..e2d06de01 100644 --- a/packages/coding-agent/test/mcp-render-status.test.ts +++ b/packages/coding-agent/test/mcp-render-status.test.ts @@ -110,13 +110,13 @@ describe("MCP tool rendering", () => { it("replaces the pending call header with a success header after completion", async () => { const uiTheme = await getRequiredTheme(); const pendingIcon = Bun.stripANSI(formatStatusIcon("pending", uiTheme)); - const successIcon = Bun.stripANSI(formatStatusIcon("success", uiTheme)); + const doneIcon = Bun.stripANSI(uiTheme.styledSymbol("tool.mcp", "accent")); const rendered = await renderCompletedMCPTool(false); expect(makeTool().mergeCallAndResult).toBe(true); expect(makeDeferredTool().mergeCallAndResult).toBe(true); - expect(rendered).toContain(`${successIcon} sentry/search_events`); + expect(rendered).toContain(`${doneIcon} sentry/search_events`); expect(rendered).not.toContain(`${pendingIcon} sentry/search_events`); }); diff --git a/packages/coding-agent/test/read-tool-group.test.ts b/packages/coding-agent/test/read-tool-group.test.ts index 907c08ec4..f0af5b40b 100644 --- a/packages/coding-agent/test/read-tool-group.test.ts +++ b/packages/coding-agent/test/read-tool-group.test.ts @@ -50,7 +50,7 @@ describe("ReadToolGroupComponent", () => { expect(rendered.toLowerCase()).not.toContain("ctrl+o"); }); - it("uses the read-specific success mark for completed reads", () => { + it("uses the enabled dot for completed reads", () => { const component = new ReadToolGroupComponent(); component.updateArgs({ path: "/tmp/example.ts" }, "read-success"); component.updateResult( diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 560839b5e..832b4b2ae 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -828,11 +828,12 @@ describe("AskTool multiline custom input rendering", () => { expect(renderedText).toContain("second line"); expect(renderedText).toContain("third line"); - // Count success icons — should be exactly one for the custom input block, + // Count tool.ask glyphs — should be exactly one for the custom input block, // plus one for the question status icon (if present). The key contract is that - // continuation lines do NOT get their own success icon. + // continuation lines do NOT get their own glyph. + const askGlyph = theme!.symbol("tool.ask"); const successIconCount = ( - renderedText.match(new RegExp(theme!.status.success.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || [] + renderedText.match(new RegExp(askGlyph.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || [] ).length; // One icon on the status line header + one on the custom input first line = 2 max expect(successIconCount).toBeLessThanOrEqual(2); @@ -843,7 +844,7 @@ describe("AskTool multiline custom input rendering", () => { const trimmed = line.trim(); if (trimmed.includes("second line") || trimmed.includes("third line")) { // These continuation lines must NOT start with a success icon - expect(trimmed.startsWith(theme!.status.success)).toBe(false); + expect(trimmed.startsWith(askGlyph)).toBe(false); } } }); From 9cfb63fd0719225689f6f57c802053d7b2d6f1c9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 18:31:17 +0200 Subject: [PATCH 47/53] fix(agent): retained mid-stream tool calls on deliberate aborts - Kept committed tool-call blocks on TTSR/user-interrupt aborts, pairing them with a labeled placeholder result. - Dropped incomplete tool calls only on anonymous `abort()` where partial args are unsafe to replay. --- packages/agent/CHANGELOG.md | 1 + packages/agent/src/agent-loop.ts | 65 ++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6d7634df9..32d64ffc8 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying - Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors - Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results +- Fixed deliberate aborts (TTSR rule matches, user-interrupt labels) so a mid-stream tool-call block that never reached `toolcall_end` is retained on the aborted assistant message and paired with a placeholder result labeled by the abort reason, instead of being dropped; anonymous aborts (bare `abort()`) still drop incomplete tool calls whose partial arguments are unsafe to replay - Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls ## [15.10.3] - 2026-06-08 diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index afded1216..876a48b48 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1132,6 +1132,20 @@ export function abortReasonText(signal: AbortSignal | undefined): string { return "Request was aborted"; } +/** True when an abort carried a *deliberate*, human-meaningful reason — a string + * reason or a non-`AbortError` `Error` (TTSR rule match, user-interrupt label). + * A bare `abort()` (default `AbortError` `DOMException`) is anonymous and returns + * false. Used to decide whether a mid-stream tool call survives the abort: a + * deliberate interruption is a conscious decision made after the (partial) call + * was observed, so the block is retained and paired with a labeled placeholder; + * an anonymous abort drops incomplete calls whose args may be unsafe to replay. */ +function isExplicitAbortReason(signal: AbortSignal | undefined): boolean { + const reason = signal?.reason; + if (typeof reason === "string") return reason.trim().length > 0; + if (reason instanceof Error) return reason.name !== "AbortError" && reason.message.trim().length > 0; + return false; +} + function emitAbortedAssistantMessage( partialMessage: AssistantMessage | null, addedPartial: boolean, @@ -1142,31 +1156,32 @@ function emitAbortedAssistantMessage( requestSignal: AbortSignal | undefined, ): AssistantMessage { const errorMessage = abortReasonText(requestSignal); - const abortedMessage = snapshotAssistantMessage( - retainCompletedToolCalls( - partialMessage - ? { ...partialMessage, stopReason: "aborted", errorMessage } - : { - role: "assistant", - content: [], - api: config.model.api, - provider: config.model.provider, - model: config.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: "aborted", - errorMessage, - timestamp: Date.now(), - }, - completedToolCallIds, - ), - ); + const base: AssistantMessage = partialMessage + ? { ...partialMessage, stopReason: "aborted", errorMessage } + : { + role: "assistant", + content: [], + api: config.model.api, + provider: config.model.provider, + model: config.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: "aborted", + errorMessage, + timestamp: Date.now(), + }; + // A deliberate, labeled abort (TTSR rule match, user interrupt) keeps every + // committed tool-call block so the loop pairs it with a placeholder labeled by + // `errorMessage`; an anonymous abort still drops calls that never completed + // (no `toolcall_end`), whose partial args are unsafe to replay. + const retained = isExplicitAbortReason(requestSignal) ? base : retainCompletedToolCalls(base, completedToolCallIds); + const abortedMessage = snapshotAssistantMessage(retained); if (addedPartial) { context.messages[context.messages.length - 1] = abortedMessage; } else { From 31b6f0bf31e6e3d252a6afbe911eb774b3e2801f Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:38:59 +0200 Subject: [PATCH 48/53] refactor(ai): consolidated provider config into single-source registry - Derived descriptors, default-model map, env keys, login list, and refresh dispatch from one ProviderDefinition per provider. - Disabled OpenAI Codex stream obfuscation and interrupted whitespace-only tool-call argument deltas. - Derived auth-broker callback ports and paste-code login set from the registry. --- docs/adding-a-provider.md | 85 +++ packages/ai/CHANGELOG.md | 3 + packages/ai/package.json | 12 +- packages/ai/scripts/generate-models.ts | 2 +- packages/ai/src/auth-broker/remote-store.ts | 2 +- packages/ai/src/auth-storage.ts | 362 +------------ packages/ai/src/index.ts | 8 +- .../ai/src/provider-models/descriptors.ts | 393 ++------------ .../provider-models/discovery-constants.ts | 11 + .../ai/src/provider-models/openai-compat.ts | 8 +- packages/ai/src/providers/anthropic.ts | 2 +- .../src/providers/github-copilot-headers.ts | 2 +- packages/ai/src/providers/kimi.ts | 2 +- .../src/providers/openai-codex-responses.ts | 120 ++++- .../ai/src/providers/openai-completions.ts | 4 +- packages/ai/src/providers/openai-responses.ts | 2 +- .../oauth => registry}/alibaba-coding-plan.ts | 31 +- packages/ai/src/registry/amazon-bedrock.ts | 23 + packages/ai/src/registry/anthropic.ts | 29 + .../oauth => registry}/api-key-login.ts | 2 +- .../oauth => registry}/api-key-validation.ts | 0 packages/ai/src/registry/cerebras.ts | 28 + .../cloudflare-ai-gateway.ts | 25 +- packages/ai/src/registry/cursor.ts | 25 + .../src/{utils/oauth => registry}/deepseek.ts | 31 +- packages/ai/src/registry/derived.ts | 9 + .../src/{utils/oauth => registry}/firepass.ts | 16 +- packages/ai/src/registry/fireworks.ts | 27 + packages/ai/src/registry/github-copilot.ts | 26 + packages/ai/src/registry/gitlab-duo.ts | 21 + .../ai/src/registry/google-antigravity.ts | 23 + packages/ai/src/registry/google-gemini-cli.ts | 23 + packages/ai/src/registry/google-vertex.ts | 42 ++ packages/ai/src/registry/google.ts | 10 + packages/ai/src/registry/groq.ts | 10 + .../{utils/oauth => registry}/huggingface.ts | 33 +- packages/ai/src/registry/index.ts | 4 + .../ai/src/{utils/oauth => registry}/kagi.ts | 20 +- .../ai/src/{utils/oauth => registry}/kilo.ts | 17 +- packages/ai/src/registry/kimi-code.ts | 21 + .../src/{utils/oauth => registry}/litellm.ts | 25 +- packages/ai/src/registry/lm-studio.ts | 35 ++ packages/ai/src/registry/minimax-code-cn.ts | 14 + packages/ai/src/registry/minimax-code.ts | 14 + packages/ai/src/registry/minimax.ts | 8 + packages/ai/src/registry/mistral.ts | 10 + packages/ai/src/registry/moonshot.ts | 27 + packages/ai/src/registry/nanogpt.ts | 27 + .../src/{utils/oauth => registry}/nvidia.ts | 31 +- .../oauth/__tests__/xai-oauth.test.ts | 0 .../{utils => registry}/oauth/anthropic.ts | 0 .../oauth/callback-server.ts | 0 .../src/{utils => registry}/oauth/cursor.ts | 0 .../oauth/github-copilot.ts | 0 .../{utils => registry}/oauth/gitlab-duo.ts | 0 .../oauth/google-antigravity.ts | 0 .../oauth/google-gemini-cli.ts | 0 .../oauth/google-oauth-shared.ts | 0 packages/ai/src/registry/oauth/index.ts | 164 ++++++ .../ai/src/{utils => registry}/oauth/kimi.ts | 0 .../{utils => registry}/oauth/minimax-code.ts | 2 +- .../src/{utils => registry}/oauth/oauth.html | 0 .../{utils => registry}/oauth/openai-codex.ts | 0 .../src/{utils => registry}/oauth/opencode.ts | 0 .../{utils => registry}/oauth/perplexity.ts | 0 .../ai/src/{utils => registry}/oauth/pkce.ts | 0 .../ai/src/{utils => registry}/oauth/types.ts | 56 +- .../ai/src/{utils => registry}/oauth/wafer.ts | 2 +- .../{utils => registry}/oauth/xai-oauth.ts | 0 .../src/{utils => registry}/oauth/xiaomi.ts | 0 .../{utils/oauth => registry}/ollama-cloud.ts | 14 +- .../src/{utils/oauth => registry}/ollama.ts | 26 +- .../ai/src/registry/openai-codex-device.ts | 18 + packages/ai/src/registry/openai-codex.ts | 22 + packages/ai/src/registry/openai.ts | 10 + packages/ai/src/registry/opencode-go.ts | 16 + packages/ai/src/registry/opencode-zen.ts | 16 + .../{utils/oauth => registry}/openrouter.ts | 17 +- .../src/{utils/oauth => registry}/parallel.ts | 20 +- packages/ai/src/registry/perplexity.ts | 13 + .../src/{utils/oauth => registry}/qianfan.ts | 30 +- .../{utils/oauth => registry}/qwen-portal.ts | 37 +- packages/ai/src/registry/registry.ts | 141 +++++ packages/ai/src/registry/synthetic.ts | 27 + .../src/{utils/oauth => registry}/tavily.ts | 22 +- packages/ai/src/registry/together.ts | 27 + packages/ai/src/registry/types.ts | 118 ++++ .../src/{utils/oauth => registry}/venice.ts | 25 +- .../oauth => registry}/vercel-ai-gateway.ts | 31 +- .../ai/src/{utils/oauth => registry}/vllm.ts | 30 +- packages/ai/src/registry/wafer-pass.ts | 17 + packages/ai/src/registry/wafer-serverless.ts | 21 + packages/ai/src/registry/xai-oauth.ts | 27 + packages/ai/src/registry/xai.ts | 10 + .../ai/src/registry/xiaomi-token-plan-ams.ts | 17 + .../ai/src/registry/xiaomi-token-plan-cn.ts | 17 + .../ai/src/registry/xiaomi-token-plan-sgp.ts | 17 + packages/ai/src/registry/xiaomi.ts | 17 + .../ai/src/{utils/oauth => registry}/zai.ts | 33 +- packages/ai/src/registry/zenmux.ts | 27 + .../zhipu-coding-plan.ts} | 33 +- packages/ai/src/stream.ts | 124 +---- packages/ai/src/types.ts | 62 +-- packages/ai/src/usage/github-copilot.ts | 3 +- packages/ai/src/usage/kimi.ts | 2 +- packages/ai/src/utils/discovery/gemini.ts | 2 +- .../src/utils/discovery/openai-compatible.ts | 2 +- packages/ai/src/utils/oauth/cerebras.ts | 16 - packages/ai/src/utils/oauth/fireworks.ts | 15 - packages/ai/src/utils/oauth/index.ts | 502 ------------------ packages/ai/src/utils/oauth/lm-studio.ts | 38 -- packages/ai/src/utils/oauth/moonshot.ts | 23 - packages/ai/src/utils/oauth/nanogpt.ts | 15 - packages/ai/src/utils/oauth/synthetic.ts | 15 - packages/ai/src/utils/oauth/together.ts | 16 - packages/ai/src/utils/oauth/zenmux.ts | 15 - packages/ai/test/anthropic-alignment.test.ts | 7 +- packages/ai/test/anthropic-oauth.test.ts | 2 +- .../ai/test/auth-broker-refresher.test.ts | 2 +- packages/ai/test/auth-broker-wire.test.ts | 2 +- .../test/auth-storage-api-key-login.test.ts | 6 +- .../auth-storage-broker-no-sentinel.test.ts | 2 +- .../test/auth-storage-codex-selection.test.ts | 4 +- ...-storage-credential-disabled-event.test.ts | 2 +- .../auth-storage-force-refresh-rotate.test.ts | 2 +- .../auth-storage-oauth-refresh-race.test.ts | 2 +- .../ai/test/auth-storage-refresh-skew.test.ts | 2 +- .../test/callback-server-manual-input.test.ts | 4 +- .../github-copilot-anthropic-auth.test.ts | 2 +- packages/ai/test/github-copilot-login.test.ts | 2 +- packages/ai/test/github-copilot-oauth.test.ts | 2 +- .../test/google-gemini-cli-alignment.test.ts | 2 +- packages/ai/test/issue-1846-repro.test.ts | 2 +- packages/ai/test/issue-772-repro.test.ts | 2 +- packages/ai/test/issue-830-repro.test.ts | 2 +- packages/ai/test/issue-957-repro.test.ts | 2 +- packages/ai/test/kagi-login.test.ts | 2 +- packages/ai/test/kilo-login.test.ts | 2 +- packages/ai/test/minimax-code-login.test.ts | 2 +- packages/ai/test/nanogpt-login.test.ts | 2 +- packages/ai/test/oauth-deepseek.test.ts | 4 +- packages/ai/test/oauth.ts | 4 +- packages/ai/test/ollama-cloud-login.test.ts | 2 +- packages/ai/test/openai-codex-stream.test.ts | 63 +++ packages/ai/test/openrouter-login.test.ts | 2 +- packages/ai/test/provider-registry.test.ts | 102 ++++ packages/ai/test/remote-auth-store.test.ts | 2 +- packages/ai/test/synthetic-login.test.ts | 2 +- packages/ai/test/xiaomi-oauth.test.ts | 2 +- .../test/xiaomi-tp-login-integration.test.ts | 2 +- packages/ai/test/zenmux-login.test.ts | 2 +- packages/ai/test/zenmux-provider.test.ts | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/cli/auth-broker-cli.ts | 13 +- .../coding-agent/src/cli/auth-gateway-cli.ts | 2 +- packages/coding-agent/src/cli/list-models.ts | 5 + .../coding-agent/src/config/model-registry.ts | 83 ++- .../src/extensibility/extensions/types.ts | 9 +- .../src/extensibility/legacy-pi-ai-shim.ts | 2 +- .../extensibility/plugins/legacy-pi-compat.ts | 11 +- packages/coding-agent/src/mcp/oauth-flow.ts | 6 +- .../src/modes/components/login-dialog.ts | 2 +- .../src/modes/components/oauth-selector.ts | 4 +- .../modes/controllers/selector-controller.ts | 15 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 +- .../src/modes/setup-wizard/scenes/sign-in.ts | 14 +- .../src/slash-commands/builtin-registry.ts | 2 +- .../src/web/search/providers/codex.ts | 2 +- .../test/auth-storage-rotation.test.ts | 4 +- .../legacy-pi-ai-type-remap.test.ts | 2 +- .../model-registry-runtime-provider.test.ts | 4 +- .../modes/components/oauth-selector.test.ts | 2 +- .../test/pi-scope-aliases.test.ts | 14 +- .../sdk-credential-disabled-bridge.test.ts | 2 +- .../test/setup-wizard-sign-in.test.ts | 2 +- 175 files changed, 2146 insertions(+), 1983 deletions(-) create mode 100644 docs/adding-a-provider.md create mode 100644 packages/ai/src/provider-models/discovery-constants.ts rename packages/ai/src/{utils/oauth => registry}/alibaba-coding-plan.ts (60%) create mode 100644 packages/ai/src/registry/amazon-bedrock.ts create mode 100644 packages/ai/src/registry/anthropic.ts rename packages/ai/src/{utils/oauth => registry}/api-key-login.ts (98%) rename packages/ai/src/{utils/oauth => registry}/api-key-validation.ts (100%) create mode 100644 packages/ai/src/registry/cerebras.ts rename packages/ai/src/{utils/oauth => registry}/cloudflare-ai-gateway.ts (57%) create mode 100644 packages/ai/src/registry/cursor.ts rename packages/ai/src/{utils/oauth => registry}/deepseek.ts (57%) create mode 100644 packages/ai/src/registry/derived.ts rename packages/ai/src/{utils/oauth => registry}/firepass.ts (61%) create mode 100644 packages/ai/src/registry/fireworks.ts create mode 100644 packages/ai/src/registry/github-copilot.ts create mode 100644 packages/ai/src/registry/gitlab-duo.ts create mode 100644 packages/ai/src/registry/google-antigravity.ts create mode 100644 packages/ai/src/registry/google-gemini-cli.ts create mode 100644 packages/ai/src/registry/google-vertex.ts create mode 100644 packages/ai/src/registry/google.ts create mode 100644 packages/ai/src/registry/groq.ts rename packages/ai/src/{utils/oauth => registry}/huggingface.ts (61%) create mode 100644 packages/ai/src/registry/index.ts rename packages/ai/src/{utils/oauth => registry}/kagi.ts (74%) rename packages/ai/src/{utils/oauth => registry}/kilo.ts (80%) create mode 100644 packages/ai/src/registry/kimi-code.ts rename packages/ai/src/{utils/oauth => registry}/litellm.ts (58%) create mode 100644 packages/ai/src/registry/lm-studio.ts create mode 100644 packages/ai/src/registry/minimax-code-cn.ts create mode 100644 packages/ai/src/registry/minimax-code.ts create mode 100644 packages/ai/src/registry/minimax.ts create mode 100644 packages/ai/src/registry/mistral.ts create mode 100644 packages/ai/src/registry/moonshot.ts create mode 100644 packages/ai/src/registry/nanogpt.ts rename packages/ai/src/{utils/oauth => registry}/nvidia.ts (70%) rename packages/ai/src/{utils => registry}/oauth/__tests__/xai-oauth.test.ts (100%) rename packages/ai/src/{utils => registry}/oauth/anthropic.ts (100%) rename packages/ai/src/{utils => registry}/oauth/callback-server.ts (100%) rename packages/ai/src/{utils => registry}/oauth/cursor.ts (100%) rename packages/ai/src/{utils => registry}/oauth/github-copilot.ts (100%) rename packages/ai/src/{utils => registry}/oauth/gitlab-duo.ts (100%) rename packages/ai/src/{utils => registry}/oauth/google-antigravity.ts (100%) rename packages/ai/src/{utils => registry}/oauth/google-gemini-cli.ts (100%) rename packages/ai/src/{utils => registry}/oauth/google-oauth-shared.ts (100%) create mode 100644 packages/ai/src/registry/oauth/index.ts rename packages/ai/src/{utils => registry}/oauth/kimi.ts (100%) rename packages/ai/src/{utils => registry}/oauth/minimax-code.ts (97%) rename packages/ai/src/{utils => registry}/oauth/oauth.html (100%) rename packages/ai/src/{utils => registry}/oauth/openai-codex.ts (100%) rename packages/ai/src/{utils => registry}/oauth/opencode.ts (100%) rename packages/ai/src/{utils => registry}/oauth/perplexity.ts (100%) rename packages/ai/src/{utils => registry}/oauth/pkce.ts (100%) rename packages/ai/src/{utils => registry}/oauth/types.ts (58%) rename packages/ai/src/{utils => registry}/oauth/wafer.ts (97%) rename packages/ai/src/{utils => registry}/oauth/xai-oauth.ts (100%) rename packages/ai/src/{utils => registry}/oauth/xiaomi.ts (100%) rename packages/ai/src/{utils/oauth => registry}/ollama-cloud.ts (53%) rename packages/ai/src/{utils/oauth => registry}/ollama.ts (60%) create mode 100644 packages/ai/src/registry/openai-codex-device.ts create mode 100644 packages/ai/src/registry/openai-codex.ts create mode 100644 packages/ai/src/registry/openai.ts create mode 100644 packages/ai/src/registry/opencode-go.ts create mode 100644 packages/ai/src/registry/opencode-zen.ts rename packages/ai/src/{utils/oauth => registry}/openrouter.ts (52%) rename packages/ai/src/{utils/oauth => registry}/parallel.ts (71%) create mode 100644 packages/ai/src/registry/perplexity.ts rename packages/ai/src/{utils/oauth => registry}/qianfan.ts (63%) rename packages/ai/src/{utils/oauth => registry}/qwen-portal.ts (56%) create mode 100644 packages/ai/src/registry/registry.ts create mode 100644 packages/ai/src/registry/synthetic.ts rename packages/ai/src/{utils/oauth => registry}/tavily.ts (66%) create mode 100644 packages/ai/src/registry/together.ts create mode 100644 packages/ai/src/registry/types.ts rename packages/ai/src/{utils/oauth => registry}/venice.ts (65%) rename packages/ai/src/{utils/oauth => registry}/vercel-ai-gateway.ts (50%) rename packages/ai/src/{utils/oauth => registry}/vllm.ts (58%) create mode 100644 packages/ai/src/registry/wafer-pass.ts create mode 100644 packages/ai/src/registry/wafer-serverless.ts create mode 100644 packages/ai/src/registry/xai-oauth.ts create mode 100644 packages/ai/src/registry/xai.ts create mode 100644 packages/ai/src/registry/xiaomi-token-plan-ams.ts create mode 100644 packages/ai/src/registry/xiaomi-token-plan-cn.ts create mode 100644 packages/ai/src/registry/xiaomi-token-plan-sgp.ts create mode 100644 packages/ai/src/registry/xiaomi.ts rename packages/ai/src/{utils/oauth => registry}/zai.ts (60%) create mode 100644 packages/ai/src/registry/zenmux.ts rename packages/ai/src/{utils/oauth/zhipu.ts => registry/zhipu-coding-plan.ts} (61%) delete mode 100644 packages/ai/src/utils/oauth/cerebras.ts delete mode 100644 packages/ai/src/utils/oauth/fireworks.ts delete mode 100644 packages/ai/src/utils/oauth/index.ts delete mode 100644 packages/ai/src/utils/oauth/lm-studio.ts delete mode 100644 packages/ai/src/utils/oauth/moonshot.ts delete mode 100644 packages/ai/src/utils/oauth/nanogpt.ts delete mode 100644 packages/ai/src/utils/oauth/synthetic.ts delete mode 100644 packages/ai/src/utils/oauth/together.ts delete mode 100644 packages/ai/src/utils/oauth/zenmux.ts create mode 100644 packages/ai/test/provider-registry.test.ts diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md new file mode 100644 index 000000000..55bddd39e --- /dev/null +++ b/docs/adding-a-provider.md @@ -0,0 +1,85 @@ +# Adding a provider + +Providers in `packages/ai` are described by a single declarative +`ProviderDefinition` and collected in one registry. Every scattered structure — +the `KnownProvider` / `OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, +`DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the +`/login` provider list, the `refreshOAuthToken` / `AuthStorage.login` dispatch, +and the coding-agent callback maps — is **derived** from that registry. + +**Scope.** This is for a provider that reuses an existing wire API +(`openai-completions`, `anthropic-messages`, `google-generative-ai`, …) — the +common case for gateways and API-key providers, since stream dispatch keys on +`model.api`, not `model.provider`. Adding a *new wire protocol* (a new +`KnownApi`) is a separate task that also touches `stream.ts` dispatch, +`api-registry.ts`, and `types.ts`. + +## Shape + +For the common case, a provider is still **one new def file + one registry line**: + +1. **Create `packages/ai/src/registry/.ts`** exporting one + `export const Provider = { … } as const satisfies ProviderDefinition;`. +2. **Add it to the `ALL` array** in `packages/ai/src/registry/registry.ts` + (one import + one array entry). `ALL` order is the `/login` list order for + loginable providers. + +That is the full change for: +- env-key-only providers, +- providers with a simple inline API-key login flow, +- most OpenAI-compatible gateways. + +For a **non-trivial provider-local OAuth flow**, put the implementation in +`packages/ai/src/registry/oauth/.ts` and lazy-import it from the def +file. The shared OAuth flow infrastructure it builds on lives in the same +`registry/oauth/` directory. + +Either way, descriptors, default-model map, env-key map, login list, and refresh +dispatch all update automatically, and the `KnownProvider` / `OAuthProvider` +unions gain the new id by derivation. + +## `ProviderDefinition` fields + +See `packages/ai/src/registry/types.ts` for the authoritative, +JSDoc-annotated interface. Presence of a field opts the provider into a derived +structure: + +| Field | Effect | +|---|---| +| `id`, `name` | Required. `name` shows in the `/login` list. | +| `defaultModel` | Present ⇒ member of `KnownProvider` (a chat-model provider). | +| `createModelManagerOptions` | Runtime model-discovery factory. Present (and not `specialModelManager`) ⇒ appears in `PROVIDER_DESCRIPTORS`. | +| `allowUnauthenticated` | Runtime creates a model manager even without a key. | +| `dynamicModelsAuthoritative` | Successful discovery replaces bundled models. | +| `catalogDiscovery` | `{ label, envVars, oauthProvider?, allowUnauthenticated? }` for offline catalog generation (`generate-models.ts`). | +| `specialModelManager` | Bespoke runtime factory (`google-antigravity` / `google-gemini-cli` / `openai-codex`); excluded from `PROVIDER_DESCRIPTORS`. | +| `envKeys` | Env-var fallback for `getEnvApiKey`: a var name string or a `() => string \| undefined` resolver. | +| `login` | Interactive login. Present ⇒ member of `OAuthProvider`, shown in `/login`, dispatchable via `AuthStorage.login`. Returns an api-key `string` or `OAuthCredentials`. | +| `refreshToken` | OAuth refresher; omit for static-token providers (the dispatch returns credentials unchanged). | +| `storeCredentialsAs` | Store credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). | +| `callbackPort` | Present ⇒ entry in the auth-broker `CALLBACK_PORTS` map. | +| `pasteCodeFlow` | OAuth flow needs a pasted code/redirect URL ⇒ member of `PASTE_CODE_LOGIN_PROVIDERS`. | + +## Conventions + +- Use `... as const satisfies ProviderDefinition` so the literal `id` is preserved + for the union derivation. +- `login` / `refreshToken` for simple API-key or validation-based flows can live + directly in the provider def file (export the named login function there so + tests can import it directly). +- `login` / `refreshToken` for heavy provider-local OAuth flows MUST reach the + adjacent `registry/oauth/*` module via a dynamic-import + thunk (`const { loginX } = await import("./oauth/x"); return loginX(cb);`), + keeping those flows out of the eager startup graph. +- All OAuth code lives under `registry/oauth/`: the shared flow infra + (`callback-server`, `pkce`, `google-oauth-shared`, `types`, the runtime API + `index`) plus every provider flow, including the `github-copilot` / `kimi` / + `openai-codex` helpers reused by the streaming and usage layers. The non-OAuth + API-key helpers (`api-key-login`, `api-key-validation`) sit beside the def + files in `registry/`, since they back simple paste-an-API-key logins. +- For a simple OpenAI-compatible gateway, build the manager inline with the + exported `createSimpleOpenAICompletionsOptions(providerId, baseUrl, config)` — + no edits to `openai-compat.ts` required. +- A `ProviderDefinition` may also be registered at runtime by an extension via + `registerOAuthProvider` (the `AuthStorage.login` dispatcher handles built-ins + and extensions through the same path). diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f42857c26..2dc3b0223 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,9 +6,12 @@ - Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429 - Optimized the Anthropic `cch` attestation patch to locate the billing-header placeholder with native `Buffer.indexOf` (memmem) instead of a hand-rolled byte loop. The marker sits ~99% through the body (`messages` serializes before `system`), so the old scan walked almost the entire payload; output bytes are unchanged but the patch is ~7.5x faster (563µs -> 75µs on a 1MB body). +- Refactored provider configuration to a single-source registry (`provider-registry/`). The `KnownProvider`/`OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, `DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the `/login` provider list (`builtInOAuthProviders`), and the `refreshOAuthToken`/`AuthStorage.login` dispatch are all derived from it. Thin provider-specific login flows are now inlined into the provider def file; heavier provider-local oauth flows live adjacent under `provider-registry/providers/oauth/`; only shared oauth infra / streaming helpers remain in `utils/oauth/`. Adding a provider that reuses an existing wire API is now one new provider def plus one registry entry in the common case. Exposes `PROVIDER_REGISTRY`, `getProviderDefinition`, `ProviderDefinition`, and `PASTE_CODE_LOGIN_PROVIDERS`. ### Fixed +- Disabled OpenAI Codex Responses stream obfuscation by sending `stream_options.include_obfuscation=false`, reducing raw WebSocket/SSE debug noise and bandwidth. +- Interrupted OpenAI Codex Responses streams that emit long runs of whitespace-only tool-call argument deltas, preventing degenerate WebSocket/SSE responses from filling the raw stream buffer indefinitely. - Preserved streaming responses when Anthropic emits unrecognized content_block envelopes by ignoring unknown blocks and continuing to emit known content - Applied cache control to the most recent tool result block when building Anthropic OAuth payloads without a preceding text block, enabling ephemeral caching for tool-result-only messages - Kept Anthropic sampling parameters (temperature, top_p, top_k) when thinking is explicitly disabled diff --git a/packages/ai/package.json b/packages/ai/package.json index 436187a7c..3e3df85c8 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -120,13 +120,13 @@ "types": "./src/utils/discovery/*.ts", "import": "./src/utils/discovery/*.ts" }, - "./utils/oauth": { - "types": "./src/utils/oauth/index.ts", - "import": "./src/utils/oauth/index.ts" + "./oauth": { + "types": "./src/registry/oauth/index.ts", + "import": "./src/registry/oauth/index.ts" }, - "./utils/oauth/*": { - "types": "./src/utils/oauth/*.ts", - "import": "./src/utils/oauth/*.ts" + "./oauth/*": { + "types": "./src/registry/oauth/*.ts", + "import": "./src/registry/oauth/*.ts" }, "./utils/schema": { "types": "./src/utils/schema/index.ts", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 497fb7c25..a2a49f251 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -38,10 +38,10 @@ import { } from "../src/provider-models/openai-compat"; import { getGitLabDuoModels } from "../src/providers/gitlab-duo"; import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants"; +import type { OAuthProvider } from "../src/registry/oauth/types"; import type { Model } from "../src/types"; import { fetchAntigravityDiscoveryModels } from "../src/utils/discovery/antigravity"; import { fetchCodexModels } from "../src/utils/discovery/codex"; -import type { OAuthProvider } from "../src/utils/oauth/types"; const packageRoot = path.join(import.meta.dir, ".."); diff --git a/packages/ai/src/auth-broker/remote-store.ts b/packages/ai/src/auth-broker/remote-store.ts index c0f9f17a3..9523b832e 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -17,9 +17,9 @@ import { REMOTE_REFRESH_SENTINEL, type StoredAuthCredential, } from "../auth-storage"; +import type { OAuthCredentials } from "../registry/oauth/types"; import type { Provider } from "../types"; import type { UsageReport } from "../usage"; -import type { OAuthCredentials } from "../utils/oauth/types"; import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client"; import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types"; diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 49a3843ba..d61c4a3fe 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -13,6 +13,9 @@ import * as path from "node:path"; import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; import type { ApiKeyResolver } from "./auth-retry"; import { isUsageLimitError } from "./rate-limit-utils"; +import { getProviderDefinition } from "./registry"; +import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth"; +import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types"; import { getEnvApiKey, getEnvApiKeyName } from "./stream"; import type { Provider } from "./types"; import type { @@ -32,11 +35,6 @@ import { antigravityUsageProvider } from "./usage/google-antigravity"; import { kimiUsageProvider } from "./usage/kimi"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; import { zaiUsageProvider } from "./usage/zai"; -import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth"; -import { loginDeepSeek } from "./utils/oauth/deepseek"; -import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex"; -import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types"; -import { loginXiaomi, loginXiaomiTokenPlan } from "./utils/oauth/xiaomi"; const USAGE_RANKING_METRIC_EPSILON = 1e-9; @@ -1554,345 +1552,33 @@ export class AuthStorage { onPrompt: (prompt: { message: string; placeholder?: string }) => Promise; }, ): Promise { - let credentials: OAuthCredentials; const saveApiKeyCredential = async (apiKey: string): Promise => { const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey }; await this.set(provider, newCredential); }; const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }); - switch (provider) { - case "anthropic": { - const { loginAnthropic } = await import("./utils/oauth/anthropic"); - credentials = await loginAnthropic({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "xai-oauth": { - const { loginXAIOAuth } = await import("./utils/oauth/xai-oauth"); - credentials = await loginXAIOAuth({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "alibaba-coding-plan": { - const { loginAlibabaCodingPlan } = await import("./utils/oauth/alibaba-coding-plan"); - const apiKey = await loginAlibabaCodingPlan(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "github-copilot": { - const { loginGitHubCopilot } = await import("./utils/oauth/github-copilot"); - credentials = await loginGitHubCopilot({ - onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }), - onPrompt: ctrl.onPrompt, - onProgress: ctrl.onProgress, - signal: ctrl.signal, - }); - break; - } - case "google-gemini-cli": { - const { loginGeminiCli } = await import("./utils/oauth/google-gemini-cli"); - credentials = await loginGeminiCli({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "google-antigravity": { - const { loginAntigravity } = await import("./utils/oauth/google-antigravity"); - credentials = await loginAntigravity({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "openai-codex": { - const { loginOpenAICodex } = await import("./utils/oauth/openai-codex"); - credentials = await loginOpenAICodex({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "openai-codex-device": { - // Device/headless flow — stores credentials under "openai-codex" so the - // provider can pick them up without a separate provider configuration. - const deviceCredentials = await loginOpenAICodexDevice(ctrl); - const newCredential: OAuthCredential = { type: "oauth", ...deviceCredentials }; - await this.#upsertOAuthCredential("openai-codex", newCredential); - return; - } - case "gitlab-duo": { - const { loginGitLabDuo } = await import("./utils/oauth/gitlab-duo"); - credentials = await loginGitLabDuo({ - ...ctrl, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - }); - break; - } - case "kimi-code": { - const { loginKimi } = await import("./utils/oauth/kimi"); - credentials = await loginKimi(ctrl); - break; - } - case "kilo": { - const { loginKilo } = await import("./utils/oauth/kilo"); - credentials = await loginKilo(ctrl); - break; - } - case "cursor": { - const { loginCursor } = await import("./utils/oauth/cursor"); - credentials = await loginCursor( - url => ctrl.onAuth({ url }), - ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined, - ); - break; - } - case "perplexity": { - const { loginPerplexity } = await import("./utils/oauth/perplexity"); - credentials = await loginPerplexity(ctrl); - break; - } - case "huggingface": { - const { loginHuggingface } = await import("./utils/oauth/huggingface"); - const apiKey = await loginHuggingface(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "opencode-zen": - case "opencode-go": { - const { loginOpenCode } = await import("./utils/oauth/opencode"); - const apiKey = await loginOpenCode(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "lm-studio": { - const { loginLmStudio } = await import("./utils/oauth/lm-studio"); - const apiKey = await loginLmStudio(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "ollama": { - const { loginOllama } = await import("./utils/oauth/ollama"); - const apiKey = await loginOllama(ctrl); - if (!apiKey) { - return; - } - await saveApiKeyCredential(apiKey); - return; - } - case "ollama-cloud": { - const { loginOllamaCloud } = await import("./utils/oauth/ollama-cloud"); - const apiKey = await loginOllamaCloud(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "cerebras": { - const { loginCerebras } = await import("./utils/oauth/cerebras"); - const apiKey = await loginCerebras(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "deepseek": { - const apiKey = await loginDeepSeek(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "fireworks": { - const { loginFireworks } = await import("./utils/oauth/fireworks"); - const apiKey = await loginFireworks(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "firepass": { - const { loginFirepass } = await import("./utils/oauth/firepass"); - const apiKey = await loginFirepass(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "wafer-pass": { - const { loginWaferPass } = await import("./utils/oauth/wafer"); - const apiKey = await loginWaferPass(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "wafer-serverless": { - const { loginWaferServerless } = await import("./utils/oauth/wafer"); - const apiKey = await loginWaferServerless(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "zai": { - const { loginZai } = await import("./utils/oauth/zai"); - const apiKey = await loginZai(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "zhipu-coding-plan": { - const { loginZhipuCodingPlan } = await import("./utils/oauth/zhipu"); - const apiKey = await loginZhipuCodingPlan(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "qianfan": { - const { loginQianfan } = await import("./utils/oauth/qianfan"); - const apiKey = await loginQianfan(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "minimax-code": { - const { loginMiniMaxCode } = await import("./utils/oauth/minimax-code"); - const apiKey = await loginMiniMaxCode(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "minimax-code-cn": { - const { loginMiniMaxCodeCn } = await import("./utils/oauth/minimax-code"); - const apiKey = await loginMiniMaxCodeCn(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "synthetic": { - const { loginSynthetic } = await import("./utils/oauth/synthetic"); - const apiKey = await loginSynthetic(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "tavily": { - const { loginTavily } = await import("./utils/oauth/tavily"); - const apiKey = await loginTavily(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "venice": { - const { loginVenice } = await import("./utils/oauth/venice"); - const apiKey = await loginVenice(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "litellm": { - const { loginLiteLLM } = await import("./utils/oauth/litellm"); - const apiKey = await loginLiteLLM(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "moonshot": { - const { loginMoonshot } = await import("./utils/oauth/moonshot"); - const apiKey = await loginMoonshot(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "kagi": { - const { loginKagi } = await import("./utils/oauth/kagi"); - const apiKey = await loginKagi(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "nanogpt": { - const { loginNanoGPT } = await import("./utils/oauth/nanogpt"); - const apiKey = await loginNanoGPT(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "openrouter": { - const { loginOpenRouter } = await import("./utils/oauth/openrouter"); - const apiKey = await loginOpenRouter(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "together": { - const { loginTogether } = await import("./utils/oauth/together"); - const apiKey = await loginTogether(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "cloudflare-ai-gateway": { - const { loginCloudflareAiGateway } = await import("./utils/oauth/cloudflare-ai-gateway"); - const apiKey = await loginCloudflareAiGateway(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "vercel-ai-gateway": { - const { loginVercelAiGateway } = await import("./utils/oauth/vercel-ai-gateway"); - const apiKey = await loginVercelAiGateway(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "vllm": { - const { loginVllm } = await import("./utils/oauth/vllm"); - const apiKey = await loginVllm(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "parallel": { - const { loginParallel } = await import("./utils/oauth/parallel"); - const apiKey = await loginParallel(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "qwen-portal": { - const { loginQwenPortal } = await import("./utils/oauth/qwen-portal"); - const apiKey = await loginQwenPortal(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "nvidia": { - const { loginNvidia } = await import("./utils/oauth/nvidia"); - const apiKey = await loginNvidia(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi": { - const apiKey = await loginXiaomi(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi-token-plan-sgp": { - const apiKey = await loginXiaomiTokenPlan(ctrl, "sgp"); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi-token-plan-ams": { - const apiKey = await loginXiaomiTokenPlan(ctrl, "ams"); - await saveApiKeyCredential(apiKey); - return; - } - case "xiaomi-token-plan-cn": { - const apiKey = await loginXiaomiTokenPlan(ctrl, "cn"); - await saveApiKeyCredential(apiKey); - return; - } - case "zenmux": { - const { loginZenMux } = await import("./utils/oauth/zenmux"); - const apiKey = await loginZenMux(ctrl); - await saveApiKeyCredential(apiKey); - return; - } - default: { - const customProvider = getOAuthProvider(provider); - if (!customProvider) { - throw new Error(`Unknown OAuth provider: ${provider}`); - } - const customLoginResult = await customProvider.login({ - onAuth: info => ctrl.onAuth(info), - onProgress: ctrl.onProgress, - onPrompt: ctrl.onPrompt, - onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, - signal: ctrl.signal, - }); - if (typeof customLoginResult === "string") { - await saveApiKeyCredential(customLoginResult); - return; - } - credentials = customLoginResult; - break; - } + // Built-in registry first, then runtime-registered extension providers. + const def = getProviderDefinition(provider) ?? getOAuthProvider(provider); + if (!def?.login) { + throw new Error(`Unknown OAuth provider: ${provider}`); } - const newCredential: OAuthCredential = { type: "oauth", ...credentials }; - await this.#upsertOAuthCredential(provider, newCredential); + const result = await def.login({ + onAuth: ctrl.onAuth, + onProgress: ctrl.onProgress, + onPrompt: ctrl.onPrompt, + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, + signal: ctrl.signal, + }); + if (typeof result === "string") { + // Some flows (e.g. ollama) return "" to signal that no key was entered. + if (!result) { + return; + } + await saveApiKeyCredential(result); + return; + } + const newCredential: OAuthCredential = { type: "oauth", ...result }; + await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential); } /** diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index a9d3c724a..7209e8145 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -29,6 +29,7 @@ export * from "./providers/openai-completions"; export * from "./providers/openai-responses"; export * from "./providers/synthetic"; export * from "./rate-limit-utils"; +export * from "./registry"; export * from "./stream"; export * from "./types"; export * from "./usage"; @@ -43,13 +44,6 @@ export * from "./usage/zai"; export * from "./utils/anthropic-auth"; export * from "./utils/discovery"; export * from "./utils/event-stream"; -export * from "./utils/oauth"; -export type { - OAuthCredentials, - OAuthProvider, - OAuthProviderId, - OAuthProviderInfo, -} from "./utils/oauth/types"; export * from "./utils/overflow"; export * from "./utils/retry"; export * from "./utils/schema"; diff --git a/packages/ai/src/provider-models/descriptors.ts b/packages/ai/src/provider-models/descriptors.ts index e78168c99..5685eccb9 100644 --- a/packages/ai/src/provider-models/descriptors.ts +++ b/packages/ai/src/provider-models/descriptors.ts @@ -1,364 +1,43 @@ /** - * Unified provider descriptors — single source of truth for provider metadata - * used by both runtime model discovery (model-registry.ts) and catalog - * generation (generate-models.ts). + * Provider descriptors and the default-model map, derived from the single-source + * provider registry (`../registry`). + * + * The descriptor/catalog types and guards now live in the registry; they are + * re-exported here for back-compat with `generate-models.ts` and existing + * `@oh-my-pi/pi-ai/provider-models` consumers. */ -import type { ModelManagerOptions } from "../model-manager"; -import type { Api, KnownProvider } from "../types"; -import type { OAuthProvider } from "../utils/oauth/types"; -import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google"; -import { ollamaCloudModelManagerOptions } from "./ollama"; -import { - alibabaCodingPlanModelManagerOptions, - anthropicModelManagerOptions, - cerebrasModelManagerOptions, - cloudflareAiGatewayModelManagerOptions, - deepseekModelManagerOptions, - firepassModelManagerOptions, - fireworksModelManagerOptions, - githubCopilotModelManagerOptions, - groqModelManagerOptions, - huggingfaceModelManagerOptions, - kiloModelManagerOptions, - kimiCodeModelManagerOptions, - litellmModelManagerOptions, - lmStudioModelManagerOptions, - mistralModelManagerOptions, - moonshotModelManagerOptions, - nanoGptModelManagerOptions, - nvidiaModelManagerOptions, - ollamaModelManagerOptions, - openaiModelManagerOptions, - opencodeGoModelManagerOptions, - opencodeZenModelManagerOptions, - openrouterModelManagerOptions, - qianfanModelManagerOptions, - qwenPortalModelManagerOptions, - syntheticModelManagerOptions, - togetherModelManagerOptions, - veniceModelManagerOptions, - vercelAiGatewayModelManagerOptions, - vllmModelManagerOptions, - waferPassModelManagerOptions, - waferServerlessModelManagerOptions, - xaiModelManagerOptions, - xaiOAuthModelManagerOptions, - xiaomiModelManagerOptions, - zenmuxModelManagerOptions, - zhipuCodingPlanModelManagerOptions, -} from "./openai-compat"; -import { cursorModelManagerOptions, zaiModelManagerOptions } from "./special"; +import { PROVIDER_REGISTRY } from "../registry"; +import type { ProviderDescriptor } from "../registry/types"; +import type { KnownProvider } from "../types"; -/** Catalog discovery configuration for providers that support endpoint-based model listing. */ -export interface CatalogDiscoveryConfig { - /** Human-readable name for log messages. */ - label: string; - /** Environment variables to check for API keys during catalog generation. */ - envVars: string[]; - /** OAuth provider for credential refresh during catalog generation. */ - oauthProvider?: OAuthProvider; - /** When true, catalog discovery proceeds even without credentials. */ - allowUnauthenticated?: boolean; -} - -/** Unified provider descriptor used by both runtime discovery and catalog generation. */ -export interface ProviderDescriptor { - providerId: KnownProvider; - createModelManagerOptions(config: { apiKey?: string; baseUrl?: string }): ModelManagerOptions; - /** Preferred model ID when no explicit selection is made. */ - defaultModel: string; - /** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */ - allowUnauthenticated?: boolean; - /** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */ - dynamicModelsAuthoritative?: boolean; - /** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */ - catalogDiscovery?: CatalogDiscoveryConfig; -} - -/** A provider descriptor that has catalog discovery configured. */ -export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig }; - -/** Type guard for descriptors with catalog discovery. */ -export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor { - return d.catalogDiscovery != null; -} - -/** Whether catalog discovery may run without provider credentials. */ -export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean { - return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false; -} - -function descriptor( - providerId: KnownProvider, - defaultModel: string, - createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"], - options: Pick = {}, -): ProviderDescriptor { - return { - providerId, - defaultModel, - createModelManagerOptions, - ...options, - }; -} - -function catalog( - label: string, - envVars: string[], - options: Pick = {}, -): CatalogDiscoveryConfig { - return { - label, - envVars, - ...options, - }; -} - -function catalogDescriptor( - providerId: KnownProvider, - defaultModel: string, - createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"], - catalogDiscovery: CatalogDiscoveryConfig, - options: Pick = {}, -): ProviderDescriptor { - return { - ...descriptor(providerId, defaultModel, createModelManagerOptions, options), - catalogDiscovery, - }; -} +export * from "../registry/types"; /** - * All standard providers. Special providers (google-antigravity, google-gemini-cli, - * openai-codex) are handled separately because they require different config shapes. + * Runtime model-discovery descriptors: every registry provider that exposes a + * standard model-manager factory. Special-managed providers + * (`google-antigravity`/`google-gemini-cli`/`openai-codex`) are built bespoke in + * the coding-agent runtime and are excluded here. */ -export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [ - descriptor("anthropic", "claude-opus-4-6", config => anthropicModelManagerOptions(config)), - catalogDescriptor( - "alibaba-coding-plan", - "qwen3.5-plus", - config => alibabaCodingPlanModelManagerOptions(config), - catalog("Alibaba Coding Plan", ["ALIBABA_CODING_PLAN_API_KEY"]), - ), - descriptor("openai", "gpt-5.4", config => openaiModelManagerOptions(config)), - descriptor("groq", "openai/gpt-oss-120b", config => groqModelManagerOptions(config)), - catalogDescriptor( - "huggingface", - "deepseek-ai/DeepSeek-R1", - config => huggingfaceModelManagerOptions(config), - catalog("Hugging Face", ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]), - ), - catalogDescriptor( - "cerebras", - "zai-glm-4.6", - config => cerebrasModelManagerOptions(config), - catalog("Cerebras", ["CEREBRAS_API_KEY"]), - ), - catalogDescriptor( - "fireworks", - "kimi-k2.6", - config => fireworksModelManagerOptions(config), - catalog("Fireworks", ["FIREWORKS_API_KEY"]), - ), - // Fire Pass does not expose a /v1/models endpoint — the API returns HTTP 403 - // on any catalog-discovery request, so dynamic model listing is not feasible. - // - // The single model `kimi-k2.6-turbo` is seeded via the `prevModelsJson` - // fallback in `generate-models.ts`, which preserves entries from the previous - // catalog snapshot when a provider does not surface them dynamically. - // - // IMPORTANT: Do NOT delete the firepass section from models.json. No - // descriptor here produces that entry dynamically — removing it from - // models.json would permanently drop the model from the catalog with no - // automated mechanism to restore it. - descriptor("firepass", "kimi-k2.6-turbo", config => firepassModelManagerOptions(config)), - catalogDescriptor( - "wafer-pass", - "GLM-5.1", - config => waferPassModelManagerOptions(config), - catalog("Wafer Pass", ["WAFER_PASS_API_KEY"], { oauthProvider: "wafer-pass" }), - ), - catalogDescriptor( - "wafer-serverless", - "GLM-5.1", - config => waferServerlessModelManagerOptions(config), - catalog("Wafer Serverless", ["WAFER_SERVERLESS_API_KEY"], { oauthProvider: "wafer-serverless" }), - ), - descriptor("xai", "grok-4-fast-non-reasoning", config => xaiModelManagerOptions(config)), - catalogDescriptor( - "xai-oauth", - "grok-4.3", - config => xaiOAuthModelManagerOptions(config), - catalog("xAI Grok OAuth (SuperGrok)", ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], { - oauthProvider: "xai-oauth", - }), - ), - catalogDescriptor( - "deepseek", - "deepseek-v4-pro", - config => deepseekModelManagerOptions(config), - catalog("DeepSeek", ["DEEPSEEK_API_KEY"]), - ), - descriptor("mistral", "devstral-medium-latest", config => mistralModelManagerOptions(config)), - catalogDescriptor( - "nvidia", - "nvidia/llama-3.1-nemotron-70b-instruct", - config => nvidiaModelManagerOptions(config), - catalog("NVIDIA", ["NVIDIA_API_KEY"]), - ), - catalogDescriptor( - "nanogpt", - "openai/gpt-5.4", - config => nanoGptModelManagerOptions(config), - catalog("NanoGPT", ["NANO_GPT_API_KEY"]), - ), - descriptor("opencode-zen", "claude-sonnet-4-6", config => opencodeZenModelManagerOptions(config)), - descriptor("opencode-go", "kimi-k2.5", config => opencodeGoModelManagerOptions(config)), - catalogDescriptor( - "openrouter", - "openai/gpt-5.4", - config => openrouterModelManagerOptions(config), - catalog("OpenRouter", ["OPENROUTER_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "kilo", - "anthropic/claude-sonnet-4.5", - config => kiloModelManagerOptions(config), - catalog("Kilo Gateway", ["KILO_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "vercel-ai-gateway", - "anthropic/claude-sonnet-4-6", - config => vercelAiGatewayModelManagerOptions(config), - catalog("Vercel AI Gateway", ["VERCEL_AI_GATEWAY_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "ollama", - "gpt-oss:20b", - config => ollamaModelManagerOptions(config), - catalog("Ollama", ["OLLAMA_API_KEY"]), - { allowUnauthenticated: true }, - ), - catalogDescriptor( - "ollama-cloud", - "gpt-oss:120b", - config => ollamaCloudModelManagerOptions(config), - catalog("Ollama Cloud", ["OLLAMA_CLOUD_API_KEY"], { oauthProvider: "ollama-cloud" }), - ), - catalogDescriptor( - "cloudflare-ai-gateway", - "claude-sonnet-4-5", - config => cloudflareAiGatewayModelManagerOptions(config), - catalog("Cloudflare AI Gateway", ["CLOUDFLARE_AI_GATEWAY_API_KEY"]), - ), - catalogDescriptor( - "kimi-code", - "kimi-k2.5", - config => kimiCodeModelManagerOptions(config), - catalog("Kimi Code", ["KIMI_API_KEY"]), - ), - catalogDescriptor( - "qwen-portal", - "coder-model", - config => qwenPortalModelManagerOptions(config), - catalog("Qwen Portal", ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], { oauthProvider: "qwen-portal" }), - ), - catalogDescriptor( - "synthetic", - "hf:zai-org/GLM-5.1", - config => syntheticModelManagerOptions(config), - catalog("Synthetic", ["SYNTHETIC_API_KEY"]), - { dynamicModelsAuthoritative: true }, - ), - catalogDescriptor( - "venice", - "llama-3.3-70b", - config => veniceModelManagerOptions(config), - catalog("Venice", ["VENICE_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "litellm", - "claude-opus-4-6", - config => litellmModelManagerOptions(config), - catalog("LiteLLM", ["LITELLM_API_KEY"], { allowUnauthenticated: true }), - ), - descriptor("lm-studio", "llama-3-8b", config => lmStudioModelManagerOptions(config), { allowUnauthenticated: true }), - catalogDescriptor( - "vllm", - "gpt-oss-20b", - config => vllmModelManagerOptions(config), - catalog("vLLM", ["VLLM_API_KEY"], { allowUnauthenticated: true }), - ), - catalogDescriptor( - "moonshot", - "kimi-k2.5", - config => moonshotModelManagerOptions(config), - catalog("Moonshot", ["MOONSHOT_API_KEY"]), - ), - catalogDescriptor( - "qianfan", - "deepseek-v3.2", - config => qianfanModelManagerOptions(config), - catalog("Qianfan", ["QIANFAN_API_KEY"]), - ), - catalogDescriptor( - "together", - "moonshotai/Kimi-K2.5", - config => togetherModelManagerOptions(config), - catalog("Together", ["TOGETHER_API_KEY"]), - ), - catalogDescriptor( - "xiaomi", - "mimo-v2-flash", - config => xiaomiModelManagerOptions(config), - catalog("Xiaomi", ["XIAOMI_API_KEY"]), - ), - descriptor("xiaomi-token-plan-sgp", "mimo-v2.5", config => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }), - ), - descriptor("xiaomi-token-plan-ams", "mimo-v2.5", config => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }), - ), - descriptor("xiaomi-token-plan-cn", "mimo-v2.5", config => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }), - ), - catalogDescriptor( - "zenmux", - "anthropic/claude-opus-4.6", - config => zenmuxModelManagerOptions(config), - catalog("ZenMux", ["ZENMUX_API_KEY"]), - ), - catalogDescriptor("zai", "glm-5.1", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])), - catalogDescriptor( - "zhipu-coding-plan", - "glm-5.1", - config => zhipuCodingPlanModelManagerOptions(config), - catalog("Zhipu Coding Plan", ["ZHIPU_API_KEY"]), - ), - descriptor("github-copilot", "gpt-4o", config => githubCopilotModelManagerOptions(config)), - descriptor("google", "gemini-2.5-pro", config => googleModelManagerOptions(config)), - descriptor("google-vertex", "gemini-3-pro-preview", config => googleVertexModelManagerOptions(config), { - allowUnauthenticated: true, - }), - catalogDescriptor( - "cursor", - "claude-sonnet-4-6", - config => cursorModelManagerOptions(config), - catalog("Cursor", ["CURSOR_API_KEY"], { oauthProvider: "cursor" }), - ), -] as const; +export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = PROVIDER_REGISTRY.flatMap(provider => { + const { createModelManagerOptions } = provider; + if (!createModelManagerOptions || provider.specialModelManager) { + return []; + } + return [ + { + providerId: provider.id, + defaultModel: provider.defaultModel ?? "", + createModelManagerOptions, + allowUnauthenticated: provider.allowUnauthenticated, + dynamicModelsAuthoritative: provider.dynamicModelsAuthoritative, + catalogDiscovery: provider.catalogDiscovery, + }, + ]; +}); -/** Default model IDs for all known providers, built from descriptors + special providers. */ -export const DEFAULT_MODEL_PER_PROVIDER: Record = { - ...Object.fromEntries(PROVIDER_DESCRIPTORS.map(d => [d.providerId, d.defaultModel])), - // Providers not in PROVIDER_DESCRIPTORS (special auth or no standard discovery) - "alibaba-coding-plan": "qwen3.5-plus", - "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", - "google-antigravity": "gemini-3-pro-high", - "google-gemini-cli": "gemini-2.5-pro", - "google-vertex": "gemini-3-pro-preview", - minimax: "MiniMax-M2.5", - "minimax-code": "MiniMax-M2.5", - "minimax-code-cn": "MiniMax-M2.5", - "openai-codex": "gpt-5.4", - "gitlab-duo": "duo-chat-sonnet-4-5", -} as Record; +/** Default model IDs for all known providers, derived from the registry. */ +export const DEFAULT_MODEL_PER_PROVIDER: Record = Object.fromEntries( + PROVIDER_REGISTRY.filter(provider => provider.defaultModel != null).map( + provider => [provider.id, provider.defaultModel] as [string, string], + ), +) as Record; diff --git a/packages/ai/src/provider-models/discovery-constants.ts b/packages/ai/src/provider-models/discovery-constants.ts new file mode 100644 index 000000000..83d60ffba --- /dev/null +++ b/packages/ai/src/provider-models/discovery-constants.ts @@ -0,0 +1,11 @@ +/** + * Fallback context-window / max-output-token values for models discovered + * without limit metadata. + * + * Kept in a dependency-free leaf module (rather than `openai-compat.ts`) so the + * model-discovery helpers in `utils/discovery/*` can import them without pulling + * the package root barrel (`@oh-my-pi/pi-ai`) into the model-manager init graph, + * which would otherwise form an import cycle through the provider registry. + */ +export const UNK_CONTEXT_WINDOW = 222_222; +export const UNK_MAX_TOKENS = 8_888; diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index c529fb135..2313193a9 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1,6 +1,7 @@ import { Effort } from "../effort"; import type { ModelManagerOptions } from "../model-manager"; import { getBundledModels } from "../models"; +import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import type { Api, Model, Provider, ThinkingConfig } from "../types"; import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; import { @@ -9,8 +10,8 @@ import { type OpenAICompatibleModelRecord, } from "../utils/discovery/openai-compatible"; import { toFireworksPublicModelId } from "../utils/fireworks-model-id"; -import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants"; const MODELS_DEV_URL = "https://models.dev/api.json"; const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; @@ -441,7 +442,7 @@ function isLikelyNanoGptTextModelId(id: string): boolean { type SimpleProviderConfig = { apiKey?: string; baseUrl?: string }; -function createSimpleOpenAICompletionsOptions( +export function createSimpleOpenAICompletionsOptions( providerId: Parameters[0], defaultBaseUrl: string, config?: SimpleProviderConfig, @@ -2385,8 +2386,7 @@ export function anthropicModelManagerOptions( // Models.dev provider descriptors for generate-models.ts // --------------------------------------------------------------------------- -export const UNK_CONTEXT_WINDOW = 222_222; -export const UNK_MAX_TOKENS = 8_888; +export { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants"; /** Describes how to map models.dev API data for a single provider. */ export interface ModelsDevProviderDescriptor { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 61979d7b1..12adf6df0 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -19,6 +19,7 @@ import { } from "../model-thinking"; import { calculateCost } from "../models"; import { isUsageLimitError } from "../rate-limit-utils"; +import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream"; import type { Api, @@ -58,7 +59,6 @@ import { isFoundryEnabled } from "../utils/foundry"; import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector"; import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator"; import { parseStreamingJsonThrottled } from "../utils/json-parse"; -import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { notifyProviderResponse } from "../utils/provider-response"; import { isCopilotTransientModelError } from "../utils/retry"; import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema"; diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index c66fa8290..39576c369 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,5 +1,5 @@ +import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import type { Message } from "../types"; -import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; /** * Infer whether the current request to Copilot is user-initiated or agent-initiated. * Accepts `unknown[]` because providers may pass pre-converted message shapes. diff --git a/packages/ai/src/providers/kimi.ts b/packages/ai/src/providers/kimi.ts index af98f4f34..276fe3da4 100644 --- a/packages/ai/src/providers/kimi.ts +++ b/packages/ai/src/providers/kimi.ts @@ -9,9 +9,9 @@ * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output. */ +import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import type { Api, Context, Model } from "../types"; import type { AssistantMessageEventStream } from "../utils/event-stream"; -import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { type OpenAIAnthropicApiFormat, type OpenAIAnthropicShimOptions, diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f8eaedffe..05625372e 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -142,6 +142,12 @@ const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed be /** Max total time to spend retrying 429s with server-provided delays (5 minutes). */ const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000; const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]); +// Provider/model failure mode: Codex can keep a response alive by streaming +// whitespace-only function-call argument deltas forever. Those frames count as +// transport activity, so idle timers never fire; cap the run before raw debug +// buffers and partial JSON grow without semantic progress. +const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT = 256; +const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT = 16 * 1024; function isCodexStreamProgressEvent(event: unknown): boolean { if (isOpenAIResponsesProgressEvent(event)) return true; @@ -235,6 +241,20 @@ interface CodexStreamRuntime { providerRetryAttempt: number; sawTerminalEvent: boolean; canSafelyReplayWebsocketOverSse: boolean; + whitespaceToolCallArgumentsDelta?: CodexWhitespaceToolCallArgumentsDeltaState; +} + +interface CodexWhitespaceToolCallArgumentsDeltaState { + itemId: string; + outputIndex?: number; + consecutiveEvents: number; + consecutiveChars: number; + firstSequenceNumber?: number; + lastSequenceNumber?: number; +} + +interface CodexWhitespaceToolCallArgumentsDeltaInterruption { + message: string; } interface CodexStreamProcessingContext { @@ -945,9 +965,88 @@ function createCodexStreamRuntime(initial: { providerRetryAttempt: 0, sawTerminalEvent: false, canSafelyReplayWebsocketOverSse: true, + whitespaceToolCallArgumentsDelta: undefined, }; } +function resetWhitespaceToolCallArgumentsDelta(runtime: CodexStreamRuntime): void { + runtime.whitespaceToolCallArgumentsDelta = undefined; +} + +function isJsonWhitespaceOnly(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x20) { + return false; + } + } + return true; +} + +function readOptionalInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.trunc(value); +} + +function observeWhitespaceToolCallArgumentsDelta( + runtime: CodexStreamRuntime, + rawEvent: Record, + delta: string, +): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined { + if (!isJsonWhitespaceOnly(delta)) { + resetWhitespaceToolCallArgumentsDelta(runtime); + return undefined; + } + + const itemId = + typeof rawEvent.item_id === "string" && rawEvent.item_id.length > 0 + ? rawEvent.item_id + : (runtime.currentItem?.id ?? ""); + const outputIndex = readOptionalInteger(rawEvent.output_index); + const sequenceNumber = readOptionalInteger(rawEvent.sequence_number); + let state = runtime.whitespaceToolCallArgumentsDelta; + if (!state || state.itemId !== itemId || state.outputIndex !== outputIndex) { + state = { + itemId, + outputIndex, + consecutiveEvents: 0, + consecutiveChars: 0, + firstSequenceNumber: sequenceNumber, + }; + runtime.whitespaceToolCallArgumentsDelta = state; + } + + state.consecutiveEvents += 1; + state.consecutiveChars += delta.length; + state.lastSequenceNumber = sequenceNumber; + if ( + state.consecutiveEvents < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT && + state.consecutiveChars < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT + ) { + return undefined; + } + + const itemLabel = itemId ? ` for item ${itemId}` : ""; + const sequenceLabel = + state.firstSequenceNumber === undefined || state.lastSequenceNumber === undefined + ? "" + : `, sequence ${state.firstSequenceNumber}..${state.lastSequenceNumber}`; + return { + message: `Interrupted OpenAI Codex response after ${state.consecutiveEvents} consecutive whitespace-only tool-call argument delta events (${state.consecutiveChars} chars${sequenceLabel})${itemLabel}.`, + }; +} + +function interruptWhitespaceToolCallArgumentsDelta( + requestSetup: CodexRequestSetup, + runtime: CodexStreamRuntime, + interruption: CodexWhitespaceToolCallArgumentsDeltaInterruption, +): never { + const error = new Error(interruption.message); + requestSetup.requestAbortController.abort(error); + runtime.websocketState?.connection?.close("degenerate-tool-call"); + throw error; +} + async function processCodexResponseStream( context: CodexStreamProcessingContext, runtime: CodexStreamRuntime, @@ -984,8 +1083,9 @@ function handleCodexStreamEvent(args: { runtime: CodexStreamRuntime; rawEvent: Record; firstTokenTime?: number; + requestSetup: CodexRequestSetup; }): number | undefined { - const { model, output, stream, runtime, rawEvent } = args; + const { model, output, stream, runtime, rawEvent, requestSetup } = args; const eventType = typeof rawEvent.type === "string" ? rawEvent.type : ""; if (!eventType) return args.firstTokenTime; @@ -994,6 +1094,7 @@ function handleCodexStreamEvent(args: { let firstTokenTime = args.firstTokenTime; if (eventType === "response.output_item.added") { + resetWhitespaceToolCallArgumentsDelta(runtime); if (!firstTokenTime) firstTokenTime = Date.now(); const item = rawEvent.item as CodexEventItem; runtime.currentItem = item; @@ -1055,11 +1156,13 @@ function handleCodexStreamEvent(args: { } if (eventType === "response.function_call_arguments.delta") { - handleToolCallArgumentsDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex); + const interruption = handleToolCallArgumentsDelta(runtime, rawEvent, stream, output, blockIndex); + if (interruption) interruptWhitespaceToolCallArgumentsDelta(requestSetup, runtime, interruption); return firstTokenTime; } if (eventType === "response.function_call_arguments.done") { + resetWhitespaceToolCallArgumentsDelta(runtime); handleToolCallArgumentsDone(runtime.currentItem, runtime.currentBlock, rawEvent); return firstTokenTime; } @@ -1075,6 +1178,7 @@ function handleCodexStreamEvent(args: { } if (eventType === "response.output_item.done") { + resetWhitespaceToolCallArgumentsDelta(runtime); handleOutputItemDone(model, output, stream, runtime, rawEvent, blockIndex); return firstTokenTime; } @@ -1206,15 +1310,18 @@ function handleMessageTextDelta( } function handleToolCallArgumentsDelta( - currentItem: CodexEventItem | null, - currentBlock: CodexOutputBlock | null, + runtime: CodexStreamRuntime, rawEvent: Record, stream: AssistantMessageEventStream, output: AssistantMessage, blockIndex: () => number, -): void { - if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return; +): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined { + const currentItem = runtime.currentItem; + const currentBlock = runtime.currentBlock; + if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return undefined; const delta = (rawEvent as { delta?: string }).delta || ""; + const interruption = observeWhitespaceToolCallArgumentsDelta(runtime, rawEvent, delta); + if (interruption) return interruption; currentBlock.partialJson += delta; const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0); if (throttled) { @@ -1222,6 +1329,7 @@ function handleToolCallArgumentsDelta( currentBlock.lastParseLen = throttled.parsedLen; } stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output }); + return undefined; } function handleToolCallArgumentsDone( diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index f75174c91..e29ac13d5 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -13,6 +13,8 @@ import packageJson from "../../package.json" with { type: "json" }; import type { Effort } from "../effort"; import { getSupportedEfforts } from "../model-thinking"; import { calculateCost } from "../models"; +import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; +import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import { getEnvApiKey } from "../stream"; import { type AssistantMessage, @@ -53,8 +55,6 @@ import { iterateWithIdleTimeout, } from "../utils/idle-iterator"; import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse"; -import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; -import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { notifyProviderResponse } from "../utils/provider-response"; import { callWithCopilotModelRetry } from "../utils/retry"; import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema"; diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index f3f251099..897056dbd 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -6,6 +6,7 @@ import type { ResponseInput, ResponseStreamEvent, } from "openai/resources/responses/responses"; +import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import { getEnvApiKey } from "../stream"; import type { AssistantMessage, @@ -39,7 +40,6 @@ import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout, } from "../utils/idle-iterator"; -import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { notifyProviderResponse } from "../utils/provider-response"; import { callWithCopilotModelRetry } from "../utils/retry"; import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema"; diff --git a/packages/ai/src/utils/oauth/alibaba-coding-plan.ts b/packages/ai/src/registry/alibaba-coding-plan.ts similarity index 60% rename from packages/ai/src/utils/oauth/alibaba-coding-plan.ts rename to packages/ai/src/registry/alibaba-coding-plan.ts index 5d79dc2ea..c9dc04878 100644 --- a/packages/ai/src/utils/oauth/alibaba-coding-plan.ts +++ b/packages/ai/src/registry/alibaba-coding-plan.ts @@ -1,27 +1,12 @@ -/** - * Alibaba Coding Plan login flow. - * - * Alibaba Coding Plan provides OpenAI-compatible models via https://coding-intl.dashscope.aliyuncs.com/v1. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to Alibaba Cloud DashScope API key settings - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - +import { alibabaCodingPlanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://modelstudio.console.alibabacloud.com/"; const API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; const VALIDATION_MODEL = "qwen3.5-plus"; -/** - * Login to Alibaba Coding Plan. - * - * Opens browser to API keys page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginAlibabaCodingPlan(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Alibaba Coding Plan login requires onPrompt callback"); @@ -57,3 +42,13 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise< return trimmed; } + +export const alibabaCodingPlanProvider = { + id: "alibaba-coding-plan", + name: "Alibaba Coding Plan", + defaultModel: "qwen3.5-plus", + createModelManagerOptions: (config: ModelManagerConfig) => alibabaCodingPlanModelManagerOptions(config), + catalogDiscovery: { label: "Alibaba Coding Plan", envVars: ["ALIBABA_CODING_PLAN_API_KEY"] }, + envKeys: "ALIBABA_CODING_PLAN_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginAlibabaCodingPlan(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/amazon-bedrock.ts b/packages/ai/src/registry/amazon-bedrock.ts new file mode 100644 index 000000000..82fd01cdb --- /dev/null +++ b/packages/ai/src/registry/amazon-bedrock.ts @@ -0,0 +1,23 @@ +import { $env } from "@oh-my-pi/pi-utils"; +import type { ProviderDefinition } from "./types"; + +export const amazonBedrockProvider = { + id: "amazon-bedrock", + name: "Amazon Bedrock", + defaultModel: "us.anthropic.claude-opus-4-6-v1", + // Amazon Bedrock accepts bearer tokens, IAM keys, profiles, ECS/IRSA credential chains. + envKeys: () => { + const hasEcsCredentials = + !!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI; + const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN; + if ( + $env.AWS_PROFILE || + ($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) || + $env.AWS_BEARER_TOKEN_BEDROCK || + hasEcsCredentials || + hasWebIdentity + ) { + return ""; + } + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/anthropic.ts b/packages/ai/src/registry/anthropic.ts new file mode 100644 index 000000000..f53b6d7c0 --- /dev/null +++ b/packages/ai/src/registry/anthropic.ts @@ -0,0 +1,29 @@ +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { anthropicModelManagerOptions } from "../provider-models/openai-compat"; +import { isFoundryEnabled } from "../utils/foundry"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const anthropicProvider = { + id: "anthropic", + name: "Anthropic (Claude Pro/Max)", + defaultModel: "claude-opus-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => anthropicModelManagerOptions(config), + // Foundry mode optionally switches Anthropic auth to enterprise gateway credentials. + envKeys: () => + isFoundryEnabled() + ? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY") + : $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"), + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginAnthropic } = await import("./oauth/anthropic"); + return loginAnthropic(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshAnthropicToken } = await import("./oauth/anthropic"); + return refreshAnthropicToken(credentials.refresh); + }, + callbackPort: 54545, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/api-key-login.ts b/packages/ai/src/registry/api-key-login.ts similarity index 98% rename from packages/ai/src/utils/oauth/api-key-login.ts rename to packages/ai/src/registry/api-key-login.ts index 8b589107f..514b6e15a 100644 --- a/packages/ai/src/utils/oauth/api-key-login.ts +++ b/packages/ai/src/registry/api-key-login.ts @@ -11,7 +11,7 @@ import { validateApiKeyAgainstModelsEndpoint, validateOpenAICompatibleApiKey, } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController } from "./oauth/types"; type ChatCompletionsValidation = { kind: "chat-completions"; diff --git a/packages/ai/src/utils/oauth/api-key-validation.ts b/packages/ai/src/registry/api-key-validation.ts similarity index 100% rename from packages/ai/src/utils/oauth/api-key-validation.ts rename to packages/ai/src/registry/api-key-validation.ts diff --git a/packages/ai/src/registry/cerebras.ts b/packages/ai/src/registry/cerebras.ts new file mode 100644 index 000000000..98016c366 --- /dev/null +++ b/packages/ai/src/registry/cerebras.ts @@ -0,0 +1,28 @@ +import { cerebrasModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginCerebras = createApiKeyLogin({ + providerLabel: "Cerebras", + authUrl: "https://cloud.cerebras.ai/platform/", + instructions: "Copy your API key from the Cerebras dashboard", + promptMessage: "Paste your Cerebras API key", + placeholder: "csk-...", + validation: { + kind: "chat-completions", + provider: "Cerebras", + baseUrl: "https://api.cerebras.ai/v1", + model: "gpt-oss-120b", + }, +}); + +export const cerebrasProvider = { + id: "cerebras", + name: "Cerebras", + defaultModel: "zai-glm-4.6", + createModelManagerOptions: (config: ModelManagerConfig) => cerebrasModelManagerOptions(config), + catalogDiscovery: { label: "Cerebras", envVars: ["CEREBRAS_API_KEY"] }, + envKeys: "CEREBRAS_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginCerebras(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/cloudflare-ai-gateway.ts b/packages/ai/src/registry/cloudflare-ai-gateway.ts similarity index 57% rename from packages/ai/src/utils/oauth/cloudflare-ai-gateway.ts rename to packages/ai/src/registry/cloudflare-ai-gateway.ts index 9b564cf76..c0b64ab89 100644 --- a/packages/ai/src/utils/oauth/cloudflare-ai-gateway.ts +++ b/packages/ai/src/registry/cloudflare-ai-gateway.ts @@ -1,15 +1,6 @@ -/** - * Cloudflare AI Gateway login flow. - * - * Cloudflare AI Gateway proxies upstream model providers. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open Cloudflare AI Gateway docs/dashboard - * 2. User copies their Cloudflare AI Gateway token/API key - * 3. User pastes the API key into the CLI - */ - -import type { OAuthController } from "./types"; +import { cloudflareAiGatewayModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/authentication/"; @@ -46,3 +37,13 @@ export async function loginCloudflareAiGateway(options: OAuthController): Promis return trimmed; } + +export const cloudflareAiGatewayProvider = { + id: "cloudflare-ai-gateway", + name: "Cloudflare AI Gateway", + defaultModel: "claude-sonnet-4-5", + createModelManagerOptions: (config: ModelManagerConfig) => cloudflareAiGatewayModelManagerOptions(config), + catalogDiscovery: { label: "Cloudflare AI Gateway", envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"] }, + envKeys: "CLOUDFLARE_AI_GATEWAY_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginCloudflareAiGateway(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/cursor.ts b/packages/ai/src/registry/cursor.ts new file mode 100644 index 000000000..9d143e768 --- /dev/null +++ b/packages/ai/src/registry/cursor.ts @@ -0,0 +1,25 @@ +import { cursorModelManagerOptions } from "../provider-models/special"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const cursorProvider = { + id: "cursor", + name: "Cursor (Claude, GPT, etc.)", + defaultModel: "claude-sonnet-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => cursorModelManagerOptions(config), + catalogDiscovery: { label: "Cursor", envVars: ["CURSOR_API_KEY"], oauthProvider: "cursor" }, + envKeys: "CURSOR_ACCESS_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginCursor } = await import("./oauth/cursor"); + return loginCursor( + url => cb.onAuth({ url }), + cb.onProgress ? () => cb.onProgress?.("Waiting for browser authentication...") : undefined, + ); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshCursorToken } = await import("./oauth/cursor"); + return refreshCursorToken(credentials.refresh); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/deepseek.ts b/packages/ai/src/registry/deepseek.ts similarity index 57% rename from packages/ai/src/utils/oauth/deepseek.ts rename to packages/ai/src/registry/deepseek.ts index e0b6e2869..669f4288b 100644 --- a/packages/ai/src/utils/oauth/deepseek.ts +++ b/packages/ai/src/registry/deepseek.ts @@ -1,13 +1,7 @@ -/** - * DeepSeek login flow (API key paste against https://api.deepseek.com). - * - * Validation hits `GET /v1/models` so it authenticates the key without - * depending on a specific model being enabled on the account. The previous - * implementation issued a chat-completion against `deepseek-v4-pro`, which - * 404s for accounts without that preview model even when the key is valid. - */ +import { deepseekModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; -import type { OAuthController, OAuthPrompt } from "./types"; +import type { OAuthController, OAuthLoginCallbacks, OAuthPrompt } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const innerLogin = createApiKeyLogin({ providerLabel: "DeepSeek", @@ -22,17 +16,10 @@ const innerLogin = createApiKeyLogin({ }, }); -/** - * Normalize a pasted DeepSeek API key. - * - * Users frequently copy keys out of `curl` snippets that include the - * `Authorization: Bearer …` prefix. Strip it so validation does not fail - * with a confusing 401, and reject obviously empty input early. - */ export function normalizeDeepSeekApiKey(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { - return trimmed; // let the shared factory throw the canonical "API key is required" + return trimmed; } const stripped = trimmed.replace(/^bearer\b\s*/i, ""); if (!stripped) { @@ -51,3 +38,13 @@ export const loginDeepSeek = async (options: OAuthController): Promise = : options; return innerLogin(wrapped); }; + +export const deepseekProvider = { + id: "deepseek", + name: "DeepSeek", + defaultModel: "deepseek-v4-pro", + createModelManagerOptions: (config: ModelManagerConfig) => deepseekModelManagerOptions(config), + catalogDiscovery: { label: "DeepSeek", envVars: ["DEEPSEEK_API_KEY"] }, + envKeys: "DEEPSEEK_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginDeepSeek(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/derived.ts b/packages/ai/src/registry/derived.ts new file mode 100644 index 000000000..d72d6372c --- /dev/null +++ b/packages/ai/src/registry/derived.ts @@ -0,0 +1,9 @@ +import { PROVIDER_REGISTRY } from "./registry"; + +/** + * Providers whose OAuth flow needs a pasted code/redirect URL rather than a + * local callback server. Consumed by the coding-agent login UX. + */ +export const PASTE_CODE_LOGIN_PROVIDERS: ReadonlySet = new Set( + PROVIDER_REGISTRY.filter(p => p.pasteCodeFlow).map(p => p.id), +); diff --git a/packages/ai/src/utils/oauth/firepass.ts b/packages/ai/src/registry/firepass.ts similarity index 61% rename from packages/ai/src/utils/oauth/firepass.ts rename to packages/ai/src/registry/firepass.ts index 57689b681..3599c2a38 100644 --- a/packages/ai/src/utils/oauth/firepass.ts +++ b/packages/ai/src/registry/firepass.ts @@ -1,3 +1,8 @@ +import { firepassModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + /** * Fire Pass login flow. * @@ -7,8 +12,6 @@ * pings the chat completions endpoint with the router id directly. * See https://docs.fireworks.ai/firepass. */ -import { createApiKeyLogin } from "./api-key-login"; - export const loginFirepass = createApiKeyLogin({ providerLabel: "Fire Pass", authUrl: "https://app.fireworks.ai/settings/users/api-keys", @@ -22,3 +25,12 @@ export const loginFirepass = createApiKeyLogin({ model: "accounts/fireworks/routers/kimi-k2p6-turbo", }, }); + +export const firepassProvider = { + id: "firepass", + name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)", + defaultModel: "kimi-k2.6-turbo", + createModelManagerOptions: (config: ModelManagerConfig) => firepassModelManagerOptions(config), + envKeys: "FIREPASS_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginFirepass(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/fireworks.ts b/packages/ai/src/registry/fireworks.ts new file mode 100644 index 000000000..20d17d91d --- /dev/null +++ b/packages/ai/src/registry/fireworks.ts @@ -0,0 +1,27 @@ +import { fireworksModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginFireworks = createApiKeyLogin({ + providerLabel: "Fireworks", + authUrl: "https://app.fireworks.ai/settings/users/api-keys", + instructions: "Create or copy your Fireworks API key", + promptMessage: "Paste your Fireworks API key", + placeholder: "fw_...", + validation: { + kind: "models-endpoint", + provider: "Fireworks", + modelsUrl: "https://api.fireworks.ai/inference/v1/models", + }, +}); + +export const fireworksProvider = { + id: "fireworks", + name: "Fireworks", + defaultModel: "kimi-k2.6", + createModelManagerOptions: (config: ModelManagerConfig) => fireworksModelManagerOptions(config), + catalogDiscovery: { label: "Fireworks", envVars: ["FIREWORKS_API_KEY"] }, + envKeys: "FIREWORKS_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginFireworks(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/github-copilot.ts b/packages/ai/src/registry/github-copilot.ts new file mode 100644 index 000000000..b8e757ac6 --- /dev/null +++ b/packages/ai/src/registry/github-copilot.ts @@ -0,0 +1,26 @@ +import { githubCopilotModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const githubCopilotProvider = { + id: "github-copilot", + name: "GitHub Copilot", + defaultModel: "gpt-4o", + createModelManagerOptions: (config: ModelManagerConfig) => githubCopilotModelManagerOptions(config), + envKeys: "COPILOT_GITHUB_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginGitHubCopilot } = await import("./oauth/github-copilot"); + return loginGitHubCopilot({ + onAuth: (url, instructions) => cb.onAuth({ url, instructions }), + onPrompt: cb.onPrompt, + onProgress: cb.onProgress, + signal: cb.signal, + }); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshGitHubCopilotToken } = await import("./oauth/github-copilot"); + return refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/gitlab-duo.ts b/packages/ai/src/registry/gitlab-duo.ts new file mode 100644 index 000000000..aa6bf3775 --- /dev/null +++ b/packages/ai/src/registry/gitlab-duo.ts @@ -0,0 +1,21 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const gitlabDuoProvider = { + id: "gitlab-duo", + name: "GitLab Duo", + defaultModel: "duo-chat-sonnet-4-5", + envKeys: "GITLAB_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginGitLabDuo } = await import("./oauth/gitlab-duo"); + return loginGitLabDuo(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshGitLabDuoToken } = await import("./oauth/gitlab-duo"); + return refreshGitLabDuoToken(credentials); + }, + callbackPort: 8080, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google-antigravity.ts b/packages/ai/src/registry/google-antigravity.ts new file mode 100644 index 000000000..beeda90cc --- /dev/null +++ b/packages/ai/src/registry/google-antigravity.ts @@ -0,0 +1,23 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const googleAntigravityProvider = { + id: "google-antigravity", + name: "Antigravity (Gemini 3, Claude, GPT-OSS)", + defaultModel: "gemini-3-pro-high", + specialModelManager: true, + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginAntigravity } = await import("./oauth/google-antigravity"); + return loginAntigravity(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + if (!credentials.projectId) { + throw new Error("Antigravity credentials missing projectId"); + } + const { refreshAntigravityToken } = await import("./oauth/google-antigravity"); + return refreshAntigravityToken(credentials.refresh, credentials.projectId); + }, + callbackPort: 51121, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google-gemini-cli.ts b/packages/ai/src/registry/google-gemini-cli.ts new file mode 100644 index 000000000..e0552340f --- /dev/null +++ b/packages/ai/src/registry/google-gemini-cli.ts @@ -0,0 +1,23 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const googleGeminiCliProvider = { + id: "google-gemini-cli", + name: "Google Cloud Code Assist (Gemini CLI)", + defaultModel: "gemini-2.5-pro", + specialModelManager: true, + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginGeminiCli } = await import("./oauth/google-gemini-cli"); + return loginGeminiCli(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + if (!credentials.projectId) { + throw new Error("Google Cloud credentials missing projectId"); + } + const { refreshGoogleCloudToken } = await import("./oauth/google-gemini-cli"); + return refreshGoogleCloudToken(credentials.refresh, credentials.projectId); + }, + callbackPort: 8085, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google-vertex.ts b/packages/ai/src/registry/google-vertex.ts new file mode 100644 index 000000000..9dc6ca9b3 --- /dev/null +++ b/packages/ai/src/registry/google-vertex.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $env } from "@oh-my-pi/pi-utils"; +import { googleVertexModelManagerOptions } from "../provider-models/google"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +let cachedVertexAdcCredentialsExists: boolean | null = null; + +function hasVertexAdcCredentials(): boolean { + if (cachedVertexAdcCredentialsExists === null) { + const gacPath = $env.GOOGLE_APPLICATION_CREDENTIALS; + if (gacPath) { + cachedVertexAdcCredentialsExists = fs.existsSync(gacPath); + } else { + cachedVertexAdcCredentialsExists = fs.existsSync( + path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"), + ); + } + } + return cachedVertexAdcCredentialsExists; +} + +export const googleVertexProvider = { + id: "google-vertex", + name: "Google Vertex AI", + defaultModel: "gemini-3-pro-preview", + createModelManagerOptions: (config: ModelManagerConfig) => googleVertexModelManagerOptions(config), + allowUnauthenticated: true, + // Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials. + envKeys: () => { + if ($env.GOOGLE_CLOUD_API_KEY) { + return $env.GOOGLE_CLOUD_API_KEY; + } + const hasCredentials = hasVertexAdcCredentials(); + const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT); + const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION); + if (hasCredentials && hasProject && hasLocation) { + return ""; + } + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google.ts b/packages/ai/src/registry/google.ts new file mode 100644 index 000000000..2f4bfca49 --- /dev/null +++ b/packages/ai/src/registry/google.ts @@ -0,0 +1,10 @@ +import { googleModelManagerOptions } from "../provider-models/google"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const googleProvider = { + id: "google", + name: "Google Gemini", + defaultModel: "gemini-2.5-pro", + createModelManagerOptions: (config: ModelManagerConfig) => googleModelManagerOptions(config), + envKeys: "GEMINI_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/groq.ts b/packages/ai/src/registry/groq.ts new file mode 100644 index 000000000..7c636d6f7 --- /dev/null +++ b/packages/ai/src/registry/groq.ts @@ -0,0 +1,10 @@ +import { groqModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const groqProvider = { + id: "groq", + name: "Groq", + defaultModel: "openai/gpt-oss-120b", + createModelManagerOptions: (config: ModelManagerConfig) => groqModelManagerOptions(config), + envKeys: "GROQ_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/huggingface.ts b/packages/ai/src/registry/huggingface.ts similarity index 61% rename from packages/ai/src/utils/oauth/huggingface.ts rename to packages/ai/src/registry/huggingface.ts index ca5553f0f..d5d0f4115 100644 --- a/packages/ai/src/utils/oauth/huggingface.ts +++ b/packages/ai/src/registry/huggingface.ts @@ -1,29 +1,14 @@ -/** - * Hugging Face Inference login flow. - * - * Hugging Face Inference Providers expose an OpenAI-compatible endpoint via - * https://router.huggingface.co/v1. - * - * This is an API key flow: - * 1. Open browser to Hugging Face token settings - * 2. User creates/copies a token with Inference Providers permission - * 3. User pastes the token into the CLI - */ - +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { huggingfaceModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained"; const API_BASE_URL = "https://router.huggingface.co/v1"; const VALIDATION_MODEL = "openai/gpt-oss-120b"; -/** - * Login to Hugging Face Inference Providers. - * - * Opens browser to token settings, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginHuggingface(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Hugging Face login requires onPrompt callback"); @@ -60,3 +45,13 @@ export async function loginHuggingface(options: OAuthController): Promise huggingfaceModelManagerOptions(config), + catalogDiscovery: { label: "Hugging Face", envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] }, + envKeys: () => $pickenv("HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"), + login: (cb: OAuthLoginCallbacks) => loginHuggingface(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/index.ts b/packages/ai/src/registry/index.ts new file mode 100644 index 000000000..ba0f057af --- /dev/null +++ b/packages/ai/src/registry/index.ts @@ -0,0 +1,4 @@ +export * from "./derived"; +export * from "./oauth"; +export * from "./registry"; +export * from "./types"; diff --git a/packages/ai/src/utils/oauth/kagi.ts b/packages/ai/src/registry/kagi.ts similarity index 74% rename from packages/ai/src/utils/oauth/kagi.ts rename to packages/ai/src/registry/kagi.ts index 3833c43ad..e856a6b62 100644 --- a/packages/ai/src/utils/oauth/kagi.ts +++ b/packages/ai/src/registry/kagi.ts @@ -1,14 +1,5 @@ -/** - * Kagi login flow. - * - * Kagi web search uses an API key from the account settings page. - * This is an API key flow: - * 1. Open browser to Kagi API settings - * 2. User copies API key - * 3. User pastes key into CLI - */ - -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://kagi.com/settings/api"; @@ -45,3 +36,10 @@ export async function loginKagi(options: OAuthController): Promise { return trimmed; } + +export const kagiProvider = { + id: "kagi", + name: "Kagi", + envKeys: "KAGI_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginKagi(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/kilo.ts b/packages/ai/src/registry/kilo.ts similarity index 80% rename from packages/ai/src/utils/oauth/kilo.ts rename to packages/ai/src/registry/kilo.ts index 6b0293456..2c593554a 100644 --- a/packages/ai/src/utils/oauth/kilo.ts +++ b/packages/ai/src/registry/kilo.ts @@ -1,4 +1,6 @@ -import type { OAuthController, OAuthCredentials } from "./types"; +import { kiloModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthCredentials } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const KILO_DEVICE_AUTH_BASE_URL = "https://api.kilo.ai/api/device-auth"; const POLL_INTERVAL_MS = 5000; @@ -15,9 +17,6 @@ interface KiloDeviceAuthPollResponse { token?: string; } -/** - * Login with Kilo Gateway OAuth (device code flow). - */ export async function loginKilo(callbacks: OAuthController): Promise { const initiateResponse = await fetch(`${KILO_DEVICE_AUTH_BASE_URL}/codes`, { method: "POST", @@ -85,3 +84,13 @@ export async function loginKilo(callbacks: OAuthController): Promise kiloModelManagerOptions(config), + catalogDiscovery: { label: "Kilo Gateway", envVars: ["KILO_API_KEY"], allowUnauthenticated: true }, + envKeys: "KILO_API_KEY", + login: loginKilo, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/kimi-code.ts b/packages/ai/src/registry/kimi-code.ts new file mode 100644 index 000000000..aedb23beb --- /dev/null +++ b/packages/ai/src/registry/kimi-code.ts @@ -0,0 +1,21 @@ +import { kimiCodeModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const kimiCodeProvider = { + id: "kimi-code", + name: "Kimi Code", + defaultModel: "kimi-k2.5", + createModelManagerOptions: (config: ModelManagerConfig) => kimiCodeModelManagerOptions(config), + catalogDiscovery: { label: "Kimi Code", envVars: ["KIMI_API_KEY"] }, + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginKimi } = await import("./oauth/kimi"); + return loginKimi(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshKimiToken } = await import("./oauth/kimi"); + return refreshKimiToken(credentials.refresh); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/litellm.ts b/packages/ai/src/registry/litellm.ts similarity index 58% rename from packages/ai/src/utils/oauth/litellm.ts rename to packages/ai/src/registry/litellm.ts index 4ddd6542e..a84072092 100644 --- a/packages/ai/src/utils/oauth/litellm.ts +++ b/packages/ai/src/registry/litellm.ts @@ -1,15 +1,6 @@ -/** - * LiteLLM login flow. - * - * LiteLLM is an OpenAI-compatible proxy that routes requests to many upstream providers. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to LiteLLM docs/dashboard - * 2. User copies their LiteLLM API key - * 3. User pastes the API key into the CLI - */ - -import type { OAuthController } from "./types"; +import { litellmModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy"; @@ -45,3 +36,13 @@ export async function loginLiteLLM(options: OAuthController): Promise { return trimmed; } + +export const litellmProvider = { + id: "litellm", + name: "LiteLLM", + defaultModel: "claude-opus-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => litellmModelManagerOptions(config), + catalogDiscovery: { label: "LiteLLM", envVars: ["LITELLM_API_KEY"], allowUnauthenticated: true }, + envKeys: "LITELLM_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginLiteLLM(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/lm-studio.ts b/packages/ai/src/registry/lm-studio.ts new file mode 100644 index 000000000..869560b59 --- /dev/null +++ b/packages/ai/src/registry/lm-studio.ts @@ -0,0 +1,35 @@ +import { lmStudioModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +const PROVIDER_ID = "lm-studio"; +export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; + +export async function loginLmStudio(options: OAuthController): Promise { + if (!options.onPrompt) { + throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); + } + + const apiKey = await options.onPrompt({ + message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)", + placeholder: DEFAULT_LOCAL_TOKEN, + allowEmpty: true, + }); + + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } + + const trimmed = apiKey.trim(); + return trimmed || DEFAULT_LOCAL_TOKEN; +} + +export const lmStudioProvider = { + id: "lm-studio", + name: "LM Studio (Local OpenAI-compatible)", + defaultModel: "llama-3-8b", + createModelManagerOptions: (config: ModelManagerConfig) => lmStudioModelManagerOptions(config), + allowUnauthenticated: true, + envKeys: "LM_STUDIO_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginLmStudio(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/minimax-code-cn.ts b/packages/ai/src/registry/minimax-code-cn.ts new file mode 100644 index 000000000..c6b1ccd85 --- /dev/null +++ b/packages/ai/src/registry/minimax-code-cn.ts @@ -0,0 +1,14 @@ +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const minimaxCodeCnProvider = { + id: "minimax-code-cn", + name: "MiniMax Coding Plan (China)", + defaultModel: "MiniMax-M2.5", + envKeys: "MINIMAX_CODE_CN_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginMiniMaxCodeCn } = await import("./oauth/minimax-code"); + return loginMiniMaxCodeCn(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/minimax-code.ts b/packages/ai/src/registry/minimax-code.ts new file mode 100644 index 000000000..a9733d32f --- /dev/null +++ b/packages/ai/src/registry/minimax-code.ts @@ -0,0 +1,14 @@ +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const minimaxCodeProvider = { + id: "minimax-code", + name: "MiniMax Coding Plan (International)", + defaultModel: "MiniMax-M2.5", + envKeys: "MINIMAX_CODE_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginMiniMaxCode } = await import("./oauth/minimax-code"); + return loginMiniMaxCode(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/minimax.ts b/packages/ai/src/registry/minimax.ts new file mode 100644 index 000000000..c6ff9ae1f --- /dev/null +++ b/packages/ai/src/registry/minimax.ts @@ -0,0 +1,8 @@ +import type { ProviderDefinition } from "./types"; + +export const minimaxProvider = { + id: "minimax", + name: "MiniMax", + defaultModel: "MiniMax-M2.5", + envKeys: "MINIMAX_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/mistral.ts b/packages/ai/src/registry/mistral.ts new file mode 100644 index 000000000..b9bfc634d --- /dev/null +++ b/packages/ai/src/registry/mistral.ts @@ -0,0 +1,10 @@ +import { mistralModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const mistralProvider = { + id: "mistral", + name: "Mistral", + defaultModel: "devstral-medium-latest", + createModelManagerOptions: (config: ModelManagerConfig) => mistralModelManagerOptions(config), + envKeys: "MISTRAL_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/moonshot.ts b/packages/ai/src/registry/moonshot.ts new file mode 100644 index 000000000..52fa5dfea --- /dev/null +++ b/packages/ai/src/registry/moonshot.ts @@ -0,0 +1,27 @@ +import { moonshotModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginMoonshot = createApiKeyLogin({ + providerLabel: "Moonshot", + authUrl: "https://platform.moonshot.ai/console/api-keys", + instructions: "Copy your API key from the Moonshot dashboard", + promptMessage: "Paste your Moonshot API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "moonshot", + modelsUrl: "https://api.moonshot.ai/v1/models", + }, +}); + +export const moonshotProvider = { + id: "moonshot", + name: "Moonshot (Kimi API)", + defaultModel: "kimi-k2.5", + createModelManagerOptions: (config: ModelManagerConfig) => moonshotModelManagerOptions(config), + catalogDiscovery: { label: "Moonshot", envVars: ["MOONSHOT_API_KEY"] }, + envKeys: "MOONSHOT_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginMoonshot(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/nanogpt.ts b/packages/ai/src/registry/nanogpt.ts new file mode 100644 index 000000000..a04b8da99 --- /dev/null +++ b/packages/ai/src/registry/nanogpt.ts @@ -0,0 +1,27 @@ +import { nanoGptModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginNanoGPT = createApiKeyLogin({ + providerLabel: "NanoGPT", + authUrl: "https://nano-gpt.com/api", + instructions: "Create or copy your NanoGPT API key", + promptMessage: "Paste your NanoGPT API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "NanoGPT", + modelsUrl: "https://nano-gpt.com/api/v1/models", + }, +}); + +export const nanogptProvider = { + id: "nanogpt", + name: "NanoGPT", + defaultModel: "openai/gpt-5.4", + createModelManagerOptions: (config: ModelManagerConfig) => nanoGptModelManagerOptions(config), + catalogDiscovery: { label: "NanoGPT", envVars: ["NANO_GPT_API_KEY"] }, + envKeys: "NANO_GPT_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginNanoGPT(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/nvidia.ts b/packages/ai/src/registry/nvidia.ts similarity index 70% rename from packages/ai/src/utils/oauth/nvidia.ts rename to packages/ai/src/registry/nvidia.ts index 50fb10800..35a90af44 100644 --- a/packages/ai/src/utils/oauth/nvidia.ts +++ b/packages/ai/src/registry/nvidia.ts @@ -1,28 +1,13 @@ -/** - * NVIDIA login flow. - * - * NVIDIA provides OpenAI-compatible models via https://integrate.api.nvidia.com/v1. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to NVIDIA NGC catalog - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - +import { nvidiaModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://org.ngc.nvidia.com/setup/personal-keys"; const API_BASE_URL = "https://integrate.api.nvidia.com/v1"; const VALIDATION_MODEL = "nvidia/llama-3.1-nemotron-70b-instruct"; const PROVIDER_ID = "nvidia"; -/** - * Login to NVIDIA. - * - * Opens browser to NVIDIA dashboard, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginNvidia(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("NVIDIA login requires onPrompt callback"); @@ -68,3 +53,13 @@ export async function loginNvidia(options: OAuthController): Promise { return trimmed; } + +export const nvidiaProvider = { + id: "nvidia", + name: "NVIDIA", + defaultModel: "nvidia/llama-3.1-nemotron-70b-instruct", + createModelManagerOptions: (config: ModelManagerConfig) => nvidiaModelManagerOptions(config), + catalogDiscovery: { label: "NVIDIA", envVars: ["NVIDIA_API_KEY"] }, + envKeys: "NVIDIA_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginNvidia(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/__tests__/xai-oauth.test.ts b/packages/ai/src/registry/oauth/__tests__/xai-oauth.test.ts similarity index 100% rename from packages/ai/src/utils/oauth/__tests__/xai-oauth.test.ts rename to packages/ai/src/registry/oauth/__tests__/xai-oauth.test.ts diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/registry/oauth/anthropic.ts similarity index 100% rename from packages/ai/src/utils/oauth/anthropic.ts rename to packages/ai/src/registry/oauth/anthropic.ts diff --git a/packages/ai/src/utils/oauth/callback-server.ts b/packages/ai/src/registry/oauth/callback-server.ts similarity index 100% rename from packages/ai/src/utils/oauth/callback-server.ts rename to packages/ai/src/registry/oauth/callback-server.ts diff --git a/packages/ai/src/utils/oauth/cursor.ts b/packages/ai/src/registry/oauth/cursor.ts similarity index 100% rename from packages/ai/src/utils/oauth/cursor.ts rename to packages/ai/src/registry/oauth/cursor.ts diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/registry/oauth/github-copilot.ts similarity index 100% rename from packages/ai/src/utils/oauth/github-copilot.ts rename to packages/ai/src/registry/oauth/github-copilot.ts diff --git a/packages/ai/src/utils/oauth/gitlab-duo.ts b/packages/ai/src/registry/oauth/gitlab-duo.ts similarity index 100% rename from packages/ai/src/utils/oauth/gitlab-duo.ts rename to packages/ai/src/registry/oauth/gitlab-duo.ts diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/registry/oauth/google-antigravity.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-antigravity.ts rename to packages/ai/src/registry/oauth/google-antigravity.ts diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/registry/oauth/google-gemini-cli.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-gemini-cli.ts rename to packages/ai/src/registry/oauth/google-gemini-cli.ts diff --git a/packages/ai/src/utils/oauth/google-oauth-shared.ts b/packages/ai/src/registry/oauth/google-oauth-shared.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-oauth-shared.ts rename to packages/ai/src/registry/oauth/google-oauth-shared.ts diff --git a/packages/ai/src/registry/oauth/index.ts b/packages/ai/src/registry/oauth/index.ts new file mode 100644 index 000000000..a5d2e581d --- /dev/null +++ b/packages/ai/src/registry/oauth/index.ts @@ -0,0 +1,164 @@ +// ============================================================================ +// High-level API +// ============================================================================ + +import { getProviderDefinition, PROVIDER_REGISTRY } from "../registry"; +import type { + OAuthCredentials, + OAuthProvider, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, +} from "./types"; + +export type * from "./types"; + +const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter( + provider => provider.login && provider.showInLoginList !== false, +).map(provider => ({ + id: provider.id, + name: provider.name, + available: provider.available ?? true, +})); + +const customOAuthProviders = new Map(); + +/** + * Register a custom OAuth provider. + */ +export function registerOAuthProvider(provider: OAuthProviderInterface): void { + customOAuthProviders.set(provider.id, provider); +} + +/** + * Get a custom OAuth provider by ID. + */ +export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { + return customOAuthProviders.get(id); +} + +/** + * Remove all custom OAuth providers registered by a source. + */ +export function unregisterOAuthProviders(sourceId: string): void { + for (const [id, provider] of customOAuthProviders.entries()) { + if (provider.sourceId === sourceId) { + customOAuthProviders.delete(id); + } + } +} + +/** + * Refresh token for any OAuth provider. + * Saves the new credentials and returns the new access token. + */ +export async function refreshOAuthToken( + provider: OAuthProvider, + credentials: OAuthCredentials, +): Promise { + if (!credentials) { + throw new Error(`No OAuth credentials found for ${provider}`); + } + const def = getProviderDefinition(provider); + if (!def?.login) { + throw new Error(`Unknown OAuth provider: ${provider}`); + } + // Providers without a real refresher (static bearer tokens / API keys that + // don't expire) return the credentials unchanged. + return def.refreshToken ? def.refreshToken(credentials) : credentials; +} +function getPerplexityJwtExpiryMs(token: string): number | undefined { + const parts = token.split("."); + if (parts.length !== 3) return undefined; + const payload = parts[1]; + if (!payload) return undefined; + try { + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown }; + if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined; + return decoded.exp * 1000 - 5 * 60_000; + } catch { + return undefined; + } +} + +/** + * Build API-key bytes for a provider from an already-fresh OAuth credential. + * + * Refresh is owned by AuthStorage. This helper deliberately refuses expired + * credentials so it cannot POST broker redaction sentinels to upstream token + * endpoints as a side channel. + * + * For providers that need credential metadata at request time, returns + * JSON-encoded credentials plus expiry metadata for diagnostics/edge guards. + * @returns API key string, or null if no credentials + * @throws Error if the credential is expired and must be refreshed upstream + */ +export async function getOAuthApiKey( + provider: OAuthProvider, + credentials: Record, +): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { + let creds = credentials[provider]; + if (!creds) { + return null; + } + + if (provider === "perplexity") { + // Perplexity JWTs usually omit `exp` (server-side sessions). Trust the JWT + // claim when present; otherwise treat the credential as non-expiring rather + // than honoring a stale stored `expires` (older logins wrote loginTime+1h). + const NEVER_EXPIRES = 8.64e15; + const normalizedExpires = + creds.expires > 0 && creds.expires < 10_000_000_000 ? creds.expires * 1000 : creds.expires; + const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); + const expires = jwtExpiry ?? Math.max(normalizedExpires, NEVER_EXPIRES); + if (expires !== creds.expires) { + creds = { ...creds, expires }; + } + } + // Refresh is the sole responsibility of `AuthStorage` (which calls + // `refreshOAuthToken` directly with broker-aware single-flighting). If we + // reach here with an expired credential, the outer pipeline failed to + // refresh before this call OR the refresh slot is the broker sentinel — + // either way, posting the credential to a provider endpoint would only + // trigger a `__remote__`-against-real-provider failure that gets classified + // as `invalid_grant` and disables the row. Refuse loudly instead. + if (Date.now() >= creds.expires) { + if (provider === "perplexity") { + const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); + if (jwtExpiry && Date.now() < jwtExpiry) { + const fallbackCredentials = { ...creds, expires: jwtExpiry }; + return { newCredentials: fallbackCredentials, apiKey: fallbackCredentials.access }; + } + } + throw new Error( + `OAuth credential for ${provider} is expired and must be refreshed via AuthStorage before getOAuthApiKey is called`, + ); + } + // For providers that need request-time credential metadata, return JSON. + const needsStructuredApiKey = + provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity"; + const apiKey = needsStructuredApiKey + ? JSON.stringify({ + token: creds.access, + enterpriseUrl: creds.enterpriseUrl, + projectId: creds.projectId, + refreshToken: creds.refresh, + expiresAt: creds.expires, + email: creds.email, + accountId: creds.accountId, + }) + : creds.access; + return { newCredentials: creds, apiKey }; +} + +/** + * Get list of OAuth providers. + */ +export function getOAuthProviders(): OAuthProviderInfo[] { + const customProviders = Array.from(customOAuthProviders.values(), provider => ({ + id: provider.id, + name: provider.name, + available: true, + })); + return [...builtInOAuthProviders, ...customProviders]; +} diff --git a/packages/ai/src/utils/oauth/kimi.ts b/packages/ai/src/registry/oauth/kimi.ts similarity index 100% rename from packages/ai/src/utils/oauth/kimi.ts rename to packages/ai/src/registry/oauth/kimi.ts diff --git a/packages/ai/src/utils/oauth/minimax-code.ts b/packages/ai/src/registry/oauth/minimax-code.ts similarity index 97% rename from packages/ai/src/utils/oauth/minimax-code.ts rename to packages/ai/src/registry/oauth/minimax-code.ts index 0000ed31e..23e7db2f7 100644 --- a/packages/ai/src/utils/oauth/minimax-code.ts +++ b/packages/ai/src/registry/oauth/minimax-code.ts @@ -13,7 +13,7 @@ * China: https://api.minimaxi.com/v1 */ -import { validateOpenAICompatibleApiKey } from "./api-key-validation"; +import { validateOpenAICompatibleApiKey } from "../api-key-validation"; import type { OAuthController } from "./types"; const AUTH_URL_INTL = "https://platform.minimax.io/subscribe/coding-plan"; diff --git a/packages/ai/src/utils/oauth/oauth.html b/packages/ai/src/registry/oauth/oauth.html similarity index 100% rename from packages/ai/src/utils/oauth/oauth.html rename to packages/ai/src/registry/oauth/oauth.html diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/registry/oauth/openai-codex.ts similarity index 100% rename from packages/ai/src/utils/oauth/openai-codex.ts rename to packages/ai/src/registry/oauth/openai-codex.ts diff --git a/packages/ai/src/utils/oauth/opencode.ts b/packages/ai/src/registry/oauth/opencode.ts similarity index 100% rename from packages/ai/src/utils/oauth/opencode.ts rename to packages/ai/src/registry/oauth/opencode.ts diff --git a/packages/ai/src/utils/oauth/perplexity.ts b/packages/ai/src/registry/oauth/perplexity.ts similarity index 100% rename from packages/ai/src/utils/oauth/perplexity.ts rename to packages/ai/src/registry/oauth/perplexity.ts diff --git a/packages/ai/src/utils/oauth/pkce.ts b/packages/ai/src/registry/oauth/pkce.ts similarity index 100% rename from packages/ai/src/utils/oauth/pkce.ts rename to packages/ai/src/registry/oauth/pkce.ts diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/registry/oauth/types.ts similarity index 58% rename from packages/ai/src/utils/oauth/types.ts rename to packages/ai/src/registry/oauth/types.ts index 4fd9a887b..e7006c1cb 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/registry/oauth/types.ts @@ -1,3 +1,5 @@ +import type { OAuthProviderUnion } from "../registry"; + export type OAuthCredentials = { refresh: string; access: string; @@ -8,57 +10,7 @@ export type OAuthCredentials = { accountId?: string; }; -export type OAuthProvider = - | "alibaba-coding-plan" - | "anthropic" - | "cerebras" - | "cloudflare-ai-gateway" - | "cursor" - | "deepseek" - | "fireworks" - | "firepass" - | "github-copilot" - | "google-gemini-cli" - | "google-antigravity" - | "gitlab-duo" - | "huggingface" - | "kimi-code" - | "kilo" - | "kagi" - | "litellm" - | "lm-studio" - | "minimax-code" - | "minimax-code-cn" - | "moonshot" - | "nvidia" - | "nanogpt" - | "ollama" - | "ollama-cloud" - | "openai-codex" - | "openai-codex-device" - | "opencode-go" - | "openrouter" - | "opencode-zen" - | "parallel" - | "perplexity" - | "qianfan" - | "qwen-portal" - | "synthetic" - | "tavily" - | "together" - | "venice" - | "vercel-ai-gateway" - | "wafer-pass" - | "wafer-serverless" - | "vllm" - | "xai-oauth" - | "xiaomi" - | "xiaomi-token-plan-sgp" - | "xiaomi-token-plan-ams" - | "xiaomi-token-plan-cn" - | "zenmux" - | "zai" - | "zhipu-coding-plan"; +export type OAuthProvider = OAuthProviderUnion; export type OAuthProviderId = OAuthProvider | (string & {}); @@ -99,4 +51,6 @@ export interface OAuthProviderInterface { login(callbacks: OAuthLoginCallbacks): Promise; refreshToken?(credentials: OAuthCredentials): Promise; getApiKey?(credentials: OAuthCredentials): string; + /** Store resulting OAuth credentials under a different provider id. */ + readonly storeCredentialsAs?: string; } diff --git a/packages/ai/src/utils/oauth/wafer.ts b/packages/ai/src/registry/oauth/wafer.ts similarity index 97% rename from packages/ai/src/utils/oauth/wafer.ts rename to packages/ai/src/registry/oauth/wafer.ts index 490ceada6..d9d74a557 100644 --- a/packages/ai/src/utils/oauth/wafer.ts +++ b/packages/ai/src/registry/oauth/wafer.ts @@ -18,7 +18,7 @@ * Validation uses the shared `/v1/models` endpoint, which works for both * tiers and is cheap (no token spend). */ -import { createApiKeyLogin } from "./api-key-login"; +import { createApiKeyLogin } from "../api-key-login"; const WAFER_AUTH_URL = "https://wafer.ai/dashboard"; const WAFER_MODELS_URL = "https://pass.wafer.ai/v1/models"; diff --git a/packages/ai/src/utils/oauth/xai-oauth.ts b/packages/ai/src/registry/oauth/xai-oauth.ts similarity index 100% rename from packages/ai/src/utils/oauth/xai-oauth.ts rename to packages/ai/src/registry/oauth/xai-oauth.ts diff --git a/packages/ai/src/utils/oauth/xiaomi.ts b/packages/ai/src/registry/oauth/xiaomi.ts similarity index 100% rename from packages/ai/src/utils/oauth/xiaomi.ts rename to packages/ai/src/registry/oauth/xiaomi.ts diff --git a/packages/ai/src/utils/oauth/ollama-cloud.ts b/packages/ai/src/registry/ollama-cloud.ts similarity index 53% rename from packages/ai/src/utils/oauth/ollama-cloud.ts rename to packages/ai/src/registry/ollama-cloud.ts index 00caa7d31..322c042b1 100644 --- a/packages/ai/src/utils/oauth/ollama-cloud.ts +++ b/packages/ai/src/registry/ollama-cloud.ts @@ -1,4 +1,6 @@ -import type { OAuthController } from "./types"; +import { ollamaCloudModelManagerOptions } from "../provider-models/ollama"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const OLLAMA_CLOUD_KEYS_URL = "https://ollama.com/settings/keys"; @@ -26,3 +28,13 @@ export async function loginOllamaCloud(options: OAuthController): Promise ollamaCloudModelManagerOptions(config), + catalogDiscovery: { label: "Ollama Cloud", envVars: ["OLLAMA_CLOUD_API_KEY"], oauthProvider: "ollama-cloud" }, + envKeys: "OLLAMA_CLOUD_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginOllamaCloud(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/ollama.ts b/packages/ai/src/registry/ollama.ts similarity index 60% rename from packages/ai/src/utils/oauth/ollama.ts rename to packages/ai/src/registry/ollama.ts index 868d00efa..375452e51 100644 --- a/packages/ai/src/utils/oauth/ollama.ts +++ b/packages/ai/src/registry/ollama.ts @@ -1,16 +1,6 @@ -/** - * Ollama login flow. - * - * Ollama is typically used locally without authentication, but some hosted - * deployments require a bearer token/API key. - * - * This flow is API-key based (not OAuth): - * 1. Optionally open Ollama docs - * 2. Prompt user for API key/token (optional) - * 3. Persist key only when provided - */ - -import type { OAuthController } from "./types"; +import { ollamaModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const OLLAMA_DOCS_URL = "https://github.com/ollama/ollama/blob/main/docs/api.md"; @@ -45,3 +35,13 @@ export async function loginOllama(options: OAuthController): Promise { return apiKey.trim(); } + +export const ollamaProvider = { + id: "ollama", + name: "Ollama (Local OpenAI-compatible)", + defaultModel: "gpt-oss:20b", + createModelManagerOptions: (config: ModelManagerConfig) => ollamaModelManagerOptions(config), + allowUnauthenticated: true, + login: loginOllama, + envKeys: "OLLAMA_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/openai-codex-device.ts b/packages/ai/src/registry/openai-codex-device.ts new file mode 100644 index 000000000..95f9b3867 --- /dev/null +++ b/packages/ai/src/registry/openai-codex-device.ts @@ -0,0 +1,18 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const openaiCodexDeviceProvider = { + id: "openai-codex-device", + name: "ChatGPT Plus/Pro (Codex, headless/device)", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenAICodexDevice } = await import("./oauth/openai-codex"); + return loginOpenAICodexDevice(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshOpenAICodexToken } = await import("./oauth/openai-codex"); + return refreshOpenAICodexToken(credentials.refresh); + }, + storeCredentialsAs: "openai-codex", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/openai-codex.ts b/packages/ai/src/registry/openai-codex.ts new file mode 100644 index 000000000..3957bf788 --- /dev/null +++ b/packages/ai/src/registry/openai-codex.ts @@ -0,0 +1,22 @@ +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const openaiCodexProvider = { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + defaultModel: "gpt-5.4", + specialModelManager: true, + envKeys: "OPENAI_CODEX_OAUTH_TOKEN", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenAICodex } = await import("./oauth/openai-codex"); + return loginOpenAICodex(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshOpenAICodexToken } = await import("./oauth/openai-codex"); + return refreshOpenAICodexToken(credentials.refresh); + }, + callbackPort: 1455, + pasteCodeFlow: true, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/openai.ts b/packages/ai/src/registry/openai.ts new file mode 100644 index 000000000..1fe6b3c33 --- /dev/null +++ b/packages/ai/src/registry/openai.ts @@ -0,0 +1,10 @@ +import { openaiModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const openaiProvider = { + id: "openai", + name: "OpenAI", + defaultModel: "gpt-5.4", + createModelManagerOptions: (config: ModelManagerConfig) => openaiModelManagerOptions(config), + envKeys: "OPENAI_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/opencode-go.ts b/packages/ai/src/registry/opencode-go.ts new file mode 100644 index 000000000..b9dfdaf92 --- /dev/null +++ b/packages/ai/src/registry/opencode-go.ts @@ -0,0 +1,16 @@ +import { opencodeGoModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const opencodeGoProvider = { + id: "opencode-go", + name: "OpenCode Go", + defaultModel: "kimi-k2.5", + createModelManagerOptions: (config: ModelManagerConfig) => opencodeGoModelManagerOptions(config), + envKeys: "OPENCODE_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenCode } = await import("./oauth/opencode"); + return loginOpenCode(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/opencode-zen.ts b/packages/ai/src/registry/opencode-zen.ts new file mode 100644 index 000000000..87e652e13 --- /dev/null +++ b/packages/ai/src/registry/opencode-zen.ts @@ -0,0 +1,16 @@ +import { opencodeZenModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const opencodeZenProvider = { + id: "opencode-zen", + name: "OpenCode Zen", + defaultModel: "claude-sonnet-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => opencodeZenModelManagerOptions(config), + envKeys: "OPENCODE_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginOpenCode } = await import("./oauth/opencode"); + return loginOpenCode(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/openrouter.ts b/packages/ai/src/registry/openrouter.ts similarity index 52% rename from packages/ai/src/utils/oauth/openrouter.ts rename to packages/ai/src/registry/openrouter.ts index c3b539316..8951c79f7 100644 --- a/packages/ai/src/utils/oauth/openrouter.ts +++ b/packages/ai/src/registry/openrouter.ts @@ -1,11 +1,14 @@ +import { openrouterModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + /** OpenRouter login flow (API key paste, validated via /auth/key). * * `/api/v1/models` is public and returns 200 for any bearer (including bogus), * so it cannot validate auth. `/api/v1/auth/key` is the canonical "who am I" * endpoint — 200 for valid keys, 401 otherwise. */ -import { createApiKeyLogin } from "./api-key-login"; - export const loginOpenRouter = createApiKeyLogin({ providerLabel: "OpenRouter", authUrl: "https://openrouter.ai/keys", @@ -18,3 +21,13 @@ export const loginOpenRouter = createApiKeyLogin({ modelsUrl: "https://openrouter.ai/api/v1/auth/key", }, }); + +export const openrouterProvider = { + id: "openrouter", + name: "OpenRouter", + defaultModel: "openai/gpt-5.4", + createModelManagerOptions: (config: ModelManagerConfig) => openrouterModelManagerOptions(config), + catalogDiscovery: { label: "OpenRouter", envVars: ["OPENROUTER_API_KEY"], allowUnauthenticated: true }, + envKeys: "OPENROUTER_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginOpenRouter(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/parallel.ts b/packages/ai/src/registry/parallel.ts similarity index 71% rename from packages/ai/src/utils/oauth/parallel.ts rename to packages/ai/src/registry/parallel.ts index d2b614e63..ef3e98a9d 100644 --- a/packages/ai/src/utils/oauth/parallel.ts +++ b/packages/ai/src/registry/parallel.ts @@ -1,14 +1,5 @@ -/** - * Parallel login flow. - * - * Parallel uses an API key from the account settings page. - * This is an API key flow: - * 1. Open browser to Parallel API key settings - * 2. User copies API key - * 3. User pastes key into the CLI/TUI - */ - -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://platform.parallel.ai/settings?tab=api-keys"; @@ -44,3 +35,10 @@ export async function loginParallel(options: OAuthController): Promise { return trimmed; } + +export const parallelProvider = { + id: "parallel", + name: "Parallel", + envKeys: "PARALLEL_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginParallel(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/perplexity.ts b/packages/ai/src/registry/perplexity.ts new file mode 100644 index 000000000..11fd98b03 --- /dev/null +++ b/packages/ai/src/registry/perplexity.ts @@ -0,0 +1,13 @@ +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const perplexityProvider = { + id: "perplexity", + name: "Perplexity (Pro/Max)", + envKeys: "PERPLEXITY_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginPerplexity } = await import("./oauth/perplexity"); + return loginPerplexity(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/qianfan.ts b/packages/ai/src/registry/qianfan.ts similarity index 63% rename from packages/ai/src/utils/oauth/qianfan.ts rename to packages/ai/src/registry/qianfan.ts index 77850477f..7d6a6a73c 100644 --- a/packages/ai/src/utils/oauth/qianfan.ts +++ b/packages/ai/src/registry/qianfan.ts @@ -1,26 +1,12 @@ -/** - * Qianfan login flow. - * - * Qianfan provides an OpenAI-compatible API endpoint. - * Login is API-key based: - * 1. Open browser to Qianfan API key console - * 2. User copies API key - * 3. User pastes key into CLI prompt - */ - +import { qianfanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://console.bce.baidu.com/qianfan/ais/console/apiKey"; const API_BASE_URL = "https://qianfan.baidubce.com/v2"; const VALIDATION_MODEL = "deepseek-v3.2"; -/** - * Login to Qianfan. - * - * Opens browser to API key page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginQianfan(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Qianfan login requires onPrompt callback"); @@ -56,3 +42,13 @@ export async function loginQianfan(options: OAuthController): Promise { return trimmed; } + +export const qianfanProvider = { + id: "qianfan", + name: "Qianfan", + defaultModel: "deepseek-v3.2", + createModelManagerOptions: (config: ModelManagerConfig) => qianfanModelManagerOptions(config), + catalogDiscovery: { label: "Qianfan", envVars: ["QIANFAN_API_KEY"] }, + envKeys: "QIANFAN_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginQianfan(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/qwen-portal.ts b/packages/ai/src/registry/qwen-portal.ts similarity index 56% rename from packages/ai/src/utils/oauth/qwen-portal.ts rename to packages/ai/src/registry/qwen-portal.ts index 5ba843cc4..f398962d9 100644 --- a/packages/ai/src/utils/oauth/qwen-portal.ts +++ b/packages/ai/src/registry/qwen-portal.ts @@ -1,28 +1,13 @@ -/** - * Qwen Portal login flow. - * - * Qwen Portal exposes an OpenAI-compatible endpoint at https://portal.qwen.ai/v1 - * and accepts OAuth bearer tokens or API keys. - * - * This is a token/API-key flow: - * 1. Open Qwen Portal - * 2. Copy either your OAuth token or API key - * 3. Paste it into the CLI - */ - +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { qwenPortalModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://chat.qwen.ai"; const API_BASE_URL = "https://portal.qwen.ai/v1"; const VALIDATION_MODEL = "coder-model"; -/** - * Login to Qwen Portal. - * - * Prompts for either `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY` value. - * Returns the value directly (stored as api_key credential in auth storage). - */ export async function loginQwenPortal(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Qwen Portal login requires onPrompt callback"); @@ -58,3 +43,17 @@ export async function loginQwenPortal(options: OAuthController): Promise return trimmed; } + +export const qwenPortalProvider = { + id: "qwen-portal", + name: "Qwen Portal", + defaultModel: "coder-model", + createModelManagerOptions: (config: ModelManagerConfig) => qwenPortalModelManagerOptions(config), + catalogDiscovery: { + label: "Qwen Portal", + envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + oauthProvider: "qwen-portal", + }, + envKeys: () => $pickenv("QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"), + login: (cb: OAuthLoginCallbacks) => loginQwenPortal(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts new file mode 100644 index 000000000..43085522c --- /dev/null +++ b/packages/ai/src/registry/registry.ts @@ -0,0 +1,141 @@ +import { alibabaCodingPlanProvider } from "./alibaba-coding-plan"; +import { amazonBedrockProvider } from "./amazon-bedrock"; +import { anthropicProvider } from "./anthropic"; +import { cerebrasProvider } from "./cerebras"; +import { cloudflareAiGatewayProvider } from "./cloudflare-ai-gateway"; +import { cursorProvider } from "./cursor"; +import { deepseekProvider } from "./deepseek"; +import { firepassProvider } from "./firepass"; +import { fireworksProvider } from "./fireworks"; +import { githubCopilotProvider } from "./github-copilot"; +import { gitlabDuoProvider } from "./gitlab-duo"; +import { googleProvider } from "./google"; +import { googleAntigravityProvider } from "./google-antigravity"; +import { googleGeminiCliProvider } from "./google-gemini-cli"; +import { googleVertexProvider } from "./google-vertex"; +import { groqProvider } from "./groq"; +import { huggingfaceProvider } from "./huggingface"; +import { kagiProvider } from "./kagi"; +import { kiloProvider } from "./kilo"; +import { kimiCodeProvider } from "./kimi-code"; +import { litellmProvider } from "./litellm"; +import { lmStudioProvider } from "./lm-studio"; +import { minimaxProvider } from "./minimax"; +import { minimaxCodeProvider } from "./minimax-code"; +import { minimaxCodeCnProvider } from "./minimax-code-cn"; +import { mistralProvider } from "./mistral"; +import { moonshotProvider } from "./moonshot"; +import { nanogptProvider } from "./nanogpt"; +import { nvidiaProvider } from "./nvidia"; +import { ollamaProvider } from "./ollama"; +import { ollamaCloudProvider } from "./ollama-cloud"; +import { openaiProvider } from "./openai"; +import { openaiCodexProvider } from "./openai-codex"; +import { openaiCodexDeviceProvider } from "./openai-codex-device"; +import { opencodeGoProvider } from "./opencode-go"; +import { opencodeZenProvider } from "./opencode-zen"; +import { openrouterProvider } from "./openrouter"; +import { parallelProvider } from "./parallel"; +import { perplexityProvider } from "./perplexity"; +import { qianfanProvider } from "./qianfan"; +import { qwenPortalProvider } from "./qwen-portal"; +import { syntheticProvider } from "./synthetic"; +import { tavilyProvider } from "./tavily"; +import { togetherProvider } from "./together"; +import type { ProviderDefinition } from "./types"; +import { veniceProvider } from "./venice"; +import { vercelAiGatewayProvider } from "./vercel-ai-gateway"; +import { vllmProvider } from "./vllm"; +import { waferPassProvider } from "./wafer-pass"; +import { waferServerlessProvider } from "./wafer-serverless"; +import { xaiProvider } from "./xai"; +import { xaiOauthProvider } from "./xai-oauth"; +import { xiaomiProvider } from "./xiaomi"; +import { xiaomiTokenPlanAmsProvider } from "./xiaomi-token-plan-ams"; +import { xiaomiTokenPlanCnProvider } from "./xiaomi-token-plan-cn"; +import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp"; +import { zaiProvider } from "./zai"; +import { zenmuxProvider } from "./zenmux"; +import { zhipuCodingPlanProvider } from "./zhipu-coding-plan"; + +/** + * The single per-provider list. Adding a provider = create `./providers/.ts` + * and add its export here. Every legacy structure (`KnownProvider`/`OAuthProvider` + * unions, descriptors, env map, login list, refresh/login dispatch, CLI callback + * maps) is derived from this registry. Order matches the interactive `/login` + * list for the loginable providers; non-login model providers are appended. + */ +const ALL = [ + openaiCodexProvider, + anthropicProvider, + zaiProvider, + kimiCodeProvider, + openrouterProvider, + githubCopilotProvider, + cursorProvider, + googleAntigravityProvider, + googleGeminiCliProvider, + openaiCodexDeviceProvider, + xaiOauthProvider, + gitlabDuoProvider, + alibabaCodingPlanProvider, + zhipuCodingPlanProvider, + qwenPortalProvider, + minimaxCodeProvider, + minimaxCodeCnProvider, + xiaomiProvider, + xiaomiTokenPlanSgpProvider, + xiaomiTokenPlanAmsProvider, + xiaomiTokenPlanCnProvider, + firepassProvider, + waferPassProvider, + deepseekProvider, + moonshotProvider, + cerebrasProvider, + fireworksProvider, + togetherProvider, + nvidiaProvider, + huggingfaceProvider, + perplexityProvider, + qianfanProvider, + veniceProvider, + syntheticProvider, + nanogptProvider, + waferServerlessProvider, + vercelAiGatewayProvider, + cloudflareAiGatewayProvider, + litellmProvider, + kiloProvider, + zenmuxProvider, + opencodeZenProvider, + opencodeGoProvider, + tavilyProvider, + kagiProvider, + parallelProvider, + ollamaProvider, + ollamaCloudProvider, + lmStudioProvider, + vllmProvider, + openaiProvider, + googleProvider, + googleVertexProvider, + xaiProvider, + groqProvider, + mistralProvider, + minimaxProvider, + amazonBedrockProvider, +]; + +export type RegistryDef = (typeof ALL)[number]; +export const PROVIDER_REGISTRY: readonly ProviderDefinition[] = ALL; + +const BY_ID = new Map(ALL.map(p => [p.id, p] as [string, ProviderDefinition])); + +export function getProviderDefinition(id: string): ProviderDefinition | undefined { + return BY_ID.get(id); +} + +/** Chat-model providers (those carrying a `defaultModel`). */ +export type KnownProviderId = Extract["id"]; +/** Loginable providers (those carrying a `login` flow). */ +export type OAuthProviderUnion = Extract["id"]; diff --git a/packages/ai/src/registry/synthetic.ts b/packages/ai/src/registry/synthetic.ts new file mode 100644 index 000000000..8e859911e --- /dev/null +++ b/packages/ai/src/registry/synthetic.ts @@ -0,0 +1,27 @@ +import { syntheticModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginSynthetic = createApiKeyLogin({ + providerLabel: "Synthetic", + authUrl: "https://dev.synthetic.new/docs/api/overview", + instructions: "Copy your API key from the Synthetic dashboard", + promptMessage: "Paste your Synthetic API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "Synthetic", + modelsUrl: "https://api.synthetic.new/openai/v1/models", + }, +}); + +export const syntheticProvider = { + id: "synthetic", + name: "Synthetic", + defaultModel: "hf:zai-org/GLM-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => syntheticModelManagerOptions(config), + dynamicModelsAuthoritative: true, + catalogDiscovery: { label: "Synthetic", envVars: ["SYNTHETIC_API_KEY"] }, + envKeys: "SYNTHETIC_API_KEY", + login: loginSynthetic, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/tavily.ts b/packages/ai/src/registry/tavily.ts similarity index 66% rename from packages/ai/src/utils/oauth/tavily.ts rename to packages/ai/src/registry/tavily.ts index cc319e641..bc07ab743 100644 --- a/packages/ai/src/utils/oauth/tavily.ts +++ b/packages/ai/src/registry/tavily.ts @@ -1,14 +1,5 @@ -/** - * Tavily login flow. - * - * Tavily web search uses an API key from the account settings page. - * This is an API key flow: - * 1. Open browser to Tavily settings - * 2. User copies API key - * 3. User pastes key into CLI - */ - -import type { OAuthController } from "./types"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://app.tavily.com/home"; @@ -18,7 +9,7 @@ const AUTH_URL = "https://app.tavily.com/home"; * Opens browser to API keys page and prompts user to paste their API key. * Returns the API key directly (not OAuthCredentials - this isn't OAuth). */ -export async function loginTavily(options: OAuthController): Promise { +export async function loginTavily(options: OAuthLoginCallbacks): Promise { if (!options.onPrompt) { throw new Error("Tavily login requires onPrompt callback"); } @@ -44,3 +35,10 @@ export async function loginTavily(options: OAuthController): Promise { return trimmed; } + +export const tavilyProvider = { + id: "tavily", + name: "Tavily", + envKeys: "TAVILY_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginTavily(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/together.ts b/packages/ai/src/registry/together.ts new file mode 100644 index 000000000..1b2c9ff6f --- /dev/null +++ b/packages/ai/src/registry/together.ts @@ -0,0 +1,27 @@ +import { togetherModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginTogether = createApiKeyLogin({ + providerLabel: "Together", + authUrl: "https://api.together.xyz/settings/api-keys", + instructions: "Copy your API key from the Together dashboard", + promptMessage: "Paste your Together API key", + placeholder: "sk-...", + validation: { + kind: "chat-completions", + provider: "together", + baseUrl: "https://api.together.xyz/v1", + model: "moonshotai/Kimi-K2.5", + }, +}); + +export const togetherProvider = { + id: "together", + name: "Together", + defaultModel: "moonshotai/Kimi-K2.5", + createModelManagerOptions: (config: ModelManagerConfig) => togetherModelManagerOptions(config), + catalogDiscovery: { label: "Together", envVars: ["TOGETHER_API_KEY"] }, + envKeys: "TOGETHER_API_KEY", + login: (cb: Parameters[0]) => loginTogether(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/types.ts b/packages/ai/src/registry/types.ts new file mode 100644 index 000000000..1a65244fa --- /dev/null +++ b/packages/ai/src/registry/types.ts @@ -0,0 +1,118 @@ +/** + * Single-source provider model. Every provider — model providers, gateways, + * search/tool credentials, and login-only flows — is described by one + * {@link ProviderDefinition}. The legacy scattered structures (the + * `KnownProvider`/`OAuthProvider` unions, `PROVIDER_DESCRIPTORS`, + * `serviceProviderMap`, `builtInOAuthProviders`, the refresh/login switches, + * and the CLI callback maps) are all *derived* from the registry of these + * definitions. Adding a provider is one new file in `./providers/` plus one + * line in `./registry.ts`. + */ +import type { ModelManagerOptions } from "../model-manager"; +import type { Api } from "../types"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; + +/** Config passed to a provider's runtime model-manager factory. */ +export type ModelManagerConfig = { apiKey?: string; baseUrl?: string }; + +/** + * API-key environment fallback: either a single env var name (e.g. + * `"OPENAI_API_KEY"`) or a resolver that inspects several env vars / probes + * the host (Vertex ADC, Bedrock credential chains, …). + */ +export type KeyResolver = string | (() => string | undefined); + +/** Catalog discovery configuration for providers that support endpoint-based model listing. */ +export interface CatalogDiscoveryConfig { + /** Human-readable name for log messages. */ + label: string; + /** Environment variables to check for API keys during catalog generation. */ + envVars: readonly string[]; + /** OAuth provider for credential refresh during catalog generation. */ + oauthProvider?: string; + /** When true, catalog discovery proceeds even without credentials. */ + allowUnauthenticated?: boolean; +} + +/** Unified provider descriptor used by both runtime discovery and catalog generation. */ +export interface ProviderDescriptor { + providerId: string; + createModelManagerOptions(config: ModelManagerConfig): ModelManagerOptions; + /** Preferred model ID when no explicit selection is made. */ + defaultModel: string; + /** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */ + allowUnauthenticated?: boolean; + /** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */ + dynamicModelsAuthoritative?: boolean; + /** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */ + catalogDiscovery?: CatalogDiscoveryConfig; +} + +/** A provider descriptor that has catalog discovery configured. */ +export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig }; + +/** Type guard for descriptors with catalog discovery. */ +export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor { + return d.catalogDiscovery != null; +} + +/** Whether catalog discovery may run without provider credentials. */ +export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean { + return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false; +} + +/** + * Declarative description of a single provider. All fields are optional except + * `id`/`name`; presence of a field opts the provider into a derived structure: + * + * - `defaultModel` present ⇒ member of `KnownProvider` (a chat-model provider). + * - `createModelManagerOptions` present (and not `specialModelManager`) ⇒ + * appears in `PROVIDER_DESCRIPTORS` for runtime model discovery. + * - `envKeys` present ⇒ env-var fallback in `getEnvApiKey`. + * - `login` present ⇒ member of `OAuthProvider`, shown in the `/login` list + * (unless `showInLoginList === false`) and dispatchable via `AuthStorage.login`. + * - `callbackPort` present ⇒ entry in the auth-broker `CALLBACK_PORTS` map. + * - `pasteCodeFlow` ⇒ member of `PASTE_CODE_LOGIN_PROVIDERS`. + * + * Heavy OAuth flow modules MUST be reached through dynamic-import thunks in + * `login`/`refreshToken` so they stay out of the eager startup graph. + */ +export interface ProviderDefinition { + readonly id: string; + readonly name: string; + /** Login-list availability flag. Defaults to true when shown. */ + readonly available?: boolean; + /** Whether to surface in the interactive login list. Defaults to true when `login` is present. */ + readonly showInLoginList?: boolean; + // --- model discovery --- + /** Preferred model ID when no explicit selection is made. Presence ⇒ `KnownProvider` member. */ + readonly defaultModel?: string; + /** Runtime model-manager factory. Omitted for login-only tools and catalog-only providers. */ + readonly createModelManagerOptions?: (config: ModelManagerConfig) => ModelManagerOptions; + /** When true, the runtime creates a model manager even without a valid API key. */ + readonly allowUnauthenticated?: boolean; + /** When true, successful runtime discovery replaces bundled provider models. */ + readonly dynamicModelsAuthoritative?: boolean; + /** Catalog discovery configuration for generate-models.ts. */ + readonly catalogDiscovery?: CatalogDiscoveryConfig; + /** + * Providers whose model manager is constructed bespoke in the coding-agent + * runtime (`google-antigravity`/`google-gemini-cli`/`openai-codex`). Excluded + * from the derived `PROVIDER_DESCRIPTORS`; the registry supplies only their + * identity/login/refresh/default-model metadata. + */ + readonly specialModelManager?: boolean; + // --- env-var fallback --- + readonly envKeys?: KeyResolver; + // --- interactive login (OAuthProviderInterface-compatible) --- + readonly login?: (callbacks: OAuthLoginCallbacks) => Promise; + readonly refreshToken?: (credentials: OAuthCredentials) => Promise; + readonly getApiKey?: (credentials: OAuthCredentials) => string; + /** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */ + readonly storeCredentialsAs?: string; + // --- coding-agent login UX --- + /** Auth-broker local callback-server port. Presence ⇒ entry in `CALLBACK_PORTS`. */ + readonly callbackPort?: number; + /** OAuth flow needs a pasted code/redirect URL rather than a callback server. */ + readonly pasteCodeFlow?: boolean; +} diff --git a/packages/ai/src/utils/oauth/venice.ts b/packages/ai/src/registry/venice.ts similarity index 65% rename from packages/ai/src/utils/oauth/venice.ts rename to packages/ai/src/registry/venice.ts index fd378d800..f9fb3dee6 100644 --- a/packages/ai/src/utils/oauth/venice.ts +++ b/packages/ai/src/registry/venice.ts @@ -1,16 +1,7 @@ -/** - * Venice login flow. - * - * Venice provides OpenAI-compatible models via https://api.venice.ai/api/v1. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open browser to Venice API key settings - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - +import { veniceModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://venice.ai/settings/api"; const API_BASE_URL = "https://api.venice.ai/api/v1"; @@ -57,3 +48,13 @@ export async function loginVenice(options: OAuthController): Promise { return trimmed; } + +export const veniceProvider = { + id: "venice", + name: "Venice", + defaultModel: "llama-3.3-70b", + createModelManagerOptions: (config: ModelManagerConfig) => veniceModelManagerOptions(config), + catalogDiscovery: { label: "Venice", envVars: ["VENICE_API_KEY"], allowUnauthenticated: true }, + envKeys: "VENICE_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginVenice(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/vercel-ai-gateway.ts b/packages/ai/src/registry/vercel-ai-gateway.ts similarity index 50% rename from packages/ai/src/utils/oauth/vercel-ai-gateway.ts rename to packages/ai/src/registry/vercel-ai-gateway.ts index 3163c4869..5c3e266b2 100644 --- a/packages/ai/src/utils/oauth/vercel-ai-gateway.ts +++ b/packages/ai/src/registry/vercel-ai-gateway.ts @@ -1,24 +1,9 @@ -/** - * Vercel AI Gateway login flow. - * - * Vercel AI Gateway proxies upstream model providers through a unified endpoint. - * - * This is not OAuth - it's a simple API key flow: - * 1. Open Vercel AI Gateway docs - * 2. User copies their API key - * 3. User pastes the API key into the CLI - */ - -import type { OAuthController } from "./types"; +import { vercelAiGatewayModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi-keys&title=AI+Gateway+API+Keys"; -/** - * Login to Vercel AI Gateway. - * - * Opens browser to Vercel AI Gateway docs and prompts for an API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginVercelAiGateway(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Vercel AI Gateway login requires onPrompt callback"); @@ -45,3 +30,13 @@ export async function loginVercelAiGateway(options: OAuthController): Promise vercelAiGatewayModelManagerOptions(config), + catalogDiscovery: { label: "Vercel AI Gateway", envVars: ["VERCEL_AI_GATEWAY_API_KEY"], allowUnauthenticated: true }, + envKeys: "AI_GATEWAY_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginVercelAiGateway(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/vllm.ts b/packages/ai/src/registry/vllm.ts similarity index 58% rename from packages/ai/src/utils/oauth/vllm.ts rename to packages/ai/src/registry/vllm.ts index d2e10db1d..3e175be77 100644 --- a/packages/ai/src/utils/oauth/vllm.ts +++ b/packages/ai/src/registry/vllm.ts @@ -1,24 +1,12 @@ -/** - * vLLM login flow. - * - * vLLM is commonly self-hosted with an OpenAI-compatible API at a local base URL. - * Some deployments require a bearer token, others allow unauthenticated access. - * - * This flow stores an API-key-style credential used by `/login` and auth storage. - */ - -import type { OAuthController, OAuthProvider } from "./types"; +import { vllmModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthController, OAuthLoginCallbacks, OAuthProvider } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const PROVIDER_ID: OAuthProvider = "vllm"; const AUTH_URL = "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html"; const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8000/v1"; const DEFAULT_LOCAL_TOKEN = "vllm-local"; -/** - * Login to vLLM. - * - * Opens vLLM OpenAI-compatible auth docs, prompts for an optional token, - * and returns a stored key value. - */ + export async function loginVllm(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); @@ -38,3 +26,13 @@ export async function loginVllm(options: OAuthController): Promise { const trimmed = apiKey.trim(); return trimmed || DEFAULT_LOCAL_TOKEN; } + +export const vllmProvider = { + id: "vllm", + name: "vLLM (Local OpenAI-compatible)", + defaultModel: "gpt-oss-20b", + createModelManagerOptions: (config: ModelManagerConfig) => vllmModelManagerOptions(config), + catalogDiscovery: { label: "vLLM", envVars: ["VLLM_API_KEY"], allowUnauthenticated: true }, + envKeys: "VLLM_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginVllm(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/wafer-pass.ts b/packages/ai/src/registry/wafer-pass.ts new file mode 100644 index 000000000..357b05f06 --- /dev/null +++ b/packages/ai/src/registry/wafer-pass.ts @@ -0,0 +1,17 @@ +import { waferPassModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const waferPassProvider = { + id: "wafer-pass", + name: "Wafer Pass (flat-rate subscription)", + defaultModel: "GLM-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => waferPassModelManagerOptions(config), + catalogDiscovery: { label: "Wafer Pass", envVars: ["WAFER_PASS_API_KEY"], oauthProvider: "wafer-pass" }, + envKeys: "WAFER_PASS_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginWaferPass } = await import("./oauth/wafer"); + return loginWaferPass(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/wafer-serverless.ts b/packages/ai/src/registry/wafer-serverless.ts new file mode 100644 index 000000000..627c34f96 --- /dev/null +++ b/packages/ai/src/registry/wafer-serverless.ts @@ -0,0 +1,21 @@ +import { waferServerlessModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const waferServerlessProvider = { + id: "wafer-serverless", + name: "Wafer Serverless (pay-as-you-go)", + defaultModel: "GLM-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => waferServerlessModelManagerOptions(config), + catalogDiscovery: { + label: "Wafer Serverless", + envVars: ["WAFER_SERVERLESS_API_KEY"], + oauthProvider: "wafer-serverless", + }, + envKeys: "WAFER_SERVERLESS_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginWaferServerless } = await import("./oauth/wafer"); + return loginWaferServerless(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xai-oauth.ts b/packages/ai/src/registry/xai-oauth.ts new file mode 100644 index 000000000..fe020b24e --- /dev/null +++ b/packages/ai/src/registry/xai-oauth.ts @@ -0,0 +1,27 @@ +import { $pickenv } from "@oh-my-pi/pi-utils"; +import { xaiOAuthModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xaiOauthProvider = { + id: "xai-oauth", + name: "xAI Grok OAuth (SuperGrok Subscription)", + defaultModel: "grok-4.3", + createModelManagerOptions: (config: ModelManagerConfig) => xaiOAuthModelManagerOptions(config), + catalogDiscovery: { + label: "xAI Grok OAuth (SuperGrok)", + envVars: ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], + oauthProvider: "xai-oauth", + }, + envKeys: () => $pickenv("XAI_OAUTH_TOKEN", "XAI_API_KEY"), + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXAIOAuth } = await import("./oauth/xai-oauth"); + return loginXAIOAuth(cb); + }, + refreshToken: async (credentials: OAuthCredentials) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { refreshXAIOAuthToken } = await import("./oauth/xai-oauth"); + return refreshXAIOAuthToken(credentials.refresh); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xai.ts b/packages/ai/src/registry/xai.ts new file mode 100644 index 000000000..1afc01545 --- /dev/null +++ b/packages/ai/src/registry/xai.ts @@ -0,0 +1,10 @@ +import { xaiModelManagerOptions } from "../provider-models/openai-compat"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xaiProvider = { + id: "xai", + name: "xAI", + defaultModel: "grok-4-fast-non-reasoning", + createModelManagerOptions: (config: ModelManagerConfig) => xaiModelManagerOptions(config), + envKeys: "XAI_API_KEY", +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi-token-plan-ams.ts b/packages/ai/src/registry/xiaomi-token-plan-ams.ts new file mode 100644 index 000000000..bd1e13ad8 --- /dev/null +++ b/packages/ai/src/registry/xiaomi-token-plan-ams.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiTokenPlanAmsProvider = { + id: "xiaomi-token-plan-ams", + name: "Xiaomi Token Plan (Europe)", + defaultModel: "mimo-v2.5", + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }), + envKeys: "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); + return loginXiaomiTokenPlan(cb, "ams"); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi-token-plan-cn.ts b/packages/ai/src/registry/xiaomi-token-plan-cn.ts new file mode 100644 index 000000000..c0d4fcdf8 --- /dev/null +++ b/packages/ai/src/registry/xiaomi-token-plan-cn.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiTokenPlanCnProvider = { + id: "xiaomi-token-plan-cn", + name: "Xiaomi Token Plan (China)", + defaultModel: "mimo-v2.5", + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }), + envKeys: "XIAOMI_TOKEN_PLAN_CN_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); + return loginXiaomiTokenPlan(cb, "cn"); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi-token-plan-sgp.ts b/packages/ai/src/registry/xiaomi-token-plan-sgp.ts new file mode 100644 index 000000000..63a9de7b6 --- /dev/null +++ b/packages/ai/src/registry/xiaomi-token-plan-sgp.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiTokenPlanSgpProvider = { + id: "xiaomi-token-plan-sgp", + name: "Xiaomi Token Plan (Singapore)", + defaultModel: "mimo-v2.5", + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }), + envKeys: "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); + return loginXiaomiTokenPlan(cb, "sgp"); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi.ts b/packages/ai/src/registry/xiaomi.ts new file mode 100644 index 000000000..ea56a313e --- /dev/null +++ b/packages/ai/src/registry/xiaomi.ts @@ -0,0 +1,17 @@ +import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const xiaomiProvider = { + id: "xiaomi", + name: "Xiaomi MiMo", + defaultModel: "mimo-v2-flash", + createModelManagerOptions: (config: ModelManagerConfig) => xiaomiModelManagerOptions(config), + catalogDiscovery: { label: "Xiaomi", envVars: ["XIAOMI_API_KEY"] }, + envKeys: "XIAOMI_API_KEY", + login: async (cb: OAuthLoginCallbacks) => { + // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. + const { loginXiaomi } = await import("./oauth/xiaomi"); + return loginXiaomi(cb); + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/zai.ts b/packages/ai/src/registry/zai.ts similarity index 60% rename from packages/ai/src/utils/oauth/zai.ts rename to packages/ai/src/registry/zai.ts index 90b84ba44..641e0147b 100644 --- a/packages/ai/src/utils/oauth/zai.ts +++ b/packages/ai/src/registry/zai.ts @@ -1,39 +1,22 @@ -/** - * Z.AI login flow. - * - * Z.AI is a platform that provides access to GLM models through an OpenAI-compatible API. - * API docs: https://docs.z.ai/guides/overview/quick-start - * - * This is not OAuth - it's a simple API key flow: - * 1. User gets their API key from https://z.ai/settings/api-keys - * 2. User pastes the API key into the CLI - */ - +import { zaiModelManagerOptions } from "../provider-models/special"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://z.ai/manage-apikey/apikey-list"; const API_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; const VALIDATION_MODEL = "glm-4.7"; -/** - * Login to Z.AI. - * - * Opens browser to API keys page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginZai(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Z.AI login requires onPrompt callback"); } - // Open browser to API keys page options.onAuth?.({ url: AUTH_URL, instructions: "Copy your API key from the dashboard", }); - // Prompt user to paste their API key const apiKey = await options.onPrompt({ message: "Paste your Z.AI API key", placeholder: "sk-...", @@ -58,3 +41,13 @@ export async function loginZai(options: OAuthController): Promise { }); return trimmed; } + +export const zaiProvider = { + id: "zai", + name: "Z.AI (GLM Coding Plan)", + defaultModel: "glm-5.1", + createModelManagerOptions: (config: ModelManagerConfig) => zaiModelManagerOptions(config), + catalogDiscovery: { label: "zAI", envVars: ["ZAI_API_KEY"] }, + envKeys: "ZAI_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginZai(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/zenmux.ts b/packages/ai/src/registry/zenmux.ts new file mode 100644 index 000000000..233759b98 --- /dev/null +++ b/packages/ai/src/registry/zenmux.ts @@ -0,0 +1,27 @@ +import { zenmuxModelManagerOptions } from "../provider-models/openai-compat"; +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; + +export const loginZenMux = createApiKeyLogin({ + providerLabel: "ZenMux", + authUrl: "https://zenmux.ai/settings/keys", + instructions: "Create or copy your ZenMux API key", + promptMessage: "Paste your ZenMux API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "ZenMux", + modelsUrl: "https://zenmux.ai/api/v1/models", + }, +}); + +export const zenmuxProvider = { + id: "zenmux", + name: "ZenMux", + defaultModel: "anthropic/claude-opus-4.6", + createModelManagerOptions: (config: ModelManagerConfig) => zenmuxModelManagerOptions(config), + catalogDiscovery: { label: "ZenMux", envVars: ["ZENMUX_API_KEY"] }, + envKeys: "ZENMUX_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginZenMux(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/utils/oauth/zhipu.ts b/packages/ai/src/registry/zhipu-coding-plan.ts similarity index 61% rename from packages/ai/src/utils/oauth/zhipu.ts rename to packages/ai/src/registry/zhipu-coding-plan.ts index 06f315a5e..566403888 100644 --- a/packages/ai/src/utils/oauth/zhipu.ts +++ b/packages/ai/src/registry/zhipu-coding-plan.ts @@ -1,39 +1,22 @@ -/** - * Zhipu Coding Plan login flow. - * - * GLM Coding Plan provides an OpenAI-compatible API on the dedicated coding - * endpoint. API docs: https://docs.bigmodel.cn/cn/coding-plan/quick-start - * - * Simple API key flow: - * 1. User gets a Coding Plan API key from https://bigmodel.cn/coding-plan/personal/overview - * 2. User pastes the API key into the CLI - */ - +import { zhipuCodingPlanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController } from "./types"; +import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import type { ModelManagerConfig, ProviderDefinition } from "./types"; const AUTH_URL = "https://bigmodel.cn/coding-plan/personal/overview"; const API_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; const VALIDATION_MODEL = "glm-5.1"; -/** - * Login to Zhipu Coding Plan. - * - * Opens browser to API keys page, prompts user to paste their API key. - * Returns the API key directly (not OAuthCredentials - this isn't OAuth). - */ export async function loginZhipuCodingPlan(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Zhipu Coding Plan login requires onPrompt callback"); } - // Open browser to API keys page options.onAuth?.({ url: AUTH_URL, instructions: "Copy your API key from the Coding Plan dashboard", }); - // Prompt user to paste their API key const apiKey = await options.onPrompt({ message: "Paste your Zhipu API key", placeholder: ".", @@ -58,3 +41,13 @@ export async function loginZhipuCodingPlan(options: OAuthController): Promise zhipuCodingPlanModelManagerOptions(config), + catalogDiscovery: { label: "Zhipu Coding Plan", envVars: ["ZHIPU_API_KEY"] }, + envKeys: "ZHIPU_API_KEY", + login: (cb: OAuthLoginCallbacks) => loginZhipuCodingPlan(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index c081a80c8..03d273a51 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,7 +1,4 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { $env, $pickenv, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; +import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import { getCustomApi } from "./api-registry"; import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry"; import type { Effort } from "./effort"; @@ -47,6 +44,7 @@ import { import { isSyntheticModel, streamSynthetic } from "./providers/synthetic"; import { streamXAIResponses } from "./providers/xai-responses"; import { isUsageLimitError } from "./rate-limit-utils"; +import { PROVIDER_REGISTRY } from "./registry"; import type { Api, AssistantMessage, @@ -61,24 +59,8 @@ import type { ToolChoice, } from "./types"; import { AssistantMessageEventStream } from "./utils/event-stream"; -import { isFoundryEnabled } from "./utils/foundry"; import { withRequestDebugFetch } from "./utils/request-debug"; -let cachedVertexAdcCredentialsExists: boolean | null = null; - -function hasVertexAdcCredentials(): boolean { - if (cachedVertexAdcCredentialsExists === null) { - const gacPath = $env.GOOGLE_APPLICATION_CREDENTIALS; - if (gacPath) { - cachedVertexAdcCredentialsExists = fs.existsSync(gacPath); - } else { - cachedVertexAdcCredentialsExists = fs.existsSync( - path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"), - ); - } - } - return cachedVertexAdcCredentialsExists; -} function isGoogleVertexAuthenticatedModel(model: Model): boolean { return ( model.provider === "google-vertex" && @@ -175,100 +157,22 @@ function resolveVertexRequest(input: string | URL | Request): string | URL | Req type KeyResolver = string | (() => string | undefined); -const serviceProviderMap: Record = { - "alibaba-coding-plan": "ALIBABA_CODING_PLAN_API_KEY", - openai: "OPENAI_API_KEY", - google: "GEMINI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - xai: "XAI_API_KEY", - "xai-oauth": () => $pickenv("XAI_OAUTH_TOKEN", "XAI_API_KEY"), - fireworks: "FIREWORKS_API_KEY", - firepass: "FIREPASS_API_KEY", - "wafer-pass": "WAFER_PASS_API_KEY", - "wafer-serverless": "WAFER_SERVERLESS_API_KEY", - openrouter: "OPENROUTER_API_KEY", - kilo: "KILO_API_KEY", - "vercel-ai-gateway": "AI_GATEWAY_API_KEY", - zai: "ZAI_API_KEY", - "zhipu-coding-plan": "ZHIPU_API_KEY", - mistral: "MISTRAL_API_KEY", - minimax: "MINIMAX_API_KEY", - "minimax-code": "MINIMAX_CODE_API_KEY", - "minimax-code-cn": "MINIMAX_CODE_CN_API_KEY", - "opencode-go": "OPENCODE_API_KEY", - "opencode-zen": "OPENCODE_API_KEY", - cursor: "CURSOR_ACCESS_TOKEN", - deepseek: "DEEPSEEK_API_KEY", - "openai-codex": "OPENAI_CODEX_OAUTH_TOKEN", +const LEGACY_ENV_KEYS: Record = { + // Non-provider / search-tool keys and API-name keys not modeled as registry provider defs. "azure-openai-responses": "AZURE_OPENAI_API_KEY", + "llama.cpp": "LLAMA_CPP_API_KEY", exa: "EXA_API_KEY", jina: "JINA_API_KEY", brave: "BRAVE_API_KEY", - perplexity: "PERPLEXITY_API_KEY", - tavily: "TAVILY_API_KEY", - parallel: "PARALLEL_API_KEY", - kagi: "KAGI_API_KEY", - "github-copilot": "COPILOT_GITHUB_TOKEN", - // Foundry mode optionally switches Anthropic auth to enterprise gateway credentials. - anthropic: () => - isFoundryEnabled() - ? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY") - : $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"), - "gitlab-duo": "GITLAB_TOKEN", - // Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials. - "google-vertex": () => { - if ($env.GOOGLE_CLOUD_API_KEY) { - return $env.GOOGLE_CLOUD_API_KEY; - } - const hasCredentials = hasVertexAdcCredentials(); - const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT); - const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION); - if (hasCredentials && hasProject && hasLocation) { - return ""; - } - }, - // Amazon Bedrock supports multiple credential sources: - // 1. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token) - // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys - // 3. AWS_PROFILE - named profile from ~/.aws/credentials - // 4. AWS_CONTAINER_CREDENTIALS_* - ECS/Task IAM role credentials - // 5. AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN - IRSA (EKS) web identity - "amazon-bedrock": () => { - const hasEcsCredentials = - !!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI; - const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN; - if ( - $env.AWS_PROFILE || - ($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) || - $env.AWS_BEARER_TOKEN_BEDROCK || - hasEcsCredentials || - hasWebIdentity - ) { - return ""; - } - }, - synthetic: "SYNTHETIC_API_KEY", - "cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY", - huggingface: () => $pickenv("HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"), - litellm: "LITELLM_API_KEY", - moonshot: "MOONSHOT_API_KEY", - nvidia: "NVIDIA_API_KEY", - nanogpt: "NANO_GPT_API_KEY", - "lm-studio": "LM_STUDIO_API_KEY", - ollama: "OLLAMA_API_KEY", - "ollama-cloud": "OLLAMA_CLOUD_API_KEY", - "llama.cpp": "LLAMA_CPP_API_KEY", - qianfan: "QIANFAN_API_KEY", - "qwen-portal": () => $pickenv("QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"), - together: "TOGETHER_API_KEY", - zenmux: "ZENMUX_API_KEY", - venice: "VENICE_API_KEY", - vllm: "VLLM_API_KEY", - xiaomi: "XIAOMI_API_KEY", - "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", - "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", - "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", +}; + +const serviceProviderMap: Record = { + ...Object.fromEntries( + PROVIDER_REGISTRY.flatMap(provider => + provider.envKeys != null ? [[provider.id, provider.envKeys] as [string, KeyResolver]] : [], + ), + ), + ...LEGACY_ENV_KEYS, }; /** diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 566574be4..00aebceba 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -28,6 +28,7 @@ import type { OllamaChatOptions } from "./providers/ollama"; import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses"; import type { OpenAICompletionsOptions } from "./providers/openai-completions"; import type { OpenAIResponsesOptions } from "./providers/openai-responses"; +import type { KnownProviderId } from "./registry"; import type { AssistantMessageEventStream } from "./utils/event-stream"; export type { AssistantMessageEventStream } from "./utils/event-stream"; @@ -96,61 +97,12 @@ export interface ThinkingConfig { mode: ThinkingControlMode; } -export type KnownProvider = - | "alibaba-coding-plan" - | "amazon-bedrock" - | "anthropic" - | "google" - | "google-gemini-cli" - | "google-antigravity" - | "google-vertex" - | "openai" - | "openai-codex" - | "kimi-code" - | "minimax-code" - | "minimax-code-cn" - | "github-copilot" - | "fireworks" - | "firepass" - | "gitlab-duo" - | "cursor" - | "deepseek" - | "xai" - | "xai-oauth" - | "groq" - | "cerebras" - | "openrouter" - | "kilo" - | "vercel-ai-gateway" - | "zai" - | "zhipu-coding-plan" - | "mistral" - | "minimax" - | "opencode-go" - | "opencode-zen" - | "synthetic" - | "cloudflare-ai-gateway" - | "huggingface" - | "litellm" - | "moonshot" - | "nvidia" - | "nanogpt" - | "ollama" - | "ollama-cloud" - | "qianfan" - | "qwen-portal" - | "together" - | "venice" - | "vllm" - | "xiaomi" - | "xiaomi-token-plan-sgp" - | "xiaomi-token-plan-ams" - | "xiaomi-token-plan-cn" - | "wafer-pass" - | "wafer-serverless" - | "zenmux" - | "lm-studio"; -export type Provider = KnownProvider | string; +export type KnownProvider = KnownProviderId; +// `Provider` is any provider-id string; `KnownProvider` enumerates the built-in model +// providers. Kept structurally `string` (the prior `KnownProvider | string` already +// collapsed to `string`) so the registry-derived `KnownProvider` can reference the model +// types below without forming a circular type-alias reference. +export type Provider = string; import type { Effort } from "./effort"; diff --git a/packages/ai/src/usage/github-copilot.ts b/packages/ai/src/usage/github-copilot.ts index 82add1289..a810ebb06 100644 --- a/packages/ai/src/usage/github-copilot.ts +++ b/packages/ai/src/usage/github-copilot.ts @@ -3,6 +3,8 @@ * * Normalizes Copilot quota usage into the shared UsageReport schema. */ + +import { OPENCODE_HEADERS } from "../registry/oauth/github-copilot"; import type { UsageAmount, UsageFetchContext, @@ -14,7 +16,6 @@ import type { UsageWindow, } from "../usage"; import { isRecord, toBoolean, toNumber } from "../utils"; -import { OPENCODE_HEADERS } from "../utils/oauth/github-copilot"; type CopilotQuotaDetail = { entitlement: number; diff --git a/packages/ai/src/usage/kimi.ts b/packages/ai/src/usage/kimi.ts index edbfe4549..17c6a2e6f 100644 --- a/packages/ai/src/usage/kimi.ts +++ b/packages/ai/src/usage/kimi.ts @@ -1,4 +1,5 @@ import { $env } from "@oh-my-pi/pi-utils"; +import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import type { UsageAmount, UsageFetchContext, @@ -10,7 +11,6 @@ import type { UsageWindow, } from "../usage"; import { isRecord } from "../utils"; -import { getKimiCommonHeaders } from "../utils/oauth/kimi"; // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.) import { toNumber } from "./shared"; diff --git a/packages/ai/src/utils/discovery/gemini.ts b/packages/ai/src/utils/discovery/gemini.ts index 7657d10ba..09e0163fc 100644 --- a/packages/ai/src/utils/discovery/gemini.ts +++ b/packages/ai/src/utils/discovery/gemini.ts @@ -1,6 +1,6 @@ -import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai"; import * as z from "zod/v4"; import { getBundledModels } from "../../models"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../../provider-models/discovery-constants"; import type { Model } from "../../types"; const GOOGLE_GENERATIVE_AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; diff --git a/packages/ai/src/utils/discovery/openai-compatible.ts b/packages/ai/src/utils/discovery/openai-compatible.ts index 07ad8c38b..3dc654c75 100644 --- a/packages/ai/src/utils/discovery/openai-compatible.ts +++ b/packages/ai/src/utils/discovery/openai-compatible.ts @@ -1,5 +1,5 @@ -import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai"; import * as z from "zod/v4"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../../provider-models/discovery-constants"; import type { Api, Model, Provider } from "../../types"; const MODELS_PATH = "/models"; diff --git a/packages/ai/src/utils/oauth/cerebras.ts b/packages/ai/src/utils/oauth/cerebras.ts deleted file mode 100644 index 5f66f1399..000000000 --- a/packages/ai/src/utils/oauth/cerebras.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Cerebras login flow (API key paste against https://api.cerebras.ai/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginCerebras = createApiKeyLogin({ - providerLabel: "Cerebras", - authUrl: "https://cloud.cerebras.ai/platform/", - instructions: "Copy your API key from the Cerebras dashboard", - promptMessage: "Paste your Cerebras API key", - placeholder: "csk-...", - validation: { - kind: "chat-completions", - provider: "Cerebras", - baseUrl: "https://api.cerebras.ai/v1", - model: "gpt-oss-120b", - }, -}); diff --git a/packages/ai/src/utils/oauth/fireworks.ts b/packages/ai/src/utils/oauth/fireworks.ts deleted file mode 100644 index ece0f0751..000000000 --- a/packages/ai/src/utils/oauth/fireworks.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Fireworks login flow (API key paste against https://api.fireworks.ai/inference/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginFireworks = createApiKeyLogin({ - providerLabel: "Fireworks", - authUrl: "https://app.fireworks.ai/settings/users/api-keys", - instructions: "Create or copy your Fireworks API key", - promptMessage: "Paste your Fireworks API key", - placeholder: "fw_...", - validation: { - kind: "models-endpoint", - provider: "Fireworks", - modelsUrl: "https://api.fireworks.ai/inference/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts deleted file mode 100644 index c50ea90cf..000000000 --- a/packages/ai/src/utils/oauth/index.ts +++ /dev/null @@ -1,502 +0,0 @@ -// ============================================================================ -// High-level API -// ============================================================================ -import type { - OAuthCredentials, - OAuthProvider, - OAuthProviderId, - OAuthProviderInfo, - OAuthProviderInterface, -} from "./types"; - -const builtInOAuthProviders: OAuthProviderInfo[] = [ - // Most popular coding subscriptions / gateways. - { - id: "openai-codex", - name: "ChatGPT Plus/Pro (Codex Subscription)", - available: true, - }, - { - id: "anthropic", - name: "Anthropic (Claude Pro/Max)", - available: true, - }, - { - id: "zai", - name: "Z.AI (GLM Coding Plan)", - available: true, - }, - { - id: "kimi-code", - name: "Kimi Code", - available: true, - }, - { - id: "openrouter", - name: "OpenRouter", - available: true, - }, - // Other coding subscriptions & first-party assistants. - { - id: "github-copilot", - name: "GitHub Copilot", - available: true, - }, - { - id: "cursor", - name: "Cursor (Claude, GPT, etc.)", - available: true, - }, - { - id: "google-antigravity", - name: "Antigravity (Gemini 3, Claude, GPT-OSS)", - available: true, - }, - { - id: "google-gemini-cli", - name: "Google Cloud Code Assist (Gemini CLI)", - available: true, - }, - { - id: "openai-codex-device", - name: "ChatGPT Plus/Pro (Codex, headless/device)", - available: true, - }, - { - id: "xai-oauth", - name: "xAI Grok OAuth (SuperGrok Subscription)", - available: true, - }, - { - id: "gitlab-duo", - name: "GitLab Duo", - available: true, - }, - { - id: "alibaba-coding-plan", - name: "Alibaba Coding Plan", - available: true, - }, - { - id: "zhipu-coding-plan", - name: "Zhipu Coding Plan (智谱)", - available: true, - }, - { - id: "qwen-portal", - name: "Qwen Portal", - available: true, - }, - { - id: "minimax-code", - name: "MiniMax Coding Plan (International)", - available: true, - }, - { - id: "minimax-code-cn", - name: "MiniMax Coding Plan (China)", - available: true, - }, - { - id: "xiaomi", - name: "Xiaomi MiMo", - available: true, - }, - { - id: "xiaomi-token-plan-sgp", - name: "Xiaomi Token Plan (Singapore)", - available: true, - }, - { - id: "xiaomi-token-plan-ams", - name: "Xiaomi Token Plan (Europe)", - available: true, - }, - { - id: "xiaomi-token-plan-cn", - name: "Xiaomi Token Plan (China)", - available: true, - }, - { - id: "firepass", - name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)", - available: true, - }, - { - id: "wafer-pass", - name: "Wafer Pass (flat-rate subscription)", - available: true, - }, - // Direct model-provider APIs (pay-as-you-go inference). - { - id: "deepseek", - name: "DeepSeek", - available: true, - }, - { - id: "moonshot", - name: "Moonshot (Kimi API)", - available: true, - }, - { - id: "cerebras", - name: "Cerebras", - available: true, - }, - { - id: "fireworks", - name: "Fireworks", - available: true, - }, - { - id: "together", - name: "Together", - available: true, - }, - { - id: "nvidia", - name: "NVIDIA", - available: true, - }, - { - id: "huggingface", - name: "Hugging Face Inference", - available: true, - }, - { - id: "perplexity", - name: "Perplexity (Pro/Max)", - available: true, - }, - { - id: "qianfan", - name: "Qianfan", - available: true, - }, - { - id: "venice", - name: "Venice", - available: true, - }, - { - id: "synthetic", - name: "Synthetic", - available: true, - }, - { - id: "nanogpt", - name: "NanoGPT", - available: true, - }, - { - id: "wafer-serverless", - name: "Wafer Serverless (pay-as-you-go)", - available: true, - }, - // Aggregator gateways / routers. - { - id: "vercel-ai-gateway", - name: "Vercel AI Gateway", - available: true, - }, - { - id: "cloudflare-ai-gateway", - name: "Cloudflare AI Gateway", - available: true, - }, - { - id: "litellm", - name: "LiteLLM", - available: true, - }, - { - id: "kilo", - name: "Kilo Gateway", - available: true, - }, - { - id: "zenmux", - name: "ZenMux", - available: true, - }, - { - id: "opencode-zen", - name: "OpenCode Zen", - available: true, - }, - { - id: "opencode-go", - name: "OpenCode Go", - available: true, - }, - // Search & tool providers. - { - id: "tavily", - name: "Tavily", - available: true, - }, - { - id: "kagi", - name: "Kagi", - available: true, - }, - { - id: "parallel", - name: "Parallel", - available: true, - }, - // Local runtimes. - { - id: "ollama", - name: "Ollama (Local OpenAI-compatible)", - available: true, - }, - { - id: "ollama-cloud", - name: "Ollama Cloud", - available: true, - }, - { - id: "lm-studio", - name: "LM Studio (Local OpenAI-compatible)", - available: true, - }, - { - id: "vllm", - name: "vLLM (Local OpenAI-compatible)", - available: true, - }, -]; - -const customOAuthProviders = new Map(); - -/** - * Register a custom OAuth provider. - */ -export function registerOAuthProvider(provider: OAuthProviderInterface): void { - customOAuthProviders.set(provider.id, provider); -} - -/** - * Get a custom OAuth provider by ID. - */ -export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { - return customOAuthProviders.get(id); -} - -/** - * Remove all custom OAuth providers registered by a source. - */ -export function unregisterOAuthProviders(sourceId: string): void { - for (const [id, provider] of customOAuthProviders.entries()) { - if (provider.sourceId === sourceId) { - customOAuthProviders.delete(id); - } - } -} - -/** - * Refresh token for any OAuth provider. - * Saves the new credentials and returns the new access token. - */ -export async function refreshOAuthToken( - provider: OAuthProvider, - credentials: OAuthCredentials, -): Promise { - if (!credentials) { - throw new Error(`No OAuth credentials found for ${provider}`); - } - - let newCredentials: OAuthCredentials; - switch (provider) { - case "anthropic": { - const { refreshAnthropicToken } = await import("./anthropic"); - newCredentials = await refreshAnthropicToken(credentials.refresh); - break; - } - case "github-copilot": { - const { refreshGitHubCopilotToken } = await import("./github-copilot"); - newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl); - break; - } - case "google-gemini-cli": { - const { refreshGoogleCloudToken } = await import("./google-gemini-cli"); - if (!credentials.projectId) { - throw new Error("Google Cloud credentials missing projectId"); - } - newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId); - break; - } - case "google-antigravity": { - const { refreshAntigravityToken } = await import("./google-antigravity"); - if (!credentials.projectId) { - throw new Error("Antigravity credentials missing projectId"); - } - newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId); - break; - } - case "openai-codex": - case "openai-codex-device": { - const { refreshOpenAICodexToken } = await import("./openai-codex"); - newCredentials = await refreshOpenAICodexToken(credentials.refresh); - break; - } - case "kimi-code": { - const { refreshKimiToken } = await import("./kimi"); - newCredentials = await refreshKimiToken(credentials.refresh); - break; - } - case "gitlab-duo": { - const { refreshGitLabDuoToken } = await import("./gitlab-duo"); - newCredentials = await refreshGitLabDuoToken(credentials); - break; - } - case "cursor": { - const { refreshCursorToken } = await import("./cursor"); - newCredentials = await refreshCursorToken(credentials.refresh); - break; - } - case "xai-oauth": { - const { refreshXAIOAuthToken } = await import("./xai-oauth"); - newCredentials = await refreshXAIOAuthToken(credentials.refresh); - break; - } - case "kilo": - case "perplexity": - case "huggingface": - case "opencode-zen": - case "opencode-go": - case "openrouter": - case "cerebras": - case "fireworks": - case "firepass": - case "nvidia": - case "nanogpt": - case "synthetic": - case "together": - case "litellm": - case "lm-studio": - case "ollama": - case "ollama-cloud": - case "xiaomi": - case "xiaomi-token-plan-sgp": - case "xiaomi-token-plan-ams": - case "xiaomi-token-plan-cn": - case "zai": - case "zhipu-coding-plan": - case "qianfan": - case "venice": - case "minimax-code": - case "minimax-code-cn": - case "moonshot": - case "kagi": - case "cloudflare-ai-gateway": - case "vercel-ai-gateway": - case "qwen-portal": - case "wafer-pass": - case "wafer-serverless": - case "zenmux": - case "vllm": - // API keys / static bearer tokens don't expire, return as-is - newCredentials = credentials; - break; - default: - throw new Error(`Unknown OAuth provider: ${provider}`); - } - return newCredentials; -} -function getPerplexityJwtExpiryMs(token: string): number | undefined { - const parts = token.split("."); - if (parts.length !== 3) return undefined; - const payload = parts[1]; - if (!payload) return undefined; - try { - const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown }; - if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined; - return decoded.exp * 1000 - 5 * 60_000; - } catch { - return undefined; - } -} - -/** - * Build API-key bytes for a provider from an already-fresh OAuth credential. - * - * Refresh is owned by AuthStorage. This helper deliberately refuses expired - * credentials so it cannot POST broker redaction sentinels to upstream token - * endpoints as a side channel. - * - * For providers that need credential metadata at request time, returns - * JSON-encoded credentials plus expiry metadata for diagnostics/edge guards. - * @returns API key string, or null if no credentials - * @throws Error if the credential is expired and must be refreshed upstream - */ -export async function getOAuthApiKey( - provider: OAuthProvider, - credentials: Record, -): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { - let creds = credentials[provider]; - if (!creds) { - return null; - } - - if (provider === "perplexity") { - // Perplexity JWTs usually omit `exp` (server-side sessions). Trust the JWT - // claim when present; otherwise treat the credential as non-expiring rather - // than honoring a stale stored `expires` (older logins wrote loginTime+1h). - const NEVER_EXPIRES = 8.64e15; - const normalizedExpires = - creds.expires > 0 && creds.expires < 10_000_000_000 ? creds.expires * 1000 : creds.expires; - const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); - const expires = jwtExpiry ?? Math.max(normalizedExpires, NEVER_EXPIRES); - if (expires !== creds.expires) { - creds = { ...creds, expires }; - } - } - // Refresh is the sole responsibility of `AuthStorage` (which calls - // `refreshOAuthToken` directly with broker-aware single-flighting). If we - // reach here with an expired credential, the outer pipeline failed to - // refresh before this call OR the refresh slot is the broker sentinel — - // either way, posting the credential to a provider endpoint would only - // trigger a `__remote__`-against-real-provider failure that gets classified - // as `invalid_grant` and disables the row. Refuse loudly instead. - if (Date.now() >= creds.expires) { - if (provider === "perplexity") { - const jwtExpiry = getPerplexityJwtExpiryMs(creds.access); - if (jwtExpiry && Date.now() < jwtExpiry) { - const fallbackCredentials = { ...creds, expires: jwtExpiry }; - return { newCredentials: fallbackCredentials, apiKey: fallbackCredentials.access }; - } - } - throw new Error( - `OAuth credential for ${provider} is expired and must be refreshed via AuthStorage before getOAuthApiKey is called`, - ); - } - // For providers that need request-time credential metadata, return JSON. - const needsStructuredApiKey = - provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity"; - const apiKey = needsStructuredApiKey - ? JSON.stringify({ - token: creds.access, - enterpriseUrl: creds.enterpriseUrl, - projectId: creds.projectId, - refreshToken: creds.refresh, - expiresAt: creds.expires, - email: creds.email, - accountId: creds.accountId, - }) - : creds.access; - return { newCredentials: creds, apiKey }; -} - -/** - * Get list of OAuth providers. - */ -export function getOAuthProviders(): OAuthProviderInfo[] { - const customProviders = Array.from(customOAuthProviders.values(), provider => ({ - id: provider.id, - name: provider.name, - available: true, - })); - return [...builtInOAuthProviders, ...customProviders]; -} diff --git a/packages/ai/src/utils/oauth/lm-studio.ts b/packages/ai/src/utils/oauth/lm-studio.ts deleted file mode 100644 index d1e3921fe..000000000 --- a/packages/ai/src/utils/oauth/lm-studio.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * LM Studio login flow. - * - * LM Studio provides an OpenAI-compatible API at a local base URL. - * It usually runs unauthenticated but can be configured to require a bearer token. - * - * This flow stores an API-key-style credential used by `/login` and auth storage. - */ - -import type { OAuthController, OAuthProvider } from "./types"; - -const PROVIDER_ID: OAuthProvider = "lm-studio"; -export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; - -/** - * Login to LM Studio. - * - * Opens LM Studio API docs, prompts for an optional token, - * and returns a stored key value. - */ -export async function loginLmStudio(options: OAuthController): Promise { - if (!options.onPrompt) { - throw new Error(`${PROVIDER_ID} login requires onPrompt callback`); - } - - const apiKey = await options.onPrompt({ - message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)", - placeholder: DEFAULT_LOCAL_TOKEN, - allowEmpty: true, - }); - - if (options.signal?.aborted) { - throw new Error("Login cancelled"); - } - - const trimmed = apiKey.trim(); - return trimmed || DEFAULT_LOCAL_TOKEN; -} diff --git a/packages/ai/src/utils/oauth/moonshot.ts b/packages/ai/src/utils/oauth/moonshot.ts deleted file mode 100644 index b825b9273..000000000 --- a/packages/ai/src/utils/oauth/moonshot.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Moonshot login flow (API key paste against https://api.moonshot.ai/v1). - * - * Validation hits `GET /v1/models` rather than a chat completion. Moonshot's - * thinking models (e.g. kimi-k2.5/k2.6) reject the `temperature: 0` probe used - * by the chat-completions validator with `invalid temperature: only 1 is - * allowed for this model`, so a hello-world chat call cannot authenticate the - * key reliably across the Moonshot catalog. - */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginMoonshot = createApiKeyLogin({ - providerLabel: "Moonshot", - authUrl: "https://platform.moonshot.ai/console/api-keys", - instructions: "Copy your API key from the Moonshot dashboard", - promptMessage: "Paste your Moonshot API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "moonshot", - modelsUrl: "https://api.moonshot.ai/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/nanogpt.ts b/packages/ai/src/utils/oauth/nanogpt.ts deleted file mode 100644 index 7f499465d..000000000 --- a/packages/ai/src/utils/oauth/nanogpt.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** NanoGPT login flow (API key paste, validated via /models). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginNanoGPT = createApiKeyLogin({ - providerLabel: "NanoGPT", - authUrl: "https://nano-gpt.com/api", - instructions: "Create or copy your NanoGPT API key", - promptMessage: "Paste your NanoGPT API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "NanoGPT", - modelsUrl: "https://nano-gpt.com/api/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/synthetic.ts b/packages/ai/src/utils/oauth/synthetic.ts deleted file mode 100644 index 5b2aaeeb0..000000000 --- a/packages/ai/src/utils/oauth/synthetic.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Synthetic login flow (API key paste against https://api.synthetic.new/openai/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginSynthetic = createApiKeyLogin({ - providerLabel: "Synthetic", - authUrl: "https://dev.synthetic.new/docs/api/overview", - instructions: "Copy your API key from the Synthetic dashboard", - promptMessage: "Paste your Synthetic API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "Synthetic", - modelsUrl: "https://api.synthetic.new/openai/v1/models", - }, -}); diff --git a/packages/ai/src/utils/oauth/together.ts b/packages/ai/src/utils/oauth/together.ts deleted file mode 100644 index 03bc5af43..000000000 --- a/packages/ai/src/utils/oauth/together.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Together login flow (API key paste against https://api.together.xyz/v1). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginTogether = createApiKeyLogin({ - providerLabel: "Together", - authUrl: "https://api.together.xyz/settings/api-keys", - instructions: "Copy your API key from the Together dashboard", - promptMessage: "Paste your Together API key", - placeholder: "sk-...", - validation: { - kind: "chat-completions", - provider: "together", - baseUrl: "https://api.together.xyz/v1", - model: "moonshotai/Kimi-K2.5", - }, -}); diff --git a/packages/ai/src/utils/oauth/zenmux.ts b/packages/ai/src/utils/oauth/zenmux.ts deleted file mode 100644 index 4cbd8d575..000000000 --- a/packages/ai/src/utils/oauth/zenmux.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** ZenMux login flow (API key paste, validated via /models). */ -import { createApiKeyLogin } from "./api-key-login"; - -export const loginZenMux = createApiKeyLogin({ - providerLabel: "ZenMux", - authUrl: "https://zenmux.ai/settings/keys", - instructions: "Create or copy your ZenMux API key", - promptMessage: "Paste your ZenMux API key", - placeholder: "sk-...", - validation: { - kind: "models-endpoint", - provider: "ZenMux", - modelsUrl: "https://zenmux.ai/api/v1/models", - }, -}); diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 5c4ee08a6..d6346815e 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -190,7 +190,7 @@ describe("Anthropic request fingerprint alignment", () => { // the `scope: "global"` field that CC attaches to its middle breakpoint — // `prompt-caching-scope-2026-01-05` only works against canonical // `api.anthropic.com`, and third-party Anthropic-compatible proxies - // (z.ai, openrouter, g0i, …) reject the unknown field outright. + // (z.ai, openrouter, …) reject the unknown field outright. const blocks = buildAnthropicSystemBlocks(["Stay concise."], { includeClaudeCodeInstruction: true, extraInstructions: ["Use citations when possible"], @@ -487,7 +487,10 @@ describe("Anthropic request fingerprint alignment", () => { { metadata: { account_uuid: otherAccountId }, sessionId }, )) as { metadata?: { user_id?: string } }; const firstUserId = JSON.parse(first.metadata?.user_id ?? "{}") as { account_uuid?: string; device_id?: string }; - const secondUserId = JSON.parse(second.metadata?.user_id ?? "{}") as { account_uuid?: string; device_id?: string }; + const secondUserId = JSON.parse(second.metadata?.user_id ?? "{}") as { + account_uuid?: string; + device_id?: string; + }; expect(firstUserId.account_uuid).toBe(accountId); expect(secondUserId.account_uuid).toBe(otherAccountId); diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 39c6be5f5..c4a29cd5a 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import { AnthropicOAuthFlow, refreshAnthropicToken } from "../src/registry/oauth/anthropic"; import { buildAnthropicAuthConfig, buildAnthropicSearchHeaders, buildAnthropicUrl } from "../src/utils/anthropic-auth"; -import { AnthropicOAuthFlow, refreshAnthropicToken } from "../src/utils/oauth/anthropic"; import { withEnv } from "./helpers"; const originalFetch = global.fetch; diff --git a/packages/ai/test/auth-broker-refresher.test.ts b/packages/ai/test/auth-broker-refresher.test.ts index 52e192963..30c5b2652 100644 --- a/packages/ai/test/auth-broker-refresher.test.ts +++ b/packages/ai/test/auth-broker-refresher.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "../src"; -import * as oauthUtils from "../src/utils/oauth"; +import * as oauthUtils from "../src/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-broker-wire.test.ts b/packages/ai/test/auth-broker-wire.test.ts index 469a7f489..5e39be6da 100644 --- a/packages/ai/test/auth-broker-wire.test.ts +++ b/packages/ai/test/auth-broker-wire.test.ts @@ -12,7 +12,7 @@ import { SqliteAuthCredentialStore, startAuthBroker, } from "../src"; -import * as oauthUtils from "../src/utils/oauth"; +import * as oauthUtils from "../src/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-storage-api-key-login.test.ts b/packages/ai/test/auth-storage-api-key-login.test.ts index df2c6a244..4ad5b714c 100644 --- a/packages/ai/test/auth-storage-api-key-login.test.ts +++ b/packages/ai/test/auth-storage-api-key-login.test.ts @@ -5,9 +5,9 @@ import * as os from "node:os"; import * as path from "node:path"; import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import * as deepseekModule from "../src/utils/oauth/deepseek"; -import * as kagiModule from "../src/utils/oauth/kagi"; -import * as ollamaCloudModule from "../src/utils/oauth/ollama-cloud"; +import * as deepseekModule from "../src/registry/deepseek"; +import * as kagiModule from "../src/registry/kagi"; +import * as ollamaCloudModule from "../src/registry/ollama-cloud"; function countCredentialRows(dbPath: string, provider: string): number { const db = new Database(dbPath, { readonly: true }); diff --git a/packages/ai/test/auth-storage-broker-no-sentinel.test.ts b/packages/ai/test/auth-storage-broker-no-sentinel.test.ts index 5f6507eed..5a0742eda 100644 --- a/packages/ai/test/auth-storage-broker-no-sentinel.test.ts +++ b/packages/ai/test/auth-storage-broker-no-sentinel.test.ts @@ -8,7 +8,7 @@ import { REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore, } from "../src/auth-storage"; -import * as oauthUtils from "../src/utils/oauth"; +import * as oauthUtils from "../src/registry/oauth"; describe("AuthStorage broker sentinel refresh", () => { let tempDir = ""; diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index d31f775b4..bf88b27a3 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -3,9 +3,9 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import * as oauthUtils from "../src/registry/oauth"; +import type { OAuthCredentials } from "../src/registry/oauth/types"; import type { UsageLimit, UsageProvider, UsageReport } from "../src/usage"; -import * as oauthUtils from "../src/utils/oauth"; -import type { OAuthCredentials } from "../src/utils/oauth/types"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; diff --git a/packages/ai/test/auth-storage-credential-disabled-event.test.ts b/packages/ai/test/auth-storage-credential-disabled-event.test.ts index f9748a102..f4b6dabf5 100644 --- a/packages/ai/test/auth-storage-credential-disabled-event.test.ts +++ b/packages/ai/test/auth-storage-credential-disabled-event.test.ts @@ -6,7 +6,7 @@ import { type CredentialDisabledEvent, type StoredAuthCredential, } from "../src/auth-storage"; -import * as oauthUtils from "../src/utils/oauth"; +import * as oauthUtils from "../src/registry/oauth"; // Env vars short-circuit AuthStorage.getApiKey before the OAuth refresh path runs; suppress // them for every test in this file so the credential-disable code path can be exercised. diff --git a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts index fc7d0bb01..ed3389fb4 100644 --- a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts +++ b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { registerOAuthProvider, unregisterOAuthProviders } from "../src/utils/oauth"; +import { registerOAuthProvider, unregisterOAuthProviders } from "../src/registry/oauth"; const PROVIDER = "unit-rotate-oauth"; const SOURCE = "auth-storage-force-refresh-rotate-test"; diff --git a/packages/ai/test/auth-storage-oauth-refresh-race.test.ts b/packages/ai/test/auth-storage-oauth-refresh-race.test.ts index 25797974f..c2bbf9ded 100644 --- a/packages/ai/test/auth-storage-oauth-refresh-race.test.ts +++ b/packages/ai/test/auth-storage-oauth-refresh-race.test.ts @@ -8,7 +8,7 @@ import { type CredentialDisabledEvent, SqliteAuthCredentialStore, } from "../src/auth-storage"; -import * as oauthUtils from "../src/utils/oauth"; +import * as oauthUtils from "../src/registry/oauth"; import { withEnv } from "./helpers"; const SUPPRESS_ANTHROPIC_ENV = { diff --git a/packages/ai/test/auth-storage-refresh-skew.test.ts b/packages/ai/test/auth-storage-refresh-skew.test.ts index d3ed0517a..3ff8fc04b 100644 --- a/packages/ai/test/auth-storage-refresh-skew.test.ts +++ b/packages/ai/test/auth-storage-refresh-skew.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { registerOAuthProvider, unregisterOAuthProviders } from "../src/utils/oauth"; +import { registerOAuthProvider, unregisterOAuthProviders } from "../src/registry/oauth"; describe("AuthStorage OAuth refresh skew", () => { let tempDir = ""; diff --git a/packages/ai/test/callback-server-manual-input.test.ts b/packages/ai/test/callback-server-manual-input.test.ts index 4583bf228..1ad15a2e6 100644 --- a/packages/ai/test/callback-server-manual-input.test.ts +++ b/packages/ai/test/callback-server-manual-input.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { OAuthCallbackFlow } from "../src/utils/oauth/callback-server"; -import type { OAuthCredentials } from "../src/utils/oauth/types"; +import { OAuthCallbackFlow } from "../src/registry/oauth/callback-server"; +import type { OAuthCredentials } from "../src/registry/oauth/types"; class TestCallbackFlow extends OAuthCallbackFlow { async generateAuthUrl(_state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index ab2ce1c82..bdb37ca7d 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { buildAnthropicClientOptions, streamAnthropic } from "../src/providers/anthropic"; +import { OPENCODE_HEADERS } from "../src/registry/oauth/github-copilot"; import type { Context, Model } from "../src/types"; import { buildAnthropicUrl } from "../src/utils/anthropic-auth"; -import { OPENCODE_HEADERS } from "../src/utils/oauth/github-copilot"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-login.test.ts b/packages/ai/test/github-copilot-login.test.ts index b4ff72e75..2f80b17d7 100644 --- a/packages/ai/test/github-copilot-login.test.ts +++ b/packages/ai/test/github-copilot-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot"; +import { loginGitHubCopilot } from "../src/registry/oauth/github-copilot"; const originalFetch = global.fetch; const FAST_POLL_OPTIONS = { pollIntervalFloorMs: 0, pollIntervalScaleMs: 1 } as const; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 43a0b6d16..ca3b43cc8 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -3,7 +3,7 @@ import { getGitHubCopilotBaseUrl, normalizeGitHubCopilotEnterpriseDomain, parseGitHubCopilotApiKey, -} from "../src/utils/oauth/github-copilot"; +} from "../src/registry/oauth/github-copilot"; describe("GitHub Copilot OAuth helpers", () => { it("treats github.com as the public Copilot host", () => { diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index bfadc6171..e27572491 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -8,8 +8,8 @@ import { shouldRefreshGeminiCliCredentials, streamGoogleGeminiCli, } from "../src/providers/google-gemini-cli"; +import { getOAuthApiKey } from "../src/registry/oauth"; import type { Context, Model, TJsonSchema } from "../src/types"; -import { getOAuthApiKey } from "../src/utils/oauth"; function createModel(provider: "google-gemini-cli" | "google-antigravity"): Model<"google-gemini-cli"> { return { diff --git a/packages/ai/test/issue-1846-repro.test.ts b/packages/ai/test/issue-1846-repro.test.ts index 66ba01a03..d508991b1 100644 --- a/packages/ai/test/issue-1846-repro.test.ts +++ b/packages/ai/test/issue-1846-repro.test.ts @@ -4,8 +4,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; import { convertMessages, detectCompat } from "../src/providers/openai-completions"; +import { getOAuthProviders } from "../src/registry/oauth"; import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "../src/types"; -import { getOAuthProviders } from "../src/utils/oauth"; const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; const SGP_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1"; diff --git a/packages/ai/test/issue-772-repro.test.ts b/packages/ai/test/issue-772-repro.test.ts index 1d3bb7758..13fb334f5 100644 --- a/packages/ai/test/issue-772-repro.test.ts +++ b/packages/ai/test/issue-772-repro.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { hookFetch } from "@oh-my-pi/pi-utils"; import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { loginXiaomi } from "../src/utils/oauth/xiaomi"; +import { loginXiaomi } from "../src/registry/oauth/xiaomi"; const TOKEN_PLAN_SGP_HOST = "token-plan-sgp.xiaomimimo.com"; const STANDARD_HOST = "api.xiaomimimo.com"; diff --git a/packages/ai/test/issue-830-repro.test.ts b/packages/ai/test/issue-830-repro.test.ts index b0f9e1d91..08444bf4c 100644 --- a/packages/ai/test/issue-830-repro.test.ts +++ b/packages/ai/test/issue-830-repro.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "../src/provider-models/openai-compat"; +import { getOAuthProviders } from "../src/registry/oauth"; import { getEnvApiKey } from "../src/stream"; import type { OpenAICompat } from "../src/types"; -import { getOAuthProviders } from "../src/utils/oauth"; describe("deepseek built-in provider (issue #830)", () => { test("registers built-in runtime descriptor with DEEPSEEK_API_KEY env discovery", () => { diff --git a/packages/ai/test/issue-957-repro.test.ts b/packages/ai/test/issue-957-repro.test.ts index 91048b695..019b499ba 100644 --- a/packages/ai/test/issue-957-repro.test.ts +++ b/packages/ai/test/issue-957-repro.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import * as kimiOauth from "../src/utils/oauth/kimi"; +import * as kimiOauth from "../src/registry/oauth/kimi"; const originalFetch = global.fetch; diff --git a/packages/ai/test/kagi-login.test.ts b/packages/ai/test/kagi-login.test.ts index 37686a0b4..a73d7beca 100644 --- a/packages/ai/test/kagi-login.test.ts +++ b/packages/ai/test/kagi-login.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loginKagi } from "../src/utils/oauth/kagi"; +import { loginKagi } from "../src/registry/kagi"; describe("kagi login", () => { it("opens Kagi API settings and prompts for key", async () => { diff --git a/packages/ai/test/kilo-login.test.ts b/packages/ai/test/kilo-login.test.ts index 3364e5529..315cd7df9 100644 --- a/packages/ai/test/kilo-login.test.ts +++ b/packages/ai/test/kilo-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginKilo } from "../src/utils/oauth/kilo"; +import { loginKilo } from "../src/registry/kilo"; const originalFetch = global.fetch; diff --git a/packages/ai/test/minimax-code-login.test.ts b/packages/ai/test/minimax-code-login.test.ts index 8baa260fb..3ec6ca8bf 100644 --- a/packages/ai/test/minimax-code-login.test.ts +++ b/packages/ai/test/minimax-code-login.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { loginMiniMaxCode, loginMiniMaxCodeCn } from "../src/utils/oauth/minimax-code"; +import { loginMiniMaxCode, loginMiniMaxCodeCn } from "../src/registry/oauth/minimax-code"; describe("MiniMax Coding Plan login", () => { it("opens the international platform and validates against the international API", async () => { diff --git a/packages/ai/test/nanogpt-login.test.ts b/packages/ai/test/nanogpt-login.test.ts index 601a10e83..f67bea46d 100644 --- a/packages/ai/test/nanogpt-login.test.ts +++ b/packages/ai/test/nanogpt-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginNanoGPT } from "../src/utils/oauth/nanogpt"; +import { loginNanoGPT } from "../src/registry/nanogpt"; const originalFetch = global.fetch; diff --git a/packages/ai/test/oauth-deepseek.test.ts b/packages/ai/test/oauth-deepseek.test.ts index 8e867ca62..acc1e3676 100644 --- a/packages/ai/test/oauth-deepseek.test.ts +++ b/packages/ai/test/oauth-deepseek.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginDeepSeek, normalizeDeepSeekApiKey } from "../src/utils/oauth/deepseek"; -import type { OAuthController } from "../src/utils/oauth/types"; +import { loginDeepSeek, normalizeDeepSeekApiKey } from "../src/registry/deepseek"; +import type { OAuthController } from "../src/registry/oauth/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index af2f52dfe..578daf588 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -8,8 +8,8 @@ */ import * as fs from "node:fs/promises"; import path from "node:path"; -import { getOAuthApiKey } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { getOAuthApiKey } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import { $flag, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; /** diff --git a/packages/ai/test/ollama-cloud-login.test.ts b/packages/ai/test/ollama-cloud-login.test.ts index 18c3ce505..90fc695ab 100644 --- a/packages/ai/test/ollama-cloud-login.test.ts +++ b/packages/ai/test/ollama-cloud-login.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loginOllamaCloud } from "../src/utils/oauth/ollama-cloud"; +import { loginOllamaCloud } from "../src/registry/ollama-cloud"; describe("ollama cloud login", () => { it("opens Ollama Cloud key settings and trims the pasted key", async () => { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 73486aa4d..9de1fb94f 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -2101,6 +2101,69 @@ describe("openai-codex streaming", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("interrupts websocket streams that emit only whitespace tool-call argument deltas", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + const token = createCodexTestToken(); + const fetchMock = vi.fn(async () => { + throw new Error("SSE fallback should not run for degenerate tool-call arguments"); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + let sendCount = 0; + let closeCount = 0; + class WhitespaceArgumentsWebSocket extends MockWebSocket { + constructor(url: string, options?: { headers?: WsHeaders }) { + super(url, options); + this.scheduleOpen(); + } + + send(): void { + sendCount += 1; + this.sendJson({ + type: "response.output_item.added", + item: { + type: "function_call", + id: "fc_ws_whitespace", + call_id: "call_ws_whitespace", + name: "todo", + arguments: "", + }, + }); + for (let sequence = 1; sequence <= 300; sequence += 1) { + this.sendJson({ + type: "response.function_call_arguments.delta", + delta: sequence % 2 === 0 ? " ".repeat(64) : "\t", + item_id: "fc_ws_whitespace", + output_index: 1, + sequence_number: sequence, + }); + } + } + + close(): void { + closeCount += 1; + super.close(); + } + } + global.WebSocket = WhitespaceArgumentsWebSocket as unknown as typeof WebSocket; + + const model = createCodexTestModel("https://chatgpt.com/backend-api"); + const providerSessionState = new Map(); + const result = await streamOpenAICodexResponses(model, createCodexTestContext(), { + apiKey: token, + sessionId: "ws-whitespace-arguments-session", + providerSessionState, + }).result(); + + expect(sendCount).toBe(1); + expect(closeCount).toBeGreaterThan(0); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("whitespace-only tool-call argument delta"); + expect(result.errorMessage).toContain("fc_ws_whitespace"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("retries websocket stream closes before surfacing transport errors", async () => { const tempDir = TempDir.createSync("@pi-codex-stream-"); setAgentDir(tempDir.path()); diff --git a/packages/ai/test/openrouter-login.test.ts b/packages/ai/test/openrouter-login.test.ts index aa6557a14..ec22d8e1c 100644 --- a/packages/ai/test/openrouter-login.test.ts +++ b/packages/ai/test/openrouter-login.test.ts @@ -1,8 +1,8 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, test, vi } from "bun:test"; import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { getOAuthProviders } from "../src/registry/oauth"; import { getEnvApiKey } from "../src/stream"; -import { getOAuthProviders } from "../src/utils/oauth"; const originalOpenRouterApiKey = Bun.env.OPENROUTER_API_KEY; const originalFetch = global.fetch; diff --git a/packages/ai/test/provider-registry.test.ts b/packages/ai/test/provider-registry.test.ts new file mode 100644 index 000000000..ad6d9aa49 --- /dev/null +++ b/packages/ai/test/provider-registry.test.ts @@ -0,0 +1,102 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, test, vi } from "bun:test"; +import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; +import { PASTE_CODE_LOGIN_PROVIDERS } from "../src/registry"; +import { + getOAuthProviders, + refreshOAuthToken, + registerOAuthProvider, + unregisterOAuthProviders, +} from "../src/registry/oauth"; +import * as anthropicOauth from "../src/registry/oauth/anthropic"; +import type { OAuthCredentials, OAuthProvider } from "../src/registry/oauth/types"; +import { getEnvApiKey } from "../src/stream"; + +const FIXTURE_SOURCE = "provider-registry-test"; +const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY"] as const; +const originalEnv = new Map(ENV_KEYS.map(key => [key, Bun.env[key]])); + +afterEach(() => { + unregisterOAuthProviders(FIXTURE_SOURCE); + for (const key of ENV_KEYS) { + const original = originalEnv.get(key); + if (original === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = original; + } + } + vi.restoreAllMocks(); +}); + +describe("provider registry derivation", () => { + test("descriptors are derived for standard model providers, excluding special-managed ones", () => { + const zenmux = PROVIDER_DESCRIPTORS.find(descriptor => descriptor.providerId === "zenmux"); + expect(zenmux).toBeDefined(); + expect(zenmux?.defaultModel).toBe("anthropic/claude-opus-4.6"); + // The derived factory carries the provider identity through. + expect(zenmux?.createModelManagerOptions({ apiKey: "k" }).providerId).toBe("zenmux"); + + // openai-codex is special-managed (bespoke runtime factory) → excluded from descriptors, + // but still a known model provider with a default. + expect(PROVIDER_DESCRIPTORS.some(descriptor => descriptor.providerId === "openai-codex")).toBe(false); + expect(DEFAULT_MODEL_PER_PROVIDER["openai-codex"]).toBe("gpt-5.4"); + // Login-only tools have no default model. + expect(DEFAULT_MODEL_PER_PROVIDER).not.toHaveProperty("kagi"); + }); + + test("env-key map merges registry defs with legacy non-provider keys", () => { + Bun.env.ZENMUX_API_KEY = "zenmux-env"; + Bun.env.EXA_API_KEY = "exa-env"; + expect(getEnvApiKey("zenmux")).toBe("zenmux-env"); + // Legacy search-tool key preserved (not a registry provider def). + expect(getEnvApiKey("exa")).toBe("exa-env"); + }); + + test("login list contains loginable providers and excludes env-only model providers", () => { + const ids = getOAuthProviders().map(provider => provider.id); + expect(ids).toContain("zenmux"); + expect(ids).toContain("kagi"); + // openai has no interactive login flow. + expect(ids).not.toContain("openai"); + }); + + test("paste-code login set is derived from pasteCodeFlow", () => { + expect([...PASTE_CODE_LOGIN_PROVIDERS].sort()).toEqual( + ["anthropic", "gitlab-duo", "google-antigravity", "google-gemini-cli", "openai-codex"].sort(), + ); + expect(PASTE_CODE_LOGIN_PROVIDERS.has("zenmux")).toBe(false); + }); + + test("refresh dispatch returns api-key providers unchanged and routes real refreshers", async () => { + const creds: OAuthCredentials = { refresh: "r", access: "a", expires: Date.now() + 60_000 }; + // zenmux has no refresher → returned as-is. + expect(await refreshOAuthToken("zenmux", creds)).toBe(creds); + + const refreshed: OAuthCredentials = { refresh: "r2", access: "a2", expires: Date.now() + 120_000 }; + const spy = vi.spyOn(anthropicOauth, "refreshAnthropicToken").mockResolvedValue(refreshed); + expect(await refreshOAuthToken("anthropic", creds)).toBe(refreshed); + expect(spy).toHaveBeenCalledWith("r"); + + await expect(refreshOAuthToken("nonexistent-provider" as OAuthProvider, creds)).rejects.toThrow( + "Unknown OAuth provider", + ); + }); + + test("login dispatcher handles runtime-registered extension providers", async () => { + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + const storage = new AuthStorage(store); + await storage.reload(); + registerOAuthProvider({ + id: "fixture-x", + name: "Fixture X", + sourceId: FIXTURE_SOURCE, + login: async () => "fixture-key", + }); + + await storage.login("fixture-x", { onAuth: () => {}, onPrompt: async () => "" }); + + expect(store.getApiKey("fixture-x")).toBe("fixture-key"); + }); +}); diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index 7c9e6c13f..6ce28b976 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -11,7 +11,7 @@ import { SqliteAuthCredentialStore, startAuthBroker, } from "../src"; -import * as oauthUtils from "../src/utils/oauth"; +import * as oauthUtils from "../src/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/synthetic-login.test.ts b/packages/ai/test/synthetic-login.test.ts index 744c14419..00f225de9 100644 --- a/packages/ai/test/synthetic-login.test.ts +++ b/packages/ai/test/synthetic-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginSynthetic } from "../src/utils/oauth/synthetic"; +import { loginSynthetic } from "../src/registry/synthetic"; const originalFetch = global.fetch; diff --git a/packages/ai/test/xiaomi-oauth.test.ts b/packages/ai/test/xiaomi-oauth.test.ts index 7f5e0c9f4..00d9859bb 100644 --- a/packages/ai/test/xiaomi-oauth.test.ts +++ b/packages/ai/test/xiaomi-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginXiaomi } from "../src/utils/oauth/xiaomi"; +import { loginXiaomi } from "../src/registry/oauth/xiaomi"; const originalFetch = global.fetch; diff --git a/packages/ai/test/xiaomi-tp-login-integration.test.ts b/packages/ai/test/xiaomi-tp-login-integration.test.ts index 1711bfd47..ffc1d8dce 100644 --- a/packages/ai/test/xiaomi-tp-login-integration.test.ts +++ b/packages/ai/test/xiaomi-tp-login-integration.test.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from "bun:test"; import { hookFetch } from "@oh-my-pi/pi-utils"; import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { loginXiaomi } from "../src/utils/oauth/xiaomi"; +import { loginXiaomi } from "../src/registry/oauth/xiaomi"; // Realistic tp- key (same format as user's key, but a dummy value for testing) const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; diff --git a/packages/ai/test/zenmux-login.test.ts b/packages/ai/test/zenmux-login.test.ts index 19429fe1e..fed5e046a 100644 --- a/packages/ai/test/zenmux-login.test.ts +++ b/packages/ai/test/zenmux-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginZenMux } from "../src/utils/oauth/zenmux"; +import { loginZenMux } from "../src/registry/zenmux"; const originalFetch = global.fetch; diff --git a/packages/ai/test/zenmux-provider.test.ts b/packages/ai/test/zenmux-provider.test.ts index 05e6de679..8ca85c978 100644 --- a/packages/ai/test/zenmux-provider.test.ts +++ b/packages/ai/test/zenmux-provider.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; import { zenmuxModelManagerOptions } from "../src/provider-models/openai-compat"; +import { getOAuthProviders } from "../src/registry/oauth"; import { getEnvApiKey } from "../src/stream"; -import { getOAuthProviders } from "../src/utils/oauth"; const originalZenMuxApiKey = Bun.env.ZENMUX_API_KEY; const originalFetch = global.fetch; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f66c46428..9d188f40d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,13 +21,13 @@ - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded - Changed the prompt editor to highlight large-paste placeholders (`[Paste #N, +X lines]`/`[Paste #N, Y chars]`) with the same accent styling as image references (bold, no hyperlink), and to delete image/paste markers atomically: a single backspace or forward-delete removes the whole marker instead of leaving a broken `[Paste #N, +X lines` behind. - Browser tool helpers (`tab.*`) are now individually tracked and time-bounded: when a `run` cell hits its budget, the timeout error names the still-running helper(s) and how long each has been stalled (e.g. `... (stalled on tab.screenshot({ selector: ".x" }) (29.9s))`) instead of the opaque `Browser code execution timed out after 30000ms`. Page-coupled helpers that should resolve quickly (`observe`, `screenshot`, `extract`) also fail fast with a named per-op error at `min(cellBudget, 20s)`, leaving budget for the rest of the cell, rather than silently consuming the whole budget. -- Changed provider tie-breaking to rank G0I with hosted aggregator providers, after OpenRouter and before Together. - Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. - Raised the `eval` tool's per-cell `timeout` ceiling from 600s to 3600s (matching `bash`), in both the Zod schema and the `TOOL_TIMEOUTS.eval` runtime clamp, so heavy local-compute cells can request budgets above 10 minutes. - Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. - Reworked tool result-header glyphs to cut the overused success checkmark/dot: each tool now shows its own signature icon on success (terminal for bash, pencil for edit, magnifier/globe for search, plug for MCP, etc.; read keeps the read-group status dot), tools without a custom renderer fall back to a quiet `status.done` dot, and error/warning/pending states keep the universal cross/warning/spinner - Changed steady-state health indicators (LSP server ready, OAuth logged-in, plugin-doctor checks) from a success checkmark to a colored `status.enabled` dot, so failures stand out instead of every line reading as a check - Changed one-shot MCP/SSH/debug confirmation messages from a generic checkmark to contextual action glyphs (add/remove, connect/enable/disable toggles, reload, job-completed), reflecting what happened rather than just "success" +- Derived the auth-broker OAuth callback ports (`CALLBACK_PORTS`) and the paste-code login-provider set from the `@oh-my-pi/pi-ai` provider registry, removing the duplicated `CALLBACK_SERVER_PROVIDERS` tables in the model selector and the setup-wizard sign-in scene. ### Removed diff --git a/packages/coding-agent/src/cli/auth-broker-cli.ts b/packages/coding-agent/src/cli/auth-broker-cli.ts index 1c6f268f4..5a58bcb5c 100644 --- a/packages/coding-agent/src/cli/auth-broker-cli.ts +++ b/packages/coding-agent/src/cli/auth-broker-cli.ts @@ -30,6 +30,7 @@ import { type OAuthCredential, type OAuthProvider, type OAuthProviderInfo, + PROVIDER_REGISTRY, SqliteAuthCredentialStore, startAuthBroker, } from "@oh-my-pi/pi-ai"; @@ -75,13 +76,11 @@ const ACTIONS: readonly AuthBrokerAction[] = [ ]; /** Callback ports baked from the per-provider OAuth flow modules. */ -const CALLBACK_PORTS: Record = { - anthropic: 54545, - "openai-codex": 1455, - "google-gemini-cli": 8085, - "google-antigravity": 51121, - "gitlab-duo": 8080, -}; +const CALLBACK_PORTS: Record = Object.fromEntries( + PROVIDER_REGISTRY.flatMap(provider => + provider.callbackPort != null ? [[provider.id, provider.callbackPort] as [string, number]] : [], + ), +); function getTokenFilePath(): string { return path.join(getConfigRootDir(), "auth-broker.token"); diff --git a/packages/coding-agent/src/cli/auth-gateway-cli.ts b/packages/coding-agent/src/cli/auth-gateway-cli.ts index 1024914fd..fa3317333 100644 --- a/packages/coding-agent/src/cli/auth-gateway-cli.ts +++ b/packages/coding-agent/src/cli/auth-gateway-cli.ts @@ -357,7 +357,7 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis /** * Providers whose chat endpoint expects a JSON-serialized credential blob * (`{ token, projectId, refreshToken, expiresAt, … }`) rather than the raw - * access token. Mirrors `getOAuthApiKey` in `packages/ai/src/utils/oauth`. + * access token. Mirrors `getOAuthApiKey` in `packages/ai/src/registry/oauth`. */ const STRUCTURED_API_KEY_PROVIDERS: ReadonlySet = new Set([ "github-copilot", diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index eab9320b8..70673350e 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -189,6 +189,11 @@ export async function runListModelsCommand(options: RunListModelsOptions): Promi modelRegistry.registerProvider(name, config, sourceId); } extensionsResult.runtime.pendingProviderRegistrations = []; + // Discover runtime (extension) provider catalogs now that they are registered. + // The full refresh in main.ts ran before extensions loaded, so this is the only + // point where extension-contributed dynamic providers get discovered. Cache-aware + // so it reuses the shared 24 h model cache instead of refetching every invocation. + await modelRegistry.refreshRuntimeProviders("online-if-uncached"); await listModels(modelRegistry, searchPattern); } diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 7006b17d7..4d07a4d96 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -96,8 +96,8 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [ ]; import type { ApiKeyResolver } from "@oh-my-pi/pi-ai"; -import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import { isRecord, logger } from "@oh-my-pi/pi-utils"; import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme"; @@ -922,6 +922,9 @@ export class ModelRegistry { #runtimeProviderOverrides: Map = new Map(); #runtimeProvidersBySource: Map> = new Map(); #runtimeProviderSourceByName: Map = new Map(); + // Runtime model managers registered by extensions via fetchDynamicModels. + // Keyed by provider name; use the same SQLite cache path as builtins. + #runtimeModelManagers: Map; sourceId: string }> = new Map(); #rebuildPending: boolean = false; #rebuildSuspended: number = 0; @@ -999,6 +1002,27 @@ export class ModelRegistry { } } + /** + * Discover models for providers registered at runtime via `fetchDynamicModels` + * (extension providers). Merges the discovered catalog into the existing model + * set without reloading static models, so dynamically-discovered models from + * other providers are preserved. No-op when no runtime providers are registered. + * + * Drives the same SQLite model cache as built-in providers, so the default + * `online-if-uncached` strategy fetches at most once per cache TTL (24 h). + */ + async refreshRuntimeProviders(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise { + if (this.#runtimeModelManagers.size === 0) { + return; + } + this.#suspendRebuild(); + try { + await this.#refreshRuntimeDiscoveries(strategy, new Set(this.#runtimeModelManagers.keys())); + } finally { + this.#resumeRebuild(); + } + } + #reloadStaticModels(): void { const currentMtime = this.#modelsConfigFile.getMtimeMs(); if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) { @@ -1665,6 +1689,10 @@ export class ModelRegistry { } options.push(descriptor.createOptions(key)); } + // Append runtime model managers registered by extensions via fetchDynamicModels. + for (const { options: managerOpts } of this.#runtimeModelManagers.values()) { + options.push(managerOpts); + } return options; } @@ -2396,6 +2424,7 @@ export class ModelRegistry { this.#runtimeProviderApiKeys.delete(providerName); this.#runtimeProviderOverrides.delete(providerName); this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName); + this.#runtimeModelManagers.delete(providerName); this.authStorage.removeConfigApiKey(providerName); } @@ -2559,6 +2588,47 @@ export class ModelRegistry { return; } + if (config.fetchDynamicModels) { + const fetcher = config.fetchDynamicModels; + const providerBaseUrl = config.baseUrl ?? ""; + const providerApi = config.api; + const providerHeaders = config.headers; + const providerApiKey = config.apiKey; + const providerAuthHeader = config.authHeader; + const providerCompat = config.compat; + const managerOptions: ModelManagerOptions = { + providerId: providerName as Parameters[0]["providerId"], + staticModels: [], + cacheDbPath: this.#cacheDbPath, + cacheTtlMs: 24 * 60 * 60 * 1000, + dynamicModelsAuthoritative: true, + fetchDynamicModels: async () => { + const apiKey = await this.authStorage.peekApiKey(providerName); + const resolvedKey = isAuthenticated(apiKey) ? apiKey : undefined; + const modelDefs = await fetcher(resolvedKey); + const results: Model[] = []; + for (const modelDef of modelDefs) { + const overlay = buildCustomModelOverlay( + providerName, + modelDef.baseUrl ?? providerBaseUrl, + modelDef.api ?? providerApi, + providerHeaders, + providerApiKey, + providerAuthHeader, + providerCompat, + undefined, + modelDef as CustomModelDefinitionLike, + ); + if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true })); + } + return results; + }, + }; + this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" }); + // Discovery is driven by refreshRuntimeProviders() after the drain — not + // here, so registration has no network side effect and callers can await. + } + if ( config.baseUrl || config.headers || @@ -2636,6 +2706,15 @@ export interface ProviderConfigInput { getApiKey?(credentials: OAuthCredentials): string; modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; }; + /** + * Async factory that fetches the live model list from the provider endpoint. + * When present, the result is run through the same SQLite model-cache as + * built-in providers (keyed by provider name, default 24 h TTL). + * The factory receives the resolved API key (undefined when unauthenticated). + */ + fetchDynamicModels?: ( + apiKey: string | undefined, + ) => Promise[number][]>; models?: Array<{ id: string; name: string; diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 17b78e334..8abde9b06 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -28,7 +28,7 @@ import type { TextContent, TSchema, } from "@oh-my-pi/pi-ai"; -import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; @@ -1134,6 +1134,13 @@ export interface ProviderConfig { /** Optional model rewrite hook for credential-aware routing (e.g., enterprise URLs). */ modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; }; + /** + * Async factory that fetches the live model list from the provider endpoint. + * Runs through the same SQLite model-cache as built-in providers (keyed by + * provider name, default 24 h TTL). Receives the resolved API key (undefined + * when unauthenticated). Mutually exclusive with `models`. + */ + fetchDynamicModels?: (apiKey: string | undefined) => Promise; } /** Configuration for a model within a provider. */ diff --git a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts index 9426e4d7f..8006e475b 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts @@ -8,7 +8,7 @@ * entrypoint. Legacy extensions still author parameter schemas as * `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in * place of the real pi-ai entrypoint whenever a legacy extension imports the - * bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.) + * bare package root. Subpath imports (`@oh-my-pi/pi-ai/oauth`, etc.) * continue to resolve directly against the bundled pi-ai package. * * The `Type` runtime is borrowed from the Zod-backed TypeBox shim that diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 70817b916..e85e63c7d 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -33,10 +33,11 @@ const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|"); // bundled copy. Add new entries as `pkg/from -> pkg/to` whenever a plugin // surfaces another upstream-only subpath that breaks resolution. const PI_SUBPATH_REMAPS: ReadonlyMap = new Map([ - // `@mariozechner/pi-ai/oauth` re-exported `./utils/oauth/index.js`. - // Our pi-ai keeps the implementation under `utils/oauth` but never added a - // root-level re-export, so map the upstream subpath onto it directly. - ["pi-ai/oauth", "pi-ai/utils/oauth"], + // (currently empty) Upstream `@mariozechner/pi-ai/oauth` re-exported + // `./utils/oauth/index.js`. Our pi-ai now exposes the same surface at the + // real `@oh-my-pi/pi-ai/oauth` export, so the legacy subpath canonicalizes + // straight to it with no rewrite. Add `from -> to` entries here whenever a + // future upstream-only subpath surfaces that breaks resolution. ]); const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`); @@ -119,7 +120,7 @@ const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT // longer satisfies those imports. The override below redirects only the bare // pi-ai package root onto a sibling shim that re-exports the canonical surface // plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath -// imports such as `@oh-my-pi/pi-ai/utils/oauth` continue to resolve directly +// imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly // against the bundled pi-ai package. const LEGACY_PI_AI_SHIM_PATH = BUNFS_PACKAGE_ROOT ? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-ai-shim.js") diff --git a/packages/coding-agent/src/mcp/oauth-flow.ts b/packages/coding-agent/src/mcp/oauth-flow.ts index efcef86a0..7ac50765a 100644 --- a/packages/coding-agent/src/mcp/oauth-flow.ts +++ b/packages/coding-agent/src/mcp/oauth-flow.ts @@ -5,9 +5,9 @@ * by providing authorization URL, token URL, and client credentials. */ -import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/utils/oauth/callback-server"; -import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/utils/oauth/callback-server"; -import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/oauth/callback-server"; +import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/oauth/callback-server"; +import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types"; const DEFAULT_PORT = 3000; const CALLBACK_PATH = "/callback"; diff --git a/packages/coding-agent/src/modes/components/login-dialog.ts b/packages/coding-agent/src/modes/components/login-dialog.ts index 9fb44b4b5..628cb8691 100644 --- a/packages/coding-agent/src/modes/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/components/login-dialog.ts @@ -1,4 +1,4 @@ -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import { openPath } from "../../utils/open"; diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index f21386f6e..e4436a29e 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -1,5 +1,5 @@ -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/oauth/types"; import { Container, extractPrintableText, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 2d2728db2..5d644aa4a 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1,6 +1,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui"; import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui"; import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils"; @@ -57,14 +58,6 @@ import type { SessionObserverRegistry } from "../session-observer-registry"; import { computeContextBreakdown } from "../utils/context-usage"; import { buildCopyTargets } from "../utils/copy-targets"; -const CALLBACK_SERVER_PROVIDERS = new Set([ - "anthropic", - "openai-codex", - "gitlab-duo", - "google-gemini-cli", - "google-antigravity", -]); - const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login ."; export class SelectorController { @@ -928,7 +921,7 @@ export class SelectorController { async #handleOAuthLogin(providerId: string): Promise { this.ctx.showStatus(`Logging in to ${providerId}…`); const manualInput = this.ctx.oauthManualInput; - const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider); + const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId); try { await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { onAuth: (info: { url: string; instructions?: string }) => { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 69173a5ad..778cfa649 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -10,7 +10,7 @@ * - Events: AgentSessionEvent objects streamed as they occur * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response */ -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils"; import { type ExtensionUIContext, diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts index 7ddc176f4..df054f18c 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts @@ -1,20 +1,12 @@ import type { AuthStorage } from "@oh-my-pi/pi-ai"; -import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai"; +import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import { Input, matchesKey, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; import { getAgentDbPath } from "@oh-my-pi/pi-utils"; import { OAuthSelectorComponent } from "../../components/oauth-selector"; import { theme } from "../../theme/theme"; import type { SetupSceneHost, SetupTab } from "./types"; -/** Providers whose OAuth flow needs a pasted code/redirect URL rather than a callback server. */ -const CALLBACK_SERVER_PROVIDERS: Partial> = { - anthropic: true, - "openai-codex": true, - "gitlab-duo": true, - "google-gemini-cli": true, - "google-antigravity": true, -}; - function loginUrlLink(url: string): string { return `\x1b]8;;${url}\x07Open login URL\x1b]8;;\x07`; } @@ -119,7 +111,7 @@ export class SignInTab implements SetupTab { async #login(providerId: string): Promise { if (this.#loggingInProvider || this.#disposed) return; - const useManualInput = CALLBACK_SERVER_PROVIDERS[providerId as OAuthProvider] === true; + const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId); this.#selector.stopValidation(); this.#loggingInProvider = providerId; this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")]; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index ea564bde5..8db27c358 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import type { SettingPath, SettingValue } from "../config/settings"; diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts index 4b6a773a5..5cd46ecfd 100644 --- a/packages/coding-agent/src/web/search/providers/codex.ts +++ b/packages/coding-agent/src/web/search/providers/codex.ts @@ -8,7 +8,7 @@ */ import * as os from "node:os"; import { type AuthStorage, getBundledModels } from "@oh-my-pi/pi-ai"; -import { decodeJwt } from "@oh-my-pi/pi-ai/utils/oauth/openai-codex"; +import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex"; import { $env, readSseJson } from "@oh-my-pi/pi-utils"; import packageJson from "../../../../package.json" with { type: "json" }; import type { SearchResponse, SearchSource } from "../../../web/search/types"; diff --git a/packages/coding-agent/test/auth-storage-rotation.test.ts b/packages/coding-agent/test/auth-storage-rotation.test.ts index 32dcafbd9..823cf557c 100644 --- a/packages/coding-agent/test/auth-storage-rotation.test.ts +++ b/packages/coding-agent/test/auth-storage-rotation.test.ts @@ -3,8 +3,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { UsageProvider } from "@oh-my-pi/pi-ai"; -import * as oauth from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthCredentials } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import * as oauth from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { Snowflake } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts index 7d8dd56b7..744e14dd4 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts @@ -127,7 +127,7 @@ describe("legacy pi package root remaps (issue #1474)", () => { it("falls back to legacy-scoped subpath peers for direct plugin imports", async () => { const realResolveSync = Bun.resolveSync.bind(Bun); vi.spyOn(Bun, "resolveSync").mockImplementation((specifier: string, from: string) => { - if (specifier === "@oh-my-pi/pi-ai/utils/oauth") { + if (specifier === "@oh-my-pi/pi-ai/oauth") { throw new Error(`canonical peer unavailable from ${from}`); } return realResolveSync(specifier, from); diff --git a/packages/coding-agent/test/model-registry-runtime-provider.test.ts b/packages/coding-agent/test/model-registry-runtime-provider.test.ts index 30e40efbb..9ef3ffddf 100644 --- a/packages/coding-agent/test/model-registry-runtime-provider.test.ts +++ b/packages/coding-agent/test/model-registry-runtime-provider.test.ts @@ -3,8 +3,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { type AssistantMessageEventStream, clearCustomApis, Effort, getCustomApi } from "@oh-my-pi/pi-ai"; -import { getOAuthProviders, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; -import type { OAuthCredentials } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import { getOAuthProviders, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types"; import { ModelRegistry, type ProviderConfigInput } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { Snowflake } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/modes/components/oauth-selector.test.ts b/packages/coding-agent/test/modes/components/oauth-selector.test.ts index b53b93ae3..edfd5677a 100644 --- a/packages/coding-agent/test/modes/components/oauth-selector.test.ts +++ b/packages/coding-agent/test/modes/components/oauth-selector.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from "bun:test"; -import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { OAuthSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/oauth-selector"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; diff --git a/packages/coding-agent/test/pi-scope-aliases.test.ts b/packages/coding-agent/test/pi-scope-aliases.test.ts index 318ad64a5..2449fb8b5 100644 --- a/packages/coding-agent/test/pi-scope-aliases.test.ts +++ b/packages/coding-agent/test/pi-scope-aliases.test.ts @@ -29,10 +29,10 @@ const canonicalCodingAgentExtensions = Bun.resolveSync( ); const canonicalUtils = Bun.resolveSync("@oh-my-pi/pi-utils", import.meta.dir); const canonicalTui = Bun.resolveSync("@oh-my-pi/pi-tui", import.meta.dir); -// Subpath remap: upstream `pi-ai/oauth` re-exported `utils/oauth/index`; the -// shim rewrites the legacy subpath onto its current home so plugins keep -// importing the upstream layout. -const canonicalAiOauth = Bun.resolveSync("@oh-my-pi/pi-ai/utils/oauth", import.meta.dir); +// Subpath: upstream `pi-ai/oauth` re-exported `utils/oauth/index`; our pi-ai now +// exposes the same surface at the real `@oh-my-pi/pi-ai/oauth` export, so the +// legacy `@mariozechner/pi-ai/oauth` specifier canonicalizes straight to it. +const canonicalAiOauth = Bun.resolveSync("@oh-my-pi/pi-ai/oauth", import.meta.dir); interface AliasCase { id: string; @@ -65,13 +65,13 @@ const CASES: readonly AliasCase[] = [ canonicalPath: canonicalCodingAgentExtensions, symbol: "isToolCallEventType", }, - // Subpath remap: legacy `pi-ai/oauth` should resolve to `pi-ai/utils/oauth`. + // Subpath: legacy `pi-ai/oauth` resolves to the real `@oh-my-pi/pi-ai/oauth`. { id: "mariozechner-ai-oauth", aliasSpecifier: "@mariozechner/pi-ai/oauth", canonicalPath: canonicalAiOauth, - // `refreshOAuthToken` is exported by our `utils/oauth/index` and by - // upstream's `oauth.d.ts`; it makes a stable probe across both layouts. + // `refreshOAuthToken` is exported by our `oauth/index` and by upstream's + // `oauth.d.ts`; it makes a stable probe across both layouts. symbol: "refreshOAuthToken", }, // `Key` runtime helper restored on pi-tui (plannotator + rpiv-* import it). diff --git a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts index a01522c99..c28e402ad 100644 --- a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts +++ b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { AuthStorage, type CredentialDisabledEvent } from "@oh-my-pi/pi-ai"; -import * as oauthUtils from "@oh-my-pi/pi-ai/utils/oauth"; +import * as oauthUtils from "@oh-my-pi/pi-ai/oauth"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { Extension, ExtensionError, ExtensionFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; diff --git a/packages/coding-agent/test/setup-wizard-sign-in.test.ts b/packages/coding-agent/test/setup-wizard-sign-in.test.ts index ab234db51..2e5c40c6a 100644 --- a/packages/coding-agent/test/setup-wizard-sign-in.test.ts +++ b/packages/coding-agent/test/setup-wizard-sign-in.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from "bun:test"; import type { AuthStorage } from "@oh-my-pi/pi-ai"; -import type { OAuthLoginCallbacks, OAuthProviderId } from "@oh-my-pi/pi-ai/utils/oauth/types"; +import type { OAuthLoginCallbacks, OAuthProviderId } from "@oh-my-pi/pi-ai/oauth/types"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { SignInTab } from "../src/modes/setup-wizard/scenes/sign-in"; import type { SetupSceneHost } from "../src/modes/setup-wizard/scenes/types"; From e79415e78bfd7dc0f2db14c3d6de5d2a956a78dd Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 18:57:24 +0200 Subject: [PATCH 49/53] revert: codex obfuscation parameters --- .../ai/src/providers/openai-codex/request-transformer.ts | 6 ------ packages/ai/test/openai-codex-stream.test.ts | 6 +----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index df0e8be0d..91abe9dc5 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -40,10 +40,6 @@ export interface RequestBody { presence_penalty?: number; repetition_penalty?: number; reasoning?: Partial; - stream_options?: { - include_obfuscation?: boolean; - [key: string]: unknown; - }; text?: { verbosity?: "low" | "medium" | "high"; }; @@ -165,8 +161,6 @@ export async function transformRequestBody( ): Promise { body.store = false; body.stream = true; - body.stream_options ??= {}; - body.stream_options.include_obfuscation = false; if (body.input && Array.isArray(body.input)) { body.input = filterInput(body.input); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 9de1fb94f..2904b6a10 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -435,11 +435,7 @@ describe("openai-codex streaming", () => { expect(outbound).toBeDefined(); expect(outbound.raw[0]).toMatch(/^: ws → /); expect(outbound.data.length).toBeGreaterThan(0); - const outboundRequest = JSON.parse(outbound.data) as Record; - expect(outboundRequest).toMatchObject({ - type: "response.create", - stream_options: { include_obfuscation: false }, - }); + expect(() => JSON.parse(outbound.data)).not.toThrow(); // Inbound frames mirror the Codex response sequence emitted by `emitCodexResponse`. expect(inbound.map(e => e.event)).toEqual([ From 9d457f73d9918a5a2551076b0ab47a903f10c63f Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 18:53:15 +0200 Subject: [PATCH 50/53] test: migrated test imports to package subpath exports - Replaced relative `../src` imports with `@oh-my-pi/pi-ai` and `@oh-my-pi/pi-agent-core` subpaths. --- crates/pi-natives/src/ast.rs | 11 +- crates/pi-shell/src/shell.rs | 3 +- .../agent/test/append-only-context.test.ts | 4 +- packages/agent/test/harmony-leak.test.ts | 6 +- packages/agent/test/yield.test.ts | 2 +- packages/ai/CHANGELOG.md | 6 +- packages/ai/test/abort-source-tracker.test.ts | 2 +- packages/ai/test/abortable-iterator.test.ts | 2 +- packages/ai/test/anthropic-client.test.ts | 4 +- .../test/anthropic-many-image-resize.test.ts | 4 +- packages/ai/test/anthropic-oauth.test.ts | 8 +- .../ai/test/anthropic-stream-envelope.test.ts | 6 +- .../ai/test/anthropic-stream-timeout.test.ts | 6 +- packages/ai/test/api-registry.test.ts | 4 +- .../ai/test/auth-broker-refresher.test.ts | 4 +- .../ai/test/auth-broker-remote-store.test.ts | 2 +- .../test/auth-broker-snapshot-cache.test.ts | 2 +- packages/ai/test/auth-broker-wire.test.ts | 4 +- .../auth-gateway-anthropic-messages.test.ts | 6 +- .../ai/test/auth-gateway-cache-key.test.ts | 2 +- .../test/auth-gateway-classify-error.test.ts | 2 +- .../ai/test/auth-gateway-openai-chat.test.ts | 4 +- .../auth-gateway-openai-responses.test.ts | 8 +- .../ai/test/auth-gateway-pi-native.test.ts | 6 +- .../test/auth-storage-api-key-login.test.ts | 8 +- .../auth-storage-broker-no-sentinel.test.ts | 4 +- .../auth-storage-check-credentials.test.ts | 4 +- .../test/auth-storage-codex-selection.test.ts | 8 +- .../test/auth-storage-config-override.test.ts | 2 +- ...-storage-credential-disabled-event.test.ts | 4 +- .../auth-storage-credential-origin.test.ts | 2 +- .../ai/test/auth-storage-email-dedupe.test.ts | 2 +- .../auth-storage-force-refresh-rotate.test.ts | 4 +- .../auth-storage-oauth-refresh-race.test.ts | 4 +- .../ai/test/auth-storage-refresh-skew.test.ts | 4 +- .../ai/test/auth-storage-usage-cache.test.ts | 6 +- packages/ai/test/aws-credentials.test.ts | 2 +- packages/ai/test/aws-eventstream.test.ts | 2 +- packages/ai/test/aws-sigv4.test.ts | 2 +- .../azure-openai-responses-stream.test.ts | 7 +- .../test/callback-server-manual-input.test.ts | 4 +- .../ai/test/claude-ratelimit-headers.test.ts | 4 +- packages/ai/test/claude-usage-headers.test.ts | 4 +- packages/ai/test/claude-usage-retry.test.ts | 4 +- packages/ai/test/cursor-exec-handlers.test.ts | 6 +- .../test/deepseek-reasoning-content.test.ts | 6 +- packages/ai/test/event-stream.test.ts | 4 +- packages/ai/test/firepass.live.ts | 6 +- packages/ai/test/firepass.test.ts | 6 +- .../github-copilot-anthropic-auth.test.ts | 8 +- packages/ai/test/github-copilot-error.test.ts | 2 +- .../ai/test/github-copilot-headers.test.ts | 6 +- packages/ai/test/github-copilot-login.test.ts | 2 +- .../test/github-copilot-model-limits.test.ts | 8 +- packages/ai/test/github-copilot-oauth.test.ts | 2 +- .../github-copilot-openai-base-url.test.ts | 8 +- .../ai/test/github-copilot-reasoning.test.ts | 10 +- .../ai/test/google-antigravity-usage.test.ts | 4 +- .../google-gemini-cli-3x-thinking.test.ts | 4 +- .../test/google-gemini-cli-alignment.test.ts | 10 +- packages/ai/test/google-tool-choice.test.ts | 4 +- .../ai/test/google-vertex-discovery.test.ts | 6 +- packages/ai/test/issue-1203-repro.test.ts | 6 +- packages/ai/test/issue-1270-repro.test.ts | 6 +- packages/ai/test/issue-1373-repro.test.ts | 6 +- packages/ai/test/issue-1399-repro.test.ts | 6 +- packages/ai/test/issue-1417-repro.test.ts | 6 +- packages/ai/test/issue-1617-repro.test.ts | 2 +- packages/ai/test/issue-1776-repro.test.ts | 6 +- packages/ai/test/issue-1838-repro.test.ts | 6 +- packages/ai/test/issue-1846-repro.test.ts | 10 +- packages/ai/test/issue-1849-repro.test.ts | 4 +- packages/ai/test/issue-2080-repro.test.ts | 6 +- packages/ai/test/issue-772-repro.test.ts | 5 +- packages/ai/test/issue-826-repro.test.ts | 6 +- packages/ai/test/issue-830-repro.test.ts | 10 +- packages/ai/test/issue-847-repro.test.ts | 2 +- packages/ai/test/issue-883-repro.test.ts | 6 +- packages/ai/test/issue-887-repro.test.ts | 2 +- packages/ai/test/issue-911-repro.test.ts | 6 +- packages/ai/test/issue-957-repro.test.ts | 4 +- packages/ai/test/issue-959-repro.test.ts | 6 +- .../ai/test/issue-967-vision-guard.test.ts | 16 +- packages/ai/test/issue-969-repro.test.ts | 8 +- packages/ai/test/issue-976-repro.test.ts | 4 +- packages/ai/test/kagi-login.test.ts | 2 +- packages/ai/test/kilo-login.test.ts | 2 +- packages/ai/test/minimax-code-login.test.ts | 2 +- packages/ai/test/mock-provider.test.ts | 6 +- packages/ai/test/model-cache.test.ts | 4 +- packages/ai/test/models-cost.test.ts | 4 +- .../models-json-no-local-endpoints.test.ts | 2 +- packages/ai/test/nanogpt-login.test.ts | 2 +- packages/ai/test/nanogpt-model-limits.test.ts | 4 +- packages/ai/test/oauth-deepseek.test.ts | 4 +- packages/ai/test/ollama-cloud-login.test.ts | 2 +- .../ai/test/ollama-cloud-provider.test.ts | 6 +- packages/ai/test/ollama-provider.test.ts | 8 +- packages/ai/test/openai-codex-usage.test.ts | 2 +- .../ai/test/openai-completions-compat.test.ts | 8 +- ...enai-completions-disable-reasoning.test.ts | 6 +- .../openai-completions-progress-chunk.test.ts | 6 +- .../test/openai-first-event-timeout.test.ts | 12 +- .../openai-responses-cache-affinity.test.ts | 6 +- .../openai-responses-developer-role.test.ts | 4 +- .../openai-responses-history-payload.test.ts | 2 +- ...i-responses-omit-max-output-tokens.test.ts | 6 +- .../openai-responses-system-prompt.test.ts | 6 +- packages/ai/test/openrouter-login.test.ts | 6 +- .../parse-streaming-json-throttled.test.ts | 2 +- packages/ai/test/pi-native-client.test.ts | 4 +- .../ai/test/provider-fetch-override.test.ts | 8 +- packages/ai/test/provider-registry.test.ts | 14 +- packages/ai/test/provider-response.test.ts | 8 +- packages/ai/test/raw-sse-sdk-capture.test.ts | 16 +- packages/ai/test/register-builtins.test.ts | 6 +- packages/ai/test/remote-auth-store.test.ts | 4 +- packages/ai/test/request-debug.test.ts | 10 +- .../service-tier-premium-requests.test.ts | 2 +- packages/ai/test/sse-debug.test.ts | 4 +- .../ai/test/stream-markup-healing.test.ts | 10 +- .../ai/test/stream-timeout-defaults.test.ts | 2 +- packages/ai/test/stream.test.ts | 2 +- packages/ai/test/synthetic-login.test.ts | 2 +- packages/ai/test/utils-responses-id.test.ts | 2 +- packages/ai/test/wafer.live.ts | 6 +- packages/ai/test/wafer.test.ts | 13 +- packages/ai/test/xai-oauth-bundle.test.ts | 4 +- .../ai/test/xai-oauth-effort-strip.test.ts | 2 +- packages/ai/test/xiaomi-oauth.test.ts | 2 +- .../test/xiaomi-tp-login-integration.test.ts | 5 +- packages/ai/test/zenmux-login.test.ts | 2 +- packages/ai/test/zenmux-provider.test.ts | 8 +- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/package.json | 20 +++ .../component.ts} | 58 ++---- .../src/modes/components/status-line/index.ts | 1 + .../src/modes/components/status-line/types.ts | 31 +++- packages/coding-agent/test/acp-agent.test.ts | 16 +- .../coding-agent/test/acp-builtins.test.ts | 14 +- .../test/acp-client-bridge.test.ts | 2 +- .../test/acp-event-mapper.test.ts | 8 +- .../test/acp-initialize-conformance.test.ts | 8 +- .../test/acp-lazy-startup.test.ts | 16 +- .../test/acp-mcp-isolation.test.ts | 12 +- .../test/agent-session-mcp-discovery.test.ts | 8 +- .../test/agent-session-role-thinking.test.ts | 8 +- .../test/agent-session-ssh-refresh.test.ts | 18 +- .../agent-session-tool-rebuild-skip.test.ts | 8 +- .../test/agent-storage-sqlite-compat.test.ts | 2 +- .../test/auth-broker-import.test.ts | 2 +- .../test/auth-broker-snapshot-cache.test.ts | 4 +- .../test/auto-thinking-classifier.test.ts | 2 +- .../test/autoresearch-state.test.ts | 18 +- .../test/autoresearch-tools.test.ts | 16 +- .../test/bash-acp-terminal.test.ts | 6 +- .../test/bash-failure-result.test.ts | 4 +- .../coding-agent/test/checkpoint-rpc-qa.ts | 4 +- .../coding-agent/test/cli-cwd-flag.test.ts | 4 +- .../test/cli-hide-thinking-flag.test.ts | 2 +- .../coding-agent/test/cli/completions.test.ts | 2 +- .../coding-agent/test/client-prompts.test.ts | 4 +- .../test/client-resources.test.ts | 4 +- .../test/commit-agentic-attribution.test.ts | 12 +- .../test/commit-command-exit.test.ts | 6 +- ...mmit-model-selection-role-thinking.test.ts | 2 +- .../test/commit-shared-llm.test.ts | 5 +- packages/coding-agent/test/compaction.test.ts | 4 +- packages/coding-agent/test/config-cli.test.ts | 4 +- .../test/core/js-executor.test.ts | 4 +- .../core/js-static-import-rewrite.test.ts | 2 +- .../test/core/js-tool-bridge.test.ts | 2 +- .../test/core/js-workflow-helpers.test.ts | 4 +- .../test/custom-editor-keybindings.test.ts | 2 +- .../test/dap-write-sink-flush.typecheck.ts | 2 +- .../test/debug/dap-launch-failures.test.ts | 19 +- .../test/debug/log-formatting.test.ts | 2 +- .../test/debug/log-viewer.test.ts | 2 +- .../test/debug/protocol-probe.test.ts | 6 +- .../test/debug/raw-sse-buffer.test.ts | 6 +- .../test/debug/raw-sse-pretty.test.ts | 2 +- .../test/debug/raw-sse-report-bundle.test.ts | 4 +- .../test/debug/terminal-info.test.ts | 6 +- ...agent-discovery-disabled-providers.test.ts | 8 +- .../test/discovery/agent-fields.test.ts | 2 +- .../test/edit/file-snapshot-store.test.ts | 2 +- .../test/emoji-autocomplete.test.ts | 6 +- .../test/eval/agent-bridge.test.ts | 16 +- .../export/html-template-developer.test.ts | 2 +- .../html-template-script-substitution.test.ts | 2 +- .../custom-commands/ci-green.test.ts | 12 +- .../custom-commands/review.test.ts | 10 +- .../legacy-pi-ai-type-remap.test.ts | 4 +- .../legacy-pi-bunfs-root.test.ts | 2 +- .../legacy-pi-inplace-load.test.ts | 2 +- .../test/extensibility/typebox-remap.test.ts | 7 +- .../test/extensibility/typebox-shim.test.ts | 2 +- .../extension-flag-initial-message.test.ts | 12 +- packages/coding-agent/test/file-lock.test.ts | 2 +- .../coding-agent/test/gallery-cli.test.ts | 10 +- .../test/git-process-config.test.ts | 2 +- .../test/history-storage-search.test.ts | 2 +- .../test/history-storage-session.test.ts | 2 +- .../history-storage-sqlite-compat.test.ts | 2 +- .../coding-agent/test/image-b64poly.test.ts | 2 +- .../test/image-input-normalization.test.ts | 2 +- .../coding-agent/test/initial-message.test.ts | 4 +- .../test/input-controller-keybindings.test.ts | 4 +- .../test/input-controller-suspend.test.ts | 4 +- .../interactive-mode-editor-component.test.ts | 12 +- .../test/interactive-mode-lsp-startup.test.ts | 16 +- .../test/interactive-mode-plan-review.test.ts | 12 +- .../test/interactive-mode-resume-mode.test.ts | 4 +- .../test/interactive-mode-todo-clear.test.ts | 12 +- .../interactive-mode-working-accent.test.ts | 12 +- .../test/internal-urls/mcp-protocol.test.ts | 6 +- .../internal-urls/memory-protocol.test.ts | 2 +- .../test/issue-1401-repro.test.ts | 2 +- .../test/issue-1423-repro.test.ts | 4 +- .../test/issue-1606-repro.test.ts | 6 +- .../test/issue-1940-repro.test.ts | 4 +- .../coding-agent/test/issue-816-repro.test.ts | 10 +- .../coding-agent/test/issue-845-repro.test.ts | 2 +- .../coding-agent/test/issue-905-repro.test.ts | 4 +- .../coding-agent/test/issue-953-repro.test.ts | 6 +- .../coding-agent/test/issue-956-repro.test.ts | 6 +- .../test/keybindings-display.test.ts | 2 +- .../test/keybindings-migration.test.ts | 2 +- .../coding-agent/test/marketplace/cli.test.ts | 2 +- .../test/marketplace/project-scope.test.ts | 10 +- .../marketplace/slash-install-parser.test.ts | 5 +- .../test/mcp-http-transport.test.ts | 6 +- .../test/mcp-manager-oauth-refresh.test.ts | 6 +- .../mcp-manager-subscription-action.test.ts | 2 +- .../test/mcp-reconnect-storm.test.ts | 4 +- .../coding-agent/test/mcp-reconnect.test.ts | 8 +- .../test/mcp-render-status.test.ts | 4 +- .../coding-agent/test/mcp-roots-list.test.ts | 2 +- .../test/mcp-stdio-transport.test.ts | 2 +- packages/coding-agent/test/mcp-test-utils.ts | 2 +- .../coding-agent/test/mcp-timeout.test.ts | 2 +- .../test/mcp-tool-ordering.test.ts | 2 +- .../test/memories/instructions.test.ts | 4 +- .../test/memories/isolation.test.ts | 2 +- .../test/memory-session-storage.test.ts | 2 +- .../test/model-id-affixes.test.ts | 2 +- .../test/model-registry-create.test.ts | 6 +- .../components/session-selector-scope.test.ts | 6 +- .../session-selector-scrollbar.test.ts | 6 +- .../session-selector-status.test.ts | 6 +- .../session-selector-viewport.test.ts | 6 +- .../components/transcript-container.test.ts | 10 +- .../tree-selector-developer.test.ts | 6 +- .../components/tree-selector-overflow.test.ts | 6 +- .../components/user-message-keywords.test.ts | 12 +- .../command-controller-hotkeys.test.ts | 2 +- .../input-controller-tool-expansion.test.ts | 4 +- .../session-selector-delete.test.ts | 6 +- .../test/modes/image-references.test.ts | 2 +- .../modes/internal-url-autocomplete.test.ts | 14 +- .../test/modes/magic-keywords.test.ts | 4 +- .../test/modes/markdown-prose.test.ts | 2 +- .../test/modes/orchestrate.test.ts | 12 +- .../modes/theme/settings-list-theme.test.ts | 2 +- .../test/modes/theme/shimmer.test.ts | 6 +- .../coding-agent/test/modes/workflow.test.ts | 4 +- packages/coding-agent/test/oauth-flow.test.ts | 4 +- .../coding-agent/test/otel-export-probe.ts | 7 +- .../coding-agent/test/plugin-command.test.ts | 2 +- .../test/prompt-action-autocomplete.test.ts | 4 +- .../test/read-tool-group-freeze.test.ts | 8 +- .../coding-agent/test/read-tool-group.test.ts | 11 +- .../repro-issue-1020-ctx-shutdown.test.ts | 6 +- .../role-thinking-helper-propagation.test.ts | 4 +- .../test/sdk-mcp-discovery.test.ts | 2 +- .../test/secrets-obfuscator.test.ts | 4 +- .../coding-agent/test/session-color.test.ts | 2 +- .../coding-agent/test/session-ranking.test.ts | 7 +- .../coding-agent/test/session-storage.test.ts | 2 +- .../test/session/blob-store.test.ts | 4 +- .../test/session/session-status.test.ts | 4 +- .../test/setup-wizard-sign-in.test.ts | 4 +- .../coding-agent/test/setup-wizard.test.ts | 16 +- .../test/silent-abort-overlay-render.test.ts | 10 +- .../test/silent-abort-print-mode.test.ts | 8 +- .../test/slash-command-format.test.ts | 6 +- .../slash-commands/issue-943-type-repro.ts | 2 +- .../test/ssh/connection-manager.test.ts | 2 +- .../test/status-line-cache-hit.test.ts | 6 +- .../test/status-line-context-cache.test.ts | 10 +- .../test/status-line-overflow.test.ts | 14 +- .../test/status-line-path.test.ts | 7 +- .../test/status-line-settings-cache.test.ts | 8 +- .../test/status-text-sanitization.test.ts | 2 +- .../test/streaming-output.test.ts | 2 +- .../test/streaming-preview-height.test.ts | 2 +- .../test/task/autoload-skills.test.ts | 25 +-- .../task/executor-subagent-reminders.test.ts | 36 ++-- .../test/task/executor-wall-clock.test.ts | 18 +- .../test/task/executor-warnings.test.ts | 2 +- .../test/task/subagent-lsp.test.ts | 30 ++-- .../coding-agent/test/task/worktree.test.ts | 4 +- .../test/telemetry-export.test.ts | 2 +- .../coding-agent/test/theme-islight.test.ts | 2 +- .../test/theme-spinner-frames.test.ts | 2 +- .../coding-agent/test/tiny-device.test.ts | 2 +- packages/coding-agent/test/tiny-dtype.test.ts | 2 +- packages/coding-agent/test/tiny-text.test.ts | 2 +- .../test/tiny-title-generator.test.ts | 26 ++- .../coding-agent/test/tiny-worker-env.test.ts | 2 +- .../coding-agent/test/title-generator.test.ts | 2 +- .../test/tool-discovery/initial-tools.test.ts | 6 +- .../test/tool-discovery/persistence.test.ts | 9 +- .../test/tool-discovery/subagent.test.ts | 7 +- .../test/tool-discovery/tool-index.test.ts | 4 +- .../test/tool-execution-args.test.ts | 2 +- .../test/tool-live-region-scrollback.test.ts | 10 +- .../test/tools/bash-command-fixup.test.ts | 2 +- .../test/tools/bash-interceptor.test.ts | 6 +- .../test/tools/bash-skill-urls.test.ts | 8 +- .../test/tools/browser-op-tracking.test.ts | 6 +- .../tools/browser-stealth-targets.test.ts | 4 +- .../tools/browser-tab-worker-startup.test.ts | 4 +- .../coding-agent/test/tools/edit-diff.test.ts | 2 +- .../test/tools/find-renderer.test.ts | 4 +- .../test/tools/find-validate-paths.test.ts | 12 +- .../test/tools/grouped-file-output.test.ts | 8 +- .../test/tools/memory-renderer.test.ts | 8 +- .../test/tools/plan-mode-guard-local.test.ts | 6 +- .../test/tools/read-renderer.test.ts | 4 +- .../coding-agent/test/tools/review.test.ts | 6 +- .../test/tools/search-internal-urls.test.ts | 2 +- .../test/tools/search-path-lists.test.ts | 13 +- .../test/tools/search-renderer.test.ts | 4 +- .../test/tools/search-tool-bm25.test.ts | 8 +- .../coding-agent/test/tools/sqlite.test.ts | 10 +- .../test/tools/strip-output-notice.test.ts | 2 +- .../tools/task-agent-capabilities.test.ts | 12 +- .../test/tools/task-async-fallback.test.ts | 12 +- .../test/tools/task-repair-args.test.ts | 4 +- .../test/tools/task-simple-mode.test.ts | 10 +- .../test/tools/tool-output-hyperlinks.test.ts | 2 +- .../web-scrapers/youtube-parallel.test.ts | 6 +- .../test/tools/web-search-codex.test.ts | 4 +- .../test/tools/web-search-exa.test.ts | 4 +- .../test/tools/web-search-gemini.test.ts | 2 +- .../test/tools/web-search-kagi.test.ts | 6 +- .../test/tools/web-search-parallel.test.ts | 6 +- .../test/tools/web-search-searxng.test.ts | 4 +- .../test/tools/web-search-tavily.test.ts | 4 +- .../test/tools/yield-extraction.test.ts | 4 +- .../transcript-streaming-commit-repro.test.ts | 2 +- .../tui-tree-list-collapsed-lines.test.ts | 2 +- packages/coding-agent/test/update-cli.test.ts | 2 +- .../coding-agent/test/utils/clipboard.test.ts | 2 +- .../test/utils/enhanced-paste.test.ts | 2 +- .../test/utils/image-resize.test.ts | 2 +- packages/coding-agent/test/utils/jj.test.ts | 2 +- packages/coding-agent/test/utils/open.test.ts | 2 +- .../test/web/search/abort-and-timeout.test.ts | 20 +-- .../test/web/search/codex-broker.test.ts | 6 +- .../write-streaming-preview-expand.test.ts | 2 +- packages/mnemopi/package.json | 11 +- packages/mnemopi/test/ab-toggles.test.ts | 2 +- packages/mnemopi/test/annotations.test.ts | 4 +- .../test/beam-consolidate-unit.test.ts | 8 +- packages/mnemopi/test/beam-e3-e4-e6.test.ts | 2 +- packages/mnemopi/test/beam-helpers.test.ts | 2 +- packages/mnemopi/test/beam-index.test.ts | 2 +- packages/mnemopi/test/beam-parity.test.ts | 2 +- .../mnemopi/test/beam-recall-unit.test.ts | 6 +- packages/mnemopi/test/beam-store.test.ts | 8 +- packages/mnemopi/test/binary-vectors.test.ts | 2 +- .../test/c25-deltasync-allowlist.test.ts | 4 +- .../mnemopi/test/cli-errors-parity.test.ts | 2 +- .../mnemopi/test/cli-stats-parity.test.ts | 6 +- packages/mnemopi/test/cli.test.ts | 4 +- .../mnemopi/test/configurable-scoring.test.ts | 4 +- .../test/consolidate-fact-concurrency.test.ts | 4 +- .../consolidate-fact-id-collision.test.ts | 4 +- .../consolidate-fact-sibling-races.test.ts | 4 +- .../mnemopi/test/content-sanitizer.test.ts | 2 +- packages/mnemopi/test/degrade-vector.test.ts | 4 +- packages/mnemopi/test/diagnose.test.ts | 4 +- .../e5a-vector-voice-dense-rewire.test.ts | 4 +- .../test/embeddings-multilingual.test.ts | 2 +- packages/mnemopi/test/entities.test.ts | 2 +- .../test/extraction-integration.test.ts | 8 +- .../mnemopi/test/extraction-wiring.test.ts | 4 +- packages/mnemopi/test/extraction.test.ts | 15 +- packages/mnemopi/test/foundation.test.ts | 4 +- packages/mnemopi/test/graph-tools.test.ts | 4 +- .../test/identity-memory-parity.test.ts | 4 +- .../issue-1832-embedding-population.test.ts | 11 +- packages/mnemopi/test/llm-backends.test.ts | 2 +- packages/mnemopi/test/local-llm.test.ts | 12 +- packages/mnemopi/test/mcp-server.test.ts | 4 +- packages/mnemopi/test/memory-banks.test.ts | 2 +- packages/mnemopi/test/memory-facade.test.ts | 4 +- .../test/migrate-triplestore-split.test.ts | 6 +- .../mnemopi/test/optional-embeddings.test.ts | 8 +- packages/mnemopi/test/orchestrator.test.ts | 8 +- .../test/orphan-vec-episodes-cleanup.test.ts | 2 +- packages/mnemopi/test/patterns.test.ts | 7 +- packages/mnemopi/test/plugins.test.ts | 2 +- .../mnemopi/test/polyphonic-recall.test.ts | 10 +- .../test/pre-experiment-fidelity.test.ts | 2 +- .../mnemopi/test/proactive-linking.test.ts | 4 +- .../test/provider-all-15-tools-parity.test.ts | 2 +- .../test/provider-all-15-tools.test.ts | 2 +- .../mnemopi/test/query-cache-synonyms.test.ts | 4 +- .../mnemopi/test/recall-diagnostics.test.ts | 6 +- .../test/recall-precision-regressions.test.ts | 2 +- packages/mnemopi/test/recovery.test.ts | 2 +- packages/mnemopi/test/setup.ts | 10 +- packages/mnemopi/test/shmr.test.ts | 4 +- packages/mnemopi/test/streaming.test.ts | 4 +- .../test/telemetry-env-followups.test.ts | 2 +- packages/mnemopi/test/temporal-parser.test.ts | 2 +- packages/mnemopi/test/temporal-recall.test.ts | 4 +- packages/mnemopi/test/text-utilities.test.ts | 6 +- .../mnemopi/test/triples-data-dir.test.ts | 2 +- .../mnemopi/test/typed-memory-aaak.test.ts | 4 +- .../test/veracity-consolidation.test.ts | 2 +- .../mnemopi/test/weibull-mmr-intent.test.ts | 11 +- packages/stats/test/behavior-backfill.test.ts | 4 +- packages/stats/test/db-cost.test.ts | 4 +- packages/stats/test/db-range.test.ts | 6 +- .../test/priority-premium-requests.test.ts | 6 +- packages/stats/test/user-metrics.test.ts | 2 +- packages/tui/test/editor.test.ts | 2 +- packages/tui/test/issue-848-repro.test.ts | 2 +- packages/tui/test/markdown.test.ts | 8 +- .../test/process-terminal-render-harness.ts | 2 +- packages/tui/test/render-stress-harness.ts | 8 +- .../tui/test/render-stress-oracles.test.ts | 2 +- packages/tui/test/render-stress-scheduler.ts | 2 +- packages/tui/test/scroll-view.test.ts | 4 +- packages/tui/test/select-list.test.ts | 4 +- packages/tui/test/settings-list.test.ts | 4 +- packages/tui/test/wrap-ansi.test.ts | 2 +- .../typescript-edit-benchmark/package.json | 11 ++ .../test/runner.test.ts | 10 +- .../test/verify.test.ts | 2 +- packages/utils/test/color.test.ts | 2 +- .../utils/test/dirs-python-gateway.test.ts | 4 +- packages/utils/test/env.test.ts | 2 +- packages/utils/test/fetch-retry.test.ts | 2 +- packages/utils/test/format.test.ts | 2 +- packages/utils/test/install-id.test.ts | 10 +- packages/utils/test/issue-935-repro.test.ts | 2 +- .../test/logger-error-serialization.test.ts | 2 +- packages/utils/test/peek-file.test.ts | 2 +- packages/utils/test/ring.test.ts | 2 +- packages/utils/test/sanitize-text.test.ts | 2 +- packages/utils/test/spacing.test.ts | 6 +- packages/utils/test/stream.test.ts | 4 +- scripts/fix-test-imports.ts | 168 ++++++++++++++++++ 458 files changed, 1531 insertions(+), 1181 deletions(-) rename packages/coding-agent/src/modes/components/{status-line.ts => status-line/component.ts} (94%) create mode 100644 scripts/fix-test-imports.ts diff --git a/crates/pi-natives/src/ast.rs b/crates/pi-natives/src/ast.rs index 541b4ab32..ccde8daa1 100644 --- a/crates/pi-natives/src/ast.rs +++ b/crates/pi-natives/src/ast.rs @@ -819,10 +819,15 @@ pub fn ast_match(options: AstMatchOptions<'_>) -> task::Promise .then(left.byte_end.cmp(&right.byte_end)) }); - let visible_matches = - all_matches.into_iter().skip(normalized_offset as usize).collect::>(); + let visible_matches = all_matches + .into_iter() + .skip(normalized_offset as usize) + .collect::>(); let limit_reached = visible_matches.len() > normalized_limit as usize; - let matches = visible_matches.into_iter().take(normalized_limit as usize).collect::>(); + let matches = visible_matches + .into_iter() + .take(normalized_limit as usize) + .collect::>(); Ok(AstMatchResult { matches, diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 15117353d..111ad77dd 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -2290,8 +2290,7 @@ mod tests { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() - .map(|status| status.success()) - .unwrap_or(false); + .is_ok_and(|status| status.success()); if !python_ok { eprintln!("skipping nohup_builtin_does_not_mask_sighup: python3 unavailable"); return; diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 945fe4cac..b46988663 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context"; +import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { Message, Tool } from "@oh-my-pi/pi-ai"; -import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "../src/append-only-context"; -import type { AgentContext, AgentTool } from "../src/types"; // --------------------------------------------------------------------------- // Helpers diff --git a/packages/agent/test/harmony-leak.test.ts b/packages/agent/test/harmony-leak.test.ts index bd3d785c6..9588ec570 100644 --- a/packages/agent/test/harmony-leak.test.ts +++ b/packages/agent/test/harmony-leak.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from "bun:test"; -import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; import { createHarmonyAuditEvent, detectHarmonyLeak, @@ -9,7 +7,9 @@ import { isHarmonyLeakMitigationTarget, recoverHarmonyToolCall, signalListLabel, -} from "../src/harmony-leak"; +} from "@oh-my-pi/pi-agent-core/harmony-leak"; +import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import corpus from "./fixtures/harmony-leak-corpus.json" with { type: "json" }; import { createAssistantMessage } from "./helpers"; diff --git a/packages/agent/test/yield.test.ts b/packages/agent/test/yield.test.ts index f5d89ad4b..abb6142d0 100644 --- a/packages/agent/test/yield.test.ts +++ b/packages/agent/test/yield.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; -import { ExponentialYield, yieldIfDue } from "../src/utils/yield"; +import { ExponentialYield, yieldIfDue } from "@oh-my-pi/pi-agent-core/utils/yield"; const YIELD_INTERVAL_MS = 50; const YIELD_CLOCK_STEP_MS = 60_000; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2dc3b0223..42db66086 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,11 +2,15 @@ ## [Unreleased] +### Breaking Changes + +- Renamed the OAuth subpath export `@oh-my-pi/pi-ai/utils/oauth` → `@oh-my-pi/pi-ai/oauth` (and `@oh-my-pi/pi-ai/utils/oauth/*` → `@oh-my-pi/pi-ai/oauth/*`, e.g. `oauth/types`, `oauth/callback-server`, `oauth/openai-codex`) after relocating the OAuth implementation out of `utils/oauth/` into `registry/oauth/`. The high-level OAuth API (`getOAuthProviders`, `refreshOAuthToken`, `getOAuthApiKey`, `registerOAuthProvider`, `unregisterOAuthProviders`, `getOAuthProvider`) and the `OAuth*` types stay exported from the package root, unchanged. + ### Changed - Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429 - Optimized the Anthropic `cch` attestation patch to locate the billing-header placeholder with native `Buffer.indexOf` (memmem) instead of a hand-rolled byte loop. The marker sits ~99% through the body (`messages` serializes before `system`), so the old scan walked almost the entire payload; output bytes are unchanged but the patch is ~7.5x faster (563µs -> 75µs on a 1MB body). -- Refactored provider configuration to a single-source registry (`provider-registry/`). The `KnownProvider`/`OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, `DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the `/login` provider list (`builtInOAuthProviders`), and the `refreshOAuthToken`/`AuthStorage.login` dispatch are all derived from it. Thin provider-specific login flows are now inlined into the provider def file; heavier provider-local oauth flows live adjacent under `provider-registry/providers/oauth/`; only shared oauth infra / streaming helpers remain in `utils/oauth/`. Adding a provider that reuses an existing wire API is now one new provider def plus one registry entry in the common case. Exposes `PROVIDER_REGISTRY`, `getProviderDefinition`, `ProviderDefinition`, and `PASTE_CODE_LOGIN_PROVIDERS`. +- Refactored provider configuration to a single-source registry (`registry/`, renamed from `provider-registry/` with its `providers/` subdir flattened up). The `KnownProvider`/`OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, `DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the `/login` provider list (`builtInOAuthProviders`), and the `refreshOAuthToken`/`AuthStorage.login` dispatch are all derived from it. Provider defs live directly under `registry/`; thin provider-specific login flows are inlined into the def file, while heavier provider-local OAuth flows and the shared OAuth flow infra (`callback-server`, `pkce`, `google-oauth-shared`, `types`, runtime `index`) now live together under `registry/oauth/` (previously split across `provider-registry/providers/oauth/` and `utils/oauth/`). The non-OAuth API-key paste/validation helpers (`api-key-login`, `api-key-validation`) sit beside the defs in `registry/`. Adding a provider that reuses an existing wire API is now one new provider def plus one registry entry in the common case. Exposes `PROVIDER_REGISTRY`, `getProviderDefinition`, `ProviderDefinition`, and `PASTE_CODE_LOGIN_PROVIDERS`. ### Fixed diff --git a/packages/ai/test/abort-source-tracker.test.ts b/packages/ai/test/abort-source-tracker.test.ts index 6500795cd..d9b0b9def 100644 --- a/packages/ai/test/abort-source-tracker.test.ts +++ b/packages/ai/test/abort-source-tracker.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { createAbortSourceTracker } from "../src/utils/abort"; +import { createAbortSourceTracker } from "@oh-my-pi/pi-ai/utils/abort"; /** * Defends the contract `AssistantMessageEventStream` providers depend on: caller diff --git a/packages/ai/test/abortable-iterator.test.ts b/packages/ai/test/abortable-iterator.test.ts index ed95a9879..4afbeb3b5 100644 --- a/packages/ai/test/abortable-iterator.test.ts +++ b/packages/ai/test/abortable-iterator.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { iterateUntilAbort } from "../src/utils/abortable-iterator"; +import { iterateUntilAbort } from "@oh-my-pi/pi-ai/utils/abortable-iterator"; function makeSource(handlers: { next: () => Promise>; onReturn?: () => void }): AsyncIterable { return { diff --git a/packages/ai/test/anthropic-client.test.ts b/packages/ai/test/anthropic-client.test.ts index 64eb086f3..259b13b8a 100644 --- a/packages/ai/test/anthropic-client.test.ts +++ b/packages/ai/test/anthropic-client.test.ts @@ -3,8 +3,8 @@ import { AnthropicApiError, AnthropicConnectionTimeoutError, AnthropicMessagesClient, -} from "../src/providers/anthropic-client"; -import type { MessageCreateParamsStreaming } from "../src/providers/anthropic-wire"; +} from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { MessageCreateParamsStreaming } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; const params: MessageCreateParamsStreaming = { model: "claude-sonnet-4-5", diff --git a/packages/ai/test/anthropic-many-image-resize.test.ts b/packages/ai/test/anthropic-many-image-resize.test.ts index 91b13effd..493f34464 100644 --- a/packages/ai/test/anthropic-many-image-resize.test.ts +++ b/packages/ai/test/anthropic-many-image-resize.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { AssistantMessage, Context, ImageContent, Model, TextContent, Usage } from "../src/types"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { AssistantMessage, Context, ImageContent, Model, TextContent, Usage } from "@oh-my-pi/pi-ai/types"; const RED_1X1_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"; diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index c4a29cd5a..6b6e91c50 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { AnthropicOAuthFlow, refreshAnthropicToken } from "../src/registry/oauth/anthropic"; -import { buildAnthropicAuthConfig, buildAnthropicSearchHeaders, buildAnthropicUrl } from "../src/utils/anthropic-auth"; +import { AnthropicOAuthFlow, refreshAnthropicToken } from "@oh-my-pi/pi-ai/registry/oauth/anthropic"; +import { + buildAnthropicAuthConfig, + buildAnthropicSearchHeaders, + buildAnthropicUrl, +} from "@oh-my-pi/pi-ai/utils/anthropic-auth"; import { withEnv } from "./helpers"; const originalFetch = global.fetch; diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 01f45758b..006e96839 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; -import { streamAnthropic } from "../src/providers/anthropic"; -import { AnthropicMessages } from "../src/providers/anthropic-client"; -import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { AnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; const model: Model<"anthropic-messages"> = { id: "claude-sonnet-4-5", diff --git a/packages/ai/test/anthropic-stream-timeout.test.ts b/packages/ai/test/anthropic-stream-timeout.test.ts index 4b001ffee..422a94235 100644 --- a/packages/ai/test/anthropic-stream-timeout.test.ts +++ b/packages/ai/test/anthropic-stream-timeout.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client"; -import type { Context, Model } from "../src/types"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { AnthropicMessagesClientLike } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { waitForDelayOrAbort } from "./helpers"; const originalFetch = global.fetch; diff --git a/packages/ai/test/api-registry.test.ts b/packages/ai/test/api-registry.test.ts index f62840076..5491de1a4 100644 --- a/packages/ai/test/api-registry.test.ts +++ b/packages/ai/test/api-registry.test.ts @@ -5,8 +5,8 @@ import { getCustomApi, registerCustomApi, unregisterCustomApis, -} from "../src/api-registry"; -import type { AssistantMessageEventStream } from "../src/types"; +} from "@oh-my-pi/pi-ai/api-registry"; +import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/types"; afterEach(() => { clearCustomApis(); diff --git a/packages/ai/test/auth-broker-refresher.test.ts b/packages/ai/test/auth-broker-refresher.test.ts index 30c5b2652..82787da9b 100644 --- a/packages/ai/test/auth-broker-refresher.test.ts +++ b/packages/ai/test/auth-broker-refresher.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "../src"; -import * as oauthUtils from "../src/registry/oauth"; +import { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-broker-remote-store.test.ts b/packages/ai/test/auth-broker-remote-store.test.ts index 50be18938..cf553eed4 100644 --- a/packages/ai/test/auth-broker-remote-store.test.ts +++ b/packages/ai/test/auth-broker-remote-store.test.ts @@ -11,7 +11,7 @@ import { type SnapshotResponse, SqliteAuthCredentialStore, startAuthBroker, -} from "../src"; +} from "@oh-my-pi/pi-ai"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-broker-snapshot-cache.test.ts b/packages/ai/test/auth-broker-snapshot-cache.test.ts index 9480472c6..51532b4d8 100644 --- a/packages/ai/test/auth-broker-snapshot-cache.test.ts +++ b/packages/ai/test/auth-broker-snapshot-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readAuthBrokerSnapshotCache, type SnapshotResponse, writeAuthBrokerSnapshotCache } from "../src"; +import { readAuthBrokerSnapshotCache, type SnapshotResponse, writeAuthBrokerSnapshotCache } from "@oh-my-pi/pi-ai"; const TOKEN = "broker-cache-token"; const URL = "http://127.0.0.1:8765"; diff --git a/packages/ai/test/auth-broker-wire.test.ts b/packages/ai/test/auth-broker-wire.test.ts index 5e39be6da..60fb2f349 100644 --- a/packages/ai/test/auth-broker-wire.test.ts +++ b/packages/ai/test/auth-broker-wire.test.ts @@ -11,8 +11,8 @@ import { type SnapshotStreamEvent, SqliteAuthCredentialStore, startAuthBroker, -} from "../src"; -import * as oauthUtils from "../src/registry/oauth"; +} from "@oh-my-pi/pi-ai"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-gateway-anthropic-messages.test.ts b/packages/ai/test/auth-gateway-anthropic-messages.test.ts index 98679149f..94ca9e06c 100644 --- a/packages/ai/test/auth-gateway-anthropic-messages.test.ts +++ b/packages/ai/test/auth-gateway-anthropic-messages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { encodeResponse, encodeStream, parseRequest } from "../src/providers/anthropic-messages-server"; -import type { AssistantMessage, AssistantMessageEvent, ToolResultMessage } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; +import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/anthropic-messages-server"; +import type { AssistantMessage, AssistantMessageEvent, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function emptyUsage(): AssistantMessage["usage"] { return { diff --git a/packages/ai/test/auth-gateway-cache-key.test.ts b/packages/ai/test/auth-gateway-cache-key.test.ts index 4f6a55bca..9d98b28cc 100644 --- a/packages/ai/test/auth-gateway-cache-key.test.ts +++ b/packages/ai/test/auth-gateway-cache-key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { resolvePromptCacheKey } from "../src/auth-gateway/http"; +import { resolvePromptCacheKey } from "@oh-my-pi/pi-ai/auth-gateway/http"; describe("resolvePromptCacheKey", () => { it("prefers body.prompt_cache_key over everything else", () => { diff --git a/packages/ai/test/auth-gateway-classify-error.test.ts b/packages/ai/test/auth-gateway-classify-error.test.ts index d780b266f..e55fdf602 100644 --- a/packages/ai/test/auth-gateway-classify-error.test.ts +++ b/packages/ai/test/auth-gateway-classify-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { classifyGatewayError } from "../src/auth-gateway/server"; +import { classifyGatewayError } from "@oh-my-pi/pi-ai/auth-gateway/server"; describe("auth-gateway classifyGatewayError", () => { it("honours an explicit numeric `status` property on the error", () => { diff --git a/packages/ai/test/auth-gateway-openai-chat.test.ts b/packages/ai/test/auth-gateway-openai-chat.test.ts index 4db5db89f..9ce5a92be 100644 --- a/packages/ai/test/auth-gateway-openai-chat.test.ts +++ b/packages/ai/test/auth-gateway-openai-chat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { encodeResponse, encodeStream, parseRequest } from "../src/providers/openai-chat-server"; -import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream } from "../src/types"; +import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/openai-chat-server"; +import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream } from "@oh-my-pi/pi-ai/types"; function makeEventStream(events: AssistantMessageEvent[], final: AssistantMessage): AssistantMessageEventStream { async function* iter() { diff --git a/packages/ai/test/auth-gateway-openai-responses.test.ts b/packages/ai/test/auth-gateway-openai-responses.test.ts index 091928abf..d002caf86 100644 --- a/packages/ai/test/auth-gateway-openai-responses.test.ts +++ b/packages/ai/test/auth-gateway-openai-responses.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { encodeResponse, encodeStream, parseRequest } from "../src/providers/openai-responses-server"; -import type { AssistantMessage } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/openai-responses-server"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function zeroUsage(): AssistantMessage["usage"] { return { diff --git a/packages/ai/test/auth-gateway-pi-native.test.ts b/packages/ai/test/auth-gateway-pi-native.test.ts index 5f3a77a76..38e2d9ab9 100644 --- a/packages/ai/test/auth-gateway-pi-native.test.ts +++ b/packages/ai/test/auth-gateway-pi-native.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { encodeStream, formatError, parseRequest } from "../src/providers/pi-native-server"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { encodeStream, formatError, parseRequest } from "@oh-my-pi/pi-ai/providers/pi-native-server"; import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Usage, -} from "../src/types"; +} from "@oh-my-pi/pi-ai/types"; function makeEventStream(events: AssistantMessageEvent[], final: AssistantMessage): AssistantMessageEventStream { async function* iter() { diff --git a/packages/ai/test/auth-storage-api-key-login.test.ts b/packages/ai/test/auth-storage-api-key-login.test.ts index 4ad5b714c..7023aad92 100644 --- a/packages/ai/test/auth-storage-api-key-login.test.ts +++ b/packages/ai/test/auth-storage-api-key-login.test.ts @@ -4,10 +4,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import * as deepseekModule from "../src/registry/deepseek"; -import * as kagiModule from "../src/registry/kagi"; -import * as ollamaCloudModule from "../src/registry/ollama-cloud"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import * as deepseekModule from "@oh-my-pi/pi-ai/registry/deepseek"; +import * as kagiModule from "@oh-my-pi/pi-ai/registry/kagi"; +import * as ollamaCloudModule from "@oh-my-pi/pi-ai/registry/ollama-cloud"; function countCredentialRows(dbPath: string, provider: string): number { const db = new Database(dbPath, { readonly: true }); diff --git a/packages/ai/test/auth-storage-broker-no-sentinel.test.ts b/packages/ai/test/auth-storage-broker-no-sentinel.test.ts index 5a0742eda..b26a1a2f4 100644 --- a/packages/ai/test/auth-storage-broker-no-sentinel.test.ts +++ b/packages/ai/test/auth-storage-broker-no-sentinel.test.ts @@ -7,8 +7,8 @@ import { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore, -} from "../src/auth-storage"; -import * as oauthUtils from "../src/registry/oauth"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; describe("AuthStorage broker sentinel refresh", () => { let tempDir = ""; diff --git a/packages/ai/test/auth-storage-check-credentials.test.ts b/packages/ai/test/auth-storage-check-credentials.test.ts index e2f408669..3ad0ae274 100644 --- a/packages/ai/test/auth-storage-check-credentials.test.ts +++ b/packages/ai/test/auth-storage-check-credentials.test.ts @@ -29,8 +29,8 @@ import { type CompletionProbeInput, REMOTE_REFRESH_SENTINEL, type StoredAuthCredential, -} from "../src/auth-storage"; -import * as claudeUsage from "../src/usage/claude"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude"; function oauthRow(id: number, email: string, opts?: { expired?: boolean }): StoredAuthCredential { const credential: AuthCredential = { diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index bf88b27a3..e98fd6443 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import * as oauthUtils from "../src/registry/oauth"; -import type { OAuthCredentials } from "../src/registry/oauth/types"; -import type { UsageLimit, UsageProvider, UsageReport } from "../src/usage"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types"; +import type { UsageLimit, UsageProvider, UsageReport } from "@oh-my-pi/pi-ai/usage"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; diff --git a/packages/ai/test/auth-storage-config-override.test.ts b/packages/ai/test/auth-storage-config-override.test.ts index 16e140255..ad7f1a15d 100644 --- a/packages/ai/test/auth-storage-config-override.test.ts +++ b/packages/ai/test/auth-storage-config-override.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; import { withEnv } from "./helpers"; const SUPPRESS_ANTHROPIC_ENV = { diff --git a/packages/ai/test/auth-storage-credential-disabled-event.test.ts b/packages/ai/test/auth-storage-credential-disabled-event.test.ts index f4b6dabf5..6574ad57e 100644 --- a/packages/ai/test/auth-storage-credential-disabled-event.test.ts +++ b/packages/ai/test/auth-storage-credential-disabled-event.test.ts @@ -5,8 +5,8 @@ import { AuthStorage, type CredentialDisabledEvent, type StoredAuthCredential, -} from "../src/auth-storage"; -import * as oauthUtils from "../src/registry/oauth"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; // Env vars short-circuit AuthStorage.getApiKey before the OAuth refresh path runs; suppress // them for every test in this file so the credential-disable code path can be exercised. diff --git a/packages/ai/test/auth-storage-credential-origin.test.ts b/packages/ai/test/auth-storage-credential-origin.test.ts index cbb50e26d..546e8e9d4 100644 --- a/packages/ai/test/auth-storage-credential-origin.test.ts +++ b/packages/ai/test/auth-storage-credential-origin.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; import { withEnv } from "./helpers"; // Clear every env var the providers under test alias, so ambient shell / ~/.env diff --git a/packages/ai/test/auth-storage-email-dedupe.test.ts b/packages/ai/test/auth-storage-email-dedupe.test.ts index e0ae13523..268a55912 100644 --- a/packages/ai/test/auth-storage-email-dedupe.test.ts +++ b/packages/ai/test/auth-storage-email-dedupe.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AuthStorage, type OAuthCredential, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { AuthStorage, type OAuthCredential, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; const LEGACY_TIMESTAMP = 1_700_000_000; diff --git a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts index ed3389fb4..1ae31a67b 100644 --- a/packages/ai/test/auth-storage-force-refresh-rotate.test.ts +++ b/packages/ai/test/auth-storage-force-refresh-rotate.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { registerOAuthProvider, unregisterOAuthProviders } from "../src/registry/oauth"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; const PROVIDER = "unit-rotate-oauth"; const SOURCE = "auth-storage-force-refresh-rotate-test"; diff --git a/packages/ai/test/auth-storage-oauth-refresh-race.test.ts b/packages/ai/test/auth-storage-oauth-refresh-race.test.ts index c2bbf9ded..637d35bbe 100644 --- a/packages/ai/test/auth-storage-oauth-refresh-race.test.ts +++ b/packages/ai/test/auth-storage-oauth-refresh-race.test.ts @@ -7,8 +7,8 @@ import { AuthStorage, type CredentialDisabledEvent, SqliteAuthCredentialStore, -} from "../src/auth-storage"; -import * as oauthUtils from "../src/registry/oauth"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; import { withEnv } from "./helpers"; const SUPPRESS_ANTHROPIC_ENV = { diff --git a/packages/ai/test/auth-storage-refresh-skew.test.ts b/packages/ai/test/auth-storage-refresh-skew.test.ts index 3ff8fc04b..9e5e37e66 100644 --- a/packages/ai/test/auth-storage-refresh-skew.test.ts +++ b/packages/ai/test/auth-storage-refresh-skew.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { registerOAuthProvider, unregisterOAuthProviders } from "../src/registry/oauth"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; describe("AuthStorage OAuth refresh skew", () => { let tempDir = ""; diff --git a/packages/ai/test/auth-storage-usage-cache.test.ts b/packages/ai/test/auth-storage-usage-cache.test.ts index fcb2d87a6..219d49f33 100644 --- a/packages/ai/test/auth-storage-usage-cache.test.ts +++ b/packages/ai/test/auth-storage-usage-cache.test.ts @@ -16,9 +16,9 @@ import { type AuthCredentialStore, AuthStorage, type StoredAuthCredential, -} from "../src/auth-storage"; -import type { UsageLimit, UsageReport } from "../src/usage"; -import * as claudeUsage from "../src/usage/claude"; +} from "@oh-my-pi/pi-ai/auth-storage"; +import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage"; +import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude"; function anthropicReports(reports: UsageReport[] | null): UsageReport[] { return (reports ?? []).filter(r => r.provider === "anthropic"); diff --git a/packages/ai/test/aws-credentials.test.ts b/packages/ai/test/aws-credentials.test.ts index 851c4bc58..5fd483868 100644 --- a/packages/ai/test/aws-credentials.test.ts +++ b/packages/ai/test/aws-credentials.test.ts @@ -6,7 +6,7 @@ import { clearAwsCredentialCache, resolveAwsCredentials, tokenizeCredentialProcessCommand, -} from "../src/providers/aws-credentials"; +} from "@oh-my-pi/pi-ai/providers/aws-credentials"; // `credential_process` integration coverage. Drives a real `Bun.spawn` // against a fixture script so the JSON envelope contract, exit-code diff --git a/packages/ai/test/aws-eventstream.test.ts b/packages/ai/test/aws-eventstream.test.ts index 347660ca1..b75cb1cb2 100644 --- a/packages/ai/test/aws-eventstream.test.ts +++ b/packages/ai/test/aws-eventstream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { crc32, decodeEventStream, decodeMessage } from "../src/providers/aws-eventstream"; +import { crc32, decodeEventStream, decodeMessage } from "@oh-my-pi/pi-ai/providers/aws-eventstream"; // ---- Frame builder (mirrors @smithy/eventstream-codec but in-process so the // test owns the bytes). The decoder is the production code; we encode here for diff --git a/packages/ai/test/aws-sigv4.test.ts b/packages/ai/test/aws-sigv4.test.ts index bcb661f19..2b4d70d1d 100644 --- a/packages/ai/test/aws-sigv4.test.ts +++ b/packages/ai/test/aws-sigv4.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { formatAmzDate, getSigningKey, signRequest, toHex } from "../src/providers/aws-sigv4"; +import { formatAmzDate, getSigningKey, signRequest, toHex } from "@oh-my-pi/pi-ai/providers/aws-sigv4"; // Canonical AWS SigV4 test vectors. Sourced from the // `aws-sig-v4-test-suite` published with the SigV4 spec. diff --git a/packages/ai/test/azure-openai-responses-stream.test.ts b/packages/ai/test/azure-openai-responses-stream.test.ts index 416cbb044..ca44715f9 100644 --- a/packages/ai/test/azure-openai-responses-stream.test.ts +++ b/packages/ai/test/azure-openai-responses-stream.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { type AzureOpenAIResponsesOptions, streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; -import type { Context, Model, Tool } from "../src/types"; +import { + type AzureOpenAIResponsesOptions, + streamAzureOpenAIResponses, +} from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/callback-server-manual-input.test.ts b/packages/ai/test/callback-server-manual-input.test.ts index 1ad15a2e6..04e76fee9 100644 --- a/packages/ai/test/callback-server-manual-input.test.ts +++ b/packages/ai/test/callback-server-manual-input.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { OAuthCallbackFlow } from "../src/registry/oauth/callback-server"; -import type { OAuthCredentials } from "../src/registry/oauth/types"; +import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/registry/oauth/callback-server"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types"; class TestCallbackFlow extends OAuthCallbackFlow { async generateAuthUrl(_state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { diff --git a/packages/ai/test/claude-ratelimit-headers.test.ts b/packages/ai/test/claude-ratelimit-headers.test.ts index e9d2188dc..4b31abb53 100644 --- a/packages/ai/test/claude-ratelimit-headers.test.ts +++ b/packages/ai/test/claude-ratelimit-headers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { UsageLimit, UsageReport } from "../src/usage"; -import { parseClaudeRateLimitHeaders } from "../src/usage/claude"; +import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage"; +import { parseClaudeRateLimitHeaders } from "@oh-my-pi/pi-ai/usage/claude"; const NOW = 1_780_400_000_000; diff --git a/packages/ai/test/claude-usage-headers.test.ts b/packages/ai/test/claude-usage-headers.test.ts index a20dc6a1f..3aab042ad 100644 --- a/packages/ai/test/claude-usage-headers.test.ts +++ b/packages/ai/test/claude-usage-headers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { UsageFetchContext } from "../src/usage"; -import { claudeUsageProvider } from "../src/usage/claude"; +import type { UsageFetchContext } from "@oh-my-pi/pi-ai/usage"; +import { claudeUsageProvider } from "@oh-my-pi/pi-ai/usage/claude"; function getHeaderCaseInsensitive( headers: Headers | Record> | string[][] | undefined, diff --git a/packages/ai/test/claude-usage-retry.test.ts b/packages/ai/test/claude-usage-retry.test.ts index 91c11141f..52377a412 100644 --- a/packages/ai/test/claude-usage-retry.test.ts +++ b/packages/ai/test/claude-usage-retry.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import type { UsageFetchContext } from "../src/usage"; -import { claudeUsageProvider } from "../src/usage/claude"; +import type { UsageFetchContext } from "@oh-my-pi/pi-ai/usage"; +import { claudeUsageProvider } from "@oh-my-pi/pi-ai/usage/claude"; const VALID_PAYLOAD = { five_hour: { utilization: 42, resets_at: new Date(Date.now() + 5 * 60_000).toISOString() }, diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index b0ec0af0b..a11c9563c 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -4,9 +4,9 @@ import { buildCursorSystemPromptJsons, resolveExecHandler, streamCursor, -} from "../src/providers/cursor"; -import type { AgentRunRequest } from "../src/providers/cursor/gen/agent_pb"; -import type { Context, Model } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/cursor"; +import type { AgentRunRequest } from "@oh-my-pi/pi-ai/providers/cursor/gen/agent_pb"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const cursorModel: Model<"cursor-agent"> = { id: "cursor-composer-2.5", diff --git a/packages/ai/test/deepseek-reasoning-content.test.ts b/packages/ai/test/deepseek-reasoning-content.test.ts index 180885e4d..b8aebf8cf 100644 --- a/packages/ai/test/deepseek-reasoning-content.test.ts +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { convertMessages, detectCompat } from "../src/providers/openai-completions"; -import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "@oh-my-pi/pi-ai/types"; function deepseekModel(overrides: Partial>): Model<"openai-completions"> { return { diff --git a/packages/ai/test/event-stream.test.ts b/packages/ai/test/event-stream.test.ts index ff701ff44..0d9c97a8b 100644 --- a/packages/ai/test/event-stream.test.ts +++ b/packages/ai/test/event-stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { AssistantMessage } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function createPartial(text = ""): AssistantMessage { return { diff --git a/packages/ai/test/firepass.live.ts b/packages/ai/test/firepass.live.ts index 058492ba1..2a678ef70 100644 --- a/packages/ai/test/firepass.live.ts +++ b/packages/ai/test/firepass.live.ts @@ -8,9 +8,9 @@ * 2. The PR #1199 P2 fix (xhigh → max) actually clears the wire — without * the mapping Fireworks 400s the request. */ -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const apiKey = process.env.FIREPASS_API_KEY; if (!apiKey) { diff --git a/packages/ai/test/firepass.test.ts b/packages/ai/test/firepass.test.ts index fdfa5a2c7..b6b8a9e72 100644 --- a/packages/ai/test/firepass.test.ts +++ b/packages/ai/test/firepass.test.ts @@ -7,9 +7,9 @@ * form at request time. */ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index bdb37ca7d..b1918e458 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { buildAnthropicClientOptions, streamAnthropic } from "../src/providers/anthropic"; -import { OPENCODE_HEADERS } from "../src/registry/oauth/github-copilot"; -import type { Context, Model } from "../src/types"; -import { buildAnthropicUrl } from "../src/utils/anthropic-auth"; +import { buildAnthropicClientOptions, streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { OPENCODE_HEADERS } from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildAnthropicUrl } from "@oh-my-pi/pi-ai/utils/anthropic-auth"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-error.test.ts b/packages/ai/test/github-copilot-error.test.ts index fe451f7cb..e7fce6edb 100644 --- a/packages/ai/test/github-copilot-error.test.ts +++ b/packages/ai/test/github-copilot-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { rewriteCopilotError } from "../src/utils/http-inspector"; +import { rewriteCopilotError } from "@oh-my-pi/pi-ai/utils/http-inspector"; function errorWithStatus(status: number): Error { const err = new Error(`${status} Unauthorized`); diff --git a/packages/ai/test/github-copilot-headers.test.ts b/packages/ai/test/github-copilot-headers.test.ts index d0257e3b1..f293a50f6 100644 --- a/packages/ai/test/github-copilot-headers.test.ts +++ b/packages/ai/test/github-copilot-headers.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { buildCopilotDynamicHeaders, getCopilotInitiatorOverride, getCopilotPremiumMultiplier, hasCopilotVisionInput, inferCopilotInitiator, -} from "../src/providers/github-copilot-headers"; -import type { Message } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/github-copilot-headers"; +import type { Message } from "@oh-my-pi/pi-ai/types"; describe("inferCopilotInitiator", () => { it("returns 'user' when there are no messages", () => { diff --git a/packages/ai/test/github-copilot-login.test.ts b/packages/ai/test/github-copilot-login.test.ts index 2f80b17d7..5f2ca2faa 100644 --- a/packages/ai/test/github-copilot-login.test.ts +++ b/packages/ai/test/github-copilot-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginGitHubCopilot } from "../src/registry/oauth/github-copilot"; +import { loginGitHubCopilot } from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; const originalFetch = global.fetch; const FAST_POLL_OPTIONS = { pollIntervalFloorMs: 0, pollIntervalScaleMs: 1 } as const; diff --git a/packages/ai/test/github-copilot-model-limits.test.ts b/packages/ai/test/github-copilot-model-limits.test.ts index 07d76ee87..20e6248e5 100644 --- a/packages/ai/test/github-copilot-model-limits.test.ts +++ b/packages/ai/test/github-copilot-model-limits.test.ts @@ -2,10 +2,10 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { Effort } from "../src/effort"; -import { createModelManager } from "../src/model-manager"; -import { getBundledModel } from "../src/models"; -import { githubCopilotModelManagerOptions } from "../src/provider-models/openai-compat"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { createModelManager } from "@oh-my-pi/pi-ai/model-manager"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { githubCopilotModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index ca3b43cc8..36a44ee84 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -3,7 +3,7 @@ import { getGitHubCopilotBaseUrl, normalizeGitHubCopilotEnterpriseDomain, parseGitHubCopilotApiKey, -} from "../src/registry/oauth/github-copilot"; +} from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; describe("GitHub Copilot OAuth helpers", () => { it("treats github.com as the public Copilot host", () => { diff --git a/packages/ai/test/github-copilot-openai-base-url.test.ts b/packages/ai/test/github-copilot-openai-base-url.test.ts index 65d9f6520..930e33188 100644 --- a/packages/ai/test/github-copilot-openai-base-url.test.ts +++ b/packages/ai/test/github-copilot-openai-base-url.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/github-copilot-reasoning.test.ts b/packages/ai/test/github-copilot-reasoning.test.ts index 28746c85d..c8b5adac8 100644 --- a/packages/ai/test/github-copilot-reasoning.test.ts +++ b/packages/ai/test/github-copilot-reasoning.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { getBundledModel } from "../src/models"; -import { streamAnthropic } from "../src/providers/anthropic"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const testContext: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], diff --git a/packages/ai/test/google-antigravity-usage.test.ts b/packages/ai/test/google-antigravity-usage.test.ts index 4fdec1ae0..fac79ba27 100644 --- a/packages/ai/test/google-antigravity-usage.test.ts +++ b/packages/ai/test/google-antigravity-usage.test.ts @@ -5,8 +5,8 @@ * different model entries, and handles mixed-case tier names. */ import { describe, expect, it } from "bun:test"; -import type { UsageFetchContext, UsageFetchParams } from "../src/usage"; -import { antigravityUsageProvider } from "../src/usage/google-antigravity"; +import type { UsageFetchContext, UsageFetchParams } from "@oh-my-pi/pi-ai/usage"; +import { antigravityUsageProvider } from "@oh-my-pi/pi-ai/usage/google-antigravity"; const accessTokenFixture = (() => { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); diff --git a/packages/ai/test/google-gemini-cli-3x-thinking.test.ts b/packages/ai/test/google-gemini-cli-3x-thinking.test.ts index 9e1caf756..117e3aba5 100644 --- a/packages/ai/test/google-gemini-cli-3x-thinking.test.ts +++ b/packages/ai/test/google-gemini-cli-3x-thinking.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { Effort } from "@oh-my-pi/pi-ai"; import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { streamSimple } from "../src/stream"; -import type { Context, Model } from "../src/types"; interface GeminiCliThinkingConfig { thinkingLevel?: string; diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index e27572491..a2367454c 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -1,15 +1,15 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { hookFetch } from "@oh-my-pi/pi-utils"; -import * as geminiCliProvider from "../src/providers/google-gemini-cli"; +import * as geminiCliProvider from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; import { ANTIGRAVITY_SYSTEM_INSTRUCTION, buildRequest, parseGeminiCliCredentials, shouldRefreshGeminiCliCredentials, streamGoogleGeminiCli, -} from "../src/providers/google-gemini-cli"; -import { getOAuthApiKey } from "../src/registry/oauth"; -import type { Context, Model, TJsonSchema } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; +import { getOAuthApiKey } from "@oh-my-pi/pi-ai/registry/oauth"; +import type { Context, Model, TJsonSchema } from "@oh-my-pi/pi-ai/types"; +import { hookFetch } from "@oh-my-pi/pi-utils"; function createModel(provider: "google-gemini-cli" | "google-antigravity"): Model<"google-gemini-cli"> { return { diff --git a/packages/ai/test/google-tool-choice.test.ts b/packages/ai/test/google-tool-choice.test.ts index f8fd5e09a..aeac29875 100644 --- a/packages/ai/test/google-tool-choice.test.ts +++ b/packages/ai/test/google-tool-choice.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { buildGoogleGenerateContentParams } from "@oh-my-pi/pi-ai/providers/google-shared"; +import { mapGoogleToolChoice } from "@oh-my-pi/pi-ai/stream"; import type { Context, Tool, ToolChoice } from "@oh-my-pi/pi-ai/types"; -import { buildGoogleGenerateContentParams } from "../src/providers/google-shared"; -import { mapGoogleToolChoice } from "../src/stream"; describe("mapGoogleToolChoice (F7)", () => { it("returns string passthrough for auto/none/any", () => { diff --git a/packages/ai/test/google-vertex-discovery.test.ts b/packages/ai/test/google-vertex-discovery.test.ts index 7a4471f87..f7c2780b2 100644 --- a/packages/ai/test/google-vertex-discovery.test.ts +++ b/packages/ai/test/google-vertex-discovery.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { resolveProviderModels } from "../src/model-manager"; -import { googleVertexModelManagerOptions } from "../src/provider-models/google"; -import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "../src/provider-models/openai-compat"; +import { resolveProviderModels } from "@oh-my-pi/pi-ai/model-manager"; +import { googleVertexModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/google"; +import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const googleVertexModelsDevPayload = { "google-vertex": { diff --git a/packages/ai/test/issue-1203-repro.test.ts b/packages/ai/test/issue-1203-repro.test.ts index 48a856013..0a6b3fc52 100644 --- a/packages/ai/test/issue-1203-repro.test.ts +++ b/packages/ai/test/issue-1203-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-1270-repro.test.ts b/packages/ai/test/issue-1270-repro.test.ts index 00d3914e1..1169b6b0c 100644 --- a/packages/ai/test/issue-1270-repro.test.ts +++ b/packages/ai/test/issue-1270-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { __resetVertexTokenCache } from "../src/providers/google-auth"; -import { streamGoogleVertex } from "../src/providers/google-vertex"; -import type { Model } from "../src/types"; +import { __resetVertexTokenCache } from "@oh-my-pi/pi-ai/providers/google-auth"; +import { streamGoogleVertex } from "@oh-my-pi/pi-ai/providers/google-vertex"; +import type { Model } from "@oh-my-pi/pi-ai/types"; const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; const METADATA_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"; diff --git a/packages/ai/test/issue-1373-repro.test.ts b/packages/ai/test/issue-1373-repro.test.ts index 88e855f4e..ce0e65111 100644 --- a/packages/ai/test/issue-1373-repro.test.ts +++ b/packages/ai/test/issue-1373-repro.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { streamBedrock } from "../src/providers/amazon-bedrock"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalSkipAuth = process.env.AWS_BEDROCK_SKIP_AUTH; diff --git a/packages/ai/test/issue-1399-repro.test.ts b/packages/ai/test/issue-1399-repro.test.ts index f03e74a90..fc523c553 100644 --- a/packages/ai/test/issue-1399-repro.test.ts +++ b/packages/ai/test/issue-1399-repro.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; +import { clearAwsCredentialCache } from "@oh-my-pi/pi-ai/providers/aws-credentials"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { streamBedrock } from "../src/providers/amazon-bedrock"; -import { clearAwsCredentialCache } from "../src/providers/aws-credentials"; -import type { Context, Model } from "../src/types"; const model: Model<"bedrock-converse-stream"> = { id: "zai.glm-5", diff --git a/packages/ai/test/issue-1417-repro.test.ts b/packages/ai/test/issue-1417-repro.test.ts index 4ffa72f4c..ed1ffea12 100644 --- a/packages/ai/test/issue-1417-repro.test.ts +++ b/packages/ai/test/issue-1417-repro.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readModelCache } from "../src/model-cache"; -import { resolveProviderModels } from "../src/model-manager"; -import type { Model } from "../src/types"; +import { readModelCache } from "@oh-my-pi/pi-ai/model-cache"; +import { resolveProviderModels } from "@oh-my-pi/pi-ai/model-manager"; +import type { Model } from "@oh-my-pi/pi-ai/types"; const TTL_MS = 24 * 60 * 60 * 1000; diff --git a/packages/ai/test/issue-1617-repro.test.ts b/packages/ai/test/issue-1617-repro.test.ts index 84e546340..6caf43635 100644 --- a/packages/ai/test/issue-1617-repro.test.ts +++ b/packages/ai/test/issue-1617-repro.test.ts @@ -19,7 +19,7 @@ import { type ModelsDevModel, opencodeGoModelManagerOptions, opencodeZenModelManagerOptions, -} from "../src/provider-models/openai-compat"; +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const OPENCODE_ZEN_BASE = "https://opencode.ai/zen/v1"; const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; diff --git a/packages/ai/test/issue-1776-repro.test.ts b/packages/ai/test/issue-1776-repro.test.ts index 0a32dcdd8..f929221ff 100644 --- a/packages/ai/test/issue-1776-repro.test.ts +++ b/packages/ai/test/issue-1776-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-1838-repro.test.ts b/packages/ai/test/issue-1838-repro.test.ts index a7df4c00a..12e51a374 100644 --- a/packages/ai/test/issue-1838-repro.test.ts +++ b/packages/ai/test/issue-1838-repro.test.ts @@ -33,9 +33,9 @@ * own native format and would reject the extra key. */ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { AssistantMessage, Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-1846-repro.test.ts b/packages/ai/test/issue-1846-repro.test.ts index d508991b1..39df0d735 100644 --- a/packages/ai/test/issue-1846-repro.test.ts +++ b/packages/ai/test/issue-1846-repro.test.ts @@ -1,11 +1,11 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, it, vi } from "bun:test"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { convertMessages, detectCompat } from "../src/providers/openai-completions"; -import { getOAuthProviders } from "../src/registry/oauth"; -import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "../src/types"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "@oh-my-pi/pi-ai/types"; const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; const SGP_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1"; diff --git a/packages/ai/test/issue-1849-repro.test.ts b/packages/ai/test/issue-1849-repro.test.ts index 105e4539e..63a912fd7 100644 --- a/packages/ai/test/issue-1849-repro.test.ts +++ b/packages/ai/test/issue-1849-repro.test.ts @@ -13,12 +13,12 @@ * generator regenerates. */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { clampFireworksKimiMaxTokens, FIREWORKS_KIMI_MAX_TOKENS, isFireworksKimiK2ModelId, -} from "../src/provider-models/openai-compat"; +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; describe("Fireworks Kimi K2 maxTokens cap (#1849)", () => { it("recognizes Kimi K2.x public and wire ids", () => { diff --git a/packages/ai/test/issue-2080-repro.test.ts b/packages/ai/test/issue-2080-repro.test.ts index e3d6c3dbe..36f9c657f 100644 --- a/packages/ai/test/issue-2080-repro.test.ts +++ b/packages/ai/test/issue-2080-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-772-repro.test.ts b/packages/ai/test/issue-772-repro.test.ts index 13fb334f5..76594649d 100644 --- a/packages/ai/test/issue-772-repro.test.ts +++ b/packages/ai/test/issue-772-repro.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { loginXiaomi } from "../src/registry/oauth/xiaomi"; - const TOKEN_PLAN_SGP_HOST = "token-plan-sgp.xiaomimimo.com"; const STANDARD_HOST = "api.xiaomimimo.com"; diff --git a/packages/ai/test/issue-826-repro.test.ts b/packages/ai/test/issue-826-repro.test.ts index dd78aeda0..7cb629cdf 100644 --- a/packages/ai/test/issue-826-repro.test.ts +++ b/packages/ai/test/issue-826-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { Context, Model, Tool } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const baseModel: Model<"anthropic-messages"> = { id: "claude-sonnet-4-5", diff --git a/packages/ai/test/issue-830-repro.test.ts b/packages/ai/test/issue-830-repro.test.ts index 08444bf4c..4a6e53cb1 100644 --- a/packages/ai/test/issue-830-repro.test.ts +++ b/packages/ai/test/issue-830-repro.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; -import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "../src/provider-models/openai-compat"; -import { getOAuthProviders } from "../src/registry/oauth"; -import { getEnvApiKey } from "../src/stream"; -import type { OpenAICompat } from "../src/types"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; +import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; +import type { OpenAICompat } from "@oh-my-pi/pi-ai/types"; describe("deepseek built-in provider (issue #830)", () => { test("registers built-in runtime descriptor with DEEPSEEK_API_KEY env discovery", () => { diff --git a/packages/ai/test/issue-847-repro.test.ts b/packages/ai/test/issue-847-repro.test.ts index 684792ee2..fccf56034 100644 --- a/packages/ai/test/issue-847-repro.test.ts +++ b/packages/ai/test/issue-847-repro.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { ollamaModelManagerOptions } from "../src/provider-models/openai-compat"; +import { ollamaModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-883-repro.test.ts b/packages/ai/test/issue-883-repro.test.ts index 236a99f2b..c0710ecb0 100644 --- a/packages/ai/test/issue-883-repro.test.ts +++ b/packages/ai/test/issue-883-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { convertMessages, detectCompat } from "../src/providers/openai-completions"; -import type { AssistantMessage, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai/types"; function deepseekModel(overrides: Partial>): Model<"openai-completions"> { return { diff --git a/packages/ai/test/issue-887-repro.test.ts b/packages/ai/test/issue-887-repro.test.ts index 6e512b28c..463a3e56d 100644 --- a/packages/ai/test/issue-887-repro.test.ts +++ b/packages/ai/test/issue-887-repro.test.ts @@ -13,7 +13,7 @@ import { MODELS_DEV_PROVIDER_DESCRIPTORS, type ModelsDevModel, opencodeGoModelManagerOptions, -} from "../src/provider-models/openai-compat"; +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; diff --git a/packages/ai/test/issue-911-repro.test.ts b/packages/ai/test/issue-911-repro.test.ts index 4ca61123f..f039c8e58 100644 --- a/packages/ai/test/issue-911-repro.test.ts +++ b/packages/ai/test/issue-911-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-957-repro.test.ts b/packages/ai/test/issue-957-repro.test.ts index 019b499ba..d26e1f9ea 100644 --- a/packages/ai/test/issue-957-repro.test.ts +++ b/packages/ai/test/issue-957-repro.test.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import * as kimiOauth from "../src/registry/oauth/kimi"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import * as kimiOauth from "@oh-my-pi/pi-ai/registry/oauth/kimi"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-959-repro.test.ts b/packages/ai/test/issue-959-repro.test.ts index e652e06a1..87fe2219c 100644 --- a/packages/ai/test/issue-959-repro.test.ts +++ b/packages/ai/test/issue-959-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-967-vision-guard.test.ts b/packages/ai/test/issue-967-vision-guard.test.ts index c6a127dc6..607b7d9bf 100644 --- a/packages/ai/test/issue-967-vision-guard.test.ts +++ b/packages/ai/test/issue-967-vision-guard.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "bun:test"; -import { convertAnthropicMessages } from "../src/providers/anthropic"; -import { convertMessages as convertGoogleMessages } from "../src/providers/google-shared"; -import { convertCodexResponsesMessages } from "../src/providers/openai-codex-responses"; -import { convertMessages as convertOpenAICompletionsMessages } from "../src/providers/openai-completions"; -import type { ResolvedOpenAICompat } from "../src/providers/openai-completions-compat"; +import { convertAnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic"; +import { convertMessages as convertGoogleMessages } from "@oh-my-pi/pi-ai/providers/google-shared"; +import { convertCodexResponsesMessages } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; +import { convertMessages as convertOpenAICompletionsMessages } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; import { appendResponsesToolResultMessages, convertResponsesInputContent, -} from "../src/providers/openai-responses-shared"; -import { NON_VISION_IMAGE_PLACEHOLDER } from "../src/providers/vision-guard"; -import type { Api, AssistantMessage, Context, Model, ToolResultMessage, Usage } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; +import { NON_VISION_IMAGE_PLACEHOLDER } from "@oh-my-pi/pi-ai/providers/vision-guard"; +import type { Api, AssistantMessage, Context, Model, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai/types"; const emptyUsage: Usage = { input: 0, diff --git a/packages/ai/test/issue-969-repro.test.ts b/packages/ai/test/issue-969-repro.test.ts index 9f42a85bb..c33749f92 100644 --- a/packages/ai/test/issue-969-repro.test.ts +++ b/packages/ai/test/issue-969-repro.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { getSupportedEfforts } from "../src/model-thinking"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { getSupportedEfforts } from "@oh-my-pi/pi-ai/model-thinking"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/issue-976-repro.test.ts b/packages/ai/test/issue-976-repro.test.ts index 5e088d0f9..743358a06 100644 --- a/packages/ai/test/issue-976-repro.test.ts +++ b/packages/ai/test/issue-976-repro.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { buildRequest } from "../src/providers/google-gemini-cli"; -import type { Context, Model } from "../src/types"; +import { buildRequest } from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; function createModel(): Model<"google-gemini-cli"> { return { diff --git a/packages/ai/test/kagi-login.test.ts b/packages/ai/test/kagi-login.test.ts index a73d7beca..1bbe75f6c 100644 --- a/packages/ai/test/kagi-login.test.ts +++ b/packages/ai/test/kagi-login.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loginKagi } from "../src/registry/kagi"; +import { loginKagi } from "@oh-my-pi/pi-ai/registry/kagi"; describe("kagi login", () => { it("opens Kagi API settings and prompts for key", async () => { diff --git a/packages/ai/test/kilo-login.test.ts b/packages/ai/test/kilo-login.test.ts index 315cd7df9..23e22d91d 100644 --- a/packages/ai/test/kilo-login.test.ts +++ b/packages/ai/test/kilo-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginKilo } from "../src/registry/kilo"; +import { loginKilo } from "@oh-my-pi/pi-ai/registry/kilo"; const originalFetch = global.fetch; diff --git a/packages/ai/test/minimax-code-login.test.ts b/packages/ai/test/minimax-code-login.test.ts index 3ec6ca8bf..754783112 100644 --- a/packages/ai/test/minimax-code-login.test.ts +++ b/packages/ai/test/minimax-code-login.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { loginMiniMaxCode, loginMiniMaxCodeCn } from "@oh-my-pi/pi-ai/registry/oauth/minimax-code"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { loginMiniMaxCode, loginMiniMaxCodeCn } from "../src/registry/oauth/minimax-code"; describe("MiniMax Coding Plan login", () => { it("opens the international platform and validates against the international API", async () => { diff --git a/packages/ai/test/mock-provider.test.ts b/packages/ai/test/mock-provider.test.ts index bdd189b3a..249b6edf8 100644 --- a/packages/ai/test/mock-provider.test.ts +++ b/packages/ai/test/mock-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { clearCustomApis, getCustomApi } from "../src/api-registry"; +import { clearCustomApis, getCustomApi } from "@oh-my-pi/pi-ai/api-registry"; import { createMockModel, isMockModel, @@ -8,8 +8,8 @@ import { type MockModel, registerMockApi, streamMock, -} from "../src/providers/mock"; -import type { AssistantMessage, AssistantMessageEvent, Context, ToolCall } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/mock"; +import type { AssistantMessage, AssistantMessageEvent, Context, ToolCall } from "@oh-my-pi/pi-ai/types"; afterEach(() => { clearCustomApis(); diff --git a/packages/ai/test/model-cache.test.ts b/packages/ai/test/model-cache.test.ts index 7ebd3a45f..25353f68a 100644 --- a/packages/ai/test/model-cache.test.ts +++ b/packages/ai/test/model-cache.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readModelCache, writeModelCache } from "../src/model-cache"; -import type { Model } from "../src/types"; +import { readModelCache, writeModelCache } from "@oh-my-pi/pi-ai/model-cache"; +import type { Model } from "@oh-my-pi/pi-ai/types"; const TTL_MS = 24 * 60 * 60 * 1000; diff --git a/packages/ai/test/models-cost.test.ts b/packages/ai/test/models-cost.test.ts index 07b80d523..b8787fa49 100644 --- a/packages/ai/test/models-cost.test.ts +++ b/packages/ai/test/models-cost.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { calculateCost, getBundledModel } from "../src/models"; -import type { Usage } from "../src/types"; +import { calculateCost, getBundledModel } from "@oh-my-pi/pi-ai/models"; +import type { Usage } from "@oh-my-pi/pi-ai/types"; describe("calculateCost", () => { it("keeps token-based calculation for GitHub Copilot models", () => { diff --git a/packages/ai/test/models-json-no-local-endpoints.test.ts b/packages/ai/test/models-json-no-local-endpoints.test.ts index 614bb135b..0757d26a6 100644 --- a/packages/ai/test/models-json-no-local-endpoints.test.ts +++ b/packages/ai/test/models-json-no-local-endpoints.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; +import type { Model } from "@oh-my-pi/pi-ai/types"; import MODELS_JSON from "../src/models.json" with { type: "json" }; -import type { Model } from "../src/types"; // Pins the invariant: the committed `models.json` must never carry a // local/self-hosted provider's catalog. Those providers default to an endpoint diff --git a/packages/ai/test/nanogpt-login.test.ts b/packages/ai/test/nanogpt-login.test.ts index f67bea46d..0b301b18d 100644 --- a/packages/ai/test/nanogpt-login.test.ts +++ b/packages/ai/test/nanogpt-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginNanoGPT } from "../src/registry/nanogpt"; +import { loginNanoGPT } from "@oh-my-pi/pi-ai/registry/nanogpt"; const originalFetch = global.fetch; diff --git a/packages/ai/test/nanogpt-model-limits.test.ts b/packages/ai/test/nanogpt-model-limits.test.ts index 0270b18ff..512a74970 100644 --- a/packages/ai/test/nanogpt-model-limits.test.ts +++ b/packages/ai/test/nanogpt-model-limits.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { Effort } from "../src/effort"; -import { nanoGptModelManagerOptions } from "../src/provider-models/openai-compat"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { nanoGptModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; const originalFetch = global.fetch; diff --git a/packages/ai/test/oauth-deepseek.test.ts b/packages/ai/test/oauth-deepseek.test.ts index acc1e3676..3ecf301be 100644 --- a/packages/ai/test/oauth-deepseek.test.ts +++ b/packages/ai/test/oauth-deepseek.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginDeepSeek, normalizeDeepSeekApiKey } from "../src/registry/deepseek"; -import type { OAuthController } from "../src/registry/oauth/types"; +import { loginDeepSeek, normalizeDeepSeekApiKey } from "@oh-my-pi/pi-ai/registry/deepseek"; +import type { OAuthController } from "@oh-my-pi/pi-ai/registry/oauth/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/ollama-cloud-login.test.ts b/packages/ai/test/ollama-cloud-login.test.ts index 90fc695ab..a6b1a2b54 100644 --- a/packages/ai/test/ollama-cloud-login.test.ts +++ b/packages/ai/test/ollama-cloud-login.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { loginOllamaCloud } from "../src/registry/ollama-cloud"; +import { loginOllamaCloud } from "@oh-my-pi/pi-ai/registry/ollama-cloud"; describe("ollama cloud login", () => { it("opens Ollama Cloud key settings and trims the pasted key", async () => { diff --git a/packages/ai/test/ollama-cloud-provider.test.ts b/packages/ai/test/ollama-cloud-provider.test.ts index cf5c6c383..033b7b8de 100644 --- a/packages/ai/test/ollama-cloud-provider.test.ts +++ b/packages/ai/test/ollama-cloud-provider.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { ollamaCloudModelManagerOptions } from "../src/provider-models/ollama"; -import { completeSimple, getEnvApiKey, stream, streamSimple } from "../src/stream"; -import type { Context, Model, Tool } from "../src/types"; +import { ollamaCloudModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/ollama"; +import { completeSimple, getEnvApiKey, stream, streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const originalApiKey = Bun.env.OLLAMA_CLOUD_API_KEY; const originalFetch = global.fetch; diff --git a/packages/ai/test/ollama-provider.test.ts b/packages/ai/test/ollama-provider.test.ts index 4fa4866c2..ccf7ec666 100644 --- a/packages/ai/test/ollama-provider.test.ts +++ b/packages/ai/test/ollama-provider.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { Effort } from "../src/effort"; -import { ollamaModelManagerOptions } from "../src/provider-models/openai-compat"; -import { streamOllama } from "../src/providers/ollama"; -import type { Context, Model, Tool } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { ollamaModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { streamOllama } from "@oh-my-pi/pi-ai/providers/ollama"; +import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 626646402..f7f927a9d 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -7,7 +7,7 @@ * widget lose per-model visibility. */ import { describe, expect, it } from "bun:test"; -import { openaiCodexUsageProvider } from "../src/usage/openai-codex"; +import { openaiCodexUsageProvider } from "@oh-my-pi/pi-ai/usage/openai-codex"; const accessTokenFixture = (() => { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index da047b421..c26b35bc4 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { applyOpenRouterRoutingVariant, convertMessages, detectCompat, streamOpenAICompletions, -} from "../src/providers/openai-completions"; -import { type ResolvedOpenAICompat, resolveOpenAICompat } from "../src/providers/openai-completions-compat"; -import type { AssistantMessage, Context, Model, OpenAICompat } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { type ResolvedOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; +import type { AssistantMessage, Context, Model, OpenAICompat } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-completions-disable-reasoning.test.ts b/packages/ai/test/openai-completions-disable-reasoning.test.ts index 629a74887..4d3e4d19c 100644 --- a/packages/ai/test/openai-completions-disable-reasoning.test.ts +++ b/packages/ai/test/openai-completions-disable-reasoning.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { Effort } from "../src/effort"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-completions-progress-chunk.test.ts b/packages/ai/test/openai-completions-progress-chunk.test.ts index 7d6a7766f..db4b1058d 100644 --- a/packages/ai/test/openai-completions-progress-chunk.test.ts +++ b/packages/ai/test/openai-completions-progress-chunk.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { getOpenAICompletionsStreamIdleTimeoutFallbackMs, isOpenAICompletionsProgressChunk, streamOpenAICompletions, -} from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +} from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-first-event-timeout.test.ts b/packages/ai/test/openai-first-event-timeout.test.ts index d23dc868f..4886cf79c 100644 --- a/packages/ai/test/openai-first-event-timeout.test.ts +++ b/packages/ai/test/openai-first-event-timeout.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import { streamSimple } from "../src/stream"; -import type { Context, Model, TextContent } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamAzureOpenAIResponses } from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, TextContent } from "@oh-my-pi/pi-ai/types"; import { waitForDelayOrAbort } from "./helpers"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts index 9d9622502..8cd6d2fc7 100644 --- a/packages/ai/test/openai-responses-cache-affinity.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { type OpenAIResponsesOptions, streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; diff --git a/packages/ai/test/openai-responses-developer-role.test.ts b/packages/ai/test/openai-responses-developer-role.test.ts index e4f028a2c..6789f2e3b 100644 --- a/packages/ai/test/openai-responses-developer-role.test.ts +++ b/packages/ai/test/openai-responses-developer-role.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { supportsDeveloperRole } from "../src/providers/openai-responses"; -import type { Model } from "../src/types"; +import { supportsDeveloperRole } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Model } from "@oh-my-pi/pi-ai/types"; describe("supportsDeveloperRole", () => { it("returns true for openai provider with official API base URL", () => { diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts index 4b86ab9d1..6b5020d1d 100644 --- a/packages/ai/test/openai-responses-history-payload.test.ts +++ b/packages/ai/test/openai-responses-history-payload.test.ts @@ -3,7 +3,7 @@ import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; -import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "../src/utils"; +import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "@oh-my-pi/pi-ai/utils"; function createAbortedSignal(): AbortSignal { const controller = new AbortController(); diff --git a/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts b/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts index a5dcb1537..423ffa78d 100644 --- a/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts +++ b/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamSimple } from "../src/stream"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts index 0f459da8e..970d6567f 100644 --- a/packages/ai/test/openai-responses-system-prompt.test.ts +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/openrouter-login.test.ts b/packages/ai/test/openrouter-login.test.ts index ec22d8e1c..f98dd21c2 100644 --- a/packages/ai/test/openrouter-login.test.ts +++ b/packages/ai/test/openrouter-login.test.ts @@ -1,8 +1,8 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, test, vi } from "bun:test"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { getOAuthProviders } from "../src/registry/oauth"; -import { getEnvApiKey } from "../src/stream"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; const originalOpenRouterApiKey = Bun.env.OPENROUTER_API_KEY; const originalFetch = global.fetch; diff --git a/packages/ai/test/parse-streaming-json-throttled.test.ts b/packages/ai/test/parse-streaming-json-throttled.test.ts index 9cd74ab6b..efe81683c 100644 --- a/packages/ai/test/parse-streaming-json-throttled.test.ts +++ b/packages/ai/test/parse-streaming-json-throttled.test.ts @@ -3,7 +3,7 @@ import { parseStreamingJson, parseStreamingJsonThrottled, STREAMING_JSON_PARSE_MIN_GROWTH, -} from "../src/utils/json-parse"; +} from "@oh-my-pi/pi-ai/utils/json-parse"; describe("parseStreamingJsonThrottled (F5)", () => { it("parses the first non-empty buffer even when growth is below the threshold", () => { diff --git a/packages/ai/test/pi-native-client.test.ts b/packages/ai/test/pi-native-client.test.ts index 2c8627454..9ccb9e38f 100644 --- a/packages/ai/test/pi-native-client.test.ts +++ b/packages/ai/test/pi-native-client.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; -import { streamPiNative } from "../src/providers/pi-native-client"; -import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model } from "../src/types"; +import { streamPiNative } from "@oh-my-pi/pi-ai/providers/pi-native-client"; +import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; function sseBytes(events: AssistantMessageEvent[]): Uint8Array { const encoder = new TextEncoder(); diff --git a/packages/ai/test/provider-fetch-override.test.ts b/packages/ai/test/provider-fetch-override.test.ts index d16ddca8a..fdf174ed2 100644 --- a/packages/ai/test/provider-fetch-override.test.ts +++ b/packages/ai/test/provider-fetch-override.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/provider-registry.test.ts b/packages/ai/test/provider-registry.test.ts index ad6d9aa49..cd4391778 100644 --- a/packages/ai/test/provider-registry.test.ts +++ b/packages/ai/test/provider-registry.test.ts @@ -1,17 +1,17 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, test, vi } from "bun:test"; -import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; -import { PASTE_CODE_LOGIN_PROVIDERS } from "../src/registry"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; +import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai/registry"; import { getOAuthProviders, refreshOAuthToken, registerOAuthProvider, unregisterOAuthProviders, -} from "../src/registry/oauth"; -import * as anthropicOauth from "../src/registry/oauth/anthropic"; -import type { OAuthCredentials, OAuthProvider } from "../src/registry/oauth/types"; -import { getEnvApiKey } from "../src/stream"; +} from "@oh-my-pi/pi-ai/registry/oauth"; +import * as anthropicOauth from "@oh-my-pi/pi-ai/registry/oauth/anthropic"; +import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/registry/oauth/types"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; const FIXTURE_SOURCE = "provider-registry-test"; const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY"] as const; diff --git a/packages/ai/test/provider-response.test.ts b/packages/ai/test/provider-response.test.ts index f4d3cb393..14d4571fe 100644 --- a/packages/ai/test/provider-response.test.ts +++ b/packages/ai/test/provider-response.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamSimple } from "../src/stream"; -import type { Context, Model, ProviderResponseMetadata } from "../src/types"; -import { normalizeProviderResponse, notifyProviderResponse } from "../src/utils/provider-response"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai/types"; +import { normalizeProviderResponse, notifyProviderResponse } from "@oh-my-pi/pi-ai/utils/provider-response"; describe("provider response metadata", () => { it("normalizes response status, headers, and request id", () => { diff --git a/packages/ai/test/raw-sse-sdk-capture.test.ts b/packages/ai/test/raw-sse-sdk-capture.test.ts index 02ab86e03..9df20c855 100644 --- a/packages/ai/test/raw-sse-sdk-capture.test.ts +++ b/packages/ai/test/raw-sse-sdk-capture.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamAnthropic } from "../src/providers/anthropic"; -import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client"; -import type { RawMessageStreamEvent } from "../src/providers/anthropic-wire"; -import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model, RawSseEvent } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { AnthropicMessagesClientLike } from "@oh-my-pi/pi-ai/providers/anthropic-client"; +import type { RawMessageStreamEvent } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; +import { streamAzureOpenAIResponses } from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, Model, RawSseEvent } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/register-builtins.test.ts b/packages/ai/test/register-builtins.test.ts index 8e6628786..9f7a6a389 100644 --- a/packages/ai/test/register-builtins.test.ts +++ b/packages/ai/test/register-builtins.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { setBedrockProviderModule, streamBedrock } from "../src/providers/register-builtins"; -import type { AssistantMessage, Context, Model } from "../src/types"; -import type { AssistantMessageEventStream } from "../src/utils/event-stream"; +import { setBedrockProviderModule, streamBedrock } from "@oh-my-pi/pi-ai/providers/register-builtins"; +import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; +import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; function createModel(): Model<"bedrock-converse-stream"> { return { diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index 6ce28b976..e1a6e2be6 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -10,8 +10,8 @@ import { RemoteAuthCredentialStore, SqliteAuthCredentialStore, startAuthBroker, -} from "../src"; -import * as oauthUtils from "../src/registry/oauth"; +} from "@oh-my-pi/pi-ai"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/request-debug.test.ts b/packages/ai/test/request-debug.test.ts index dbe482054..22bbf57e7 100644 --- a/packages/ai/test/request-debug.test.ts +++ b/packages/ai/test/request-debug.test.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { clearCustomApis, registerCustomApi } from "@oh-my-pi/pi-ai/api-registry"; +import { stream } from "@oh-my-pi/pi-ai/stream"; +import type { AssistantMessage, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { wrapFetchForRequestDebug } from "@oh-my-pi/pi-ai/utils/request-debug"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { clearCustomApis, registerCustomApi } from "../src/api-registry"; -import { stream } from "../src/stream"; -import type { AssistantMessage, FetchImpl, Model } from "../src/types"; -import { AssistantMessageEventStream } from "../src/utils/event-stream"; -import { wrapFetchForRequestDebug } from "../src/utils/request-debug"; const enc = new TextEncoder(); diff --git a/packages/ai/test/service-tier-premium-requests.test.ts b/packages/ai/test/service-tier-premium-requests.test.ts index ca4bb1f7c..43216b83b 100644 --- a/packages/ai/test/service-tier-premium-requests.test.ts +++ b/packages/ai/test/service-tier-premium-requests.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getPriorityPremiumRequests, resolveServiceTier, shouldSendServiceTier } from "../src/types"; +import { getPriorityPremiumRequests, resolveServiceTier, shouldSendServiceTier } from "@oh-my-pi/pi-ai/types"; describe("getPriorityPremiumRequests", () => { it("counts priority tier as one premium request on OpenAI", () => { diff --git a/packages/ai/test/sse-debug.test.ts b/packages/ai/test/sse-debug.test.ts index bc839265b..00dc71881 100644 --- a/packages/ai/test/sse-debug.test.ts +++ b/packages/ai/test/sse-debug.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { RawSseEvent } from "../src/types"; -import { notifyRawSseEvent } from "../src/utils/sse-debug"; +import type { RawSseEvent } from "@oh-my-pi/pi-ai/types"; +import { notifyRawSseEvent } from "@oh-my-pi/pi-ai/utils/sse-debug"; describe("notifyRawSseEvent", () => { it("dispatches diagnostic events without cloning raw lines", () => { diff --git a/packages/ai/test/stream-markup-healing.test.ts b/packages/ai/test/stream-markup-healing.test.ts index 79b6b74b5..c7e6facac 100644 --- a/packages/ai/test/stream-markup-healing.test.ts +++ b/packages/ai/test/stream-markup-healing.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import { stream } from "../src/stream"; -import type { Context, Model, Tool, ToolCall } from "../src/types"; -import { getStreamMarkupHealingPattern, StreamMarkupHealing } from "../src/utils/stream-markup-healing"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { stream } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model, Tool, ToolCall } from "@oh-my-pi/pi-ai/types"; +import { getStreamMarkupHealingPattern, StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing"; const originalFetch = global.fetch; diff --git a/packages/ai/test/stream-timeout-defaults.test.ts b/packages/ai/test/stream-timeout-defaults.test.ts index ee22a94e4..3a75fb814 100644 --- a/packages/ai/test/stream-timeout-defaults.test.ts +++ b/packages/ai/test/stream-timeout-defaults.test.ts @@ -5,7 +5,7 @@ import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout, -} from "../src/utils/idle-iterator"; +} from "@oh-my-pi/pi-ai/utils/idle-iterator"; /** * Per-provider fallback overrides on the stream-watchdog helpers. diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 3d5325d42..16b08a72d 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -5,11 +5,11 @@ import * as os from "node:os"; import * as path from "node:path"; import { Effort } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { __resetVertexTokenCache } from "@oh-my-pi/pi-ai/providers/google-auth"; import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { $which } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; -import { __resetVertexTokenCache } from "../src/providers/google-auth"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) diff --git a/packages/ai/test/synthetic-login.test.ts b/packages/ai/test/synthetic-login.test.ts index 00f225de9..2820360dd 100644 --- a/packages/ai/test/synthetic-login.test.ts +++ b/packages/ai/test/synthetic-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginSynthetic } from "../src/registry/synthetic"; +import { loginSynthetic } from "@oh-my-pi/pi-ai/registry/synthetic"; const originalFetch = global.fetch; diff --git a/packages/ai/test/utils-responses-id.test.ts b/packages/ai/test/utils-responses-id.test.ts index 2b2066780..c400c7709 100644 --- a/packages/ai/test/utils-responses-id.test.ts +++ b/packages/ai/test/utils-responses-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { normalizeResponsesToolCallId } from "../src/utils"; +import { normalizeResponsesToolCallId } from "@oh-my-pi/pi-ai/utils"; describe("normalizeResponsesToolCallId", () => { it("preserves existing item prefix when truncating oversized ids", () => { diff --git a/packages/ai/test/wafer.live.ts b/packages/ai/test/wafer.live.ts index f8dcd8caa..33f7fd323 100644 --- a/packages/ai/test/wafer.live.ts +++ b/packages/ai/test/wafer.live.ts @@ -7,9 +7,9 @@ * `model` field preserved verbatim (`GLM-5.1`, not lowercased) and a non-empty * assistant text returned. */ -import { getBundledModel } from "../src/models"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const apiKey = process.env.WAFER_PASS_API_KEY ?? process.env.WAFER_SERVERLESS_API_KEY; if (!apiKey) { diff --git a/packages/ai/test/wafer.test.ts b/packages/ai/test/wafer.test.ts index 29e9b491f..a7deb0100 100644 --- a/packages/ai/test/wafer.test.ts +++ b/packages/ai/test/wafer.test.ts @@ -11,11 +11,14 @@ * the case-sensitive id pass-through against the wire. */ import { afterEach, describe, expect, it } from "bun:test"; -import { createModelManager } from "../src/model-manager"; -import { getBundledModel } from "../src/models"; -import { waferPassModelManagerOptions, waferServerlessModelManagerOptions } from "../src/provider-models/openai-compat"; -import { streamOpenAICompletions } from "../src/providers/openai-completions"; -import type { Context, Model } from "../src/types"; +import { createModelManager } from "@oh-my-pi/pi-ai/model-manager"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { + waferPassModelManagerOptions, + waferServerlessModelManagerOptions, +} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; const originalFetch = global.fetch; diff --git a/packages/ai/test/xai-oauth-bundle.test.ts b/packages/ai/test/xai-oauth-bundle.test.ts index 824b2babe..c688ca1fc 100644 --- a/packages/ai/test/xai-oauth-bundle.test.ts +++ b/packages/ai/test/xai-oauth-bundle.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { buildXaiOAuthStaticSeed } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import type { Model } from "@oh-my-pi/pi-ai/types"; import MODELS_JSON from "../src/models.json" with { type: "json" }; -import { buildXaiOAuthStaticSeed } from "../src/provider-models/openai-compat"; -import type { Model } from "../src/types"; // Pins the invariant: bundled `models.json` carries every entry the runtime // curated catalog (XAI_OAUTH_CURATED_MODELS, surfaced via diff --git a/packages/ai/test/xai-oauth-effort-strip.test.ts b/packages/ai/test/xai-oauth-effort-strip.test.ts index 11b49542f..2239abadb 100644 --- a/packages/ai/test/xai-oauth-effort-strip.test.ts +++ b/packages/ai/test/xai-oauth-effort-strip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { modelOmitsReasoningEffort } from "@oh-my-pi/pi-ai/model-thinking"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; -import { modelOmitsReasoningEffort } from "../src/model-thinking"; // Pins fix #2 of the compaction effort-override bug. Before this fix, // `resolveOpenAiReasoningEffort` called `requireSupportedEffort` which threw diff --git a/packages/ai/test/xiaomi-oauth.test.ts b/packages/ai/test/xiaomi-oauth.test.ts index 00d9859bb..11ffcbb21 100644 --- a/packages/ai/test/xiaomi-oauth.test.ts +++ b/packages/ai/test/xiaomi-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginXiaomi } from "../src/registry/oauth/xiaomi"; +import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; const originalFetch = global.fetch; diff --git a/packages/ai/test/xiaomi-tp-login-integration.test.ts b/packages/ai/test/xiaomi-tp-login-integration.test.ts index ffc1d8dce..fe1fdb6a0 100644 --- a/packages/ai/test/xiaomi-tp-login-integration.test.ts +++ b/packages/ai/test/xiaomi-tp-login-integration.test.ts @@ -14,11 +14,10 @@ */ import { describe, expect, it } from "bun:test"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; -import { loginXiaomi } from "../src/registry/oauth/xiaomi"; - // Realistic tp- key (same format as user's key, but a dummy value for testing) const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; diff --git a/packages/ai/test/zenmux-login.test.ts b/packages/ai/test/zenmux-login.test.ts index fed5e046a..7186c3b05 100644 --- a/packages/ai/test/zenmux-login.test.ts +++ b/packages/ai/test/zenmux-login.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { loginZenMux } from "../src/registry/zenmux"; +import { loginZenMux } from "@oh-my-pi/pi-ai/registry/zenmux"; const originalFetch = global.fetch; diff --git a/packages/ai/test/zenmux-provider.test.ts b/packages/ai/test/zenmux-provider.test.ts index 8ca85c978..e11bf9af5 100644 --- a/packages/ai/test/zenmux-provider.test.ts +++ b/packages/ai/test/zenmux-provider.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; -import { zenmuxModelManagerOptions } from "../src/provider-models/openai-compat"; -import { getOAuthProviders } from "../src/registry/oauth"; -import { getEnvApiKey } from "../src/stream"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; +import { zenmuxModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; const originalZenMuxApiKey = Bun.env.ZENMUX_API_KEY; const originalFetch = global.fetch; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9d188f40d..5dc396c78 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ ### Changed +- Updated pi-ai OAuth imports to the renamed `@oh-my-pi/pi-ai/oauth` subpath (was `@oh-my-pi/pi-ai/utils/oauth`) across the login UI, MCP OAuth flow, model registry, setup wizard, and web-search Codex auth. The legacy-plugin specifier shim drops its `pi-ai/oauth` → `pi-ai/utils/oauth` subpath rewrite, since the canonical `@oh-my-pi/pi-ai/oauth` export now resolves directly. - Changed forced self-updates for Homebrew installs to run `brew reinstall` and for mise installs to run `mise install --force` after `mise upgrade` when `--force` is requested - Changed TTSR rule bucketing and matching so rules with only `astCondition` are treated as TTSR rules and evaluated in the interrupt flow using reconstructed edit/write source snapshots - Normalized image content before it enters model context so attached images are downscaled and preprocessed for prompts, steering messages, follow-ups, and custom agent messages @@ -35,6 +36,7 @@ ### Fixed +- Fixed package subpath exports for status-line, setup-wizard, tool-discovery, and gallery fixture modules so rewritten test imports resolve through `@oh-my-pi/pi-coding-agent`. - Fixed runtime model provider discovery so extension-registered providers are now refreshed after extension load and extension-supplied models appear without restarting - Fixed task-row shimmer timing so every running description starts its highlight on the first character together and reaches the last character together, regardless of text length. - Fixed the `eval` tool's `read`/`write`/`append` helpers (both Python and JS backends) treating `local://` (and other internal-URL) paths as plain filesystem paths. `pathlib.Path`/`path.resolve` collapse `local://x.md` to `local:/x.md`, so `write("local://x.md", …)` created a junk `local:` directory under the cwd instead of writing where `read local://x.md` resolves. The helpers now substitute injected on-disk roots for known schemes (currently `local://`, pinned to the session's own `local://` root), reject path traversal and unknown `scheme://` paths, and leave plain paths resolving against the cwd. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 2711909fc..61d0c5a74 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -133,6 +133,14 @@ "types": "./src/cli/*.ts", "import": "./src/cli/*.ts" }, + "./cli/gallery-fixtures": { + "types": "./src/cli/gallery-fixtures/index.ts", + "import": "./src/cli/gallery-fixtures/index.ts" + }, + "./cli/gallery-fixtures/*": { + "types": "./src/cli/gallery-fixtures/*.ts", + "import": "./src/cli/gallery-fixtures/*.ts" + }, "./cli/commands/*": { "types": "./src/cli/commands/*.ts", "import": "./src/cli/commands/*.ts" @@ -441,6 +449,14 @@ "types": "./src/modes/controllers/*.ts", "import": "./src/modes/controllers/*.ts" }, + "./modes/setup-wizard": { + "types": "./src/modes/setup-wizard/index.ts", + "import": "./src/modes/setup-wizard/index.ts" + }, + "./modes/setup-wizard/*": { + "types": "./src/modes/setup-wizard/*.ts", + "import": "./src/modes/setup-wizard/*.ts" + }, "./modes/rpc/*": { "types": "./src/modes/rpc/*.ts", "import": "./src/modes/rpc/*.ts" @@ -498,6 +514,10 @@ "types": "./src/task/*.ts", "import": "./src/task/*.ts" }, + "./tool-discovery/*": { + "types": "./src/tool-discovery/*.ts", + "import": "./src/tool-discovery/*.ts" + }, "./tools": { "types": "./src/tools/index.ts", "import": "./src/tools/index.ts" diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line/component.ts similarity index 94% rename from packages/coding-agent/src/modes/components/status-line.ts rename to packages/coding-agent/src/modes/components/status-line/component.ts index 03799e2ef..63e34d120 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -4,46 +4,24 @@ import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; -import { settings } from "../../config/settings"; -import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema"; -import { theme } from "../../modes/theme/theme"; -import type { AgentSession } from "../../session/agent-session"; -import * as git from "../../utils/git"; -import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color"; -import { sanitizeStatusText } from "../shared"; -import { computeNonMessageTokens } from "../utils/context-usage"; -import { - canReuseCachedPr, - createPrCacheContext, - isSamePrCacheContext, - type PrCacheContext, -} from "./status-line/git-utils"; -import { getPreset } from "./status-line/presets"; -import { renderSegment, type SegmentContext } from "./status-line/segments"; -import { getSeparator } from "./status-line/separators"; -import { calculateTokensPerSecond } from "./status-line/token-rate"; - -export interface StatusLineSegmentOptions { - model?: { showThinkingLevel?: boolean }; - path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean }; - git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean }; - time?: { format?: "12h" | "24h"; showSeconds?: boolean }; -} - -export interface StatusLineSettings { - preset?: StatusLinePreset; - leftSegments?: StatusLineSegmentId[]; - rightSegments?: StatusLineSegmentId[]; - separator?: StatusLineSeparatorStyle; - segmentOptions?: StatusLineSegmentOptions; - showHookStatus?: boolean; - sessionAccent?: boolean; -} - -export type EffectiveStatusLineSettings = Required< - Pick -> & - StatusLineSettings; +import { settings } from "../../../config/settings"; +import type { AgentSession } from "../../../session/agent-session"; +import * as git from "../../../utils/git"; +import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color"; +import { sanitizeStatusText } from "../../shared"; +import { theme } from "../../theme/theme"; +import { computeNonMessageTokens } from "../../utils/context-usage"; +import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils"; +import { getPreset } from "./presets"; +import { renderSegment, type SegmentContext } from "./segments"; +import { getSeparator } from "./separators"; +import { calculateTokensPerSecond } from "./token-rate"; +import type { + EffectiveStatusLineSettings, + StatusLineSegmentId, + StatusLineSegmentOptions, + StatusLineSettings, +} from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // Per-message token cache diff --git a/packages/coding-agent/src/modes/components/status-line/index.ts b/packages/coding-agent/src/modes/components/status-line/index.ts index 79207227d..4719d6ee8 100644 --- a/packages/coding-agent/src/modes/components/status-line/index.ts +++ b/packages/coding-agent/src/modes/components/status-line/index.ts @@ -1,3 +1,4 @@ +export * from "./component"; export * from "./presets"; export * from "./segments"; export * from "./separators"; diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index 274598d96..a6bb971d5 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -1,14 +1,29 @@ import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema"; import type { AgentSession } from "../../../session/agent-session"; -import type { StatusLineSegmentOptions, StatusLineSettings } from "../status-line"; -export type { - StatusLinePreset, - StatusLineSegmentId, - StatusLineSegmentOptions, - StatusLineSeparatorStyle, - StatusLineSettings, -}; +export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle }; + +export interface StatusLineSegmentOptions { + model?: { showThinkingLevel?: boolean }; + path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean }; + git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean }; + time?: { format?: "12h" | "24h"; showSeconds?: boolean }; +} + +export interface StatusLineSettings { + preset?: StatusLinePreset; + leftSegments?: StatusLineSegmentId[]; + rightSegments?: StatusLineSegmentId[]; + separator?: StatusLineSeparatorStyle; + segmentOptions?: StatusLineSegmentOptions; + showHookStatus?: boolean; + sessionAccent?: boolean; +} + +export type EffectiveStatusLineSettings = Required< + Pick +> & + StatusLineSettings; // ═══════════════════════════════════════════════════════════════════════════ // Segment Rendering diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 597c3c643..85d56324e 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -18,13 +18,17 @@ import { zSessionNotification, } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + ACP_BOOTSTRAP_RACE_GUARD_MS, + AcpAgent, + createAcpExtensionUiContext, +} from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; +import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { resetSettingsForTest, Settings } from "../src/config/settings"; -import { ACP_BOOTSTRAP_RACE_GUARD_MS, AcpAgent, createAcpExtensionUiContext } from "../src/modes/acp/acp-agent"; -import type { PlanModeState } from "../src/plan-mode/state"; -import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; -import { SILENT_ABORT_MARKER } from "../src/session/messages"; -import { SessionManager } from "../src/session/session-manager"; import { expectAcpStructure } from "./helpers/acp-schema"; const TEST_MODELS: Model[] = [ diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index 48f2112ed..9e35757cf 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, spyOn } from "bun:test"; -import { Settings } from "../src/config/settings"; -import type { AgentSession } from "../src/session/agent-session"; -import type { SessionManager } from "../src/session/session-manager"; -import { executeAcpBuiltinSlashCommand } from "../src/slash-commands/acp-builtins"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import type { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { executeAcpBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins"; interface FakeAcpBuiltinSession { fastMode: boolean; @@ -689,7 +689,7 @@ describe("wave 5 — adapters and polish", () => { it("/mcp add foo --url https://example.com --token X --scope project: outputs success or propagates write error", async () => { // Uses project scope so it writes to /tmp/project/.omp/mcp.json which test infra controls. // We verify the command either reports success or a meaningful error (not a parse error). - const mcpModule = await import("../src/mcp/config-writer"); + const mcpModule = await import("@oh-my-pi/pi-coding-agent/mcp/config-writer"); const spy = spyOn(mcpModule, "addMCPServer").mockResolvedValue(undefined); try { const { output, runtime } = createRuntime(); @@ -727,7 +727,7 @@ describe("wave 5 — adapters and polish", () => { // /ssh add — spy on addSSHHost it("/ssh add foo --host x --user y --scope user: calls addSSHHost", async () => { - const sshModule = await import("../src/ssh/config-writer"); + const sshModule = await import("@oh-my-pi/pi-coding-agent/ssh/config-writer"); const spy = spyOn(sshModule, "addSSHHost").mockResolvedValue(undefined); try { const { output, runtime } = createRuntime(); @@ -835,7 +835,7 @@ describe("wave 5 — adapters and polish", () => { // /marketplace discover bulleted list it("/marketplace discover: output is bulleted with ' - ' token", async () => { - const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace"); + const { MarketplaceManager } = await import("@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"); const discoverSpy = spyOn(MarketplaceManager.prototype, "listAvailablePlugins").mockResolvedValue([ { name: "hello", version: "1.0.0", description: "A greeting plugin" } as never, { name: "world", version: "2.0.0", description: undefined } as never, diff --git a/packages/coding-agent/test/acp-client-bridge.test.ts b/packages/coding-agent/test/acp-client-bridge.test.ts index 62946af23..4f86ea012 100644 --- a/packages/coding-agent/test/acp-client-bridge.test.ts +++ b/packages/coding-agent/test/acp-client-bridge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { AgentSideConnection, RequestPermissionRequest } from "@agentclientprotocol/sdk"; -import { createAcpClientBridge } from "../src/modes/acp/acp-client-bridge"; +import { createAcpClientBridge } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-client-bridge"; describe("ACP client bridge permission requests", () => { it("forwards pending tool-call status to session/request_permission", async () => { diff --git a/packages/coding-agent/test/acp-event-mapper.test.ts b/packages/coding-agent/test/acp-event-mapper.test.ts index c6c10e1cb..1cab03a00 100644 --- a/packages/coding-agent/test/acp-event-mapper.test.ts +++ b/packages/coding-agent/test/acp-event-mapper.test.ts @@ -5,14 +5,14 @@ import path from "node:path"; import type { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; import { zSessionNotification } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; -import { AcpAgent } from "../src/modes/acp/acp-agent"; +import { AcpAgent } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; import { buildToolCallStartUpdate, mapAgentSessionEventToAcpSessionUpdates, normalizeReplayToolArguments, -} from "../src/modes/acp/acp-event-mapper"; -import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; +} from "@oh-my-pi/pi-coding-agent/modes/acp/acp-event-mapper"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { expectAcpStructure, expectAcpStructureRejects } from "./helpers/acp-schema"; function makeAssistantMessage(text: string) { diff --git a/packages/coding-agent/test/acp-initialize-conformance.test.ts b/packages/coding-agent/test/acp-initialize-conformance.test.ts index f70c386e5..eddd6bee6 100644 --- a/packages/coding-agent/test/acp-initialize-conformance.test.ts +++ b/packages/coding-agent/test/acp-initialize-conformance.test.ts @@ -10,11 +10,11 @@ import * as path from "node:path"; import type { AgentSideConnection, InitializeRequest } from "@agentclientprotocol/sdk"; import { zInitializeResponse } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; +import { AcpAgent } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; +import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "@oh-my-pi/pi-coding-agent/modes/acp/terminal-auth"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getConfigRootDir, setAgentDir, VERSION } from "@oh-my-pi/pi-utils"; -import { AcpAgent } from "../src/modes/acp/acp-agent"; -import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "../src/modes/acp/terminal-auth"; -import type { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; import { expectAcpStructure } from "./helpers/acp-schema"; const TEST_MODELS: Model[] = [ diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 2833ae51b..afdabaee0 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -11,12 +11,12 @@ import { type SessionNotification, } from "@agentclientprotocol/sdk"; import type { Model } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAcpConnection } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-mode"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { Settings } from "../src/config/settings"; -import { createAcpConnection } from "../src/modes/acp/acp-mode"; -import type { AgentSession } from "../src/session/agent-session"; -import { AuthStorage } from "../src/session/auth-storage"; -import { SessionManager } from "../src/session/session-manager"; const TEST_MODEL: Model = { id: "claude-sonnet-4-20250514", @@ -156,7 +156,7 @@ async function closeTransport(writable: WritableStream): Promise describe("ACP lazy startup", () => { it("keeps ACP background jobs disabled by default and preserves explicit opt-ins", async () => { - const { runRootCommand } = await import("../src/main"); + const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); type ObservedBackgroundSettings = { asyncEnabled: boolean; @@ -314,8 +314,8 @@ describe("ACP lazy startup", () => { const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); try { const settings = Settings.isolated({ "marketplace.autoUpdate": "off" }); - const { runRootCommand } = await import("../src/main"); - const { createAgentSession } = await import("../src/sdk"); + const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); + const { createAgentSession } = await import("@oh-my-pi/pi-coding-agent/sdk"); let session: AgentSession | undefined; const stopped = runRootCommand( diff --git a/packages/coding-agent/test/acp-mcp-isolation.test.ts b/packages/coding-agent/test/acp-mcp-isolation.test.ts index 926bf53c3..abef4c4f2 100644 --- a/packages/coding-agent/test/acp-mcp-isolation.test.ts +++ b/packages/coding-agent/test/acp-mcp-isolation.test.ts @@ -16,13 +16,13 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAcpSessionFactory } from "@oh-my-pi/pi-coding-agent/main"; +import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { ModelRegistry } from "../src/config/model-registry"; -import { Settings } from "../src/config/settings"; -import { createAcpSessionFactory } from "../src/main"; -import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "../src/sdk"; -import type { AgentSession } from "../src/session/agent-session"; -import { AuthStorage } from "../src/session/auth-storage"; describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { it("forces enableMCP=false even when baseOptions opts in", async () => { diff --git a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts index 4d3eed103..3c5d66ddf 100644 --- a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts +++ b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts @@ -4,11 +4,11 @@ import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { Effort, type Model } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import * as z from "zod/v4"; -import { Settings } from "../src/config/settings"; -import type { CustomTool } from "../src/extensibility/custom-tools/types"; -import { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; function createModel(): Model<"openai-responses"> { return { diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 19df05517..c7b002ff4 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -2,14 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import * as autoThinkingClassifier from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { + AUTO_THINKING, + clampAutoThinkingEffort, + resolveProvisionalAutoLevel, +} from "@oh-my-pi/pi-coding-agent/thinking"; import { TempDir } from "@oh-my-pi/pi-utils"; -import * as autoThinkingClassifier from "../src/auto-thinking/classifier"; -import { AUTO_THINKING, clampAutoThinkingEffort, resolveProvisionalAutoLevel } from "../src/thinking"; import { createAssistantMessage } from "./helpers/agent-session-setup"; describe("AgentSession role model thinking behavior", () => { diff --git a/packages/coding-agent/test/agent-session-ssh-refresh.test.ts b/packages/coding-agent/test/agent-session-ssh-refresh.test.ts index 663c57688..ed74523a1 100644 --- a/packages/coding-agent/test/agent-session-ssh-refresh.test.ts +++ b/packages/coding-agent/test/agent-session-ssh-refresh.test.ts @@ -1,16 +1,16 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; +import { reset as resetCapabilities } from "@oh-my-pi/pi-coding-agent/capability"; +import { type SSHHost, sshCapability } from "@oh-my-pi/pi-coding-agent/capability/ssh"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { addSSHHost, removeSSHHost, updateSSHHost } from "@oh-my-pi/pi-coding-agent/ssh/config-writer"; +import * as connectionManager from "@oh-my-pi/pi-coding-agent/ssh/connection-manager"; +import { loadSshTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { getSSHConfigPath, TempDir } from "@oh-my-pi/pi-utils"; -import { reset as resetCapabilities } from "../src/capability"; -import { type SSHHost, sshCapability } from "../src/capability/ssh"; -import { Settings } from "../src/config/settings"; -import { loadCapability } from "../src/discovery"; -import { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; -import { addSSHHost, removeSSHHost, updateSSHHost } from "../src/ssh/config-writer"; -import * as connectionManager from "../src/ssh/connection-manager"; -import { loadSshTool, type ToolSession } from "../src/tools"; function createModel(): Model<"openai-responses"> { return { diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 42b921a93..18e5066fb 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import * as z from "zod/v4"; -import { Settings } from "../src/config/settings"; -import type { CustomTool } from "../src/extensibility/custom-tools/types"; -import { AgentSession } from "../src/session/agent-session"; -import { SessionManager } from "../src/session/session-manager"; // Cache-stability invariant: when MCP servers reconnect with byte-identical tool // definitions, `refreshMCPTools` must not rebuild the system prompt. A rebuild diff --git a/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts b/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts index 83d946ebc..7bd59320b 100644 --- a/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts +++ b/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AgentStorage } from "../src/session/agent-storage"; +import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import { readTableSql } from "./helpers/sqlite-inspect"; const LEGACY_TIMESTAMP = 1_700_000_000; diff --git a/packages/coding-agent/test/auth-broker-import.test.ts b/packages/coding-agent/test/auth-broker-import.test.ts index 877210325..8f21357bc 100644 --- a/packages/coding-agent/test/auth-broker-import.test.ts +++ b/packages/coding-agent/test/auth-broker-import.test.ts @@ -3,8 +3,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthBrokerServerHandle, AuthStorage, SqliteAuthCredentialStore, startAuthBroker } from "@oh-my-pi/pi-ai"; +import { runAuthBrokerCommand } from "@oh-my-pi/pi-coding-agent/cli/auth-broker-cli"; import { getAgentDbPath, setAgentDir } from "@oh-my-pi/pi-utils"; -import { runAuthBrokerCommand } from "../src/cli/auth-broker-cli"; const ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout); diff --git a/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts b/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts index 7dba4027c..510f1b94c 100644 --- a/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts +++ b/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts @@ -3,12 +3,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthBrokerServerHandle, AuthStorage, SqliteAuthCredentialStore, startAuthBroker } from "@oh-my-pi/pi-ai"; -import { discoverAuthStorage } from "../src/sdk"; +import { discoverAuthStorage } from "@oh-my-pi/pi-coding-agent/sdk"; import { readAuthBrokerSnapshotCache, type SnapshotResponse, writeAuthBrokerSnapshotCache, -} from "../src/session/auth-storage"; +} from "@oh-my-pi/pi-coding-agent/session/auth-storage"; const ENV_KEYS = [ "OMP_AUTH_BROKER_URL", diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index e472420c2..8d712ef3c 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "bun:test"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import { parseDifficultyBucket, parseDifficultyLevel } from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; import { AUTO_THINKING, clampAutoThinkingEffort, parseConfiguredThinkingLevel, parseThinkingLevel, } from "@oh-my-pi/pi-coding-agent/thinking"; -import { parseDifficultyBucket, parseDifficultyLevel } from "../src/auto-thinking/classifier"; describe("auto thinking classifier helpers", () => { it("parses configured thinking without widening provider-facing thinking selectors", () => { diff --git a/packages/coding-agent/test/autoresearch-state.test.ts b/packages/coding-agent/test/autoresearch-state.test.ts index d44a02fbc..08f3dcb71 100644 --- a/packages/coding-agent/test/autoresearch-state.test.ts +++ b/packages/coding-agent/test/autoresearch-state.test.ts @@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Snowflake } from "@oh-my-pi/pi-utils"; -import { createAutoresearchExtension } from "../src/autoresearch/index"; +import { createAutoresearchExtension } from "@oh-my-pi/pi-coding-agent/autoresearch"; import { buildExperimentState, computeConfidence, @@ -11,11 +10,16 @@ import { findBaselineRunNumber, findBestKeptMetric, reconstructControlState, -} from "../src/autoresearch/state"; -import { AutoresearchStorage } from "../src/autoresearch/storage"; -import type { ExperimentResult } from "../src/autoresearch/types"; -import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand } from "../src/extensibility/extensions"; -import * as git from "../src/utils/git"; +} from "@oh-my-pi/pi-coding-agent/autoresearch/state"; +import { AutoresearchStorage } from "@oh-my-pi/pi-coding-agent/autoresearch/storage"; +import type { ExperimentResult } from "@oh-my-pi/pi-coding-agent/autoresearch/types"; +import type { + ExtensionAPI, + ExtensionCommandContext, + RegisteredCommand, +} from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; +import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; +import { Snowflake } from "@oh-my-pi/pi-utils"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/coding-agent/test/autoresearch-tools.test.ts b/packages/coding-agent/test/autoresearch-tools.test.ts index 32b2ce59a..7bc7d34b7 100644 --- a/packages/coding-agent/test/autoresearch-tools.test.ts +++ b/packages/coding-agent/test/autoresearch-tools.test.ts @@ -3,16 +3,16 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import { createSessionRuntime } from "@oh-my-pi/pi-coding-agent/autoresearch/state"; +import { openAutoresearchStorage } from "@oh-my-pi/pi-coding-agent/autoresearch/storage"; +import { createInitExperimentTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/init-experiment"; +import { createLogExperimentTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/log-experiment"; +import { createRunExperimentTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/run-experiment"; +import { createUpdateNotesTool } from "@oh-my-pi/pi-coding-agent/autoresearch/tools/update-notes"; +import type { LogDetails, RunDetails } from "@oh-my-pi/pi-coding-agent/autoresearch/types"; +import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { Snowflake } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; -import { createSessionRuntime } from "../src/autoresearch/state"; -import { openAutoresearchStorage } from "../src/autoresearch/storage"; -import { createInitExperimentTool } from "../src/autoresearch/tools/init-experiment"; -import { createLogExperimentTool } from "../src/autoresearch/tools/log-experiment"; -import { createRunExperimentTool } from "../src/autoresearch/tools/run-experiment"; -import { createUpdateNotesTool } from "../src/autoresearch/tools/update-notes"; -import type { LogDetails, RunDetails } from "../src/autoresearch/types"; -import type { ExtensionAPI, ExtensionContext } from "../src/extensibility/extensions"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/coding-agent/test/bash-acp-terminal.test.ts b/packages/coding-agent/test/bash-acp-terminal.test.ts index 0323dec17..b9aad742f 100644 --- a/packages/coding-agent/test/bash-acp-terminal.test.ts +++ b/packages/coding-agent/test/bash-acp-terminal.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; -import type { ClientBridge, ClientBridgeTerminalHandle } from "../src/session/client-bridge"; -import type { ToolSession } from "../src/tools"; -import { BashTool } from "../src/tools/bash"; +import type { ClientBridge, ClientBridgeTerminalHandle } from "@oh-my-pi/pi-coding-agent/session/client-bridge"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; function makeSession(bridge: ClientBridge): ToolSession { return { diff --git a/packages/coding-agent/test/bash-failure-result.test.ts b/packages/coding-agent/test/bash-failure-result.test.ts index b75be6c90..0e8523ebd 100644 --- a/packages/coding-agent/test/bash-failure-result.test.ts +++ b/packages/coding-agent/test/bash-failure-result.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { ToolSession } from "../src/tools"; -import { BashTool } from "../src/tools/bash"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; function makeSession(): ToolSession { return { diff --git a/packages/coding-agent/test/checkpoint-rpc-qa.ts b/packages/coding-agent/test/checkpoint-rpc-qa.ts index 277569196..ad65599a2 100644 --- a/packages/coding-agent/test/checkpoint-rpc-qa.ts +++ b/packages/coding-agent/test/checkpoint-rpc-qa.ts @@ -2,13 +2,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentEvent, AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { RpcClient } from "../src/modes/rpc/rpc-client"; +import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client"; import { type BranchSummaryEntry, type CustomMessageEntry, parseSessionEntries, type SessionMessageEntry, -} from "../src/session/session-manager"; +} from "@oh-my-pi/pi-coding-agent/session/session-manager"; function extractText(message: AgentMessage): string { if (message.role !== "assistant") return ""; diff --git a/packages/coding-agent/test/cli-cwd-flag.test.ts b/packages/coding-agent/test/cli-cwd-flag.test.ts index 0f131f153..c61ac34e4 100644 --- a/packages/coding-agent/test/cli-cwd-flag.test.ts +++ b/packages/coding-agent/test/cli-cwd-flag.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; +import { applyStartupCwd } from "@oh-my-pi/pi-coding-agent/cli/startup-cwd"; import { getProjectDir, normalizePathForComparison, setProjectDir } from "@oh-my-pi/pi-utils"; -import { parseArgs } from "../src/cli/args"; -import { applyStartupCwd } from "../src/cli/startup-cwd"; const originalProjectDir = getProjectDir(); diff --git a/packages/coding-agent/test/cli-hide-thinking-flag.test.ts b/packages/coding-agent/test/cli-hide-thinking-flag.test.ts index 4b4dcf949..a58634a1c 100644 --- a/packages/coding-agent/test/cli-hide-thinking-flag.test.ts +++ b/packages/coding-agent/test/cli-hide-thinking-flag.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { Effort } from "@oh-my-pi/pi-ai"; -import { parseArgs } from "../src/cli/args"; +import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; describe("parseArgs — --hide-thinking flag", () => { it("parses --hide-thinking as a boolean flag", () => { diff --git a/packages/coding-agent/test/cli/completions.test.ts b/packages/coding-agent/test/cli/completions.test.ts index 491279d85..2dcf3a17c 100644 --- a/packages/coding-agent/test/cli/completions.test.ts +++ b/packages/coding-agent/test/cli/completions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { buildSpec, type CompletionSpec, generateCompletion } from "@oh-my-pi/pi-coding-agent/cli/completion-gen"; import type { CliConfig, CommandCtor } from "@oh-my-pi/pi-utils/cli"; -import { buildSpec, type CompletionSpec, generateCompletion } from "../../src/cli/completion-gen"; const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", ".."); const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts"); diff --git a/packages/coding-agent/test/client-prompts.test.ts b/packages/coding-agent/test/client-prompts.test.ts index b0c272f67..a21b47986 100644 --- a/packages/coding-agent/test/client-prompts.test.ts +++ b/packages/coding-agent/test/client-prompts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { getPrompt, listPrompts, serverSupportsPrompts } from "../src/mcp/client"; -import type { MCPGetPromptResult, MCPPrompt, MCPPromptsListResult } from "../src/mcp/types"; +import { getPrompt, listPrompts, serverSupportsPrompts } from "@oh-my-pi/pi-coding-agent/mcp/client"; +import type { MCPGetPromptResult, MCPPrompt, MCPPromptsListResult } from "@oh-my-pi/pi-coding-agent/mcp/types"; import { createMockConnection, createMockTransport } from "./mcp-test-utils"; describe("listPrompts", () => { diff --git a/packages/coding-agent/test/client-resources.test.ts b/packages/coding-agent/test/client-resources.test.ts index 72f2d647e..d3ac976fb 100644 --- a/packages/coding-agent/test/client-resources.test.ts +++ b/packages/coding-agent/test/client-resources.test.ts @@ -7,7 +7,7 @@ import { serverSupportsResources, subscribeToResources, unsubscribeFromResources, -} from "../src/mcp/client"; +} from "@oh-my-pi/pi-coding-agent/mcp/client"; import type { MCPResource, MCPResourceReadResult, @@ -15,7 +15,7 @@ import type { MCPResourceTemplate, MCPResourceTemplatesListResult, MCPTransport, -} from "../src/mcp/types"; +} from "@oh-my-pi/pi-coding-agent/mcp/types"; import { createMockConnection, createMockTransport } from "./mcp-test-utils"; describe("listResources", () => { diff --git a/packages/coding-agent/test/commit-agentic-attribution.test.ts b/packages/coding-agent/test/commit-agentic-attribution.test.ts index 6b16b8246..820c7cbb8 100644 --- a/packages/coding-agent/test/commit-agentic-attribution.test.ts +++ b/packages/coding-agent/test/commit-agentic-attribution.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai"; -import { runCommitAgentSession } from "../src/commit/agentic/agent"; -import * as toolsModule from "../src/commit/agentic/tools"; -import { Settings } from "../src/config/settings"; -import type { CreateAgentSessionResult } from "../src/sdk"; -import * as sdkModule from "../src/sdk"; -import type { PromptOptions } from "../src/session/agent-session"; +import { runCommitAgentSession } from "@oh-my-pi/pi-coding-agent/commit/agentic/agent"; +import * as toolsModule from "@oh-my-pi/pi-coding-agent/commit/agentic/tools"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; +import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; +import type { PromptOptions } from "@oh-my-pi/pi-coding-agent/session/agent-session"; describe("commit agent prompt attribution", () => { afterEach(() => { diff --git a/packages/coding-agent/test/commit-command-exit.test.ts b/packages/coding-agent/test/commit-command-exit.test.ts index 5b2a88dc6..f75247049 100644 --- a/packages/coding-agent/test/commit-command-exit.test.ts +++ b/packages/coding-agent/test/commit-command-exit.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import CommitCommand from "@oh-my-pi/pi-coding-agent/commands/commit"; +import * as commitModule from "@oh-my-pi/pi-coding-agent/commit"; +import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { postmortem } from "@oh-my-pi/pi-utils"; -import CommitCommand from "../src/commands/commit"; -import * as commitModule from "../src/commit"; -import * as themeModule from "../src/modes/theme/theme"; describe("omp commit command lifecycle (issue #1041)", () => { afterEach(() => { diff --git a/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts b/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts index f5cffc8f5..498ce011d 100644 --- a/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts +++ b/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; -import { resolvePrimaryModel, resolveSmolModel } from "../src/commit/model-selection"; +import { resolvePrimaryModel, resolveSmolModel } from "@oh-my-pi/pi-coding-agent/commit/model-selection"; function getModelOrThrow(id: string) { const model = getBundledModel("anthropic", id); diff --git a/packages/coding-agent/test/commit-shared-llm.test.ts b/packages/coding-agent/test/commit-shared-llm.test.ts index f30f54f9f..64be9b8f1 100644 --- a/packages/coding-agent/test/commit-shared-llm.test.ts +++ b/packages/coding-agent/test/commit-shared-llm.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../src/commit/shared-llm"; +import { + createConventionalAnalysisTool, + parseConventionalAnalysisResponse, +} from "@oh-my-pi/pi-coding-agent/commit/shared-llm"; describe("commit shared LLM parsing", () => { it("ignores harmless extra fields in conventional analysis tool output", () => { diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 453a51a84..1b733df5d 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -15,7 +15,6 @@ import * as ai from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { encodeTextSignatureV1 } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; import type { AssistantMessage, Model, ProviderPayload, Usage } from "@oh-my-pi/pi-ai/types"; -import { hookFetch } from "@oh-my-pi/pi-utils"; import { buildSessionContext, type CompactionEntry, @@ -25,7 +24,8 @@ import { type SessionEntry, type SessionMessageEntry, type ThinkingLevelChangeEntry, -} from "../src/session/session-manager"; +} from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { hookFetch } from "@oh-my-pi/pi-utils"; import { e2eApiKey } from "./utilities"; // ============================================================================ diff --git a/packages/coding-agent/test/config-cli.test.ts b/packages/coding-agent/test/config-cli.test.ts index 62cba399a..bcd7f7b96 100644 --- a/packages/coding-agent/test/config-cli.test.ts +++ b/packages/coding-agent/test/config-cli.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { runConfigCommand } from "@oh-my-pi/pi-coding-agent/cli/config-cli"; +import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { runConfigCommand } from "../src/cli/config-cli"; -import { resetSettingsForTest } from "../src/config/settings"; let testAgentDir = ""; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; diff --git a/packages/coding-agent/test/core/js-executor.test.ts b/packages/coding-agent/test/core/js-executor.test.ts index 7776bfbb2..c8757c7c7 100644 --- a/packages/coding-agent/test/core/js-executor.test.ts +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -2,11 +2,11 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, setDefaultTimeout import * as path from "node:path"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; +import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; -import { disposeAllVmContexts } from "../../src/eval/js/context-manager"; -import { executeJs, type JsResult } from "../../src/eval/js/executor"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup // can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed diff --git a/packages/coding-agent/test/core/js-static-import-rewrite.test.ts b/packages/coding-agent/test/core/js-static-import-rewrite.test.ts index 1bb41310d..4771caee4 100644 --- a/packages/coding-agent/test/core/js-static-import-rewrite.test.ts +++ b/packages/coding-agent/test/core/js-static-import-rewrite.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { rewriteImports, wrapCode } from "../../src/eval/js/context-manager"; +import { rewriteImports, wrapCode } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; // Test fixtures embed user-supplied `import(...)` syntax that the rewriter must // transform. The strings are split so static-analysis heuristics don't read them diff --git a/packages/coding-agent/test/core/js-tool-bridge.test.ts b/packages/coding-agent/test/core/js-tool-bridge.test.ts index 811463622..9b4b747b6 100644 --- a/packages/coding-agent/test/core/js-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "bun:test"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { callSessionTool } from "@oh-my-pi/pi-coding-agent/eval/js/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import * as z from "zod/v4"; -import { callSessionTool } from "../../src/eval/js/tool-bridge"; function createTool( name: string, diff --git a/packages/coding-agent/test/core/js-workflow-helpers.test.ts b/packages/coding-agent/test/core/js-workflow-helpers.test.ts index d8592165a..9beb785b9 100644 --- a/packages/coding-agent/test/core/js-workflow-helpers.test.ts +++ b/packages/coding-agent/test/core/js-workflow-helpers.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from "bun:test"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; +import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { disposeAllVmContexts } from "../../src/eval/js/context-manager"; -import { executeJs, type JsResult } from "../../src/eval/js/executor"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup // can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed diff --git a/packages/coding-agent/test/custom-editor-keybindings.test.ts b/packages/coding-agent/test/custom-editor-keybindings.test.ts index 04647e4bf..9009f86c2 100644 --- a/packages/coding-agent/test/custom-editor-keybindings.test.ts +++ b/packages/coding-agent/test/custom-editor-keybindings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "bun:test"; +import { CustomEditor, extractBracketedImagePastePath } from "@oh-my-pi/pi-coding-agent/modes/components/custom-editor"; import { defaultEditorTheme } from "../../tui/test/test-themes"; -import { CustomEditor, extractBracketedImagePastePath } from "../src/modes/components/custom-editor"; function ctrl(key: string): string { return String.fromCharCode(key.toLowerCase().charCodeAt(0) & 31); diff --git a/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts b/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts index 08efa692e..02bffbb19 100644 --- a/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts +++ b/packages/coding-agent/test/dap-write-sink-flush.typecheck.ts @@ -1,4 +1,4 @@ -import type { DapClient } from "../src/dap/client"; +import type { DapClient } from "@oh-my-pi/pi-coding-agent/dap/client"; // Type-only import forces standard TypeScript to check src/dap/client.ts, // including the socketToSink() implementation against DapWriteSink.flush(). diff --git a/packages/coding-agent/test/debug/dap-launch-failures.test.ts b/packages/coding-agent/test/debug/dap-launch-failures.test.ts index 9d5802860..5ec7abaab 100644 --- a/packages/coding-agent/test/debug/dap-launch-failures.test.ts +++ b/packages/coding-agent/test/debug/dap-launch-failures.test.ts @@ -2,13 +2,18 @@ import { afterEach, describe, expect, it, spyOn, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { Settings } from "../../src/config/settings"; -import * as dapModule from "../../src/dap"; -import { DapClient } from "../../src/dap/client"; -import { DapSessionManager } from "../../src/dap/session"; -import type { DapCapabilities, DapClientState, DapEventMessage, DapResolvedAdapter } from "../../src/dap/types"; -import type { ToolSession } from "../../src/tools"; -import { DebugTool } from "../../src/tools/debug"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import * as dapModule from "@oh-my-pi/pi-coding-agent/dap"; +import { DapClient } from "@oh-my-pi/pi-coding-agent/dap/client"; +import { DapSessionManager } from "@oh-my-pi/pi-coding-agent/dap/session"; +import type { + DapCapabilities, + DapClientState, + DapEventMessage, + DapResolvedAdapter, +} from "@oh-my-pi/pi-coding-agent/dap/types"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { DebugTool } from "@oh-my-pi/pi-coding-agent/tools/debug"; const TEST_ADAPTER: DapResolvedAdapter = { name: "lldb-dap", diff --git a/packages/coding-agent/test/debug/log-formatting.test.ts b/packages/coding-agent/test/debug/log-formatting.test.ts index c0df00f5f..76d4997e9 100644 --- a/packages/coding-agent/test/debug/log-formatting.test.ts +++ b/packages/coding-agent/test/debug/log-formatting.test.ts @@ -3,7 +3,7 @@ import { formatDebugLogExpandedLines, formatDebugLogLine, parseDebugLogTimestampMs, -} from "../../src/debug/log-formatting"; +} from "@oh-my-pi/pi-coding-agent/debug/log-formatting"; describe("formatDebugLogLine", () => { it("strips ANSI codes and carriage returns", () => { diff --git a/packages/coding-agent/test/debug/log-viewer.test.ts b/packages/coding-agent/test/debug/log-viewer.test.ts index 16bce1360..d32b5de96 100644 --- a/packages/coding-agent/test/debug/log-viewer.test.ts +++ b/packages/coding-agent/test/debug/log-viewer.test.ts @@ -4,7 +4,7 @@ import { DebugLogViewerModel, LOAD_OLDER_LABEL, SESSION_BOUNDARY_WARNING, -} from "../../src/debug/log-viewer"; +} from "@oh-my-pi/pi-coding-agent/debug/log-viewer"; describe("DebugLogViewerModel", () => { const describeRow = (row: { kind: string; logIndex?: number }): string => { diff --git a/packages/coding-agent/test/debug/protocol-probe.test.ts b/packages/coding-agent/test/debug/protocol-probe.test.ts index 20bea256d..2eabac540 100644 --- a/packages/coding-agent/test/debug/protocol-probe.test.ts +++ b/packages/coding-agent/test/debug/protocol-probe.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeAll, describe, expect, it } from "bun:test"; -import { getImageDimensions, ImageBudget, ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; import { buildLargeTextLines, buildSampleImage, encodeRgbPng, ProtocolProbeComponent, -} from "../../src/debug/protocol-probe"; -import { initTheme } from "../../src/modes/theme/theme"; +} from "@oh-my-pi/pi-coding-agent/debug/protocol-probe"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getImageDimensions, ImageBudget, ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; beforeAll(async () => { // buildLargeTextLines styles the OSC 66 span through the global theme singleton. diff --git a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts index 56008eab7..510810c35 100644 --- a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; import type { Model } from "@oh-my-pi/pi-ai"; -import { RawSseDebugBuffer, rawSseRecordLines, resolveRawSseDebugBuffer } from "../../src/debug/raw-sse-buffer"; +import { + RawSseDebugBuffer, + rawSseRecordLines, + resolveRawSseDebugBuffer, +} from "@oh-my-pi/pi-coding-agent/debug/raw-sse-buffer"; const model: Model<"anthropic-messages"> = { id: "claude-test", diff --git a/packages/coding-agent/test/debug/raw-sse-pretty.test.ts b/packages/coding-agent/test/debug/raw-sse-pretty.test.ts index 2dc12e11e..5a29c0b8e 100644 --- a/packages/coding-agent/test/debug/raw-sse-pretty.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-pretty.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { expandPrettyDataLines } from "../../src/debug/raw-sse"; +import { expandPrettyDataLines } from "@oh-my-pi/pi-coding-agent/debug/raw-sse"; // Wide enough that `truncateToWidth` would clip the payload in the viewer; matches // what real Codex `response.output_item.done` frames look like on the wire. diff --git a/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts b/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts index bb43fdb58..3137d9e1a 100644 --- a/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-report-bundle.test.ts @@ -3,9 +3,9 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { Model } from "@oh-my-pi/pi-ai"; +import { RawSseDebugBuffer } from "@oh-my-pi/pi-coding-agent/debug/raw-sse-buffer"; +import { createReportBundle } from "@oh-my-pi/pi-coding-agent/debug/report-bundle"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { RawSseDebugBuffer } from "../../src/debug/raw-sse-buffer"; -import { createReportBundle } from "../../src/debug/report-bundle"; const model: Model<"anthropic-messages"> = { id: "claude-test", diff --git a/packages/coding-agent/test/debug/terminal-info.test.ts b/packages/coding-agent/test/debug/terminal-info.test.ts index fdc69144d..b937efbc5 100644 --- a/packages/coding-agent/test/debug/terminal-info.test.ts +++ b/packages/coding-agent/test/debug/terminal-info.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { + collectTerminalState, + formatTerminalState, + type TerminalStateInfo, +} from "@oh-my-pi/pi-coding-agent/debug/terminal-info"; import { TERMINAL } from "@oh-my-pi/pi-tui"; -import { collectTerminalState, formatTerminalState, type TerminalStateInfo } from "../../src/debug/terminal-info"; const sample: TerminalStateInfo = { detectedId: "kitty", diff --git a/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts b/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts index cdca0d86a..4efe97225 100644 --- a/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts +++ b/packages/coding-agent/test/discovery/agent-discovery-disabled-providers.test.ts @@ -6,10 +6,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { disableProvider, enableProvider } from "../../src/capability"; -import { clearCache as clearFsCache } from "../../src/capability/fs"; -import { clearClaudePluginRootsCache } from "../../src/discovery/helpers"; -import { discoverAgents } from "../../src/task/discovery"; +import { disableProvider, enableProvider } from "@oh-my-pi/pi-coding-agent/capability"; +import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import { clearClaudePluginRootsCache } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; +import { discoverAgents } from "@oh-my-pi/pi-coding-agent/task/discovery"; const PLUGIN_AGENT_MD = [ "---", diff --git a/packages/coding-agent/test/discovery/agent-fields.test.ts b/packages/coding-agent/test/discovery/agent-fields.test.ts index 56a3e8974..34371f7b2 100644 --- a/packages/coding-agent/test/discovery/agent-fields.test.ts +++ b/packages/coding-agent/test/discovery/agent-fields.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Effort } from "@oh-my-pi/pi-ai"; -import { parseAgentFields } from "../../src/discovery/helpers"; +import { parseAgentFields } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; describe("parseAgentFields", () => { test("parses blocking from boolean frontmatter", () => { diff --git a/packages/coding-agent/test/edit/file-snapshot-store.test.ts b/packages/coding-agent/test/edit/file-snapshot-store.test.ts index 978f4a23e..7d0def8ee 100644 --- a/packages/coding-agent/test/edit/file-snapshot-store.test.ts +++ b/packages/coding-agent/test/edit/file-snapshot-store.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { InMemorySnapshotStore } from "@oh-my-pi/hashline"; -import { canonicalSnapshotKey, getFileSnapshotStore } from "../../src/edit/file-snapshot-store"; +import { canonicalSnapshotKey, getFileSnapshotStore } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store"; interface SessionOwner { fileSnapshotStore?: InMemorySnapshotStore; diff --git a/packages/coding-agent/test/emoji-autocomplete.test.ts b/packages/coding-agent/test/emoji-autocomplete.test.ts index 0d02b61f9..e0d71ea20 100644 --- a/packages/coding-agent/test/emoji-autocomplete.test.ts +++ b/packages/coding-agent/test/emoji-autocomplete.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { applyEmojiCompletion, getEmojiSuggestions, tryEmojiInlineReplace } from "../src/modes/emoji-autocomplete"; +import { + applyEmojiCompletion, + getEmojiSuggestions, + tryEmojiInlineReplace, +} from "@oh-my-pi/pi-coding-agent/modes/emoji-autocomplete"; describe("emoji autocomplete", () => { describe("getEmojiSuggestions", () => { diff --git a/packages/coding-agent/test/eval/agent-bridge.test.ts b/packages/coding-agent/test/eval/agent-bridge.test.ts index 87b401d1a..832dd7fba 100644 --- a/packages/coding-agent/test/eval/agent-bridge.test.ts +++ b/packages/coding-agent/test/eval/agent-bridge.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { Settings } from "../../src/config/settings"; -import { runEvalAgent } from "../../src/eval/agent-bridge"; -import type { LocalProtocolOptions } from "../../src/internal-urls"; -import type { MCPManager } from "../../src/mcp"; -import * as taskDiscovery from "../../src/task/discovery"; -import * as taskExecutor from "../../src/task/executor"; -import type { AgentDefinition, SingleResult } from "../../src/task/types"; -import type { ToolSession } from "../../src/tools"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { runEvalAgent } from "@oh-my-pi/pi-coding-agent/eval/agent-bridge"; +import type { LocalProtocolOptions } from "@oh-my-pi/pi-coding-agent/internal-urls"; +import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp"; +import * as taskDiscovery from "@oh-my-pi/pi-coding-agent/task/discovery"; +import * as taskExecutor from "@oh-my-pi/pi-coding-agent/task/executor"; +import type { AgentDefinition, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; function createResult(): SingleResult { return { diff --git a/packages/coding-agent/test/export/html-template-developer.test.ts b/packages/coding-agent/test/export/html-template-developer.test.ts index b1f1916aa..6d15bbb4f 100644 --- a/packages/coding-agent/test/export/html-template-developer.test.ts +++ b/packages/coding-agent/test/export/html-template-developer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { TEMPLATE } from "../../src/export/html/template.generated"; +import { TEMPLATE } from "@oh-my-pi/pi-coding-agent/export/html/template.generated"; describe("HTML export template developer message support", () => { it("renders developer-role messages in the main feed", () => { diff --git a/packages/coding-agent/test/export/html-template-script-substitution.test.ts b/packages/coding-agent/test/export/html-template-script-substitution.test.ts index f4446ff73..2c37cc1eb 100644 --- a/packages/coding-agent/test/export/html-template-script-substitution.test.ts +++ b/packages/coding-agent/test/export/html-template-script-substitution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { TEMPLATE } from "../../src/export/html/template.generated"; +import { TEMPLATE } from "@oh-my-pi/pi-coding-agent/export/html/template.generated"; // Regression: `String.prototype.replace(string, string)` treats `$'`, `$&`, // `$$`, `$n`, etc. as substitution patterns. The inlined `