diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 674795a1d..436e0692b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Enhanced error messages to include retry-after timing information from API rate limit headers ## [3.34.0] - 2026-01-09 @@ -314,4 +317,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. +Initial release with multi-provider LLM support. \ No newline at end of file diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 99080abc3..a8090cef6 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -24,6 +24,7 @@ import type { } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { parseStreamingJson } from "../utils/json-parse"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { transformMessages } from "./transorm-messages"; @@ -279,7 +280,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } catch (error) { for (const block of output.content) delete (block as any).index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatErrorMessageWithRetryAfter(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 7af5d1676..936f8761c 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -18,6 +18,7 @@ import type { ToolCall, } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { convertMessages, @@ -638,7 +639,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatErrorMessageWithRetryAfter(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 5879a95b9..cfac426d1 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -18,6 +18,7 @@ import type { ToolCall, } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import type { GoogleThinkingLevel } from "./google-gemini-cli"; import { @@ -262,7 +263,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( } } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatErrorMessageWithRetryAfter(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 1303dbc93..bf3c260e8 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -18,6 +18,7 @@ import type { ToolCall, } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import type { GoogleThinkingLevel } from "./google-gemini-cli"; import { @@ -250,7 +251,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( } } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatErrorMessageWithRetryAfter(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index b625436c3..3aee7e96c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -24,6 +24,7 @@ import type { } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { parseStreamingJson } from "../utils/json-parse"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { CODEX_BASE_URL, @@ -151,7 +152,9 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" if (!response.ok) { const info = await parseCodexError(response); - throw new Error(info.friendlyMessage || info.message); + const error = new Error(info.friendlyMessage || info.message); + (error as { headers?: Headers }).headers = response.headers; + throw error; } if (!response.body) { @@ -362,7 +365,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } catch (error) { for (const block of output.content) delete (block as { index?: number }).index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatErrorMessageWithRetryAfter(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 9f1795440..42dd95674 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -26,6 +26,7 @@ import type { } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { parseStreamingJson } from "../utils/json-parse"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { transformMessages } from "./transorm-messages"; @@ -306,7 +307,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( } catch (error) { for (const block of output.content) delete (block as any).index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatErrorMessageWithRetryAfter(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 462ddfaad..b9461f8c0 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -27,6 +27,7 @@ import type { } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { parseStreamingJson } from "../utils/json-parse"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { transformMessages } from "./transorm-messages"; @@ -303,7 +304,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( } catch (error) { for (const block of output.content) delete (block as any).index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatErrorMessageWithRetryAfter(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/utils/retry-after.ts b/packages/ai/src/utils/retry-after.ts new file mode 100644 index 000000000..86bdac6c8 --- /dev/null +++ b/packages/ai/src/utils/retry-after.ts @@ -0,0 +1,110 @@ +export type HeadersLike = Headers | Record | undefined | null; + +const RETRY_AFTER_HINT = "retry-after-ms="; + +export function formatErrorMessageWithRetryAfter(error: unknown, headers?: HeadersLike): string { + const message = error instanceof Error ? error.message : JSON.stringify(error); + if (message.includes(RETRY_AFTER_HINT)) { + return message; + } + + const retryAfterMs = getRetryAfterMsFromHeaders(headers ?? getHeadersFromError(error)); + if (retryAfterMs === undefined) { + return message; + } + + return `${message} ${RETRY_AFTER_HINT}${retryAfterMs}`; +} + +export function getRetryAfterMsFromHeaders(headers: HeadersLike): number | undefined { + if (!headers) return undefined; + + const retryAfter = parseRetryAfterHeader(getHeaderValue(headers, "retry-after")); + const resetMs = parseResetHeader(getHeaderValue(headers, "x-ratelimit-reset-ms"), "ms"); + const resetSeconds = parseResetHeader(getHeaderValue(headers, "x-ratelimit-reset"), "s"); + + const candidates = [retryAfter, resetMs, resetSeconds].filter((value): value is number => value !== undefined); + if (candidates.length === 0) return undefined; + return Math.max(...candidates); +} + +function getHeadersFromError(error: unknown): HeadersLike { + if (!error || typeof error !== "object") return undefined; + const record = error as { headers?: unknown; response?: { headers?: unknown }; cause?: unknown }; + const direct = extractHeaders(record.headers) ?? extractHeaders(record.response?.headers); + if (direct) return direct; + if (record.cause) return getHeadersFromError(record.cause); + return undefined; +} + +function extractHeaders(value: unknown): HeadersLike { + if (!value) return undefined; + if (value instanceof Headers) return value; + if (typeof value === "object") return value as Record; + return undefined; +} + +function getHeaderValue(headers: Headers | Record, name: string): string | undefined { + if (headers instanceof Headers) { + const value = headers.get(name); + return value ?? undefined; + } + + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target && typeof value === "string") { + return value; + } + } + return undefined; +} + +function parseRetryAfterHeader(value: string | undefined): number | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + if (numeric <= 0) return undefined; + return Math.ceil(numeric * 1000); + } + + const dateMs = Date.parse(trimmed); + if (!Number.isNaN(dateMs)) { + const delay = dateMs - Date.now(); + return delay > 0 ? Math.ceil(delay) : undefined; + } + + return undefined; +} + +function parseResetHeader(value: string | undefined, unit: "ms" | "s"): number | undefined { + if (!value) return undefined; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return undefined; + + const nowMs = Date.now(); + let targetMs: number | undefined; + + if (unit === "ms") { + if (numeric > 1e12) { + targetMs = numeric; + } else if (numeric > 1e9) { + targetMs = numeric * 1000; + } else { + return Math.ceil(numeric); + } + } else { + if (numeric > 1e12) { + targetMs = numeric; + } else if (numeric > 1e9) { + targetMs = numeric * 1000; + } else { + return Math.ceil(numeric * 1000); + } + } + + if (targetMs <= nowMs) return undefined; + return Math.ceil(targetMs - nowMs); +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b6e02711f..bcf5ec874 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,28 @@ # Changelog ## [Unreleased] +### Added + +- Added retry logic with exponential backoff for auto-compaction failures +- Added fallback to alternative models when auto-compaction fails with the primary model +- Added support for `pi/` model aliases in task tool (e.g., `pi/slow`, `pi/default`) +- Added visual cycle indicator when switching between role models showing available roles +- Added automatic model inheritance for subtasks when parent uses default model +- Added `--` separator in grep tool to prevent pattern interpretation as flags + +### Changed + +- Changed role model cycling to remember last selected role instead of matching current model +- Changed edit tool to merge call and result displays into single block +- Changed model override behavior to persist in settings when explicitly set via CLI + +### Fixed + +- Fixed retry-after parsing from error messages supporting multiple header formats (retry-after, retry-after-ms, x-ratelimit-reset) +- Fixed image attachments being dropped when steering/follow-up messages are queued during streaming +- Fixed image auto-resize not applying to clipboard images before sending +- Fixed clipboard image attachments being dropped when steering/follow-up messages are queued while streaming +- Fixed clipboard image attachments ignoring the auto-resize setting before sending ## [3.34.0] - 2026-01-09 @@ -1954,4 +1976,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 6ea4267d1..a63ef72e0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -459,7 +459,10 @@ export class AgentSession { const content = message.content; if (typeof content === "string") return content; const textBlocks = content.filter((c) => c.type === "text"); - return textBlocks.map((c) => (c as TextContent).text).join(""); + const text = textBlocks.map((c) => (c as TextContent).text).join(""); + if (text.length > 0) return text; + const hasImages = content.some((c) => c.type === "image"); + return hasImages ? "[Image]" : ""; } /** Find the last assistant message in agent state (including aborted ones) */ @@ -722,9 +725,9 @@ export class AgentSession { ); } if (options.streamingBehavior === "followUp") { - await this._queueFollowUp(expandedText); + await this._queueFollowUp(expandedText, options?.images); } else { - await this._queueSteer(expandedText); + await this._queueSteer(expandedText, options?.images); } return; } @@ -953,11 +956,16 @@ export class AgentSession { /** * Internal: Queue a steering message (already expanded, no extension command check). */ - private async _queueSteer(text: string): Promise { - this._steeringMessages.push(text); + private async _queueSteer(text: string, images?: ImageContent[]): Promise { + const displayText = text || (images && images.length > 0 ? "[Image]" : ""); + this._steeringMessages.push(displayText); + const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; + if (images && images.length > 0) { + content.push(...images); + } this.agent.steer({ role: "user", - content: [{ type: "text", text }], + content, timestamp: Date.now(), }); } @@ -965,11 +973,16 @@ export class AgentSession { /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ - private async _queueFollowUp(text: string): Promise { - this._followUpMessages.push(text); + private async _queueFollowUp(text: string, images?: ImageContent[]): Promise { + const displayText = text || (images && images.length > 0 ? "[Image]" : ""); + this._followUpMessages.push(displayText); + const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; + if (images && images.length > 0) { + content.push(...images); + } this.agent.followUp({ role: "user", - content: [{ type: "text", text }], + content, timestamp: Date.now(), }); } @@ -1175,7 +1188,7 @@ export class AgentSession { /** * Cycle through configured role models in a fixed order. - * Skips missing roles and deduplicates models. + * Skips missing roles. * @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"]) * @param options - Optional settings: `temporary` to not persist to settings */ @@ -1189,7 +1202,6 @@ export class AgentSession { const currentModel = this.model; if (!currentModel) return undefined; const roleModels: Array<{ role: string; model: Model }> = []; - const seen = new Set(); for (const role of roleOrder) { const roleModelStr = @@ -1208,15 +1220,15 @@ export class AgentSession { } if (!match) continue; - const key = `${match.provider}/${match.id}`; - if (seen.has(key)) continue; - seen.add(key); roleModels.push({ role, model: match }); } if (roleModels.length <= 1) return undefined; - let currentIndex = roleModels.findIndex((entry) => modelsAreEqual(entry.model, currentModel)); + const lastRole = this.sessionManager.getLastModelChangeRole(); + let currentIndex = lastRole + ? roleModels.findIndex((entry) => entry.role === lastRole) + : roleModels.findIndex((entry) => modelsAreEqual(entry.model, currentModel)); if (currentIndex === -1) currentIndex = 0; const nextIndex = (currentIndex + 1) % roleModels.length; @@ -1558,6 +1570,60 @@ export class AgentSession { } } + private _getModelKey(model: Model): string { + return `${model.provider}/${model.id}`; + } + + private _resolveRoleModel( + role: string, + availableModels: Model[], + currentModel: Model | undefined, + ): Model | undefined { + const roleModelStr = + role === "default" + ? (this.settingsManager.getModelRole("default") ?? + (currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined)) + : this.settingsManager.getModelRole(role); + + if (!roleModelStr) return undefined; + + const parsed = parseModelString(roleModelStr); + if (parsed) { + return availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id); + } + const roleLower = roleModelStr.toLowerCase(); + return availableModels.find((m) => m.id.toLowerCase() === roleLower); + } + + private _getCompactionModelCandidates(availableModels: Model[]): Model[] { + const candidates: Model[] = []; + const seen = new Set(); + + const addCandidate = (model: Model | undefined): void => { + if (!model) return; + const key = this._getModelKey(model); + if (seen.has(key)) return; + seen.add(key); + candidates.push(model); + }; + + const currentModel = this.model; + addCandidate(this._resolveRoleModel("default", availableModels, currentModel)); + addCandidate(this._resolveRoleModel("slow", availableModels, currentModel)); + addCandidate(this._resolveRoleModel("small", availableModels, currentModel)); + addCandidate(this._resolveRoleModel("smol", availableModels, currentModel)); + + const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow); + for (const model of sortedByContext) { + if (!seen.has(this._getModelKey(model))) { + addCandidate(model); + break; + } + } + + return candidates; + } + /** * Internal: Run auto-compaction with events. */ @@ -1577,8 +1643,8 @@ export class AgentSession { return; } - const apiKey = await this._modelRegistry.getApiKey(this.model); - if (!apiKey) { + const availableModels = this._modelRegistry.getAvailable(); + if (availableModels.length === 0) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } @@ -1626,14 +1692,68 @@ export class AgentSession { tokensBefore = hookCompaction.tokensBefore; details = hookCompaction.details; } else { - // Generate compaction result - const compactResult = await compact( - preparation, - this.model, - apiKey, - undefined, - this._autoCompactionAbortController.signal, - ); + const candidates = this._getCompactionModelCandidates(availableModels); + const retrySettings = this.settingsManager.getRetrySettings(); + let compactResult: CompactionResult | undefined; + let lastError: unknown; + + for (const candidate of candidates) { + const apiKey = await this._modelRegistry.getApiKey(candidate); + if (!apiKey) continue; + + let attempt = 0; + while (true) { + try { + compactResult = await compact( + preparation, + candidate, + apiKey, + undefined, + this._autoCompactionAbortController.signal, + ); + break; + } catch (error) { + if (this._autoCompactionAbortController.signal.aborted) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + const retryAfterMs = this._parseRetryAfterMsFromError(message); + const shouldRetry = + retrySettings.enabled && + attempt < retrySettings.maxRetries && + (retryAfterMs !== undefined || this._isRetryableErrorMessage(message)); + if (!shouldRetry) { + lastError = error; + break; + } + + const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt; + const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs; + attempt++; + logger.warn("Auto-compaction failed, retrying", { + attempt, + maxRetries: retrySettings.maxRetries, + delayMs, + retryAfterMs, + error: message, + }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + if (compactResult) { + break; + } + } + + if (!compactResult) { + if (lastError) { + throw lastError; + } + throw new Error("Compaction failed: no available model"); + } + summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; @@ -1725,12 +1845,61 @@ export class AgentSession { if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; + return this._isRetryableErrorMessage(err); + } + + private _isRetryableErrorMessage(errorMessage: string): boolean { // Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test( - err, + errorMessage, ); } + private _parseRetryAfterMsFromError(errorMessage: string): number | undefined { + const now = Date.now(); + const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage); + if (retryAfterMsMatch) { + return Math.max(0, Number(retryAfterMsMatch[1])); + } + + const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage); + if (retryAfterMatch) { + const value = retryAfterMatch[1]; + const seconds = Number(value); + if (!Number.isNaN(seconds)) { + return Math.max(0, seconds * 1000); + } + const dateMs = Date.parse(value); + if (!Number.isNaN(dateMs)) { + return Math.max(0, dateMs - now); + } + } + + const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage); + if (resetMsMatch) { + const resetMs = Number(resetMsMatch[1]); + if (!Number.isNaN(resetMs)) { + if (resetMs > 1_000_000_000_000) { + return Math.max(0, resetMs - now); + } + return Math.max(0, resetMs); + } + } + + const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage); + if (resetMatch) { + const resetSeconds = Number(resetMatch[1]); + if (!Number.isNaN(resetSeconds)) { + if (resetSeconds > 1_000_000_000) { + return Math.max(0, resetSeconds * 1000 - now); + } + return Math.max(0, resetSeconds * 1000); + } + } + + return undefined; + } + /** * Handle retryable errors with exponential backoff. * @returns true if retry was initiated, false if max retries exceeded or disabled diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 93d23ba84..af317da50 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -633,6 +633,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir); time("discoverContextFiles"); + let agent: Agent; + let session: AgentSession; + const toolSession: ToolSession = { cwd, hasUI: options.hasUI ?? false, @@ -643,6 +646,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} getSessionFile: () => sessionManager.getSessionFile() ?? null, getSessionSpawns: () => options.spawns ?? "*", getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined), + getActiveModelString: () => { + const activeModel = agent?.state.model; + return activeModel ? formatModelString(activeModel) : undefined; + }, settings: settingsManager, }; @@ -782,8 +789,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry); } - let agent: Agent; - let session: AgentSession; const getSessionContext = () => ({ sessionManager, modelRegistry, diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 0e8f84d30..57ba9b9b9 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1338,6 +1338,21 @@ export class SessionManager { return this.leafId ? this.byId.get(this.leafId) : undefined; } + /** + * Get the most recent model role from the current session path. + * Returns undefined if no model change has been recorded. + */ + getLastModelChangeRole(): string | undefined { + let current = this.getLeafEntry(); + while (current) { + if (current.type === "model_change") { + return current.role ?? "default"; + } + current = current.parentId ? this.byId.get(current.parentId) : undefined; + } + return undefined; + } + getEntry(id: string): SessionEntry | undefined { return this.byId.get(id); } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 5ea339fc5..610f9fce8 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -371,7 +371,8 @@ export class SettingsManager { private settingsPath: string | null; private cwd: string | null; private globalSettings: Settings; - private settings: Settings; + private overrides: Settings; + private settings!: Settings; private persist: boolean; private constructor(settingsPath: string | null, cwd: string | null, initialSettings: Settings, persist: boolean) { @@ -379,8 +380,8 @@ export class SettingsManager { this.cwd = cwd; this.persist = persist; this.globalSettings = initialSettings; - const projectSettings = this.loadProjectSettings(); - this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings)); + this.overrides = {}; + this.rebuildSettings(); // Apply environment variables from settings this.applyEnvironmentVariables(); @@ -474,9 +475,17 @@ export class SettingsManager { return SettingsManager.migrateSettings(merged as Record); } + private rebuildSettings(projectSettings?: Settings): void { + const resolvedProjectSettings = projectSettings ?? this.loadProjectSettings(); + this.settings = normalizeSettings( + deepMergeSettings(deepMergeSettings(this.globalSettings, resolvedProjectSettings), this.overrides), + ); + } + /** Apply additional overrides on top of current settings */ applyOverrides(overrides: Partial): void { - this.settings = normalizeSettings(deepMergeSettings(this.settings, overrides)); + this.overrides = deepMergeSettings(this.overrides, overrides); + this.rebuildSettings(); } private save(): void { @@ -491,9 +500,9 @@ export class SettingsManager { // Save only global settings (project settings are read-only) writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); - // Re-merge project settings into active settings + // Re-merge project settings into active settings (preserve overrides) const projectSettings = this.loadProjectSettings(); - this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings)); + this.rebuildSettings(projectSettings); } catch (error) { console.error(`Warning: Could not save settings file: ${error}`); } @@ -523,6 +532,11 @@ export class SettingsManager { this.globalSettings.modelRoles = {}; } this.globalSettings.modelRoles[role] = model; + + if (this.overrides.modelRoles && this.overrides.modelRoles[role] !== undefined) { + this.overrides.modelRoles[role] = model; + } + this.save(); } diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index e9a878120..10424b516 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -229,6 +229,7 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin } export const editToolRenderer = { + mergeCallAndResult: true, renderCall(args: EditRenderArgs, uiTheme: Theme): Component { const ui = createToolUIKit(uiTheme); const rawPath = args.file_path || args.path || ""; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index c5fbab730..6128bbb20 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -196,7 +196,7 @@ export function createGrepTool(session: ToolSession): AgentTool string | null; /** Get resolved model string if explicitly set for this session */ getModelString?: () => string | undefined; + /** Get the current session model string, regardless of how it was chosen */ + getActiveModelString?: () => string | undefined; /** Settings manager (optional) */ settings?: { getImageAutoResize(): boolean; diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts index 558a20032..d8bf97b9d 100644 --- a/packages/coding-agent/src/core/tools/renderers.ts +++ b/packages/coding-agent/src/core/tools/renderers.ts @@ -31,6 +31,7 @@ type ToolRenderer = { theme: Theme, args?: unknown, ) => Component; + mergeCallAndResult?: boolean; }; export const toolRenderers: Record = { diff --git a/packages/coding-agent/src/core/tools/task/index.ts b/packages/coding-agent/src/core/tools/task/index.ts index 62a47915a..923da543e 100644 --- a/packages/coding-agent/src/core/tools/task/index.ts +++ b/packages/coding-agent/src/core/tools/task/index.ts @@ -135,7 +135,12 @@ export async function createTaskTool( const startTime = Date.now(); const { agents, projectAgentsDir } = await discoverAgents(session.cwd); const { agent: agentName, context, model, output: outputSchema } = params; - const modelOverride = model ?? session.getModelString?.(); + + const isDefaultModelAlias = (value: string | undefined): boolean => { + if (!value) return true; + const normalized = value.trim().toLowerCase(); + return normalized === "default" || normalized === "pi/default" || normalized === "omp/default"; + }; // Validate agent exists const agent = getAgent(agents, agentName); @@ -156,6 +161,10 @@ export async function createTaskTool( }; } + const shouldInheritSessionModel = model === undefined && isDefaultModelAlias(agent.model); + const sessionModel = shouldInheritSessionModel ? session.getActiveModelString?.() : undefined; + const modelOverride = model ?? sessionModel ?? session.getModelString?.(); + // Handle empty or missing tasks if (!params.tasks || params.tasks.length === 0) { return { diff --git a/packages/coding-agent/src/core/tools/task/model-resolver.ts b/packages/coding-agent/src/core/tools/task/model-resolver.ts index b90a5d8e3..73681666e 100644 --- a/packages/coding-agent/src/core/tools/task/model-resolver.ts +++ b/packages/coding-agent/src/core/tools/task/model-resolver.ts @@ -8,7 +8,7 @@ * - Fuzzy match: "opus" → "p-anthropic/claude-opus-4-5" * - Comma fallback: "gpt, opus" → tries gpt first, then opus * - "default" → undefined (use system default) - * - "omp/slow" → configured slow model from settings + * - "omp/slow" or "pi/slow" → configured slow model from settings */ import { type Settings, settingsCapability } from "../../../capability/settings"; @@ -145,9 +145,10 @@ export function resolveModelPattern(pattern: string | undefined, availableModels .filter(Boolean); for (const p of patterns) { - // Handle omp/ aliases - looks up role in settings.modelRoles - if (p.toLowerCase().startsWith("omp/")) { - const role = p.slice(4); // Remove "omp/" prefix + // Handle omp/ or pi/ aliases - looks up role in settings.modelRoles + const lower = p.toLowerCase(); + if (lower.startsWith("omp/") || lower.startsWith("pi/")) { + const role = lower.startsWith("omp/") ? p.slice(4) : p.slice(3); const resolved = resolveOmpAlias(role, models); if (resolved) return resolved; continue; // Role not configured, try next pattern diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e4a70a170..b49490f18 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -289,6 +289,9 @@ async function buildSessionOptions( process.exit(1); } options.model = model; + settingsManager.applyOverrides({ + modelRoles: { default: `${model.provider}/${model.id}` }, + }); } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { options.model = scopedModels[0].model; } diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index bb49c2598..84c8db16d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -369,20 +369,23 @@ export class ToolExecutionComponent extends Container { this.contentBox.setBgFn(bgFn); this.contentBox.clear(); - // Render call component - try { - const callComponent = renderer.renderCall(this.args, theme); - if (callComponent) { - // Ensure component has invalidate() method for Component interface - const component = callComponent as any; - if (!component.invalidate) { - component.invalidate = () => {}; + const shouldRenderCall = !this.result || !renderer.mergeCallAndResult; + if (shouldRenderCall) { + // Render call component + try { + const callComponent = renderer.renderCall(this.args, theme); + if (callComponent) { + // Ensure component has invalidate() method for Component interface + const component = callComponent as any; + if (!component.invalidate) { + component.invalidate = () => {}; + } + this.contentBox.addChild(component); } - this.contentBox.addChild(component); + } catch { + // Fall back to default on error + this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0)); } - } catch { - // Fall back to default on error - this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0)); } // Render result component if we have a result diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b17696866..c3569f1cb 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -38,6 +38,7 @@ import { VoiceSupervisor } from "../../core/voice-supervisor"; import { disableProvider, enableProvider } from "../../discovery"; import { getChangelogPath, parseChangelog } from "../../utils/changelog"; import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard"; +import { resizeImage } from "../../utils/image-resize"; import { registerAsyncCleanup } from "../cleanup"; import { ArminComponent } from "./components/armin"; import { AssistantMessageComponent } from "./components/assistant-message"; @@ -1139,7 +1140,9 @@ export class InteractiveMode { if (this.session.isStreaming) { this.editor.addToHistory(text); this.editor.setText(""); - await this.session.prompt(text, { streamingBehavior: "steer" }); + const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined; + this.pendingImages = []; + await this.session.prompt(text, { streamingBehavior: "steer", images }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); return; @@ -1504,22 +1507,24 @@ export class InteractiveMode { * If multiple status messages are emitted back-to-back (without anything else being added to the chat), * we update the previous status line instead of appending new ones to avoid log spam. */ - private showStatus(message: string): void { + private showStatus(message: string, options?: { dim?: boolean }): void { if (this.isBackgrounded) { return; } const children = this.chatContainer.children; const last = children.length > 0 ? children[children.length - 1] : undefined; const secondLast = children.length > 1 ? children[children.length - 2] : undefined; + const useDim = options?.dim ?? true; + const rendered = useDim ? theme.fg("dim", message) : message; if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) { - this.lastStatusText.setText(theme.fg("dim", message)); + this.lastStatusText.setText(rendered); this.ui.requestRender(); return; } const spacer = new Spacer(1); - const text = new Text(theme.fg("dim", message), 1, 0); + const text = new Text(rendered, 1, 0); this.chatContainer.addChild(spacer); this.chatContainer.addChild(text); this.lastStatusSpacer = spacer; @@ -1822,10 +1827,24 @@ export class InteractiveMode { try { const image = await readImageFromClipboard(); if (image) { + let imageData = image; + if (this.settingsManager.getImageAutoResize()) { + try { + const resized = await resizeImage({ + type: "image", + data: image.data, + mimeType: image.mimeType, + }); + imageData = { data: resized.data, mimeType: resized.mimeType }; + } catch { + imageData = image; + } + } + this.pendingImages.push({ type: "image", - data: image.data, - mimeType: image.mimeType, + data: imageData.data, + mimeType: imageData.mimeType, }); // Insert styled placeholder at cursor like Claude does const imageNum = this.pendingImages.length; @@ -1980,7 +1999,8 @@ export class InteractiveMode { private async cycleRoleModel(options?: { temporary?: boolean }): Promise { try { - const result = await this.session.cycleRoleModels(["slow", "default", "smol"], options); + const roleOrder = ["slow", "default", "smol"]; + const result = await this.session.cycleRoleModels(roleOrder, options); if (!result) { this.showStatus("Only one role model available"); return; @@ -1989,10 +2009,24 @@ export class InteractiveMode { this.statusLine.invalidate(); this.updateEditorBorderColor(); const roleLabel = result.role === "default" ? "default" : result.role; + const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel)); const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; const tempLabel = options?.temporary ? " (temporary)" : ""; - this.showStatus(`Switched to ${roleLabel}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}`); + const cycleSeparator = theme.fg("dim", " > "); + const cycleLabel = roleOrder + .map((role) => { + if (role === result.role) { + return theme.bold(theme.fg("accent", role)); + } + return theme.fg("muted", role); + }) + .join(cycleSeparator); + const orderLabel = ` (cycle: ${cycleLabel})`; + this.showStatus( + `Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`, + { dim: false }, + ); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); }