From 6eda70aadabe21ea0e63f698d9d47bb3bf39d372 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 16:40:58 +0200 Subject: [PATCH 01/17] refactor: replaced abortableSleep with scheduler.wait and fetchWithRetry - Removed local `abortableSleep` in favour of Node's built-in `scheduler.wait` from `node:timers/promises`. - Consolidated per-provider retry/fetch loops into a shared `fetchWithRetry` utility in `packages/utils`. - Moved `extractHttpStatusFromError`, `isRetryableError`, and related helpers out of `packages/ai` into `packages/utils`. - Deleted `extractRetryDelay` in favour of `extractRetryHint` with unified header and body parsing. --- docs/tools/github.md | 2 +- packages/ai/src/provider-models/ollama.ts | 14 +- packages/ai/src/providers/anthropic.ts | 21 +- .../ai/src/providers/google-gemini-cli.ts | 145 ++------- .../ai/src/providers/google-gemini-headers.ts | 100 ------ packages/ai/src/providers/ollama.ts | 15 +- .../src/providers/openai-codex-responses.ts | 105 ++----- .../ai/src/providers/openai-completions.ts | 4 +- packages/ai/src/utils/http-inspector.ts | 4 +- packages/ai/src/utils/oauth/github-copilot.ts | 16 +- packages/ai/src/utils/oauth/kimi.ts | 7 +- packages/ai/src/utils/retry.ts | 138 +-------- packages/ai/test/copilot-retry.test.ts | 5 +- .../ai/test/google-gemini-cli-429.test.ts | 94 +++--- .../test/google-gemini-cli-alignment.test.ts | 2 +- .../coding-agent/src/session/agent-session.ts | 18 +- packages/coding-agent/src/tools/gh.ts | 13 +- .../src/web/search/providers/gemini.ts | 128 ++------ packages/utils/src/abortable.ts | 13 - packages/utils/src/fetch-retry.ts | 290 ++++++++++++++++++ packages/utils/src/index.ts | 3 +- 21 files changed, 470 insertions(+), 667 deletions(-) create mode 100644 packages/utils/src/fetch-retry.ts diff --git a/docs/tools/github.md b/docs/tools/github.md index 268cbfc6f..7cf753f4b 100644 --- a/docs/tools/github.md +++ b/docs/tools/github.md @@ -227,7 +227,7 @@ Watch flow: - `gh` interactive editor fallback is suppressed for `pr_create` by forcing either `--body-file` or `--body ""`. - `gh-renderer` provides compact headers for all ops and a custom live watch view for `run_watch`. - Background work / cancellation - - `run_watch` loops until success/failure and uses `abortableSleep()` between polls. + - `run_watch` loops until success/failure and uses `scheduler.wait()` between polls. - `GithubTool.execute()` is wrapped in `untilAborted()`; `git.github.run()` forwards the abort signal into `Bun.spawn()`. ## Limits & Caps diff --git a/packages/ai/src/provider-models/ollama.ts b/packages/ai/src/provider-models/ollama.ts index 1f3fa4586..c71fbf617 100644 --- a/packages/ai/src/provider-models/ollama.ts +++ b/packages/ai/src/provider-models/ollama.ts @@ -1,4 +1,4 @@ -import { abortableSleep } from "@oh-my-pi/pi-utils"; +import { fetchWithRetry } from "@oh-my-pi/pi-utils"; import type { ModelManagerOptions } from "../model-manager"; import { Effort } from "../model-thinking"; import type { ThinkingConfig } from "../types"; @@ -19,16 +19,7 @@ type OllamaShowResponse = { model_info?: Record; }; -const MODEL_RETRY_DELAYS_MS = [2_000, 5_000, 10_000]; - -async function fetchWithRetry(url: string, init: RequestInit): Promise { - for (let attempt = 0; attempt < MODEL_RETRY_DELAYS_MS.length; attempt++) { - const response = await fetch(url, init); - if (response.ok || response.status < 500) return response; - await abortableSleep(MODEL_RETRY_DELAYS_MS[attempt]!); - } - return fetch(url, init); -} +const OLLAMA_RETRY_DELAYS_MS = [2_000, 5_000, 10_000]; function trimTrailingSlash(value: string): string { return value.endsWith("/") ? value.slice(0, -1) : value; @@ -109,6 +100,7 @@ export function ollamaCloudModelManagerOptions( const response = await fetchWithRetry(`${baseUrl}/api/tags`, { method: "GET", headers: createCloudHeaders(apiKey), + defaultDelayMs: OLLAMA_RETRY_DELAYS_MS, }); if (!response.ok) { throw new Error(`HTTP ${response.status} from ${baseUrl}/api/tags`); diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 5034c32a7..58d56b1a1 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1,5 +1,6 @@ import * as nodeCrypto from "node:crypto"; import * as fs from "node:fs"; +import { scheduler } from "node:timers/promises"; import * as tls from "node:tls"; import Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from "@anthropic-ai/sdk"; import type { @@ -8,7 +9,14 @@ import type { MessageParam, RawMessageStreamEvent, } from "@anthropic-ai/sdk/resources/messages"; -import { $env, abortableSleep, isEnoent, readSseEvents } from "@oh-my-pi/pi-utils"; +import { + $env, + extractHttpStatusFromError, + isEnoent, + isRetryableError, + isUnexpectedSocketCloseMessage, + readSseEvents, +} from "@oh-my-pi/pi-utils"; import { hasOpus47ApiRestrictions, mapEffortToAnthropicAdaptiveEffort } from "../model-thinking"; import { calculateCost } from "../models"; import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream"; @@ -48,12 +56,7 @@ import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTi import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse"; import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { notifyProviderResponse } from "../utils/provider-response"; -import { - extractHttpStatusFromError, - isCopilotRetryableError, - isRetryableError, - isUnexpectedSocketCloseMessage, -} from "../utils/retry"; +import { isCopilotTransientModelError } from "../utils/retry"; import { COMBINATOR_KEYS, NO_STRICT } from "../utils/schema"; import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug"; import { @@ -844,7 +847,7 @@ function isProviderRetryableStreamEnvelopeError(error: unknown): boolean { export function isProviderRetryableError(error: unknown, provider?: string): boolean { if (!(error instanceof Error)) return false; - if (provider === "github-copilot" && isCopilotRetryableError(error)) return true; + if (provider === "github-copilot" && isCopilotTransientModelError(error)) return true; const msg = error.message.toLowerCase(); if ( isUnexpectedSocketCloseMessage(msg) || @@ -1287,7 +1290,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } providerRetryAttempt++; const delayMs = PROVIDER_BASE_DELAY_MS * 2 ** (providerRetryAttempt - 1); - await abortableSleep(delayMs, options?.signal); + await scheduler.wait(delayMs, { signal: options?.signal }); output.content.length = 0; output.responseId = undefined; output.errorMessage = strictFallbackErrorMessage; diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 78fb939a6..d2de661ef 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -4,8 +4,9 @@ * Uses the Cloud Code Assist API endpoint to access Gemini and Claude models. */ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { scheduler } from "node:timers/promises"; import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "@google/genai"; -import { abortableSleep, readSseJson } from "@oh-my-pi/pi-utils"; +import { fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils"; import { calculateCost } from "../models"; import type { Api, @@ -23,14 +24,8 @@ import { AssistantMessageEventStream } from "../utils/event-stream"; import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector"; import { refreshAntigravityToken } from "../utils/oauth/google-antigravity"; import { refreshGoogleCloudToken } from "../utils/oauth/google-gemini-cli"; -import { extractHttpStatusFromError } from "../utils/retry"; import { sanitizeSchemaForCCA } from "../utils/schema"; -import { - ANTIGRAVITY_SYSTEM_INSTRUCTION, - extractRetryDelay, - getAntigravityUserAgent, - getGeminiCliHeaders, -} from "./google-gemini-headers"; +import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "./google-gemini-headers"; import { convertMessages, convertTools, @@ -72,7 +67,6 @@ const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_ export { ANTIGRAVITY_SYSTEM_INSTRUCTION, - extractRetryDelay, getAntigravityUserAgent, getGeminiCliHeaders, getGeminiCliUserAgent, @@ -104,16 +98,6 @@ function shouldInjectAntigravitySystemInstruction(modelId: string): boolean { return normalized.includes("claude") || normalized.includes("gemini-3-pro-high"); } -/** - * Check if an error is retryable (rate limit, server error, network error, etc.) - */ -function isRetryableError(status: number, errorText: string): boolean { - if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { - return true; - } - return /resource.?exhausted|rate.?limit|overloaded|service.?unavailable|other.?side.?closed/i.test(errorText); -} - /** * Extract a clean, user-friendly error message from Google API error response. * Parses JSON error responses and returns just the message field. @@ -366,109 +350,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( headers: requestHeaders, }; - // Fetch with retry logic for rate limits and transient errors - let response: Response | undefined; - let lastError: Error | undefined; - let requestUrl: string | undefined; - let rateLimitTimeSpent = 0; - - for (let attempt = 0; ; attempt++) { - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); - } - - try { - const endpoint = endpoints[Math.min(attempt, endpoints.length - 1)]; - requestUrl = `${endpoint}/v1internal:streamGenerateContent?alt=sse`; - response = await fetch(requestUrl, { - method: "POST", - headers: requestHeaders, - body: requestBodyJson, - signal: options?.signal, - }); - - if (response.ok) { - break; // Success, exit retry loop - } - - const errorText = await response.text(); - - // Handle 429 rate limits with time budget - if (response.status === 429) { - if (/quota|exhausted/i.test(errorText)) { - throw withHttpStatus( - new Error(`Cloud Code Assist API error (429): ${extractErrorMessage(errorText)}`), - 429, - ); - } - const serverDelay = extractRetryDelay(errorText, response); - if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) { - rateLimitTimeSpent += serverDelay; - await abortableSleep(serverDelay, options?.signal); - continue; - } - // Fallback: use exponential backoff if no server delay, up to MAX_RETRIES - if (!serverDelay && attempt < MAX_RETRIES) { - await abortableSleep(BASE_DELAY_MS * 2 ** attempt, options?.signal); - continue; - } - } else if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { - // Non-429 retryable errors use standard attempt cap - const serverDelay = extractRetryDelay(errorText, response); - const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt; - - // Check if server delay exceeds max allowed (default: 60s) for non-429 errors - const maxDelayMs = options?.maxRetryDelayMs ?? 60000; - if (maxDelayMs > 0 && serverDelay && serverDelay > maxDelayMs) { - const delaySeconds = Math.ceil(serverDelay / 1000); - throw withHttpStatus( - new Error( - `Server requested ${delaySeconds}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${extractErrorMessage(errorText)}`, - ), - response.status, - ); - } - - await abortableSleep(delayMs, options?.signal); - continue; - } - - // Not retryable or budget exceeded - throw withHttpStatus( - new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`), - response.status, - ); - } catch (error) { - // Check for abort - fetch throws AbortError, our code throws "Request was aborted" - if (error instanceof Error) { - if (error.name === "AbortError" || error.message === "Request was aborted") { - throw new Error("Request was aborted"); - } - } - - // HTTP responses are handled inside the try block. - // If we intentionally throw with status metadata, don't convert it into a network retry. - if (extractHttpStatusFromError(error) !== undefined) { - throw error; - } - // Extract detailed error message from fetch errors (Node includes cause) - lastError = error instanceof Error ? error : new Error(String(error)); - if (lastError.message === "fetch failed" && lastError.cause instanceof Error) { - lastError = new Error(`Network error: ${lastError.cause.message}`); - } - // Network errors are retryable - if (attempt < MAX_RETRIES) { - const delayMs = BASE_DELAY_MS * 2 ** attempt; - await abortableSleep(delayMs, options?.signal); - continue; - } - throw lastError; - } - } - - if (!response?.ok) { - throw lastError ?? new Error("Failed to get response after retries"); + const response = await fetchWithRetry( + attempt => `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`, + { + method: "POST", + headers: requestHeaders, + body: requestBodyJson, + signal: options?.signal, + maxAttempts: MAX_RETRIES + 1, + defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt, + maxDelayMs: options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS, + }, + ); + if (!response.ok) { + const errorText = await response.text(); + throw withHttpStatus( + new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`), + response.status, + ); } + const requestUrl = response.url; let started = false; const ensureStarted = () => { @@ -702,7 +603,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (emptyAttempt > 0) { const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1); try { - await abortableSleep(backoffMs, options?.signal); + await scheduler.wait(backoffMs, { signal: options?.signal }); } catch { // Normalize AbortError to expected message for consistent error handling throw new Error("Request was aborted"); diff --git a/packages/ai/src/providers/google-gemini-headers.ts b/packages/ai/src/providers/google-gemini-headers.ts index 73b5fbb28..64f9e3177 100644 --- a/packages/ai/src/providers/google-gemini-headers.ts +++ b/packages/ai/src/providers/google-gemini-headers.ts @@ -39,103 +39,3 @@ export let getAntigravityUserAgent = () => { getAntigravityUserAgent = () => userAgent; return userAgent; }; - -/** - * Extract retry delay from Gemini error response (in milliseconds). - * Checks headers first (Retry-After, x-ratelimit-reset, x-ratelimit-reset-after), - * then parses body patterns like: - * - "Your quota will reset after 39s" - * - "Your quota will reset after 18h31m10s" - * - "Please retry in Xs" or "Please retry in Xms" - * - "retryDelay": "34.074824224s" (JSON field) - */ -export function extractRetryDelay(errorText: string, response?: Response | Headers): number | undefined { - const normalizeDelay = (ms: number): number | undefined => (ms > 0 ? Math.ceil(ms + 1000) : undefined); - - const headers = response instanceof Headers ? response : response?.headers; - if (headers) { - const retryAfter = headers.get("retry-after"); - if (retryAfter) { - const retryAfterSeconds = Number(retryAfter); - if (Number.isFinite(retryAfterSeconds)) { - const delay = normalizeDelay(retryAfterSeconds * 1000); - if (delay !== undefined) { - return delay; - } - } - const retryAfterDate = new Date(retryAfter); - const retryAfterMs = retryAfterDate.getTime(); - if (!Number.isNaN(retryAfterMs)) { - const delay = normalizeDelay(retryAfterMs - Date.now()); - if (delay !== undefined) { - return delay; - } - } - } - - const rateLimitReset = headers.get("x-ratelimit-reset"); - if (rateLimitReset) { - const resetSeconds = Number.parseInt(rateLimitReset, 10); - if (!Number.isNaN(resetSeconds)) { - const delay = normalizeDelay(resetSeconds * 1000 - Date.now()); - if (delay !== undefined) { - return delay; - } - } - } - - const rateLimitResetAfter = headers.get("x-ratelimit-reset-after"); - if (rateLimitResetAfter) { - const resetAfterSeconds = Number(rateLimitResetAfter); - if (Number.isFinite(resetAfterSeconds)) { - const delay = normalizeDelay(resetAfterSeconds * 1000); - if (delay !== undefined) { - return delay; - } - } - } - } - - // Pattern 1: "Your quota will reset after ..." (formats: "18h31m10s", "10m15s", "6s", "39s") - const durationMatch = errorText.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i); - if (durationMatch) { - const hours = durationMatch[1] ? parseInt(durationMatch[1], 10) : 0; - const minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; - const seconds = parseFloat(durationMatch[3]); - if (!Number.isNaN(seconds)) { - const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000; - const delay = normalizeDelay(totalMs); - if (delay !== undefined) { - return delay; - } - } - } - - // Pattern 2: "Please retry in X[ms|s]" - const retryInMatch = errorText.match(/Please retry in ([0-9.]+)(ms|s)/i); - if (retryInMatch?.[1]) { - const value = parseFloat(retryInMatch[1]); - if (!Number.isNaN(value) && value > 0) { - const ms = retryInMatch[2].toLowerCase() === "ms" ? value : value * 1000; - const delay = normalizeDelay(ms); - if (delay !== undefined) { - return delay; - } - } - } - - // Pattern 3: "retryDelay": "34.074824224s" (JSON field in error details) - const retryDelayMatch = errorText.match(/"retryDelay":\s*"([0-9.]+)(ms|s)"/i); - if (retryDelayMatch?.[1]) { - const value = parseFloat(retryDelayMatch[1]); - if (!Number.isNaN(value) && value > 0) { - const ms = retryDelayMatch[2].toLowerCase() === "ms" ? value : value * 1000; - const delay = normalizeDelay(ms); - if (delay !== undefined) { - return delay; - } - } - } - - return undefined; -} diff --git a/packages/ai/src/providers/ollama.ts b/packages/ai/src/providers/ollama.ts index 22186b0cf..b4f987f53 100644 --- a/packages/ai/src/providers/ollama.ts +++ b/packages/ai/src/providers/ollama.ts @@ -1,4 +1,4 @@ -import { abortableSleep } from "@oh-my-pi/pi-utils"; +import { fetchWithRetry } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import { getEnvApiKey } from "../stream"; import type { @@ -334,16 +334,6 @@ function mapDoneReason(doneReason: string | undefined, output: AssistantMessage) const OLLAMA_RETRY_DELAYS_MS = [2_000, 5_000, 10_000]; -async function fetchChatWithRetry(url: string, init: RequestInit): Promise { - const signal = init.signal as AbortSignal | undefined; - for (let attempt = 0; attempt < OLLAMA_RETRY_DELAYS_MS.length; attempt++) { - const response = await fetch(url, init); - if (response.ok || response.status < 500) return response; - await abortableSleep(OLLAMA_RETRY_DELAYS_MS[attempt]!, signal); - } - return fetch(url, init); -} - export const streamOllama: StreamFunction<"ollama-chat"> = ( model: Model<"ollama-chat">, context: Context, @@ -377,7 +367,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = ( url: `${baseUrl}/api/chat`, body, }; - const response = await fetchChatWithRetry(`${baseUrl}/api/chat`, { + const response = await fetchWithRetry(`${baseUrl}/api/chat`, { method: "POST", headers: { ...model.headers, @@ -387,6 +377,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = ( }, body: JSON.stringify(body), signal: options.signal, + defaultDelayMs: OLLAMA_RETRY_DELAYS_MS, }); if (!response.ok) { throw new Error(`HTTP ${response.status} from ${baseUrl}/api/chat`); diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f56eaa2a3..3d4b44107 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -1,5 +1,6 @@ import * as os from "node:os"; -import { $env, $flag, abortableSleep, asRecord, logger, readSseJson, structuredCloneJSON } from "@oh-my-pi/pi-utils"; +import { scheduler } from "node:timers/promises"; +import { $env, $flag, asRecord, fetchWithRetry, logger, readSseJson, structuredCloneJSON } from "@oh-my-pi/pi-utils"; import type OpenAI from "openai"; import type { ResponseCustomToolCall, @@ -13,7 +14,6 @@ import type { } from "openai/resources/responses/responses"; import packageJson from "../../package.json" with { type: "json" }; import { calculateCost } from "../models"; -import { isUsageLimitError } from "../rate-limit-utils"; import { getEnvApiKey } from "../stream"; import { type Api, @@ -76,7 +76,6 @@ export interface OpenAICodexResponsesOptions extends StreamOptions { const CODEX_DEBUG = $flag("PI_CODEX_DEBUG"); const CODEX_MAX_RETRIES = 5; -const CODEX_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); const CODEX_RETRY_DELAY_MS = 500; const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000; const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300000; @@ -661,7 +660,9 @@ async function openInitialCodexEventStream( }); if (!activateFallback) { websocketRetries += 1; - await abortableSleep(getCodexWebSocketRetryDelayMs(websocketRetries), requestSetup.requestSignal); + await scheduler.wait(getCodexWebSocketRetryDelayMs(websocketRetries), { + signal: requestSetup.requestSignal, + }); continue; } break; @@ -1407,10 +1408,9 @@ async function tryReplayWebsocketFailureOverSse( if (!activateFallback) { runtime.websocketStreamRetries += 1; - await abortableSleep( - getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries), - context.requestSetup.requestSignal, - ); + await scheduler.wait(getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries), { + signal: context.requestSetup.requestSignal, + }); await reopenCodexWebSocketRuntimeStream(context, runtime, state); return true; } @@ -1461,7 +1461,9 @@ async function tryRetryCodexProviderError( runtime.sawTerminalEvent = false; resetOutputState(context.output); context.firstTokenTime = undefined; - await abortableSleep(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, context.requestSetup.requestSignal); + await scheduler.wait(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, { + signal: context.requestSetup.requestSignal, + }); if (runtime.transport === "websocket" && websocketState) { await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState); @@ -2194,15 +2196,15 @@ async function openCodexSseEventStream( sentTurnStateHeader: headers.has(X_CODEX_TURN_STATE_HEADER), sentModelsEtagHeader: headers.has(X_MODELS_ETAG_HEADER), }); - const response = await fetchWithRetry( - url, - { - method: "POST", - headers, - body: JSON.stringify(body), - }, + const response = await fetchWithRetry(url, { + method: "POST", + headers, + body: JSON.stringify(body), signal, - ); + maxAttempts: CODEX_MAX_RETRIES + 1, + defaultDelayMs: attempt => CODEX_RETRY_DELAY_MS * (attempt + 1), + maxDelayMs: CODEX_RATE_LIMIT_BUDGET_MS, + }); logCodexDebug("codex response", { url: response.url, status: response.status, @@ -2292,75 +2294,6 @@ function logCodexDebug(message: string, details?: Record): void logger.debug(`[codex] ${message}`, details ?? {}); } -function getRetryDelayMs( - response: Response | null, - attempt: number, - errorBody?: string, -): { delay: number; serverProvided: boolean } { - const retryAfter = response?.headers?.get("retry-after") || null; - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) { - return { delay: Math.max(0, seconds * 1000), serverProvided: true }; - } - const parsedDate = Date.parse(retryAfter); - if (!Number.isNaN(parsedDate)) { - return { delay: Math.max(0, parsedDate - Date.now()), serverProvided: true }; - } - } - if (errorBody) { - const msMatch = /try again in\s+(\d+(?:\.\d+)?)\s*ms/i.exec(errorBody); - if (msMatch) { - const ms = Number(msMatch[1]); - if (Number.isFinite(ms)) return { delay: Math.max(ms, 100), serverProvided: true }; - } - const sMatch = /try again in\s+(\d+(?:\.\d+)?)\s*s(?:ec)?/i.exec(errorBody); - if (sMatch) { - const seconds = Number(sMatch[1]); - if (Number.isFinite(seconds)) return { delay: Math.max(seconds * 1000, 100), serverProvided: true }; - } - } - return { delay: CODEX_RETRY_DELAY_MS * (attempt + 1), serverProvided: false }; -} - -async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal): Promise { - let attempt = 0; - let rateLimitTimeSpent = 0; - while (true) { - try { - const response = await fetch(url, { ...init, signal: signal ?? init.signal }); - if (!CODEX_RETRYABLE_STATUS.has(response.status)) { - return response; - } - if (signal?.aborted) return response; - const errorBody = await response.clone().text(); - // Usage-limit errors are persistent (account allocation exhausted) — retrying with the - // same credential is futile. Bail out immediately so the error propagates to the agent - // session layer where credential switching happens. - if (response.status === 429 && isUsageLimitError(errorBody)) { - return response; - } - const { delay, serverProvided } = getRetryDelayMs(response, attempt, errorBody); - if (response.status === 429 && serverProvided) { - if (rateLimitTimeSpent + delay > CODEX_RATE_LIMIT_BUDGET_MS) { - return response; - } - rateLimitTimeSpent += delay; - } else if (attempt >= CODEX_MAX_RETRIES) { - return response; - } - await abortableSleep(delay, signal); - } catch (error) { - if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) { - throw error; - } - const delay = CODEX_RETRY_DELAY_MS * (attempt + 1); - await abortableSleep(delay, signal); - } - attempt += 1; - } -} - function redactHeaders(headers: Headers): Record { const redacted: Record = {}; for (const [key, value] of headers.entries()) { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index c4d1778d4..e41eae2df 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1,4 +1,4 @@ -import { $env } from "@oh-my-pi/pi-utils"; +import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import OpenAI from "openai"; import type { ChatCompletionAssistantMessageParam, @@ -54,7 +54,7 @@ import { parseStreamingJson } 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, extractHttpStatusFromError } from "../utils/retry"; +import { callWithCopilotModelRetry } from "../utils/retry"; import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; import { wrapFetchForSseDebug } from "../utils/sse-debug"; import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice"; diff --git a/packages/ai/src/utils/http-inspector.ts b/packages/ai/src/utils/http-inspector.ts index 525c6a3a7..d08db8a42 100644 --- a/packages/ai/src/utils/http-inspector.ts +++ b/packages/ai/src/utils/http-inspector.ts @@ -1,6 +1,6 @@ import * as path from "node:path"; -import { getLogsDir } from "@oh-my-pi/pi-utils"; -import { extractHttpStatusFromError, isCopilotTransientModelError } from "./retry.js"; +import { extractHttpStatusFromError, getLogsDir } from "@oh-my-pi/pi-utils"; +import { isCopilotTransientModelError } from "./retry.js"; import { formatErrorMessageWithRetryAfter } from "./retry-after.js"; export type RawHttpRequestDump = { diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index f0d226a90..7430846ff 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -1,7 +1,7 @@ /** * GitHub Copilot OAuth flow (opencode OAuth app) */ -import { abortableSleep } from "@oh-my-pi/pi-utils"; +import { scheduler } from "node:timers/promises"; import { getBundledModels } from "../../models"; import type { OAuthCredentials } from "./types"; @@ -159,14 +159,6 @@ async function startDeviceFlow(domain: string): Promise { }; } -async function sleepForGitHubAccessTokenPoll(ms: number, signal?: AbortSignal): Promise { - try { - await abortableSleep(ms, signal); - } catch { - throw new Error("Login cancelled"); - } -} - async function pollForGitHubAccessToken( domain: string, deviceCode: string, @@ -187,7 +179,11 @@ async function pollForGitHubAccessToken( const remainingMs = deadline - Date.now(); const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); - await sleepForGitHubAccessTokenPoll(waitMs, signal); + try { + await scheduler.wait(waitMs, { signal }); + } catch { + throw new Error("Login cancelled"); + } const raw = await fetchJson(urls.accessTokenUrl, { method: "POST", diff --git a/packages/ai/src/utils/oauth/kimi.ts b/packages/ai/src/utils/oauth/kimi.ts index c1cc4ee44..34caa7df4 100644 --- a/packages/ai/src/utils/oauth/kimi.ts +++ b/packages/ai/src/utils/oauth/kimi.ts @@ -6,7 +6,8 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { $env, abortableSleep, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; +import { scheduler } from "node:timers/promises"; +import { $env, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import packageJson from "../../../package.json" with { type: "json" }; import type { OAuthController, OAuthCredentials } from "./types"; @@ -185,7 +186,7 @@ async function pollForToken( const error = payload.error; if (error === "authorization_pending") { - await abortableSleep(waitMs, signal); + await scheduler.wait(waitMs, { signal }); continue; } @@ -193,7 +194,7 @@ async function pollForToken( waitMs += 5000; const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined; if (retryAfter && retryAfter > waitMs) waitMs = retryAfter; - await abortableSleep(waitMs, signal); + await scheduler.wait(waitMs, { signal }); continue; } diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index ec7fdc59a..1a04263f6 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -1,104 +1,5 @@ -import { abortableSleep } from "@oh-my-pi/pi-utils"; - -type ErrorLike = { - message?: string; - name?: string; - status?: number; - statusCode?: number; - response?: { status?: number }; - cause?: unknown; - code?: unknown; - error?: { code?: unknown } | null; -}; - -export function isUnexpectedSocketCloseMessage(message: string): boolean { - return /\b(?:the\s+)?socket connection (?:was )?closed unexpectedly\b/i.test(message); -} - -const TRANSIENT_MESSAGE_PATTERN = - /overloaded|rate.?limit|too many requests|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|stream stall/i; - -const VALIDATION_MESSAGE_PATTERN = - /invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i; - -/** - * Identify errors that should be retried (timeouts, 5xx, 408, 429, transient network failures). - */ -export function isRetryableError(error: unknown): boolean { - const info = error as ErrorLike | null; - const message = info?.message ?? ""; - const name = info?.name ?? ""; - if (name === "AbortError" || /timeout|timed out|aborted/i.test(message)) return true; - - const status = extractHttpStatusFromError(error); - if (status !== undefined) { - if (status >= 500) return true; - if (status === 408 || status === 429) return true; - if (status >= 400 && status < 500) return false; - } - - if (VALIDATION_MESSAGE_PATTERN.test(message)) return false; - - return isUnexpectedSocketCloseMessage(message) || TRANSIENT_MESSAGE_PATTERN.test(message); -} - -export function extractHttpStatusFromError(error: unknown): number | undefined { - return extractHttpStatusFromErrorInternal(error, 0); -} - -function extractHttpStatusFromErrorInternal(error: unknown, depth: number): number | undefined { - if (!error || typeof error !== "object" || depth > 2) return undefined; - const info = error as ErrorLike; - const rawStatus = - info.status ?? - info.statusCode ?? - (info.response && typeof info.response === "object" ? info.response.status : undefined); - - let status: number | undefined; - if (typeof rawStatus === "number" && Number.isFinite(rawStatus)) { - status = rawStatus; - } else if (typeof rawStatus === "string") { - const parsed = Number(rawStatus); - if (Number.isFinite(parsed)) { - status = parsed; - } - } - - if (status !== undefined && status >= 100 && status <= 599) { - return status; - } - - if (info.message) { - const extracted = extractStatusFromMessage(info.message); - if (extracted !== undefined) return extracted; - } - - if (info.cause) { - return extractHttpStatusFromErrorInternal(info.cause, depth + 1); - } - - return undefined; -} - -function extractStatusFromMessage(message: string): number | undefined { - const patterns = [ - /error\s*\((\d{3})\)/i, - /status\s*[:=]?\s*(\d{3})/i, - /\bhttp\s*(\d{3})\b/i, - /\b(\d{3})\s*(?:status|error)\b/i, - ]; - - for (const pattern of patterns) { - const match = pattern.exec(message); - if (!match) continue; - const value = Number(match[1]); - if (Number.isFinite(value) && value >= 100 && value <= 599) { - return value; - } - } - - return undefined; -} +import { scheduler } from "node:timers/promises"; +import { extractHttpStatusFromError, isRetryableError } from "@oh-my-pi/pi-utils"; /** * GitHub Copilot intermittently rejects preview models (gpt-5.3-codex, @@ -115,33 +16,10 @@ function extractStatusFromMessage(message: string): number | undefined { */ export function isCopilotTransientModelError(error: unknown): boolean { if (extractHttpStatusFromError(error) !== 400) return false; - return extractErrorCode(error) === "model_not_supported"; -} - -export function isCopilotRetryableError(error: unknown): boolean { - if (isCopilotTransientModelError(error)) return true; - - const status = extractHttpStatusFromError(error); - if (status !== undefined) { - return status >= 500 || status === 408 || status === 429; - } - - const message = error instanceof Error ? error.message : String(error); - return ( - isUnexpectedSocketCloseMessage(message) || - /request was aborted|aborted|fetch failed|network error|timed?\s*out|timeout|other side closed/i.test(message) - ); -} - -function extractErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== "object") return undefined; - const info = error as ErrorLike; - if (typeof info.code === "string") return info.code; - const nested = info.error; - if (nested && typeof nested === "object" && typeof nested.code === "string") { - return nested.code; - } - return undefined; + if (!error || typeof error !== "object") return false; + const info = error as { code?: unknown; error?: { code?: unknown } | null }; + const code = typeof info.code === "string" ? info.code : info.error?.code; + return code === "model_not_supported"; } const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 3; @@ -166,9 +44,9 @@ export async function callWithCopilotModelRetry( return await fn(); } catch (error) { lastError = error; - if (!isCopilotRetryableError(error)) throw error; + if (!isCopilotTransientModelError(error) && !isRetryableError(error)) throw error; if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break; - await abortableSleep(COPILOT_MODEL_RETRY_BASE_DELAY_MS * (attempt + 1), options.signal); + await scheduler.wait(COPILOT_MODEL_RETRY_BASE_DELAY_MS * (attempt + 1), { signal: options.signal }); } } throw lastError; diff --git a/packages/ai/test/copilot-retry.test.ts b/packages/ai/test/copilot-retry.test.ts index 580f8156a..28881b14d 100644 --- a/packages/ai/test/copilot-retry.test.ts +++ b/packages/ai/test/copilot-retry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { callWithCopilotModelRetry, isCopilotTransientModelError, isRetryableError } from "@oh-my-pi/pi-ai/utils/retry"; +import { callWithCopilotModelRetry, isCopilotTransientModelError } from "@oh-my-pi/pi-ai/utils/retry"; +import { isRetryableError } from "@oh-my-pi/pi-utils"; type ErrorShape = { status: number; code?: string; error?: { code?: string; message?: string }; message: string }; @@ -132,7 +133,7 @@ describe("callWithCopilotModelRetry", () => { { provider: "github-copilot", signal: controller.signal }, ), ).rejects.toBeDefined(); - // fn runs once; abortableSleep rejects before a second attempt. + // fn runs once; scheduler.wait rejects before a second attempt. expect(calls).toBe(1); }); }); diff --git a/packages/ai/test/google-gemini-cli-429.test.ts b/packages/ai/test/google-gemini-cli-429.test.ts index 644a1b202..934e6218d 100644 --- a/packages/ai/test/google-gemini-cli-429.test.ts +++ b/packages/ai/test/google-gemini-cli-429.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { extractRetryDelay } from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; +import { extractRetryHint } from "@oh-my-pi/pi-utils"; // The fail-fast regex used inside the provider to distinguish "known quota errors" (throw immediately) // from "ambiguous 429s" (retry up to RATE_LIMIT_BUDGET_MS). @@ -7,102 +7,84 @@ import { extractRetryDelay } from "@oh-my-pi/pi-ai/providers/google-gemini-cli"; const FAIL_FAST_RE = /quota|exhausted/i; const shouldFailFast = (errorText: string) => FAIL_FAST_RE.test(errorText); -// normalizeDelay adds a 1 second buffer and rounds up: -// normalizeDelay(ms) = Math.ceil(ms + 1000) -// So all extractRetryDelay results are at least 1 second more than the raw delay. - describe("google-gemini-cli 429 fail-fast detection", () => { - it("fails fast on 'Quota exceeded'", () => { - expect(shouldFailFast("Quota exceeded")).toBe(true); + it("fails fast on 'Quota exceeded' messages", () => { + expect(shouldFailFast("Quota exceeded for project")).toBe(true); }); - it("fails fast on 'Resource has been exhausted'", () => { + it("fails fast on 'exhausted' messages", () => { expect(shouldFailFast("Resource has been exhausted")).toBe(true); }); - it("retries (does NOT fail fast) on 'Too many requests'", () => { - expect(shouldFailFast("Too many requests")).toBe(false); + it("does not fail fast on ambiguous 429 ('Please retry in 5s')", () => { + expect(shouldFailFast("Please retry in 5s")).toBe(false); }); - it("retries (does NOT fail fast) on 'rate limit exceeded'", () => { - expect(shouldFailFast("rate limit exceeded")).toBe(false); + it("does not fail fast on generic rate-limit text", () => { + expect(shouldFailFast("Rate limit exceeded, please slow down")).toBe(false); }); - it("fails fast on per-minute quota message", () => { - expect(shouldFailFast("Exceeded per minute quota for generateContent")).toBe(true); + it("matches case-insensitively", () => { + expect(shouldFailFast("QUOTA EXCEEDED")).toBe(true); + expect(shouldFailFast("Resource Has Been Exhausted")).toBe(true); }); - it("does NOT fail fast on generic 429 with empty body", () => { + it("does not fail fast on empty error", () => { expect(shouldFailFast("")).toBe(false); }); - - it("does NOT fail fast on unknown 429 message", () => { - expect(shouldFailFast("Internal server error")).toBe(false); - }); - - it("is case-insensitive for all variants", () => { - expect(shouldFailFast("QUOTA EXHAUSTED")).toBe(true); - expect(shouldFailFast("Rate Limit Exceeded")).toBe(false); - expect(shouldFailFast("TOO MANY REQUESTS")).toBe(false); - }); }); -describe("extractRetryDelay – header parsing", () => { - it("reads retry-after header as seconds → normalizeDelay(5000) = 6000", () => { +describe("extractRetryHint – header parsing", () => { + it("reads retry-after header as seconds", () => { const headers = new Headers({ "retry-after": "5" }); - // normalizeDelay(5 * 1000) = Math.ceil(5000 + 1000) = 6000 - expect(extractRetryDelay("", headers)).toBe(6000); + expect(extractRetryHint(headers)).toBe(5_000); }); - it("reads x-ratelimit-reset-after header as seconds → normalizeDelay(30000) = 31000", () => { + it("reads x-ratelimit-reset-after header as seconds", () => { const headers = new Headers({ "x-ratelimit-reset-after": "30" }); - // normalizeDelay(30 * 1000) = Math.ceil(30000 + 1000) = 31000 - expect(extractRetryDelay("", headers)).toBe(31000); + expect(extractRetryHint(headers)).toBe(30_000); }); it("prefers retry-after over x-ratelimit-reset-after when both are present", () => { const headers = new Headers({ "retry-after": "5", "x-ratelimit-reset-after": "30" }); - // retry-after is checked first → 6000 - expect(extractRetryDelay("", headers)).toBe(6000); + expect(extractRetryHint(headers)).toBe(5_000); }); }); -describe("extractRetryDelay – body text parsing", () => { - it("parses 'retryDelay' JSON field in seconds → normalizeDelay(3000) = 4000", () => { - // Regex: /"retryDelay":\s*"([0-9.]+)(ms|s)"/i - const body = '"retryDelay": "3s"'; - // normalizeDelay(3 * 1000) = Math.ceil(3000 + 1000) = 4000 - expect(extractRetryDelay(body)).toBe(4000); +describe("extractRetryHint – body text parsing", () => { + it("parses 'retryDelay' JSON field in seconds", () => { + expect(extractRetryHint(undefined, '"retryDelay": "3s"')).toBe(3_000); }); - it("parses 'retryDelay' JSON field in milliseconds → normalizeDelay(500) = 1500", () => { - const body = '"retryDelay": "500ms"'; - // normalizeDelay(500) = Math.ceil(500 + 1000) = 1500 - expect(extractRetryDelay(body)).toBe(1500); + it("parses 'retryDelay' JSON field in milliseconds", () => { + expect(extractRetryHint(undefined, '"retryDelay": "500ms"')).toBe(500); }); - it("parses 'Please retry in Xs' pattern → normalizeDelay(5000) = 6000", () => { - // Regex: /Please retry in ([0-9.]+)(ms|s)/i - expect(extractRetryDelay("Please retry in 5s")).toBe(6000); + it("parses 'Please retry in Xs' pattern", () => { + expect(extractRetryHint(undefined, "Please retry in 5s")).toBe(5_000); }); - it("parses 'quota will reset after Xs' simple duration → normalizeDelay(39000) = 40000", () => { - // Regex: /reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i - // totalMs = 39 * 1000 = 39000 → normalizeDelay = 40000 - expect(extractRetryDelay("Your quota will reset after 39s")).toBe(40000); + it("parses 'quota will reset after Xs' simple duration", () => { + expect(extractRetryHint(undefined, "Your quota will reset after 39s")).toBe(39_000); }); it("parses compound duration 'reset after 1h30m10s'", () => { - // (1*3600 + 30*60 + 10) * 1000 = 5 410 000 ms - // normalizeDelay(5410000) = Math.ceil(5410000 + 1000) = 5411000 - expect(extractRetryDelay("Your quota will reset after 1h30m10s")).toBe(5411000); + expect(extractRetryHint(undefined, "Your quota will reset after 1h30m10s")).toBe(5_410_000); + }); + + it("parses Codex-style 'try again in Xms'", () => { + expect(extractRetryHint(undefined, "try again in 250ms")).toBe(250); + }); + + it("parses Codex-style 'try again in Xs'", () => { + expect(extractRetryHint(undefined, "try again in 12s")).toBe(12_000); }); it("returns undefined when body contains no recognised delay pattern", () => { - expect(extractRetryDelay("Quota exceeded, please try again later")).toBeUndefined(); + expect(extractRetryHint(undefined, "Quota exceeded, please try again later")).toBeUndefined(); }); it("returns undefined for empty error string and no headers", () => { - expect(extractRetryDelay("")).toBeUndefined(); + expect(extractRetryHint(undefined, "")).toBeUndefined(); }); }); diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index 5311f9c94..213e02caa 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -244,7 +244,7 @@ describe("Google Gemini CLI alignment", () => { const result = await stream.result(); expect(fetchCalls).toBe(1); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("Server requested 121s retry delay (max: 1s)"); + expect(result.errorMessage).toContain("Cloud Code Assist API error (503)"); }); }); }); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ccb06eb3b..f61f35a13 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -16,7 +16,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; - +import { scheduler } from "node:timers/promises"; import { type Agent, AgentBusyError, @@ -47,14 +47,20 @@ import { calculateRateLimitBackoffMs, getSupportedEfforts, isContextOverflow, - isUnexpectedSocketCloseMessage, isUsageLimitError, modelsAreEqual, parseRateLimitReason, streamSimple, } from "@oh-my-pi/pi-ai"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; -import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils"; +import { + getAgentDbPath, + isEnoent, + isUnexpectedSocketCloseMessage, + logger, + prompt, + Snowflake, +} from "@oh-my-pi/pi-utils"; import { type AsyncJob, AsyncJobManager } from "../async"; import type { Rule } from "../capability/rule"; import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry"; @@ -1446,7 +1452,7 @@ export class AgentSession { const scheduled = (async () => { if (delayMs > 0) { try { - await abortableSleep(delayMs, signal); + await scheduler.wait(delayMs, { signal }); } catch { return; } @@ -5976,7 +5982,7 @@ export class AgentSession { error: message, model: `${candidate.provider}/${candidate.id}`, }); - await abortableSleep(delayMs, autoCompactionSignal); + await scheduler.wait(delayMs, { signal: autoCompactionSignal }); } } @@ -6502,7 +6508,7 @@ export class AgentSession { this.#retryAbortController?.abort(); this.#retryAbortController = retryAbortController; try { - await abortableSleep(delayMs, retryAbortController.signal); + await scheduler.wait(delayMs, { signal: retryAbortController.signal }); } catch { if (this.#retryAbortController !== retryAbortController) { return false; diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 88ca0fcd9..b6f587a61 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -1,9 +1,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { scheduler } from "node:timers/promises"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; -import { abortableSleep, getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; +import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { Settings } from "../config/settings"; import githubDescription from "../prompts/tools/github.md" with { type: "text" }; @@ -3400,7 +3401,7 @@ async function executeRunWatch( note, }), }); - await abortableSleep(graceSeconds * 1000, signal); + await scheduler.wait(graceSeconds * 1000, { signal }); run = await fetchRunSnapshot(session.cwd, repo, runId, signal); } @@ -3435,7 +3436,7 @@ async function executeRunWatch( return buildTextResult(formatRunWatchResult(repo, run, [], tail), run.url, finalDetails); } - await abortableSleep(intervalSeconds * 1000, signal); + await scheduler.wait(intervalSeconds * 1000, { signal }); } } @@ -3477,7 +3478,7 @@ async function executeRunWatch( note, }), }); - await abortableSleep(graceSeconds * 1000, signal); + await scheduler.wait(graceSeconds * 1000, { signal }); runs = await fetchRunsForCommit(session.cwd, repo, headSha, branch, signal); } @@ -3533,11 +3534,11 @@ async function executeRunWatch( note, }), }); - await abortableSleep(intervalSeconds * 1000, signal); + await scheduler.wait(intervalSeconds * 1000, { signal }); continue; } settledSuccessSignature = undefined; - await abortableSleep(intervalSeconds * 1000, signal); + await scheduler.wait(intervalSeconds * 1000, { signal }); } } diff --git a/packages/coding-agent/src/web/search/providers/gemini.ts b/packages/coding-agent/src/web/search/providers/gemini.ts index 82c44913f..decaf98e1 100644 --- a/packages/coding-agent/src/web/search/providers/gemini.ts +++ b/packages/coding-agent/src/web/search/providers/gemini.ts @@ -5,15 +5,11 @@ * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity". * Returns synthesized answers with citations and source metadata from grounding chunks. */ -import { - ANTIGRAVITY_SYSTEM_INSTRUCTION, - extractRetryDelay, - getAntigravityUserAgent, - getGeminiCliHeaders, -} from "@oh-my-pi/pi-ai"; +import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "@oh-my-pi/pi-ai"; import { refreshAntigravityToken } from "@oh-my-pi/pi-ai/utils/oauth/google-antigravity"; import { refreshGoogleCloudToken } from "@oh-my-pi/pi-ai/utils/oauth/google-gemini-cli"; -import { getAgentDbPath } from "@oh-my-pi/pi-utils"; +import { fetchWithRetry, getAgentDbPath } from "@oh-my-pi/pi-utils"; + import { AgentStorage } from "../../../session/agent-storage"; import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types"; import { SearchProviderError } from "../../../web/search/types"; @@ -303,96 +299,40 @@ async function callGeminiSearch( } (requestBody.request as Record).generationConfig = generationConfig; } - let response: Response | undefined; - let rateLimitTimeSpent = 0; - let lastError: Error | undefined; + const buildInit = (): RequestInit => ({ + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...headers, + }, + body: JSON.stringify(requestBody), + }); + const urlFor = (attempt: number) => + `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`; - for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) { - const url = `${endpoints[endpointIndex]}/v1internal:streamGenerateContent?alt=sse`; + let response = await fetchWithRetry(urlFor, { + ...buildInit(), + maxAttempts: MAX_RETRIES + 1, + defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt, + maxDelayMs: RATE_LIMIT_BUDGET_MS, + }); - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "Content-Type": "application/json", - Accept: "text/event-stream", - ...headers, - }, - body: JSON.stringify(requestBody), - }); - } catch (error) { - if (attempt < MAX_RETRIES) { - await Bun.sleep(BASE_DELAY_MS * 2 ** attempt); - continue; - } - - if (auth.isAntigravity && endpointIndex < endpoints.length - 1) { - break; - } - - throw error; - } - - if (response.ok) { - break; - } - - const errorText = await response.text(); - const canRefreshAuth = - response.status === 401 || - response.status === 403 || - (response.status === 400 && - /api key not valid|invalid credentials|invalid authentication/i.test(errorText)); - if (canRefreshAuth && attempt === 0 && (await refreshGeminiAuth(auth))) { - continue; - } - const isRetryableStatus = - response.status === 429 || - response.status === 500 || - response.status === 502 || - response.status === 503 || - response.status === 504; - - if (isRetryableStatus && attempt < MAX_RETRIES) { - const serverDelay = extractRetryDelay(errorText, response); - if (response.status === 429) { - if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) { - rateLimitTimeSpent += serverDelay; - await Bun.sleep(serverDelay); - continue; - } - if (!serverDelay) { - await Bun.sleep(BASE_DELAY_MS * 2 ** attempt); - continue; - } - } else { - await Bun.sleep(serverDelay ?? BASE_DELAY_MS * 2 ** attempt); - continue; - } - } - - lastError = new SearchProviderError( - "gemini", - `Gemini Cloud Code API error (${response.status}): ${errorText}`, - response.status, - ); - - if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) { - break; - } - - throw lastError; + if (!response.ok) { + const errorText = await response.clone().text(); + const canRefreshAuth = + response.status === 401 || + response.status === 403 || + (response.status === 400 && /api key not valid|invalid credentials|invalid authentication/i.test(errorText)); + if (canRefreshAuth && (await refreshGeminiAuth(auth))) { + response = await fetchWithRetry(urlFor, { + ...buildInit(), + maxAttempts: MAX_RETRIES + 1, + defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt, + maxDelayMs: RATE_LIMIT_BUDGET_MS, + }); } - - if (response?.ok) { - break; - } - } - - if (!response) { - throw new SearchProviderError("gemini", "Gemini API request failed", 500); } if (!response.ok) { diff --git a/packages/utils/src/abortable.ts b/packages/utils/src/abortable.ts index 0b3e727cf..49554b0fc 100644 --- a/packages/utils/src/abortable.ts +++ b/packages/utils/src/abortable.ts @@ -10,19 +10,6 @@ export class AbortError extends Error { } } -/** - * Sleep for a given number of milliseconds, respecting abort signal. - * - * Uses setTimeout (not Bun.sleep) so that vitest fake timers can intercept it in tests. - */ -export function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return untilAborted(signal, () => { - const { promise, resolve } = Promise.withResolvers(); - setTimeout(resolve, ms); - return promise; - }); -} - /** * Creates an abortable stream from a given stream and signal. * diff --git a/packages/utils/src/fetch-retry.ts b/packages/utils/src/fetch-retry.ts new file mode 100644 index 000000000..35814eedc --- /dev/null +++ b/packages/utils/src/fetch-retry.ts @@ -0,0 +1,290 @@ +import { scheduler } from "node:timers/promises"; + +// "reset after 1h2m3s" / "10m15s" / "39s" +const QUOTA_RESET_PATTERN = /reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i; +// "Please retry in 250ms" / "Please retry in 12s" +const PLEASE_RETRY_PATTERN = /Please retry in ([0-9.]+)(ms|s)/i; +// JSON field: "retryDelay": "34.074824224s" +const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i; +// "try again in 250ms" / "try again in 12s" / "try again in 12sec" +const TRY_AGAIN_PATTERN = /try again in\s+(\d+(?:\.\d+)?)\s*(ms|s)(?:ec)?/i; + +/** + * Server-suggested retry delay extraction. Merges the patterns historically used + * by the OpenAI Codex and Google Gemini retry helpers. + * + * Header sources (checked in order): + * - `Retry-After` (numeric seconds, or HTTP date) + * - `x-ratelimit-reset` (Unix epoch seconds) + * - `x-ratelimit-reset-after` (seconds) + * + * Body patterns: + * - `Your quota will reset after 18h31m10s` / `10m15s` / `39s` + * - `Please retry in 250ms` / `Please retry in 12s` + * - `"retryDelay": "34.074824224s"` (JSON error detail field) + * - `try again in 250ms` / `try again in 12s` / `try again in 12sec` + * + * Returns `undefined` if no signal is found. + */ +export function extractRetryHint(source: Response | Headers | null | undefined, body?: string): number | undefined { + const headers = source instanceof Headers ? source : (source?.headers ?? undefined); + if (headers) { + const retryAfter = headers.get("retry-after"); + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const parsedDate = Date.parse(retryAfter); + if (!Number.isNaN(parsedDate)) return Math.max(0, parsedDate - Date.now()); + } + const rateLimitReset = headers.get("x-ratelimit-reset"); + if (rateLimitReset) { + const resetSeconds = Number.parseInt(rateLimitReset, 10); + if (!Number.isNaN(resetSeconds)) { + const delta = resetSeconds * 1000 - Date.now(); + if (delta > 0) return delta; + } + } + const rateLimitResetAfter = headers.get("x-ratelimit-reset-after"); + if (rateLimitResetAfter) { + const seconds = Number(rateLimitResetAfter); + if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000; + } + } + + if (!body) return undefined; + + const quotaMatch = QUOTA_RESET_PATTERN.exec(body); + if (quotaMatch) { + const hours = quotaMatch[1] ? Number.parseInt(quotaMatch[1], 10) : 0; + const minutes = quotaMatch[2] ? Number.parseInt(quotaMatch[2], 10) : 0; + const seconds = Number.parseFloat(quotaMatch[3]!); + if (!Number.isNaN(seconds)) { + const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000; + if (totalMs > 0) return totalMs; + } + } + for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) { + const match = pattern.exec(body); + if (match?.[1]) { + const value = Number.parseFloat(match[1]); + if (Number.isFinite(value) && value > 0) { + return match[2]!.toLowerCase() === "ms" ? value : value * 1000; + } + } + } + return undefined; +} + +export interface FetchWithRetryOptions extends RequestInit { + /** Total fetch attempts (initial + retries). Default `5`. */ + maxAttempts?: number; + /** + * Per-delay cap. Server-provided `Retry-After` hints exceeding this return + * the current response immediately — caller deals with the `!response.ok`. + * Default `60_000`. + */ + maxDelayMs?: number; + /** + * Fallback delay schedule when no server hint is present. Number, array + * (indexed by attempt, clamped to last), or function. Default exponential + * `500ms * 2 ** attempt` capped at `maxDelayMs`. + */ + defaultDelayMs?: number | readonly number[] | ((attempt: number) => number); + /** + * Optional per-attempt overlay merged into the base `RequestInit` each try. + * Headers from the overlay shallow-merge over the base. Useful for auth + * token refresh or user-agent rotation. + */ + prepareInit?: (attempt: number) => RequestInit | Promise; +} + +const DEFAULT_MAX_DELAY_MS = 60_000; +const DEFAULT_MAX_ATTEMPTS = 5; + +/** + * Fetch with bounded retries and sensible defaults. Retries on any + * `isRetryableStatus` (5xx, 408, 429) and on transient network errors. Server + * `Retry-After`/quota hints are honoured up to `maxDelayMs`; a hint that exceeds + * the cap returns the current response so the caller can fail fast. Aborts on + * `init.signal` propagate as `"Request was aborted"`. + * + * The caller is responsible for inspecting `!response.ok` once the call returns. + */ +export async function fetchWithRetry( + url: string | URL | ((attempt: number) => string | URL), + options: FetchWithRetryOptions = {}, +): Promise { + const { + maxAttempts = DEFAULT_MAX_ATTEMPTS, + maxDelayMs = DEFAULT_MAX_DELAY_MS, + defaultDelayMs, + prepareInit, + ...baseInit + } = options; + const signal = baseInit.signal as AbortSignal | undefined; + + for (let attempt = 0; ; attempt++) { + if (signal?.aborted) throw new Error("Request was aborted"); + const requestUrl = typeof url === "function" ? url(attempt) : url; + const init = prepareInit ? mergeInit(baseInit, await prepareInit(attempt)) : baseInit; + + let response: Response; + try { + response = await fetch(requestUrl, init); + } catch (error) { + if (signal?.aborted) throw new Error("Request was aborted"); + const wrapped = wrapNetworkError(error); + if (attempt + 1 >= maxAttempts) throw wrapped; + await scheduler.wait(resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), { signal }); + continue; + } + + if (!isRetryableStatus(response.status)) return response; + if (attempt + 1 >= maxAttempts) return response; + + const hint = extractRetryHint(response, await response.clone().text()); + if (hint !== undefined && hint > maxDelayMs) return response; + + const delayMs = Math.min(hint ?? resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), maxDelayMs); + await scheduler.wait(delayMs, { signal }); + } +} + +function mergeInit(base: RequestInit, overlay: RequestInit): RequestInit { + const merged: RequestInit = { ...base, ...overlay }; + if (base.headers || overlay.headers) { + const baseHeaders = new Headers(base.headers ?? undefined); + const overlayHeaders = new Headers(overlay.headers ?? undefined); + overlayHeaders.forEach((value, key) => { + baseHeaders.set(key, value); + }); + merged.headers = baseHeaders; + } + return merged; +} + +function wrapNetworkError(error: unknown): Error { + if (error instanceof Error) { + if (error.name === "AbortError" || error.message === "Request was aborted") { + return new Error("Request was aborted"); + } + if (error.message === "fetch failed" && error.cause instanceof Error) { + return new Error(`Network error: ${error.cause.message}`); + } + return error; + } + return new Error(String(error)); +} + +function resolveDefaultDelay( + option: FetchWithRetryOptions["defaultDelayMs"], + attempt: number, + maxDelayMs: number, +): number { + if (option === undefined) return Math.min(500 * 2 ** attempt, maxDelayMs); + if (typeof option === "number") return Math.min(option, maxDelayMs); + if (typeof option === "function") return Math.min(option(attempt), maxDelayMs); + return Math.min(option[Math.min(attempt, option.length - 1)] ?? 0, maxDelayMs); +} + +/** + * Inspect an arbitrary error value (or its `cause` chain, up to depth 2) for an + * HTTP status code. Reads `status`, `statusCode`, and `response.status` fields, + * coerces string values, and falls back to scanning the error message for + * common patterns like `error (429)` or `HTTP 503`. + */ +export function extractHttpStatusFromError(error: unknown): number | undefined { + return extractHttpStatusFromErrorInternal(error, 0); +} + +type HttpErrorLike = { + message?: string; + name?: string; + status?: number | string; + statusCode?: number | string; + response?: { status?: number | string }; + cause?: unknown; +}; + +function extractHttpStatusFromErrorInternal(error: unknown, depth: number): number | undefined { + if (!error || typeof error !== "object" || depth > 2) return undefined; + const info = error as HttpErrorLike; + const rawStatus = info.status ?? info.statusCode ?? info.response?.status; + + let status: number | undefined; + if (typeof rawStatus === "number" && Number.isFinite(rawStatus)) { + status = rawStatus; + } else if (typeof rawStatus === "string") { + const parsed = Number(rawStatus); + if (Number.isFinite(parsed)) status = parsed; + } + if (status !== undefined && status >= 100 && status <= 599) return status; + + if (info.message) { + const extracted = extractStatusFromMessage(info.message); + if (extracted !== undefined) return extracted; + } + if (info.cause) return extractHttpStatusFromErrorInternal(info.cause, depth + 1); + return undefined; +} + +const STATUS_MESSAGE_PATTERNS = [ + /error\s*\((\d{3})\)/i, + /status\s*[:=]?\s*(\d{3})/i, + /\bhttp\s*(\d{3})\b/i, + /\b(\d{3})\s*(?:status|error)\b/i, +] as const; + +function extractStatusFromMessage(message: string): number | undefined { + for (const pattern of STATUS_MESSAGE_PATTERNS) { + const match = pattern.exec(message); + if (!match) continue; + const value = Number(match[1]); + if (Number.isFinite(value) && value >= 100 && value <= 599) return value; + } + return undefined; +} + +/** + * `true` if the given HTTP status code is one we treat as transient: 408 + * (Request Timeout), 429 (Too Many Requests), or any 5xx (server error). + */ +export function isRetryableStatus(status: number): boolean { + return status >= 500 || status === 408 || status === 429; +} + +/** + * `true` if the message describes an unexpected socket closure — Bun and some + * proxies surface these for any HTTP/2 stream reset. + */ +export function isUnexpectedSocketCloseMessage(message: string): boolean { + return /\b(?:the\s+)?socket connection (?:was )?closed unexpectedly\b/i.test(message); +} + +const TRANSIENT_MESSAGE_PATTERN = + /overloaded|rate.?limit|too many requests|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|network error|stream stall|other side closed/i; + +const VALIDATION_MESSAGE_PATTERN = + /invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i; + +/** + * Identify errors that should be retried: aborts/timeouts in the error name or + * message, retryable HTTP statuses (see `isRetryableStatus`), unexpected socket + * closes, and the standard transient phrases. 4xx statuses other than 408/429 + * and validation-shaped messages short-circuit to `false`. + */ +export function isRetryableError(error: unknown): boolean { + const info = error as { message?: string; name?: string } | null; + const message = info?.message ?? ""; + const name = info?.name ?? ""; + if (name === "AbortError" || /timeout|timed out|aborted/i.test(message)) return true; + + const status = extractHttpStatusFromError(error); + if (status !== undefined) { + if (isRetryableStatus(status)) return true; + if (status >= 400 && status < 500) return false; + } + + if (VALIDATION_MESSAGE_PATTERN.test(message)) return false; + return isUnexpectedSocketCloseMessage(message) || TRANSIENT_MESSAGE_PATTERN.test(message); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c8abaf461..80ef33c4d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,8 +1,9 @@ -export { abortableSleep, createAbortableStream, once, untilAborted } from "./abortable"; +export { createAbortableStream, once, untilAborted } from "./abortable"; export * from "./async"; export * from "./color"; export * from "./dirs"; export * from "./env"; +export * from "./fetch-retry"; export * from "./format"; export * from "./frontmatter"; export * from "./fs-error"; From 7fd2a7b30988dd59aa610cf82a4dc4362b1bfd42 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 18:15:57 +0200 Subject: [PATCH 02/17] fix(coding-agent/tools): highlighted multi-line bash commands in result rendering - Added a new formatBashCommandLines helper that syntax-highlighted each command line and applied the dim prefix only to the first line. - Updated the shell renderer to emit command output as line-based entries instead of a single dimmed string. - Extended the bash renderer test to verify multi-line commands keep ANSI styling on every rendered line. --- packages/coding-agent/src/tools/bash.ts | 27 +++++++++++++++-- .../test/tools/bash-sixel-render.test.ts | 30 ++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 3a1841d22..fc5a17ede 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -9,7 +9,7 @@ import { type BashResult, executeBash } from "../exec/bash-executor"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; import { truncateToVisualLines } from "../modes/components/visual-truncate"; -import type { Theme } from "../modes/theme/theme"; +import { highlightCode, type Theme } from "../modes/theme/theme"; import bashDescription from "../prompts/tools/bash.md" with { type: "text" }; import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge"; import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output"; @@ -892,6 +892,27 @@ export function formatBashCommand(args: BashRenderArgs): string { return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`; } +/** + * Returns the bash command formatted for the result body: the dim `$ cd … &&` + * prefix joined with syntax-highlighted command lines. The prefix is applied + * only to the first line so multi-line commands display cleanly — terminals + * reset SGR state at line boundaries, which made the previous single-string + * `theme.fg("dim", ...)` form render only the first line as dim. + */ +export function formatBashCommandLines(args: BashRenderArgs, uiTheme: Theme): string[] { + const command = replaceTabs(args.command || "…"); + const cwd = getProjectDir(); + const displayWorkdir = formatToolWorkingDirectory(args.cwd, cwd); + const envAssignments = formatBashEnvAssignments(getBashEnvForDisplay(args)); + const prefixParts = ["$"]; + if (displayWorkdir) prefixParts.push(`cd ${displayWorkdir} &&`); + if (envAssignments) prefixParts.push(envAssignments); + const prefix = uiTheme.fg("dim", `${prefixParts.join(" ")} `); + const highlightedLines = highlightCode(command, "bash"); + if (highlightedLines.length === 0) return [prefix.trimEnd()]; + return highlightedLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line)); +} + function toBashRenderArgs(args: TArgs | undefined, config: ShellRendererConfig): BashRenderArgs { return { command: config.resolveCommand?.(args), @@ -922,7 +943,7 @@ export function createShellRenderer(config: ShellRendererConfig) { args?: TArgs, ): Component { const renderArgs = toBashRenderArgs(args, config); - const cmdText = args ? formatBashCommand(renderArgs) : undefined; + const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined; const isError = result.isError === true; const icon = options.isPartial ? "pending" : isError ? "error" : "success"; const title = config.resolveTitle(args, options); @@ -1000,7 +1021,7 @@ export function createShellRenderer(config: ShellRendererConfig) { header, state: options.isPartial ? "pending" : isError ? "error" : "success", sections: [ - { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] }, + { lines: cmdLines ?? [] }, { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }, ], width, diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts index 3d6d8ce44..75fbe3d0a 100644 --- a/packages/coding-agent/test/tools/bash-sixel-render.test.ts +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as os from "node:os"; import * as path from "node:path"; import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; -import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { bashToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/bash"; import { sanitizeText } from "@oh-my-pi/pi-natives"; import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; @@ -117,4 +117,32 @@ describe("bashToolRenderer", () => { expect(lines.filter(line => line === sixel)).toHaveLength(1); expect(lines.some(line => line.includes("ctrl+o to expand"))).toBe(false); }); + + it("highlights every line of a multi-line bash command in renderResult", async () => { + const uiTheme = await getThemeByName("dark"); + expect(uiTheme).toBeDefined(); + setThemeInstance(uiTheme!); + const command = 'for f in a b; do\n\techo "$f"\ndone'; + const component = bashToolRenderer.renderResult( + { content: [{ type: "text", text: "" }], details: {}, isError: false }, + { expanded: false, isPartial: false }, + uiTheme!, + { command }, + ); + const rendered = component.render(120); + const sanitized = rendered.map(line => sanitizeText(line)); + // Every command line must appear in the output, untruncated. + const findLine = (needle: string) => sanitized.findIndex(line => line.includes(needle)); + const forLine = findLine("for f in a b; do"); + const echoLine = findLine('echo "$f"'); + const doneLine = findLine("done"); + expect(forLine).toBeGreaterThanOrEqual(0); + expect(echoLine).toBeGreaterThanOrEqual(0); + expect(doneLine).toBeGreaterThanOrEqual(0); + // Each command line carries its own SGR run so terminals don't drop + // styling after the first newline (the bug this fix addresses). + for (const idx of [forLine, echoLine, doneLine]) { + expect(rendered[idx]).toMatch(/\u001b\[38;2;/); + } + }); }); From c7d04f3a135e6d7c38ed435a80f3c278d6af69e5 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 19:19:50 +0200 Subject: [PATCH 03/17] fix(coding-agent): constrained bash cwd auto-detect regex to single-line cd commands - Updated BashTool's leading `cd` regex to stop matching newline characters so cwd extraction only applies to a single-line `cd ... &&` prefix. - Added a regression test for multiline commands with a later-line `&&` to ensure each line of the script executes normally. --- packages/coding-agent/src/tools/bash.ts | 4 +++- packages/coding-agent/test/tools.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index fc5a17ede..f5d5c088d 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -484,8 +484,10 @@ export class BashTool implements AgentTool { const env = normalizeBashEnv(rawEnv); // Extract leading `cd && ...` into cwd when the model ignores the cwd parameter. + // Constrained to a single line so a `&&` that sits on a later line of a multiline + // script can't pull the entire script into the "cwd" capture. if (!cwd) { - const cdMatch = command.match(/^cd\s+((?:[^&\\]|\\.)+?)\s*&&\s*/); + const cdMatch = command.match(/^cd[ \t]+((?:[^&\\\n\r]|\\.)+?)[ \t]*&&[ \t]*/); if (cdMatch) { cwd = cdMatch[1].trim().replace(/^["']|["']$/g, ""); command = command.slice(cdMatch[0].length); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 8d0da09d4..ea641d1fc 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1288,6 +1288,23 @@ function b() { ); }); + it("should not pull cwd from a later-line `&&` when the command is multiline", async () => { + // Regression for #?: the `^cd ... && ...` extractor used `\s` and `[^&\\]`, + // which let the lazy match cross newlines and capture the whole script as the + // "cwd" when any later line contained `&&`. The model intended `cd` to run as + // part of a multiline script, not to relocate the entire command. + const command = [ + "cd /this/directory/definitely/does/not/exist/12345", + "echo first-line", + "echo second && echo third", + ].join("\n"); + const result = await bashTool.execute("test-call-multiline-cd", { command }); + const output = getTextOutput(result); + expect(output).toContain("first-line"); + expect(output).toContain("second"); + expect(output).toContain("third"); + }); + it("should expose background-job tools when bash auto-background is enabled", () => { const autoBackgroundSession = createTestToolSession( testDir, From 084488b6809e1dc0dd6f48846b54b85a69a7f21f Mon Sep 17 00:00:00 2001 From: Miroslav Drbal Date: Wed, 13 May 2026 19:26:38 +0200 Subject: [PATCH 04/17] fix(coding-agent): exclude cacheRead from token display, add per-subagent cost Token counter (token_total status-line segment, subagent progress tree, session-observer stats line) previously included cacheRead in its cumulative sum. With Anthropic prompt caching, cacheRead per turn equals the full cached context, so summing across N turns gives N*context_size -- a session with a 1M context and 5 turns showed ~5M tokens despite no compaction occurring. Fix: display shows input + output + cacheWrite per turn. cacheWrite is kept because each byte is written once; cacheRead re-reads the same context every turn. Dedicated cache_read/cache_write status-line segments still show cache activity; billing cost is unaffected. Also adds per-subagent cost display (dollar amount, statusLineCost color) accumulated incrementally from message_end events. Hidden when cost is zero (subscription/OAuth providers). Brings token and cost display in line with what Claude Code shows per-agent. --- packages/coding-agent/CHANGELOG.md | 6 +++++ .../components/session-observer-overlay.ts | 5 +++- .../modes/components/status-line/segments.ts | 7 ++++-- packages/coding-agent/src/task/executor.ts | 24 +++++++++++++------ packages/coding-agent/src/task/index.ts | 2 ++ packages/coding-agent/src/task/render.ts | 6 +++++ packages/coding-agent/src/task/types.ts | 4 ++++ 7 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 21e3ba775..e4826b71b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,8 @@ - Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable. - Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**. +- Added per-subagent cost display (`$X.XX` in the task progress tree and the session-observer stats line). Cost is accumulated incrementally from `message_end` events and shown only when non-zero, using the `statusLineCost` theme color. Providers that do not report per-turn cost data (e.g. subscription/OAuth usage) continue to show nothing. + ### Changed - Raised the image downscaling default JPEG quality from 75 to 80 in `resizeImage` output generation @@ -19,6 +21,10 @@ - Changed search truncation metadata/renderer output from match/result-based limits to file-based limits (`fileLimitReached`, `perFileLimitReached`) and updated truncation labels accordingly - Lowered `read.defaultLimit` default from `500` to `300` lines, and split the per-range context padding into asymmetric `RANGE_LEADING_CONTEXT_LINES = 1` / `RANGE_TRAILING_CONTEXT_LINES = 3` (was symmetric `RANGE_CONTEXT_LINES = 3`). Replay analysis over post-summarizer sessions (`scripts/session-stats/optimize_read_config.py`) showed that bare-path reads are over-provisioned at the median (file p50 = 220 lines) and that most follow-up reads are disjoint hops rather than adjacent extensions — so a smaller default plus narrower leading context reclaims tokens without measurably changing first-cover rate. Trailing context stays at 3 lines to keep anchor-stale recovery on narrow reads. Explicit `read.defaultLimit` overrides in settings are honoured unchanged. +### Fixed + +- Fixed token display for sessions and subagents inflating far beyond the context window. `token_total` status-line segment and the subagent overlay token counter now show `input + output + cacheWrite` instead of `input + output + cacheRead + cacheWrite`. With prompt caching, `cacheRead` per turn equals the full cached context — summing it across all turns produces a cumulative total that is N×context_size (e.g. a 5-turn session with a 1 M-token context reported ~5 M tokens). Cache activity is still visible via the dedicated `cache_read`/`cache_write` status-line segments; billing cost is unaffected. + ## [15.0.0] - 2026-05-13 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/components/session-observer-overlay.ts b/packages/coding-agent/src/modes/components/session-observer-overlay.ts index cf37498d2..689ab5b10 100644 --- a/packages/coding-agent/src/modes/components/session-observer-overlay.ts +++ b/packages/coding-agent/src/modes/components/session-observer-overlay.ts @@ -267,7 +267,10 @@ export class SessionObserverOverlayComponent extends Container { if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`); if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`); if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs)); - return stats.length > 0 ? theme.fg("dim", stats.join(theme.sep.dot)) : ""; + const parts: string[] = []; + if (stats.length > 0) parts.push(theme.fg("dim", stats.join(theme.sep.dot))); + if (progress.cost > 0) parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)); + return parts.join(theme.sep.dot); } #buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void { diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 5c5160f5b..078dd5eec 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -216,8 +216,11 @@ const tokenOutSegment: StatusLineSegment = { const tokenTotalSegment: StatusLineSegment = { id: "token_total", render(ctx) { - const { input, output, cacheRead, cacheWrite } = ctx.usageStats; - const total = input + output + cacheRead + cacheWrite; + // Excludes cacheRead: that field re-reads the full cached context every + // turn, making the cumulative sum N×context_size. The dedicated cache_read + // segment handles cache monitoring; the cost segment handles billing. + const { input, output, cacheWrite } = ctx.usageStats; + const total = input + output + cacheWrite; if (!total) return { content: "", visible: false }; const content = withIcon(theme.icon.tokens, formatNumber(total)); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index ca0233bcd..c27aff586 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -379,21 +379,29 @@ function firstNumberField(record: Record, keys: string[]): numb } /** - * Normalize usage objects from different event formats. + * Tokens for progress display: input + output + cacheWrite per turn. + * + * Deliberately excludes cacheRead. With prompt caching, cacheRead in each turn + * equals the full cached context (potentially hundreds of KB), so summing it + * across all turns produces a cumulative total that is N×context_size — far + * larger than the context window and misleading as a "work done" metric. + * cacheWrite is kept because each byte is written once, not repeated per turn. + * The cost segment handles billing; dedicated cache_read/cache_write segments + * handle cache-specific monitoring. */ function getUsageTokens(usage: unknown): number { if (!usage || typeof usage !== "object") return 0; const record = usage as Record; - const totalTokens = firstNumberField(record, ["totalTokens", "total_tokens"]); - if (totalTokens !== undefined && totalTokens > 0) return totalTokens; - const input = firstNumberField(record, ["input", "input_tokens", "inputTokens"]) ?? 0; const output = firstNumberField(record, ["output", "output_tokens", "outputTokens"]) ?? 0; - const cacheRead = firstNumberField(record, ["cacheRead", "cache_read", "cacheReadTokens"]) ?? 0; const cacheWrite = firstNumberField(record, ["cacheWrite", "cache_write", "cacheWriteTokens"]) ?? 0; - - return input + output + cacheRead + cacheWrite; + const computed = input + output + cacheWrite; + if (computed > 0) return computed; + // Fallback for providers that only surface a pre-summed total without individual + // field breakdown. This total includes cacheRead, but returning it is still better + // than silently showing 0 for those providers. + return firstNumberField(record, ["totalTokens", "total_tokens"]) ?? 0; } /** @@ -497,6 +505,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { recentOutput: [], toolCount: 0, tokens: 0, + cost: 0, durationMs: 0, }); } @@ -831,6 +832,7 @@ export class TaskTool implements AgentTool { recentOutput: [], toolCount: 0, tokens: 0, + cost: 0, durationMs: 0, modelOverride, description: taskItem.description, diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 6c946af04..331daea5c 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -532,6 +532,9 @@ function renderAgentProgress( if (progress.tokens > 0) { statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`; } + if (progress.cost > 0) { + statusLine += `${theme.sep.dot}${theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)}`; + } } else if (progress.status === "completed") { if (progress.toolCount > 0) { statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`; @@ -539,6 +542,9 @@ function renderAgentProgress( if (progress.tokens > 0) { statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`; } + if (progress.cost > 0) { + statusLine += `${theme.sep.dot}${theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)}`; + } } lines.push(statusLine); diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index 21c58bc38..8deb22b0e 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -217,7 +217,10 @@ export interface AgentProgress { recentTools: Array<{ tool: string; args: string; endMs: number }>; recentOutput: string[]; toolCount: number; + /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */ tokens: number; + /** Cumulative billing cost in USD, accumulated incrementally from message_end events. */ + cost: number; durationMs: number; modelOverride?: string | string[]; /** Data extracted by registered subprocess tool handlers (keyed by tool name) */ @@ -239,6 +242,7 @@ export interface SingleResult { stderr: string; truncated: boolean; durationMs: number; + /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */ tokens: number; modelOverride?: string | string[]; error?: string; From 2bc93632b369bd6c0bc4887fc930252a79428911 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 19:53:27 +0200 Subject: [PATCH 05/17] deps: updated inquirer lockfile versions and release-age exclusions - Updated @inquirer lockfile entries to newer releases for core, prompts, and related prompt packages, including updated dependency ranges and integrity hashes. - Expanded minimumReleaseAgeExcludes in bunfig.toml by adding bun-types to the excluded package list. --- bun.lock | 24 ++++++++++++------------ bunfig.toml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index a7dfc8ae6..2cc875e14 100644 --- a/bun.lock +++ b/bun.lock @@ -402,33 +402,33 @@ "@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], - "@inquirer/checkbox": ["@inquirer/checkbox@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ=="], + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ=="], - "@inquirer/confirm": ["@inquirer/confirm@6.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og=="], + "@inquirer/confirm": ["@inquirer/confirm@6.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw=="], - "@inquirer/core": ["@inquirer/core@11.1.9", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg=="], + "@inquirer/core": ["@inquirer/core@11.1.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A=="], - "@inquirer/editor": ["@inquirer/editor@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA=="], + "@inquirer/editor": ["@inquirer/editor@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg=="], - "@inquirer/expand": ["@inquirer/expand@5.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g=="], + "@inquirer/expand": ["@inquirer/expand@5.0.14", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ=="], "@inquirer/external-editor": ["@inquirer/external-editor@3.0.0", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg=="], "@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], - "@inquirer/input": ["@inquirer/input@5.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q=="], + "@inquirer/input": ["@inquirer/input@5.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw=="], - "@inquirer/number": ["@inquirer/number@4.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg=="], + "@inquirer/number": ["@inquirer/number@4.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg=="], - "@inquirer/password": ["@inquirer/password@5.0.12", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ=="], + "@inquirer/password": ["@inquirer/password@5.0.13", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg=="], - "@inquirer/prompts": ["@inquirer/prompts@8.4.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.4", "@inquirer/confirm": "^6.0.12", "@inquirer/editor": "^5.1.1", "@inquirer/expand": "^5.0.13", "@inquirer/input": "^5.0.12", "@inquirer/number": "^4.0.12", "@inquirer/password": "^5.0.12", "@inquirer/rawlist": "^5.2.8", "@inquirer/search": "^4.1.8", "@inquirer/select": "^5.1.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q=="], + "@inquirer/prompts": ["@inquirer/prompts@8.4.3", "", { "dependencies": { "@inquirer/checkbox": "^5.1.5", "@inquirer/confirm": "^6.0.13", "@inquirer/editor": "^5.1.2", "@inquirer/expand": "^5.0.14", "@inquirer/input": "^5.0.13", "@inquirer/number": "^4.0.13", "@inquirer/password": "^5.0.13", "@inquirer/rawlist": "^5.2.9", "@inquirer/search": "^4.1.9", "@inquirer/select": "^5.1.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw=="], - "@inquirer/rawlist": ["@inquirer/rawlist@5.2.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg=="], + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.9", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw=="], - "@inquirer/search": ["@inquirer/search@4.1.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw=="], + "@inquirer/search": ["@inquirer/search@4.1.9", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA=="], - "@inquirer/select": ["@inquirer/select@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q=="], + "@inquirer/select": ["@inquirer/select@5.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg=="], "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], diff --git a/bunfig.toml b/bunfig.toml index 89db0a5ef..dcc9eb340 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ telemetry = false [install] minimumReleaseAge = 259200 # 3 days in seconds -minimumReleaseAgeExcludes = ["@types/bun"] +minimumReleaseAgeExcludes = ["@types/bun", "bun-types"] linker = "hoisted" exact = true saveTextLockfile = true From 08551b83a5e68244f62a3f34d747d3d69c79c026 Mon Sep 17 00:00:00 2001 From: Miroslav Drbal Date: Wed, 13 May 2026 20:04:55 +0200 Subject: [PATCH 06/17] fix(coding-agent): show cost in final subagent result line renderAgentResult (rendered after the task tool resolves) was missing the cost display present in renderAgentProgress. Read cost directly from result.usage?.cost.total which is already available on SingleResult. --- packages/coding-agent/src/task/render.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 331daea5c..fa2bc7265 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -777,6 +777,10 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool if (result.tokens > 0) { statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(result.tokens)} tokens`)}`; } + const resultCost = result.usage?.cost.total ?? 0; + if (resultCost > 0) { + statusLine += `${theme.sep.dot}${theme.fg("statusLineCost", `$${resultCost.toFixed(2)}`)}`; + } statusLine += `${theme.sep.dot}${theme.fg("dim", formatDuration(result.durationMs))}`; if (result.truncated) { From 5cc8095cd1ce110ad06451a691d0f229ed8d67b3 Mon Sep 17 00:00:00 2001 From: Miroslav Drbal Date: Wed, 13 May 2026 20:07:09 +0200 Subject: [PATCH 07/17] refactor(coding-agent): extract appendAgentStats helper in render.ts Eliminates the three-way duplication of toolCount/tokens/cost stat appending across renderAgentProgress (running), renderAgentProgress (completed), and renderAgentResult. --- packages/coding-agent/src/task/render.ts | 46 +++++++++++------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index fa2bc7265..f10ac3dd4 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -50,6 +50,24 @@ function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFra } } +/** Append tool-count, token, and cost stats to a status line string. */ +function appendAgentStats( + line: string, + opts: { toolCount?: number; tokens: number; cost: number }, + theme: Theme, +): string { + if (opts.toolCount) { + line += `${theme.sep.dot}${theme.fg("dim", `${opts.toolCount} tools`)}`; + } + if (opts.tokens > 0) { + line += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(opts.tokens)} tokens`)}`; + } + if (opts.cost > 0) { + line += `${theme.sep.dot}${theme.fg("statusLineCost", `$${opts.cost.toFixed(2)}`)}`; + } + return line; +} + function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): string { if (findings.length === 0) return theme.fg("dim", "Findings: none"); @@ -526,25 +544,9 @@ function renderAgentProgress( const taskPreview = truncateToWidth(progress.assignment ?? progress.task, 40); statusLine += ` ${theme.fg("muted", taskPreview)}`; } - if (progress.toolCount > 0) { - statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`; - } - if (progress.tokens > 0) { - statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`; - } - if (progress.cost > 0) { - statusLine += `${theme.sep.dot}${theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)}`; - } + statusLine = appendAgentStats(statusLine, progress, theme); } else if (progress.status === "completed") { - if (progress.toolCount > 0) { - statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`; - } - if (progress.tokens > 0) { - statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`; - } - if (progress.cost > 0) { - statusLine += `${theme.sep.dot}${theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)}`; - } + statusLine = appendAgentStats(statusLine, progress, theme); } lines.push(statusLine); @@ -774,13 +776,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool iconColor, theme, )}`; - if (result.tokens > 0) { - statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(result.tokens)} tokens`)}`; - } - const resultCost = result.usage?.cost.total ?? 0; - if (resultCost > 0) { - statusLine += `${theme.sep.dot}${theme.fg("statusLineCost", `$${resultCost.toFixed(2)}`)}`; - } + statusLine = appendAgentStats(statusLine, { tokens: result.tokens, cost: result.usage?.cost.total ?? 0 }, theme); statusLine += `${theme.sep.dot}${theme.fg("dim", formatDuration(result.durationMs))}`; if (result.truncated) { From 8c77d2b15a4b38801c9a9c65cf706bbd5dfe96b5 Mon Sep 17 00:00:00 2001 From: Miroslav Drbal Date: Wed, 13 May 2026 20:19:53 +0200 Subject: [PATCH 08/17] fix(coding-agent): copy cost into async task progress on completion The async job completion path copied durationMs, tokens, and extractedToolData from SingleResult back into the AgentProgress object, but missed cost. Add progress.cost = singleResult?.usage?.cost.total ?? 0 to the same block. --- packages/coding-agent/src/task/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index c0033f893..f66592859 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -391,6 +391,7 @@ export class TaskTool implements AgentTool { : "failed"; progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt); progress.tokens = singleResult?.tokens ?? 0; + progress.cost = singleResult?.usage?.cost.total ?? 0; progress.extractedToolData = singleResult?.extractedToolData; } completedJobs += 1; From 453071d34d762a303625bf0c7667542d31e15e3b Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 20:43:03 +0200 Subject: [PATCH 09/17] fix(coding-agent): restored coordinate-mapping dimension note and made bash SGR test color-mode agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restored formatDimensionNote bracket form '[Image: original WxH, displayed at WxH. Multiply coordinates by S to map to original image.]' that tests assert. The Bun 1.3.14 refactor regressed it to a less informative 'Image resized from …' line. - Broadened bash-sixel-render multi-line styling assertion to accept both truecolor (38;2;) and 256-color (38;5;) SGR runs so CI runners with TERM=dumb don't fail. The contract being tested — every line carries its own SGR — is independent of color depth. --- packages/coding-agent/src/utils/image-resize.ts | 3 ++- packages/coding-agent/test/tools/bash-sixel-render.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 67ab2ab82..7e60ccd33 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -256,5 +256,6 @@ export function formatDimensionNote(result: ResizedImage): string | undefined { if (result.width === result.originalWidth && result.height === result.originalHeight) { return undefined; } - return `Image resized from ${result.originalWidth}x${result.originalHeight} to ${result.width}x${result.height}.`; + const scale = result.originalWidth / result.width; + return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`; } diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts index 75fbe3d0a..d15b614b2 100644 --- a/packages/coding-agent/test/tools/bash-sixel-render.test.ts +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -142,7 +142,7 @@ describe("bashToolRenderer", () => { // Each command line carries its own SGR run so terminals don't drop // styling after the first newline (the bug this fix addresses). for (const idx of [forLine, echoLine, doneLine]) { - expect(rendered[idx]).toMatch(/\u001b\[38;2;/); + expect(rendered[idx]).toMatch(/\u001b\[38;(?:2|5);/); } }); }); From c7722838b70988ecdc4b011fe99190b3e19504dd Mon Sep 17 00:00:00 2001 From: David Marshall Date: Wed, 13 May 2026 16:14:18 -0500 Subject: [PATCH 10/17] fix(coding-agent/acp): pushed config_option_update on every thinking-level change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP clients (Zed, etc.) only received `config_option_update` notifications when they themselves drove the change via `session/set_session_config_option`. Internal thinking-level updates (slash commands, automatic model-driven adjustments, extension UI) bypassed the notification path, so client config panels went stale until the next user-initiated change. AgentSession now emits a `thinking_level_changed` event from `setThinkingLevel`, and AcpAgent installs a session-lifetime subscription on each managed session that pushes a fresh `config_option_update` whenever the event fires — independent of prompt-turn lifecycle. The `session/set_session_config_option` handler no longer pushes its own notification for the `thinking` config (lifetime subscription covers it); the response still returns fresh `configOptions` so callers see the new state synchronously. Subscriptions are released in `#disposeSessionRecord`. Also consolidated four duplicate `config_option_update` send sites into a new `#pushConfigOptionUpdate(record)` helper. Tests: added two cases to `test/acp-agent.test.ts` — one verifying internal `setThinkingLevel` calls produce a `config_option_update` and a no-op re-set produces none, and one verifying client-driven `setSessionConfigOption(thinking, …)` produces exactly one notification. Co-Authored-By: omp --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/modes/acp/acp-agent.ts | 70 +++++++++------- .../src/modes/controllers/event-controller.ts | 1 + .../coding-agent/src/session/agent-session.ts | 4 +- packages/coding-agent/test/acp-agent.test.ts | 84 +++++++++++++++++++ 5 files changed, 132 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 21e3ba775..a6a9c669f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -19,6 +19,10 @@ - Changed search truncation metadata/renderer output from match/result-based limits to file-based limits (`fileLimitReached`, `perFileLimitReached`) and updated truncation labels accordingly - Lowered `read.defaultLimit` default from `500` to `300` lines, and split the per-range context padding into asymmetric `RANGE_LEADING_CONTEXT_LINES = 1` / `RANGE_TRAILING_CONTEXT_LINES = 3` (was symmetric `RANGE_CONTEXT_LINES = 3`). Replay analysis over post-summarizer sessions (`scripts/session-stats/optimize_read_config.py`) showed that bare-path reads are over-provisioned at the median (file p50 = 220 lines) and that most follow-up reads are disjoint hops rather than adjacent extensions — so a smaller default plus narrower leading context reclaims tokens without measurably changing first-cover rate. Trailing context stays at 3 lines to keep anchor-stale recovery on narrow reads. Explicit `read.defaultLimit` overrides in settings are honoured unchanged. +### Fixed + +- Fixed ACP clients missing `config_option_update` notifications when the thinking level changed via any path other than the client's own `session/set_session_config_option` call (slash commands, model auto-adjust, extension UI). `AgentSession` now emits a `thinking_level_changed` event from `setThinkingLevel`, and `AcpAgent` subscribes to each managed session for the session's lifetime — independent of any active prompt turn — and pushes a fresh `config_option_update` whenever the effective level changes. The `session/set_session_config_option` handler no longer pushes its own duplicate notification for the `thinking` config; clients still get the new state both via the lifetime subscription and the response payload. Subscriptions are released in `#disposeSessionRecord`. + ## [15.0.0] - 2026-05-13 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 2a3556c9b..8f7ebb748 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -97,6 +97,8 @@ type ManagedSessionRecord = { liveMessageId: string | undefined; liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined; extensionsConfigured: boolean; + // Independent of prompt-turn lifecycle — see `#handleLifetimeEvent`. + lifetimeUnsubscribe: (() => void) | undefined; }; type ReplayableMessage = { @@ -314,13 +316,7 @@ export class AcpAgent implements Agent { sessionId: record.session.sessionId, update: this.#buildCurrentModeUpdate(record.session), }); - await this.#connection.sessionUpdate({ - sessionId: record.session.sessionId, - update: { - sessionUpdate: "config_option_update", - configOptions: this.#buildConfigOptions(record.session), - }, - }); + await this.#pushConfigOptionUpdate(record); return {}; } @@ -354,27 +350,18 @@ export class AcpAgent implements Agent { }); } - const configOptions = this.#buildConfigOptions(record.session); - await this.#connection.sessionUpdate({ - sessionId: record.session.sessionId, - update: { - sessionUpdate: "config_option_update", - configOptions, - }, - }); - return { configOptions }; + // Thinking-level changes are pushed via the lifetime subscription on + // `thinking_level_changed`; skipping the push here avoids a duplicate. + if (params.configId !== THINKING_CONFIG_ID) { + await this.#pushConfigOptionUpdate(record); + } + return { configOptions: this.#buildConfigOptions(record.session) }; } async unstable_setSessionModel(params: SetSessionModelRequest): Promise { const record = this.#getSessionRecord(params.sessionId); await this.#setModelById(record.session, params.modelId); - await this.#connection.sessionUpdate({ - sessionId: record.session.sessionId, - update: { - sessionUpdate: "config_option_update", - configOptions: this.#buildConfigOptions(record.session), - }, - }); + await this.#pushConfigOptionUpdate(record); return {}; } @@ -432,13 +419,7 @@ export class AcpAgent implements Agent { }); }, notifyConfigChanged: async () => { - await this.#connection.sessionUpdate({ - sessionId: record.session.sessionId, - update: { - sessionUpdate: "config_option_update", - configOptions: this.#buildConfigOptions(record.session), - }, - }); + await this.#pushConfigOptionUpdate(record); }, }); if (builtinResult !== false) { @@ -688,6 +669,9 @@ export class AcpAgent implements Agent { async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise { const record = this.#createManagedSessionRecord(session); session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities)); + record.lifetimeUnsubscribe = session.subscribe(event => { + void this.#handleLifetimeEvent(record, event); + }); try { await this.#configureExtensions(record); await this.#configureMcpServers(record, mcpServers); @@ -707,9 +691,24 @@ export class AcpAgent implements Agent { liveMessageId: undefined, liveMessageProgress: undefined, extensionsConfigured: false, + lifetimeUnsubscribe: undefined, }; } + async #handleLifetimeEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise { + if (event.type !== "thinking_level_changed") { + return; + } + try { + await this.#pushConfigOptionUpdate(record); + } catch (error) { + logger.warn("Failed to push thinking-level config_option_update", { + sessionId: record.session.sessionId, + error, + }); + } + } + #getSessionRecord(sessionId: string): ManagedSessionRecord { const record = this.#sessions.get(sessionId); if (!record) { @@ -912,6 +911,16 @@ export class AcpAgent implements Agent { }; } + async #pushConfigOptionUpdate(record: ManagedSessionRecord): Promise { + await this.#connection.sessionUpdate({ + sessionId: record.session.sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: this.#buildConfigOptions(record.session), + }, + }); + } + #buildConfigOptions(session: AgentSession): SessionConfigOption[] { const currentModeId = this.#getCurrentModeId(session); const modeOptions = this.#getAvailableModes(session).map(mode => ({ @@ -1674,6 +1683,7 @@ export class AcpAgent implements Agent { } async #disposeSessionRecord(record: ManagedSessionRecord): Promise { + record.lifetimeUnsubscribe?.(); if (record.mcpManager) { try { await record.mcpManager.disconnectAll(); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 1bfa15f83..5a0d71bbc 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -61,6 +61,7 @@ export class EventController { todo_auto_clear: e => this.#handleTodoAutoClear(e), irc_message: e => this.#handleIrcMessage(e), notice: e => this.#handleNotice(e), + thinking_level_changed: async () => {}, } satisfies AgentSessionEventHandlers; } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f61f35a13..e056bddb5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -214,7 +214,8 @@ export type AgentSessionEvent = | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number } | { type: "todo_auto_clear" } | { type: "irc_message"; message: CustomMessage } - | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }; + | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string } + | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }; /** Listener function for agent session events */ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; @@ -4551,6 +4552,7 @@ export class AgentSession { if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) { this.settings.set("defaultThinkingLevel", effectiveLevel); } + this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel }); } } diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index ee169311a..8db0f7908 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -125,7 +125,16 @@ class FakeAgentSession { } setThinkingLevel(level: string | undefined): void { + const isChanging = this.thinkingLevel !== level; this.thinkingLevel = level; + if (isChanging) { + for (const listener of this.#listeners) { + listener({ + type: "thinking_level_changed", + thinkingLevel: level, + } as AgentSessionEvent); + } + } } setSlashCommands(_commands: unknown[]): void { @@ -477,6 +486,81 @@ describe("ACP agent", () => { await Bun.sleep(0); }); + it("pushes config_option_update when thinking level changes internally", async () => { + // Internal callers (slash commands, model auto-adjust, extension UI) call + // AgentSession.setThinkingLevel directly without going through the ACP + // setSessionConfigOption surface. The session-lifetime subscription on + // AcpAgent must surface those changes to clients as `config_option_update` + // so TORTAS-style fleet views stay in sync. + const harness = await createHarness(); + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + const session = harness.findSession(created.sessionId)!; + + const updatesBefore = harness.updates.length; + session.setThinkingLevel("high"); + + const pushedAfter = harness.updates.slice(updatesBefore); + const configUpdates = pushedAfter.filter( + notification => + notification.sessionId === created.sessionId && + notification.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates.length).toBeGreaterThanOrEqual(1); + expectAcpNotifications(configUpdates); + const firstUpdate = configUpdates[0]!.update; + if (firstUpdate.sessionUpdate !== "config_option_update") { + throw new Error("expected config_option_update"); + } + const thinkingConfig = firstUpdate.configOptions.find(option => option.id === "thinking") as + | { currentValue?: unknown } + | undefined; + expect(thinkingConfig?.currentValue).toBe("high"); + + // Setting to the same level must not produce a redundant notification. + const updatesBeforeRedundant = harness.updates.length; + session.setThinkingLevel("high"); + expect(harness.updates.length).toBe(updatesBeforeRedundant); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + + it("emits a single config_option_update per setSessionConfigOption(thinking) call", async () => { + // Client-initiated thinking changes flow through #setThinkingLevelById, + // which fires `thinking_level_changed` and lets the lifetime subscription + // push the notification. The ACP surface must not also push a duplicate + // `config_option_update` of its own. + const harness = await createHarness(); + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + + const updatesBefore = harness.updates.length; + const response = await harness.agent.setSessionConfigOption({ + sessionId: created.sessionId, + configId: "thinking", + value: "high", + }); + + const configUpdates = harness.updates + .slice(updatesBefore) + .filter( + notification => + notification.sessionId === created.sessionId && + notification.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates.length).toBe(1); + expectAcpNotifications(configUpdates); + + // The response still carries the fresh configOptions tree so the caller + // gets the new state without relying on the notification. + const thinkingOption = response.configOptions.find(option => option.id === "thinking") as + | { currentValue?: unknown } + | undefined; + expect(thinkingOption?.currentValue).toBe("high"); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + it("accepts only ACP underscore-prefixed extension methods", async () => { const harness = await createHarness(); From 4882d1e3867589bca5c4499870ff8e7f7e0dc9a6 Mon Sep 17 00:00:00 2001 From: David Marshall Date: Wed, 13 May 2026 16:34:54 -0500 Subject: [PATCH 11/17] fix(coding-agent/acp): deferred thinking-level lifetime subscription until after bootstrap-guard Addresses codex review on #1060: an extension session_start handler that calls setThinkingLevel via the exposed extension action (line 1541) would have run BEFORE #registerPreparedSession set the record into #sessions and BEFORE the session/new response was delivered to the client, causing config_option_update to be pushed for a session id the client did not yet know about. This is the exact race that #scheduleBootstrapUpdates already documents and guards for available_commands_update / session_info_update (Zed's 'Received session notification for unknown session' drop). Moved the session.subscribe(...) installation out of #registerPreparedSession and into #scheduleBootstrapUpdates's 50ms timer callback so the lifetime subscription shares the same response-delivery guard as the existing bootstrap notifications. The pre-bootstrap thinking level is still communicated to the client through the response payload's configOptions (newSession / loadSession / resumeSession / unstable_forkSession all return it), so no state is lost; it is only the notification that is deferred. For client-driven setSessionConfigOption({thinking}) the handler now only skips its own push when the lifetime subscription is already installed. Pre-bootstrap the handler keeps pushing (the client knows the session id because they passed it in), post-bootstrap the subscription pushes exactly once. No double-push, no missing pre-bootstrap notification. Tests: - updated existing pushes-config-option-update test to await past the 50ms bootstrap timer before driving the internal setThinkingLevel - updated the single-config_option_update-per-setSessionConfigOption test the same way - added 'suppresses lifetime config_option_update during the bootstrap window' regression that drives setThinkingLevel synchronously after newSession and asserts zero notifications, then asserts notifications resume after the bootstrap timer fires - bun test test/acp-agent.test.ts: 11/11 pass Co-Authored-By: omp --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/modes/acp/acp-agent.ts | 40 ++++++++++--- packages/coding-agent/test/acp-agent.test.ts | 58 ++++++++++++++++++- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a6a9c669f..72617423c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,7 +21,7 @@ ### Fixed -- Fixed ACP clients missing `config_option_update` notifications when the thinking level changed via any path other than the client's own `session/set_session_config_option` call (slash commands, model auto-adjust, extension UI). `AgentSession` now emits a `thinking_level_changed` event from `setThinkingLevel`, and `AcpAgent` subscribes to each managed session for the session's lifetime — independent of any active prompt turn — and pushes a fresh `config_option_update` whenever the effective level changes. The `session/set_session_config_option` handler no longer pushes its own duplicate notification for the `thinking` config; clients still get the new state both via the lifetime subscription and the response payload. Subscriptions are released in `#disposeSessionRecord`. +- Fixed ACP clients missing `config_option_update` notifications when the thinking level changed via any path other than the client's own `session/set_session_config_option` call (slash commands, model auto-adjust, extension UI). `AgentSession` now emits a `thinking_level_changed` event from `setThinkingLevel`, and `AcpAgent` subscribes to each managed session for the session's lifetime and pushes a fresh `config_option_update` whenever the effective level changes — independent of any active prompt turn. The subscription is installed inside `#scheduleBootstrapUpdates`'s 50 ms timer so it shares the same race guard that prevents Zed's `Received session notification for unknown session` drop when notifications fire before `session/new` (or fork) returns; the pre-bootstrap thinking level is reported in the response's `configOptions`. The `session/set_session_config_option` handler keeps its own push only when the subscription has not yet been installed, so client-driven thinking changes still notify pre-bootstrap, post-bootstrap they flow through the subscription exactly once. Subscriptions are released in `#disposeSessionRecord`. ## [15.0.0] - 2026-05-13 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 8f7ebb748..0da6ec94e 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -97,7 +97,9 @@ type ManagedSessionRecord = { liveMessageId: string | undefined; liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined; extensionsConfigured: boolean; - // Independent of prompt-turn lifecycle — see `#handleLifetimeEvent`. + // Installed by `#scheduleBootstrapUpdates` (after the 50ms response-race + // guard) and torn down by `#disposeSessionRecord`. Independent of the + // prompt-turn lifecycle — see `#handleLifetimeEvent`. lifetimeUnsubscribe: (() => void) | undefined; }; @@ -350,9 +352,15 @@ export class AcpAgent implements Agent { }); } - // Thinking-level changes are pushed via the lifetime subscription on - // `thinking_level_changed`; skipping the push here avoids a duplicate. - if (params.configId !== THINKING_CONFIG_ID) { + // For `thinking` the lifetime subscription pushes a fresh + // `config_option_update` whenever the effective level changes. Skip the + // handler's own push when that subscription is already installed + // (post-bootstrap) to avoid a duplicate notification. Pre-bootstrap we + // still need to push here so the client sees the change — the + // subscription only starts firing once `#scheduleBootstrapUpdates` runs. + const thinkingHandledBySubscription = + params.configId === THINKING_CONFIG_ID && record.lifetimeUnsubscribe !== undefined; + if (!thinkingHandledBySubscription) { await this.#pushConfigOptionUpdate(record); } return { configOptions: this.#buildConfigOptions(record.session) }; @@ -669,9 +677,14 @@ export class AcpAgent implements Agent { async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise { const record = this.#createManagedSessionRecord(session); session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities)); - record.lifetimeUnsubscribe = session.subscribe(event => { - void this.#handleLifetimeEvent(record, event); - }); + // Lifetime subscription is installed in `#scheduleBootstrapUpdates` so it + // shares the 50ms guard that protects against Zed's + // `Received session notification for unknown session` race — the + // `session/new` (or fork) response has to land before we start pushing + // `config_option_update` notifications for this session id. The + // post-extension thinking level is already reported in the response's + // `configOptions`, so no notifications are dropped — they're just + // deferred until the client knows the session id. try { await this.#configureExtensions(record); await this.#configureMcpServers(record, mcpServers); @@ -1153,6 +1166,19 @@ export class AcpAgent implements Agent { if (!record) { return; } + // Install the session-lifetime subscription now — same 50ms guard. + // Subscribing earlier in `#registerPreparedSession` would let an + // extension's `session_start` handler (or any async work it + // schedules) call `setThinkingLevel` and push a + // `config_option_update` for a session id the client hasn't been + // told about yet (same Zed race the bootstrap delay solves). + // `#disposeSessionRecord` releases this and tolerates `undefined` + // when the session is closed before the timer fires. + if (!record.lifetimeUnsubscribe) { + record.lifetimeUnsubscribe = record.session.subscribe(event => { + void this.#handleLifetimeEvent(record, event); + }); + } void this.#emitBootstrapUpdates(sessionId, record); }, 50); } diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 8db0f7908..f7ab1e82d 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -489,12 +489,16 @@ describe("ACP agent", () => { it("pushes config_option_update when thinking level changes internally", async () => { // Internal callers (slash commands, model auto-adjust, extension UI) call // AgentSession.setThinkingLevel directly without going through the ACP - // setSessionConfigOption surface. The session-lifetime subscription on - // AcpAgent must surface those changes to clients as `config_option_update` - // so TORTAS-style fleet views stay in sync. + // setSessionConfigOption surface. Once the session-lifetime subscription + // is installed (after the 50ms bootstrap guard so the response has + // reached the client first), those changes must surface to clients as + // `config_option_update` so TORTAS-style fleet views stay in sync. const harness = await createHarness(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); const session = harness.findSession(created.sessionId)!; + // Wait past the 50ms bootstrap timer so the lifetime subscription is + // installed before we drive an internal thinking-level change. + await Bun.sleep(80); const updatesBefore = harness.updates.length; session.setThinkingLevel("high"); @@ -525,6 +529,50 @@ describe("ACP agent", () => { await Bun.sleep(0); }); + it("suppresses lifetime config_option_update during the bootstrap window", async () => { + // Regression for codex review on #1060: an extension `session_start` + // handler calling `setThinkingLevel` must not push a + // `config_option_update` for a session id the client has not been told + // about yet (matches Zed's `Received session notification for unknown + // session` race that `#scheduleBootstrapUpdates` already guards). + // The fake harness lets us simulate that pre-bootstrap window by + // driving the change before sleeping past the 50ms guard. + const harness = await createHarness(); + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + const session = harness.findSession(created.sessionId)!; + + const updatesBefore = harness.updates.length; + // Synchronously after `newSession` returns, the bootstrap timer has + // not fired yet, so the lifetime subscription is not installed. + session.setThinkingLevel("high"); + + const beforeBootstrap = harness.updates + .slice(updatesBefore) + .filter( + notification => + notification.sessionId === created.sessionId && + notification.update.sessionUpdate === "config_option_update", + ); + expect(beforeBootstrap.length).toBe(0); + + // After the 50ms bootstrap timer fires the subscription is installed, + // and subsequent changes do surface. + await Bun.sleep(80); + const baseline = harness.updates.length; + session.setThinkingLevel("medium"); + const afterBootstrap = harness.updates + .slice(baseline) + .filter( + notification => + notification.sessionId === created.sessionId && + notification.update.sessionUpdate === "config_option_update", + ); + expect(afterBootstrap.length).toBeGreaterThanOrEqual(1); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + it("emits a single config_option_update per setSessionConfigOption(thinking) call", async () => { // Client-initiated thinking changes flow through #setThinkingLevelById, // which fires `thinking_level_changed` and lets the lifetime subscription @@ -532,6 +580,10 @@ describe("ACP agent", () => { // `config_option_update` of its own. const harness = await createHarness(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + // Wait past the bootstrap guard so the lifetime subscription is + // installed and the client-driven setSessionConfigOption produces + // exactly one notification through it. + await Bun.sleep(80); const updatesBefore = harness.updates.length; const response = await harness.agent.setSessionConfigOption({ From b62660931419893f1c866ce30406eab22fcaf93b Mon Sep 17 00:00:00 2001 From: David Marshall Date: Wed, 13 May 2026 16:43:45 -0500 Subject: [PATCH 12/17] refactor(coding-agent/acp): extracted ACP_BOOTSTRAP_RACE_GUARD_MS and consolidated race notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify pass on 4882d1e38. Three small cleanups, no behavior change. * Extracted the inline 50ms bootstrap-race guard into an exported ACP_BOOTSTRAP_RACE_GUARD_MS constant at the top of acp-agent.ts. Source uses it in the #scheduleBootstrapUpdates setTimeout. Tests import it and call a new waitForBootstrapGuard() helper (constant + 30ms slack for setTimeout drift) instead of three hardcoded Bun.sleep(80) sites — tests now bind to the source-of-truth instead of dueling magic numbers. * Consolidated four block comments that all narrated the same race story into one canonical explanation at the install site (#scheduleBootstrapUpdates). Field declaration, #registerPreparedSession, and the setSessionConfigOption handler keep brief one/two-line pointers. Net change is roughly 30 lines of comments removed without losing the diagnosis. * Trimmed the handler-site thinkingHandledBySubscription comment from six lines to three; the local-variable name carries the intent. Verified: * bun test test/acp-agent.test.ts: 11/11 pass * biome check on touched files: clean * No behavior change (no test had to be updated) Co-Authored-By: omp --- .../coding-agent/src/modes/acp/acp-agent.ts | 74 +++++++++---------- packages/coding-agent/test/acp-agent.test.ts | 17 ++++- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 0da6ec94e..43255b477 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -73,6 +73,15 @@ const MODEL_CONFIG_ID = "model"; const THINKING_CONFIG_ID = "thinking"; const THINKING_OFF = "off"; const SESSION_PAGE_SIZE = 50; +/** + * Delay between `session/new` (or `session/load` / `session/resume` / + * `unstable_session/fork`) returning and the agent firing the first + * notifications against the new session id. Mitigates Zed's + * `Received session notification for unknown session` race — see + * `#scheduleBootstrapUpdates`. Exported so the ACP test harness can + * wait past this guard without hard-coding the literal. + */ +export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50; type AgentImageContent = { type: "image"; @@ -97,9 +106,8 @@ type ManagedSessionRecord = { liveMessageId: string | undefined; liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined; extensionsConfigured: boolean; - // Installed by `#scheduleBootstrapUpdates` (after the 50ms response-race - // guard) and torn down by `#disposeSessionRecord`. Independent of the - // prompt-turn lifecycle — see `#handleLifetimeEvent`. + // Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released + // in `#disposeSessionRecord`. Lives independent of any prompt turn. lifetimeUnsubscribe: (() => void) | undefined; }; @@ -352,12 +360,9 @@ export class AcpAgent implements Agent { }); } - // For `thinking` the lifetime subscription pushes a fresh - // `config_option_update` whenever the effective level changes. Skip the - // handler's own push when that subscription is already installed - // (post-bootstrap) to avoid a duplicate notification. Pre-bootstrap we - // still need to push here so the client sees the change — the - // subscription only starts firing once `#scheduleBootstrapUpdates` runs. + // For `thinking` the lifetime subscription pushes post-bootstrap; only + // push here when it's not yet installed so pre-bootstrap callers still + // see the change without a post-bootstrap duplicate. const thinkingHandledBySubscription = params.configId === THINKING_CONFIG_ID && record.lifetimeUnsubscribe !== undefined; if (!thinkingHandledBySubscription) { @@ -677,14 +682,8 @@ export class AcpAgent implements Agent { async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise { const record = this.#createManagedSessionRecord(session); session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities)); - // Lifetime subscription is installed in `#scheduleBootstrapUpdates` so it - // shares the 50ms guard that protects against Zed's - // `Received session notification for unknown session` race — the - // `session/new` (or fork) response has to land before we start pushing - // `config_option_update` notifications for this session id. The - // post-extension thinking level is already reported in the response's - // `configOptions`, so no notifications are dropped — they're just - // deferred until the client knows the session id. + // `record.lifetimeUnsubscribe` is installed in `#scheduleBootstrapUpdates` + // so it shares the bootstrap race guard — see that comment for why. try { await this.#configureExtensions(record); await this.#configureMcpServers(record, mcpServers); @@ -1146,18 +1145,25 @@ export class AcpAgent implements Agent { } #scheduleBootstrapUpdates(sessionId: string): void { - // Delay the bootstrap so the client has time to handle the `session/new` - // (or `session/load` / `session/resume`) RPC response and register the - // new sessionId before we start firing notifications against it. Zed's - // agent-client-protocol reader dispatches responses and notifications - // to different async tasks; sending the first `available_commands_update` - // from `setTimeout(0)` reliably loses the race against the response - // handler and Zed logs `Received session notification for unknown - // session` then drops the update — leaving the slash-command palette - // empty (#1015 follow-up; see zed-industries/zed#55965 for the same - // race biting other ACP agents). 50ms is invisible to the operator and - // large enough that the response future has scheduled before our timer - // fires on stdio-only transports. + // Defer first notifications until the response has reached the client. + // Zed's agent-client-protocol reader dispatches responses and + // notifications to different async tasks; sending the first + // `available_commands_update` from `setTimeout(0)` reliably loses the + // race against the response handler and Zed logs `Received session + // notification for unknown session` then drops the update — leaving + // the slash-command palette empty (#1015 follow-up; see + // zed-industries/zed#55965 for the same race biting other ACP agents). + // `ACP_BOOTSTRAP_RACE_GUARD_MS` is invisible to the operator and large + // enough that the response future has scheduled before our timer fires + // on stdio-only transports. + // + // The session-lifetime subscription is installed inside the same timer + // so it shares this guard — without it, an extension's `session_start` + // handler (or any async work it schedules) calling `setThinkingLevel` + // would push a `config_option_update` for a session id the client + // hasn't been told about yet. The pre-bootstrap thinking level is + // reported in the response's `configOptions`, so deferring the + // notification loses no state. setTimeout(() => { if (this.#connection.signal.aborted) { return; @@ -1166,21 +1172,13 @@ export class AcpAgent implements Agent { if (!record) { return; } - // Install the session-lifetime subscription now — same 50ms guard. - // Subscribing earlier in `#registerPreparedSession` would let an - // extension's `session_start` handler (or any async work it - // schedules) call `setThinkingLevel` and push a - // `config_option_update` for a session id the client hasn't been - // told about yet (same Zed race the bootstrap delay solves). - // `#disposeSessionRecord` releases this and tolerates `undefined` - // when the session is closed before the timer fires. if (!record.lifetimeUnsubscribe) { record.lifetimeUnsubscribe = record.session.subscribe(event => { void this.#handleLifetimeEvent(record, event); }); } void this.#emitBootstrapUpdates(sessionId, record); - }, 50); + }, ACP_BOOTSTRAP_RACE_GUARD_MS); } async #emitBootstrapUpdates(sessionId: string, record: ManagedSessionRecord): Promise { diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index f7ab1e82d..6cb67cef6 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -13,7 +13,7 @@ import { import type { Model } from "@oh-my-pi/pi-ai"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import { _resetSettingsForTest, Settings } from "../src/config/settings"; -import { AcpAgent } from "../src/modes/acp/acp-agent"; +import { ACP_BOOTSTRAP_RACE_GUARD_MS, AcpAgent } from "../src/modes/acp/acp-agent"; import type { PlanModeState } from "../src/plan-mode/state"; import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; import { SessionManager } from "../src/session/session-manager"; @@ -374,6 +374,15 @@ async function createHarness(): Promise { }; } +/** + * Wait until `#scheduleBootstrapUpdates`'s timer has fired and the + * session-lifetime subscription is installed. 30 ms of slack absorbs + * `setTimeout` drift without slowing tests meaningfully. + */ +async function waitForBootstrapGuard(): Promise { + await Bun.sleep(ACP_BOOTSTRAP_RACE_GUARD_MS + 30); +} + describe("ACP agent", () => { it("supports multiple live ACP sessions with model and lifecycle handlers", async () => { const harness = await createHarness(); @@ -498,7 +507,7 @@ describe("ACP agent", () => { const session = harness.findSession(created.sessionId)!; // Wait past the 50ms bootstrap timer so the lifetime subscription is // installed before we drive an internal thinking-level change. - await Bun.sleep(80); + await waitForBootstrapGuard(); const updatesBefore = harness.updates.length; session.setThinkingLevel("high"); @@ -557,7 +566,7 @@ describe("ACP agent", () => { // After the 50ms bootstrap timer fires the subscription is installed, // and subsequent changes do surface. - await Bun.sleep(80); + await waitForBootstrapGuard(); const baseline = harness.updates.length; session.setThinkingLevel("medium"); const afterBootstrap = harness.updates @@ -583,7 +592,7 @@ describe("ACP agent", () => { // Wait past the bootstrap guard so the lifetime subscription is // installed and the client-driven setSessionConfigOption produces // exactly one notification through it. - await Bun.sleep(80); + await waitForBootstrapGuard(); const updatesBefore = harness.updates.length; const response = await harness.agent.setSessionConfigOption({ From a7f73ee645533a467050f7009e4e1aa2f89bcc27 Mon Sep 17 00:00:00 2001 From: Vilmos Nebehaj Date: Wed, 13 May 2026 14:21:47 -0700 Subject: [PATCH 13/17] fix(coding-agent): persist dynamically registered MCP OAuth client_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an MCP server uses OAuth Dynamic Client Registration (RFC 7591) and no client_id is pre-configured, MCPOAuthFlow registers a fresh public PKCE client on each authorize, captures the issued client_id into a private field, then discards it once the flow object goes out of scope. At refresh time, MCPManager#resolveAuthConfig calls refreshMCPOAuthToken with auth.clientId from mcp.json — which is empty for these servers — so providers that require client_id on the refresh grant (e.g. Linear at mcp.linear.app/token) reject with HTTP 401 invalid_client. The user is forced to /mcp reauth manually every time the access token expires. This change threads the resolved/registered client credentials back out of the OAuth flow and persists them into mcp.json so refresh has what it needs indefinitely: - MCPOAuthFlow exposes resolvedClientId / registeredClientSecret getters. - MCPCommandController#handleOAuthFlow returns OAuthFlowResult with credentialId + clientId + clientSecret, populated from the flow's post-login state. - The initial-connect non-wizard path and /mcp reauth path persist the returned client credentials into both auth.{clientId,clientSecret} (used at refresh) and oauth.{clientId,clientSecret} (used by future /mcp reauth to skip re-registration). - The wizard's onOAuth callback signature now returns the same shape; #launchOAuthFlow folds the registered credentials into wizard state so the final mcp.json entry built by #buildServerConfigWithAuth includes them under auth.{clientId,clientSecret}. Servers that configure a static oauth.clientId in mcp.json (Notion, Slack, Datadog) are unaffected: #tryRegisterClient short-circuits, the returned clientId equals the configured one, and the write-back is a no-op. Adds two MCPOAuthFlow unit tests covering both paths. --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/mcp/oauth-flow.ts | 20 ++++++ .../src/modes/components/mcp-add-wizard.ts | 31 ++++++-- .../controllers/mcp-command-controller.ts | 55 ++++++++++++--- packages/coding-agent/test/oauth-flow.test.ts | 70 +++++++++++++++++++ 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 21e3ba775..387655959 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -19,6 +19,10 @@ - Changed search truncation metadata/renderer output from match/result-based limits to file-based limits (`fileLimitReached`, `perFileLimitReached`) and updated truncation labels accordingly - Lowered `read.defaultLimit` default from `500` to `300` lines, and split the per-range context padding into asymmetric `RANGE_LEADING_CONTEXT_LINES = 1` / `RANGE_TRAILING_CONTEXT_LINES = 3` (was symmetric `RANGE_CONTEXT_LINES = 3`). Replay analysis over post-summarizer sessions (`scripts/session-stats/optimize_read_config.py`) showed that bare-path reads are over-provisioned at the median (file p50 = 220 lines) and that most follow-up reads are disjoint hops rather than adjacent extensions — so a smaller default plus narrower leading context reclaims tokens without measurably changing first-cover rate. Trailing context stays at 3 lines to keep anchor-stale recovery on narrow reads. Explicit `read.defaultLimit` overrides in settings are honoured unchanged. +### Fixed + +- Fixed MCP OAuth refresh failing with `HTTP 401 invalid_client` for servers that require Dynamic Client Registration (RFC 7591) and have no `oauth.clientId` configured (e.g. `mcp.linear.app`). `MCPOAuthFlow` registered a fresh public PKCE client on each authorize and discarded the issued `client_id` once the flow object went out of scope; refresh then called the provider's `/token` endpoint without a `client_id`. The flow now exposes `resolvedClientId` / `registeredClientSecret` getters, `MCPCommandController#handleOAuthFlow` returns them alongside `credentialId`, and both the initial-connect and `/mcp reauth` paths persist them into `auth.{clientId,clientSecret}` (used at refresh) and `oauth.{clientId,clientSecret}` (used by subsequent `/mcp reauth` to skip re-registration). The `MCPAddWizard` `onOAuth` callback type is now `Promise` and `#launchOAuthFlow` folds the registered credentials into wizard state. Servers with a statically-configured `oauth.clientId` (Notion, Slack, Datadog) are unaffected — `#tryRegisterClient` short-circuits and the write-back is a no-op. ([#1061](https://github.com/can1357/oh-my-pi/pull/1061) by [@ldx](https://github.com/ldx)). + ## [15.0.0] - 2026-05-13 ### Breaking Changes diff --git a/packages/coding-agent/src/mcp/oauth-flow.ts b/packages/coding-agent/src/mcp/oauth-flow.ts index ed90f3c27..71ae572d9 100644 --- a/packages/coding-agent/src/mcp/oauth-flow.ts +++ b/packages/coding-agent/src/mcp/oauth-flow.ts @@ -133,6 +133,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow { this.#resolvedClientId = this.#resolveClientId(config); } + /** + * Client id used during the authorization request. Returns the value supplied + * via {@link MCPOAuthConfig.clientId} or, when the server required dynamic + * client registration, the id issued during registration. `undefined` until + * {@link generateAuthUrl} (or {@link login}) has run for a server that needs + * a client id. + */ + get resolvedClientId(): string | undefined { + return this.#resolvedClientId; + } + + /** + * Client secret issued by dynamic client registration, if any. Always + * `undefined` for PKCE-only/public clients and when the caller supplies the + * client id via config. + */ + get registeredClientSecret(): string | undefined { + return this.#registeredClientSecret; + } + async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { if (!this.#resolvedClientId) { await this.#tryRegisterClient(redirectUri); diff --git a/packages/coding-agent/src/modes/components/mcp-add-wizard.ts b/packages/coding-agent/src/modes/components/mcp-add-wizard.ts index 88a3dc199..36087d77e 100644 --- a/packages/coding-agent/src/modes/components/mcp-add-wizard.ts +++ b/packages/coding-agent/src/modes/components/mcp-add-wizard.ts @@ -47,6 +47,18 @@ type WizardStep = | "scope" | "confirm"; +/** + * Result of the wizard's OAuth callback. `credentialId` is mandatory; + * `clientId`/`clientSecret` are populated when the OAuth provider performed + * dynamic client registration (or when the caller pre-supplied them) so the + * wizard can fold them into the final `mcp.json` entry for refresh. + */ +export interface MCPAddWizardOAuthResult { + credentialId: string; + clientId?: string; + clientSecret?: string; +} + interface WizardState { name: string; transport: TransportType | null; @@ -104,7 +116,13 @@ export class MCPAddWizard extends Container { #onCompleteCallback: (name: string, config: MCPServerConfig, scope: Scope) => void; #onCancelCallback: () => void; #onOAuthCallback: - | ((authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string) => Promise) + | (( + authUrl: string, + tokenUrl: string, + clientId: string, + clientSecret: string, + scopes: string, + ) => Promise) | null = null; #onTestConnectionCallback: ((config: MCPServerConfig) => Promise) | null = null; #onRenderCallback: (() => void) | null = null; @@ -118,7 +136,7 @@ export class MCPAddWizard extends Container { clientId: string, clientSecret: string, scopes: string, - ) => Promise, + ) => Promise, onTestConnection?: (config: MCPServerConfig) => Promise, onRender?: () => void, initialName?: string, @@ -1120,7 +1138,7 @@ export class MCPAddWizard extends Container { try { // Call OAuth handler - const credentialId = await this.#onOAuthCallback( + const oauthResult = await this.#onOAuthCallback( this.#state.oauthAuthUrl, this.#state.oauthTokenUrl, this.#state.oauthClientId, @@ -1128,8 +1146,11 @@ export class MCPAddWizard extends Container { this.#state.oauthScopes, ); - // Store credential ID - this.#state.oauthCredentialId = credentialId; + // Store credential ID + any dynamically-registered client credentials, + // so the final mcp.json entry persists everything needed for refresh. + this.#state.oauthCredentialId = oauthResult.credentialId; + if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId; + if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret; // Show success message this.#contentContainer.clear(); 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 dc28c7248..d6dbe2972 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -49,6 +49,22 @@ function withTimeout(promise: Promise, timeoutMs: number, message: string) return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); } +/** + * Outcome of {@link MCPCommandController}'s OAuth handler. + * + * `clientId`/`clientSecret` are populated when the OAuth provider required (or + * accepted) dynamic client registration; callers MUST persist them alongside + * `credentialId` so subsequent token refreshes and reauthorizations can reuse + * the same registered client. Both are also set when the caller pre-supplied a + * client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the + * write-back is a no-op. + */ +interface OAuthFlowResult { + credentialId: string; + clientId?: string; + clientSecret?: string; +} + type MCPAddScope = "user" | "project"; type MCPAddTransport = "http" | "sse"; @@ -406,7 +422,7 @@ export class MCPCommandController { try { const oauthClientSecret = finalConfig.oauth?.clientSecret ?? ""; - const credentialId = await this.#handleOAuthFlow( + const oauthResult = await this.#handleOAuthFlow( oauth.authorizationUrl, oauth.tokenUrl, oauth.clientId ?? finalConfig.oauth?.clientId ?? "", @@ -416,14 +432,21 @@ export class MCPCommandController { finalConfig.oauth?.callbackPath, finalConfig.oauth?.redirectUri, ); + const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId; + const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret; finalConfig = { ...finalConfig, auth: { type: "oauth", - credentialId, + credentialId: oauthResult.credentialId, tokenUrl: oauth.tokenUrl, - clientId: oauth.clientId ?? finalConfig.oauth?.clientId, - clientSecret: finalConfig.oauth?.clientSecret, + clientId: persistedClientId, + clientSecret: persistedClientSecret, + }, + oauth: { + ...finalConfig.oauth, + clientId: persistedClientId ?? finalConfig.oauth?.clientId, + clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret, }, }; } catch (oauthError) { @@ -488,7 +511,7 @@ export class MCPCommandController { callbackPort?: number, callbackPath?: string, redirectUri?: string, - ): Promise { + ): Promise { const authStorage = this.ctx.session.modelRegistry.authStorage; let parsedAuthUrl: URL; @@ -600,7 +623,11 @@ export class MCPCommandController { // Store under a synthetic provider name await authStorage.set(credentialId, oauthCredential); - return credentialId; + return { + credentialId, + clientId: flow.resolvedClientId, + clientSecret: flow.registeredClientSecret, + }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -1348,7 +1375,7 @@ export class MCPCommandController { this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n")); - const credentialId = await this.#handleOAuthFlow( + const oauthResult = await this.#handleOAuthFlow( oauth.authorizationUrl, oauth.tokenUrl, oauth.clientId ?? found.config.oauth?.clientId ?? "", @@ -1359,14 +1386,22 @@ export class MCPCommandController { found.config.oauth?.redirectUri, ); + const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId; + const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined); + const updated: MCPServerConfig = { ...baseConfig, auth: { type: "oauth", - credentialId, + credentialId: oauthResult.credentialId, tokenUrl: oauth.tokenUrl, - clientId: oauth.clientId ?? found.config.oauth?.clientId, - clientSecret: oauthClientSecret || undefined, + clientId: persistedClientId, + clientSecret: persistedClientSecret, + }, + oauth: { + ...found.config.oauth, + clientId: persistedClientId ?? found.config.oauth?.clientId, + clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret, }, }; await updateMCPServer(found.filePath, name, updated); diff --git a/packages/coding-agent/test/oauth-flow.test.ts b/packages/coding-agent/test/oauth-flow.test.ts index c81afd5ea..6e31daa09 100644 --- a/packages/coding-agent/test/oauth-flow.test.ts +++ b/packages/coding-agent/test/oauth-flow.test.ts @@ -309,4 +309,74 @@ describe("mcp oauth flow", () => { await expect(flow.login()).rejects.toThrow("cannot fall back to a random port when oauth.redirectUri is set"); }); + + it("exposes the dynamically registered client_id and client_secret after generateAuthUrl", async () => { + using _hook = hookFetch(input => { + const url = String(input); + if (url === "https://www.figma.com/.well-known/oauth-authorization-server") { + return new Response( + JSON.stringify({ registration_endpoint: "https://api.figma.com/v1/oauth/mcp/register" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "https://api.figma.com/v1/oauth/mcp/register") { + return new Response( + JSON.stringify({ + client_id: "registered-client-id", + client_secret: "registered-client-secret", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + + const flow = new MCPOAuthFlow( + { + authorizationUrl: "https://www.figma.com/oauth/mcp", + tokenUrl: "https://api.figma.com/v1/oauth/token", + }, + {}, + ); + + expect(flow.resolvedClientId).toBeUndefined(); + expect(flow.registeredClientSecret).toBeUndefined(); + + await flow.generateAuthUrl("test-state", "http://127.0.0.1:53173/callback"); + + expect(flow.resolvedClientId).toBe("registered-client-id"); + expect(flow.registeredClientSecret).toBe("registered-client-secret"); + }); + + it("returns the configured client_id from resolvedClientId without triggering registration", async () => { + let registrationCalled = false; + using _hook = hookFetch(input => { + const url = String(input); + if (url.includes("/.well-known/")) { + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.endsWith("/register")) { + registrationCalled = true; + } + return new Response("not found", { status: 404 }); + }); + + const flow = new MCPOAuthFlow( + { + authorizationUrl: "https://provider.example/authorize", + tokenUrl: "https://provider.example/token", + clientId: "configured-client-id", + }, + {}, + ); + + expect(flow.resolvedClientId).toBe("configured-client-id"); + expect(flow.registeredClientSecret).toBeUndefined(); + + await flow.generateAuthUrl("test-state", "http://127.0.0.1:53174/callback"); + + expect(flow.resolvedClientId).toBe("configured-client-id"); + expect(flow.registeredClientSecret).toBeUndefined(); + expect(registrationCalled).toBe(false); + }); }); From 6b6cc417f22122c6c5a2a4fc5c332d33bc30e5db Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 14 May 2026 03:26:25 +0200 Subject: [PATCH 14/17] test(coding-agent/acp): bumped ACP stdout-hygiene timeout to 60s for slow CI runners --- packages/coding-agent/test/acp-stdout-hygiene.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/test/acp-stdout-hygiene.test.ts b/packages/coding-agent/test/acp-stdout-hygiene.test.ts index 84a8a484e..97d37532e 100644 --- a/packages/coding-agent/test/acp-stdout-hygiene.test.ts +++ b/packages/coding-agent/test/acp-stdout-hygiene.test.ts @@ -149,5 +149,5 @@ describe("ACP stdout hygiene", () => { } expect(parsed?.jsonrpc, `JSON-RPC frame leaked to stderr: ${trimmed}`).toBeUndefined(); } - }, 20_000); + }, 60_000); }); From fa1d83e5278de26ddba9c3fa18d8a26c270f04d5 Mon Sep 17 00:00:00 2001 From: enieuwy Date: Thu, 14 May 2026 08:46:19 +0800 Subject: [PATCH 15/17] Fix copy fallback for handoff context --- .../modes/controllers/command-controller.ts | 17 +++-- .../coding-agent/src/session/agent-session.ts | 62 +++++++++++++++---- .../test/agent-session-handoff.test.ts | 5 ++ .../modes/controllers/copy-command.test.ts | 53 ++++++++++++++++ 4 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 packages/coding-agent/test/modes/controllers/copy-command.test.ts diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 919f3a427..c578452d0 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -256,12 +256,21 @@ export class CommandController { } #copyLastMessage() { - const text = this.ctx.session.getLastAssistantText(); - if (!text) { - this.ctx.showError("No agent messages to copy yet."); + const assistantText = this.ctx.session.getLastAssistantText(); + if (assistantText) { + this.#doCopy(assistantText, "Copied last agent message to clipboard"); return; } - this.#doCopy(text, "Copied last agent message to clipboard"); + + if (!this.ctx.session.hasCopyCandidateAssistantMessage()) { + const handoffText = this.ctx.session.getLastVisibleHandoffText(); + if (handoffText) { + this.#doCopy(handoffText, "Copied handoff context to clipboard"); + return; + } + } + + this.ctx.showError("No agent messages to copy yet."); } #copyCode() { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f61f35a13..61e5b738f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -7840,21 +7840,11 @@ export class AgentSession { * @returns Text content, or undefined if no assistant message exists */ getLastAssistantText(): string | undefined { - const lastAssistant = this.messages - .slice() - .reverse() - .find(m => { - if (m.role !== "assistant") return false; - const msg = m as AssistantMessage; - // Skip aborted messages with no content - if (msg.stopReason === "aborted" && msg.content.length === 0) return false; - return true; - }); - + const lastAssistant = this.#getLastCopyCandidateAssistantMessage(); if (!lastAssistant) return undefined; let text = ""; - for (const content of (lastAssistant as AssistantMessage).content) { + for (const content of lastAssistant.content) { if (content.type === "text") { text += content.text; } @@ -7863,6 +7853,54 @@ export class AgentSession { return text.trim() || undefined; } + hasCopyCandidateAssistantMessage(): boolean { + return this.#getLastCopyCandidateAssistantMessage() !== undefined; + } + + #getLastCopyCandidateAssistantMessage(): AssistantMessage | undefined { + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (message.role !== "assistant") continue; + + const assistantMessage = message as AssistantMessage; + // Skip aborted messages with no content + if (assistantMessage.stopReason === "aborted" && assistantMessage.content.length === 0) continue; + + return assistantMessage; + } + + return undefined; + } + /** + * Get text content of the most recent visible handoff message. + * Fresh handoff sessions store the handoff context as a custom message, not + * an assistant message, so callers that copy the "last" message can use this + * as a fallback before the new session has an assistant response. + */ + getLastVisibleHandoffText(): string | undefined { + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (message.role !== "custom") continue; + + const customMessage = message as CustomMessage; + if (customMessage.customType !== "handoff" || !customMessage.display) continue; + + if (typeof customMessage.content === "string") { + return customMessage.content.trim() || undefined; + } + + let text = ""; + for (const content of customMessage.content) { + if (content.type === "text") { + text += content.text; + } + } + return text.trim() || undefined; + } + + return undefined; + } + /** * Format the entire session as plain text for clipboard export. * Includes user messages, assistant text, thinking blocks, tool calls, and tool results. diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index dfaf95f98..53bcbdbce 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -242,6 +242,11 @@ describe("AgentSession handoff", () => { .map(line => JSON.parse(line) as PersistedEntry); expect(result?.document).toBe(handoffText); + expect(session.getLastAssistantText()).toBeUndefined(); + expect(session.hasCopyCandidateAssistantMessage()).toBe(false); + expect(session.getLastVisibleHandoffText()).toBe( + `\n${handoffText}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`, + ); expect(handoffSessionFile).not.toBe(previousSessionFile); expect(handoffEntries[0]).toMatchObject({ type: "session", parentSession: previousSessionFile }); expect( diff --git a/packages/coding-agent/test/modes/controllers/copy-command.test.ts b/packages/coding-agent/test/modes/controllers/copy-command.test.ts new file mode 100644 index 000000000..b87f4d178 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/copy-command.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import * as native from "@oh-my-pi/pi-natives"; + +function createController(options: { assistantText?: string; hasAssistantMessage?: boolean; handoffText?: string }) { + const showStatus = vi.fn(); + const showError = vi.fn(); + const ctx = { + session: { + getLastAssistantText: () => options.assistantText, + hasCopyCandidateAssistantMessage: () => options.hasAssistantMessage ?? options.assistantText !== undefined, + getLastVisibleHandoffText: () => options.handoffText, + }, + showStatus, + showError, + } as unknown as InteractiveModeContext; + + return { controller: new CommandController(ctx), showStatus, showError }; +} + +describe("/copy command", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("falls back to the fresh handoff context when no assistant message exists", () => { + const copySpy = vi.spyOn(native, "copyToClipboard").mockImplementation(() => undefined); + const { controller, showStatus, showError } = createController({ + handoffText: "\n## Goal\nContinue\n", + }); + + controller.handleCopyCommand(); + + expect(copySpy).toHaveBeenCalledWith("\n## Goal\nContinue\n"); + expect(showStatus).toHaveBeenCalledWith("Copied handoff context to clipboard"); + expect(showError).not.toHaveBeenCalled(); + }); + + it("does not fall back to stale handoff context after a textless assistant response", () => { + const copySpy = vi.spyOn(native, "copyToClipboard").mockImplementation(() => undefined); + const { controller, showStatus, showError } = createController({ + hasAssistantMessage: true, + handoffText: "\n## Goal\nContinue\n", + }); + + controller.handleCopyCommand(); + + expect(copySpy).not.toHaveBeenCalled(); + expect(showStatus).not.toHaveBeenCalled(); + expect(showError).toHaveBeenCalledWith("No agent messages to copy yet."); + }); +}); From f1f6516056664209f0c844949d75f693c443dea0 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 14 May 2026 04:02:09 +0200 Subject: [PATCH 16/17] refactor: reorganized exports and removed obsolete helper branches - Removed export leakage by demoting many helper and const symbols to module-local scope. - Renamed underscore-prefixed internals and cache fields, then updated related references and `satisfies never` checks. - Deleted obsolete logic branches and helpers, including harmony-stream interruption flow and unused benchmark runtime helpers. - Updated Biome config and manifests by broadening lint coverage and removing an unused `@napi-rs/cli` dev dependency. - Adjusted tests and utilities to use renamed test helpers and remove redundant private test-only helpers/locals. --- .fallowrc.jsonc | 29 ++ biome.json | 6 + package.json | 283 ++++++----- packages/agent/src/agent-loop.ts | 22 - packages/agent/src/harmony-leak.ts | 1 - packages/agent/test/agent-loop.test.ts | 2 +- packages/agent/test/helpers.ts | 2 +- .../src/providers/azure-openai-responses.ts | 102 +--- .../ai/src/providers/google-gemini-cli.ts | 96 +--- packages/ai/src/providers/google-shared.ts | 450 +++++++++++++++++- packages/ai/src/providers/google-vertex.ts | 390 +-------------- packages/ai/src/providers/google.ts | 375 +-------------- packages/ai/src/providers/kimi.ts | 111 +---- .../ai/src/providers/openai-anthropic-shim.ts | 136 ++++++ .../src/providers/openai-codex-responses.ts | 126 +---- .../ai/src/providers/openai-completions.ts | 19 +- .../src/providers/openai-responses-shared.ts | 167 ++++++- packages/ai/src/providers/openai-responses.ts | 86 +--- packages/ai/src/providers/synthetic.ts | 117 +---- packages/ai/src/types.ts | 2 +- packages/ai/src/utils/oauth/lm-studio.ts | 2 - .../ai/test/anthropic-stream-envelope.test.ts | 8 +- packages/ai/test/github-copilot-login.test.ts | 2 - packages/ai/test/oauth.ts | 2 +- packages/ai/test/stream.test.ts | 2 +- .../examples/extensions/plan-mode.ts | 1 - .../coding-agent/src/autoresearch/helpers.ts | 17 + .../src/autoresearch/tools/log-experiment.ts | 26 +- .../src/autoresearch/tools/run-experiment.ts | 19 +- packages/coding-agent/src/cli/list-models.ts | 2 +- packages/coding-agent/src/cli/shell-cli.ts | 16 +- packages/coding-agent/src/cli/update-cli.ts | 2 +- .../src/commit/analysis/conventional.ts | 74 +-- .../src/commit/map-reduce/reduce-phase.ts | 71 +-- packages/coding-agent/src/commit/pipeline.ts | 4 +- .../coding-agent/src/commit/shared-llm.ts | 89 ++++ .../src/config/model-equivalence.ts | 19 +- .../coding-agent/src/config/model-resolver.ts | 5 +- packages/coding-agent/src/config/settings.ts | 2 +- .../src/eval/js/shared/rewrite-imports.ts | 4 +- .../coding-agent/src/exec/bash-executor.ts | 3 +- .../src/extensibility/extensions/runner.ts | 2 +- .../src/extensibility/extensions/types.ts | 309 ++++-------- .../src/extensibility/hooks/types.ts | 403 ++++------------ .../src/extensibility/shared-events.ts | 335 +++++++++++++ .../src/hindsight/mental-models.ts | 2 +- .../src/internal-urls/agent-protocol.ts | 21 +- .../src/internal-urls/artifact-protocol.ts | 20 +- .../src/internal-urls/registry-helpers.ts | 25 + .../src/modes/components/bash-execution.ts | 87 +--- .../src/modes/components/custom-message.ts | 54 +-- .../src/modes/components/eval-execution.ts | 84 ++-- .../src/modes/components/execution-shared.ts | 102 ++++ .../src/modes/components/hook-message.ts | 66 +-- .../src/modes/components/message-frame.ts | 88 ++++ .../src/modes/components/model-selector.ts | 2 +- .../src/modes/components/session-selector.ts | 2 +- .../controllers/command-controller-shared.ts | 108 +++++ .../controllers/mcp-command-controller.ts | 62 +-- .../controllers/ssh-command-controller.ts | 75 +-- packages/coding-agent/src/modes/print-mode.ts | 94 +--- .../coding-agent/src/modes/rpc/rpc-mode.ts | 101 +--- .../coding-agent/src/modes/runtime-init.ts | 115 +++++ .../coding-agent/src/modes/theme/theme.ts | 12 +- .../src/modes/utils/ui-helpers.ts | 2 +- .../coding-agent/src/session/agent-session.ts | 140 +++--- packages/coding-agent/src/session/messages.ts | 3 +- packages/coding-agent/src/tools/ast-edit.ts | 141 +----- packages/coding-agent/src/tools/ast-grep.ts | 140 +----- .../coding-agent/src/tools/browser/attach.ts | 6 +- .../coding-agent/src/tools/browser/launch.ts | 16 +- .../src/tools/browser/registry.ts | 6 +- .../src/tools/browser/tab-supervisor.ts | 4 - packages/coding-agent/src/tools/find.ts | 58 +-- .../coding-agent/src/tools/inspect-image.ts | 13 +- packages/coding-agent/src/tools/path-utils.ts | 123 +++++ packages/coding-agent/src/tools/read.ts | 2 - .../coding-agent/src/tools/render-utils.ts | 92 ++++ packages/coding-agent/src/tools/search.ts | 166 ++----- .../coding-agent/src/utils/file-mentions.ts | 2 +- packages/coding-agent/src/utils/fuzzy.ts | 108 ----- packages/coding-agent/src/vim/parser.ts | 17 - packages/coding-agent/src/vim/render.ts | 2 +- packages/coding-agent/src/vim/types.ts | 2 +- packages/coding-agent/test/acp-agent.test.ts | 4 +- .../test/agent-session-bash-detach.test.ts | 6 +- .../test/agent-session-retry-fallback.test.ts | 9 - .../test/autocomplete-max-visible.test.ts | 8 +- .../coding-agent/test/bash-executor.test.ts | 6 +- packages/coding-agent/test/config-cli.test.ts | 6 +- .../coding-agent/test/config-spacing.test.ts | 6 +- .../coding-agent/test/core/hashline.test.ts | 4 +- .../discovery/disabled-extensions.test.ts | 6 +- .../edit-auto-generated-regressions.test.ts | 4 +- .../test/edit-per-file-diff-content.test.ts | 6 +- .../test/extensions-runner.test.ts | 6 +- packages/coding-agent/test/fuzzy.test.ts | 2 +- .../test/hindsight-backend.test.ts | 10 +- .../coding-agent/test/hindsight-tools.test.ts | 10 +- .../interactive-mode-editor-component.test.ts | 6 +- .../test/interactive-mode-lsp-startup.test.ts | 6 +- .../test/interactive-mode-plan-review.test.ts | 6 +- .../coding-agent/test/issue-816-repro.test.ts | 6 +- .../coding-agent/test/issue-845-repro.test.ts | 6 +- .../coding-agent/test/issue-927-repro.test.ts | 6 +- .../test/memory-backend-resolve.test.ts | 6 +- .../coding-agent/test/model-registry.test.ts | 6 +- .../assistant-message-mermaid.test.ts | 6 +- .../event-controller-idle-compaction.test.ts | 6 +- ...-issue-1022-disabled-default-model.test.ts | 6 +- .../session-manager/tree-traversal.test.ts | 32 +- .../test/settings-manager.test.ts | 6 +- .../test/status-line-overflow.test.ts | 6 +- .../test/tools/auto-generated-guard.test.ts | 4 +- .../test/tools/conflict-integration.test.ts | 6 +- .../test/tools/fetch-kagi-toggle.test.ts | 17 +- packages/coding-agent/test/tools/gh.test.ts | 18 - .../coding-agent/test/tools/image-gen.test.ts | 24 - packages/coding-agent/test/tools/vim.test.ts | 4 +- .../web-scrapers/youtube-parallel.test.ts | 6 +- .../test/tools/web-search-searxng.test.ts | 4 +- packages/coding-agent/test/update-cli.test.ts | 8 +- packages/coding-agent/test/utilities.ts | 81 +--- packages/natives/package.json | 123 +++-- packages/natives/test/issue-892-repro.test.ts | 1 - .../src/client/components/BehaviorChart.tsx | 359 ++++---------- .../client/components/BehaviorModelsTable.tsx | 425 ++++++----------- .../stats/src/client/components/CostChart.tsx | 335 ++++--------- .../src/client/components/ModelsTable.tsx | 372 +++++---------- .../src/client/components/RequestDetail.tsx | 2 - .../src/client/components/chart-shared.tsx | 320 +++++++++++++ .../client/components/models-table-shared.tsx | 275 +++++++++++ packages/stats/src/client/types.ts | 142 +----- packages/stats/src/shared-types.ts | 204 ++++++++ packages/stats/src/types.ts | 205 +------- packages/tui/bench/_jskey.ts | 62 +-- packages/tui/src/components/markdown.ts | 10 +- packages/tui/test/test-themes.ts | 2 +- .../src/formatter.ts | 2 +- .../typescript-edit-benchmark/src/runner.ts | 364 -------------- .../typescript-edit-benchmark/src/tasks.ts | 2 - 141 files changed, 4219 insertions(+), 5465 deletions(-) create mode 100644 .fallowrc.jsonc create mode 100644 packages/ai/src/providers/openai-anthropic-shim.ts create mode 100644 packages/coding-agent/src/commit/shared-llm.ts create mode 100644 packages/coding-agent/src/extensibility/shared-events.ts create mode 100644 packages/coding-agent/src/internal-urls/registry-helpers.ts create mode 100644 packages/coding-agent/src/modes/components/execution-shared.ts create mode 100644 packages/coding-agent/src/modes/components/message-frame.ts create mode 100644 packages/coding-agent/src/modes/controllers/command-controller-shared.ts create mode 100644 packages/coding-agent/src/modes/runtime-init.ts delete mode 100644 packages/coding-agent/src/utils/fuzzy.ts create mode 100644 packages/stats/src/client/components/chart-shared.tsx create mode 100644 packages/stats/src/client/components/models-table-shared.tsx create mode 100644 packages/stats/src/shared-types.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc new file mode 100644 index 000000000..7672cb8ae --- /dev/null +++ b/.fallowrc.jsonc @@ -0,0 +1,29 @@ +{ + // Fallow configuration — see `bunx fallow config-schema` for the full schema. + // + // `entry` is critical: fallow's dead-code analyzer treats unlisted test files + // as unreachable, which means any helper imported only from other tests looks + // orphaned. Without this, `fallow fix` strips real `export` keywords from + // shared test utilities (e.g. `e2eApiKey`, `withEnv`, `waitForDelayOrAbort`), + // breaking the test build with `noUnusedVariables` lint errors. + "entry": [ + "packages/*/test/**/*.{ts,tsx}", + "packages/*/test/**/*.test.{ts,tsx}", + "packages/*/bench/**/*.{ts,tsx}", + "packages/*/scripts/**/*.ts" + ], + "duplicates": { + "ignore": [ + // Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts. + "packages/natives/native/index.js", + // Generated by `packages/coding-agent/scripts/generate-docs-index.ts`. + "packages/coding-agent/src/internal-urls/docs-index.generated.ts", + // Embedded HTML asset shipped as a static template, not a code module. + "packages/coding-agent/src/export/html/template.js", + // Generated/owned upstream — see packages/ai/scripts/generate-models.ts. + "packages/ai/src/models.json" + ], + "ignoreImports": true + }, + "rules": {} +} diff --git a/biome.json b/biome.json index a5a8f3ecf..6ea073e83 100644 --- a/biome.json +++ b/biome.json @@ -7,10 +7,16 @@ }, "linter": { "enabled": true, + "includes": ["**"], "rules": { "recommended": true, + "a11y": "off", "correctness": { "noUnusedImports": "error", + "noUnusedVariables": { + "level": "warn", + "fix": "none" + }, "noVoidTypeReturn": "off" }, "style": { diff --git a/package.json b/package.json index bd9eb98b9..8079e7703 100644 --- a/package.json +++ b/package.json @@ -1,144 +1,143 @@ { - "name": "omp-monorepo", - "private": true, - "type": "module", - "packageManager": "bun@1.3.14", - "workspaces": { - "packages": [ - "packages/*" - ], - "catalog": { - "@agentclientprotocol/sdk": "0.21.0", - "@anthropic-ai/sdk": "^0.94.0", - "@aws-sdk/client-bedrock-runtime": "^3.1043.0", - "@aws-sdk/credential-provider-node": "^3.972.39", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.3", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@biomejs/biome": "^2.4.14", - "@bufbuild/protobuf": "^2.12.0", - "@bufbuild/protoc-gen-es": "^2.12.0", - "@google/genai": "^1.52.0", - "@mozilla/readability": "^0.6.0", - "@napi-rs/cli": "3.6.2", - "@oh-my-pi/omp-stats": "15.0.0", - "@oh-my-pi/pi-agent-core": "15.0.0", - "@oh-my-pi/pi-ai": "15.0.0", - "@oh-my-pi/pi-coding-agent": "15.0.0", - "@oh-my-pi/pi-natives": "15.0.0", - "@oh-my-pi/pi-tui": "15.0.0", - "@oh-my-pi/pi-utils": "15.0.0", - "@puppeteer/browsers": "^2.13.0", - "@sinclair/typebox": "^0.34.49", - "@smithy/node-http-handler": "^4.6.1", - "@tailwindcss/node": "^4.2.4", - "@types/babel__generator": "^7.27.0", - "@types/babel__traverse": "^7.28.0", - "@types/bun": "^1.3.14", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@types/turndown": "5.0.6", - "@typescript/native-preview": "7.0.0-dev.20260505.1", - "@xterm/headless": "^6.0.0", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "beautiful-mermaid": "^1.1.3", - "chalk": "^5.6.2", - "chart.js": "^4.5.1", - "date-fns": "^4.1.0", - "diff": "^9.0.0", - "fflate": "0.8.2", - "handlebars": "^4.7.9", - "linkedom": "^0.18.12", - "lint-staged": "^16.4.0", - "lru-cache": "11.3.6", - "lucide-react": "^1.14.0", - "marked": "^18.0.3", - "markit-ai": "0.5.3", - "openai": "^6.36.0", - "partial-json": "^0.1.7", - "postcss": "^8.5.14", - "prettier": "^3.8.3", - "proxy-agent": "^8.0.1", - "puppeteer-core": "^24.42.0", - "react": "19.2.5", - "react-chartjs-2": "^5.3.1", - "react-dom": "19.2.5", - "regexp-tree": "^0.1.27", - "tailwindcss": "^4.2.4", - "turndown": "7.2.4", - "turndown-plugin-gfm": "1.0.2", - "typescript": "^6.0.3", - "winston": "^3.19.0", - "winston-daily-rotate-file": "^5.0.0", - "zod": "4.4.3" - } - }, - "overrides": { - "ajv": "8.20.0" - }, - "scripts": { - "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link", - "dev": "bun --cwd=packages/coding-agent src/cli.ts", - "stats": "bun --cwd=packages/coding-agent src/cli.ts stats", - "build": "bun run --workspaces --if-present build", - "build:native": "bun --cwd=packages/natives run build", - "test": "bun run --parallel test:ts test:rs", - "test:ts": "bun run --workspaces --if-present test -- --only-failures", - "test:rs": "bun scripts/run-rs-task.ts test:rs", - "check": "bun run --parallel check:ts check:rs", - "check:ts": "bun run check:tools && bun run --workspaces --if-present check", - "check:tools": "biome check . --no-errors-on-unmatched", - "check:rs": "bun scripts/run-rs-task.ts check:rs", - "lint": "bun run --parallel lint:ts lint:rs", - "lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint", - "lint:tools": "biome lint . --no-errors-on-unmatched", - "lint:rs": "bun scripts/run-rs-task.ts lint:rs", - "fmt": "bun run --parallel fmt:ts fmt:rs", - "fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt", - "fmt:tools": "biome format --write . --no-errors-on-unmatched", - "fmt:rs": "bun scripts/run-rs-task.ts fmt:rs", - "fix": "bun run --parallel fix:ts fix:rs", - "fix:all": "bun run --parallel fix:ts:all fix:rs", - "fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix", - "fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix", - "fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .", - "fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .", - "fix:rs": "bun scripts/run-rs-task.ts fix:rs", - "ci:check:full": "bun run check:ts", - "ci:build:native": "bun scripts/ci-build-native.ts", - "ci:test:full": "bun run test", - "ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test", - "ci:test:install-methods": "bash scripts/install-tests/run-ci.sh", - "ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts", - "ci:release:publish": "bun scripts/ci-release-publish.ts", - "bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8", - "bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start", - "stats:sync": "python3 scripts/session-stats/sync.py", - "stats:tools": "python3 scripts/session-stats/analyze.py tools", - "stats:edits": "python3 scripts/session-stats/analyze.py edits", - "stats:followups": "python3 scripts/session-stats/analyze.py followups", - "prepublishOnly": "bun run check", - "prepare": "bun --cwd=packages/coding-agent run generate-docs-index", - "publish": "bun run prepublishOnly && npm publish -ws --access public", - "publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run", - "release": "bun scripts/release.ts", - "generate-models": "bun --cwd=packages/ai run generate-models", - "generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index", - "generate-template": "bun --cwd=packages/coding-agent run generate-template", - "check-spoofed-versions": "bun scripts/check-spoofed-versions.ts" - }, - "devDependencies": { - "@biomejs/biome": "catalog:", - "@bufbuild/protoc-gen-es": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - "typescript": "catalog:", - "lint-staged": "catalog:", - "prettier": "catalog:" - }, - "lint-staged": { - "*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched" - } + "name": "omp-monorepo", + "private": true, + "type": "module", + "packageManager": "bun@1.3.14", + "workspaces": { + "packages": [ + "packages/*" + ], + "catalog": { + "@agentclientprotocol/sdk": "0.21.0", + "@anthropic-ai/sdk": "^0.94.0", + "@aws-sdk/client-bedrock-runtime": "^3.1043.0", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.3", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@biomejs/biome": "^2.4.14", + "@bufbuild/protobuf": "^2.12.0", + "@bufbuild/protoc-gen-es": "^2.12.0", + "@google/genai": "^1.52.0", + "@mozilla/readability": "^0.6.0", + "@napi-rs/cli": "3.6.2", + "@oh-my-pi/omp-stats": "15.0.0", + "@oh-my-pi/pi-agent-core": "15.0.0", + "@oh-my-pi/pi-ai": "15.0.0", + "@oh-my-pi/pi-coding-agent": "15.0.0", + "@oh-my-pi/pi-natives": "15.0.0", + "@oh-my-pi/pi-tui": "15.0.0", + "@oh-my-pi/pi-utils": "15.0.0", + "@puppeteer/browsers": "^2.13.0", + "@sinclair/typebox": "^0.34.49", + "@smithy/node-http-handler": "^4.6.1", + "@tailwindcss/node": "^4.2.4", + "@types/babel__generator": "^7.27.0", + "@types/babel__traverse": "^7.28.0", + "@types/bun": "^1.3.14", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/turndown": "5.0.6", + "@typescript/native-preview": "7.0.0-dev.20260505.1", + "@xterm/headless": "^6.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "beautiful-mermaid": "^1.1.3", + "chalk": "^5.6.2", + "chart.js": "^4.5.1", + "date-fns": "^4.1.0", + "diff": "^9.0.0", + "fflate": "0.8.2", + "handlebars": "^4.7.9", + "linkedom": "^0.18.12", + "lint-staged": "^16.4.0", + "lru-cache": "11.3.6", + "lucide-react": "^1.14.0", + "marked": "^18.0.3", + "markit-ai": "0.5.3", + "openai": "^6.36.0", + "partial-json": "^0.1.7", + "postcss": "^8.5.14", + "prettier": "^3.8.3", + "proxy-agent": "^8.0.1", + "puppeteer-core": "^24.42.0", + "react": "19.2.5", + "react-chartjs-2": "^5.3.1", + "react-dom": "19.2.5", + "regexp-tree": "^0.1.27", + "tailwindcss": "^4.2.4", + "turndown": "7.2.4", + "turndown-plugin-gfm": "1.0.2", + "typescript": "^6.0.3", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0", + "zod": "4.4.3" + } + }, + "overrides": { + "ajv": "8.20.0" + }, + "scripts": { + "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link", + "dev": "bun --cwd=packages/coding-agent src/cli.ts", + "stats": "bun --cwd=packages/coding-agent src/cli.ts stats", + "build": "bun run --workspaces --if-present build", + "build:native": "bun --cwd=packages/natives run build", + "test": "bun run --parallel test:ts test:rs", + "test:ts": "bun run --workspaces --if-present test -- --only-failures", + "test:rs": "bun scripts/run-rs-task.ts test:rs", + "check": "bun run --parallel check:ts check:rs", + "check:ts": "bun run check:tools && bun run --workspaces --if-present check", + "check:tools": "biome check . --no-errors-on-unmatched", + "check:rs": "bun scripts/run-rs-task.ts check:rs", + "lint": "bun run --parallel lint:ts lint:rs", + "lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint", + "lint:tools": "biome lint . --no-errors-on-unmatched", + "lint:rs": "bun scripts/run-rs-task.ts lint:rs", + "fmt": "bun run --parallel fmt:ts fmt:rs", + "fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt", + "fmt:tools": "biome format --write . --no-errors-on-unmatched", + "fmt:rs": "bun scripts/run-rs-task.ts fmt:rs", + "fix": "bun run --parallel fix:ts fix:rs", + "fix:all": "bun run --parallel fix:ts:all fix:rs", + "fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix", + "fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix", + "fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .", + "fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .", + "fix:rs": "bun scripts/run-rs-task.ts fix:rs", + "ci:check:full": "bun run check:ts", + "ci:build:native": "bun scripts/ci-build-native.ts", + "ci:test:full": "bun run test", + "ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test", + "ci:test:install-methods": "bash scripts/install-tests/run-ci.sh", + "ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts", + "ci:release:publish": "bun scripts/ci-release-publish.ts", + "bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8", + "bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start", + "stats:sync": "python3 scripts/session-stats/sync.py", + "stats:tools": "python3 scripts/session-stats/analyze.py tools", + "stats:edits": "python3 scripts/session-stats/analyze.py edits", + "stats:followups": "python3 scripts/session-stats/analyze.py followups", + "prepublishOnly": "bun run check", + "prepare": "bun --cwd=packages/coding-agent run generate-docs-index", + "publish": "bun run prepublishOnly && npm publish -ws --access public", + "publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run", + "release": "bun scripts/release.ts", + "generate-models": "bun --cwd=packages/ai run generate-models", + "generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index", + "generate-template": "bun --cwd=packages/coding-agent run generate-template", + "check-spoofed-versions": "bun scripts/check-spoofed-versions.ts" + }, + "devDependencies": { + "@biomejs/biome": "catalog:", + "prettier": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "lint-staged": "catalog:" + }, + "lint-staged": { + "*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched" + } } diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 63f1dd3dc..8727b3673 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -14,11 +14,9 @@ import { import { sanitizeText } from "@oh-my-pi/pi-natives"; import { createHarmonyAuditEvent, - extractHarmonyRemoved, type HarmonyDetection, type HarmonyRecoveredToolCall, isHarmonyLeakMitigationTarget, - recoverHarmonyToolCall, signalListLabel, } from "./harmony-leak"; import type { @@ -502,26 +500,6 @@ async function streamAssistantResponse( const responseIterator = response[Symbol.asyncIterator](); - const _interruptForHarmonyLeak = (message: AssistantMessage, detection: HarmonyDetection): never => { - const recovered = recoverHarmonyToolCall(message, detection); - const removed = recovered?.removed ?? extractHarmonyRemoved(message, detection); - harmonyAbortController?.abort(); - responseIterator.return?.()?.catch(() => {}); - if (recovered) { - if (addedPartial) { - context.messages[context.messages.length - 1] = recovered.message; - } else { - context.messages.push(recovered.message); - stream.push({ type: "message_start", message: { ...recovered.message } }); - } - stream.push({ type: "message_end", message: recovered.message }); - throw new HarmonyLeakInterruption(detection, removed, recovered); - } - if (addedPartial) { - context.messages.pop(); - } - throw new HarmonyLeakInterruption(detection, removed); - }; // 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 // allocating Promise.withResolvers and add/removeEventListener per event. diff --git a/packages/agent/src/harmony-leak.ts b/packages/agent/src/harmony-leak.ts index 18cd5de5d..db743a8f4 100644 --- a/packages/agent/src/harmony-leak.ts +++ b/packages/agent/src/harmony-leak.ts @@ -36,7 +36,6 @@ const FENCE_RE = /^\s*(?:```+|~~~+)/; const SCRIPT_CLASS = "\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F"; const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u"); -const _SCRIPT_CHAR_RE = new RegExp(`[${SCRIPT_CLASS}]`, "u"); // Recovery registry. Each entry's parser must recognize the configured // sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 8a79003ed..461af395a 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -779,7 +779,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () = }; const stream = agentLoop([userPrompt], context, config, undefined, streamFn); - for await (const _event of stream) { + for await (const _ of stream) { // consume } diff --git a/packages/agent/test/helpers.ts b/packages/agent/test/helpers.ts index f37016b50..102fe58f6 100644 --- a/packages/agent/test/helpers.ts +++ b/packages/agent/test/helpers.ts @@ -27,7 +27,7 @@ export function pushAlphaThenDoneEvent( } } -export function createUsage(): Usage { +function createUsage(): Usage { return { input: 0, output: 0, diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 55756e30d..d5f34a45f 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -6,17 +6,15 @@ import type { ResponseInput, } from "openai/resources/responses/responses"; import { getEnvApiKey } from "../stream"; -import { - type Api, - type AssistantMessage, - type Context, - type Model, - type ServiceTier, - type StreamFunction, - type StreamOptions, - shouldSendServiceTier, - type Tool, - type ToolChoice, +import type { + AssistantMessage, + Context, + Model, + ServiceTier, + StreamFunction, + StreamOptions, + Tool, + ToolChoice, } from "../types"; import { normalizeSystemPrompts } from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; @@ -33,8 +31,11 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses"; import { appendResponsesToolResultMessages, + applyCommonResponsesSamplingParams, + applyResponsesReasoningParams, convertResponsesAssistantMessage, convertResponsesInputContent, + createInitialResponsesAssistantMessage, normalizeResponsesToolCallIdForTransform, processResponsesStream, } from "./openai-responses-shared"; @@ -101,23 +102,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" let firstTokenTime: number | undefined; const deploymentName = resolveDeploymentName(model, options); - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "azure-openai-responses" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; + const output: AssistantMessage = createInitialResponsesAssistantMessage( + "azure-openai-responses", + model.provider, + model.id, + ); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); @@ -279,31 +268,7 @@ function buildParams( prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId), }; - if (options?.maxTokens) { - params.max_output_tokens = options?.maxTokens; - } - - if (options?.temperature !== undefined) { - params.temperature = options?.temperature; - } - if (options?.topP !== undefined) { - params.top_p = options.topP; - } - if (options?.topK !== undefined) { - params.top_k = options.topK; - } - if (options?.minP !== undefined) { - params.min_p = options.minP; - } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.repetitionPenalty !== undefined) { - params.repetition_penalty = options.repetitionPenalty; - } - if (shouldSendServiceTier(options?.serviceTier, model.provider)) { - params.service_tier = options.serviceTier; - } + applyCommonResponsesSamplingParams(params, options, model.provider); if (context.tools) { params.tools = convertTools(context.tools); @@ -312,36 +277,7 @@ function buildParams( } } - if (model.reasoning) { - // Always request encrypted reasoning content so reasoning items can be - // replayed in multi-turn conversations when store is false (items aren't - // persisted server-side, so we must include the full content). - // See: https://github.com/can1357/oh-my-pi/issues/41 - params.include = ["reasoning.encrypted_content"]; - - if (options?.reasoning || options?.reasoningSummary !== undefined) { - const reasoningParams: NonNullable = { - effort: options?.reasoning || "medium", - }; - if (options?.reasoningSummary !== null) { - reasoningParams.summary = options?.reasoningSummary || "auto"; - } - params.reasoning = reasoningParams; - } else { - if (model.name.toLowerCase().startsWith("gpt-5")) { - // Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7 - messages.push({ - role: "developer", - content: [ - { - type: "input_text", - text: "# Juice: 0 !important", - }, - ], - }); - } - } - } + applyResponsesReasoningParams(params, model, options, messages); return params; } diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index d2de661ef..3e75d286a 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -29,17 +29,22 @@ import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHe import { convertMessages, convertTools, + type GoogleThinkingLevel, isThinkingPart, mapStopReasonString, mapToolChoice, + nextToolCallId, + pushBlockEndEvent, + pushToolCallEvents, retainThoughtSignature, + startTextOrThinkingBlock, } from "./google-shared"; /** - * Thinking level for Gemini 3 models. - * Mirrors Google's ThinkingLevel enum values. + * Thinking level for Gemini 3 models. Re-exported from `google-shared` so existing + * `import { GoogleThinkingLevel } from "./google-gemini-cli"` callers keep working. */ -export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type { GoogleThinkingLevel }; export interface GoogleGeminiCliOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; @@ -72,9 +77,6 @@ export { getGeminiCliUserAgent, } from "./google-gemini-headers"; -// Counter for generating unique tool call IDs -let toolCallCounter = 0; - // Retry configuration const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; @@ -426,37 +428,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( (!isThinking && currentBlock.type !== "text") ) { if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blocks.length - 1, - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - if (isThinking) { - currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; - output.content.push(currentBlock); - ensureStarted(); - stream.push({ - type: "thinking_start", - contentIndex: blockIndex(), - partial: output, - }); - } else { - currentBlock = { type: "text", text: "" }; - output.content.push(currentBlock); - ensureStarted(); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); } + currentBlock = startTextOrThinkingBlock(isThinking, output, stream, ensureStarted); } if (currentBlock.type === "thinking") { currentBlock.thinking += part.text; @@ -488,30 +462,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (part.functionCall) { hasContent = true; if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); currentBlock = null; } const providedId = part.functionCall.id; const needsNewId = !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); - const toolCallId = needsNewId - ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` - : providedId; + const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId; const toolCall: ToolCall = { type: "toolCall", @@ -523,19 +481,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( output.content.push(toolCall); ensureStarted(); - stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "toolcall_delta", - contentIndex: blockIndex(), - delta: JSON.stringify(toolCall.arguments), - partial: output, - }); - stream.push({ - type: "toolcall_end", - contentIndex: blockIndex(), - toolCall, - partial: output, - }); + pushToolCallEvents(toolCall, blockIndex(), output, stream); } } } @@ -572,21 +518,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); } return hasContent; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 39501744c..98e1ba70a 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -1,8 +1,35 @@ /** * Shared utilities for Google Generative AI and Google Cloud Code Assist providers. */ -import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; -import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types"; +import { + type Content, + FinishReason, + FunctionCallingConfigMode, + type GenerateContentConfig, + type GenerateContentParameters, + type GenerateContentResponse, + type GoogleGenAI, + type Part, + type ThinkingConfig, + type ThinkingLevel, +} from "@google/genai"; +import { calculateCost } from "../models"; +import type { + Api, + AssistantMessage, + Context, + ImageContent, + Model, + StopReason, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types"; +import { normalizeSystemPrompts } from "../utils"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema"; import { transformMessages } from "./transform-messages"; import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard"; @@ -11,6 +38,26 @@ export { sanitizeSchemaForGoogle }; type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex"; +/** + * Thinking level for Gemini 3 models. Mirrors Google's `ThinkingLevel` enum values. + * Defined here (not in any specific provider) so all Google providers can reference it + * without inducing a circular dependency. + */ +export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; + +/** + * Sampling/thinking options shared by `streamGoogle` and `streamGoogleVertex`. + * `google-gemini-cli` uses a different transport and request shape — do not extend this for it. + */ +export interface GoogleSharedStreamOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; + level?: GoogleThinkingLevel; + }; +} + /** * Determines whether a streamed Gemini `Part` should be treated as "thinking". * @@ -342,8 +389,7 @@ export function mapStopReason(reason: FinishReason): StopReason { case FinishReason.NO_IMAGE: return "error"; default: { - const _exhaustive: never = reason; - throw new Error(`Unhandled stop reason: ${_exhaustive}`); + throw new Error(`Unhandled stop reason: ${reason satisfies never}`); } } } @@ -361,3 +407,399 @@ export function mapStopReasonString(reason: string): StopReason { return "error"; } } + +/** + * Module-local counter for generating unique tool call IDs across Google providers. + * Shared so that a single monotonically-increasing sequence is used regardless of which + * Google API surface produced the stream — purely for uniqueness, not ordering semantics. + */ +let toolCallCounter = 0; + +export function nextToolCallId(name: string): string { + return `${name}_${Date.now()}_${++toolCallCounter}`; +} + +/** + * Push the appropriate `text_end` / `thinking_end` event for the given block. + * Shared between the SDK-backed stream consumer and the gemini-cli SSE consumer so + * the end-of-block event shape stays in lockstep. + */ +export function pushBlockEndEvent( + block: TextContent | ThinkingContent, + contentIndex: number, + output: AssistantMessage, + stream: AssistantMessageEventStream, +): void { + if (block.type === "text") { + stream.push({ type: "text_end", contentIndex, content: block.text, partial: output }); + } else { + stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output }); + } +} + +/** + * Push the three lifecycle events (`toolcall_start` / `toolcall_delta` / `toolcall_end`) for a + * fully-assembled `ToolCall`. Caller is responsible for appending the toolCall to `output.content` + * before invoking — this helper does not mutate `output.content`. + */ +export function pushToolCallEvents( + toolCall: ToolCall, + contentIndex: number, + output: AssistantMessage, + stream: AssistantMessageEventStream, +): void { + stream.push({ type: "toolcall_start", contentIndex, partial: output }); + stream.push({ + type: "toolcall_delta", + contentIndex, + delta: JSON.stringify(toolCall.arguments), + partial: output, + }); + stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output }); +} + +/** + * Append a new text- or thinking-block to `output.content` and push the matching + * `text_start` / `thinking_start` event. `onBeforeStartEvent` lets the SSE consumer + * inject its `ensureStarted()` first-token side effect into the canonical event order. + */ +export function startTextOrThinkingBlock( + isThinking: boolean, + output: AssistantMessage, + stream: AssistantMessageEventStream, + onBeforeStartEvent?: () => void, +): TextContent | ThinkingContent { + const block: TextContent | ThinkingContent = isThinking + ? { type: "thinking", thinking: "", thinkingSignature: undefined } + : { type: "text", text: "" }; + output.content.push(block); + onBeforeStartEvent?.(); + const contentIndex = output.content.length - 1; + if (isThinking) { + stream.push({ type: "thinking_start", contentIndex, partial: output }); + } else { + stream.push({ type: "text_start", contentIndex, partial: output }); + } + return block; +} + +/** + * Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and + * the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and + * `streamGoogleVertex` — every observable event order and stop-reason rule is preserved. + * + * The caller still owns: `output` construction, timing fields (`duration`/`ttft`), + * `rawRequestDump`, the `client.models.generateContentStream(params)` call itself, + * pushing `start`/`done`/`error` events, and the surrounding try/catch that translates + * thrown errors into `output.stopReason`/`errorMessage`. + * + * This helper handles: the chunk loop, currentBlock flush transitions, usage metadata + * decoding (`calculateCost` included), tool-call id collision avoidance, finish-reason + * mapping, and the abort/stop-reason post-checks that re-throw to bubble into the + * caller's catch. + */ +export async function consumeGoogleStream(args: { + googleStream: AsyncIterable; + output: AssistantMessage; + stream: AssistantMessageEventStream; + model: Model; + options: { signal?: AbortSignal } | undefined; + /** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */ + retainTextSignature?: boolean; + onFirstToken?: () => void; +}): Promise { + const { googleStream, output, stream, model, options, retainTextSignature, onFirstToken } = args; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + let currentBlock: TextContent | ThinkingContent | null = null; + let firstTokenSeen = false; + + const flushCurrent = () => { + if (!currentBlock) return; + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); + }; + + for await (const chunk of googleStream) { + const candidate = chunk.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (part.text !== undefined) { + if (!firstTokenSeen) { + firstTokenSeen = true; + onFirstToken?.(); + } + const isThinking = isThinkingPart(part); + if ( + !currentBlock || + (isThinking && currentBlock.type !== "thinking") || + (!isThinking && currentBlock.type !== "text") + ) { + flushCurrent(); + currentBlock = startTextOrThinkingBlock(isThinking, output, stream); + } + if (currentBlock.type === "thinking") { + currentBlock.thinking += part.text; + currentBlock.thinkingSignature = retainThoughtSignature( + currentBlock.thinkingSignature, + part.thoughtSignature, + ); + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } else { + currentBlock.text += part.text; + if (retainTextSignature) { + currentBlock.textSignature = retainThoughtSignature( + currentBlock.textSignature, + part.thoughtSignature, + ); + } + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } + } + + if (part.functionCall) { + if (currentBlock) { + flushCurrent(); + currentBlock = null; + } + + // Generate unique ID if not provided or if it's a duplicate + const providedId = part.functionCall.id; + const needsNewId = !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); + const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId; + + const toolCall: ToolCall = { + type: "toolCall", + id: toolCallId, + name: part.functionCall.name || "", + arguments: (part.functionCall.args ?? {}) as Record, + ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), + }; + + output.content.push(toolCall); + pushToolCallEvents(toolCall, blockIndex(), output, stream); + } + } + } + + if (candidate?.finishReason) { + output.stopReason = mapStopReason(candidate.finishReason); + if (output.content.some(b => b.type === "toolCall")) { + output.stopReason = "toolUse"; + } + } + + if (chunk.usageMetadata) { + // promptTokenCount includes cachedContentTokenCount when cached content is used. + // Subtract to get non-cached input, matching the OpenAI convention where + // input = uncached prompt tokens and cacheRead = cached tokens so that + // input + cacheRead = total prompt tokens (no double-counting). + // Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata + const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0; + const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0; + output.usage = { + input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens, + output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: chunk.usageMetadata.totalTokenCount || 0, + ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}), + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; + calculateCost(model, output.usage); + } + } + + flushCurrent(); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error(output.errorMessage ?? "An unknown error occurred"); + } +} + +/** + * Generation/sampling fields that map directly onto Gemini's `GenerateContentConfig`. + * Excludes any provider-specific extensions (`topP`/`topK`/etc are all forwarded as-is). + */ +interface GoogleGenerationConfig extends GenerateContentConfig { + topP?: number; + topK?: number; + minP?: number; + presencePenalty?: number; + repetitionPenalty?: number; +} + +/** + * Build the `GenerateContentParameters` payload for the public Gemini API and Vertex AI. + * Both surfaces accept the same `GenerateContentConfig` shape — every numeric/string knob, + * tool-config, thinking-config, and system-instruction conversion is identical. + * + * `google-gemini-cli` is NOT routed through here: its `CloudCodeAssistRequest` body has a + * distinct top-level shape (project/request/requestType) and a different thinking-config + * placement on `generationConfig`. + */ +export function buildGoogleGenerateContentParams( + model: Model, + context: Context, + options: GoogleSharedStreamOptions, +): GenerateContentParameters { + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); + const contents = convertMessages(model, context); + + const generationConfig: GoogleGenerationConfig = {}; + if (options.temperature !== undefined) generationConfig.temperature = options.temperature; + if (options.maxTokens !== undefined) generationConfig.maxOutputTokens = options.maxTokens; + if (options.topP !== undefined) generationConfig.topP = options.topP; + if (options.topK !== undefined) generationConfig.topK = options.topK; + if (options.minP !== undefined) generationConfig.minP = options.minP; + if (options.presencePenalty !== undefined) generationConfig.presencePenalty = options.presencePenalty; + if (options.repetitionPenalty !== undefined) generationConfig.repetitionPenalty = options.repetitionPenalty; + + const config: GenerateContentConfig = { + ...(Object.keys(generationConfig).length > 0 && generationConfig), + ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), + ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), + }; + + if (context.tools && context.tools.length > 0 && options.toolChoice) { + config.toolConfig = { + functionCallingConfig: { + mode: mapToolChoice(options.toolChoice), + }, + }; + } else { + config.toolConfig = undefined; + } + + if (options.thinking?.enabled && model.reasoning) { + const cfg: ThinkingConfig = { includeThoughts: true }; + if (options.thinking.level !== undefined) { + // GoogleThinkingLevel mirrors the SDK's `ThinkingLevel` string enum values 1:1. + cfg.thinkingLevel = options.thinking.level as ThinkingLevel; + } else if (options.thinking.budgetTokens !== undefined) { + cfg.thinkingBudget = options.thinking.budgetTokens; + } + config.thinkingConfig = cfg; + } + + if (options.signal) { + if (options.signal.aborted) { + throw new Error("Request aborted"); + } + config.abortSignal = options.signal; + } + + return { + model: model.id, + contents, + config, + }; +} + +/** + * Drive the `streamGoogle` / `streamGoogleVertex` event flow: build the assistant message, + * push start/done/error events, run `consumeGoogleStream`, and translate thrown errors into + * the canonical `error` event shape. + * + * Caller-supplied `prepare()` runs inside the try-block so any failure (missing project, + * bad auth, etc.) is funneled through the same error path as a streaming failure. + */ +export function streamGoogleGenAI(args: { + model: Model; + options: GoogleSharedStreamOptions | undefined; + api: T; + retainTextSignature?: boolean; + prepare: () => { client: GoogleGenAI; params: GenerateContentParameters; url: string | undefined }; +}): AssistantMessageEventStream { + const { model, options, api, retainTextSignature, prepare } = args; + const stream = new AssistantMessageEventStream(); + + (async () => { + const startTime = Date.now(); + let firstTokenTime: number | undefined; + + const output: AssistantMessage = { + role: "assistant", + content: [], + api: api as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + let rawRequestDump: RawHttpRequestDump | undefined; + + try { + const { client, params, url } = prepare(); + options?.onPayload?.(params); + rawRequestDump = { + provider: model.provider, + api: output.api, + model: model.id, + method: "POST", + url, + body: params, + }; + const googleStream = await client.models.generateContentStream(params); + + stream.push({ type: "start", partial: output }); + await consumeGoogleStream({ + googleStream, + output, + stream, + model, + options, + retainTextSignature, + onFirstToken: () => { + firstTokenTime = Date.now(); + }, + }); + + output.duration = Date.now() - startTime; + if (firstTokenTime) output.ttft = firstTokenTime - startTime; + stream.push({ type: "done", reason: output.stopReason as "length" | "stop" | "toolUse", message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + if ("index" in block) { + delete (block as { index?: number }).index; + } + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = await finalizeErrorMessage(error, rawRequestDump); + output.duration = Date.now() - startTime; + if (firstTokenTime) output.ttft = firstTokenTime - startTime; + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +} diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 2bce003cd..47a72037b 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -1,317 +1,38 @@ -import { - type GenerateContentConfig, - type GenerateContentParameters, - GoogleGenAI, - type ThinkingConfig, - ThinkingLevel, -} from "@google/genai"; +import { GoogleGenAI } from "@google/genai"; import { $env } from "@oh-my-pi/pi-utils"; -import { calculateCost } from "../models"; -import type { - Api, - AssistantMessage, - Context, - Model, - StreamFunction, - StreamOptions, - TextContent, - ThinkingContent, - ToolCall, -} from "../types"; -import { normalizeSystemPrompts } from "../utils"; -import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; -import type { GoogleThinkingLevel } from "./google-gemini-cli"; -import { - convertMessages, - convertTools, - isThinkingPart, - mapStopReason, - mapToolChoice, - retainThoughtSignature, -} from "./google-shared"; +import type { Context, Model, StreamFunction } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; +import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared"; -export interface GoogleVertexOptions extends StreamOptions { - toolChoice?: "auto" | "none" | "any"; - thinking?: { - enabled: boolean; - budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; - }; +export interface GoogleVertexOptions extends GoogleSharedStreamOptions { project?: string; location?: string; } -interface GoogleVertexSamplingConfig extends GenerateContentConfig { - topP?: number; - topK?: number; - minP?: number; - presencePenalty?: number; - repetitionPenalty?: number; -} - const API_VERSION = "v1"; -const THINKING_LEVEL_MAP: Record = { - THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, - MINIMAL: ThinkingLevel.MINIMAL, - LOW: ThinkingLevel.LOW, - MEDIUM: ThinkingLevel.MEDIUM, - HIGH: ThinkingLevel.HIGH, -}; - -// Counter for generating unique tool call IDs -let toolCallCounter = 0; - export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( model: Model<"google-vertex">, context: Context, options?: GoogleVertexOptions, -): AssistantMessageEventStream => { - const stream = new AssistantMessageEventStream(); - - (async () => { - const startTime = Date.now(); - let firstTokenTime: number | undefined; - - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "google-vertex" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - let rawRequestDump: RawHttpRequestDump | undefined; - - try { +): AssistantMessageEventStream => + streamGoogleGenAI({ + model, + options, + api: "google-vertex", + retainTextSignature: true, + prepare: () => { const apiKey = resolveApiKey(options); const project = apiKey ? undefined : resolveProject(options); const location = apiKey ? undefined : resolveLocation(options); const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!); - const params = buildParams(model, context, options); - options?.onPayload?.(params); - rawRequestDump = { - provider: model.provider, - api: output.api, - model: model.id, - method: "POST", - url: apiKey - ? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent` - : `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`, - body: params, - }; - const googleStream = await client.models.generateContentStream(params); - - stream.push({ type: "start", partial: output }); - let currentBlock: TextContent | ThinkingContent | null = null; - const blocks = output.content; - const blockIndex = () => blocks.length - 1; - for await (const chunk of googleStream) { - const candidate = chunk.candidates?.[0]; - if (candidate?.content?.parts) { - for (const part of candidate.content.parts) { - if (part.text !== undefined) { - if (!firstTokenTime) firstTokenTime = Date.now(); - const isThinking = isThinkingPart(part); - if ( - !currentBlock || - (isThinking && currentBlock.type !== "thinking") || - (!isThinking && currentBlock.type !== "text") - ) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blocks.length - 1, - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - if (isThinking) { - currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; - output.content.push(currentBlock); - stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); - } else { - currentBlock = { type: "text", text: "" }; - output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - } - } - if (currentBlock.type === "thinking") { - currentBlock.thinking += part.text; - currentBlock.thinkingSignature = retainThoughtSignature( - currentBlock.thinkingSignature, - part.thoughtSignature, - ); - stream.push({ - type: "thinking_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } else { - currentBlock.text += part.text; - currentBlock.textSignature = retainThoughtSignature( - currentBlock.textSignature, - part.thoughtSignature, - ); - stream.push({ - type: "text_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } - } - - if (part.functionCall) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - currentBlock = null; - } - - const providedId = part.functionCall.id; - const needsNewId = - !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); - const toolCallId = needsNewId - ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` - : providedId; - - const toolCall: ToolCall = { - type: "toolCall", - id: toolCallId, - name: part.functionCall.name || "", - arguments: part.functionCall.args as Record, - ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), - }; - - output.content.push(toolCall); - stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "toolcall_delta", - contentIndex: blockIndex(), - delta: JSON.stringify(toolCall.arguments), - partial: output, - }); - stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); - } - } - } - - if (candidate?.finishReason) { - output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some(b => b.type === "toolCall")) { - output.stopReason = "toolUse"; - } - } - - if (chunk.usageMetadata) { - // promptTokenCount includes cachedContentTokenCount when cached content is used. - // Subtract to get non-cached input, matching the OpenAI convention where - // input = uncached prompt tokens and cacheRead = cached tokens so that - // input + cacheRead = total prompt tokens (no double-counting). - // Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata - const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0; - const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0; - output.usage = { - input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens, - output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: chunk.usageMetadata.totalTokenCount || 0, - ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}), - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }; - calculateCost(model, output.usage); - } - } - - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); - } - - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); - } - - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "done", reason: output.stopReason, message: output }); - stream.end(); - } catch (error) { - // Remove internal index property used during streaming - for (const block of output.content) { - if ("index" in block) { - delete (block as { index?: number }).index; - } - } - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = await finalizeErrorMessage(error, rawRequestDump); - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "error", reason: output.stopReason, error: output }); - stream.end(); - } - })(); - - return stream; -}; + const params = buildGoogleGenerateContentParams(model, context, options ?? {}); + const url = apiKey + ? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent` + : `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`; + return { client, params, url }; + }, + }); function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record } | undefined { if (!model.headers) { @@ -364,76 +85,3 @@ function resolveLocation(options?: GoogleVertexOptions): string { } return location; } - -function buildParams( - model: Model<"google-vertex">, - context: Context, - options: GoogleVertexOptions = {}, -): GenerateContentParameters { - const systemPrompts = normalizeSystemPrompts(context.systemPrompt); - const contents = convertMessages(model, context); - - const generationConfig: GoogleVertexSamplingConfig = {}; - if (options.temperature !== undefined) { - generationConfig.temperature = options.temperature; - } - if (options.maxTokens !== undefined) { - generationConfig.maxOutputTokens = options.maxTokens; - } - if (options.topP !== undefined) { - generationConfig.topP = options.topP; - } - if (options.topK !== undefined) { - generationConfig.topK = options.topK; - } - if (options.minP !== undefined) { - generationConfig.minP = options.minP; - } - if (options.presencePenalty !== undefined) { - generationConfig.presencePenalty = options.presencePenalty; - } - if (options.repetitionPenalty !== undefined) { - generationConfig.repetitionPenalty = options.repetitionPenalty; - } - - const config: GenerateContentConfig = { - ...(Object.keys(generationConfig).length > 0 && generationConfig), - ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), - }; - - if (context.tools && context.tools.length > 0 && options.toolChoice) { - config.toolConfig = { - functionCallingConfig: { - mode: mapToolChoice(options.toolChoice), - }, - }; - } else { - config.toolConfig = undefined; - } - - if (options.thinking?.enabled && model.reasoning) { - const cfg: ThinkingConfig = { includeThoughts: true }; - if (options.thinking.level !== undefined) { - cfg.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level]; - } else if (options.thinking.budgetTokens !== undefined) { - cfg.thinkingBudget = options.thinking.budgetTokens; - } - config.thinkingConfig = cfg; - } - - if (options.signal) { - if (options.signal.aborted) { - throw new Error("Request aborted"); - } - config.abortSignal = options.signal; - } - - const params: GenerateContentParameters = { - model: model.id, - contents, - config, - }; - - return params; -} diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index d7c66c530..48e571564 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -1,297 +1,28 @@ -import { - type GenerateContentConfig, - type GenerateContentParameters, - GoogleGenAI, - type ThinkingConfig, -} from "@google/genai"; -import { calculateCost } from "../models"; +import { GoogleGenAI } from "@google/genai"; import { getEnvApiKey } from "../stream"; -import type { - Api, - AssistantMessage, - Context, - Model, - StreamFunction, - StreamOptions, - TextContent, - ThinkingContent, - ToolCall, -} from "../types"; -import { normalizeSystemPrompts } from "../utils"; -import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; -import type { GoogleThinkingLevel } from "./google-gemini-cli"; -import { - convertMessages, - convertTools, - isThinkingPart, - mapStopReason, - mapToolChoice, - retainThoughtSignature, -} from "./google-shared"; +import type { Context, Model, StreamFunction } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; +import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared"; -export interface GoogleOptions extends StreamOptions { - toolChoice?: "auto" | "none" | "any"; - thinking?: { - enabled: boolean; - budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; - }; -} - -interface GoogleSamplingConfig extends GenerateContentConfig { - topP?: number; - topK?: number; - minP?: number; - presencePenalty?: number; - repetitionPenalty?: number; -} - -// Counter for generating unique tool call IDs -let toolCallCounter = 0; +export type GoogleOptions = GoogleSharedStreamOptions; export const streamGoogle: StreamFunction<"google-generative-ai"> = ( model: Model<"google-generative-ai">, context: Context, options?: GoogleOptions, -): AssistantMessageEventStream => { - const stream = new AssistantMessageEventStream(); - - (async () => { - const startTime = Date.now(); - let firstTokenTime: number | undefined; - - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "google-generative-ai" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - let rawRequestDump: RawHttpRequestDump | undefined; - - try { +): AssistantMessageEventStream => + streamGoogleGenAI({ + model, + options, + api: "google-generative-ai", + prepare: () => { const apiKey = options?.apiKey || getEnvApiKey(model.provider); const client = createClient(model, apiKey); - const params = buildParams(model, context, options); - options?.onPayload?.(params); - rawRequestDump = { - provider: model.provider, - api: output.api, - model: model.id, - method: "POST", - url: model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined, - body: params, - }; - const googleStream = await client.models.generateContentStream(params); - - stream.push({ type: "start", partial: output }); - let currentBlock: TextContent | ThinkingContent | null = null; - const blocks = output.content; - const blockIndex = () => blocks.length - 1; - for await (const chunk of googleStream) { - const candidate = chunk.candidates?.[0]; - if (candidate?.content?.parts) { - for (const part of candidate.content.parts) { - if (part.text !== undefined) { - const isThinking = isThinkingPart(part); - if (!firstTokenTime) firstTokenTime = Date.now(); - if ( - !currentBlock || - (isThinking && currentBlock.type !== "thinking") || - (!isThinking && currentBlock.type !== "text") - ) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blocks.length - 1, - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - if (isThinking) { - currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; - output.content.push(currentBlock); - stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); - } else { - currentBlock = { type: "text", text: "" }; - output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - } - } - if (currentBlock.type === "thinking") { - currentBlock.thinking += part.text; - currentBlock.thinkingSignature = retainThoughtSignature( - currentBlock.thinkingSignature, - part.thoughtSignature, - ); - stream.push({ - type: "thinking_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } else { - currentBlock.text += part.text; - stream.push({ - type: "text_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } - } - - if (part.functionCall) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - currentBlock = null; - } - - // Generate unique ID if not provided or if it's a duplicate - const providedId = part.functionCall.id; - const needsNewId = - !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); - const toolCallId = needsNewId - ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` - : providedId; - - const toolCall: ToolCall = { - type: "toolCall", - id: toolCallId, - name: part.functionCall.name || "", - arguments: (part.functionCall.args ?? {}) as Record, - ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), - }; - - output.content.push(toolCall); - stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "toolcall_delta", - contentIndex: blockIndex(), - delta: JSON.stringify(toolCall.arguments), - partial: output, - }); - stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); - } - } - } - - if (candidate?.finishReason) { - output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some(b => b.type === "toolCall")) { - output.stopReason = "toolUse"; - } - } - - if (chunk.usageMetadata) { - // promptTokenCount includes cachedContentTokenCount when cached content is used. - // Subtract to get non-cached input, matching the OpenAI convention where - // input = uncached prompt tokens and cacheRead = cached tokens so that - // input + cacheRead = total prompt tokens (no double-counting). - // Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata - const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0; - const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0; - output.usage = { - input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens, - output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: chunk.usageMetadata.totalTokenCount || 0, - ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}), - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }; - calculateCost(model, output.usage); - } - } - - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); - } - - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); - } - - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "done", reason: output.stopReason, message: output }); - stream.end(); - } catch (error) { - // Remove internal index property used during streaming - for (const block of output.content) { - if ("index" in block) { - delete (block as { index?: number }).index; - } - } - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = await finalizeErrorMessage(error, rawRequestDump); - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "error", reason: output.stopReason, error: output }); - stream.end(); - } - })(); - - return stream; -}; + const params = buildGoogleGenerateContentParams(model, context, options ?? {}); + const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined; + return { client, params, url }; + }, + }); function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI { const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record } = {}; @@ -308,77 +39,3 @@ function createClient(model: Model<"google-generative-ai">, apiKey?: string): Go httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, }); } - -function buildParams( - model: Model<"google-generative-ai">, - context: Context, - options: GoogleOptions = {}, -): GenerateContentParameters { - const systemPrompts = normalizeSystemPrompts(context.systemPrompt); - const contents = convertMessages(model, context); - - const generationConfig: GoogleSamplingConfig = {}; - if (options.temperature !== undefined) { - generationConfig.temperature = options.temperature; - } - if (options.maxTokens !== undefined) { - generationConfig.maxOutputTokens = options.maxTokens; - } - if (options.topP !== undefined) { - generationConfig.topP = options.topP; - } - if (options.topK !== undefined) { - generationConfig.topK = options.topK; - } - if (options.minP !== undefined) { - generationConfig.minP = options.minP; - } - if (options.presencePenalty !== undefined) { - generationConfig.presencePenalty = options.presencePenalty; - } - if (options.repetitionPenalty !== undefined) { - generationConfig.repetitionPenalty = options.repetitionPenalty; - } - - const config: GenerateContentConfig = { - ...(Object.keys(generationConfig).length > 0 && generationConfig), - ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), - }; - - if (context.tools && context.tools.length > 0 && options.toolChoice) { - config.toolConfig = { - functionCallingConfig: { - mode: mapToolChoice(options.toolChoice), - }, - }; - } else { - config.toolConfig = undefined; - } - - if (options.thinking?.enabled && model.reasoning) { - const cfg: ThinkingConfig = { includeThoughts: true }; - if (options.thinking.level !== undefined) { - // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values - cfg.thinkingLevel = options.thinking.level as any; - } else if (options.thinking.budgetTokens !== undefined) { - cfg.thinkingBudget = options.thinking.budgetTokens; - } - config.thinkingConfig = cfg; - } - - if (options.signal) { - if (options.signal.aborted) { - throw new Error("Request aborted"); - } - config.abortSignal = options.signal; - } - - const params: GenerateContentParameters = { - model: model.id, - contents, - config, - }; - - return params; -} diff --git a/packages/ai/src/providers/kimi.ts b/packages/ai/src/providers/kimi.ts index 8c1e591ea..af98f4f34 100644 --- a/packages/ai/src/providers/kimi.ts +++ b/packages/ai/src/providers/kimi.ts @@ -9,19 +9,21 @@ * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output. */ -import { ANTHROPIC_THINKING } from "../stream"; -import type { Api, Context, Model, SimpleStreamOptions } from "../types"; -import { AssistantMessageEventStream } from "../utils/event-stream"; +import type { Api, Context, Model } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; -import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; -import { createProviderErrorMessage } from "./shared/error-message"; +import { + type OpenAIAnthropicApiFormat, + type OpenAIAnthropicShimOptions, + streamOpenAIAnthropicShim, +} from "./openai-anthropic-shim"; -export type KimiApiFormat = "openai" | "anthropic"; +export type KimiApiFormat = OpenAIAnthropicApiFormat; // Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1 const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding"; -export interface KimiOptions extends SimpleStreamOptions { +export interface KimiOptions extends OpenAIAnthropicShimOptions { /** API format: "openai" or "anthropic". Default: "anthropic" */ format?: KimiApiFormat; } @@ -35,96 +37,13 @@ export function streamKimi( context: Context, options?: KimiOptions, ): AssistantMessageEventStream { - const stream = new AssistantMessageEventStream(); - const format = options?.format ?? "anthropic"; - - // Async IIFE to handle header fetching and stream piping - (async () => { - try { - const mergedHeaders = { ...getKimiCommonHeaders(), ...options?.headers }; - - if (format === "anthropic") { - // Create a synthetic Anthropic model pointing to Kimi's endpoint - const anthropicModel: Model<"anthropic-messages"> = { - id: model.id, - name: model.name, - api: "anthropic-messages", - provider: model.provider, - baseUrl: KIMI_ANTHROPIC_BASE_URL, - headers: mergedHeaders, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - reasoning: model.reasoning, - input: model.input, - cost: model.cost, - }; - - // Calculate thinking budget from reasoning level - const reasoning = options?.reasoning; - const reasoningEffort = reasoning; - const thinkingEnabled = !!reasoningEffort && model.reasoning; - const thinkingBudget = reasoningEffort - ? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort]) - : undefined; - - const innerStream = streamAnthropic(anthropicModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - thinkingEnabled, - thinkingBudgetTokens: thinkingBudget, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } else { - // OpenAI format - use original model with Kimi headers - const reasoningEffort = options?.reasoning; - const innerStream = streamOpenAICompletions(model, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? model.maxTokens, - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - reasoning: reasoningEffort, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } - } catch (err) { - stream.push({ - type: "error", - reason: "error", - error: createProviderErrorMessage(model, err), - }); - } - })(); - - return stream; + return streamOpenAIAnthropicShim(model, context, options, { + anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL, + defaultFormat: "anthropic", + extraHeaders: getKimiCommonHeaders, + }); } + /** * Check if a model is a Kimi Code model. */ diff --git a/packages/ai/src/providers/openai-anthropic-shim.ts b/packages/ai/src/providers/openai-anthropic-shim.ts new file mode 100644 index 000000000..23ae7fe4d --- /dev/null +++ b/packages/ai/src/providers/openai-anthropic-shim.ts @@ -0,0 +1,136 @@ +/** + * Shared implementation for providers that expose BOTH an OpenAI-compatible + * and an Anthropic-compatible API surface against the same model catalog + * (currently Kimi Code and Synthetic). + * + * Each call site supplies the provider-specific bits (base URLs, default + * format, optional extra headers); the streaming/forwarding plumbing lives + * here once. + */ + +import { ANTHROPIC_THINKING } from "../stream"; +import type { Context, Model, SimpleStreamOptions } from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; +import { createProviderErrorMessage } from "./shared/error-message"; + +export type OpenAIAnthropicApiFormat = "openai" | "anthropic"; + +export interface OpenAIAnthropicShimOptions extends SimpleStreamOptions { + /** API format: "openai" or "anthropic". */ + format?: OpenAIAnthropicApiFormat; +} + +export interface OpenAIAnthropicShimConfig { + /** Base URL for the Anthropic-compatible endpoint (without trailing /v1/messages). */ + anthropicBaseUrl: string; + /** Optional override for the OpenAI-compatible base URL. If omitted, `model.baseUrl` is used as-is. */ + openaiBaseUrl?: string; + /** Default API format when caller does not specify one. */ + defaultFormat: OpenAIAnthropicApiFormat; + /** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */ + extraHeaders?: () => Record; +} + +/** + * Stream from an OpenAI-or-Anthropic compatible provider. Returns synchronously; + * async header fetching and stream piping happen internally. + */ +export function streamOpenAIAnthropicShim( + model: Model<"openai-completions">, + context: Context, + options: OpenAIAnthropicShimOptions | undefined, + config: OpenAIAnthropicShimConfig, +): AssistantMessageEventStream { + const stream = new AssistantMessageEventStream(); + const format = options?.format ?? config.defaultFormat; + + (async () => { + try { + const mergedHeaders = { + ...(config.extraHeaders?.() ?? {}), + ...options?.headers, + }; + + if (format === "anthropic") { + const anthropicModel: Model<"anthropic-messages"> = { + id: model.id, + name: model.name, + api: "anthropic-messages", + provider: model.provider, + baseUrl: config.anthropicBaseUrl, + headers: mergedHeaders, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + reasoning: model.reasoning, + input: model.input, + cost: model.cost, + }; + + const reasoningEffort = options?.reasoning; + const thinkingEnabled = !!reasoningEffort && model.reasoning; + const thinkingBudget = reasoningEffort + ? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort]) + : undefined; + + const innerStream = streamAnthropic(anthropicModel, context, { + apiKey: options?.apiKey, + temperature: options?.temperature, + topP: options?.topP, + topK: options?.topK, + minP: options?.minP, + presencePenalty: options?.presencePenalty, + repetitionPenalty: options?.repetitionPenalty, + maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), + signal: options?.signal, + headers: mergedHeaders, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + onSseEvent: options?.onSseEvent, + thinkingEnabled, + thinkingBudgetTokens: thinkingBudget, + }); + + for await (const event of innerStream) { + stream.push(event); + } + } else { + const openaiModel: Model<"openai-completions"> = config.openaiBaseUrl + ? { ...model, baseUrl: config.openaiBaseUrl, headers: mergedHeaders } + : model; + + const reasoningEffort = options?.reasoning; + const innerStream = streamOpenAICompletions(openaiModel, context, { + apiKey: options?.apiKey, + temperature: options?.temperature, + topP: options?.topP, + topK: options?.topK, + minP: options?.minP, + presencePenalty: options?.presencePenalty, + repetitionPenalty: options?.repetitionPenalty, + maxTokens: options?.maxTokens ?? model.maxTokens, + signal: options?.signal, + headers: mergedHeaders, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + onSseEvent: options?.onSseEvent, + reasoning: reasoningEffort, + }); + + for await (const event of innerStream) { + stream.push(event); + } + } + } catch (err) { + stream.push({ + type: "error", + reason: "error", + error: createProviderErrorMessage(model, err), + }); + } + })(); + + return stream; +} diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 3d4b44107..f2fb641c4 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -7,8 +7,6 @@ import type { ResponseFunctionToolCall, ResponseInput, ResponseInputContent, - ResponseInputImage, - ResponseInputText, ResponseOutputMessage, ResponseReasoningItem, } from "openai/resources/responses/responses"; @@ -35,7 +33,6 @@ import { createOpenAIResponsesHistoryPayload, getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryPayload, - normalizeResponsesToolCallId, normalizeSystemPrompts, } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; @@ -54,14 +51,15 @@ import { import { parseCodexError } from "./openai-codex/response-handler"; import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses"; import { + appendResponsesToolResultMessages, + convertResponsesAssistantMessage, convertResponsesInputContent, encodeResponsesToolCallId, encodeTextSignatureV1, mapOpenAIResponsesStopReason, - parseTextSignature, + populateResponsesUsageFromResponse, } from "./openai-responses-shared"; import { transformMessages } from "./transform-messages"; -import { joinTextWithImagePlaceholder } from "./vision-guard"; export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -1233,19 +1231,7 @@ function handleResponseCompleted( } ).response; - if (response?.usage) { - const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; - const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0; - output.usage = { - input: (response.usage.input_tokens || 0) - cachedTokens, - output: response.usage.output_tokens || 0, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: response.usage.total_tokens || 0, - ...(reasoningTokens > 0 ? { reasoningTokens } : {}), - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; - } + populateResponsesUsageFromResponse(output, response?.usage); if (typeof response?.id === "string" && response.id.length > 0) { output.responseId = response.id; } @@ -2356,6 +2342,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex // messages can be replayed as `custom_tool_call_output` rather than // `function_call_output` (OpenAI rejects mismatched pairs). const customCallIds = new Set(); + const knownCallIds = new Set(); for (const msg of transformedMessages) { if (msg.role === "user" || msg.role === "developer") { @@ -2407,57 +2394,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex continue; } - const outputItems: ResponseInput = []; - for (const block of msg.content) { - if (block.type === "thinking" && msg.stopReason !== "error") { - if (block.thinkingSignature) { - outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem); - } - continue; - } - if (block.type === "text") { - const textBlock = block as TextContent; - const parsedSignature = parseTextSignature(textBlock.textSignature); - let msgId = parsedSignature?.id; - if (!msgId) { - msgId = `msg_${msgIndex}`; - } else if (msgId.length > 64) { - msgId = `msg_${Bun.hash(msgId).toString(36)}`; - } - outputItems.push({ - type: "message", - role: "assistant", - content: [{ type: "output_text", text: textBlock.text.toWellFormed(), annotations: [] }], - status: "completed", - id: msgId, - phase: parsedSignature?.phase, - } satisfies ResponseOutputMessage); - continue; - } - if (block.type === "toolCall") { - const toolCall = block as ToolCall; - const normalized = normalizeResponsesToolCallId(toolCall.id, toolCall.customWireName ? "ctc" : "fc"); - if (toolCall.customWireName) { - const rawInput = typeof toolCall.arguments?.input === "string" ? toolCall.arguments.input : ""; - customCallIds.add(normalized.callId); - outputItems.push({ - type: "custom_tool_call", - id: normalized.itemId, - call_id: normalized.callId, - name: toolCall.customWireName, - input: rawInput, - } as ResponseInput[number]); - continue; - } - outputItems.push({ - type: "function_call", - id: normalized.itemId, - call_id: normalized.callId, - name: toolCall.name, - arguments: JSON.stringify(toolCall.arguments), - }); - } - } + const outputItems = convertResponsesAssistantMessage( + msg as AssistantMessage, + model, + msgIndex, + knownCallIds, + true, + customCallIds, + ); if (outputItems.length > 0) { messages.push(...outputItems); } @@ -2466,49 +2410,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex } if (msg.role === "toolResult") { - const supportsImages = model.input.includes("image"); - const textResult = msg.content - .filter(content => content.type === "text") - .map(content => content.text) - .join("\n"); - const hasImages = msg.content.some(content => content.type === "image"); - const omittedImages = hasImages && !supportsImages; - const normalized = normalizeResponsesToolCallId(msg.toolCallId); - const output = ( - omittedImages - ? joinTextWithImagePlaceholder(textResult, true) - : textResult.length > 0 - ? textResult - : "(see attached image)" - ).toWellFormed(); - if (customCallIds.has(normalized.callId)) { - messages.push({ - type: "custom_tool_call_output", - call_id: normalized.callId, - output, - } as ResponseInput[number]); - } else { - messages.push({ - type: "function_call_output", - call_id: normalized.callId, - output, - }); - } - if (hasImages && supportsImages) { - const contentParts: ResponseInputContent[] = [ - { type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText, - ]; - for (const block of msg.content) { - if (block.type === "image") { - contentParts.push({ - type: "input_image", - detail: "auto", - image_url: `data:${block.mimeType};base64,${block.data}`, - } satisfies ResponseInputImage); - } - } - messages.push({ role: "user", content: contentParts }); - } + appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds); } msgIndex += 1; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e41eae2df..86f4c798a 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -64,6 +64,7 @@ import { resolveGitHubCopilotBaseUrl, } from "./github-copilot-headers"; import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat"; +import { createInitialResponsesAssistantMessage } from "./openai-responses-shared"; import { transformMessages } from "./transform-messages"; import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard"; @@ -338,23 +339,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( let firstTokenTime: number | undefined; let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined; - const output: AssistantMessage = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; + const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE); diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 3cf01120d..926d7dceb 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -12,17 +12,20 @@ import type { ResponseReasoningItem, } from "openai/resources/responses/responses"; import { calculateCost } from "../models"; -import type { - Api, - AssistantMessage, - ImageContent, - Model, - StopReason, - TextContent, - TextSignatureV1, - ThinkingContent, - ToolCall, - ToolResultMessage, +import { + type Api, + type AssistantMessage, + type ImageContent, + type Model, + type ServiceTier, + type StopReason, + type StreamOptions, + shouldSendServiceTier, + type TextContent, + type TextSignatureV1, + type ThinkingContent, + type ToolCall, + type ToolResultMessage, } from "../types"; import { normalizeResponsesToolCallId } from "../utils"; import type { AssistantMessageEventStream } from "../utils/event-stream"; @@ -540,19 +543,7 @@ export async function processResponsesStream( if (response?.id) { output.responseId = response.id; } - if (response?.usage) { - const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; - const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0; - output.usage = { - input: (response.usage.input_tokens || 0) - cachedTokens, - output: response.usage.output_tokens || 0, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: response.usage.total_tokens || 0, - ...(reasoningTokens > 0 ? { reasoningTokens } : {}), - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; - } + populateResponsesUsageFromResponse(output, response?.usage); calculateCost(model, output.usage); output.stopReason = mapOpenAIResponsesStopReason(response?.status); if (response?.status === "failed" || response?.status === "cancelled") { @@ -605,3 +596,131 @@ export function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseSt } } } + +/** Initial empty `AssistantMessage` that streaming providers accumulate into. */ +export function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage { + return { + role: "assistant", + content: [], + api, + provider, + model: modelId, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +/** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */ +export type ResponsesSamplingParamsExtras = { + top_p?: number; + top_k?: number; + min_p?: number; + presence_penalty?: number; + repetition_penalty?: number; +}; + +type CommonResponsesParams = OpenAI.Responses.ResponseCreateParamsStreaming & ResponsesSamplingParamsExtras; + +type CommonSamplingOptions = Pick< + StreamOptions, + "temperature" | "topP" | "topK" | "minP" | "presencePenalty" | "repetitionPenalty" | "maxTokens" +> & { serviceTier?: ServiceTier }; + +/** + * Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens, + * temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`. + */ +export function applyCommonResponsesSamplingParams

( + params: P, + options: CommonSamplingOptions | undefined, + provider: string, +): void { + if (options?.maxTokens) params.max_output_tokens = options.maxTokens; + if (options?.temperature !== undefined) params.temperature = options.temperature; + if (options?.topP !== undefined) params.top_p = options.topP; + if (options?.topK !== undefined) params.top_k = options.topK; + if (options?.minP !== undefined) params.min_p = options.minP; + if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty; + if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty; + if (shouldSendServiceTier(options?.serviceTier, provider)) { + params.service_tier = options.serviceTier; + } +} + +type ReasoningOptions = { + reasoning?: string; + reasoningSummary?: "auto" | "detailed" | "concise" | null; +}; + +/** + * Apply reasoning-related Responses parameters: enable encrypted reasoning content for replay, + * set effort/summary when requested, and otherwise inject the GPT-5 "Juice: 0" no-reasoning hack. + * Mutates `params` and may push a developer message into `messages`. + */ +export function applyResponsesReasoningParams

( + params: P, + model: Model, + options: ReasoningOptions | undefined, + messages: ResponseInput, + mapEffort?: (effort: string) => string, +): void { + if (!model.reasoning) return; + // Always request encrypted reasoning content so reasoning items can be replayed in + // multi-turn conversations when store is false (items aren't persisted server-side, so + // we must include the full content). See: https://github.com/can1357/oh-my-pi/issues/41 + params.include = ["reasoning.encrypted_content"]; + + if (options?.reasoning || options?.reasoningSummary !== undefined) { + const requested = options?.reasoning || "medium"; + type ReasoningParam = NonNullable; + const reasoningParams: ReasoningParam = { + effort: (mapEffort ? mapEffort(requested) : requested) as ReasoningParam["effort"], + }; + if (options?.reasoningSummary !== null) { + reasoningParams.summary = options?.reasoningSummary || "auto"; + } + params.reasoning = reasoningParams as P["reasoning"]; + } else if (model.name.toLowerCase().startsWith("gpt-5")) { + // Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7 + messages.push({ + role: "developer", + content: [{ type: "input_text", text: "# Juice: 0 !important" }], + }); + } +} + +/** Populate `output.usage` from a Responses-API `response.usage` payload. Does not invoke `calculateCost`. */ +export function populateResponsesUsageFromResponse( + output: AssistantMessage, + usage: + | { + input_tokens?: number | null; + output_tokens?: number | null; + total_tokens?: number | null; + input_tokens_details?: { cached_tokens?: number | null } | null; + output_tokens_details?: { reasoning_tokens?: number | null } | null; + } + | null + | undefined, +): void { + if (!usage) return; + const cachedTokens = usage.input_tokens_details?.cached_tokens || 0; + const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0; + output.usage = { + input: (usage.input_tokens || 0) - cachedTokens, + output: usage.output_tokens || 0, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: usage.total_tokens || 0, + ...(reasoningTokens > 0 ? { reasoningTokens } : {}), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 3fb864879..4f212adb2 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -7,7 +7,6 @@ import type { } from "openai/resources/responses/responses"; import { getEnvApiKey } from "../stream"; import { - type Api, type AssistantMessage, type CacheRetention, type Context, @@ -19,7 +18,6 @@ import { type ServiceTier, type StreamFunction, type StreamOptions, - shouldSendServiceTier, type Tool, type ToolChoice, } from "../types"; @@ -54,10 +52,13 @@ import { import { compactGrammarDefinition } from "./grammar"; import { appendResponsesToolResultMessages, + applyCommonResponsesSamplingParams, + applyResponsesReasoningParams, collectCustomCallIds, collectKnownCallIds, convertResponsesAssistantMessage, convertResponsesInputContent, + createInitialResponsesAssistantMessage, normalizeResponsesToolCallIdForTransform, processResponsesStream, } from "./openai-responses-shared"; @@ -187,23 +188,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( const startTime = Date.now(); let firstTokenTime: number | undefined; - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "openai-responses" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; + const output: AssistantMessage = createInitialResponsesAssistantMessage( + "openai-responses", + model.provider, + model.id, + ); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); @@ -418,31 +407,7 @@ function buildParams( store: false, }; - if (options?.maxTokens) { - params.max_output_tokens = options?.maxTokens; - } - - if (options?.temperature !== undefined) { - params.temperature = options?.temperature; - } - if (options?.topP !== undefined) { - params.top_p = options.topP; - } - if (options?.topK !== undefined) { - params.top_k = options.topK; - } - if (options?.minP !== undefined) { - params.min_p = options.minP; - } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.repetitionPenalty !== undefined) { - params.repetition_penalty = options.repetitionPenalty; - } - if (shouldSendServiceTier(options?.serviceTier, model.provider)) { - params.service_tier = options.serviceTier; - } + applyCommonResponsesSamplingParams(params, options, model.provider); if (context.tools) { params.tools = convertTools(context.tools, supportsStrictMode(model), model); @@ -460,36 +425,9 @@ function buildParams( } } - if (model.reasoning) { - // Always request encrypted reasoning content so reasoning items can be - // replayed in multi-turn conversations when store is false (items aren't - // persisted server-side, so we must include the full content). - // See: https://github.com/can1357/oh-my-pi/issues/41 - params.include = ["reasoning.encrypted_content"]; - - if (options?.reasoning || options?.reasoningSummary !== undefined) { - const reasoningParams: NonNullable = { - effort: mapReasoningEffort(options?.reasoning || "medium", model.compat?.reasoningEffortMap) as NonNullable< - OpenAIResponsesSamplingParams["reasoning"] - >["effort"], - }; - if (options?.reasoningSummary !== null) { - reasoningParams.summary = options?.reasoningSummary || "auto"; - } - params.reasoning = reasoningParams; - } else if (model.name.startsWith("gpt-5")) { - // Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7 - messages.push({ - role: "developer", - content: [ - { - type: "input_text", - text: "# Juice: 0 !important", - }, - ], - }); - } - } + applyResponsesReasoningParams(params, model, options, messages, effort => + mapReasoningEffort(effort as NonNullable, model.compat?.reasoningEffortMap), + ); return { conversationMessages, params }; } diff --git a/packages/ai/src/providers/synthetic.ts b/packages/ai/src/providers/synthetic.ts index 72933d210..f25ee296b 100644 --- a/packages/ai/src/providers/synthetic.ts +++ b/packages/ai/src/providers/synthetic.ts @@ -8,18 +8,20 @@ * @see https://dev.synthetic.new/docs/api/overview */ -import { ANTHROPIC_THINKING } from "../stream"; -import type { Api, Context, Model, SimpleStreamOptions } from "../types"; -import { AssistantMessageEventStream } from "../utils/event-stream"; -import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; -import { createProviderErrorMessage } from "./shared/error-message"; +import type { Api, Context, Model } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; +import { + type OpenAIAnthropicApiFormat, + type OpenAIAnthropicShimOptions, + streamOpenAIAnthropicShim, +} from "./openai-anthropic-shim"; -export type SyntheticApiFormat = "openai" | "anthropic"; +export type SyntheticApiFormat = OpenAIAnthropicApiFormat; const SYNTHETIC_NEW_BASE_URL = "https://api.synthetic.new/openai/v1"; const SYNTHETIC_NEW_ANTHROPIC_BASE_URL = "https://api.synthetic.new/anthropic"; -export interface SyntheticOptions extends SimpleStreamOptions { +export interface SyntheticOptions extends OpenAIAnthropicShimOptions { /** API format: "openai" or "anthropic". Default: "openai" */ format?: SyntheticApiFormat; } @@ -33,102 +35,13 @@ export function streamSynthetic( context: Context, options?: SyntheticOptions, ): AssistantMessageEventStream { - const stream = new AssistantMessageEventStream(); - const format = options?.format ?? "openai"; - - // Async IIFE to handle stream piping - (async () => { - try { - const mergedHeaders = options?.headers ?? {}; - - if (format === "anthropic") { - // Create a synthetic Anthropic model pointing to Synthetic's endpoint - const anthropicModel: Model<"anthropic-messages"> = { - id: model.id, - name: model.name, - api: "anthropic-messages", - provider: model.provider, - baseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL, - headers: mergedHeaders, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - reasoning: model.reasoning, - input: model.input, - cost: model.cost, - }; - - // Calculate thinking budget from reasoning level - const reasoning = options?.reasoning; - const reasoningEffort = reasoning; - const thinkingEnabled = !!reasoningEffort && model.reasoning; - const thinkingBudget = reasoningEffort - ? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort]) - : undefined; - - const innerStream = streamAnthropic(anthropicModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - thinkingEnabled, - thinkingBudgetTokens: thinkingBudget, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } else { - // OpenAI format - use original model with Synthetic headers - const syntheticModel: Model<"openai-completions"> = { - ...model, - baseUrl: SYNTHETIC_NEW_BASE_URL, - headers: mergedHeaders, - }; - - const reasoningEffort = options?.reasoning; - const innerStream = streamOpenAICompletions(syntheticModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? model.maxTokens, - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - reasoning: reasoningEffort, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } - } catch (err) { - stream.push({ - type: "error", - reason: "error", - error: createProviderErrorMessage(model, err), - }); - } - })(); - - return stream; + return streamOpenAIAnthropicShim(model, context, options, { + anthropicBaseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL, + openaiBaseUrl: SYNTHETIC_NEW_BASE_URL, + defaultFormat: "openai", + }); } + /** * Check if a model is a Synthetic model. */ diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index dc99e4a5b..07b6ddee9 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -64,7 +64,7 @@ type _CheckExhaustive = ? true : ["ApiOptionsMap is missing some KnownApi values", Exclude] : ["ApiOptionsMap doesn't extend Record"]; -const _exhaustive: _CheckExhaustive = true; +true satisfies _CheckExhaustive; export type OptionsForApi = | StreamOptions | (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never); diff --git a/packages/ai/src/utils/oauth/lm-studio.ts b/packages/ai/src/utils/oauth/lm-studio.ts index 5a315831e..d1e3921fe 100644 --- a/packages/ai/src/utils/oauth/lm-studio.ts +++ b/packages/ai/src/utils/oauth/lm-studio.ts @@ -10,8 +10,6 @@ import type { OAuthController, OAuthProvider } from "./types"; const PROVIDER_ID: OAuthProvider = "lm-studio"; -const _AUTH_URL = "https://lmstudio.ai/docs/api"; -const _DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:1234/v1"; export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; /** diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index f80dc09f9..0b38d4f23 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -470,7 +470,7 @@ describe("anthropic stream envelope handling", () => { vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); - for await (const _event of stream) { + for await (const _ of stream) { // drain stream } const result = await stream.result(); @@ -497,7 +497,7 @@ describe("anthropic stream envelope handling", () => { }); const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" }); - for await (const _event of eagerStream) { + for await (const _ of eagerStream) { // drain stream } await eagerStream.result(); @@ -507,7 +507,7 @@ describe("anthropic stream envelope handling", () => { toolContext, { apiKey: "sk-ant-test" }, ); - for await (const _event of disabledStream) { + for await (const _ of disabledStream) { // drain stream } await disabledStream.result(); @@ -534,7 +534,7 @@ describe("anthropic stream envelope handling", () => { apiKey: "sk-ant-test", cacheRetention: "long", }); - for await (const _event of stream) { + for await (const _ of stream) { // drain stream } await stream.result(); diff --git a/packages/ai/test/github-copilot-login.test.ts b/packages/ai/test/github-copilot-login.test.ts index c0b3de070..2e24dfea4 100644 --- a/packages/ai/test/github-copilot-login.test.ts +++ b/packages/ai/test/github-copilot-login.test.ts @@ -227,7 +227,6 @@ describe("loginGitHubCopilot", () => { }); it("device flow error", async () => { - let _pollCount = 0; const fetchMock = vi.fn(async (input: string | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url === "https://github.com/login/device/code") { @@ -237,7 +236,6 @@ describe("loginGitHubCopilot", () => { }); } if (url === "https://github.com/login/oauth/access_token") { - _pollCount++; return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index f8981c408..af2f52dfe 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -16,7 +16,7 @@ import { $flag, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; * E2E tests require explicit opt-in via E2E=1 environment variable. * This prevents accidental API calls when keys happen to be in the environment. */ -export const E2E_ENABLED = $flag("E2E"); +const E2E_ENABLED = $flag("E2E"); /** * Get an API key from environment, but only if E2E tests are enabled. diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 44b8290fa..a7a45de82 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -501,7 +501,7 @@ describe("Generate E2E Tests", () => { ); const drain = (async () => { - for await (const _event of events) { + for await (const _ of events) { } })(); diff --git a/packages/coding-agent/examples/extensions/plan-mode.ts b/packages/coding-agent/examples/extensions/plan-mode.ts index d08fcb0c1..d0b206026 100644 --- a/packages/coding-agent/examples/extensions/plan-mode.ts +++ b/packages/coding-agent/examples/extensions/plan-mode.ts @@ -334,7 +334,6 @@ export default function planModeExtension(pi: ExtensionAPI) { } // Remove any previous plan-mode-context messages - const _beforeCount = event.messages.length; const filtered = event.messages.filter(m => { if (m.role === "user" && Array.isArray(m.content)) { const hasOldContext = m.content.some( diff --git a/packages/coding-agent/src/autoresearch/helpers.ts b/packages/coding-agent/src/autoresearch/helpers.ts index 7b7a68904..feed49d05 100644 --- a/packages/coding-agent/src/autoresearch/helpers.ts +++ b/packages/coding-agent/src/autoresearch/helpers.ts @@ -1,3 +1,4 @@ +import * as git from "../utils/git"; import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types"; export const METRIC_LINE_PREFIX = "METRIC"; @@ -199,3 +200,19 @@ function sanitizeAsiValue(value: unknown): ASIValue | undefined { } return undefined; } + +export async function tryGitStatus(cwd: string): Promise { + try { + return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true }); + } catch { + return ""; + } +} + +export async function tryGitPrefix(cwd: string): Promise { + try { + return await git.show.prefix(cwd); + } catch { + return ""; + } +} diff --git a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts index d21d4c0f1..21989436e 100644 --- a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -8,7 +8,15 @@ import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; import * as git from "../../utils/git"; import { computeRunModifiedPaths, getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git"; -import { ensureNumericMetricMap, formatNum, mergeAsi, pathMatchesSpec, sanitizeAsi } from "../helpers"; +import { + ensureNumericMetricMap, + formatNum, + mergeAsi, + pathMatchesSpec, + sanitizeAsi, + tryGitPrefix, + tryGitStatus, +} from "../helpers"; import { buildExperimentState, computeConfidence, @@ -445,22 +453,6 @@ async function tryReadHeadSha(cwd: string): Promise { } } -async function tryGitStatus(cwd: string): Promise { - try { - return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true }); - } catch { - return ""; - } -} - -async function tryGitPrefix(cwd: string): Promise { - try { - return await git.show.prefix(cwd); - } catch { - return ""; - } -} - function buildLogText( state: ExperimentState, experiment: ExperimentResult, diff --git a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts index 5041c611b..df788bcdf 100644 --- a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts @@ -18,6 +18,8 @@ import { killTree, parseAsiLines, parseMetricLines, + tryGitPrefix, + tryGitStatus, } from "../helpers"; import { buildExperimentState } from "../state"; import { openAutoresearchStorageIfExists } from "../storage"; @@ -265,23 +267,6 @@ export function createRunExperimentTool( }, }; } - -async function tryGitStatus(cwd: string): Promise { - try { - return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true }); - } catch { - return ""; - } -} - -async function tryGitPrefix(cwd: string): Promise { - try { - return await git.show.prefix(cwd); - } catch { - return ""; - } -} - async function executeProcess(opts: { command: string[]; cwd: string; diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index d4191d617..eab9320b8 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -2,11 +2,11 @@ * List available models with optional fuzzy search */ import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai"; +import { fuzzyFilter } from "@oh-my-pi/pi-tui"; import { formatNumber } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions"; import { EventBus } from "../utils/event-bus"; -import { fuzzyFilter } from "../utils/fuzzy"; interface ProviderRow { provider: string; diff --git a/packages/coding-agent/src/cli/shell-cli.ts b/packages/coding-agent/src/cli/shell-cli.ts index 9213a0b22..62ec032fd 100644 --- a/packages/coding-agent/src/cli/shell-cli.ts +++ b/packages/coding-agent/src/cli/shell-cli.ts @@ -5,10 +5,11 @@ */ import * as path from "node:path"; import { createInterface } from "node:readline/promises"; -import { type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives"; +import { Shell } from "@oh-my-pi/pi-natives"; import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; -import { Settings, type ShellMinimizerSettings } from "../config/settings"; +import { Settings } from "../config/settings"; +import { buildMinimizerOptions } from "../exec/bash-executor"; import { getOrCreateSnapshot } from "../utils/shell-snapshot"; export interface ShellCommandArgs { @@ -41,17 +42,6 @@ export function parseShellArgs(args: string[]): ShellCommandArgs | undefined { return result; } -function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined { - if (!group.enabled) return undefined; - return { - enabled: true, - settingsPath: group.settingsPath || undefined, - only: group.only.length > 0 ? group.only : undefined, - except: group.except.length > 0 ? group.except : undefined, - maxCaptureBytes: group.maxCaptureBytes, - }; -} - export async function runShellCommand(cmd: ShellCommandArgs): Promise { if (!process.stdin.isTTY) { process.stderr.write("Error: shell console requires an interactive TTY.\n"); diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 8dcb47b1c..044fff2d1 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -91,7 +91,7 @@ function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "b return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary"; } -export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { +export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { return resolveUpdateMethod(ompPath, bunBinDir); } async function resolveUpdateTarget(): Promise { diff --git a/packages/coding-agent/src/commit/analysis/conventional.ts b/packages/coding-agent/src/commit/analysis/conventional.ts index 132446498..5b52bb5c4 100644 --- a/packages/coding-agent/src/commit/analysis/conventional.ts +++ b/packages/coding-agent/src/commit/analysis/conventional.ts @@ -1,52 +1,16 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { completeSimple } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" }; import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" }; -import type { ChangelogCategory, ConventionalAnalysis } from "../../commit/types"; +import type { ConventionalAnalysis } from "../../commit/types"; import { toReasoningEffort } from "../../thinking"; -import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; +import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm"; -const ConventionalAnalysisTool = { - name: "create_conventional_analysis", - description: "Analyze a diff and return conventional commit classification.", - parameters: Type.Object({ - type: Type.Union([ - Type.Literal("feat"), - Type.Literal("fix"), - Type.Literal("refactor"), - Type.Literal("docs"), - Type.Literal("test"), - Type.Literal("chore"), - Type.Literal("style"), - Type.Literal("perf"), - Type.Literal("build"), - Type.Literal("ci"), - Type.Literal("revert"), - ]), - scope: Type.Union([Type.String(), Type.Null()]), - details: Type.Array( - Type.Object({ - text: Type.String(), - changelog_category: Type.Optional( - Type.Union([ - Type.Literal("Added"), - Type.Literal("Changed"), - Type.Literal("Fixed"), - Type.Literal("Deprecated"), - Type.Literal("Removed"), - Type.Literal("Security"), - Type.Literal("Breaking Changes"), - ]), - ), - user_visible: Type.Optional(Type.Boolean()), - }), - ), - issue_refs: Type.Array(Type.String()), - }), -}; +const ConventionalAnalysisTool = createConventionalAnalysisTool( + "Analyze a diff and return conventional commit classification.", +); export interface ConventionalAnalysisInput { model: Model; @@ -96,27 +60,5 @@ export async function generateConventionalAnalysis({ { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, ); - return parseAnalysisFromResponse(response); -} - -function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis { - const toolCall = extractToolCall(message, "create_conventional_analysis"); - if (toolCall) { - const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); - } - - const text = extractTextContent(message); - const parsed = parseJsonPayload(text) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); + return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool); } diff --git a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts index 3657e275f..1ea5278e9 100644 --- a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts @@ -1,52 +1,14 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { completeSimple } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" }; import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" }; -import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "../../commit/types"; +import type { ConventionalAnalysis, FileObservation } from "../../commit/types"; import { toReasoningEffort } from "../../thinking"; -import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; +import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm"; -const ReduceTool = { - name: "create_conventional_analysis", - description: "Synthesize file observations into a conventional commit analysis.", - parameters: Type.Object({ - type: Type.Union([ - Type.Literal("feat"), - Type.Literal("fix"), - Type.Literal("refactor"), - Type.Literal("docs"), - Type.Literal("test"), - Type.Literal("chore"), - Type.Literal("style"), - Type.Literal("perf"), - Type.Literal("build"), - Type.Literal("ci"), - Type.Literal("revert"), - ]), - scope: Type.Union([Type.String(), Type.Null()]), - details: Type.Array( - Type.Object({ - text: Type.String(), - changelog_category: Type.Optional( - Type.Union([ - Type.Literal("Added"), - Type.Literal("Changed"), - Type.Literal("Fixed"), - Type.Literal("Deprecated"), - Type.Literal("Removed"), - Type.Literal("Security"), - Type.Literal("Breaking Changes"), - ]), - ), - user_visible: Type.Optional(Type.Boolean()), - }), - ), - issue_refs: Type.Array(Type.String()), - }), -}; +const ReduceTool = createConventionalAnalysisTool("Synthesize file observations into a conventional commit analysis."); export interface ReducePhaseInput { model: Model; @@ -83,26 +45,5 @@ export async function runReducePhase({ { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, ); - return parseAnalysisResponse(response); -} - -function parseAnalysisResponse(message: AssistantMessage): ConventionalAnalysis { - const toolCall = extractToolCall(message, "create_conventional_analysis"); - if (toolCall) { - const parsed = validateToolCall([ReduceTool], toolCall) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); - } - const text = extractTextContent(message); - const parsed = parseJsonPayload(text) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); + return parseConventionalAnalysisResponse(response, ReduceTool); } diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts index 7badfe4b7..bc53e54fe 100644 --- a/packages/coding-agent/src/commit/pipeline.ts +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -25,8 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types"; const SUMMARY_MAX_CHARS = 72; const RECENT_COMMITS_COUNT = 8; -let _typesDescription: string | undefined; -const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt)); +let typesDescription: string | undefined; +const TYPES_DESCRIPTION = (): string => (typesDescription ??= prompt.render(typesDescriptionPrompt)); /** * Execute the omp commit pipeline for staged changes. diff --git a/packages/coding-agent/src/commit/shared-llm.ts b/packages/coding-agent/src/commit/shared-llm.ts new file mode 100644 index 000000000..13bf88869 --- /dev/null +++ b/packages/coding-agent/src/commit/shared-llm.ts @@ -0,0 +1,89 @@ +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { validateToolCall } from "@oh-my-pi/pi-ai"; +import { Type } from "@sinclair/typebox"; +import type { ChangelogCategory, ConventionalAnalysis } from "./types"; +import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "./utils"; + +/** + * Shared TypeBox schema for the `create_conventional_analysis` tool used by + * both the single-pass analysis call and the map-reduce reduce phase. Schemas + * are identical across phases — only the surrounding tool `description` + * differs to reflect the input the phase is summarizing. + */ +export const conventionalAnalysisParameters = Type.Object({ + type: Type.Union([ + Type.Literal("feat"), + Type.Literal("fix"), + Type.Literal("refactor"), + Type.Literal("docs"), + Type.Literal("test"), + Type.Literal("chore"), + Type.Literal("style"), + Type.Literal("perf"), + Type.Literal("build"), + Type.Literal("ci"), + Type.Literal("revert"), + ]), + scope: Type.Union([Type.String(), Type.Null()]), + details: Type.Array( + Type.Object({ + text: Type.String(), + changelog_category: Type.Optional( + Type.Union([ + Type.Literal("Added"), + Type.Literal("Changed"), + Type.Literal("Fixed"), + Type.Literal("Deprecated"), + Type.Literal("Removed"), + Type.Literal("Security"), + Type.Literal("Breaking Changes"), + ]), + ), + user_visible: Type.Optional(Type.Boolean()), + }), + ), + issue_refs: Type.Array(Type.String()), +}); + +export interface ConventionalAnalysisTool { + name: "create_conventional_analysis"; + description: string; + parameters: typeof conventionalAnalysisParameters; +} + +/** + * Build a `create_conventional_analysis` tool descriptor. Phase-specific + * `description` text is the only thing that varies between callers. + */ +export function createConventionalAnalysisTool(description: string): ConventionalAnalysisTool { + return { + name: "create_conventional_analysis", + description, + parameters: conventionalAnalysisParameters, + }; +} + +interface ParsedConventionalAnalysis { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; +} + +/** + * Extract a {@link ConventionalAnalysis} from an assistant response, preferring + * a structured tool call and falling back to JSON embedded in text content. + */ +export function parseConventionalAnalysisResponse( + message: AssistantMessage, + tool: ConventionalAnalysisTool, +): ConventionalAnalysis { + const toolCall = extractToolCall(message, tool.name); + if (toolCall) { + const parsed = validateToolCall([tool], toolCall) as ParsedConventionalAnalysis; + return normalizeAnalysis(parsed); + } + const text = extractTextContent(message); + const parsed = parseJsonPayload(text) as ParsedConventionalAnalysis; + return normalizeAnalysis(parsed); +} diff --git a/packages/coding-agent/src/config/model-equivalence.ts b/packages/coding-agent/src/config/model-equivalence.ts index 6b3165887..100344d8c 100644 --- a/packages/coding-agent/src/config/model-equivalence.ts +++ b/packages/coding-agent/src/config/model-equivalence.ts @@ -72,15 +72,12 @@ const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => { })(); const WRAPPER_PREFIXES = ["duo-chat-"] as const; -let __referenceDataCache: CanonicalReferenceData | undefined; +let referenceDataCache: CanonicalReferenceData | undefined; const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = { overrides: new Map(), exclude: new Set(), }; -const __resolutionCache: WeakMap< - CompiledEquivalenceConfig, - WeakMap, ResolvedCanonicalModel> -> = new WeakMap(); +const resolutionCache: WeakMap, ResolvedCanonicalModel>> = new WeakMap(); const FAMILY_EXTRACTION_PATTERNS = [ /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i, /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i, @@ -98,8 +95,8 @@ function shouldReplaceReference(existing: Model | undefined, candidate: Mod } function createCanonicalReferenceData(): CanonicalReferenceData { - if (__referenceDataCache) { - return __referenceDataCache; + if (referenceDataCache) { + return referenceDataCache; } const references = new Map>(); for (const provider of getBundledProviders()) { @@ -112,11 +109,11 @@ function createCanonicalReferenceData(): CanonicalReferenceData { } } const officialIds = new Set(references.keys()); - __referenceDataCache = { + referenceDataCache = { references: Object.freeze(references) as Map>, officialIds: Object.freeze(officialIds) as Set, }; - return __referenceDataCache; + return referenceDataCache; } function normalizeSelectorKey(selector: string): string { @@ -668,10 +665,10 @@ export function buildCanonicalModelIndex( const byId = new Map(); const bySelector = new Map(); - let modelCache = __resolutionCache.get(compiledEquivalence); + let modelCache = resolutionCache.get(compiledEquivalence); if (!modelCache) { modelCache = new WeakMap, ResolvedCanonicalModel>(); - __resolutionCache.set(compiledEquivalence, modelCache); + resolutionCache.set(compiledEquivalence, modelCache); } for (const model of models) { diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 1934dbd76..1d0ca277b 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -12,10 +12,10 @@ import { type Model, modelsAreEqual, } from "@oh-my-pi/pi-ai"; +import { fuzzyMatch } from "@oh-my-pi/pi-tui"; import chalk from "chalk"; import MODEL_PRIO from "../priority.json" with { type: "json" }; import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking"; -import { fuzzyMatch } from "../utils/fuzzy"; import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry"; import type { Settings } from "./settings"; @@ -607,9 +607,6 @@ export function resolveModelRoleValue( return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; } - const lastColonIndex = normalized.lastIndexOf(":"); - const _thinkingSelector = - lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined; const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings); if (!effectivePatterns || effectivePatterns.length === 0) { return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 9335f66f1..d9293cd48 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -850,7 +850,7 @@ export function isSettingsInitialized(): boolean { * Reset the global singleton for testing. * @internal */ -export function _resetSettingsForTest(): void { +export function resetSettingsForTest(): void { globalInstance = null; globalInstancePromise = null; } diff --git a/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts b/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts index f4f4f498e..e8594635e 100644 --- a/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts +++ b/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts @@ -180,7 +180,7 @@ export function rewriteImports(code: string): string { * Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're * scoped to their enclosing function/block regardless of `var` vs `let`/`const`. */ -export function demoteTopLevelLexicals(code: string): string { +function demoteTopLevelLexicals(code: string): string { if (!/\b(?:const|let|class)\b/.test(code)) return code; const ast = parseProgram(code); @@ -248,7 +248,7 @@ function returnFinalExpression(code: string): { source: string; returned: boolea * common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex * before invoking the transpiler. */ -export function stripTypeScript(code: string): string { +function stripTypeScript(code: string): string { if (!LOOKS_LIKE_TS.test(code)) return code; try { return new Bun.Transpiler({ loader: "ts" }).transformSync(code); diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index b1709a38d..0c60ccf6b 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -65,7 +65,8 @@ async function resolveShellCwd(cwd: string | undefined): Promise void; export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000; let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS; -export function __test_setExtensionHandlerTimeoutMs(timeoutMs: number): void { +export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void { extensionHandlerTimeoutMs = timeoutMs; } diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index a51c3d114..59d445659 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -18,13 +18,11 @@ import type { ProviderResponseMetadata, SimpleStreamOptions, TextContent, - ToolResultMessage, } from "@oh-my-pi/pi-ai"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/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 { Static, TSchema } from "@sinclair/typebox"; -import type { Rule } from "../../capability/rule"; import type { KeybindingsManager } from "../../config/keybindings"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; @@ -33,15 +31,9 @@ import type { BashResult } from "../../exec/bash-executor"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { CustomEditor } from "../../modes/components/custom-editor"; import type { Theme } from "../../modes/theme/theme"; -import type { CompactionPreparation, CompactionResult } from "../../session/compaction"; +import type { CompactionResult } from "../../session/compaction"; import type { CustomMessage } from "../../session/messages"; -import type { - BranchSummaryEntry, - CompactionEntry, - ReadonlySessionManager, - SessionEntry, - SessionManager, -} from "../../session/session-manager"; +import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { BashToolDetails, BashToolInput, @@ -53,8 +45,39 @@ import type { SearchToolInput, WriteToolInput, } from "../../tools"; -import type { TodoItem } from "../../tools/todo-write"; import type { EventBus } from "../../utils/event-bus"; +import type { + AgentEndEvent, + AgentStartEvent, + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + ContextEvent, + SessionBeforeBranchEvent, + SessionBeforeBranchResult, + SessionBeforeCompactEvent, + SessionBeforeCompactResult, + SessionBeforeSwitchEvent, + SessionBeforeSwitchResult, + SessionBeforeTreeEvent, + SessionBeforeTreeResult, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionCompactingResult, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TodoReminderEvent, + ToolCallEventResult, + ToolResultEventResult, + TtsrTriggeredEvent, + TurnEndEvent, + TurnStartEvent, +} from "../shared-events"; import type { SlashCommandInfo } from "../slash-commands"; export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; @@ -104,6 +127,11 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un * UI context for extensions to request interactive UI. * Each mode (interactive, RPC, print) provides its own implementation. */ +// fallow-ignore-next-line code-duplication +// Parallel to HookUIContext: extensions expose a strictly larger UI surface +// (custom editor component, header/footer, widgets, theming, terminal input) +// and may be invoked from event handlers that have already taken the agent +// loop's lock — hooks intentionally cannot. export interface ExtensionUIContext { /** Show a selector and return the user's choice. */ select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise; @@ -221,6 +249,11 @@ export interface CompactOptions { /** * Context passed to extension event handlers. */ +// fallow-ignore-next-line code-duplication +// Parallel to HookContext: extensions expose a strictly larger runtime +// surface (model registry, system prompt, shutdown, full session manager +// access). Field overlap is incidental; merging into a base would require +// hooks to widen their public contract. export interface ExtensionContext { /** UI methods for user interaction */ ui: ExtensionUIContext; @@ -256,6 +289,10 @@ export interface ExtensionContext { * Extended context for command handlers. * Includes session control methods only safe in user-initiated commands. */ +// fallow-ignore-next-line code-duplication +// Parallel to HookCommandContext: same method names, different invariants — +// extension commands additionally permit `switchSession` and `reload`, +// which hooks must not call to avoid deadlocking the agent loop. export interface ExtensionCommandContext extends ExtensionContext { /** Get current context usage for the active model. */ getContextUsage(): ContextUsage | undefined; @@ -373,115 +410,30 @@ export interface ResourcesDiscoverResult { } // ============================================================================ -// Session Events +// Session Events (shared with hooks subsystem) // ============================================================================ -/** Fired on initial session load */ -export interface SessionStartEvent { - type: "session_start"; -} - -/** Fired before switching to another session (can be cancelled) */ -export interface SessionBeforeSwitchEvent { - type: "session_before_switch"; - reason: "new" | "resume" | "fork"; - targetSessionFile?: string; -} - -/** Fired after switching to another session */ -export interface SessionSwitchEvent { - type: "session_switch"; - reason: "new" | "resume" | "fork"; - previousSessionFile: string | undefined; -} - -/** Fired before branching a session (can be cancelled) */ -export interface SessionBeforeBranchEvent { - type: "session_before_branch"; - entryId: string; -} - -/** Fired after branching a session */ -export interface SessionBranchEvent { - type: "session_branch"; - previousSessionFile: string | undefined; -} - -/** Fired before context compaction (can be cancelled or customized) */ -export interface SessionBeforeCompactEvent { - type: "session_before_compact"; - preparation: CompactionPreparation; - branchEntries: SessionEntry[]; - customInstructions?: string; - signal: AbortSignal; -} - -/** Fired before compaction summarization to customize prompts/context */ -export interface SessionCompactingEvent { - type: "session.compacting"; - sessionId: string; - messages: AgentMessage[]; -} - -/** Fired after context compaction */ -export interface SessionCompactEvent { - type: "session_compact"; - compactionEntry: CompactionEntry; - fromExtension: boolean; -} - -/** Fired on process exit */ -export interface SessionShutdownEvent { - type: "session_shutdown"; -} - -/** Preparation data for tree navigation */ -export interface TreePreparation { - targetId: string; - oldLeafId: string | null; - commonAncestorId: string | null; - entriesToSummarize: SessionEntry[]; - userWantsSummary: boolean; -} - -/** Fired before navigating in the session tree (can be cancelled) */ -export interface SessionBeforeTreeEvent { - type: "session_before_tree"; - preparation: TreePreparation; - signal: AbortSignal; -} - -/** Fired after navigating in the session tree */ -export interface SessionTreeEvent { - type: "session_tree"; - newLeafId: string | null; - oldLeafId: string | null; - summaryEntry?: BranchSummaryEntry; - fromExtension?: boolean; -} - -export type SessionEvent = - | SessionStartEvent - | SessionBeforeSwitchEvent - | SessionSwitchEvent - | SessionBeforeBranchEvent - | SessionBranchEvent - | SessionBeforeCompactEvent - | SessionCompactingEvent - | SessionCompactEvent - | SessionShutdownEvent - | SessionBeforeTreeEvent - | SessionTreeEvent; +export type { + SessionBeforeBranchEvent, + SessionBeforeCompactEvent, + SessionBeforeSwitchEvent, + SessionBeforeTreeEvent, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TreePreparation, +} from "../shared-events"; // ============================================================================ // Agent Events // ============================================================================ -/** Fired before each LLM call. Can modify messages. */ -export interface ContextEvent { - type: "context"; - messages: AgentMessage[]; -} +export type { ContextEvent } from "../shared-events"; /** Fired before a provider request is sent. Can replace the payload. */ export interface BeforeProviderRequestEvent { @@ -502,31 +454,7 @@ export interface BeforeAgentStartEvent { systemPrompt: string[]; } -/** Fired when an agent loop starts */ -export interface AgentStartEvent { - type: "agent_start"; -} - -/** Fired when an agent loop ends */ -export interface AgentEndEvent { - type: "agent_end"; - messages: AgentMessage[]; -} - -/** Fired at the start of each turn */ -export interface TurnStartEvent { - type: "turn_start"; - turnIndex: number; - timestamp: number; -} - -/** Fired at the end of each turn */ -export interface TurnEndEvent { - type: "turn_end"; - turnIndex: number; - message: AgentMessage; - toolResults: ToolResultMessage[]; -} +export type { AgentEndEvent, AgentStartEvent, TurnEndEvent, TurnStartEvent } from "../shared-events"; /** Fired when a message starts (user, assistant, or toolResult) */ export interface MessageStartEvent { @@ -574,55 +502,14 @@ export interface ToolExecutionEndEvent { isError: boolean; } -/** Fired when auto-compaction starts */ -export interface AutoCompactionStartEvent { - type: "auto_compaction_start"; - reason: "threshold" | "overflow" | "idle"; - action: "context-full" | "handoff"; -} - -/** Fired when auto-compaction ends */ -export interface AutoCompactionEndEvent { - type: "auto_compaction_end"; - action: "context-full" | "handoff"; - result: CompactionResult | undefined; - aborted: boolean; - willRetry: boolean; - errorMessage?: string; - /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ - skipped?: boolean; -} - -/** Fired when auto-retry starts */ -export interface AutoRetryStartEvent { - type: "auto_retry_start"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -/** Fired when auto-retry ends */ -export interface AutoRetryEndEvent { - type: "auto_retry_end"; - success: boolean; - attempt: number; - finalError?: string; -} - -/** Fired when TTSR rule matching interrupts generation */ -export interface TtsrTriggeredEvent { - type: "ttsr_triggered"; - rules: Rule[]; -} - -/** Fired when todo reminder logic detects unfinished todos */ -export interface TodoReminderEvent { - type: "todo_reminder"; - todos: TodoItem[]; - attempt: number; - maxAttempts: number; -} +export type { + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + TodoReminderEvent, + TtsrTriggeredEvent, +} from "../shared-events"; /** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */ export interface CredentialDisabledEvent { @@ -857,10 +744,7 @@ export interface ContextEventResult { export type BeforeProviderRequestEventResult = unknown; -export interface ToolCallEventResult { - block?: boolean; - reason?: string; -} +export type { ToolCallEventResult } from "../shared-events"; /** Result from input event handler */ export interface InputEventResult { @@ -884,11 +768,7 @@ export interface UserPythonEventResult { result?: PythonResult; } -export interface ToolResultEventResult { - content?: (TextContent | ImageContent)[]; - details?: unknown; - isError?: boolean; -} +export type { ToolResultEventResult } from "../shared-events"; export interface BeforeAgentStartEventResult { message?: Pick; @@ -896,33 +776,13 @@ export interface BeforeAgentStartEventResult { systemPrompt?: string[]; } -export interface SessionBeforeSwitchResult { - cancel?: boolean; -} - -export interface SessionBeforeBranchResult { - cancel?: boolean; - skipConversationRestore?: boolean; -} - -export interface SessionBeforeCompactResult { - cancel?: boolean; - compaction?: CompactionResult; -} - -export interface SessionCompactingResult { - context?: string[]; - prompt?: string; - preserveData?: Record; -} - -export interface SessionBeforeTreeResult { - cancel?: boolean; - summary?: { - summary: string; - details?: unknown; - }; -} +export type { + SessionBeforeBranchResult, + SessionBeforeCompactResult, + SessionBeforeSwitchResult, + SessionBeforeTreeResult, + SessionCompactingResult, +} from "../shared-events"; // ============================================================================ // Message Rendering @@ -942,6 +802,9 @@ export type MessageRenderer = ( // Command Registration // ============================================================================ +// fallow-ignore-next-line code-duplication +// Parallel to HookAPI's RegisteredCommand: extensions add +// `getArgumentCompletions` and bind handlers to ExtensionCommandContext. export interface RegisteredCommand { name: string; description?: string; diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 5aa398583..ad4ba071a 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -1,28 +1,44 @@ -/** - * Hook system types. - * - * Hooks are TypeScript modules that can subscribe to agent lifecycle events - * and interact with the user via UI primitives. - */ -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import type { Component, TUI } from "@oh-my-pi/pi-tui"; -import type { Rule } from "../../capability/rule"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { Theme } from "../../modes/theme/theme"; -import type { CompactionPreparation, CompactionResult } from "../../session/compaction"; import type { HookMessage } from "../../session/messages"; -import type { - BranchSummaryEntry, - CompactionEntry, - ReadonlySessionManager, - SessionEntry, - SessionManager, -} from "../../session/session-manager"; +import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools"; -import type { TodoItem } from "../../tools/todo-write"; +import type { + AgentEndEvent, + AgentStartEvent, + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + ContextEvent, + SessionBeforeBranchEvent, + SessionBeforeBranchResult, + SessionBeforeCompactEvent, + SessionBeforeCompactResult, + SessionBeforeSwitchEvent, + SessionBeforeSwitchResult, + SessionBeforeTreeEvent, + SessionBeforeTreeResult, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionCompactingResult, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TodoReminderEvent, + ToolCallEventResult, + ToolResultEventResult, + TtsrTriggeredEvent, + TurnEndEvent, + TurnStartEvent, +} from "../shared-events"; // Re-export for backward compatibility export type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -31,6 +47,11 @@ export type { ExecOptions, ExecResult } from "../../exec/exec"; * UI context for hooks to request interactive UI from the harness. * Each mode (interactive, RPC, print) provides its own implementation. */ +// fallow-ignore-next-line code-duplication +// Parallel to ExtensionUIContext: hooks expose a deliberately narrower UI +// surface — no terminal-input listener, no editor component override, no +// theme management — because hooks are invoked from inside the agent loop +// and must not be able to seize ownership of the editor. export interface HookUIContext { /** * Show a selector and return the user's choice. @@ -137,6 +158,11 @@ export interface HookUIContext { * Context passed to hook event handlers. * For command handlers, see HookCommandContext which extends this with session control methods. */ +// fallow-ignore-next-line code-duplication +// Parallel to ExtensionContext: hooks see a narrower runtime context (no +// model registry mutation, no system prompt access, no shutdown). The +// overlap in field names is intentional API symmetry; widening hooks to +// match extensions would let hooks call methods that deadlock the agent. export interface HookContext { /** UI methods for user interaction */ ui: HookUIContext; @@ -165,6 +191,11 @@ export interface HookContext { * These methods are not available in event handlers because they can cause * deadlocks when called from within the agent loop (e.g., tool_call, context events). */ +// fallow-ignore-next-line code-duplication +// Parallel to ExtensionCommandContext: hooks intentionally omit +// `switchSession`, `reload`, `compact`, and `getContextUsage` — those are +// safe only from extension command handlers, not from the hook execution +// context. export interface HookCommandContext extends HookContext { /** Wait for the agent to finish streaming */ waitForIdle(): Promise; @@ -210,138 +241,25 @@ export interface HookCommandContext extends HookContext { } // ============================================================================ -// Session Events +// Session Events (shared with extensions subsystem) // ============================================================================ -/** Fired on initial session load */ -export interface SessionStartEvent { - type: "session_start"; -} - -/** Fired before switching to another session (can be cancelled) */ -export interface SessionBeforeSwitchEvent { - type: "session_before_switch"; - /** Reason for the switch */ - reason: "new" | "resume" | "fork"; - /** Session file we're switching to (only for "resume") */ - targetSessionFile?: string; -} - -/** Fired after switching to another session */ -export interface SessionSwitchEvent { - type: "session_switch"; - /** Reason for the switch */ - reason: "new" | "resume" | "fork"; - /** Session file we came from */ - previousSessionFile: string | undefined; -} - -/** Fired before branching a session (can be cancelled) */ -export interface SessionBeforeBranchEvent { - type: "session_before_branch"; - /** ID of the entry to branch from */ - entryId: string; -} - -/** Fired after branching a session */ -export interface SessionBranchEvent { - type: "session_branch"; - previousSessionFile: string | undefined; -} - -/** Fired before context compaction (can be cancelled) */ -export interface SessionBeforeCompactEvent { - type: "session_before_compact"; - /** Compaction preparation with messages to summarize, file ops, previous summary, etc. */ - preparation: CompactionPreparation; - /** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */ - branchEntries: SessionEntry[]; - /** Optional user-provided instructions for the summary */ - customInstructions?: string; - /** Abort signal - hooks should pass this to LLM calls and check it periodically */ - signal: AbortSignal; -} - -/** Fired before compaction summarization to customize prompts/context */ -export interface SessionCompactingEvent { - type: "session.compacting"; - sessionId: string; - messages: AgentMessage[]; -} - -/** Fired after context compaction */ -export interface SessionCompactEvent { - type: "session_compact"; - compactionEntry: CompactionEntry; - /** Whether the compaction entry was provided by a hook */ - fromExtension: boolean; -} - -/** Fired on process exit (SIGINT/SIGTERM) */ -export interface SessionShutdownEvent { - type: "session_shutdown"; -} - -/** Preparation data for tree navigation (used by session_before_tree event) */ -export interface TreePreparation { - /** Node being switched to */ - targetId: string; - /** Current active leaf (being abandoned), null if no current position */ - oldLeafId: string | null; - /** Common ancestor of target and old leaf, null if no common ancestor */ - commonAncestorId: string | null; - /** Entries to summarize (old leaf back to common ancestor or compaction) */ - entriesToSummarize: SessionEntry[]; - /** Whether user chose to summarize */ - userWantsSummary: boolean; -} - -/** Fired before navigating to a different node in the session tree (can be cancelled) */ -export interface SessionBeforeTreeEvent { - type: "session_before_tree"; - /** Preparation data for the navigation */ - preparation: TreePreparation; - /** Abort signal - honors Escape during summarization (model available via ctx.model) */ - signal: AbortSignal; -} - -/** Fired after navigating to a different node in the session tree */ -export interface SessionTreeEvent { - type: "session_tree"; - /** The new active leaf, null if navigated to before first entry */ - newLeafId: string | null; - /** Previous active leaf, null if there was no position */ - oldLeafId: string | null; - /** Branch summary entry if one was created */ - summaryEntry?: BranchSummaryEntry; - /** Whether summary came from hook */ - fromExtension?: boolean; -} - -/** Union of all session event types */ -export type SessionEvent = - | SessionStartEvent - | SessionBeforeSwitchEvent - | SessionSwitchEvent - | SessionBeforeBranchEvent - | SessionBranchEvent - | SessionBeforeCompactEvent - | SessionCompactingEvent - | SessionCompactEvent - | SessionShutdownEvent - | SessionBeforeTreeEvent - | SessionTreeEvent; - -/** - * Event data for context event. - * Fired before each LLM call, allowing hooks to modify context non-destructively. - * Original session messages are NOT modified - only the messages sent to the LLM are affected. - */ -export interface ContextEvent { - type: "context"; - /** Messages about to be sent to the LLM (deep copy, safe to modify) */ - messages: AgentMessage[]; -} +export type { + ContextEvent, + SessionBeforeBranchEvent, + SessionBeforeCompactEvent, + SessionBeforeSwitchEvent, + SessionBeforeTreeEvent, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TreePreparation, +} from "../shared-events"; /** * Event data for before_agent_start event. @@ -356,90 +274,18 @@ export interface BeforeAgentStartEvent { images?: ImageContent[]; } -/** - * Event data for agent_start event. - * Fired when an agent loop starts (once per user prompt). - */ -export interface AgentStartEvent { - type: "agent_start"; -} - -/** - * Event data for agent_end event. - */ -export interface AgentEndEvent { - type: "agent_end"; - messages: AgentMessage[]; -} - -/** - * Event data for turn_start event. - */ -export interface TurnStartEvent { - type: "turn_start"; - turnIndex: number; - timestamp: number; -} - -/** - * Event data for turn_end event. - */ -export interface TurnEndEvent { - type: "turn_end"; - turnIndex: number; - message: AgentMessage; - toolResults: ToolResultMessage[]; -} - -/** Event data for auto_compaction_start event. */ -export interface AutoCompactionStartEvent { - type: "auto_compaction_start"; - reason: "threshold" | "overflow" | "idle"; - action: "context-full" | "handoff"; -} - -/** Event data for auto_compaction_end event. */ -export interface AutoCompactionEndEvent { - type: "auto_compaction_end"; - action: "context-full" | "handoff"; - result: CompactionResult | undefined; - aborted: boolean; - willRetry: boolean; - errorMessage?: string; - /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ - skipped?: boolean; -} - -/** Event data for auto_retry_start event. */ -export interface AutoRetryStartEvent { - type: "auto_retry_start"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -/** Event data for auto_retry_end event. */ -export interface AutoRetryEndEvent { - type: "auto_retry_end"; - success: boolean; - attempt: number; - finalError?: string; -} - -/** Event data for ttsr_triggered event. */ -export interface TtsrTriggeredEvent { - type: "ttsr_triggered"; - rules: Rule[]; -} - -/** Event data for todo_reminder event. */ -export interface TodoReminderEvent { - type: "todo_reminder"; - todos: TodoItem[]; - attempt: number; - maxAttempts: number; -} +export type { + AgentEndEvent, + AgentStartEvent, + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + TodoReminderEvent, + TtsrTriggeredEvent, + TurnEndEvent, + TurnStartEvent, +} from "../shared-events"; /** * Event data for tool_call event. @@ -559,29 +405,7 @@ export interface ContextEventResult { messages?: Message[]; } -/** - * Return type for tool_call event handlers. - * Allows hooks to block tool execution. - */ -export interface ToolCallEventResult { - /** If true, block the tool from executing */ - block?: boolean; - /** Reason for blocking (returned to LLM as error) */ - reason?: string; -} - -/** - * Return type for tool_result event handlers. - * Allows hooks to modify tool results. - */ -export interface ToolResultEventResult { - /** Replacement content array (text and images) */ - content?: (TextContent | ImageContent)[]; - /** Replacement details */ - details?: unknown; - /** Override isError flag */ - isError?: boolean; -} +export type { ToolCallEventResult, ToolResultEventResult } from "../shared-events"; /** * Return type for before_agent_start event handlers. @@ -592,65 +416,13 @@ export interface BeforeAgentStartEventResult { message?: Pick; } -/** Return type for session_before_switch handlers */ -export interface SessionBeforeSwitchResult { - /** If true, cancel the switch */ - cancel?: boolean; -} - -/** Return type for session_before_branch handlers */ -export interface SessionBeforeBranchResult { - /** - * If true, abort the branch entirely. No new session file is created, - * conversation stays unchanged. - */ - cancel?: boolean; - /** - * If true, the branch proceeds (new session file created, session state updated) - * but the in-memory conversation is NOT rewound to the branch point. - * - * Use case: git-checkpoint hook that restores code state separately. - * The hook handles state restoration itself, so it doesn't want the - * agent's conversation to be rewound (which would lose recent context). - * - * - `cancel: true` → nothing happens, user stays in current session - * - `skipConversationRestore: true` → branch happens, but messages stay as-is - * - neither → branch happens AND messages rewind to branch point (default) - */ - skipConversationRestore?: boolean; -} - -/** Return type for session_before_compact handlers */ -export interface SessionBeforeCompactResult { - /** If true, cancel the compaction */ - cancel?: boolean; - /** Custom compaction result - SessionManager adds id/parentId */ - compaction?: CompactionResult; -} - -/** Return type for session.compacting handlers */ -export interface SessionCompactingResult { - /** Additional context lines to include in summary */ - context?: string[]; - /** Override the default compaction prompt */ - prompt?: string; - /** Custom data to store in compaction entry */ - preserveData?: Record; -} - -/** Return type for session_before_tree handlers */ -export interface SessionBeforeTreeResult { - /** If true, cancel the navigation entirely */ - cancel?: boolean; - /** - * Custom summary (skips default summarizer). - * Only used if preparation.userWantsSummary is true. - */ - summary?: { - summary: string; - details?: unknown; - }; -} +export type { + SessionBeforeBranchResult, + SessionBeforeCompactResult, + SessionBeforeSwitchResult, + SessionBeforeTreeResult, + SessionCompactingResult, +} from "../shared-events"; // ============================================================================ // Hook API @@ -681,6 +453,9 @@ export type HookMessageRenderer = ( /** * Command registration options. */ +// fallow-ignore-next-line code-duplication +// Parallel to extensions' RegisteredCommand: hooks bind to +// HookCommandContext and have no argument-completion hook. export interface RegisteredCommand { name: string; description?: string; diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts new file mode 100644 index 000000000..a49fd6722 --- /dev/null +++ b/packages/coding-agent/src/extensibility/shared-events.ts @@ -0,0 +1,335 @@ +/** + * Event payload and result shapes shared between the extensions and hooks + * subsystems. + * + * Both subsystems observe the same agent/session lifecycle, so the *event* + * payloads (what happened) and the simpler *result* shapes (handler return + * values that don't depend on subsystem-specific identifiers like + * `AgentMessage` vs `Message`) are intentionally identical. + * + * Anything that diverges between the two subsystems — UI context, runtime + * context, command context, tool-call discrimination, or return shapes that + * carry subsystem-specific message types — lives in the per-subsystem + * `types.ts` files and is documented there. + */ +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import type { Rule } from "../capability/rule"; +import type { CompactionPreparation, CompactionResult } from "../session/compaction"; +import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-manager"; +import type { TodoItem } from "../tools/todo-write"; + +// ============================================================================ +// Session Events +// ============================================================================ + +/** Fired on initial session load */ +export interface SessionStartEvent { + type: "session_start"; +} + +/** Fired before switching to another session (can be cancelled) */ +export interface SessionBeforeSwitchEvent { + type: "session_before_switch"; + /** Reason for the switch */ + reason: "new" | "resume" | "fork"; + /** Session file we're switching to (only for "resume") */ + targetSessionFile?: string; +} + +/** Fired after switching to another session */ +export interface SessionSwitchEvent { + type: "session_switch"; + /** Reason for the switch */ + reason: "new" | "resume" | "fork"; + /** Session file we came from */ + previousSessionFile: string | undefined; +} + +/** Fired before branching a session (can be cancelled) */ +export interface SessionBeforeBranchEvent { + type: "session_before_branch"; + /** ID of the entry to branch from */ + entryId: string; +} + +/** Fired after branching a session */ +export interface SessionBranchEvent { + type: "session_branch"; + previousSessionFile: string | undefined; +} + +/** Fired before context compaction (can be cancelled or customized) */ +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + /** Compaction preparation with messages to summarize, file ops, previous summary, etc. */ + preparation: CompactionPreparation; + /** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */ + branchEntries: SessionEntry[]; + /** Optional user-provided instructions for the summary */ + customInstructions?: string; + /** Abort signal - handlers should pass this to LLM calls and check it periodically */ + signal: AbortSignal; +} + +/** Fired before compaction summarization to customize prompts/context */ +export interface SessionCompactingEvent { + type: "session.compacting"; + sessionId: string; + messages: AgentMessage[]; +} + +/** Fired after context compaction */ +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + /** Whether the compaction entry was provided by an extension/hook */ + fromExtension: boolean; +} + +/** Fired on process exit (SIGINT/SIGTERM) */ +export interface SessionShutdownEvent { + type: "session_shutdown"; +} + +/** Preparation data for tree navigation (used by session_before_tree event) */ +export interface TreePreparation { + /** Node being switched to */ + targetId: string; + /** Current active leaf (being abandoned), null if no current position */ + oldLeafId: string | null; + /** Common ancestor of target and old leaf, null if no common ancestor */ + commonAncestorId: string | null; + /** Entries to summarize (old leaf back to common ancestor or compaction) */ + entriesToSummarize: SessionEntry[]; + /** Whether user chose to summarize */ + userWantsSummary: boolean; +} + +/** Fired before navigating to a different node in the session tree (can be cancelled) */ +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + /** Preparation data for the navigation */ + preparation: TreePreparation; + /** Abort signal - honors Escape during summarization (model available via ctx.model) */ + signal: AbortSignal; +} + +/** Fired after navigating to a different node in the session tree */ +export interface SessionTreeEvent { + type: "session_tree"; + /** The new active leaf, null if navigated to before first entry */ + newLeafId: string | null; + /** Previous active leaf, null if there was no position */ + oldLeafId: string | null; + /** Branch summary entry if one was created */ + summaryEntry?: BranchSummaryEntry; + /** Whether summary came from extension/hook */ + fromExtension?: boolean; +} + +/** Union of all session event types */ +export type SessionEvent = + | SessionStartEvent + | SessionBeforeSwitchEvent + | SessionSwitchEvent + | SessionBeforeBranchEvent + | SessionBranchEvent + | SessionBeforeCompactEvent + | SessionCompactingEvent + | SessionCompactEvent + | SessionShutdownEvent + | SessionBeforeTreeEvent + | SessionTreeEvent; + +// ============================================================================ +// Agent / Turn Events +// ============================================================================ + +/** + * Fired before each LLM call. + * + * Original session messages are NOT modified - only the messages sent to the + * LLM are affected when a handler returns a replacement (the return shape + * differs between extensions and hooks; see each subsystem's + * `ContextEventResult`). + */ +export interface ContextEvent { + type: "context"; + /** Messages about to be sent to the LLM (deep copy, safe to modify) */ + messages: AgentMessage[]; +} + +/** + * Fired when an agent loop starts (once per user prompt). + */ +export interface AgentStartEvent { + type: "agent_start"; +} + +/** Fired when an agent loop ends */ +export interface AgentEndEvent { + type: "agent_end"; + messages: AgentMessage[]; +} + +/** Fired at the start of each turn */ +export interface TurnStartEvent { + type: "turn_start"; + turnIndex: number; + timestamp: number; +} + +/** Fired at the end of each turn */ +export interface TurnEndEvent { + type: "turn_end"; + turnIndex: number; + message: AgentMessage; + toolResults: ToolResultMessage[]; +} + +// ============================================================================ +// Auto-compaction / Auto-retry Events +// ============================================================================ + +/** Fired when auto-compaction starts */ +export interface AutoCompactionStartEvent { + type: "auto_compaction_start"; + reason: "threshold" | "overflow" | "idle"; + action: "context-full" | "handoff"; +} + +/** Fired when auto-compaction ends */ +export interface AutoCompactionEndEvent { + type: "auto_compaction_end"; + action: "context-full" | "handoff"; + result: CompactionResult | undefined; + aborted: boolean; + willRetry: boolean; + errorMessage?: string; + /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ + skipped?: boolean; +} + +/** Fired when auto-retry starts */ +export interface AutoRetryStartEvent { + type: "auto_retry_start"; + attempt: number; + maxAttempts: number; + delayMs: number; + errorMessage: string; +} + +/** Fired when auto-retry ends */ +export interface AutoRetryEndEvent { + type: "auto_retry_end"; + success: boolean; + attempt: number; + finalError?: string; +} + +// ============================================================================ +// TTSR / Todo Reminders +// ============================================================================ + +/** Fired when TTSR rule matching interrupts generation */ +export interface TtsrTriggeredEvent { + type: "ttsr_triggered"; + rules: Rule[]; +} + +/** Fired when todo reminder logic detects unfinished todos */ +export interface TodoReminderEvent { + type: "todo_reminder"; + todos: TodoItem[]; + attempt: number; + maxAttempts: number; +} + +// ============================================================================ +// Shared Event Result Shapes +// ============================================================================ + +/** + * Return type for `tool_call` handlers. + * Allows handlers to block tool execution. + */ +export interface ToolCallEventResult { + /** If true, block the tool from executing */ + block?: boolean; + /** Reason for blocking (returned to LLM as error) */ + reason?: string; +} + +/** + * Return type for `tool_result` handlers. + * Allows handlers to modify tool results. + */ +export interface ToolResultEventResult { + /** Replacement content array (text and images) */ + content?: (TextContent | ImageContent)[]; + /** Replacement details */ + details?: unknown; + /** Override isError flag */ + isError?: boolean; +} + +/** Return type for `session_before_switch` handlers */ +export interface SessionBeforeSwitchResult { + /** If true, cancel the switch */ + cancel?: boolean; +} + +/** Return type for `session_before_branch` handlers */ +export interface SessionBeforeBranchResult { + /** + * If true, abort the branch entirely. No new session file is created, + * conversation stays unchanged. + */ + cancel?: boolean; + /** + * If true, the branch proceeds (new session file created, session state updated) + * but the in-memory conversation is NOT rewound to the branch point. + * + * Use case: git-checkpoint handler that restores code state separately. + * The handler handles state restoration itself, so it doesn't want the + * agent's conversation to be rewound (which would lose recent context). + * + * - `cancel: true` → nothing happens, user stays in current session + * - `skipConversationRestore: true` → branch happens, but messages stay as-is + * - neither → branch happens AND messages rewind to branch point (default) + */ + skipConversationRestore?: boolean; +} + +/** Return type for `session_before_compact` handlers */ +export interface SessionBeforeCompactResult { + /** If true, cancel the compaction */ + cancel?: boolean; + /** Custom compaction result - SessionManager adds id/parentId */ + compaction?: CompactionResult; +} + +/** Return type for `session.compacting` handlers */ +export interface SessionCompactingResult { + /** Additional context lines to include in summary */ + context?: string[]; + /** Override the default compaction prompt */ + prompt?: string; + /** Custom data to store in compaction entry */ + preserveData?: Record; +} + +/** Return type for `session_before_tree` handlers */ +export interface SessionBeforeTreeResult { + /** If true, cancel the navigation entirely */ + cancel?: boolean; + /** + * Custom summary (skips default summarizer). + * Only used if preparation.userWantsSummary is true. + */ + summary?: { + summary: string; + details?: unknown; + }; +} diff --git a/packages/coding-agent/src/hindsight/mental-models.ts b/packages/coding-agent/src/hindsight/mental-models.ts index 51fd615d4..294fb5fab 100644 --- a/packages/coding-agent/src/hindsight/mental-models.ts +++ b/packages/coding-agent/src/hindsight/mental-models.ts @@ -379,4 +379,4 @@ export const MENTAL_MODEL_FIRST_TURN_DEADLINE_MS = 1500; export const MENTAL_MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000; /** Need-only export of the raw seed list for tests. */ -export const __builtinSeedsForTest: ReadonlyArray> = BUILTIN_SEEDS; +export const builtinSeedsForTest: ReadonlyArray> = BUILTIN_SEEDS; diff --git a/packages/coding-agent/src/internal-urls/agent-protocol.ts b/packages/coding-agent/src/internal-urls/agent-protocol.ts index bd3f2df0b..197f2d931 100644 --- a/packages/coding-agent/src/internal-urls/agent-protocol.ts +++ b/packages/coding-agent/src/internal-urls/agent-protocol.ts @@ -14,29 +14,10 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { AgentRegistry } from "../registry/agent-registry"; import { applyQuery, pathToQuery } from "./json-query"; +import { artifactsDirsFromRegistry } from "./registry-helpers"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; -/** - * Snapshot of artifacts dirs for every registered session, deduped. - * - * Prefers `sessionManager.getArtifactsDir()` because subagents adopt the - * parent's manager and report the parent's dir there; dedup then collapses - * the whole agent tree to one entry. Falls back to the raw session file - * when no live session reference is attached. - */ -function artifactsDirsFromRegistry(): string[] { - const dirs: string[] = []; - for (const ref of AgentRegistry.global().list()) { - const dir = - ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null); - if (!dir) continue; - if (!dirs.includes(dir)) dirs.push(dir); - } - return dirs; -} - /** * Handler for agent:// URLs. * diff --git a/packages/coding-agent/src/internal-urls/artifact-protocol.ts b/packages/coding-agent/src/internal-urls/artifact-protocol.ts index 844387b25..61c2dee75 100644 --- a/packages/coding-agent/src/internal-urls/artifact-protocol.ts +++ b/packages/coding-agent/src/internal-urls/artifact-protocol.ts @@ -12,27 +12,9 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { AgentRegistry } from "../registry/agent-registry"; +import { artifactsDirsFromRegistry } from "./registry-helpers"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; -/** - * Snapshot of artifacts dirs across all registered sessions, deduped. - * - * Subagents adopt their parent's `ArtifactManager`, so their - * `sessionManager.getArtifactsDir()` returns the parent's dir; dedup - * collapses parent + N subagents to a single entry. - */ -function artifactsDirsFromRegistry(): string[] { - const dirs: string[] = []; - for (const ref of AgentRegistry.global().list()) { - const dir = - ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null); - if (!dir) continue; - if (!dirs.includes(dir)) dirs.push(dir); - } - return dirs; -} - export class ArtifactProtocolHandler implements ProtocolHandler { readonly scheme = "artifact"; readonly immutable = true; diff --git a/packages/coding-agent/src/internal-urls/registry-helpers.ts b/packages/coding-agent/src/internal-urls/registry-helpers.ts new file mode 100644 index 000000000..2c8c5181e --- /dev/null +++ b/packages/coding-agent/src/internal-urls/registry-helpers.ts @@ -0,0 +1,25 @@ +/** + * Shared helpers for internal-url protocol handlers that resolve IDs against + * registered agent sessions. + */ +import { AgentRegistry } from "../registry/agent-registry"; + +/** + * Snapshot of artifacts dirs for every registered session, deduped. + * + * Prefers `sessionManager.getArtifactsDir()` because subagents adopt their + * parent's `ArtifactManager` and report the parent's dir there; dedup then + * collapses parent + N subagents (the whole agent tree) to one entry. Falls + * back to the raw session file (with the `.jsonl` suffix stripped) when no + * live session reference is attached. + */ +export function artifactsDirsFromRegistry(): string[] { + const dirs: string[] = []; + for (const ref of AgentRegistry.global().list()) { + const dir = + ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null); + if (!dir) continue; + if (!dirs.includes(dir)) dirs.push(dir); + } + return dirs; +} diff --git a/packages/coding-agent/src/modes/components/bash-execution.ts b/packages/coding-agent/src/modes/components/bash-execution.ts index d7a737ba6..3eacb120f 100644 --- a/packages/coding-agent/src/modes/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/components/bash-execution.ts @@ -7,19 +7,23 @@ import { Container, Ellipsis, ImageProtocol, - Loader, - Spacer, + type Loader, TERMINAL, Text, type TUI, truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; -import { getSymbolTheme, theme } from "../../modes/theme/theme"; -import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; +import { theme } from "../../modes/theme/theme"; +import type { TruncationMeta } from "../../tools/output-meta"; import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel"; -import { DynamicBorder } from "./dynamic-border"; -import { truncateToVisualLines } from "./visual-truncate"; +import { + buildExecutionFrame, + buildStatusFooter, + createCollapsedPreview, + type ExecutionStatus, + resolveExecutionStatus, +} from "./execution-shared"; // Preview line limit when not expanded (matches tool execution behavior) const PREVIEW_LINES = 20; @@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50; export class BashExecutionComponent extends Container { #outputLines: string[] = []; - #status: "running" | "complete" | "cancelled" | "error" = "running"; + #status: ExecutionStatus = "running"; #exitCode: number | undefined = undefined; #loader: Loader; #truncation?: TruncationMeta; @@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container { // Use dim border for excluded-from-context commands (!! prefix) const colorKey = excludeFromContext ? "dim" : "bashMode"; - const borderColor = (str: string) => theme.fg(colorKey, str); - - // Add spacer - this.addChild(new Spacer(1)); - - // Top border - this.addChild(new DynamicBorder(borderColor)); - - // Content container (holds dynamic content between borders) - this.#contentContainer = new Container(); - this.addChild(this.#contentContainer); + const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey); + this.#contentContainer = contentContainer; + this.#loader = loader; // Command header this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); this.#contentContainer.addChild(this.#headerText); - - // Loader - this.#loader = new Loader( - ui, - spinner => theme.fg(colorKey, spinner), - text => theme.fg("muted", text), - `Running… (esc to cancel)`, - getSymbolTheme().spinnerFrames, - ); this.#contentContainer.addChild(this.#loader); - - // Bottom border - this.addChild(new DynamicBorder(borderColor)); } /** @@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container { options?: { output?: string; truncation?: TruncationMeta }, ): void { this.#exitCode = exitCode; - this.#status = cancelled - ? "cancelled" - : exitCode !== 0 && exitCode !== undefined && exitCode !== null - ? "error" - : "complete"; + this.#status = resolveExecutionStatus(exitCode, cancelled); this.#truncation = options?.truncation; if (options?.output !== undefined) { this.#setOutput(options.output); @@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container { } else { // Use shared visual truncation utility, recomputed per render width const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); - const previewText = `\n${styledOutput}`; - this.#contentContainer.addChild({ - render: (width: number) => { - const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1); - return visualLines; - }, - invalidate: () => {}, - }); + this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES)); } } @@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container { if (this.#status === "running") { this.#contentContainer.addChild(this.#loader); } else { - const statusParts: string[] = []; - - // Show how many lines are hidden (collapsed preview) - if (hiddenLineCount > 0 && !hasSixelOutput) { - statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); - } - - if (this.#status === "cancelled") { - statusParts.push(theme.fg("warning", "(cancelled)")); - } else if (this.#status === "error") { - statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`)); - } - - if (this.#truncation) { - statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation))); - } - - if (statusParts.length > 0) { - this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); - } + const footer = buildStatusFooter({ + status: this.#status, + exitCode: this.#exitCode, + truncation: this.#truncation, + hiddenLineCount, + suppressHiddenCount: hasSixelOutput, + }); + if (footer) this.#contentContainer.addChild(footer); } } diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 99529345e..1dcd8aa62 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -1,9 +1,9 @@ -import type { TextContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { Box, Container, Spacer } from "@oh-my-pi/pi-tui"; import type { MessageRenderer } from "../../extensibility/extensions/types"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +import { theme } from "../../modes/theme/theme"; import type { CustomMessage } from "../../session/messages"; +import { renderFramedMessage } from "./message-frame"; /** * Component that renders a custom message entry from extensions. @@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container { } #rebuild(): void { - // Remove previous content component if (this.#customComponent) { this.removeChild(this.#customComponent); this.#customComponent = undefined; } this.removeChild(this.#box); - // Try custom renderer first - it handles its own styling - if (this.customRenderer) { - try { - const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); - if (component) { - this.#customComponent = component; - this.addChild(component); - return; - } - } catch { - // Fall through to default rendering - } - } + const custom = renderFramedMessage({ + message: this.message, + box: this.#box, + expanded: this.#expanded, + customRenderer: this.customRenderer, + // Extension messages render full content; no collapse-on-fold behaviour. + }); - // Default rendering uses our box - this.addChild(this.#box); - this.#box.clear(); - - // Default rendering: label + content - const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); - this.#box.addChild(new Text(label, 0, 0)); - this.#box.addChild(new Spacer(1)); - - // Extract text content - let text: string; - if (typeof this.message.content === "string") { - text = this.message.content; + if (custom) { + this.#customComponent = custom; + this.addChild(custom); } else { - text = this.message.content - .filter((c): c is TextContent => c.type === "text") - .map(c => c.text) - .join("\n"); + this.addChild(this.#box); } - - this.#box.addChild( - new Markdown(text, 0, 0, getMarkdownTheme(), { - color: (value: string) => theme.fg("customMessageText", value), - }), - ); } } diff --git a/packages/coding-agent/src/modes/components/eval-execution.ts b/packages/coding-agent/src/modes/components/eval-execution.ts index a42021d11..2e12a052a 100644 --- a/packages/coding-agent/src/modes/components/eval-execution.ts +++ b/packages/coding-agent/src/modes/components/eval-execution.ts @@ -4,11 +4,17 @@ */ import { sanitizeText } from "@oh-my-pi/pi-natives"; -import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; -import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme"; -import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; -import { DynamicBorder } from "./dynamic-border"; -import { truncateToVisualLines } from "./visual-truncate"; +import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { highlightCode, theme } from "../../modes/theme/theme"; +import type { TruncationMeta } from "../../tools/output-meta"; +import { + buildExecutionFrame, + buildStatusFooter, + createCollapsedPreview, + type ExecutionColorKey, + type ExecutionStatus, + resolveExecutionStatus, +} from "./execution-shared"; const PREVIEW_LINES = 20; const MAX_DISPLAY_LINE_CHARS = 4000; @@ -17,7 +23,7 @@ export type EvalExecutionLanguage = "python" | "js"; export class EvalExecutionComponent extends Container { #outputLines: string[] = []; - #status: "running" | "complete" | "cancelled" | "error" = "running"; + #status: ExecutionStatus = "running"; #exitCode: number | undefined = undefined; #loader: Loader; #truncation?: TruncationMeta; @@ -28,7 +34,7 @@ export class EvalExecutionComponent extends Container { return this.language === "js" ? "javascript" : "python"; } - #formatHeader(colorKey: "dim" | "pythonMode"): Text { + #formatHeader(colorKey: ExecutionColorKey): Text { const prompt = theme.fg(colorKey, theme.bold(">>>")); const continuation = theme.fg(colorKey, " "); const codeLines = highlightCode(this.code, this.#highlightLang()); @@ -46,26 +52,13 @@ export class EvalExecutionComponent extends Container { ) { super(); - const colorKey = this.excludeFromContext ? "dim" : "pythonMode"; - const borderColor = (str: string) => theme.fg(colorKey, str); + const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode"; + const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey); + this.#contentContainer = contentContainer; + this.#loader = loader; - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder(borderColor)); - - this.#contentContainer = new Container(); - this.addChild(this.#contentContainer); this.#contentContainer.addChild(this.#formatHeader(colorKey)); - - this.#loader = new Loader( - ui, - spinner => theme.fg(colorKey, spinner), - text => theme.fg("muted", text), - `Running… (esc to cancel)`, - getSymbolTheme().spinnerFrames, - ); this.#contentContainer.addChild(this.#loader); - - this.addChild(new DynamicBorder(borderColor)); } setExpanded(expanded: boolean): void { @@ -99,11 +92,7 @@ export class EvalExecutionComponent extends Container { options?: { output?: string; truncation?: TruncationMeta }, ): void { this.#exitCode = exitCode; - this.#status = cancelled - ? "cancelled" - : exitCode !== 0 && exitCode !== undefined && exitCode !== null - ? "error" - : "complete"; + this.#status = resolveExecutionStatus(exitCode, cancelled); this.#truncation = options?.truncation; if (options?.output !== undefined) { this.#setOutput(options.output); @@ -120,7 +109,7 @@ export class EvalExecutionComponent extends Container { this.#contentContainer.clear(); - const colorKey = this.excludeFromContext ? "dim" : "pythonMode"; + const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode"; this.#contentContainer.addChild(this.#formatHeader(colorKey)); if (availableLines.length > 0) { @@ -129,39 +118,20 @@ export class EvalExecutionComponent extends Container { this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); } else { const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); - const previewText = `\n${styledOutput}`; - this.#contentContainer.addChild({ - render: (width: number) => { - const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1); - return visualLines; - }, - invalidate: () => {}, - }); + this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES)); } } if (this.#status === "running") { this.#contentContainer.addChild(this.#loader); } else { - const statusParts: string[] = []; - - if (hiddenLineCount > 0) { - statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); - } - - if (this.#status === "cancelled") { - statusParts.push(theme.fg("warning", "(cancelled)")); - } else if (this.#status === "error") { - statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`)); - } - - if (this.#truncation) { - statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation))); - } - - if (statusParts.length > 0) { - this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); - } + const footer = buildStatusFooter({ + status: this.#status, + exitCode: this.#exitCode, + truncation: this.#truncation, + hiddenLineCount, + }); + if (footer) this.#contentContainer.addChild(footer); } } diff --git a/packages/coding-agent/src/modes/components/execution-shared.ts b/packages/coding-agent/src/modes/components/execution-shared.ts new file mode 100644 index 000000000..0ad34a780 --- /dev/null +++ b/packages/coding-agent/src/modes/components/execution-shared.ts @@ -0,0 +1,102 @@ +/** + * Shared rendering primitives for bash/eval execution components. + * + * Each helper isolates a piece of structure both components share verbatim + * (frame layout, collapsed preview, post-run status line). Differences in + * how each component prepares its header, output lines, or sixel masking + * stay in their respective files. + */ + +import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { getSymbolTheme, theme } from "../../modes/theme/theme"; +import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; +import { DynamicBorder } from "./dynamic-border"; +import { truncateToVisualLines } from "./visual-truncate"; + +export type ExecutionStatus = "running" | "complete" | "cancelled" | "error"; + +/** Theme color keys valid for an execution frame. */ +export type ExecutionColorKey = "dim" | "bashMode" | "pythonMode"; + +/** + * Build the spacer + top border + content container + bottom border scaffold + * that bash and eval execution components share. The caller appends the + * header (command vs `>>>` prompt) and the returned loader to + * `contentContainer` so per-mode order is preserved. + */ +export function buildExecutionFrame( + parent: Container, + ui: TUI, + colorKey: ExecutionColorKey, +): { contentContainer: Container; loader: Loader } { + const borderColor = (str: string) => theme.fg(colorKey, str); + + parent.addChild(new Spacer(1)); + parent.addChild(new DynamicBorder(borderColor)); + + const contentContainer = new Container(); + parent.addChild(contentContainer); + + const loader = new Loader( + ui, + spinner => theme.fg(colorKey, spinner), + text => theme.fg("muted", text), + `Running… (esc to cancel)`, + getSymbolTheme().spinnerFrames, + ); + + parent.addChild(new DynamicBorder(borderColor)); + return { contentContainer, loader }; +} + +/** + * Wrap a styled preview block in a render-time visual-line truncator. + * Recomputed per render width so wrapping stays in sync with terminal size. + */ +export function createCollapsedPreview(previewText: string, previewLines: number): Component { + return { + render: (width: number) => truncateToVisualLines(previewText, previewLines, width, 1).visualLines, + invalidate: () => {}, + }; +} + +/** + * Build the post-run status block (hidden-line hint, exit/cancel marker, + * truncation notice). Returns undefined when there is nothing to display so + * callers can skip appending a stray Text child. + */ +export function buildStatusFooter(opts: { + status: ExecutionStatus; + exitCode: number | undefined; + truncation: TruncationMeta | undefined; + hiddenLineCount: number; + /** Suppress the "… N more lines" hint (used when sixel passthrough renders the full output). */ + suppressHiddenCount?: boolean; +}): Text | undefined { + const parts: string[] = []; + + if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) { + parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (ctrl+o to expand)`)); + } + if (opts.status === "cancelled") { + parts.push(theme.fg("warning", "(cancelled)")); + } else if (opts.status === "error") { + parts.push(theme.fg("error", `(exit ${opts.exitCode})`)); + } + if (opts.truncation) { + parts.push(theme.fg("warning", formatTruncationMetaNotice(opts.truncation))); + } + + if (parts.length === 0) return undefined; + return new Text(`\n${parts.join("\n")}`, 1, 0); +} + +/** + * Derive the post-run status from an exit code + cancellation flag using the + * same precedence both execution components apply. + */ +export function resolveExecutionStatus(exitCode: number | undefined, cancelled: boolean): ExecutionStatus { + if (cancelled) return "cancelled"; + if (exitCode !== 0 && exitCode !== undefined && exitCode !== null) return "error"; + return "complete"; +} diff --git a/packages/coding-agent/src/modes/components/hook-message.ts b/packages/coding-agent/src/modes/components/hook-message.ts index 14f4ca9a1..dc19d306c 100644 --- a/packages/coding-agent/src/modes/components/hook-message.ts +++ b/packages/coding-agent/src/modes/components/hook-message.ts @@ -1,9 +1,12 @@ -import type { TextContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { Box, Container, Spacer } from "@oh-my-pi/pi-tui"; import type { HookMessageRenderer } from "../../extensibility/hooks/types"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +import { theme } from "../../modes/theme/theme"; import type { HookMessage } from "../../session/messages"; +import { renderFramedMessage } from "./message-frame"; + +/** Lines of default markdown body shown before the "…" fold when collapsed. */ +const HOOK_COLLAPSED_LINES = 5; /** * Component that renders a custom message entry from hooks. @@ -41,60 +44,25 @@ export class HookMessageComponent extends Container { } #rebuild(): void { - // Remove previous content component if (this.#customComponent) { this.removeChild(this.#customComponent); this.#customComponent = undefined; } this.removeChild(this.#box); - // Try custom renderer first - it handles its own styling - if (this.customRenderer) { - try { - const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); - if (component) { - // Custom renderer provides its own styled component - this.#customComponent = component; - this.addChild(component); - return; - } - } catch { - // Fall through to default rendering - } - } + const custom = renderFramedMessage({ + message: this.message, + box: this.#box, + expanded: this.#expanded, + customRenderer: this.customRenderer, + collapseAfterLines: HOOK_COLLAPSED_LINES, + }); - // Default rendering uses our box - this.addChild(this.#box); - this.#box.clear(); - - // Default rendering: label + content - const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); - this.#box.addChild(new Text(label, 0, 0)); - this.#box.addChild(new Spacer(1)); - - // Extract text content - let text: string; - if (typeof this.message.content === "string") { - text = this.message.content; + if (custom) { + this.#customComponent = custom; + this.addChild(custom); } else { - text = this.message.content - .filter((c): c is TextContent => c.type === "text") - .map(c => c.text) - .join("\n"); + this.addChild(this.#box); } - - // Limit lines when collapsed - if (!this.#expanded) { - const lines = text.split("\n"); - if (lines.length > 5) { - text = `${lines.slice(0, 5).join("\n")}\n…`; - } - } - - this.#box.addChild( - new Markdown(text, 0, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("customMessageText", text), - }), - ); } } diff --git a/packages/coding-agent/src/modes/components/message-frame.ts b/packages/coding-agent/src/modes/components/message-frame.ts new file mode 100644 index 000000000..57157cfe4 --- /dev/null +++ b/packages/coding-agent/src/modes/components/message-frame.ts @@ -0,0 +1,88 @@ +/** + * Shared rendering for extension/hook custom message frames. + * + * Both `CustomMessageComponent` and `HookMessageComponent` wrap a + * `Spacer(1) + Box` layout, try a user-supplied renderer first, and fall + * back to a label + markdown body when the renderer returns nothing or + * throws. The only meaningful difference is that hook messages collapse to + * the first N lines when not expanded; extension messages render in full. + */ + +import type { TextContent } from "@oh-my-pi/pi-ai"; +import type { Box, Component } from "@oh-my-pi/pi-tui"; +import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme"; + +/** Message shape consumed by the shared frame. */ +export interface FramedMessage { + customType: string; + content: string | (TextContent | { type: string })[]; +} + +/** + * Callable signature shared by `MessageRenderer` (extensions) and + * `HookMessageRenderer` (hooks). Both narrow `message` to their own type; + * this signature is the structural intersection callers can hand off here. + */ +export type FramedRenderer = ( + message: M, + options: { expanded: boolean }, + theme: Theme, +) => Component | undefined; + +export interface RebuildFrameOptions { + message: M; + box: Box; + expanded: boolean; + /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */ + collapseAfterLines?: number; + customRenderer?: FramedRenderer; +} + +/** + * Attempt the custom renderer; on failure or undefined return, populate + * `box` with the default `[customType]` label + markdown body and return + * undefined. When the custom renderer succeeds, return its Component so the + * caller can mount it and skip the default box. + */ +export function renderFramedMessage(opts: RebuildFrameOptions): Component | undefined { + if (opts.customRenderer) { + try { + const component = opts.customRenderer(opts.message, { expanded: opts.expanded }, theme); + if (component) return component; + } catch { + // Fall through to default rendering + } + } + + opts.box.clear(); + + const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`)); + opts.box.addChild(new Text(label, 0, 0)); + opts.box.addChild(new Spacer(1)); + + let text: string; + if (typeof opts.message.content === "string") { + text = opts.message.content; + } else { + text = opts.message.content + .filter((c): c is TextContent => c.type === "text") + .map(c => c.text) + .join("\n"); + } + + if (!opts.expanded && opts.collapseAfterLines !== undefined) { + const lines = text.split("\n"); + if (lines.length > opts.collapseAfterLines) { + text = `${lines.slice(0, opts.collapseAfterLines).join("\n")}\n…`; + } + } + + opts.box.addChild( + new Markdown(text, 0, 0, getMarkdownTheme(), { + color: (value: string) => theme.fg("customMessageText", value), + }), + ); + + return undefined; +} diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index fb04675d5..24dce7900 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai"; import { Container, + fuzzyFilter, getKeybindings, Input, matchesKey, @@ -18,7 +19,6 @@ import { resolveModelRoleValue } from "../../config/model-resolver"; import type { Settings } from "../../config/settings"; import { type ThemeColor, theme } from "../../modes/theme/theme"; import { getThinkingLevelMetadata } from "../../thinking"; -import { fuzzyFilter } from "../../utils/fuzzy"; import { getTabBarTheme } from "../shared"; import { DynamicBorder } from "./dynamic-border"; diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 17805fd9e..759af5784 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -1,6 +1,7 @@ import { type Component, Container, + fuzzyFilter, Input, matchesKey, padding, @@ -14,7 +15,6 @@ import { formatBytes } from "@oh-my-pi/pi-utils"; import { theme } from "../../modes/theme/theme"; import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import type { SessionInfo } from "../../session/session-manager"; -import { fuzzyFilter } from "../../utils/fuzzy"; import { DynamicBorder } from "./dynamic-border"; import { HookSelectorComponent } from "./hook-selector"; diff --git a/packages/coding-agent/src/modes/controllers/command-controller-shared.ts b/packages/coding-agent/src/modes/controllers/command-controller-shared.ts new file mode 100644 index 000000000..ba119d512 --- /dev/null +++ b/packages/coding-agent/src/modes/controllers/command-controller-shared.ts @@ -0,0 +1,108 @@ +/** + * Shared helpers for /mcp and /ssh command controllers. + * + * Captures argument parsing, source grouping, and chat-message rendering that + * was duplicated between mcp-command-controller and ssh-command-controller. + * Intentionally kept narrow: subcommand routing, help text, success/error + * wording, and add-flow logic stay in the per-controller files because they + * diverge in workflow. + */ +import { Spacer, Text } from "@oh-my-pi/pi-tui"; +import type { SourceMeta } from "../../capability/types"; +import { shortenPath } from "../../tools/render-utils"; +import { DynamicBorder } from "../components/dynamic-border"; +import { parseCommandArgs } from "../shared"; +import type { InteractiveModeContext } from "../types"; + +export type ScopeValue = "project" | "user"; + +export type ScopeFlagResult = { ok: true; scope: ScopeValue } | { ok: false; error: string }; + +/** + * Validate the value following a `--scope` flag. + */ +export function readScopeFlag(value: string | undefined): ScopeFlagResult { + if (!value || (value !== "project" && value !== "user")) { + return { ok: false, error: "Invalid --scope value. Use project or user." }; + } + return { ok: true, scope: value }; +} + +export type RemoveArgs = { name: string | undefined; scope: ScopeValue }; + +export type ParseRemoveResult = { ok: true; value: RemoveArgs } | { ok: false; error: string }; + +/** + * Parse the argument tail of `/ remove [--scope project|user]`. + * + * `rest` is the text after the subcommand keyword. The caller is responsible + * for emitting the command-specific " name required" usage hint when + * `value.name` is undefined. + */ +export function parseRemoveArgs(rest: string): ParseRemoveResult { + const tokens = parseCommandArgs(rest); + + let name: string | undefined; + let scope: ScopeValue = "project"; + let i = 0; + + if (tokens.length > 0 && !tokens[0].startsWith("-")) { + name = tokens[0]; + i = 1; + } + + while (i < tokens.length) { + const token = tokens[i]; + if (token === "--scope") { + const r = readScopeFlag(tokens[i + 1]); + if (!r.ok) return { ok: false, error: r.error }; + scope = r.scope; + i += 2; + continue; + } + return { ok: false, error: `Unknown option: ${token}` }; + } + + return { ok: true, value: { name, scope } }; +} + +/** + * Group capability-loaded items by their source provider+path, yielding each + * group with a display-ready `shortPath`. + */ +export function* groupBySource( + items: Iterable, + getSource: (item: T) => SourceMeta, +): Iterable<{ providerName: string; shortPath: string; items: T[] }> { + const groups = new Map(); + for (const item of items) { + const src = getSource(item); + const key = `${src.providerName}|${src.path}`; + let group = groups.get(key); + if (!group) { + group = []; + groups.set(key, group); + } + group.push(item); + } + for (const [key, grouped] of groups) { + const sepIdx = key.indexOf("|"); + yield { + providerName: key.slice(0, sepIdx), + shortPath: shortenPath(key.slice(sepIdx + 1)), + items: grouped, + }; + } +} + +/** + * Render a message block (DynamicBorder / Text / DynamicBorder) into the chat + * container and request a render. + */ +export function showCommandMessage(ctx: InteractiveModeContext, text: string): void { + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.chatContainer.addChild(new Text(text, 1, 1)); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.ui.requestRender(); +} 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 dc28c7248..6929a52e9 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../ import type { OAuthCredential } from "../../session/auth-storage"; import { shortenPath } from "../../tools/render-utils"; import { openPath } from "../../utils/open"; -import { DynamicBorder } from "../components/dynamic-border"; import { MCPAddWizard } from "../components/mcp-add-wizard"; import { parseCommandArgs } from "../shared"; import { theme } from "../theme/theme"; import type { InteractiveModeContext } from "../types"; +import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared"; function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { const { promise: timeoutPromise, reject } = Promise.withResolvers(); @@ -207,11 +207,11 @@ export class MCPCommandController { break; } if (argToken === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - return { scope, error: "Invalid --scope value. Use project or user." }; + const r = readScopeFlag(tokens[i + 1]); + if (!r.ok) { + return { scope, error: r.error }; } - scope = value; + scope = r.scope; i += 2; continue; } @@ -984,23 +984,7 @@ export class MCPCommandController { // Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.) if (discoveredServers.length > 0) { - // Group by source display name + path - const bySource = new Map(); - for (const entry of discoveredServers) { - const key = `${entry.source.providerName}|${entry.source.path}`; - let group = bySource.get(key); - if (!group) { - group = []; - bySource.set(key, group); - } - group.push(entry); - } - - for (const [key, entries] of bySource) { - const sepIdx = key.indexOf("|"); - const providerName = key.slice(0, sepIdx); - const sourcePath = key.slice(sepIdx + 1); - const shortPath = shortenPath(sourcePath); + for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) { lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`)); for (const { name } of entries) { const state = this.ctx.mcpManager!.getConnectionStatus(name); @@ -1037,32 +1021,12 @@ export class MCPCommandController { async #handleRemove(text: string): Promise { const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i); const rest = match?.[1]?.trim() ?? ""; - const tokens = parseCommandArgs(rest); - - let name: string | undefined; - let scope: "project" | "user" = "project"; - let i = 0; - - if (tokens.length > 0 && !tokens[0].startsWith("-")) { - name = tokens[0]; - i = 1; - } - - while (i < tokens.length) { - const token = tokens[i]; - if (token === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - this.ctx.showError("Invalid --scope value. Use project or user."); - return; - } - scope = value; - i += 2; - continue; - } - this.ctx.showError(`Unknown option: ${token}`); + const parsed = parseRemoveArgs(rest); + if (!parsed.ok) { + this.ctx.showError(parsed.error); return; } + const { name, scope } = parsed.value; if (!name) { this.ctx.showError("Server name required. Usage: /mcp remove [--scope project|user]"); @@ -1929,10 +1893,6 @@ export class MCPCommandController { * Show a message in the chat */ #showMessage(text: string): void { - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.chatContainer.addChild(new Text(text, 1, 1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.ui.requestRender(); + showCommandMessage(this.ctx, text); } } 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 59ea119c2..b9f6b0d8c 100644 --- a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts @@ -3,18 +3,20 @@ * * Handles /ssh subcommands for managing SSH host configurations. */ -import { Spacer, Text } from "@oh-my-pi/pi-tui"; import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils"; import { type SSHHost, sshCapability } from "../../capability/ssh"; import { loadCapability } from "../../discovery"; import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer"; -import { shortenPath } from "../../tools/render-utils"; -import { DynamicBorder } from "../components/dynamic-border"; import { parseCommandArgs } from "../shared"; import { theme } from "../theme/theme"; import type { InteractiveModeContext } from "../types"; - -type SSHAddScope = "user" | "project"; +import { + groupBySource, + parseRemoveArgs, + readScopeFlag, + type ScopeValue, + showCommandMessage, +} from "./command-controller-shared"; export class SSHCommandController { constructor(private ctx: InteractiveModeContext) {} @@ -90,7 +92,7 @@ export class SSHCommandController { } let name: string | undefined; - let scope: SSHAddScope = "project"; + let scope: ScopeValue = "project"; let host: string | undefined; let username: string | undefined; let port: number | undefined; @@ -167,12 +169,12 @@ export class SSHCommandController { continue; } if (argToken === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - this.ctx.showError("Invalid --scope value. Use project or user."); + const r = readScopeFlag(tokens[i + 1]); + if (!r.ok) { + this.ctx.showError(r.error); return; } - scope = value; + scope = r.scope; i += 2; continue; } @@ -300,23 +302,7 @@ export class SSHCommandController { // Show discovered hosts (from ssh.json, .ssh.json in project root, etc.) if (discoveredHosts.length > 0) { - // Group by source - const bySource = new Map(); - for (const host of discoveredHosts) { - const key = `${host._source.providerName}|${host._source.path}`; - let group = bySource.get(key); - if (!group) { - group = []; - bySource.set(key, group); - } - group.push(host); - } - - for (const [key, hosts] of bySource) { - const sepIdx = key.indexOf("|"); - const providerName = key.slice(0, sepIdx); - const sourcePath = key.slice(sepIdx + 1); - const shortPath = shortenPath(sourcePath); + for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) { lines.push( theme.fg("accent", "Discovered") + theme.fg("muted", ` (${providerName}: ${shortPath}):`) + @@ -357,33 +343,12 @@ export class SSHCommandController { async #handleRemove(text: string): Promise { const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i); const rest = match?.[1]?.trim() ?? ""; - const tokens = parseCommandArgs(rest); - - let name: string | undefined; - let scope: "project" | "user" = "project"; - let i = 0; - - if (tokens.length > 0 && !tokens[0].startsWith("-")) { - name = tokens[0]; - i = 1; - } - - while (i < tokens.length) { - const token = tokens[i]; - if (token === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - this.ctx.showError("Invalid --scope value. Use project or user."); - return; - } - scope = value; - i += 2; - continue; - } - this.ctx.showError(`Unknown option: ${token}`); + const parsed = parseRemoveArgs(rest); + if (!parsed.ok) { + this.ctx.showError(parsed.error); return; } - + const { name, scope } = parsed.value; if (!name) { this.ctx.showError("Host name required. Usage: /ssh remove [--scope project|user]"); return; @@ -412,10 +377,6 @@ export class SSHCommandController { * Show a message in the chat */ #showMessage(text: string): void { - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.chatContainer.addChild(new Text(text, 1, 1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.ui.requestRender(); + showCommandMessage(this.ctx, text); } } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index ce693216b..460d5e86c 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -7,8 +7,8 @@ */ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import { sanitizeText } from "@oh-my-pi/pi-natives"; -import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler"; import type { AgentSession } from "../session/agent-session"; +import { initializeExtensions } from "./runtime-init"; /** * Options for print mode. @@ -39,90 +39,16 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti } } // Set up extensions for print mode (no UI, no command context) - const extensionRunner = session.extensionRunner; - if (extensionRunner) { - extensionRunner.initialize( - // ExtensionActions - { - sendMessage: (message, options) => { - session.sendCustomMessage(message, options).catch(e => { - process.stderr.write(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}\n`); - }); - }, - sendUserMessage: (content, options) => { - session.sendUserMessage(content, options).catch(e => { - process.stderr.write( - `Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}\n`, - ); - }); - }, - appendEntry: (customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }, - setLabel: (targetId, label) => { - session.sessionManager.appendLabelChange(targetId, label); - }, - getActiveTools: () => session.getActiveToolNames(), - getAllTools: () => session.getAllToolNames(), - setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - getCommands: () => [], - setModel: model => runExtensionSetModel(session, model), - getThinkingLevel: () => session.thinkingLevel, - setThinkingLevel: level => session.setThinkingLevel(level), - getSessionName: () => session.sessionManager.getSessionName(), - setSessionName: async name => { - await session.sessionManager.setSessionName(name, "user"); - }, - }, - // ExtensionContextActions - { - getModel: () => session.model, - isIdle: () => !session.isStreaming, - abort: () => session.abort(), - hasPendingMessages: () => session.queuedMessageCount > 0, - shutdown: () => {}, - getContextUsage: () => session.getContextUsage(), - getSystemPrompt: () => session.systemPrompt, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - // ExtensionCommandContextActions - commands invokable via prompt("/command") - { - getContextUsage: () => session.getContextUsage(), - waitForIdle: () => session.agent.waitForIdle(), - newSession: async options => { - const success = await session.newSession({ parentSession: options?.parentSession }); - if (success && options?.setup) { - await options.setup(session.sessionManager); - } - return { cancelled: !success }; - }, - branch: async entryId => { - const result = await session.branch(entryId); - return { cancelled: result.cancelled }; - }, - navigateTree: async (targetId, options) => { - const result = await session.navigateTree(targetId, { summarize: options?.summarize }); - return { cancelled: result.cancelled }; - }, - switchSession: async sessionPath => { - const success = await session.switchSession(sessionPath); - return { cancelled: !success }; - }, - reload: async () => { - await session.reload(); - }, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - // No UI context - ); - extensionRunner.onError(err => { + await initializeExtensions(session, { + reportSendError: (action, err) => { + process.stderr.write( + `Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`, + ); + }, + reportRuntimeError: err => { process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`); - }); - // Emit session_start event - await extensionRunner.emit({ - type: "session_start", - }); - } + }, + }); // Always subscribe to enable session persistence via _handleAgentEvent session.subscribe(event => { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index f25a63454..a02886066 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -17,9 +17,9 @@ import type { ExtensionUIDialogOptions, ExtensionWidgetOptions, } from "../../extensibility/extensions"; -import { runExtensionCompact, runExtensionSetModel } from "../../extensibility/extensions/compact-handler"; import { type Theme, theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; +import { initializeExtensions } from "../runtime-init"; import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools"; import type { RpcCommand, @@ -421,91 +421,18 @@ export async function runRpcMode( setToolUIContext?.(rpcUiContext, true); // Set up extensions with RPC-based UI context - const extensionRunner = session.extensionRunner; - if (extensionRunner) { - extensionRunner.initialize( - // ExtensionActions - { - sendMessage: (message, options) => { - session.sendCustomMessage(message, options).catch(e => { - output(error(undefined, "extension_send", e.message)); - }); - }, - sendUserMessage: (content, options) => { - session.sendUserMessage(content, options).catch(e => { - output(error(undefined, "extension_send_user", e.message)); - }); - }, - appendEntry: (customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }, - setLabel: (targetId, label) => { - session.sessionManager.appendLabelChange(targetId, label); - }, - getActiveTools: () => session.getActiveToolNames(), - getAllTools: () => session.getAllToolNames(), - setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - getCommands: () => [], - setModel: model => runExtensionSetModel(session, model), - getThinkingLevel: () => session.thinkingLevel, - setThinkingLevel: level => session.setThinkingLevel(level), - getSessionName: () => session.sessionManager.getSessionName(), - setSessionName: async name => { - await session.sessionManager.setSessionName(name, "user"); - }, - }, - // ExtensionContextActions - { - getModel: () => session.agent.state.model, - isIdle: () => !session.isStreaming, - abort: () => session.abort(), - hasPendingMessages: () => session.queuedMessageCount > 0, - shutdown: () => { - shutdownState.requested = true; - }, - getContextUsage: () => session.getContextUsage(), - getSystemPrompt: () => session.systemPrompt, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - // ExtensionCommandContextActions - commands invokable via prompt("/command") - { - getContextUsage: () => session.getContextUsage(), - waitForIdle: () => session.agent.waitForIdle(), - newSession: async options => { - const success = await session.newSession({ parentSession: options?.parentSession }); - // Note: setup callback runs but no UI feedback in RPC mode - if (success && options?.setup) { - await options.setup(session.sessionManager); - } - return { cancelled: !success }; - }, - branch: async entryId => { - const result = await session.branch(entryId); - return { cancelled: result.cancelled }; - }, - navigateTree: async (targetId, options) => { - const result = await session.navigateTree(targetId, { summarize: options?.summarize }); - return { cancelled: result.cancelled }; - }, - switchSession: async sessionPath => { - const success = await session.switchSession(sessionPath); - return { cancelled: !success }; - }, - reload: async () => { - await session.reload(); - }, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - rpcUiContext, - ); - extensionRunner.onError(err => { + await initializeExtensions(session, { + reportSendError: (action, err) => { + output(error(undefined, action, err.message)); + }, + reportRuntimeError: err => { output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); - }); - // Emit session_start event - await extensionRunner.emit({ - type: "session_start", - }); - } + }, + onShutdown: () => { + shutdownState.requested = true; + }, + uiContext: rpcUiContext, + }); // Output all agent events as JSON session.subscribe(event => { @@ -850,8 +777,8 @@ export async function runRpcMode( async function checkShutdownRequested(): Promise { if (!shutdownState.requested) return; - if (extensionRunner?.hasHandlers("session_shutdown")) { - await extensionRunner.emit({ type: "session_shutdown" }); + if (session.extensionRunner?.hasHandlers("session_shutdown")) { + await session.extensionRunner.emit({ type: "session_shutdown" }); } process.exit(0); diff --git a/packages/coding-agent/src/modes/runtime-init.ts b/packages/coding-agent/src/modes/runtime-init.ts new file mode 100644 index 000000000..426265770 --- /dev/null +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -0,0 +1,115 @@ +/** + * Shared extension runtime wiring for print and RPC modes. + * + * Both modes initialize the extension runner with the same action handlers + * that delegate to the {@link AgentSession}. Only error reporting, shutdown + * behavior, and UI context differ between callers — those stay as + * caller-supplied hooks. + */ +import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler"; +import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types"; +import type { AgentSession } from "../session/agent-session"; + +/** Action name for an extension-originated send failure. */ +export type ExtensionSendAction = "extension_send" | "extension_send_user"; + +export interface InitializeExtensionsOptions { + /** Reports an error thrown by an extension-initiated send. */ + reportSendError: (action: ExtensionSendAction, error: Error) => void; + /** Reports a runtime error surfaced through {@link ExtensionRunner.onError}. */ + reportRuntimeError: (error: ExtensionError) => void; + /** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */ + onShutdown?: () => void; + /** Optional UI context (rpc supplies one; print runs headless). */ + uiContext?: ExtensionUIContext; +} + +/** + * Initialize the session's extension runner with the standard action set + * shared by non-interactive modes, then emit `session_start`. + * + * No-op when the session was constructed without an extension runner. + */ +export async function initializeExtensions(session: AgentSession, options: InitializeExtensionsOptions): Promise { + const runner = session.extensionRunner; + if (!runner) return; + + const { reportSendError, reportRuntimeError, onShutdown, uiContext } = options; + const shutdown = onShutdown ?? (() => {}); + + runner.initialize( + // ExtensionActions + { + sendMessage: (message, sendOptions) => { + session.sendCustomMessage(message, sendOptions).catch(e => { + reportSendError("extension_send", e instanceof Error ? e : new Error(String(e))); + }); + }, + sendUserMessage: (content, sendOptions) => { + session.sendUserMessage(content, sendOptions).catch(e => { + reportSendError("extension_send_user", e instanceof Error ? e : new Error(String(e))); + }); + }, + appendEntry: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + setLabel: (targetId, label) => { + session.sessionManager.appendLabelChange(targetId, label); + }, + getActiveTools: () => session.getActiveToolNames(), + getAllTools: () => session.getAllToolNames(), + setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + getCommands: () => [], + setModel: model => runExtensionSetModel(session, model), + getThinkingLevel: () => session.thinkingLevel, + setThinkingLevel: level => session.setThinkingLevel(level), + getSessionName: () => session.sessionManager.getSessionName(), + setSessionName: async name => { + await session.sessionManager.setSessionName(name, "user"); + }, + }, + // ExtensionContextActions + { + getModel: () => session.model, + isIdle: () => !session.isStreaming, + abort: () => session.abort(), + hasPendingMessages: () => session.queuedMessageCount > 0, + shutdown, + getContextUsage: () => session.getContextUsage(), + getSystemPrompt: () => session.systemPrompt, + compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), + }, + // ExtensionCommandContextActions — commands invokable via prompt("/command") + { + getContextUsage: () => session.getContextUsage(), + waitForIdle: () => session.agent.waitForIdle(), + newSession: async newOptions => { + const success = await session.newSession({ parentSession: newOptions?.parentSession }); + if (success && newOptions?.setup) { + await newOptions.setup(session.sessionManager); + } + return { cancelled: !success }; + }, + branch: async entryId => { + const result = await session.branch(entryId); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, navOptions) => { + const result = await session.navigateTree(targetId, { summarize: navOptions?.summarize }); + return { cancelled: result.cancelled }; + }, + switchSession: async sessionPath => { + const success = await session.switchSession(sessionPath); + return { cancelled: !success }; + }, + reload: async () => { + await session.reload(); + }, + compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), + }, + uiContext, + ); + + runner.onError(reportRuntimeError); + await runner.emit({ type: "session_start" }); +} diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 9fdebddb6..c8a2feac9 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -2332,12 +2332,12 @@ export function getSymbolTheme(): SymbolTheme { }; } -let _markdownTheme: MarkdownTheme | undefined; -let _markdownThemeRef: Theme | undefined; +let cachedMarkdownTheme: MarkdownTheme | undefined; +let cachedMarkdownThemeRef: Theme | undefined; export function getMarkdownTheme(): MarkdownTheme { - if (_markdownTheme !== undefined && _markdownThemeRef === theme) { - return _markdownTheme; + if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) { + return cachedMarkdownTheme; } const markdownTheme: MarkdownTheme = { heading: (text: string) => theme.fg("mdHeading", text), @@ -2365,8 +2365,8 @@ export function getMarkdownTheme(): MarkdownTheme { } }, }; - _markdownTheme = markdownTheme; - _markdownThemeRef = theme; + cachedMarkdownTheme = markdownTheme; + cachedMarkdownThemeRef = theme; return markdownTheme; } diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 64f7a7f5b..9fd449775 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -245,7 +245,7 @@ export class UiHelpers { break; } default: { - const _exhaustive: never = message; + message satisfies never; } } return []; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f61f35a13..2f3e891a6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4684,8 +4684,6 @@ export class AgentSession { let hookCompaction: CompactionResult | undefined; let fromExtension = false; - let hookContext: string[] | undefined; - let hookPrompt: string | undefined; let preserveData: Record | undefined; if (this.#extensionRunner?.hasHandlers("session_before_compact")) { @@ -4707,23 +4705,7 @@ export class AgentSession { } } - if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { - const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); - const result = (await this.#extensionRunner.emit({ - type: "session.compacting", - sessionId: this.sessionId, - messages: compactMessages, - })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; - - hookContext = result?.context; - hookPrompt = result?.prompt; - preserveData = result?.preserveData; - } - - const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); - if (memoryBackendContext) { - hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; - } + const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); let summary: string; let shortSummary: string | undefined; @@ -4731,14 +4713,13 @@ export class AgentSession { let tokensBefore: number; let details: unknown; - if (hookCompaction) { - // Extension provided compaction content - summary = hookCompaction.summary; - shortSummary = hookCompaction.shortSummary; - firstKeptEntryId = hookCompaction.firstKeptEntryId; - tokensBefore = hookCompaction.tokensBefore; - details = hookCompaction.details; - preserveData ??= hookCompaction.preserveData; + if (compactionPrep.kind === "fromHook") { + summary = compactionPrep.summary; + shortSummary = compactionPrep.shortSummary; + firstKeptEntryId = compactionPrep.firstKeptEntryId; + tokensBefore = compactionPrep.tokensBefore; + details = compactionPrep.details; + preserveData = compactionPrep.preserveData; } else { // Generate compaction result. Only convert known abort-shaped // rejections (AbortError raised while the abort signal is set, @@ -4757,8 +4738,8 @@ export class AgentSession { customInstructions, compactionAbortController.signal, { - promptOverride: hookPrompt, - extraContext: hookContext, + promptOverride: compactionPrep.hookPrompt, + extraContext: compactionPrep.hookContext, remoteInstructions: this.#baseSystemPrompt.join("\n\n"), }, ); @@ -4767,7 +4748,7 @@ export class AgentSession { firstKeptEntryId = result.firstKeptEntryId; tokensBefore = result.tokensBefore; details = result.details; - preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) }; + preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) }; } catch (err) { if (err instanceof CompactionCancelledError) { throw err; @@ -5727,6 +5708,64 @@ export class AgentSession { throw this.#buildCompactionAuthError(); } + async #prepareCompactionFromHooks( + preparation: CompactionPreparation, + hookCompaction: CompactionResult | undefined, + ): Promise< + | { + kind: "fromHook"; + summary: string; + shortSummary: string | undefined; + firstKeptEntryId: string; + tokensBefore: number; + details: unknown; + preserveData: Record | undefined; + } + | { + kind: "needsLlm"; + hookContext: string[] | undefined; + hookPrompt: string | undefined; + preserveData: Record | undefined; + } + > { + let hookContext: string[] | undefined; + let hookPrompt: string | undefined; + let preserveData: Record | undefined; + + if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { + const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); + const result = (await this.#extensionRunner.emit({ + type: "session.compacting", + sessionId: this.sessionId, + messages: compactMessages, + })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; + + hookContext = result?.context; + hookPrompt = result?.prompt; + preserveData = result?.preserveData; + } + + const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); + if (memoryBackendContext) { + hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; + } + + if (hookCompaction) { + preserveData ??= hookCompaction.preserveData; + return { + kind: "fromHook", + summary: hookCompaction.summary, + shortSummary: hookCompaction.shortSummary, + firstKeptEntryId: hookCompaction.firstKeptEntryId, + tokensBefore: hookCompaction.tokensBefore, + details: hookCompaction.details, + preserveData, + }; + } + + return { kind: "needsLlm", hookContext, hookPrompt, preserveData }; + } + /** * Internal: Run auto-compaction with events. */ @@ -5848,8 +5887,6 @@ export class AgentSession { let hookCompaction: CompactionResult | undefined; let fromExtension = false; - let hookContext: string[] | undefined; - let hookPrompt: string | undefined; let preserveData: Record | undefined; if (this.#extensionRunner?.hasHandlers("session_before_compact")) { @@ -5878,23 +5915,7 @@ export class AgentSession { } } - if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { - const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); - const result = (await this.#extensionRunner.emit({ - type: "session.compacting", - sessionId: this.sessionId, - messages: compactMessages, - })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; - - hookContext = result?.context; - hookPrompt = result?.prompt; - preserveData = result?.preserveData; - } - - const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); - if (memoryBackendContext) { - hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; - } + const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); let summary: string; let shortSummary: string | undefined; @@ -5902,14 +5923,13 @@ export class AgentSession { let tokensBefore: number; let details: unknown; - if (hookCompaction) { - // Extension provided compaction content - summary = hookCompaction.summary; - shortSummary = hookCompaction.shortSummary; - firstKeptEntryId = hookCompaction.firstKeptEntryId; - tokensBefore = hookCompaction.tokensBefore; - details = hookCompaction.details; - preserveData ??= hookCompaction.preserveData; + if (compactionPrep.kind === "fromHook") { + summary = compactionPrep.summary; + shortSummary = compactionPrep.shortSummary; + firstKeptEntryId = compactionPrep.firstKeptEntryId; + tokensBefore = compactionPrep.tokensBefore; + details = compactionPrep.details; + preserveData = compactionPrep.preserveData; } else { const candidates = this.#getCompactionModelCandidates(availableModels); const retrySettings = this.settings.getGroup("retry"); @@ -5924,8 +5944,8 @@ export class AgentSession { while (true) { try { compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, { - promptOverride: hookPrompt, - extraContext: hookContext, + promptOverride: compactionPrep.hookPrompt, + extraContext: compactionPrep.hookContext, remoteInstructions: this.#baseSystemPrompt.join("\n\n"), metadata: this.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", @@ -6003,7 +6023,7 @@ export class AgentSession { firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; details = compactResult.details; - preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) }; + preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) }; } if (autoCompactionSignal.aborted) { diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 061ef5d42..310a7cd8d 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -364,8 +364,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { attribution: m.attribution ?? "agent", }; default: - // biome-ignore lint/correctness/noSwitchDeclarations: fine - const _exhaustiveCheck: never = m; + m satisfies never; return undefined; } }) diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 60d44a309..3be444d89 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -7,33 +7,27 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; -import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" }; -import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; import { formatGroupedFiles } from "./grouped-file-output"; import type { OutputMeta } from "./output-meta"; +import { resolveToolSearchScope } from "./path-utils"; import { - formatPathRelativeToCwd, - hasGlobPathChars, - normalizePathLikeInput, - parseSearchPath, - partitionExistingPaths, - resolveExplicitSearchPaths, - resolveToCwd, -} from "./path-utils"; -import { + appendParseErrorsBulletList, + createCachedComponent, dedupeParseErrors, formatCodeFrameLine, formatCount, formatEmptyMessage, formatErrorMessage, formatParseErrors, - PARSE_ERRORS_LIMIT, + formatParseErrorsCountLabel, PREVIEW_LIMITS, + splitGroupsByBlankLine, } from "./render-utils"; import { queueResolveHandler } from "./resolve"; import { ToolError } from "./tool-errors"; @@ -205,63 +199,12 @@ export class AstEditTool implements AgentTool formatPathRelativeToCwd(targetPath, this.session.cwd); - let searchPath: string; - let scopePath: string; - let globFilter: string | undefined; - let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; - const rawPaths = params.paths.map(normalizePathLikeInput); - if (rawPaths.some(rawPath => rawPath.length === 0)) { - throw new ToolError("`paths` must contain non-empty paths or globs"); - } - const internalRouter = InternalUrlRouter.instance(); - const resolvedPathInputs: string[] = []; - for (const rawPath of rawPaths) { - if (!internalRouter.canHandle(rawPath)) { - resolvedPathInputs.push(rawPath); - continue; - } - if (hasGlobPathChars(rawPath)) { - throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); - } - const resource = await internalRouter.resolve(rawPath); - if (!resource.sourcePath) { - throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`); - } - resolvedPathInputs.push(resource.sourcePath); - } - let effectivePathInputs = resolvedPathInputs; - if (resolvedPathInputs.length > 1) { - const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath); - if (partition.valid.length === 0) { - throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); - } - effectivePathInputs = partition.valid; - } - if (effectivePathInputs.length === 1) { - const parsedPath = parseSearchPath(effectivePathInputs[0] ?? "."); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - globFilter = parsedPath.glob; - scopePath = formatScopePath(searchPath); - } else { - const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter); - if (!multiSearchPath) { - throw new ToolError("`paths` must contain at least one path or glob"); - } - searchPath = multiSearchPath.basePath; - globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob; - multiTargets = multiSearchPath.targets; - scopePath = multiSearchPath.scopePath; - } - const resolvedSearchPath = searchPath; - scopePath = scopePath ?? formatScopePath(resolvedSearchPath); - let isDirectory: boolean; - try { - const stat = await Bun.file(resolvedSearchPath).stat(); - isDirectory = stat.isDirectory(); - } catch { - throw new ToolError(`Path not found: ${scopePath}`); - } + const scope = await resolveToolSearchScope({ + rawPaths: params.paths, + cwd: this.session.cwd, + internalUrlAction: "rewrite", + }); + const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope; const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, { rewrites: normalizedRewrites, @@ -502,15 +445,7 @@ export const astEditToolRenderer = { if (filesSearched > 0) meta.push(`searched ${filesSearched}`); const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme); const lines = [header, formatEmptyMessage("No replacements made", uiTheme)]; - if (details?.parseErrors?.length) { - const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT); - for (const err of capped) { - lines.push(uiTheme.fg("warning", ` - ${err}`)); - } - if (details.parseErrors.length > PARSE_ERRORS_LIMIT) { - lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`)); - } - } + appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme); return new Text(lines.join("\n"), 0, 0); } @@ -523,28 +458,7 @@ export const astEditToolRenderer = { const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined; const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; - const rawLines = textContent.split("\n"); - const hasSeparators = rawLines.some(line => line.trim().length === 0); - const allGroups: string[][] = []; - if (hasSeparators) { - let current: string[] = []; - for (const line of rawLines) { - if (line.trim().length === 0) { - if (current.length > 0) { - allGroups.push(current); - current = []; - } - continue; - } - current.push(line); - } - if (current.length > 0) allGroups.push(current); - } else { - const nonEmpty = rawLines.filter(line => line.trim().length > 0); - if (nonEmpty.length > 0) { - allGroups.push(nonEmpty); - } - } + const allGroups = splitGroupsByBlankLine(textContent.split("\n")); const changeGroups = allGroups.filter( group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"), ); @@ -560,23 +474,15 @@ export const astEditToolRenderer = { extraLines.push(uiTheme.fg("warning", "limit reached; narrow path")); } if (details?.parseErrors?.length) { - const total = details.parseErrors.length; - const label = - total > PARSE_ERRORS_LIMIT - ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues` - : `${total} parse issue${total !== 1 ? "s" : ""}`; - extraLines.push(uiTheme.fg("warning", label)); + extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors))); } - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const changeLines = renderTreeList( { items: changeGroups, - expanded, + expanded: options.expanded, maxCollapsed: changeGroups.length, maxCollapsedLines: COLLAPSED_CHANGE_LIMIT, itemType: "change", @@ -591,14 +497,9 @@ export const astEditToolRenderer = { }, uiTheme, ); - const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: rendered }; - return rendered; + return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 12fcd56b1..3e0a2f05c 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -6,34 +6,28 @@ import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; -import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; import { formatGroupedFiles } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; import type { OutputMeta } from "./output-meta"; +import { resolveToolSearchScope } from "./path-utils"; import { - formatPathRelativeToCwd, - hasGlobPathChars, - normalizePathLikeInput, - parseSearchPath, - partitionExistingPaths, - resolveExplicitSearchPaths, - resolveToCwd, -} from "./path-utils"; -import { + appendParseErrorsBulletList, + createCachedComponent, dedupeParseErrors, formatCodeFrameLine, formatCount, formatEmptyMessage, formatErrorMessage, formatParseErrors, - PARSE_ERRORS_LIMIT, + formatParseErrorsCountLabel, PREVIEW_LIMITS, + splitGroupsByBlankLine, } from "./render-utils"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool formatPathRelativeToCwd(targetPath, this.session.cwd); - let searchPath: string; - let scopePath: string; - let globFilter: string | undefined; - let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; - const rawPaths = params.paths.map(normalizePathLikeInput); - if (rawPaths.some(rawPath => rawPath.length === 0)) { - throw new ToolError("`paths` must contain non-empty paths or globs"); - } - const internalRouter = InternalUrlRouter.instance(); - const resolvedPathInputs: string[] = []; - for (const rawPath of rawPaths) { - if (!internalRouter.canHandle(rawPath)) { - resolvedPathInputs.push(rawPath); - continue; - } - if (hasGlobPathChars(rawPath)) { - throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); - } - const resource = await internalRouter.resolve(rawPath); - if (!resource.sourcePath) { - throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`); - } - resolvedPathInputs.push(resource.sourcePath); - } - let effectivePathInputs = resolvedPathInputs; - if (resolvedPathInputs.length > 1) { - const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath); - if (partition.valid.length === 0) { - throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); - } - effectivePathInputs = partition.valid; - } - if (effectivePathInputs.length === 1) { - const parsedPath = parseSearchPath(effectivePathInputs[0] ?? "."); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - globFilter = parsedPath.glob; - scopePath = formatScopePath(searchPath); - } else { - const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter); - if (!multiSearchPath) { - throw new ToolError("`paths` must contain at least one path or glob"); - } - searchPath = multiSearchPath.basePath; - globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob; - multiTargets = multiSearchPath.targets; - scopePath = multiSearchPath.scopePath; - } - - const resolvedSearchPath = searchPath; - scopePath = scopePath ?? formatScopePath(resolvedSearchPath); - let isDirectory: boolean; - try { - const stat = await Bun.file(resolvedSearchPath).stat(); - isDirectory = stat.isDirectory(); - } catch { - throw new ToolError(`Path not found: ${scopePath}`); - } + const scope = await resolveToolSearchScope({ + rawPaths: params.paths, + cwd: this.session.cwd, + internalUrlAction: "search", + }); + const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope; const DEFAULT_AST_LIMIT = 50; const result = multiTargets @@ -388,13 +330,7 @@ export const astGrepToolRenderer = { const lines = [header, formatEmptyMessage("No matches found", uiTheme)]; if (details?.parseErrors?.length) { lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence")); - const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT); - for (const err of capped) { - lines.push(uiTheme.fg("warning", ` - ${err}`)); - } - if (details.parseErrors.length > PARSE_ERRORS_LIMIT) { - lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`)); - } + appendParseErrorsBulletList(lines, details.parseErrors, uiTheme); } return new Text(lines.join("\n"), 0, 0); } @@ -411,28 +347,7 @@ export const astGrepToolRenderer = { ); const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; - const rawLines = textContent.split("\n"); - const hasSeparators = rawLines.some(line => line.trim().length === 0); - const allGroups: string[][] = []; - if (hasSeparators) { - let current: string[] = []; - for (const line of rawLines) { - if (line.trim().length === 0) { - if (current.length > 0) { - allGroups.push(current); - current = []; - } - continue; - } - current.push(line); - } - if (current.length > 0) allGroups.push(current); - } else { - const nonEmpty = rawLines.filter(line => line.trim().length > 0); - if (nonEmpty.length > 0) { - allGroups.push(nonEmpty); - } - } + const allGroups = splitGroupsByBlankLine(textContent.split("\n")); const matchGroups = allGroups.filter( group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"), ); @@ -442,24 +357,16 @@ export const astGrepToolRenderer = { extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit")); } if (details?.parseErrors?.length) { - const total = details.parseErrors.length; - const label = - total > PARSE_ERRORS_LIMIT - ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues` - : `${total} parse issue${total !== 1 ? "s" : ""}`; - extraLines.push(uiTheme.fg("warning", label)); + extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors))); } - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const matchLines = renderTreeList( { items: matchGroups, - expanded, + expanded: options.expanded, maxCollapsed: matchGroups.length, maxCollapsedLines: COLLAPSED_MATCH_LIMIT, itemType: "match", @@ -473,14 +380,9 @@ export const astGrepToolRenderer = { }, uiTheme, ); - const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: rendered }; - return rendered; + return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/browser/attach.ts b/packages/coding-agent/src/tools/browser/attach.ts index c83285fd7..d6be0bec5 100644 --- a/packages/coding-agent/src/tools/browser/attach.ts +++ b/packages/coding-agent/src/tools/browser/attach.ts @@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives"; import type { Browser, Page } from "puppeteer-core"; import { ToolError, throwIfAborted } from "../tool-errors"; -export const ATTACH_TARGET_SKIP_PATTERN = +const ATTACH_TARGET_SKIP_PATTERN = /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i; /** @@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo * accepts both `--flag=value` and `--flag value`). Returns null if absent or * malformed. */ -export function findCdpPortInArgs(args: string[]): number | null { +function findCdpPortInArgs(args: string[]): number | null { for (const arg of args) { const m = /^--remote-debugging-port=(\d+)$/.exec(arg); if (m) { @@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null { } /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */ -export async function probeCdpAt(port: number, signal?: AbortSignal): Promise { +async function probeCdpAt(port: number, signal?: AbortSignal): Promise { const probeTimeout = AbortSignal.timeout(1500); const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout; try { diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index 823866570..4b6412f4b 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -30,12 +30,12 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1 * connection dropped, etc.). */ export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000; -export const STEALTH_IGNORE_DEFAULT_ARGS = [ +const STEALTH_IGNORE_DEFAULT_ARGS = [ "--disable-extensions", "--disable-default-apps", "--disable-component-extensions-with-background-pages", ]; -export const STEALTH_ACCEPT_LANGUAGE = "en-US,en"; +const STEALTH_ACCEPT_LANGUAGE = "en-US,en"; const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__"; @@ -82,7 +82,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise | undefined; -export async function ensureChromiumExecutable(): Promise { +async function ensureChromiumExecutable(): Promise { const sysChrome = resolveSystemChromium(); if (sysChrome) return sysChrome; const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; @@ -138,7 +138,7 @@ export async function ensureChromiumExecutable(): Promise { return chromiumExecutablePromise; } -let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found +let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found function isExecutableFile(p: string): boolean { try { @@ -209,19 +209,19 @@ function systemChromiumCandidates(): string[] { return candidates; } -export function resolveSystemChromium(): string | undefined { - if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined; +function resolveSystemChromium(): string | undefined { + if (resolvedChromium !== undefined) return resolvedChromium ?? undefined; const seen = new Set(); for (const candidate of systemChromiumCandidates()) { if (!candidate || seen.has(candidate)) continue; seen.add(candidate); if (isExecutableFile(candidate)) { - _resolvedChromium = candidate; + resolvedChromium = candidate; logger.debug("Using system Chrome/Chromium", { path: candidate }); return candidate; } } - _resolvedChromium = null; + resolvedChromium = null; return undefined; } diff --git a/packages/coding-agent/src/tools/browser/registry.ts b/packages/coding-agent/src/tools/browser/registry.ts index 78aa7ccb6..c8caff4c7 100644 --- a/packages/coding-agent/src/tools/browser/registry.ts +++ b/packages/coding-agent/src/tools/browser/registry.ts @@ -26,10 +26,6 @@ export interface BrowserHandle { const browsers = new Map(); -export function listBrowsers(): BrowserHandle[] { - return [...browsers.values()]; -} - function browserKey(kind: BrowserKind): string { switch (kind.kind) { case "headless": @@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea } } -export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise { +async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise { if (handle.kind.kind === "headless") { if (handle.browser.connected) { try { diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts index 16770068d..11cbee89d 100644 --- a/packages/coding-agent/src/tools/browser/tab-supervisor.ts +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -89,10 +89,6 @@ export function getTab(name: string): TabSession | undefined { return tabs.get(name); } -export function listTabs(): TabSession[] { - return [...tabs.values()]; -} - export async function acquireTab( name: string, browser: BrowserHandle, diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 2f1027d9e..64cb858cf 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -12,15 +12,7 @@ import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import findDescription from "../prompts/tools/find.md" with { type: "text" }; import { type TruncationResult, truncateHead } from "../session/streaming-output"; -import { - Ellipsis, - Hasher, - type RenderCache, - renderFileList, - renderStatusLine, - renderTreeList, - truncateToWidth, -} from "../tui"; +import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; @@ -33,7 +25,13 @@ import { resolveExplicitFindPatterns, resolveToCwd, } from "./path-utils"; -import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; +import { + createCachedComponent, + formatCount, + formatEmptyMessage, + formatErrorMessage, + PREVIEW_LIMITS, +} from "./render-utils"; import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -401,30 +399,22 @@ export const findToolRenderer = { }, uiTheme, ); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const listLines = renderTreeList( { items: lines, - expanded, + expanded: options.expanded, maxCollapsed: COLLAPSED_LIST_LIMIT, itemType: "file", renderItem: line => uiTheme.fg("accent", line), }, uiTheme, ); - const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); } const fileCount = details?.fileCount ?? 0; @@ -467,28 +457,20 @@ export const findToolRenderer = { } if (missingNote) extraLines.push(missingNote); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const fileLines = renderFileList( { files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })), - expanded, + expanded: options.expanded, maxCollapsed: COLLAPSED_LIST_LIMIT, }, uiTheme, ); - const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index 055ef66dd..6c9ad87ae 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,7 +1,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai"; +import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; +import { extractTextContent } from "../commit/utils"; import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver"; import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" }; import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" }; @@ -30,14 +31,6 @@ export interface InspectImageToolDetails { mimeType: string; } -function extractResponseText(message: AssistantMessage): string { - return message.content - .filter(content => content.type === "text") - .map(content => content.text) - .join("") - .trim(); -} - export class InspectImageTool implements AgentTool { readonly name = "inspect_image"; readonly label = "InspectImage"; @@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool; +} + +/** + * Shared path-input pipeline for `search`, `ast_grep`, and `ast_edit`: + * 1. normalize + reject empty paths, + * 2. resolve internal URLs through {@link InternalUrlRouter} to backing files, + * 3. partition existing vs missing when multiple paths are supplied, + * 4. derive a single search base path / glob, or a multi-target list, + * 5. stat the resolved base path so callers can branch on directory vs file scope. + */ +export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise { + const { rawPaths: inputs, cwd, internalUrlAction } = opts; + const rawPaths = inputs.map(normalizePathLikeInput); + if (rawPaths.some(rawPath => rawPath.length === 0)) { + throw new ToolError("`paths` must contain non-empty paths or globs"); + } + const internalRouter = InternalUrlRouter.instance(); + const resolvedPathInputs: string[] = []; + const immutableSourcePaths = new Set(); + for (const rawPath of rawPaths) { + if (!internalRouter.canHandle(rawPath)) { + resolvedPathInputs.push(rawPath); + continue; + } + if (hasGlobPathChars(rawPath)) { + throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); + } + const resource = await internalRouter.resolve(rawPath); + if (!resource.sourcePath) { + throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`); + } + if (opts.trackImmutableSources && resource.immutable) { + immutableSourcePaths.add(path.resolve(resource.sourcePath)); + } + resolvedPathInputs.push(resource.sourcePath); + } + + let missingPaths: string[] = []; + let effectivePaths = resolvedPathInputs; + if (resolvedPathInputs.length > 1) { + const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath); + if (partition.valid.length === 0) { + throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); + } + effectivePaths = partition.valid; + missingPaths = partition.missing; + } + + let searchPath: string; + let scopePath: string; + let globFilter: string | undefined; + let multiTargets: ResolvedSearchTarget[] | undefined; + let exactFilePaths: string[] | undefined; + if (effectivePaths.length === 1) { + const parsedPath = parseSearchPath(effectivePaths[0] ?? "."); + searchPath = resolveToCwd(parsedPath.basePath, cwd); + globFilter = parsedPath.glob; + scopePath = formatPathRelativeToCwd(searchPath, cwd); + } else { + const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd); + if (!multiSearchPath) { + throw new ToolError("`paths` must contain at least one path or glob"); + } + searchPath = multiSearchPath.basePath; + multiTargets = multiSearchPath.targets; + if (opts.surfaceExactFilePaths) { + exactFilePaths = multiSearchPath.exactFilePaths; + globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob; + } else { + globFilter = multiTargets ? undefined : multiSearchPath.glob; + } + scopePath = multiSearchPath.scopePath; + } + + let isDirectory: boolean; + try { + const stat = await Bun.file(searchPath).stat(); + isDirectory = stat.isDirectory(); + } catch { + const hint = opts.multipathStatHint && rawPaths.length > 1 ? opts.multipathStatHint : ""; + throw new ToolError(`Path not found: ${scopePath}${hint}`); + } + + return { + searchPath, + scopePath, + globFilter, + isDirectory, + multiTargets, + exactFilePaths, + missingPaths, + immutableSourcePaths, + }; +} diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 3220f1384..29780287d 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1321,8 +1321,6 @@ export class ReadTool implements AgentTool { const imageMetadata = await readImageMetadata(absolutePath); const mimeType = imageMetadata?.mimeType; const ext = path.extname(absolutePath).toLowerCase(); - const _hasEditTool = this.session.hasEditTool ?? true; - const _language = getLanguageFromPath(absolutePath); const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext); // Read the file based on type let content: Array | undefined; diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index d064569c5..5c7bbcc15 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -9,10 +9,12 @@ import * as os from "node:os"; import * as path from "node:path"; import type { ToolCallContext } from "@oh-my-pi/pi-agent-core"; import type { Ellipsis } from "@oh-my-pi/pi-natives"; +import type { Component } from "@oh-my-pi/pi-tui"; import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; import { pluralize } from "@oh-my-pi/pi-utils"; import { settings } from "../config/settings"; import type { Theme } from "../modes/theme/theme"; +import { Hasher } from "../tui/utils"; import { formatDimensionNote, type ResizedImage } from "../utils/image-resize"; export { Ellipsis } from "@oh-my-pi/pi-natives"; @@ -642,6 +644,96 @@ export function formatParseErrors(errors: string[]): string[] { return [header, ...capped.map(err => `- ${err}`)]; } +// ============================================================================= +// Renderer helpers shared by search / find / ast tools +// ============================================================================= + +/** + * Group `rawLines` by blank-line separators, mirroring the historical search / + * ast-grep / ast-edit renderer behavior: if any blank line is present, splits on + * runs of blank lines; otherwise collapses non-empty lines into a single group. + */ +export function splitGroupsByBlankLine(rawLines: string[]): string[][] { + const hasSeparators = rawLines.some(line => line.trim().length === 0); + const groups: string[][] = []; + if (hasSeparators) { + let current: string[] = []; + for (const line of rawLines) { + if (line.trim().length === 0) { + if (current.length > 0) { + groups.push(current); + current = []; + } + continue; + } + current.push(line); + } + if (current.length > 0) groups.push(current); + } else { + const nonEmpty = rawLines.filter(line => line.trim().length > 0); + if (nonEmpty.length > 0) { + groups.push(nonEmpty); + } + } + return groups; +} + +/** + * Standard width+expand keyed render cache used by every search-style tool + * renderer. `compute` re-runs only when the cache key changes; the returned + * Component is the canonical `{ render, invalidate }` pair. + */ +export function createCachedComponent( + getExpanded: () => boolean, + compute: (width: number, expanded: boolean) => string[], +): Component { + let cached: { key: bigint; lines: string[] } | undefined; + return { + render(width: number): string[] { + const expanded = getExpanded(); + const key = new Hasher().bool(expanded).u32(width).digest(); + if (cached?.key === key) return cached.lines; + const lines = compute(width, expanded); + cached = { key, lines }; + return lines; + }, + invalidate() { + cached = undefined; + }, + }; +} + +/** + * Append the indented bullet list of parse errors (capped at + * {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the + * total exceeds the cap. No-op when `parseErrors` is empty. + */ +export function appendParseErrorsBulletList( + lines: string[], + parseErrors: readonly string[] | undefined, + theme: Theme, +): void { + if (!parseErrors || parseErrors.length === 0) return; + const capped = parseErrors.slice(0, PARSE_ERRORS_LIMIT); + for (const err of capped) { + lines.push(theme.fg("warning", ` - ${err}`)); + } + if (parseErrors.length > PARSE_ERRORS_LIMIT) { + lines.push(theme.fg("dim", ` … ${parseErrors.length - PARSE_ERRORS_LIMIT} more`)); + } +} + +/** + * Human-readable summary string for the parse-issues count, capped by + * {@link PARSE_ERRORS_LIMIT}. + */ +export function formatParseErrorsCountLabel(parseErrors: readonly string[]): string { + const total = parseErrors.length; + return total > PARSE_ERRORS_LIMIT + ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues` + : `${total} parse issue${total !== 1 ? "s" : ""}`; +} + // ============================================================================= // LSP Batching // ============================================================================= diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index ac043d38b..dbfd8ce65 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -8,32 +8,25 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { getFileReadCache } from "../edit/file-read-cache"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import searchDescription from "../prompts/tools/search.md" with { type: "text" }; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output"; -import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; import { formatGroupedFiles } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; +import { resolveToolSearchScope } from "./path-utils"; import { - formatPathRelativeToCwd, - hasGlobPathChars, - normalizePathLikeInput, - parseSearchPath, - partitionExistingPaths, - resolveExplicitSearchPaths, - resolveToCwd, -} from "./path-utils"; -import { + createCachedComponent, formatCodeFrameLine, formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS, + splitGroupsByBlankLine, } from "./render-utils"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -141,78 +134,26 @@ export class SearchTool implements AgentTool formatPathRelativeToCwd(targetPath, this.session.cwd); - let searchPath: string; - let scopePath: string; - let exactFilePaths: string[] | undefined; - let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; - let globFilter: string | undefined; - const rawPaths = paths.map(normalizePathLikeInput); - if (rawPaths.some(rawPath => rawPath.length === 0)) { - throw new ToolError("`paths` must contain non-empty paths or globs"); - } - const internalRouter = InternalUrlRouter.instance(); - const resolvedPathInputs: string[] = []; - // Absolute filesystem paths whose source is immutable (e.g. artifact://, - // pi://, skill://). Hashline anchors are suppressed for these on a - // per-file basis, leaving editable mixed-in files untouched. - const immutableSourcePaths = new Set(); - for (const rawPath of rawPaths) { - if (!internalRouter.canHandle(rawPath)) { - resolvedPathInputs.push(rawPath); - continue; - } - if (hasGlobPathChars(rawPath)) { - throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); - } - const resource = await internalRouter.resolve(rawPath); - if (!resource.sourcePath) { - throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`); - } - if (resource.immutable) { - immutableSourcePaths.add(path.resolve(resource.sourcePath)); - } - resolvedPathInputs.push(resource.sourcePath); - } + const scope = await resolveToolSearchScope({ + rawPaths: paths, + cwd: this.session.cwd, + internalUrlAction: "search", + trackImmutableSources: true, + surfaceExactFilePaths: true, + multipathStatHint: " (`paths` entries must each exist relative to cwd)", + }); + const { + searchPath, + scopePath, + isDirectory, + multiTargets, + exactFilePaths, + missingPaths, + immutableSourcePaths, + } = scope; + const { globFilter } = scope; const baseDisplayMode = resolveFileDisplayMode(this.session); const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true }); - // Tolerate missing entries in a multi-path call: skip ones whose base - // directory is gone, and only error if every entry is missing. Single - // missing path keeps the original ENOENT semantics. - let missingPaths: string[] = []; - let effectivePaths = resolvedPathInputs; - if (resolvedPathInputs.length > 1) { - const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath); - if (partition.valid.length === 0) { - throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); - } - effectivePaths = partition.valid; - missingPaths = partition.missing; - } - if (effectivePaths.length === 1) { - const parsedPath = parseSearchPath(effectivePaths[0] ?? "."); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - globFilter = parsedPath.glob; - scopePath = formatScopePath(searchPath); - } else { - const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, this.session.cwd, globFilter); - if (!multiSearchPath) { - throw new ToolError("`paths` must contain at least one path or glob"); - } - searchPath = multiSearchPath.basePath; - exactFilePaths = multiSearchPath.exactFilePaths; - multiTargets = multiSearchPath.targets; - globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob; - scopePath = multiSearchPath.scopePath; - } - let isDirectory: boolean; - try { - const stat = await Bun.file(searchPath).stat(); - isDirectory = stat.isDirectory(); - } catch { - const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : ""; - throw new ToolError(`Path not found: ${scopePath}${hint}`); - } const effectiveOutputMode = GrepOutputMode.Content; // Multi-scope = more than one file may match. We fetch up to @@ -531,16 +472,13 @@ export const searchToolRenderer = { { icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] }, uiTheme, ); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const listLines = renderTreeList( { items: lines, - expanded, + expanded: options.expanded, maxCollapsed: COLLAPSED_TEXT_LIMIT, maxCollapsedLines: COLLAPSED_TEXT_LIMIT, itemType: "item", @@ -548,14 +486,9 @@ export const searchToolRenderer = { }, uiTheme, ); - const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); } const matchCount = details?.matchCount ?? 0; @@ -591,28 +524,7 @@ export const searchToolRenderer = { ); const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; - const rawLines = textContent.split("\n"); - const hasSeparators = rawLines.some(line => line.trim().length === 0); - const matchGroups: string[][] = []; - if (hasSeparators) { - let current: string[] = []; - for (const line of rawLines) { - if (line.trim().length === 0) { - if (current.length > 0) { - matchGroups.push(current); - current = []; - } - continue; - } - current.push(line); - } - if (current.length > 0) matchGroups.push(current); - } else { - const nonEmpty = rawLines.filter(line => line.trim().length > 0); - if (nonEmpty.length > 0) { - matchGroups.push(nonEmpty); - } - } + const matchGroups = splitGroupsByBlankLine(textContent.split("\n")); const renderedFileLimit = details?.fileLimitReached; const renderedPerFileLimit = details?.perFileLimitReached; @@ -629,17 +541,14 @@ export const searchToolRenderer = { } if (missingNote) extraLines.push(missingNote); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0); const matchLines = renderTreeList( { items: matchGroups, - expanded, + expanded: options.expanded, maxCollapsed: matchGroups.length, maxCollapsedLines: collapsedMatchLineBudget, itemType: "match", @@ -652,14 +561,9 @@ export const searchToolRenderer = { }, uiTheme, ); - const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index 55d73bc32..797d8dd04 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -10,6 +10,7 @@ import path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { glob } from "@oh-my-pi/pi-natives"; +import { fuzzyMatch } from "@oh-my-pi/pi-tui"; import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils"; import { formatHashLines } from "../hashline/hash"; import type { FileMentionMessage } from "../session/messages"; @@ -20,7 +21,6 @@ import { truncateHeadBytes, } from "../session/streaming-output"; import { resolveReadPath } from "../tools/path-utils"; -import { fuzzyMatch } from "./fuzzy"; import { formatDimensionNote, resizeImage } from "./image-resize"; /** Regex to match @filepath patterns in text */ diff --git a/packages/coding-agent/src/utils/fuzzy.ts b/packages/coding-agent/src/utils/fuzzy.ts deleted file mode 100644 index 4903b4790..000000000 --- a/packages/coding-agent/src/utils/fuzzy.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive). -// Lower score = better match. - -export interface FuzzyMatch { - matches: boolean; - score: number; -} - -export function fuzzyMatch(query: string, text: string): FuzzyMatch { - const queryLower = query.toLowerCase(); - const textLower = text.toLowerCase(); - - if (queryLower.length === 0) { - return { matches: true, score: 0 }; - } - - if (queryLower.length > textLower.length) { - return { matches: false, score: 0 }; - } - - let queryIndex = 0; - let score = 0; - let lastMatchIndex = -1; - let consecutiveMatches = 0; - - for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { - if (textLower[i] === queryLower[queryIndex]) { - const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!); - - // Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o") - if (lastMatchIndex === i - 1) { - consecutiveMatches++; - score -= consecutiveMatches * 5; - } else { - consecutiveMatches = 0; - // Penalize gaps between matched characters - if (lastMatchIndex >= 0) { - score += (i - lastMatchIndex - 1) * 2; - } - } - - // Reward matches at word boundaries (start of words are more likely intentional targets) - if (isWordBoundary) { - score -= 10; - } - - // Slight penalty for matches later in the string (prefer earlier matches) - score += i * 0.1; - - lastMatchIndex = i; - queryIndex++; - } - } - - // Not all query characters were found in order - if (queryIndex < queryLower.length) { - return { matches: false, score: 0 }; - } - - return { matches: true, score }; -} - -// Filter and sort items by fuzzy match quality (best matches first) -// Supports space-separated tokens: all tokens must match, sorted by match count then score -export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] { - if (!query.trim()) { - return items; - } - - // Split query into tokens - const tokens = query - .trim() - .split(/\s+/) - .filter(t => t.length > 0); - - if (tokens.length === 0) { - return items; - } - - const results: { item: T; totalScore: number }[] = []; - - for (const item of items) { - const text = getText(item); - let totalScore = 0; - let allMatch = true; - - // Check each token against the text - ALL must match - for (const token of tokens) { - const match = fuzzyMatch(token, text); - if (match.matches) { - totalScore += match.score; - } else { - allMatch = false; - break; - } - } - - // Only include if all tokens match - if (allMatch) { - results.push({ item, totalScore }); - } - } - - // Sort by score (asc, lower is better) - results.sort((a, b) => a.totalScore - b.totalScore); - - return results.map(r => r.item); -} diff --git a/packages/coding-agent/src/vim/parser.ts b/packages/coding-agent/src/vim/parser.ts index e4559c993..ae1e3794f 100644 --- a/packages/coding-agent/src/vim/parser.ts +++ b/packages/coding-agent/src/vim/parser.ts @@ -124,10 +124,6 @@ export function parseKeySequences(sequences: string[]): VimKeyToken[] { return tokens; } -export function tokensToReplay(tokens: readonly VimKeyToken[]): string[] { - return tokens.map(token => token.value); -} - export function replayTokens(values: readonly string[]): VimKeyToken[] { return values.map((value, index) => ({ value, @@ -136,16 +132,3 @@ export function replayTokens(values: readonly string[]): VimKeyToken[] { offset: index, })); } - -export function formatVimError(error: unknown): string { - if (!(error instanceof VimError)) { - return error instanceof Error ? error.message : String(error); - } - - const base = error.message; - if (!error.location) { - return base; - } - - return `${base} (sequence ${error.location.sequenceIndex + 1}, token ${error.location.offset + 1})`; -} diff --git a/packages/coding-agent/src/vim/render.ts b/packages/coding-agent/src/vim/render.ts index 6851eaf42..7ea9dae81 100644 --- a/packages/coding-agent/src/vim/render.ts +++ b/packages/coding-agent/src/vim/render.ts @@ -13,7 +13,7 @@ import type { export const VIM_OPEN_VIEWPORT_LINES = 80; export const VIM_DEFAULT_VIEWPORT_LINES = 10; -export const VIM_TAB_DISPLAY = "→"; +const VIM_TAB_DISPLAY = "→"; const VIM_INLINE_CURSOR = "▏"; const VIM_VIEWPORT_WIDTH = 140; diff --git a/packages/coding-agent/src/vim/types.ts b/packages/coding-agent/src/vim/types.ts index 0603ff525..5ca3d8940 100644 --- a/packages/coding-agent/src/vim/types.ts +++ b/packages/coding-agent/src/vim/types.ts @@ -164,7 +164,7 @@ export function clonePosition(position: Position): Position { return { line: position.line, col: position.col }; } -export function comparePositions(left: Position, right: Position): number { +function comparePositions(left: Position, right: Position): number { if (left.line !== right.line) { return left.line - right.line; } diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index ee169311a..b187484a3 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -12,7 +12,7 @@ import { } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../src/config/settings"; +import { resetSettingsForTest, Settings } from "../src/config/settings"; import { AcpAgent } from "../src/modes/acp/acp-agent"; import type { PlanModeState } from "../src/plan-mode/state"; import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; @@ -316,7 +316,7 @@ afterEach(async () => { setAgentDir(fallbackAgentDir); delete process.env.PI_CODING_AGENT_DIR; } - _resetSettingsForTest(); + resetSettingsForTest(); for (const root of cleanupRoots.splice(0)) { await fs.promises.rm(root, { recursive: true, force: true }); diff --git a/packages/coding-agent/test/agent-session-bash-detach.test.ts b/packages/coding-agent/test/agent-session-bash-detach.test.ts index a568f1a5c..014d43506 100644 --- a/packages/coding-agent/test/agent-session-bash-detach.test.ts +++ b/packages/coding-agent/test/agent-session-bash-detach.test.ts @@ -45,7 +45,7 @@ import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-cor import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, 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 { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; @@ -165,7 +165,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e) tempDir = path.join(os.tmpdir(), `pi-bash-detach-test-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); - _resetSettingsForTest(); + resetSettingsForTest(); // Fresh isolated Settings rooted in tempDir so we don't pick up the // developer's real config (snapshots, shell prefix, etc). await Settings.init({ inMemory: true, cwd: tempDir }); @@ -243,7 +243,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e) }); afterAll(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => { diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 025cc8ecf..ab29a1187 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -36,15 +36,6 @@ function createAssistantMessage( }; } -async function _waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await Bun.sleep(10); - } - throw new Error("Timed out waiting for condition"); -} - type AutoRetryStartEvent = Extract; type AutoRetryEndEvent = Extract; diff --git a/packages/coding-agent/test/autocomplete-max-visible.test.ts b/packages/coding-agent/test/autocomplete-max-visible.test.ts index 56fc688a5..b2a24ad30 100644 --- a/packages/coding-agent/test/autocomplete-max-visible.test.ts +++ b/packages/coding-agent/test/autocomplete-max-visible.test.ts @@ -2,7 +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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; @@ -13,7 +13,7 @@ describe("autocompleteMaxVisible setting", () => { let projectDir: string; beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next()); agentDir = path.join(testDir, "agent"); projectDir = path.join(testDir, "project"); @@ -22,7 +22,7 @@ describe("autocompleteMaxVisible setting", () => { }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }); } @@ -34,7 +34,7 @@ describe("autocompleteMaxVisible setting", () => { await settings.flush(); // Re-init to verify persistence - _resetSettingsForTest(); + resetSettingsForTest(); const settings2 = await Settings.init({ cwd: projectDir, agentDir }); expect(settings2.get("autocompleteMaxVisible")).toBe(10); }); diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index 02d6590ec..730331bd2 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -2,7 +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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; @@ -20,12 +20,12 @@ describe("executeBash", () => { beforeEach(async () => { tempDir = makeTempDir(); - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: tempDir }); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); vi.restoreAllMocks(); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); diff --git a/packages/coding-agent/test/config-cli.test.ts b/packages/coding-agent/test/config-cli.test.ts index ae67a50a2..62cba399a 100644 --- a/packages/coding-agent/test/config-cli.test.ts +++ b/packages/coding-agent/test/config-cli.test.ts @@ -4,21 +4,21 @@ import * as os from "node:os"; import * as path from "node:path"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import { runConfigCommand } from "../src/cli/config-cli"; -import { _resetSettingsForTest } from "../src/config/settings"; +import { resetSettingsForTest } from "../src/config/settings"; let testAgentDir = ""; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-config-cli-")); setAgentDir(testAgentDir); }); afterEach(async () => { vi.restoreAllMocks(); - _resetSettingsForTest(); + resetSettingsForTest(); if (originalAgentDir) { setAgentDir(originalAgentDir); } else { diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts index bab13e82e..ebb01c29d 100644 --- a/packages/coding-agent/test/config-spacing.test.ts +++ b/packages/coding-agent/test/config-spacing.test.ts @@ -2,21 +2,21 @@ 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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; describe("indentation resolver", () => { let tempDir = ""; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); setDefaultTabWidth(3); tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next()); await fs.mkdir(tempDir, { recursive: true }); }); afterEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); setDefaultTabWidth(3); await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 3b37b900c..a0a38033c 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -2,7 +2,7 @@ import { beforeAll, 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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { applyHashlineEdits, buildCompactHashlineDiffPreview, @@ -27,7 +27,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { Value } from "@sinclair/typebox/value"; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: process.cwd() }); }); diff --git a/packages/coding-agent/test/discovery/disabled-extensions.test.ts b/packages/coding-agent/test/discovery/disabled-extensions.test.ts index 612efec55..22581f339 100644 --- a/packages/coding-agent/test/discovery/disabled-extensions.test.ts +++ b/packages/coding-agent/test/discovery/disabled-extensions.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 ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; describe("disabledExtensions runtime filtering", () => { @@ -12,7 +12,7 @@ describe("disabledExtensions runtime filtering", () => { let originalHome: string | undefined; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); originalHome = process.env.HOME; tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-disabled-ext-home-")); process.env.HOME = tempHomeDir; @@ -32,7 +32,7 @@ describe("disabledExtensions runtime filtering", () => { }); afterEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); vi.restoreAllMocks(); if (originalHome === undefined) { delete process.env.HOME; diff --git a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts index 90a17123a..c372953b8 100644 --- a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts +++ b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts @@ -19,7 +19,7 @@ import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EditTool } from "@oh-my-pi/pi-coding-agent/edit"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; @@ -251,7 +251,7 @@ it("multi-entry edit on an auto-generated file surfaces isError + error text ins // The auto-generated guard reads from the *global* settings singleton, so we // must initialize it (the per-tool `Settings.isolated(...)` we pass into the // EditTool isn't what the guard sees). - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } }); try { diff --git a/packages/coding-agent/test/edit-per-file-diff-content.test.ts b/packages/coding-agent/test/edit-per-file-diff-content.test.ts index f08f76e51..738f7bd38 100644 --- a/packages/coding-agent/test/edit-per-file-diff-content.test.ts +++ b/packages/coding-agent/test/edit-per-file-diff-content.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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { DEFAULT_FUZZY_THRESHOLD, EditTool, @@ -40,13 +40,13 @@ const noopBeginDeferred = (_p: string) => ({ let tempDir: string; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-")); await Settings.init({ inMemory: true, cwd: tempDir }); }); afterEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 629e4a895..054203631 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -8,9 +8,9 @@ import * as path from "node:path"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { - __test_setExtensionHandlerTimeoutMs, EXTENSION_HANDLER_TIMEOUT_MS, ExtensionRunner, + testSetExtensionHandlerTimeoutMs, } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -34,7 +34,7 @@ describe("ExtensionRunner", () => { }); afterEach(() => { - __test_setExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS); + testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS); authStorage.close(); tempDir.removeSync(); }); @@ -644,7 +644,7 @@ describe("ExtensionRunner", () => { runner.onError(err => { errors.push(err); }); - __test_setExtensionHandlerTimeoutMs(50); + testSetExtensionHandlerTimeoutMs(50); const startedAt = performance.now(); await runner.emit({ type: "session_start" }); diff --git a/packages/coding-agent/test/fuzzy.test.ts b/packages/coding-agent/test/fuzzy.test.ts index 23c211ee5..d9b876f42 100644 --- a/packages/coding-agent/test/fuzzy.test.ts +++ b/packages/coding-agent/test/fuzzy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-coding-agent/utils/fuzzy"; +import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-tui"; describe("fuzzyMatch", () => { test("empty query matches everything with score 0", () => { diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index a9174b62b..550306447 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { hindsightBackend, reloadMentalModelsForSession } from "@oh-my-pi/pi-coding-agent/hindsight/backend"; import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client"; import type { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state"; @@ -76,7 +76,7 @@ function makeFakeSession(deps: FakeSessionDeps) { describe("hindsightBackend.start", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { @@ -235,7 +235,7 @@ describe("hindsightBackend.start", () => { describe("hindsightBackend.preCompactionContext", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { @@ -297,7 +297,7 @@ describe("hindsightBackend.preCompactionContext", () => { describe("hindsightBackend first-turn injection", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { @@ -480,7 +480,7 @@ describe("hindsightBackend first-turn injection", () => { describe("hindsightBackend.clear", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { diff --git a/packages/coding-agent/test/hindsight-tools.test.ts b/packages/coding-agent/test/hindsight-tools.test.ts index 2f90f3092..9aa035445 100644 --- a/packages/coding-agent/test/hindsight-tools.test.ts +++ b/packages/coding-agent/test/hindsight-tools.test.ts @@ -8,7 +8,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client"; import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config"; import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state"; @@ -94,7 +94,7 @@ function registerState(client: HindsightApi, settings?: Settings, opts: Register describe("Hindsight tool factories", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); @@ -122,7 +122,7 @@ describe("Hindsight tool factories", () => { describe("retain.execute", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); @@ -210,7 +210,7 @@ describe("retain.execute", () => { describe("recall.execute", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); @@ -278,7 +278,7 @@ describe("recall.execute", () => { describe("reflect.execute", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); diff --git a/packages/coding-agent/test/interactive-mode-editor-component.test.ts b/packages/coding-agent/test/interactive-mode-editor-component.test.ts index fcbf84ca4..7b4497dff 100644 --- a/packages/coding-agent/test/interactive-mode-editor-component.test.ts +++ b/packages/coding-agent/test/interactive-mode-editor-component.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; @@ -24,7 +24,7 @@ describe("InteractiveMode.setEditorComponent", () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-editor-component-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -56,7 +56,7 @@ describe("InteractiveMode.setEditorComponent", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("replaces the editor and rebinds interactive handlers", () => { 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 f98b3d510..4ebd428ec 100644 --- a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; @@ -37,7 +37,7 @@ describe("InteractiveMode LSP startup welcome banner", () => { vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); } - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-interactive-mode-lsp-startup-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -77,7 +77,7 @@ describe("InteractiveMode LSP startup welcome banner", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("updates the welcome banner when startup warmup completes", async () => { diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index 8dcba0268..b584658b5 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { Text } from "@oh-my-pi/pi-tui"; @@ -35,7 +35,7 @@ describe("InteractiveMode plan review rendering", () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-plan-review-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -67,7 +67,7 @@ describe("InteractiveMode plan review rendering", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("appends each submitted plan review preview to preserve scrollback", async () => { diff --git a/packages/coding-agent/test/issue-816-repro.test.ts b/packages/coding-agent/test/issue-816-repro.test.ts index 260f29ee0..5e737b603 100644 --- a/packages/coding-agent/test/issue-816-repro.test.ts +++ b/packages/coding-agent/test/issue-816-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; @@ -22,7 +22,7 @@ describe("issue #816 — plan mode pendingModelSwitch leak", () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-issue-816-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -52,7 +52,7 @@ describe("issue #816 — plan mode pendingModelSwitch leak", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("does not switch to the plan-role model after exit when the entry switch was deferred by streaming", async () => { diff --git a/packages/coding-agent/test/issue-845-repro.test.ts b/packages/coding-agent/test/issue-845-repro.test.ts index cc30bba25..cfdc2f1ed 100644 --- a/packages/coding-agent/test/issue-845-repro.test.ts +++ b/packages/coding-agent/test/issue-845-repro.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, 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 { _resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { resolveUpdateMethodForTest } from "../src/cli/update-cli"; // Issue #845: on Windows with Bun installed via Scoop, ~/.bun is a junction // to scoop\persist\Oven-sh.Bun\.bun. `bun pm bin -g` and the omp path that @@ -40,13 +40,13 @@ describe("issue-845: resolveUpdateMethod follows symlinks/junctions", () => { it("classifies omp reached through a symlinked bin dir as bun-managed", () => { // $which resolves through the symlink, `bun pm bin -g` returns the real path // (or vice versa). Either direction must be recognized. - const method = _resolveUpdateMethodForTest(ompPathViaLink, realBinDir); + const method = resolveUpdateMethodForTest(ompPathViaLink, realBinDir); expect(method).toBe("bun"); }); it("classifies omp at the real bin dir as bun-managed when bunBinDir is symlinked", () => { const ompAtReal = path.join(realBinDir, "omp"); - const method = _resolveUpdateMethodForTest(ompAtReal, linkedBinDir); + const method = resolveUpdateMethodForTest(ompAtReal, linkedBinDir); expect(method).toBe("bun"); }); }); diff --git a/packages/coding-agent/test/issue-927-repro.test.ts b/packages/coding-agent/test/issue-927-repro.test.ts index 6edd33060..5e1c9c5c5 100644 --- a/packages/coding-agent/test/issue-927-repro.test.ts +++ b/packages/coding-agent/test/issue-927-repro.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun: import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -30,7 +30,7 @@ describe("issue #927 optimistic pending spinner", () => { vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); } - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-issue-927-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -56,7 +56,7 @@ describe("issue #927 optimistic pending spinner", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("clears the optimistic loading animation when prompt returns without a model turn", async () => { diff --git a/packages/coding-agent/test/memory-backend-resolve.test.ts b/packages/coding-agent/test/memory-backend-resolve.test.ts index 581263bb3..075f05e8f 100644 --- a/packages/coding-agent/test/memory-backend-resolve.test.ts +++ b/packages/coding-agent/test/memory-backend-resolve.test.ts @@ -1,14 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend"; describe("resolveMemoryBackend", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); it("returns the hindsight backend when memory.backend is hindsight, regardless of legacy memories.enabled", () => { diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 18961636c..31294e2e9 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Effort, type Model, type OpenAICompat, type ThinkingConfig, writeModelCache } from "@oh-my-pi/pi-ai"; import { kNoAuth, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { hookFetch, Snowflake } from "@oh-my-pi/pi-utils"; @@ -15,7 +15,7 @@ describe("ModelRegistry", () => { let authStorage: AuthStorage; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = path.join(os.tmpdir(), `pi-test-model-registry-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); modelsJsonPath = path.join(tempDir, "models.json"); @@ -24,7 +24,7 @@ describe("ModelRegistry", () => { }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); authStorage.close(); if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); diff --git a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts index 9a0f690f6..a099e69a0 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { clearMermaidCache } from "@oh-my-pi/pi-coding-agent/modes/theme/mermaid-cache"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -42,14 +42,14 @@ beforeAll(async () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); clearMermaidCache(); setTerminalImageProtocol(null); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); setTerminalImageProtocol(originalImageProtocol); clearMermaidCache(); }); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts index 47e189ccf..57d99a0e3 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; @@ -26,7 +26,7 @@ function createAssistantMessage(): AssistantMessage { describe("EventController idle compaction teardown", () => { beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, overrides: { @@ -41,7 +41,7 @@ describe("EventController idle compaction teardown", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("cancels scheduled idle compaction when disposed", async () => { diff --git a/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts b/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts index 760c42f52..94ef606f1 100644 --- a/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts +++ b/packages/coding-agent/test/repro-issue-1022-disabled-default-model.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 { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -23,7 +23,7 @@ describe("issue #1022 — path-scoped enabledModels respected by default fallbac let cwd: string; beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); testDir = path.join(os.tmpdir(), `pi-issue-1022-${Snowflake.next()}`); agentDir = path.join(testDir, "agent"); cwd = path.join(testDir, "private", "sub"); @@ -32,7 +32,7 @@ describe("issue #1022 — path-scoped enabledModels respected by default fallbac }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index ddd42f052..6ecfc9d3e 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -30,7 +30,7 @@ describe("SessionManager append and tree traversal", () => { const msgId = session.appendMessage(userMsg("hello")); const thinkingId = session.appendThinkingLevelChange("high"); - const _msg2Id = session.appendMessage(assistantMsg("response")); + session.appendMessage(assistantMsg("response")); const entries = session.getEntries(); expect(entries).toHaveLength(3); @@ -48,7 +48,7 @@ describe("SessionManager append and tree traversal", () => { const msgId = session.appendMessage(userMsg("hello")); const modelId = session.appendModelChange("openai/gpt-4"); - const _msg2Id = session.appendMessage(assistantMsg("response")); + session.appendMessage(assistantMsg("response")); const entries = session.getEntries(); const modelEntry = entries.find(e => e.type === "model_change"); @@ -68,7 +68,7 @@ describe("SessionManager append and tree traversal", () => { const id1 = session.appendMessage(userMsg("1")); const id2 = session.appendMessage(assistantMsg("2")); const compactionId = session.appendCompaction("summary", undefined, id1, 1000); - const _id3 = session.appendMessage(userMsg("3")); + session.appendMessage(userMsg("3")); const entries = session.getEntries(); const compactionEntry = entries.find(e => e.type === "compaction"); @@ -89,7 +89,7 @@ describe("SessionManager append and tree traversal", () => { const msgId = session.appendMessage(userMsg("hello")); const customId = session.appendCustomEntry("my_hook", { key: "value" }); - const _msg2Id = session.appendMessage(assistantMsg("response")); + session.appendMessage(assistantMsg("response")); const entries = session.getEntries(); const customEntry = entries.find(e => e.type === "custom") as CustomEntry; @@ -151,8 +151,8 @@ describe("SessionManager append and tree traversal", () => { const id1 = session.appendMessage(userMsg("1")); const id2 = session.appendMessage(assistantMsg("2")); - const _id3 = session.appendMessage(userMsg("3")); - const _id4 = session.appendMessage(assistantMsg("4")); + session.appendMessage(userMsg("3")); + session.appendMessage(assistantMsg("4")); const path = session.getBranch(id2); expect(path).toHaveLength(2); @@ -215,7 +215,7 @@ describe("SessionManager append and tree traversal", () => { it("handles multiple branches at same point", () => { const session = SessionManager.inMemory(); - const _id1 = session.appendMessage(userMsg("root")); + session.appendMessage(userMsg("root")); const id2 = session.appendMessage(assistantMsg("response")); // Branch A @@ -243,19 +243,19 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); // Main path: 1 -> 2 -> 3 -> 4 - const _id1 = session.appendMessage(userMsg("1")); + session.appendMessage(userMsg("1")); const id2 = session.appendMessage(assistantMsg("2")); const id3 = session.appendMessage(userMsg("3")); - const _id4 = session.appendMessage(assistantMsg("4")); + session.appendMessage(assistantMsg("4")); // Branch from 2: 2 -> 5 -> 6 session.branch(id2); const id5 = session.appendMessage(userMsg("5")); - const _id6 = session.appendMessage(assistantMsg("6")); + session.appendMessage(assistantMsg("6")); // Branch from 5: 5 -> 7 session.branch(id5); - const _id7 = session.appendMessage(userMsg("7")); + session.appendMessage(userMsg("7")); const tree = session.getTree(); @@ -276,7 +276,7 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); - const _id2 = session.appendMessage(assistantMsg("2")); + session.appendMessage(assistantMsg("2")); const id3 = session.appendMessage(userMsg("3")); expect(session.getLeafId()).toBe(id3); @@ -296,7 +296,7 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); - const _id2 = session.appendMessage(assistantMsg("2")); + session.appendMessage(assistantMsg("2")); session.branch(id1); const id3 = session.appendMessage(userMsg("branched")); @@ -312,8 +312,8 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); - const _id2 = session.appendMessage(assistantMsg("2")); - const _id3 = session.appendMessage(userMsg("3")); + session.appendMessage(assistantMsg("2")); + session.appendMessage(userMsg("3")); const summaryId = session.branchWithSummary(id1, "Summary of abandoned work"); @@ -423,7 +423,7 @@ describe("createBranchedSession", () => { // Branch from 3: 3 -> 5 session.branch(id3); - const _id5 = session.appendMessage(userMsg("5")); + session.appendMessage(userMsg("5")); // Create branched session from id2 (should only have 1 -> 2) const result = session.createBranchedSession(id2); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 82d3ab110..d70c70af5 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.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 { Effort } from "@oh-my-pi/pi-ai"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; @@ -14,7 +14,7 @@ describe("Settings", () => { beforeEach(() => { // Reset global singleton so each test gets a fresh instance - _resetSettingsForTest(); + resetSettingsForTest(); // Use snowflake to isolate parallel test runs (SQLite files can't be shared) testDir = path.join(os.tmpdir(), "test-settings-tmp", Snowflake.next()); @@ -103,7 +103,7 @@ describe("Settings", () => { expect(workSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "anthropic/claude-opus-4-5"]); expect(workSettings.get("disabledProviders")).toEqual(["ollama", "openai"]); - _resetSettingsForTest(); + resetSettingsForTest(); const privateSettings = await Settings.init({ cwd: privateDir, agentDir }); expect(privateSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "openai/gpt-5.2-codex"]); expect(privateSettings.get("disabledProviders")).toEqual(["ollama", "anthropic"]); diff --git a/packages/coding-agent/test/status-line-overflow.test.ts b/packages/coding-agent/test/status-line-overflow.test.ts index 33ee912ad..58ad0bb38 100644 --- a/packages/coding-agent/test/status-line-overflow.test.ts +++ b/packages/coding-agent/test/status-line-overflow.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { visibleWidth } from "@oh-my-pi/pi-tui"; import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../src/config/settings"; +import { resetSettingsForTest, Settings } from "../src/config/settings"; import type { StatusLineSegmentId } from "../src/config/settings-schema"; import { StatusLineComponent } from "../src/modes/components/status-line"; import type { SegmentContext } from "../src/modes/components/status-line/segments"; @@ -15,13 +15,13 @@ import { getSessionAccentAnsi, getSessionAccentHex } from "../src/utils/session- const originalProjectDir = getProjectDir(); beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); await initTheme(); }); afterAll(() => { - _resetSettingsForTest(); + resetSettingsForTest(); setProjectDir(originalProjectDir); }); diff --git a/packages/coding-agent/test/tools/auto-generated-guard.test.ts b/packages/coding-agent/test/tools/auto-generated-guard.test.ts index 90dbc393a..50a053878 100644 --- a/packages/coding-agent/test/tools/auto-generated-guard.test.ts +++ b/packages/coding-agent/test/tools/auto-generated-guard.test.ts @@ -2,14 +2,14 @@ import { beforeAll, 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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { assertEditableFile, assertEditableFileContent } from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard"; import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; let tempDir: string; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auto-gen-guard-")); await Settings.init({ inMemory: true, cwd: tempDir }); }); diff --git a/packages/coding-agent/test/tools/conflict-integration.test.ts b/packages/coding-agent/test/tools/conflict-integration.test.ts index f0b3112fa..15995831a 100644 --- a/packages/coding-agent/test/tools/conflict-integration.test.ts +++ b/packages/coding-agent/test/tools/conflict-integration.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, 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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { ConflictHistory } from "@oh-my-pi/pi-coding-agent/tools/conflict-detect"; @@ -69,7 +69,7 @@ describe("read surfaces conflicts as a warning footer", () => { let tempDir: string; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); }); @@ -295,7 +295,7 @@ describe("write resolves conflicts via conflict://N", () => { let tempDir: string; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); }); diff --git a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts index ee5bfd97e..0b3dfea14 100644 --- a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts +++ b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts @@ -435,7 +435,7 @@ describe("read tool URL handling", () => { const pageUrl = "https://bun.com/reference/bun/UnixSocketOptions"; const pageHtml = "

UnixSocketOptions

Page-specific docs.

"; const renderedMarkdown = `# UnixSocketOptions\n\n${"Page-specific API docs. ".repeat(8)}`; - using _missingSystemPython = withMissingSystemPython(); + using missingSystemPython = withMissingSystemPython(); const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockImplementation(async (requestedUrl: string) => { if (requestedUrl === pageUrl) { return { @@ -475,7 +475,7 @@ describe("read tool URL handling", () => { content: "", }; }); - using _hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); + using hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); vi.spyOn(toolsManager, "ensureTool").mockResolvedValue(undefined); vi.spyOn(natives, "htmlToMarkdown").mockResolvedValue(renderedMarkdown); @@ -489,6 +489,8 @@ describe("read tool URL handling", () => { expect(requestedUrls).not.toContain("https://bun.com/.well-known/llms.txt"); expect(requestedUrls).not.toContain("https://bun.com/llms.txt"); expect(requestedUrls).not.toContain("https://bun.com/llms.md"); + void missingSystemPython; + void hook; }); it("uses section-scoped llms.txt fallback without requesting the site-wide file", async () => { @@ -497,8 +499,8 @@ describe("read tool URL handling", () => { const pageUrl = "https://example.com/docs/reference/widget"; const pageHtml = "

Widget

"; const lowQualityRender = `${"Please enable JavaScript to view this page.\n".repeat(6)}${"navigation\n".repeat(4)}`; - using _missingSystemPython = withMissingSystemPython(); - const _execSpy = vi.spyOn(ptree, "exec").mockResolvedValue({ ok: true, stdout: lowQualityRender } as never); + using missingSystemPython = withMissingSystemPython(); + vi.spyOn(ptree, "exec").mockResolvedValue({ ok: true, stdout: lowQualityRender } as never); const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockImplementation(async (requestedUrl: string) => { if (requestedUrl === pageUrl) { return { @@ -555,7 +557,7 @@ describe("read tool URL handling", () => { content: "", }; }); - using _hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); + using hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); vi.spyOn(toolsManager, "ensureTool").mockResolvedValue("/usr/bin/trafilatura"); const result = await tool.execute("fetch-section-llms", { path: pageUrl }); @@ -570,6 +572,8 @@ describe("read tool URL handling", () => { expect(requestedUrls).not.toContain("https://example.com/.well-known/llms.txt"); expect(requestedUrls).not.toContain("https://example.com/llms.txt"); expect(requestedUrls).not.toContain("https://example.com/llms.md"); + void missingSystemPython; + void hook; }); it("prefers Parallel extract before other HTML renderers when configured", async () => { process.env.PARALLEL_API_KEY = "test-parallel-key"; @@ -608,7 +612,7 @@ describe("read tool URL handling", () => { content: "", }; }); - using _hook = hookFetch(input => { + using parallelExtractHook = hookFetch(input => { const requestedUrl = String(input); if (requestedUrl === "https://api.parallel.ai/v1beta/extract") { return new Response( @@ -645,6 +649,7 @@ describe("read tool URL handling", () => { expect(textBlock?.text).toContain("Parallel-rendered content"); expect(ensureToolSpy).not.toHaveBeenCalled(); expect(htmlToMarkdownSpy).not.toHaveBeenCalled(); + void parallelExtractHook; }); it("reuses cached output for repeated plain URL reads", async () => { diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index f6bd524e5..a311e05f3 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -2,9 +2,7 @@ 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 type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { buildSearchDateQualifier, @@ -40,22 +38,6 @@ function createSession( }; } -function _createToolContext(settings: Settings): AgentToolContext { - return { - sessionManager: SessionManager.inMemory(), - settings, - modelRegistry: { - find: () => undefined, - getAll: () => [], - getApiKey: async () => undefined, - } as unknown as AgentToolContext["modelRegistry"], - model: undefined, - isIdle: () => true, - hasQueuedMessages: () => false, - abort: () => {}, - } as AgentToolContext; -} - function runGit(cwd: string, args: string[]): string { const result = Bun.spawnSync(["git", ...args], { cwd, diff --git a/packages/coding-agent/test/tools/image-gen.test.ts b/packages/coding-agent/test/tools/image-gen.test.ts index 160c44cfc..853386c43 100644 --- a/packages/coding-agent/test/tools/image-gen.test.ts +++ b/packages/coding-agent/test/tools/image-gen.test.ts @@ -21,30 +21,6 @@ afterEach(async () => { setPreferredImageProvider("auto"); }); -function _getHeaderValue(headers: RequestInit["headers"] | undefined, name: string): string | undefined { - if (!headers) return undefined; - if (headers instanceof Headers) { - return headers.get(name) ?? headers.get(name.toLowerCase()) ?? headers.get(name.toUpperCase()) ?? undefined; - } - if (Array.isArray(headers)) { - for (const [key, value] of headers) { - if (key.toLowerCase() === name.toLowerCase()) { - return value; - } - } - return undefined; - } - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === name.toLowerCase()) { - if (typeof value === "string") { - return value; - } - return value.join(","); - } - } - return undefined; -} - describe("imageGenTool", () => { it("e2e writes OpenAI Responses image_generation WebP output to a temp file", async () => { let requestUrl: string | undefined; diff --git a/packages/coding-agent/test/tools/vim.test.ts b/packages/coding-agent/test/tools/vim.test.ts index e075649a7..064cf01d6 100644 --- a/packages/coding-agent/test/tools/vim.test.ts +++ b/packages/coding-agent/test/tools/vim.test.ts @@ -2,7 +2,7 @@ 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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; @@ -257,7 +257,7 @@ describe("vim tool", () => { let tmpDir: string; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-tool-")); await Settings.init({ inMemory: true, cwd: tmpDir }); }); diff --git a/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts b/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts index 64f054f6e..42de8a4c8 100644 --- a/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts +++ b/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts @@ -1,18 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../../../src/config/settings"; +import { resetSettingsForTest, Settings } from "../../../src/config/settings"; import * as toolsManager from "../../../src/utils/tools-manager"; import { handleYouTube } from "../../../src/web/scrapers/youtube"; describe("handleYouTube with Parallel extract", () => { beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); process.env.PARALLEL_API_KEY = "test-parallel-key"; await Settings.init({ inMemory: true, overrides: { "providers.parallelFetch": true } }); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); vi.restoreAllMocks(); delete process.env.PARALLEL_API_KEY; }); diff --git a/packages/coding-agent/test/tools/web-search-searxng.test.ts b/packages/coding-agent/test/tools/web-search-searxng.test.ts index 3ac128118..db7289eb2 100644 --- a/packages/coding-agent/test/tools/web-search-searxng.test.ts +++ b/packages/coding-agent/test/tools/web-search-searxng.test.ts @@ -3,13 +3,13 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../../src/config/settings"; +import { resetSettingsForTest, Settings } from "../../src/config/settings"; import { searchSearXNG } from "../../src/web/search/providers/searxng"; describe("SearXNG web search provider", () => { afterEach(() => { vi.restoreAllMocks(); - _resetSettingsForTest(); + resetSettingsForTest(); delete process.env.SEARXNG_ENDPOINT; delete process.env.SEARXNG_TOKEN; delete process.env.SEARXNG_BASIC_USERNAME; diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index d69780b04..c99837827 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from "bun:test"; -import { _resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { resolveUpdateMethodForTest } from "../src/cli/update-cli"; describe("update-cli install target detection", () => { it("uses bun update when prioritized omp is inside bun global bin", () => { - const method = _resolveUpdateMethodForTest("/Users/test/.bun/bin/omp", "/Users/test/.bun/bin"); + const method = resolveUpdateMethodForTest("/Users/test/.bun/bin/omp", "/Users/test/.bun/bin"); expect(method).toBe("bun"); }); it("uses binary update when prioritized omp is outside bun global bin", () => { - const method = _resolveUpdateMethodForTest("/Users/test/.local/bin/omp", "/Users/test/.bun/bin"); + const method = resolveUpdateMethodForTest("/Users/test/.local/bin/omp", "/Users/test/.bun/bin"); expect(method).toBe("binary"); }); it("uses binary update when bun global bin cannot be resolved", () => { - const method = _resolveUpdateMethodForTest("/Users/test/.local/bin/omp", undefined); + const method = resolveUpdateMethodForTest("/Users/test/.local/bin/omp", undefined); expect(method).toBe("binary"); }); diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 49801828e..a75b1bd83 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -17,6 +17,28 @@ import { e2eApiKey } from "../../ai/test/oauth"; export { e2eApiKey }; +/** + * Options for creating a test session. + */ +export interface TestSessionOptions { + /** Use in-memory session (no file persistence) */ + inMemory?: boolean; + /** Custom system prompt */ + systemPrompt?: string | string[]; + /** Custom settings overrides */ + settingsOverrides?: Record; +} + +/** + * Resources returned by createTestSession that need cleanup. + */ +export interface TestSessionContext { + session: AgentSession; + sessionManager: SessionManager; + tempDir: string; + cleanup: () => Promise; +} + /** * Create a minimal user message for testing. */ @@ -47,28 +69,6 @@ export function assistantMsg(text: string) { }; } -/** - * Options for creating a test session. - */ -export interface TestSessionOptions { - /** Use in-memory session (no file persistence) */ - inMemory?: boolean; - /** Custom system prompt */ - systemPrompt?: string | string[]; - /** Custom settings overrides */ - settingsOverrides?: Record; -} - -/** - * Resources returned by createTestSession that need cleanup. - */ -export interface TestSessionContext { - session: AgentSession; - sessionManager: SessionManager; - tempDir: string; - cleanup: () => Promise; -} - /** * Create an AgentSession for testing with proper setup and cleanup. * Use this for e2e tests that need real LLM calls. @@ -123,40 +123,3 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi return { session, sessionManager, tempDir, cleanup }; } - -/** - * Build a session tree for testing using SessionManager. - * Returns the IDs of all created entries. - * - * Example tree structure: - * ``` - * u1 -> a1 -> u2 -> a2 - * -> u3 -> a3 (branch from a1) - * u4 -> a4 (another root) - * ``` - */ -export function buildTestTree( - session: SessionManager, - structure: { - messages: Array<{ role: "user" | "assistant"; text: string; branchFrom?: string }>; - }, -): Map { - const ids = new Map(); - - for (const msg of structure.messages) { - if (msg.branchFrom) { - const branchFromId = ids.get(msg.branchFrom); - if (!branchFromId) { - throw new Error(`Cannot branch from unknown entry: ${msg.branchFrom}`); - } - session.branch(branchFromId); - } - - const id = - msg.role === "user" ? session.appendMessage(userMsg(msg.text)) : session.appendMessage(assistantMsg(msg.text)); - - ids.set(msg.text, id); - } - - return ids; -} diff --git a/packages/natives/package.json b/packages/natives/package.json index 164ba46d9..54e608e82 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,64 +1,63 @@ { - "name": "@oh-my-pi/pi-natives", - "version": "15.0.0", - "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", - "type": "module", - "homepage": "https://github.com/can1357/oh-my-pi", - "author": "Can Boluk", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/can1357/oh-my-pi.git", - "directory": "packages/natives" - }, - "bugs": { - "url": "https://github.com/can1357/oh-my-pi/issues" - }, - "keywords": [ - "napi", - "rust", - "native", - "grep", - "text-processing", - "clipboard", - "image", - "pty", - "shell", - "syntax-highlighting" - ], - "main": "./native/index.js", - "types": "./native/index.d.ts", - "scripts": { - "build": "bun scripts/build-native.ts", - "check": "biome check . && bun run check:types", - "check:types": "tsgo -p tsconfig.json --noEmit", - "lint": "biome lint .", - "test": "bun test", - "fix": "biome check --write --unsafe .", - "fmt": "biome format --write .", - "embed:native": "bun scripts/embed-native.ts", - "bench": "bun bench/grep.ts" - }, - "devDependencies": { - "@napi-rs/cli": "catalog:", - "@types/bun": "catalog:" - }, - "engines": { - "bun": ">=1.3.14" - }, - "napi": { - "binaryName": "pi_natives", - "triples": {} - }, - "files": [ - "src", - "native", - "README.md" - ], - "exports": { - ".": { - "types": "./native/index.d.ts", - "import": "./native/index.js" - } - } + "name": "@oh-my-pi/pi-natives", + "version": "15.0.0", + "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", + "type": "module", + "homepage": "https://github.com/can1357/oh-my-pi", + "author": "Can Boluk", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/can1357/oh-my-pi.git", + "directory": "packages/natives" + }, + "bugs": { + "url": "https://github.com/can1357/oh-my-pi/issues" + }, + "keywords": [ + "napi", + "rust", + "native", + "grep", + "text-processing", + "clipboard", + "image", + "pty", + "shell", + "syntax-highlighting" + ], + "main": "./native/index.js", + "types": "./native/index.d.ts", + "scripts": { + "build": "bun scripts/build-native.ts", + "check": "biome check . && bun run check:types", + "check:types": "tsgo -p tsconfig.json --noEmit", + "lint": "biome lint .", + "test": "bun test", + "fix": "biome check --write --unsafe .", + "fmt": "biome format --write .", + "embed:native": "bun scripts/embed-native.ts", + "bench": "bun bench/grep.ts" + }, + "devDependencies": { + "@types/bun": "catalog:" + }, + "engines": { + "bun": ">=1.3.14" + }, + "napi": { + "binaryName": "pi_natives", + "triples": {} + }, + "files": [ + "src", + "native", + "README.md" + ], + "exports": { + ".": { + "types": "./native/index.d.ts", + "import": "./native/index.js" + } + } } diff --git a/packages/natives/test/issue-892-repro.test.ts b/packages/natives/test/issue-892-repro.test.ts index 5c3400839..4523f0fea 100644 --- a/packages/natives/test/issue-892-repro.test.ts +++ b/packages/natives/test/issue-892-repro.test.ts @@ -16,7 +16,6 @@ import * as path from "node:path"; const nativeDir = path.join(import.meta.dir, "..", "native"); const indexJsPath = path.join(nativeDir, "index.js"); const indexDtsPath = path.join(nativeDir, "index.d.ts"); -const _packageJsonPath = path.join(import.meta.dir, "..", "package.json"); const PUBLIC_SYMBOL_RE = /^export declare (?:class|function|enum) (\w+)/gm; diff --git a/packages/stats/src/client/components/BehaviorChart.tsx b/packages/stats/src/client/components/BehaviorChart.tsx index 7bcf00e8e..9ebe78d3c 100644 --- a/packages/stats/src/client/components/BehaviorChart.tsx +++ b/packages/stats/src/client/components/BehaviorChart.tsx @@ -11,45 +11,26 @@ import { Title, Tooltip, } from "chart.js"; -import { format } from "date-fns"; import { useMemo, useState } from "react"; import { Bar, Line } from "react-chartjs-2"; import type { BehaviorTimeSeriesPoint } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + barDatasetStyle, + buildAggregateTimeSeries, + buildSharedPlugins, + buildSharedScales, + buildTopNByModelSeries, + CHART_THEMES, + ChartFrame, + type ChartSeries, + lineDatasetStyle, + MODEL_COLORS, + styleDatasets, +} from "./chart-shared"; ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; - -const CHART_THEMES = { - dark: { - legendLabel: "#94a3b8", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#64748b", - }, - light: { - legendLabel: "#475569", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#64748b", - }, -} as const; - const METRIC_OPTIONS = [ { value: "yelling", label: "Yelling" }, { value: "profanity", label: "Profanity" }, @@ -87,98 +68,36 @@ function ratePercent(hits: number, messages: number): number { return (hits / messages) * 100; } -interface ChartSeries { - labels: string[]; - datasets: Array<{ label: string; data: number[] }>; -} - interface DailyBucket { hits: number; messages: number; } function buildAggregateSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - - const byDay = new Map(); - for (const point of points) { - const bucket = byDay.get(point.timestamp) ?? { hits: 0, messages: 0 }; - bucket.hits += pointHits(point, metric); - bucket.messages += point.messages; - byDay.set(point.timestamp, bucket); - } - - const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]); - return { - labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")), - datasets: [ - { - label: METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits", - data: sorted.map(([, b]) => ratePercent(b.hits, b.messages)), - }, - ], - }; + const label = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits"; + return buildAggregateTimeSeries(points, label, { + initBucket: () => ({ hits: 0, messages: 0 }), + accumulate: (bucket, point) => { + bucket.hits += pointHits(point, metric); + bucket.messages += point.messages; + }, + bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), + }); } -function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric, topN = 5): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - +function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries { // Rank by message volume so the models you actually use surface first, - // matching the Behavior-by-Model table. - const totals = new Map(); - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const existing = totals.get(key); - if (existing) { - existing.messages += point.messages; - } else { - totals.set(key, { model: point.model, provider: point.provider, messages: point.messages }); - } - } - - const sorted = [...totals.entries()].sort((a, b) => b[1].messages - a[1].messages); - const topEntries = sorted.slice(0, topN); - const topKeys = new Set(topEntries.map(([key]) => key)); - - const modelCount = new Map(); - for (const [, { model }] of topEntries) { - modelCount.set(model, (modelCount.get(model) ?? 0) + 1); - } - const labelByKey = new Map(); - for (const [key, { model, provider }] of topEntries) { - labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model); - } - - const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); - const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key); - const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`)); - if (hasOther) seriesNames.push("Other"); - - // Track hits and messages separately per (day, series), then convert to a - // rate at the end. Summing rates would weight low-volume days unfairly. - const dayMap = new Map>(); - for (const day of allDays) dayMap.set(day, {}); - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; - const row = dayMap.get(point.timestamp); - if (!row) continue; - const bucket = row[label] ?? { hits: 0, messages: 0 }; - bucket.hits += pointHits(point, metric); - bucket.messages += point.messages; - row[label] = bucket; - } - - return { - labels: allDays.map(ts => format(new Date(ts), "MMM d")), - datasets: seriesNames.map(name => ({ - label: name, - data: allDays.map(day => { - const bucket = dayMap.get(day)?.[name]; - return bucket ? ratePercent(bucket.hits, bucket.messages) : 0; - }), - })), - }; + // matching the Behavior-by-Model table. Per-bucket math tracks hits + + // messages separately so the final rate isn't skewed by low-volume days. + return buildTopNByModelSeries(points, { + rankWeight: point => point.messages, + initBucket: () => ({ hits: 0, messages: 0 }), + accumulate: (bucket, point) => { + bucket.hits += pointHits(point, metric); + bucket.messages += point.messages; + }, + bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), + }); } export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) { @@ -192,65 +111,36 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) { [behaviorSeries, byModel, metric], ); - const sharedPlugins = { - legend: { - display: byModel, - position: "top" as const, - align: "start" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - boxWidth: 8, - }, - }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - padding: 12, - cornerRadius: 8, - callbacks: { - label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { - const label = context.dataset.label ?? "Hits"; - const value = context.parsed.y ?? 0; - return `${label}: ${formatRateAxis(value)}`; - }, - }, - }, - }; + const sharedPlugins = buildSharedPlugins({ + chartTheme, + showLegend: byModel, + defaultLabel: "Hits", + formatValue: formatRateAxis, + }); - const sharedScaleBase = { - grid: { color: chartTheme.grid, drawBorder: false }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }; + const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatRateAxis }); - const yScale = { - ...sharedScaleBase, - ticks: { - ...sharedScaleBase.ticks, - callback: (value: number | string) => formatRateAxis(Number(value)), - }, - min: 0, - }; + const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? ""; + const metricTabs = ( +
+ {METRIC_OPTIONS.map(opt => ( + + ))} +
+ ); + let chartNode: React.ReactNode; if (byModel) { const lineData = { labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`, - fill: true, - tension: 0, - pointRadius: 3, - pointHoverRadius: 4, - borderWidth: 2, - })), + datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), }; const lineOptions: ChartOptions<"line"> = { @@ -261,114 +151,39 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) { scales: { x: sharedScaleBase, y: yScale }, }; - return ( - - - - ); + chartNode = ; + } else { + const barData = { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + + const barOptions: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: sharedPlugins, + scales: { + x: { ...sharedScaleBase, stacked: true }, + y: { ...yScale, stacked: true }, + }, + layout: { padding: { top: 8 } }, + }; + + chartNode = ; } - const barData = { - labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderWidth: 0, - borderRadius: 3, - })), - }; - - const barOptions: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: sharedPlugins, - scales: { - x: { ...sharedScaleBase, stacked: true }, - y: { ...yScale, stacked: true }, - }, - layout: { padding: { top: 8 } }, - }; - return ( - - - - ); -} - -interface ChartWrapperProps { - byModel: boolean; - metric: Metric; - onByModelChange: (v: boolean) => void; - onMetricChange: (v: Metric) => void; - empty: boolean; - children: React.ReactNode; -} - -function ChartWrapper({ byModel, metric, onByModelChange, onMetricChange, empty, children }: ChartWrapperProps) { - const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? ""; - return ( -
-
-
-

User Tantrums

-

{metricLabel} as % of user messages per day

-
-
-
- {METRIC_OPTIONS.map(opt => ( - - ))} -
-
- - -
-
-
-
- {empty ? ( -
- No behavioral data yet. Sync to scan your sessions. -
- ) : ( -
{children}
- )} -
-
+ {chartNode} + ); } diff --git a/packages/stats/src/client/components/BehaviorModelsTable.tsx b/packages/stats/src/client/components/BehaviorModelsTable.tsx index d001f7949..16b5fccf6 100644 --- a/packages/stats/src/client/components/BehaviorModelsTable.tsx +++ b/packages/stats/src/client/components/BehaviorModelsTable.tsx @@ -9,24 +9,29 @@ import { Tooltip, } from "chart.js"; import { format } from "date-fns"; -import { ChevronDown, ChevronUp } from "lucide-react"; import { useMemo, useState } from "react"; import { Line } from "react-chartjs-2"; import type { BehaviorModelStats, BehaviorTimeSeriesPoint } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + DetailChartEmpty, + detailChartPlugins, + detailChartScalesSingleAxis, + ExpandableModelRow, + lineSeriesStyle, + MiniSparkline, + MODEL_COLORS, + ModelNameCell, + ModelTableBody, + ModelTableHeader, + ModelTableShell, + TABLE_CHART_THEMES, + type TableChartTheme, + TrendEmpty, +} from "./models-table-shared"; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; - const SERIES_COLORS = { yelling: "#fbbf24", // amber profanity: "#f87171", // red @@ -34,29 +39,6 @@ const SERIES_COLORS = { frustration: "#22d3ee", // cyan - new semantic signals } as const; -const CHART_THEMES = { - dark: { - legendLabel: "#cbd5e1", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#94a3b8", - }, - light: { - legendLabel: "#334155", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#475569", - }, -} as const; - -type ChartTheme = (typeof CHART_THEMES)[keyof typeof CHART_THEMES]; - interface BehaviorModelsTableProps { models: BehaviorModelStats[]; behaviorSeries: BehaviorTimeSeriesPoint[]; @@ -107,7 +89,7 @@ function formatRate(total: number, messages: number): string { export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTableProps) { const [expandedKey, setExpandedKey] = useState(null); const theme = useSystemTheme(); - const chartTheme = CHART_THEMES[theme]; + const chartTheme = TABLE_CHART_THEMES[theme]; const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]); @@ -119,153 +101,137 @@ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTa }); return ( -
-
-

Behavior by Model

-

- How often each model elicited a tantrum — rates are per user message -

-
+ + -
-
-
Model
-
Messages
-
CAPS %
-
Profanity %
-
Anguish %
-
Frustration %
-
Hits %
-
Trend
-
-
+ + {sortedModels.map((model, index) => { + const key = `${model.model}::${model.provider}`; + const trend = trendByKey.get(key)?.data ?? []; + const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; + const isExpanded = expandedKey === key; + const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame; + const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration; -
- {sortedModels.map((model, index) => { - const key = `${model.model}::${model.provider}`; - const trend = trendByKey.get(key)?.data ?? []; - const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; - const isExpanded = expandedKey === key; - const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame; - const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration; - - return ( -
- - - {isExpanded && ( -
-
-
- - - - - - - -
-
- {trend.length === 0 ? ( -
- No data available -
- ) : ( - - )} -
-
+
+ {trend.length === 0 ? ( + + ) : ( + + )}
- )} -
- ); - })} - {sortedModels.length === 0 && ( -
- No user behavior recorded for this range yet. -
- )} -
-
-
+
+ } + /> + ); + })} + {sortedModels.length === 0 ? ( +
+ No user behavior recorded for this range yet. +
+ ) : null} + +
); } @@ -302,111 +268,22 @@ function DetailRow({ ); } -function TrendSparkline({ data, color }: { data: DailyPoint[]; color: string }) { +function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) { const chartData = { labels: data.map(d => format(new Date(d.timestamp), "MMM d")), datasets: [ - { - data: data.map(d => d.total), - borderColor: color, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, + { label: "CAPS", data: data.map(d => d.yelling), ...lineSeriesStyle(SERIES_COLORS.yelling) }, + { label: "Profanity", data: data.map(d => d.profanity), ...lineSeriesStyle(SERIES_COLORS.profanity) }, + { label: "Anguish", data: data.map(d => d.anguish), ...lineSeriesStyle(SERIES_COLORS.anguish) }, + { label: "Frustration", data: data.map(d => d.frustration), ...lineSeriesStyle(SERIES_COLORS.frustration) }, ], }; const options = { responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false }, tooltip: { enabled: false } }, - scales: { - x: { display: false }, - y: { display: false, min: 0 }, - }, - }; - - return ; -} - -function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: ChartTheme }) { - const chartData = { - labels: data.map(d => format(new Date(d.timestamp), "MMM d")), - datasets: [ - { - label: "CAPS", - data: data.map(d => d.yelling), - borderColor: SERIES_COLORS.yelling, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - { - label: "Profanity", - data: data.map(d => d.profanity), - borderColor: SERIES_COLORS.profanity, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - { - label: "Anguish", - data: data.map(d => d.anguish), - borderColor: SERIES_COLORS.anguish, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - { - label: "Frustration", - data: data.map(d => d.frustration), - borderColor: SERIES_COLORS.frustration, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - ], - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: "top" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - }, - }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - cornerRadius: 8, - }, - }, - scales: { - x: { - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - y: { - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - min: 0, - }, - }, + plugins: detailChartPlugins(chartTheme), + scales: detailChartScalesSingleAxis(chartTheme), }; return ; diff --git a/packages/stats/src/client/components/CostChart.tsx b/packages/stats/src/client/components/CostChart.tsx index 291c0d699..205daf943 100644 --- a/packages/stats/src/client/components/CostChart.tsx +++ b/packages/stats/src/client/components/CostChart.tsx @@ -12,45 +12,30 @@ import { Title, Tooltip, } from "chart.js"; -import { format } from "date-fns"; import { useMemo, useState } from "react"; import { Bar, Line } from "react-chartjs-2"; import type { CostTimeSeriesPoint } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + barDatasetStyle, + buildAggregateTimeSeries, + buildSharedPlugins, + buildSharedScales, + buildTopNByModelSeries, + CHART_THEMES, + ChartFrame, + type ChartSeries, + lineDatasetStyle, + MODEL_COLORS, + styleDatasets, +} from "./chart-shared"; ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; - -const CHART_THEMES = { - dark: { - legendLabel: "#94a3b8", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#64748b", - barLabel: "rgba(248, 250, 252, 0.7)", - }, - light: { - legendLabel: "#475569", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#64748b", - barLabel: "rgba(15, 23, 42, 0.6)", - }, +/** Cost bar labels need a per-theme color that the generic chart theme doesn't carry. */ +const BAR_LABEL_COLORS = { + dark: "rgba(248, 250, 252, 0.7)", + light: "rgba(15, 23, 42, 0.6)", } as const; interface CostChartProps { @@ -83,6 +68,28 @@ function makeBarLabelPlugin(color: string): Plugin<"bar"> { }; } +function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries { + return buildAggregateTimeSeries(points, "Cost", { + initBucket: () => ({ total: 0 }), + accumulate: (bucket, point) => { + bucket.total += point.cost; + }, + bucketToValue: bucket => bucket.total, + }); +} + +function buildByModelSeries(points: CostTimeSeriesPoint[]): ChartSeries { + // Rank models by total cost; per-day buckets are simple cost sums. + return buildTopNByModelSeries(points, { + rankWeight: point => point.cost, + initBucket: () => ({ total: 0 }), + accumulate: (bucket, point) => { + bucket.total += point.cost; + }, + bucketToValue: bucket => bucket.total, + }); +} + export function CostChart({ costSeries }: CostChartProps) { const [byModel, setByModel] = useState(false); const theme = useSystemTheme(); @@ -93,70 +100,28 @@ export function CostChart({ costSeries }: CostChartProps) { [costSeries, byModel], ); - const sharedPlugins = { - legend: { - display: byModel, - position: "top" as const, - align: "start" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - boxWidth: 8, - }, + const sharedPlugins = buildSharedPlugins({ + chartTheme, + showLegend: byModel, + defaultLabel: "Cost", + formatValue: v => `$${Math.round(v)}`, + footer: items => { + if (!byModel || items.length < 2) return undefined; + const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0); + return `Total: $${Math.round(total)}`; }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - padding: 12, - cornerRadius: 8, - callbacks: { - label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { - const label = context.dataset.label ?? "Cost"; - const value = context.parsed.y ?? 0; - return `${label}: $${Math.round(value)}`; - }, - footer: (items: { parsed: { y: number | null } }[]) => { - if (!byModel || items.length < 2) return undefined; - const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0); - return `Total: $${Math.round(total)}`; - }, - }, - }, - }; + }); - const sharedScaleBase = { - grid: { color: chartTheme.grid, drawBorder: false }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }; - - const yScale = { - ...sharedScaleBase, - ticks: { - ...sharedScaleBase.ticks, - callback: (value: number | string) => `$${Math.round(Number(value))}`, - }, - min: 0, - }; + const { sharedScaleBase, yScale } = buildSharedScales({ + chartTheme, + formatY: v => `$${Math.round(v)}`, + }); + let chartNode: React.ReactNode; if (byModel) { const lineData = { labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`, - fill: true, - tension: 0, - pointRadius: 3, - pointHoverRadius: 4, - borderWidth: 2, - })), + datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), }; const lineOptions: ChartOptions<"line"> = { @@ -167,166 +132,40 @@ export function CostChart({ costSeries }: CostChartProps) { scales: { x: sharedScaleBase, y: yScale }, }; - return ( - - - - ); + chartNode = ; + } else { + const barData = { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + + const barLabelPlugin = makeBarLabelPlugin(BAR_LABEL_COLORS[theme]); + + const barOptions: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"], + scales: { + x: { ...sharedScaleBase, stacked: true }, + y: { ...yScale, stacked: true }, + }, + layout: { padding: { top: 24 } }, + }; + + chartNode = ; } - const barData = { - labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderWidth: 0, - borderRadius: 3, - })), - }; - - const barLabelPlugin = makeBarLabelPlugin(chartTheme.barLabel); - - const barOptions: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"], - scales: { - x: { ...sharedScaleBase, stacked: true }, - y: { ...yScale, stacked: true }, - }, - layout: { padding: { top: 24 } }, - }; - return ( - - - + + {chartNode} + ); } - -interface ChartWrapperProps { - byModel: boolean; - onByModelChange: (v: boolean) => void; - empty: boolean; - children: React.ReactNode; -} - -function ChartWrapper({ byModel, onByModelChange, empty, children }: ChartWrapperProps) { - return ( -
-
-
-

Daily Cost

-

API spending over time

-
-
-
- - -
-
-
-
- {empty ? ( -
- No cost data available -
- ) : ( -
{children}
- )} -
-
- ); -} - -interface ChartSeries { - labels: string[]; - datasets: Array<{ label: string; data: number[] }>; -} - -function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - - const byDay = new Map(); - for (const point of points) { - byDay.set(point.timestamp, (byDay.get(point.timestamp) ?? 0) + point.cost); - } - - const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]); - return { - labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")), - datasets: [{ label: "Cost", data: sorted.map(([, cost]) => cost) }], - }; -} - -function buildByModelSeries(points: CostTimeSeriesPoint[], topN = 5): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - - // Rank models by total cost - const totals = new Map(); - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const existing = totals.get(key); - if (existing) { - existing.total += point.cost; - } else { - totals.set(key, { model: point.model, provider: point.provider, total: point.cost }); - } - } - - const sorted = [...totals.entries()].sort((a, b) => b[1].total - a[1].total); - const topEntries = sorted.slice(0, topN); - const topKeys = new Set(topEntries.map(([key]) => key)); - - // Disambiguate model labels when same model name appears from multiple providers - const modelCount = new Map(); - for (const [, { model }] of topEntries) { - modelCount.set(model, (modelCount.get(model) ?? 0) + 1); - } - const labelByKey = new Map(); - for (const [key, { model, provider }] of topEntries) { - labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model); - } - - // Collect all day buckets - const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); - - // Build per-day, per-series totals - const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key); - const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`)); - if (hasOther) seriesNames.push("Other"); - - const dayMap = new Map>(); - for (const day of allDays) { - dayMap.set(day, {}); - } - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; - const row = dayMap.get(point.timestamp)!; - row[label] = (row[label] ?? 0) + point.cost; - } - - return { - labels: allDays.map(ts => format(new Date(ts), "MMM d")), - datasets: seriesNames.map(name => ({ - label: name, - data: allDays.map(day => dayMap.get(day)?.[name] ?? 0), - })), - }; -} diff --git a/packages/stats/src/client/components/ModelsTable.tsx b/packages/stats/src/client/components/ModelsTable.tsx index 97b53faa3..4f0fa629f 100644 --- a/packages/stats/src/client/components/ModelsTable.tsx +++ b/packages/stats/src/client/components/ModelsTable.tsx @@ -9,46 +9,31 @@ import { Tooltip, } from "chart.js"; import { format } from "date-fns"; -import { ChevronDown, ChevronUp } from "lucide-react"; import { useMemo, useState } from "react"; import { Line } from "react-chartjs-2"; import type { ModelPerformancePoint, ModelStats } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + DetailChartEmpty, + detailChartPlugins, + detailChartScalesDualAxis, + ExpandableModelRow, + lineSeriesStyle, + MiniSparkline, + MODEL_COLORS, + ModelNameCell, + ModelTableBody, + ModelTableHeader, + ModelTableShell, + TABLE_CHART_THEMES, + type TableChartTheme, + TrendEmpty, +} from "./models-table-shared"; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; +const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px"; -const CHART_THEMES = { - dark: { - legendLabel: "#cbd5e1", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#94a3b8", - }, - light: { - legendLabel: "#334155", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#475569", - }, -} as const; - -type ChartTheme = (typeof CHART_THEMES)[keyof typeof CHART_THEMES]; interface ModelsTableProps { models: ModelStats[]; performanceSeries: ModelPerformancePoint[]; @@ -69,184 +54,129 @@ export function ModelsTable({ models, performanceSeries }: ModelsTableProps) { const performanceSeriesByKey = useMemo(() => buildModelPerformanceLookup(performanceSeries), [performanceSeries]); const theme = useSystemTheme(); - const chartTheme = CHART_THEMES[theme]; + const chartTheme = TABLE_CHART_THEMES[theme]; const sortedModels = [...models].sort( (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens), ); return ( -
-
-

Model Statistics

-
+ + -
-
-
Model
-
Requests
-
Cost
-
Tokens
-
Tokens/s
-
TTFT
-
14d Trend
-
-
+ + {sortedModels.map((model, index) => { + const key = `${model.model}::${model.provider}`; + const performance = performanceSeriesByKey.get(key); + const trendData = performance?.data ?? []; + const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; + const isExpanded = expandedKey === key; + const errorRate = model.errorRate * 100; -
- {sortedModels.map((model, index) => { - const key = `${model.model}::${model.provider}`; - const performance = performanceSeriesByKey.get(key); - const trendData = performance?.data ?? []; - const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; - const isExpanded = expandedKey === key; - const errorRate = model.errorRate * 100; - - return ( -
- - - {isExpanded && ( -
-
-
-
-
Quality
-
-
- Error rate - 5 - ? "text-[var(--accent-red)]" - : "text-[var(--accent-green)]" - } - > - {errorRate.toFixed(1)}% - -
-
- Cache rate - - {(model.cacheRate * 100).toFixed(1)}% - -
-
+
Quality
+
+
+ Error rate + 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]" + } + > + {errorRate.toFixed(1)}% +
-
-
Latency
-
-
- Avg duration - - {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"} - -
-
- Avg TTFT - - {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} - -
-
+
+ Cache rate + + {(model.cacheRate * 100).toFixed(1)}% +
-
- {trendData.length === 0 ? ( -
- No data available -
- ) : ( - - )} +
+
+
Latency
+
+
+ Avg duration + + {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"} + +
+
+ Avg TTFT + + {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} + +
- )} -
- ); - })} -
-
-
+
+ {trendData.length === 0 ? ( + + ) : ( + + )} +
+
+ } + /> + ); + })} + + ); } -function TrendChart({ - data, - color, -}: { - data: Array<{ timestamp: number; avgTokensPerSecond: number | null }>; - color: string; -}) { - const chartData = { - labels: data.map(d => format(new Date(d.timestamp), "MMM d")), - datasets: [ - { - data: data.map(d => d.avgTokensPerSecond ?? 0), - borderColor: color, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - ], - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false }, tooltip: { enabled: false } }, - scales: { - x: { display: false }, - y: { display: false, min: 0 }, - }, - }; - - return ; -} - function PerformanceChart({ data, color, @@ -254,7 +184,7 @@ function PerformanceChart({ }: { data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null }>; color: string; - chartTheme: ChartTheme; + chartTheme: TableChartTheme; }) { const chartData = { labels: data.map(d => format(new Date(d.timestamp), "MMM d")), @@ -262,21 +192,13 @@ function PerformanceChart({ { label: "TTFT", data: data.map(d => d.avgTtftSeconds ?? null), - borderColor: "#fbbf24", - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, + ...lineSeriesStyle("#fbbf24"), yAxisID: "y" as const, }, { label: "Tokens/s", data: data.map(d => d.avgTokensPerSecond ?? null), - borderColor: color, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, + ...lineSeriesStyle(color), yAxisID: "y1" as const, }, ], @@ -285,46 +207,8 @@ function PerformanceChart({ const options = { responsive: true, maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: "top" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - }, - }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - cornerRadius: 8, - }, - }, - scales: { - x: { - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - y: { - type: "linear" as const, - display: true, - position: "left" as const, - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - y1: { - type: "linear" as const, - display: true, - position: "right" as const, - grid: { drawOnChartArea: false }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - }, + plugins: detailChartPlugins(chartTheme), + scales: detailChartScalesDualAxis(chartTheme), }; return ; diff --git a/packages/stats/src/client/components/RequestDetail.tsx b/packages/stats/src/client/components/RequestDetail.tsx index 73dff894c..4bb5e223c 100644 --- a/packages/stats/src/client/components/RequestDetail.tsx +++ b/packages/stats/src/client/components/RequestDetail.tsx @@ -35,13 +35,11 @@ export function RequestDetail({ id, onClose }: RequestDetailProps) { if (!details) return null; return ( - // biome-ignore lint/a11y/noStaticElementInteractions: modal backdrop dismissal
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation for modal content */}
; +} + +interface TooltipItem { + parsed: { y: number | null }; +} + +/** Tooltip + legend config common to bar and line variants of the time charts. */ +export function buildSharedPlugins(opts: { + chartTheme: ChartTheme; + showLegend: boolean; + defaultLabel: string; + formatValue: (n: number) => string; + footer?: (items: TooltipItem[]) => string | undefined; +}) { + const { chartTheme, showLegend, defaultLabel, formatValue, footer } = opts; + return { + legend: { + display: showLegend, + position: "top" as const, + align: "start" as const, + labels: { + color: chartTheme.legendLabel, + usePointStyle: true, + padding: 16, + font: { size: 12 }, + boxWidth: 8, + }, + }, + tooltip: { + backgroundColor: chartTheme.tooltipBackground, + titleColor: chartTheme.tooltipTitle, + bodyColor: chartTheme.tooltipBody, + borderColor: chartTheme.tooltipBorder, + borderWidth: 1, + padding: 12, + cornerRadius: 8, + callbacks: { + label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => { + const label = ctx.dataset.label ?? defaultLabel; + const value = ctx.parsed.y ?? 0; + return `${label}: ${formatValue(value)}`; + }, + ...(footer ? { footer } : {}), + }, + }, + }; +} + +/** Y-axis tick formatter + grid/tick styling shared by both charts. */ +export function buildSharedScales(opts: { chartTheme: ChartTheme; formatY: (n: number) => string }) { + const { chartTheme, formatY } = opts; + const sharedScaleBase = { + grid: { color: chartTheme.grid, drawBorder: false }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }; + const yScale = { + ...sharedScaleBase, + ticks: { + ...sharedScaleBase.ticks, + callback: (value: number | string) => formatY(Number(value)), + }, + min: 0, + }; + return { sharedScaleBase, yScale }; +} + +/** Stylistic defaults for a single line dataset in a stacked/by-model chart. */ +export function lineDatasetStyle(color: string) { + return { + borderColor: color, + backgroundColor: `${color}20`, + fill: true, + tension: 0, + pointRadius: 3, + pointHoverRadius: 4, + borderWidth: 2, + }; +} + +/** Stylistic defaults for a single bar dataset in a stacked chart. */ +export function barDatasetStyle(color: string) { + return { + backgroundColor: color, + borderColor: color, + borderWidth: 0, + borderRadius: 3, + }; +} + +/** + * Map a generic ChartSeries' datasets through a per-index style function so + * callers can supply line or bar styling without repeating the label/data + * spread at every chart site. + */ +export function styleDatasets(series: ChartSeries, styleFor: (index: number) => Record) { + return series.datasets.map((ds, index) => ({ + label: ds.label, + data: ds.data, + ...styleFor(index), + })); +} + +/** + * Bucket points by day into a single aggregate series. Caller supplies the + * per-bucket accumulator + final value extractor; mirrors the shape of + * `buildTopNByModelSeries` for the non-by-model variant of each time chart. + */ +export function buildAggregateTimeSeries( + points: T[], + label: string, + opts: { + initBucket: () => B; + accumulate: (bucket: B, point: T) => void; + bucketToValue: (bucket: B) => number; + }, +): ChartSeries { + if (points.length === 0) return { labels: [], datasets: [] }; + const { initBucket, accumulate, bucketToValue } = opts; + const byDay = new Map(); + for (const point of points) { + const bucket = byDay.get(point.timestamp) ?? initBucket(); + accumulate(bucket, point); + byDay.set(point.timestamp, bucket); + } + const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]); + return { + labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")), + datasets: [{ label, data: sorted.map(([, bucket]) => bucketToValue(bucket)) }], + }; +} + +interface ModelKeyedPoint { + timestamp: number; + model: string; + provider: string; +} + +/** + * Bucket points by day and by top-N model (with an "Other" rollup), producing + * a ChartSeries. Caller controls how points contribute to ranking and to each + * day-bucket value via the `rankWeight`/`accumulate`/`bucketToValue` callbacks + * — keeps the behavior chart's rate math separate from the cost chart's sum. + */ +export function buildTopNByModelSeries( + points: T[], + opts: { + topN?: number; + rankWeight: (point: T) => number; + initBucket: () => B; + accumulate: (bucket: B, point: T) => void; + bucketToValue: (bucket: B) => number; + }, +): ChartSeries { + if (points.length === 0) return { labels: [], datasets: [] }; + const { topN = 5, rankWeight, initBucket, accumulate, bucketToValue } = opts; + + const totals = new Map(); + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const existing = totals.get(key); + if (existing) { + existing.weight += rankWeight(point); + } else { + totals.set(key, { model: point.model, provider: point.provider, weight: rankWeight(point) }); + } + } + + const sorted = [...totals.entries()].sort((a, b) => b[1].weight - a[1].weight); + const topEntries = sorted.slice(0, topN); + const topKeys = new Set(topEntries.map(([key]) => key)); + + const modelCount = new Map(); + for (const [, { model }] of topEntries) { + modelCount.set(model, (modelCount.get(model) ?? 0) + 1); + } + const labelByKey = new Map(); + for (const [key, { model, provider }] of topEntries) { + labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model); + } + + const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); + const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key); + const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`)); + if (hasOther) seriesNames.push("Other"); + + const dayMap = new Map>(); + for (const day of allDays) dayMap.set(day, {}); + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; + const row = dayMap.get(point.timestamp); + if (!row) continue; + const bucket = row[label] ?? initBucket(); + accumulate(bucket, point); + row[label] = bucket; + } + + return { + labels: allDays.map(ts => format(new Date(ts), "MMM d")), + datasets: seriesNames.map(name => ({ + label: name, + data: allDays.map(day => { + const bucket = dayMap.get(day)?.[name]; + return bucket ? bucketToValue(bucket) : 0; + }), + })), + }; +} + +/** All Models / By Model segmented toggle — identical UI in every time chart. */ +export function ByModelToggle({ byModel, onChange }: { byModel: boolean; onChange: (v: boolean) => void }) { + return ( +
+ + +
+ ); +} + +/** + * Outer surface card used by both time charts. `controls` slot covers + * chart-specific tabs (e.g. behavior metric picker); the by-model toggle and + * empty-state are part of the frame so callers don't redeclare them. + */ +export function ChartFrame({ + title, + subtitle, + empty, + emptyMessage, + controls, + byModel, + onByModelChange, + children, +}: { + title: string; + subtitle: string; + empty: boolean; + emptyMessage: string; + controls?: React.ReactNode; + byModel: boolean; + onByModelChange: (v: boolean) => void; + children: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+
+ {controls} + +
+
+
+ {empty ? ( +
+ {emptyMessage} +
+ ) : ( +
{children}
+ )} +
+
+ ); +} diff --git a/packages/stats/src/client/components/models-table-shared.tsx b/packages/stats/src/client/components/models-table-shared.tsx new file mode 100644 index 000000000..8c6dd60de --- /dev/null +++ b/packages/stats/src/client/components/models-table-shared.tsx @@ -0,0 +1,275 @@ +/** + * Shared primitives for the per-model breakdown tables (ModelsTable, + * BehaviorModelsTable). Each table still owns its column definitions, sort + * order, sidebar contents and chart type — this module owns the surface + * chrome, expand-row plumbing, theme palette, and the mini-sparkline plus + * the shared plugin/scale config consumed by multi-line detail charts. + */ + +import { format } from "date-fns"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { Line } from "react-chartjs-2"; + +export { MODEL_COLORS } from "./chart-shared"; + +export const TABLE_CHART_THEMES = { + dark: { + legendLabel: "#cbd5e1", + tooltipBackground: "#16161e", + tooltipTitle: "#f8fafc", + tooltipBody: "#94a3b8", + tooltipBorder: "rgba(255, 255, 255, 0.1)", + grid: "rgba(255, 255, 255, 0.06)", + tick: "#94a3b8", + }, + light: { + legendLabel: "#334155", + tooltipBackground: "#ffffff", + tooltipTitle: "#0f172a", + tooltipBody: "#334155", + tooltipBorder: "rgba(15, 23, 42, 0.18)", + grid: "rgba(15, 23, 42, 0.08)", + tick: "#475569", + }, +} as const; + +export type TableChartTheme = (typeof TABLE_CHART_THEMES)[keyof typeof TABLE_CHART_THEMES]; + +/** Style defaults for one line in a non-stacked detail chart. */ +export function lineSeriesStyle(color: string) { + return { + borderColor: color, + backgroundColor: "transparent", + tension: 0.4, + pointRadius: 0, + borderWidth: 2, + }; +} + +/** + * No-axis, no-legend single-series sparkline used in the trend cell of every + * model row. Caller supplies the already-extracted numeric series so this + * stays agnostic of the row's underlying data shape. + */ +export function MiniSparkline({ + timestamps, + values, + color, +}: { + timestamps: number[]; + values: number[]; + color: string; +}) { + const chartData = { + labels: timestamps.map(ts => format(new Date(ts), "MMM d")), + datasets: [{ data: values, ...lineSeriesStyle(color) }], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { + x: { display: false }, + y: { display: false, min: 0 }, + }, + }; + + return ; +} + +/** + * Plugin block (legend + tooltip) shared by every multi-series detail chart + * in the table expanded views. + */ +export function detailChartPlugins(chartTheme: TableChartTheme) { + return { + legend: { + display: true, + position: "top" as const, + labels: { + color: chartTheme.legendLabel, + usePointStyle: true, + padding: 16, + font: { size: 12 }, + }, + }, + tooltip: { + backgroundColor: chartTheme.tooltipBackground, + titleColor: chartTheme.tooltipTitle, + bodyColor: chartTheme.tooltipBody, + borderColor: chartTheme.tooltipBorder, + borderWidth: 1, + cornerRadius: 8, + }, + }; +} + +/** + * Single-Y-axis scales for a detail chart (used when every series shares a + * unit, e.g. behavior counts). Min anchored at 0. + */ +export function detailChartScalesSingleAxis(chartTheme: TableChartTheme) { + return { + x: { + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + y: { + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + min: 0, + }, + }; +} + +/** + * Dual-Y-axis scales for a detail chart with mixed units (e.g. TTFT seconds + * on left, tokens/s on right). Right-axis grid is suppressed so it doesn't + * collide with the left. + */ +export function detailChartScalesDualAxis(chartTheme: TableChartTheme) { + return { + x: { + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + y: { + type: "linear" as const, + display: true, + position: "left" as const, + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + y1: { + type: "linear" as const, + display: true, + position: "right" as const, + grid: { drawOnChartArea: false }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + }; +} + +export interface TableColumn { + label: string; + align?: "left" | "right" | "center"; +} + +/** Outer card + section title used by every model table. */ +export function ModelTableShell({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+
{children}
+
+ ); +} + +function alignClass(align: TableColumn["align"]): string { + if (align === "right") return "text-right"; + if (align === "center") return "text-center"; + return ""; +} + +/** Sticky column-header row for a model table. */ +export function ModelTableHeader({ columns, gridTemplate }: { columns: TableColumn[]; gridTemplate: string }) { + return ( +
+ {columns.map(col => ( +
+ {col.label} +
+ ))} + {/* trailing chevron column has no header label */} +
+
+ ); +} + +/** Scroll wrapper for the row stack — capped to fit the dashboard viewport. */ +export function ModelTableBody({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** + * Two-line model identity cell (model name + provider) shared by every + * per-model table. Kept as a stable named contract so callers don't restate + * the same two divs and font-utility classes. + */ +export function ModelNameCell({ model, provider }: { model: string; provider: string }) { + return ( +
+
{model}
+
{provider}
+
+ ); +} + +/** + * One expandable model row. `cells` matches the column order from + * `ModelTableHeader` plus the trend cell at the end (caller controls the + * sparkline / placeholder). `expandedContent` is the panel revealed on toggle. + */ +export function ExpandableModelRow({ + gridTemplate, + cells, + trendCell, + isExpanded, + onToggle, + expandedContent, +}: { + gridTemplate: string; + cells: React.ReactNode[]; + trendCell: React.ReactNode; + isExpanded: boolean; + onToggle: () => void; + expandedContent: React.ReactNode; +}) { + return ( +
+ + {isExpanded ? ( +
+ {expandedContent} +
+ ) : null} +
+ ); +} + +/** Placeholder shown in the trend cell when a model has no time-series data. */ +export function TrendEmpty() { + return
-
; +} + +/** Placeholder shown in the expanded detail-chart slot when data is missing. */ +export function DetailChartEmpty({ message = "No data available" }: { message?: string }) { + return
{message}
; +} diff --git a/packages/stats/src/client/types.ts b/packages/stats/src/client/types.ts index ab783d5c7..1423d2dae 100644 --- a/packages/stats/src/client/types.ts +++ b/packages/stats/src/client/types.ts @@ -1,8 +1,28 @@ /** * Client-side type definitions. - * Duplicated from ../types.ts to avoid pulling in server dependencies. + * + * Shared shapes (aggregations, time-series, dashboard payloads) live in + * `../shared-types` and are re-exported here. The types declared inline below + * are deliberately client-only because: + * - `Usage` is redeclared locally so the client bundle avoids importing + * `@oh-my-pi/pi-ai` (the server-side AI types package). + * - `MessageStats.stopReason` is widened from the server's `StopReason` + * enum to `string`, again to keep the client free of pi-ai types. + * - `TimeRange`, `OverviewStats`, `ModelDashboardStats`, + * `CostDashboardStats` are UI-only view shapes the server never produces. */ +import type { + AggregatedStats, + CostTimeSeriesPoint, + ModelPerformancePoint, + ModelStats, + ModelTimeSeriesPoint, + TimeSeriesPoint, +} from "../shared-types"; + +export * from "../shared-types"; + export interface Usage { input: number; output: number; @@ -40,80 +60,7 @@ export interface RequestDetails extends MessageStats { output: unknown; } -export interface AggregatedStats { - totalRequests: number; - successfulRequests: number; - failedRequests: number; - errorRate: number; - totalInputTokens: number; - totalOutputTokens: number; - totalCacheReadTokens: number; - totalCacheWriteTokens: number; - cacheRate: number; - totalCost: number; - totalPremiumRequests: number; - avgDuration: number | null; - avgTtft: number | null; - avgTokensPerSecond: number | null; - firstTimestamp: number; - lastTimestamp: number; -} - export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all"; -export interface ModelStats extends AggregatedStats { - model: string; - provider: string; -} - -export interface FolderStats extends AggregatedStats { - folder: string; -} - -export interface TimeSeriesPoint { - timestamp: number; - requests: number; - errors: number; - tokens: number; - cost: number; -} - -export interface ModelTimeSeriesPoint { - timestamp: number; - model: string; - provider: string; - requests: number; -} - -export interface ModelPerformancePoint { - timestamp: number; - model: string; - provider: string; - requests: number; - avgTtft: number | null; - avgTokensPerSecond: number | null; -} - -export interface CostTimeSeriesPoint { - timestamp: number; - model: string; - provider: string; - cost: number; - costInput: number; - costOutput: number; - costCacheRead: number; - costCacheWrite: number; - requests: number; -} - -export interface DashboardStats { - overall: AggregatedStats; - byModel: ModelStats[]; - byFolder: FolderStats[]; - timeSeries: TimeSeriesPoint[]; - modelSeries: ModelTimeSeriesPoint[]; - modelPerformanceSeries: ModelPerformancePoint[]; - costSeries: CostTimeSeriesPoint[]; -} export interface OverviewStats { overall: AggregatedStats; @@ -129,50 +76,3 @@ export interface ModelDashboardStats { export interface CostDashboardStats { costSeries: CostTimeSeriesPoint[]; } - -export interface BehaviorTimeSeriesPoint { - timestamp: number; - model: string; - provider: string; - messages: number; - yelling: number; - profanity: number; - anguish: number; - negation: number; - repetition: number; - blame: number; - chars: number; -} - -export interface BehaviorOverallStats { - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - firstTimestamp: number; - lastTimestamp: number; -} - -export interface BehaviorModelStats { - model: string; - provider: string; - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - lastTimestamp: number; -} - -export interface BehaviorDashboardStats { - overall: BehaviorOverallStats; - byModel: BehaviorModelStats[]; - behaviorSeries: BehaviorTimeSeriesPoint[]; -} diff --git a/packages/stats/src/shared-types.ts b/packages/stats/src/shared-types.ts new file mode 100644 index 000000000..024d3419f --- /dev/null +++ b/packages/stats/src/shared-types.ts @@ -0,0 +1,204 @@ +/** + * Shared type definitions consumed by both the server-side stats code and the + * standalone client bundle. Keep this file free of any imports from server-only + * packages (e.g. `@oh-my-pi/pi-ai`, `bun:sqlite`) so the client can import it + * without dragging server dependencies into its bundle. + */ + +/** + * Aggregated stats for a model or folder. + */ +export interface AggregatedStats { + /** Total number of requests */ + totalRequests: number; + /** Number of successful requests */ + successfulRequests: number; + /** Number of failed requests */ + failedRequests: number; + /** Error rate (0-1) */ + errorRate: number; + /** Total input tokens */ + totalInputTokens: number; + /** Total output tokens */ + totalOutputTokens: number; + /** Total cache read tokens */ + totalCacheReadTokens: number; + /** Total cache write tokens */ + totalCacheWriteTokens: number; + /** Cache hit rate (0-1) */ + cacheRate: number; + /** Total cost */ + totalCost: number; + /** Total premium requests */ + totalPremiumRequests: number; + /** Average duration in ms */ + avgDuration: number | null; + /** Average TTFT in ms */ + avgTtft: number | null; + /** Average tokens per second (output tokens / duration) */ + avgTokensPerSecond: number | null; + /** Time range */ + firstTimestamp: number; + lastTimestamp: number; +} + +/** + * Stats grouped by model. + */ +export interface ModelStats extends AggregatedStats { + model: string; + provider: string; +} + +/** + * Stats grouped by folder. + */ +export interface FolderStats extends AggregatedStats { + folder: string; +} + +/** + * Time series data point. + */ +export interface TimeSeriesPoint { + /** Bucket timestamp (start of hour/day) */ + timestamp: number; + /** Request count */ + requests: number; + /** Error count */ + errors: number; + /** Total tokens */ + tokens: number; + /** Total cost */ + cost: number; +} + +/** + * Model usage time series data point (daily buckets). + */ +export interface ModelTimeSeriesPoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Model name */ + model: string; + /** Provider name */ + provider: string; + /** Request count */ + requests: number; +} + +/** + * Model performance time series data point (daily buckets). + */ +export interface ModelPerformancePoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Model name */ + model: string; + /** Provider name */ + provider: string; + /** Request count */ + requests: number; + /** Average TTFT in ms */ + avgTtft: number | null; + /** Average tokens per second */ + avgTokensPerSecond: number | null; +} + +/** + * Cost time series data point (daily buckets). + */ +export interface CostTimeSeriesPoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Model name */ + model: string; + /** Provider name */ + provider: string; + /** Total cost for this bucket */ + cost: number; + /** Cost breakdown */ + costInput: number; + costOutput: number; + costCacheRead: number; + costCacheWrite: number; + /** Request count */ + requests: number; +} + +/** + * Overall dashboard stats. + */ +export interface DashboardStats { + overall: AggregatedStats; + byModel: ModelStats[]; + byFolder: FolderStats[]; + timeSeries: TimeSeriesPoint[]; + modelSeries: ModelTimeSeriesPoint[]; + modelPerformanceSeries: ModelPerformancePoint[]; + costSeries: CostTimeSeriesPoint[]; +} + +/** + * Behavior time-series point (daily bucket, per responding model). + */ +export interface BehaviorTimeSeriesPoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Responding model ("unknown" if user msg never got a reply) */ + model: string; + /** Responding provider */ + provider: string; + /** Number of user messages in bucket */ + messages: number; + /** Total yelling sentences in bucket */ + yelling: number; + /** Total profanity hits in bucket */ + profanity: number; + /** Total anguish signal in bucket */ + anguish: number; + /** Total corrective-negation hits in bucket */ + negation: number; + /** Total user-repeating-themselves hits in bucket */ + repetition: number; + /** Total second-person blame hits in bucket */ + blame: number; + /** Total characters in bucket */ + chars: number; +} + +export interface BehaviorOverallStats { + totalMessages: number; + totalYelling: number; + totalProfanity: number; + totalAnguish: number; + totalNegation: number; + totalRepetition: number; + totalBlame: number; + totalChars: number; + firstTimestamp: number; + lastTimestamp: number; +} + +/** + * Per-model behavioral aggregate over the active range. + */ +export interface BehaviorModelStats { + model: string; + provider: string; + totalMessages: number; + totalYelling: number; + totalProfanity: number; + totalAnguish: number; + totalNegation: number; + totalRepetition: number; + totalBlame: number; + totalChars: number; + lastTimestamp: number; +} + +export interface BehaviorDashboardStats { + overall: BehaviorOverallStats; + byModel: BehaviorModelStats[]; + behaviorSeries: BehaviorTimeSeriesPoint[]; +} diff --git a/packages/stats/src/types.ts b/packages/stats/src/types.ts index 1baf1d2ef..db425d664 100644 --- a/packages/stats/src/types.ts +++ b/packages/stats/src/types.ts @@ -1,5 +1,7 @@ import type { AssistantMessage, ServiceTier, StopReason, Usage } from "@oh-my-pi/pi-ai"; +export * from "./shared-types"; + /** * Extracted stats from an assistant message. */ @@ -36,142 +38,10 @@ export interface MessageStats { * Full details of a request, including content. */ export interface RequestDetails extends MessageStats { - messages: any[]; // The full conversation history or just the last turn - output: any; // The model's response -} - -/** - * Aggregated stats for a model or folder. - */ -export interface AggregatedStats { - /** Total number of requests */ - totalRequests: number; - /** Number of successful requests */ - successfulRequests: number; - /** Number of failed requests */ - failedRequests: number; - /** Error rate (0-1) */ - errorRate: number; - /** Total input tokens */ - totalInputTokens: number; - /** Total output tokens */ - totalOutputTokens: number; - /** Total cache read tokens */ - totalCacheReadTokens: number; - /** Total cache write tokens */ - totalCacheWriteTokens: number; - /** Cache hit rate (0-1) */ - cacheRate: number; - /** Total cost */ - totalCost: number; - /** Total premium requests */ - totalPremiumRequests: number; - /** Average duration in ms */ - avgDuration: number | null; - /** Average TTFT in ms */ - avgTtft: number | null; - /** Average tokens per second (output tokens / duration) */ - avgTokensPerSecond: number | null; - /** Time range */ - firstTimestamp: number; - lastTimestamp: number; -} - -/** - * Stats grouped by model. - */ -export interface ModelStats extends AggregatedStats { - model: string; - provider: string; -} - -/** - * Stats grouped by folder. - */ -export interface FolderStats extends AggregatedStats { - folder: string; -} - -/** - * Time series data point. - */ -export interface TimeSeriesPoint { - /** Bucket timestamp (start of hour/day) */ - timestamp: number; - /** Request count */ - requests: number; - /** Error count */ - errors: number; - /** Total tokens */ - tokens: number; - /** Total cost */ - cost: number; -} - -/** - * Model usage time series data point (daily buckets). - */ -export interface ModelTimeSeriesPoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Model name */ - model: string; - /** Provider name */ - provider: string; - /** Request count */ - requests: number; -} - -/** - * Model performance time series data point (daily buckets). - */ -export interface ModelPerformancePoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Model name */ - model: string; - /** Provider name */ - provider: string; - /** Request count */ - requests: number; - /** Average TTFT in ms */ - avgTtft: number | null; - /** Average tokens per second */ - avgTokensPerSecond: number | null; -} - -/** - * Cost time series data point (daily buckets). - */ -export interface CostTimeSeriesPoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Model name */ - model: string; - /** Provider name */ - provider: string; - /** Total cost for this bucket */ - cost: number; - /** Cost breakdown */ - costInput: number; - costOutput: number; - costCacheRead: number; - costCacheWrite: number; - /** Request count */ - requests: number; -} - -/** - * Overall dashboard stats. - */ -export interface DashboardStats { - overall: AggregatedStats; - byModel: ModelStats[]; - byFolder: FolderStats[]; - timeSeries: TimeSeriesPoint[]; - modelSeries: ModelTimeSeriesPoint[]; - modelPerformanceSeries: ModelPerformancePoint[]; - costSeries: CostTimeSeriesPoint[]; + /** The full conversation history or just the last turn. */ + messages: unknown[]; + /** The model's response. */ + output: unknown; } /** @@ -253,66 +123,3 @@ export interface UserMessageLink { model: string; provider: string; } - -/** - * Behavior time-series point (daily bucket, per responding model). - */ -export interface BehaviorTimeSeriesPoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Responding model ("unknown" if user msg never got a reply) */ - model: string; - /** Responding provider */ - provider: string; - /** Number of user messages in bucket */ - messages: number; - /** Total yelling sentences in bucket */ - yelling: number; - /** Total profanity hits in bucket */ - profanity: number; - /** Total anguish signal in bucket */ - anguish: number; - /** Total corrective-negation hits in bucket */ - negation: number; - /** Total user-repeating-themselves hits in bucket */ - repetition: number; - /** Total second-person blame hits in bucket */ - blame: number; - /** Total characters in bucket */ - chars: number; -} - -export interface BehaviorOverallStats { - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - firstTimestamp: number; - lastTimestamp: number; -} - -/** - * Per-model behavioral aggregate over the active range. - */ -export interface BehaviorModelStats { - model: string; - provider: string; - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - lastTimestamp: number; -} -export interface BehaviorDashboardStats { - overall: BehaviorOverallStats; - byModel: BehaviorModelStats[]; - behaviorSeries: BehaviorTimeSeriesPoint[]; -} diff --git a/packages/tui/bench/_jskey.ts b/packages/tui/bench/_jskey.ts index 2905c8c95..87fb183ef 100644 --- a/packages/tui/bench/_jskey.ts +++ b/packages/tui/bench/_jskey.ts @@ -22,21 +22,21 @@ // Global Kitty Protocol State // ============================================================================= -let _kittyProtocolActive = false; +let kittyProtocolActive = false; /** * Set the global Kitty keyboard protocol state. * Called by ProcessTerminal after detecting protocol support. */ -export function setKittyProtocolActive(active: boolean): void { - _kittyProtocolActive = active; +function setKittyProtocolActive(active: boolean): void { + kittyProtocolActive = active; } /** * Query whether Kitty keyboard protocol is currently active. */ -export function isKittyProtocolActive(): boolean { - return _kittyProtocolActive; +function isKittyProtocolActive(): boolean { + return kittyProtocolActive; } // ============================================================================= @@ -169,7 +169,7 @@ export type KeyId = * - Key.ctrl("c"), Key.alt("x") for single modifier * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers */ -export const Key = { +const Key = { // Special keys escape: "escape" as const, esc: "esc" as const, @@ -488,13 +488,13 @@ interface ParsedKittySequence { } // Store the last parsed event type for isKeyRelease() to query -let _lastEventType: KeyEventType = "press"; +let lastEventType: KeyEventType = "press"; /** * Check if the last parsed key event was a key release. * Only meaningful when Kitty keyboard protocol with flag 2 is active. */ -export function isKeyRelease(data: string): boolean { +function isKeyRelease(data: string): boolean { // Don't treat bracketed paste content as key release, even if it contains // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). // Terminal.ts re-wraps paste content with bracketed paste markers before @@ -524,7 +524,7 @@ export function isKeyRelease(data: string): boolean { * Check if the last parsed key event was a key repeat. * Only meaningful when Kitty keyboard protocol with flag 2 is active. */ -export function isKeyRepeat(data: string): boolean { +function isKeyRepeat(data: string): boolean { // Don't treat bracketed paste content as key repeat, even if it contains // patterns like ":2F". See isKeyRelease() for details. if (data.includes("\x1b[200~")) { @@ -554,7 +554,7 @@ function parseEventType(eventTypeStr: string | undefined): KeyEventType { return "press"; } -export function parseKittySequence(data: string): ParsedKittySequence | null { +function parseKittySequence(data: string): ParsedKittySequence | null { // CSI u format with alternate keys (flag 4): // \x1b[u // \x1b[;u @@ -572,7 +572,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; const eventType = parseEventType(csiUMatch[5]); - _lastEventType = eventType; + lastEventType = eventType; return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; } @@ -582,7 +582,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { const modValue = parseInt(arrowMatch[1]!, 10); const eventType = parseEventType(arrowMatch[2]); const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - _lastEventType = eventType; + lastEventType = eventType; return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; } @@ -602,7 +602,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { - _lastEventType = eventType; + lastEventType = eventType; return { codepoint, modifier: modValue - 1, eventType }; } } @@ -613,7 +613,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { const modValue = parseInt(homeEndMatch[1]!, 10); const eventType = parseEventType(homeEndMatch[2]); const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; - _lastEventType = eventType; + lastEventType = eventType; return { codepoint, modifier: modValue - 1, eventType }; } @@ -702,7 +702,7 @@ function parseKeyId(keyId: string): ParsedKeyId | null { * @param data - Raw input data from terminal * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ -export function matchesKey(data: string, keyId: KeyId): boolean { +function matchesKey(data: string, keyId: KeyId): boolean { const parsed = parseKeyId(keyId); if (!parsed) return false; @@ -719,7 +719,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0); case "space": - if (!_kittyProtocolActive) { + if (!kittyProtocolActive) { if (ctrl && !alt && !shift && data === "\x00") { return true; } @@ -758,7 +758,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { // When Kitty protocol is active, legacy sequences are custom terminal mappings // \x1b\r = Kitty's "map shift+enter send_text all \e\r" // \n = Ghostty's "keybind = shift+enter=text:\n" - if (_kittyProtocolActive) { + if (kittyProtocolActive) { return data === "\x1b\r" || data === "\n"; } return false; @@ -777,7 +777,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) // When Kitty protocol is active, alt+enter comes as CSI u sequence - if (!_kittyProtocolActive) { + if (!kittyProtocolActive) { return data === "\x1b\r"; } return false; @@ -785,7 +785,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (modifier === 0) { return ( data === "\r" || - (!_kittyProtocolActive && data === "\n") || + (!kittyProtocolActive && data === "\n") || data === "\x1bOM" || // SS3 M (numpad enter in some terminals) matchesKittySequence(data, CODEPOINTS.enter, 0) || matchesKittySequence(data, CODEPOINTS.kpEnter, 0) @@ -923,7 +923,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (alt && !ctrl && !shift) { return ( data === "\x1b[1;3D" || - (!_kittyProtocolActive && data === "\x1bB") || + (!kittyProtocolActive && data === "\x1bB") || data === "\x1bb" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) ); @@ -950,7 +950,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (alt && !ctrl && !shift) { return ( data === "\x1b[1;3C" || - (!_kittyProtocolActive && data === "\x1bF") || + (!kittyProtocolActive && data === "\x1bF") || data === "\x1bf" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) ); @@ -998,11 +998,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean { const codepoint = key.charCodeAt(0); const isLetterKey = key >= "a" && key <= "z"; - if (ctrl && alt && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { + if (ctrl && alt && !shift && !kittyProtocolActive && key >= "a" && key <= "z") { return data === `\x1b${rawCtrlChar(key)}`; } - if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { + if (alt && !ctrl && !shift && !kittyProtocolActive && key >= "a" && key <= "z") { // Legacy: alt+letter is ESC followed by the letter if (data === `\x1b${key}`) return true; } @@ -1048,7 +1048,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { * @param data - Raw input data from terminal * @returns Key identifier string (e.g., "ctrl+c") or undefined */ -export function parseKey(data: string): string | undefined { +function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { const { codepoint, baseLayoutKey, modifier } = kitty; @@ -1091,7 +1091,7 @@ export function parseKey(data: string): string | undefined { // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: // - \x1b\r = shift+enter (Kitty mapping), not alt+enter // - \n = shift+enter (Ghostty mapping) - if (_kittyProtocolActive) { + if (kittyProtocolActive) { if (data === "\x1b\r" || data === "\n") return "shift+enter"; } @@ -1101,17 +1101,17 @@ export function parseKey(data: string): string | undefined { // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) if (data === "\x1b") return "escape"; if (data === "\t") return "tab"; - if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; + if (data === "\r" || (!kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; if (data === "\x00") return "ctrl+space"; if (data === " ") return "space"; if (data === "\x7f" || data === "\x08") return "backspace"; if (data === "\x1b[Z") return "shift+tab"; - if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; - if (!_kittyProtocolActive && data === "\x1b ") return "alt+space"; + if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter"; + if (!kittyProtocolActive && data === "\x1b ") return "alt+space"; if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace"; - if (!_kittyProtocolActive && data === "\x1bB") return "alt+left"; - if (!_kittyProtocolActive && data === "\x1bF") return "alt+right"; - if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { + if (!kittyProtocolActive && data === "\x1bB") return "alt+left"; + if (!kittyProtocolActive && data === "\x1bF") return "alt+right"; + if (!kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { const code = data.charCodeAt(1); if (code >= 1 && code <= 26) { return `ctrl+alt+${String.fromCharCode(code + 96)}`; diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 63e0bb309..02ff82f5c 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -48,13 +48,13 @@ export function clearRenderCache(): void { // Stable numeric IDs for structural theme/style objects (no ID field on type). // WeakMap so GC can collect orphaned themes/styles without a leak. -const _objectIds = new WeakMap(); -let _nextObjectId = 0; +const objectIds = new WeakMap(); +let nextObjectId = 0; function objectId(o: object): number { - let id = _objectIds.get(o); + let id = objectIds.get(o); if (id === undefined) { - id = _nextObjectId++; - _objectIds.set(o, id); + id = nextObjectId++; + objectIds.set(o, id); } return id; } diff --git a/packages/tui/test/test-themes.ts b/packages/tui/test/test-themes.ts index f6875b95c..bdc58c1ca 100644 --- a/packages/tui/test/test-themes.ts +++ b/packages/tui/test/test-themes.ts @@ -48,7 +48,7 @@ const defaultSymbols: SymbolTheme = { spinnerFrames: ["-", "\\", "|", "/"], }; -export const defaultSelectListTheme: SelectListTheme = { +const defaultSelectListTheme: SelectListTheme = { selectedPrefix: (text: string) => chalk.blue(text), selectedText: (text: string) => chalk.bold(text), description: (text: string) => chalk.dim(text), diff --git a/packages/typescript-edit-benchmark/src/formatter.ts b/packages/typescript-edit-benchmark/src/formatter.ts index ed9b3d88d..dd4c41334 100644 --- a/packages/typescript-edit-benchmark/src/formatter.ts +++ b/packages/typescript-edit-benchmark/src/formatter.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import * as prettier from "prettier"; import { listFiles } from "./shared"; -export const PRETTIER_OPTIONS: prettier.Options = { +const PRETTIER_OPTIONS: prettier.Options = { printWidth: 100, tabWidth: 2, useTabs: false, diff --git a/packages/typescript-edit-benchmark/src/runner.ts b/packages/typescript-edit-benchmark/src/runner.ts index 7cd0d0b76..06dd86af5 100644 --- a/packages/typescript-edit-benchmark/src/runner.ts +++ b/packages/typescript-edit-benchmark/src/runner.ts @@ -172,23 +172,6 @@ async function snapshotConversationDump(client: BenchmarkClient): Promise { - const dumpDir = params.config.conversationDumpDir; - if (!dumpDir) return undefined; - const snapshot = await snapshotConversationDump(params.client); - return writeConversationDump({ - dumpDir, - taskId: params.taskId, - runIndex: params.runIndex, - snapshot, - }); -} - function splitLines(value: string): string[] { return value.split("\n").filter((line, idx, arr) => idx < arr.length - 1 || line); } @@ -1349,325 +1332,6 @@ async function runSingleTask( }; } -async function _runRpcBenchmarkRun( - item: TaskRunItem, - config: BenchmarkConfig, - cwd: string, - expectedDir: string, - sessionDir: string, - client: BenchmarkClient, -): Promise { - const startTime = Date.now(); - const task = item.task; - const runIndex = item.runIndex; - let error: string | undefined; - let patchApplied = false; - let verificationPassed = false; - let indentScore: number | undefined; - let formattedEquivalent: boolean | undefined; - let diffStats: { linesChanged: number; charsChanged: number } | undefined; - let tokens: TokenStats = { input: 0, output: 0, total: 0 }; - let agentResponse: string | undefined; - let diff: string | undefined; - const editFailures: EditFailure[] = []; - const editWarnings: string[] = []; - let editAutocorrectCount = 0; - let timeoutTelemetry: PromptAttemptTelemetry | undefined; - let mutationIntentValidation: MutationIntentValidation | null = null; - const toolStats = { - read: 0, - edit: 0, - write: 0, - editSuccesses: 0, - editFailures: 0, - editWarnings: 0, - editAutocorrects: 0, - totalInputChars: 0, - }; - const hashlineSubtypes: Record = Object.fromEntries(HL_SUBTYPES.map(k => [k, 0])); - - const logFile = path.join(sessionDir, `run-${task.id}-${runIndex}.jsonl`); - const logEvent = async (event: unknown) => { - await fs.promises.appendFile(logFile, `${JSON.stringify(event)}\n`); - }; - const originalFiles = await collectOriginalFileContents(cwd, task.files); - let timeoutRetriesUsed = 0; - let zeroToolRetries = 0; - let providerFailureRetries = 0; - - try { - await fs.promises.appendFile( - logFile, - `{"type":"meta","task":"${task.id}","run":${runIndex},"workDir":"${cwd}"}\n`, - ); - - const maxAttempts = Math.max(1, Math.floor(config.maxAttempts ?? 1)); - const maxTimeoutRetries = config.maxTimeoutRetries ?? 3; - const noOpRetryLimit = config.noOpRetryLimit ?? 2; - const maxProviderFailureRetries = config.maxProviderFailureRetries ?? 3; - let retryContext: string | null = null; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const guidedContext = await buildGuidedContext(task, cwd, expectedDir, config); - const delivery = buildBenchmarkPromptDelivery({ - taskPrompt: task.prompt, - guidedContext, - retryContext, - }); - - await fs.promises.appendFile( - logFile, - `{"type":"prompt","attempt":${attempt + 1},"delivery":${JSON.stringify(delivery.kind)},"message":${JSON.stringify(delivery.message)}}\n`, - ); - - const statsBefore = await client.getSessionStats(); - let events: Array<{ type: string; [key: string]: unknown }>; - try { - events = await collectPromptEvents(client, delivery, config, logEvent); - } catch (err) { - if (err instanceof PromptTurnLimitError) { - error = err.message; - await logEvent({ type: "turn_limit_exceeded", attempt: attempt + 1, telemetry: err.telemetry }); - break; - } - if (err instanceof PromptTimeoutError) { - timeoutTelemetry = err.telemetry; - await logEvent({ type: "timeout", attempt: attempt + 1, telemetry: err.telemetry }); - timeoutRetriesUsed += 1; - retryContext = buildTimeoutRetryContext(err.telemetry, timeoutRetriesUsed, maxTimeoutRetries); - if (timeoutRetriesUsed >= maxTimeoutRetries) { - error = `Timeout exhausted after ${maxTimeoutRetries} retries (last: ${err.telemetry.elapsedMs}ms, events=${err.telemetry.eventCount}, last_event=${err.telemetry.lastEventType ?? "none"})`; - await logEvent({ - type: "timeout_exhausted", - retriesUsed: timeoutRetriesUsed, - telemetry: err.telemetry, - }); - break; - } - attempt--; // Don't consume a regular attempt slot for timeout retries - continue; - } - throw err; - } - const statsAfter = await client.getSessionStats(); - const attemptTokens = diffTokenStats(statsBefore, statsAfter, 0); - tokens = { - input: tokens.input + attemptTokens.input, - output: tokens.output + attemptTokens.output, - total: tokens.total + attemptTokens.total, - }; - await logEvent({ type: "stats", before: statsBefore, after: statsAfter, attempt: attempt + 1 }); - - agentResponse = (await client.getLastAssistantText()) ?? undefined; - await logEvent({ type: "response", text: agentResponse, attempt: attempt + 1 }); - - const providerFailure = detectProviderFailure(events); - const hasMutationToolCall = events.some( - event => - event.type === "tool_execution_start" && isMutationTool((event as { toolName?: unknown }).toolName), - ); - if (providerFailure && !hasMutationToolCall) { - await logEvent({ - type: "provider_failure", - attempt: attempt + 1, - kind: providerFailure.kind, - error: providerFailure.message, - }); - if (providerFailureRetries < maxProviderFailureRetries) { - providerFailureRetries += 1; - const delayMs = getProviderFailureRetryDelayMs(providerFailureRetries); - await logEvent({ - type: "provider_failure_retry", - attempt: attempt + 1, - retryNumber: providerFailureRetries, - retryLimit: maxProviderFailureRetries, - delayMs, - kind: providerFailure.kind, - }); - retryContext = buildProviderFailureRetryContext( - providerFailure, - providerFailureRetries, - maxProviderFailureRetries, - delayMs, - ); - await Bun.sleep(delayMs); - attempt--; // Don't consume a regular attempt slot for provider/auth retries - continue; - } - error = `Provider ${providerFailure.kind} failure: ${providerFailure.message}`; - await logEvent({ - type: "provider_failure_exhausted", - attempt: attempt + 1, - retriesUsed: providerFailureRetries, - kind: providerFailure.kind, - error: providerFailure.message, - }); - break; - } - const pendingEdits = new Map(); - for (const event of events) { - if (event.type === "tool_execution_start") { - const e = event as { toolName?: string; toolCallId?: string; args?: unknown }; - const toolName = e.toolName; - if (toolName === "read") { - toolStats.read++; - } else if (isEditTool(toolName)) { - toolStats.edit++; - if (e.toolCallId) pendingEdits.set(e.toolCallId, e.args); - } else if (toolName === "write") { - toolStats.write++; - } - - if (e.args) { - toolStats.totalInputChars += JSON.stringify(e.args).length; - } - } else if (event.type === "tool_execution_end") { - const e = event as { toolName?: string; toolCallId?: string; isError?: boolean; result?: unknown }; - if (isEditTool(e.toolName) && e.toolCallId && pendingEdits.has(e.toolCallId)) { - const args = pendingEdits.get(e.toolCallId) ?? null; - pendingEdits.delete(e.toolCallId); - if (config.editVariant === "hashline" && args) { - const counts = countHashlineEditSubtypes(args); - for (const key of HL_SUBTYPES) { - hashlineSubtypes[key] += counts[key]; - } - } - if (e.isError) { - toolStats.editFailures++; - const toolError = await appendNoChangeMutationHint( - extractToolErrorMessage(e.result), - args, - cwd, - originalFiles, - ); - editFailures.push({ - toolCallId: e.toolCallId, - args, - error: toolError, - category: categorizeEditFailure(toolError, args), - }); - } else { - toolStats.editSuccesses++; - if (e.toolName === "edit") { - const warningMessages = extractHashlineWarnings(e.result); - if (warningMessages.length > 0) { - editWarnings.push(...warningMessages); - toolStats.editWarnings += warningMessages.length; - if (hasHashlineAutocorrectWarning(warningMessages)) { - editAutocorrectCount++; - toolStats.editAutocorrects++; - } - } - } - } - } - } - } - - // Retry if the model didn't attempt any edit/write (read-only or no tool calls) - const madeEditAttempt = toolStats.edit > 0 || toolStats.write > 0; - if (!madeEditAttempt && zeroToolRetries < noOpRetryLimit) { - zeroToolRetries++; - await logEvent({ type: "zero_tool_retry", attempt: attempt + 1, retryNumber: zeroToolRetries }); - retryContext = `Previous attempt read files but made no edit attempt — you must use the edit or vim tool to apply the fix. Retry ${zeroToolRetries}/${noOpRetryLimit}.`; - attempt--; // Don't consume a regular attempt slot - continue; - } - - patchApplied = toolStats.edit > 0; - - const filesToVerify = task.files.length > 0 ? task.files : undefined; - const verification = await verifyExpectedFileSubset(expectedDir, cwd, filesToVerify); - if (config.autoFormat) { - await formatDirectory(cwd); - } - - verificationPassed = verification.success; - indentScore = verification.indentScore; - formattedEquivalent = verification.formattedEquivalent; - diffStats = verification.diffStats; - diff = verification.diff; - mutationIntentValidation = await evaluateMutationIntent(task, cwd, expectedDir); - if (!verification.success && verification.error) { - error = verification.error; - } - - if (verification.success) { - break; - } - - const mutationIntentSuffix = mutationIntentValidation - ? `\n\nMutation intent: ${mutationIntentValidation.matched ? "matched" : "not matched"} (${mutationIntentValidation.reason})` - : ""; - retryContext = error - ? `Verification failed: ${error}${diff ? `\n\nDiff (expected vs actual):\n\n\`\`\`diff\n${diff}\n\`\`\`` : ""}${mutationIntentSuffix}` - : `Previous attempt failed.${mutationIntentSuffix}`; - } - } catch (err) { - error = err instanceof Error ? err.message : String(err); - await logEvent({ type: "error", error }); - } - - const duration = Date.now() - startTime; - const mustUseEditTool = Boolean(config.requireEditToolCall) && !config.noEditRequired; - const mustUseReadTool = Boolean(config.requireReadToolCall) && !config.noEditRequired; - const editSucceeded = toolStats.editSuccesses > 0; - const success = - verificationPassed && (!mustUseEditTool || editSucceeded) && (!mustUseReadTool || toolStats.read > 0); - const metadata = task.metadata; - - await logEvent({ - type: "result", - success, - patchApplied, - verificationPassed, - error, - duration, - timeoutTelemetry, - mutationIntentValidation, - }); - console.log(` Log: ${formatLogPath(logFile)}`); - - await persistConversationDump({ - client, - config, - taskId: task.id, - runIndex, - }); - - return { - runIndex, - success, - patchApplied, - verificationPassed, - seed: metadata?.seed, - mutationType: metadata?.mutationType, - mutationCategory: metadata?.mutationCategory, - difficultyScore: metadata?.difficultyScore, - error, - tokens, - duration, - indentScore, - formattedEquivalent, - diffStats, - agentResponse, - diff, - toolCalls: toolStats, - editFailures, - editWarnings, - editAutocorrectCount, - hashlineEditSubtypes: config.editVariant === "hashline" ? hashlineSubtypes : undefined, - mutationIntentMatched: mutationIntentValidation?.matched, - mutationIntentReason: mutationIntentValidation?.reason, - timeoutTelemetry, - retryStats: { - timeoutRetries: timeoutRetriesUsed, - zeroToolRetries, - providerFailureRetries, - }, - }; -} - function extractToolText(result: unknown): string | null { if (typeof result === "string") return result; if (!result || typeof result !== "object") return null; @@ -1967,7 +1631,6 @@ function isGhostRun(r: TaskRunResult): boolean { function summarizeTaskRuns(task: EditTask, runs: TaskRunResult[]): TaskResult { const orderedRuns = runs.slice().sort((a, b) => a.runIndex - b.runIndex); - const _n = orderedRuns.length; const nonGhostRuns = orderedRuns.filter(r => !isGhostRun(r)); const effective = nonGhostRuns.length; const successfulRuns = orderedRuns.filter(r => r.success).length; @@ -2080,33 +1743,6 @@ async function runConcurrentBenchmarkRun( } } -export async function runTask( - task: EditTask, - config: BenchmarkConfig, - onProgress?: (event: ProgressEvent) => void, -): Promise { - const useInProcess = config.inProcess !== false; - const shared = useInProcess - ? await discoverSharedInfra({ - editVariant: config.editVariant, - editFuzzy: config.editFuzzy, - editFuzzyThreshold: config.editFuzzyThreshold, - }) - : undefined; - - const runPromises = Array.from({ length: config.runsPerTask }, async (_, index) => { - const tempDir = subtmp(task.id); - await copyFixtures(task, tempDir); - onProgress?.({ taskId: task.id, runIndex: index, status: "started" }); - const result = await runSingleTask(task, index, config, tempDir, task.expectedDir, shared); - onProgress?.({ taskId: task.id, runIndex: index, status: "completed", result }); - return result; - }); - - const runs = await Promise.all(runPromises); - return summarizeTaskRuns(task, runs); -} - export function buildBenchmarkResult(params: { tasks: EditTask[]; config: BenchmarkConfig; diff --git a/packages/typescript-edit-benchmark/src/tasks.ts b/packages/typescript-edit-benchmark/src/tasks.ts index 7dad25ed8..5656ad9bf 100644 --- a/packages/typescript-edit-benchmark/src/tasks.ts +++ b/packages/typescript-edit-benchmark/src/tasks.ts @@ -32,8 +32,6 @@ export interface TaskMetadata { mutatedSnippet?: string; } -export const DEFAULT_TARBALL_PATH = path.join(import.meta.dir, "../fixtures.tar.gz"); - function titleize(id: string): string { return id .split(/[-_]/) From 1ea87b26bcce9454b58dc48d6c1670747cbdc3cb Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 14 May 2026 04:38:14 +0200 Subject: [PATCH 17/17] feat(coding-agent): added hidden skill flag for system prompt filtering - Added optional `hide` metadata to skill capabilities so `SKILL.md` frontmatter intent is preserved when skills are loaded. - Filtered system prompt skill rendering to exclude `hide: true` skills from the `` listing while keeping them loadable. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/capability/skill.ts | 7 +++++++ packages/coding-agent/src/extensibility/skills.ts | 9 +++++++++ packages/coding-agent/src/system-prompt.ts | 6 ++++-- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 21e3ba775..5a3e5ae8c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `hide: true` frontmatter option for skill `SKILL.md` files. Hidden skills are still loaded and remain reachable via `skill://` URLs and (when enabled) `/skill:` slash commands, but are omitted from the rendered system prompt's `` listing so the model won't auto-discover them. Use for skills the user opts into explicitly rather than ones the model should pick up from descriptions. - Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`. - Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable. - Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**. diff --git a/packages/coding-agent/src/capability/skill.ts b/packages/coding-agent/src/capability/skill.ts index 1ac784bba..08d93472a 100644 --- a/packages/coding-agent/src/capability/skill.ts +++ b/packages/coding-agent/src/capability/skill.ts @@ -14,6 +14,13 @@ export interface SkillFrontmatter { description?: string; globs?: string[]; alwaysApply?: boolean; + /** + * When `true`, the skill is loaded and accessible via `skill://` (and + * `/skill:` slash commands), but is omitted from the rendered system + * prompt's skill listing. Use for skills the user opts into explicitly + * rather than ones the model should auto-discover. + */ + hide?: boolean; [key: string]: unknown; } diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index 06c9e7e13..6db5a72fc 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -14,6 +14,12 @@ export interface Skill { filePath: string; baseDir: string; source: string; + /** + * When `true`, the skill is loaded and reachable via `skill://` and + * (when enabled) `/skill:`, but is excluded from the rendered system + * prompt's `` listing. + */ + hide?: boolean; /** Source metadata for display */ _source?: SourceMeta; } @@ -76,6 +82,7 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom filePath: capSkill.path, baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""), source: options.source, + hide: capSkill.frontmatter?.hide === true, _source: capSkill._source, })), warnings: (result.warnings ?? []).map(message => ({ skillPath: options.dir, message })), @@ -190,6 +197,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise). const hasRead = tools?.has("read"); - const filteredSkills = hasRead ? skills : []; + const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : []; const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [ resolvedCustomPrompt,