diff --git a/src/agents/custom-agent-summaries.ts b/src/agents/custom-agent-summaries.ts index a2cc28a099f..ff39ac523e0 100644 --- a/src/agents/custom-agent-summaries.ts +++ b/src/agents/custom-agent-summaries.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../shared/record-type-guard" import { truncateDescription } from "../shared/truncate-description" import type { AgentPromptMetadata } from "./types" @@ -14,9 +15,6 @@ function sanitizeMarkdownTableCell(value: string): string { .trim() } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} export function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[] { if (!Array.isArray(input)) return [] diff --git a/src/features/background-agent/error-helpers.ts b/src/features/background-agent/error-helpers.ts index 2f3dc528ab9..31e15271601 100644 --- a/src/features/background-agent/error-helpers.ts +++ b/src/features/background-agent/error-helpers.ts @@ -1,19 +1,6 @@ +import { isRecord } from "../../shared/record-type-guard" import type { EventProperties } from "./manager" -export function formatDuration(start: Date, end?: Date): string { - const duration = (end ?? new Date()).getTime() - start.getTime() - const seconds = Math.floor(duration / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - - if (hours > 0) { - return `${hours}h ${minutes % 60}m ${seconds % 60}s` - } - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } - return `${seconds}s` -} export function getErrorText(error: unknown): string { if (!error) return "" @@ -37,9 +24,6 @@ export function isAbortedSessionError(error: unknown): boolean { return message.toLowerCase().includes("aborted") } -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} export function getSessionErrorMessage( properties: EventProperties, diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index efdb47c5aa0..bd1d50bdf3f 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -3,11 +3,10 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" import { getAgentToolRestrictions, log, normalizeSDKResponse, promptWithModelSuggestionRetry } from "../../shared" -import { setSessionTemperature } from "../../shared/session-temperature-store" -import { setSessionTools } from "../../shared/session-tools-store" +import { formatDuration } from "../../shared/format-duration" +import { setSessionTemperature, setSessionTools } from "../../shared/session-state" import { isInsideTmux } from "../../shared/tmux" import { subagentSessions } from "../session-state" - import { getTaskToastManager } from "../task-toast-manager" import { ConcurrencyManager } from "./concurrency" import { @@ -21,10 +20,9 @@ import { TASK_TTL_MS, } from "./constants" import { - formatDuration, getSessionErrorMessage, isAbortedSessionError, -} from "./error-helpers" + } from "./error-helpers" import { type CircuitBreakerSettings, detectRepetitiveToolUse, diff --git a/src/features/task-toast-manager/types.ts b/src/features/task-toast-manager/types.ts index 68fbf432562..3e76f3b6dd5 100644 --- a/src/features/task-toast-manager/types.ts +++ b/src/features/task-toast-manager/types.ts @@ -1,11 +1,11 @@ -import type { ModelSource } from "../../shared/model-resolver" +import type { ModelResolutionProvenance } from "../../shared/model-resolution-pipeline" export type TaskStatus = "running" | "queued" | "completed" | "error" export interface ModelFallbackInfo { model: string type: "user-defined" | "inherited" | "category-default" | "system-default" - source?: ModelSource + source?: ModelResolutionProvenance } export interface TrackedTask { diff --git a/src/features/tmux-subagent/session-created-event.ts b/src/features/tmux-subagent/session-created-event.ts index 53440a2de75..d22f08af8b0 100644 --- a/src/features/tmux-subagent/session-created-event.ts +++ b/src/features/tmux-subagent/session-created-event.ts @@ -1,8 +1,7 @@ +import { isRecord } from "../../shared/record-type-guard" + type UnknownRecord = Record -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null -} function getNestedRecord(value: unknown, key: string): UnknownRecord | undefined { if (!isRecord(value)) return undefined diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts index 3aabed52504..fc15681df60 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts @@ -1,6 +1,6 @@ +import { formatBytes } from "../../shared/format-bytes" import { log } from "../../shared/logger" import type { Client } from "./client" -import { formatBytes } from "./message-builder" import { clearSessionState } from "./state" import { truncateUntilTargetTokens } from "./storage" import type { AutoCompactState } from "./types" diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts index 668883cdf2c..4130baf4dc2 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts @@ -140,11 +140,6 @@ export async function sanitizeEmptyMessagesBeforeSummarize( return fixedCount } -export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes}B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` - return `${(bytes / (1024 * 1024)).toFixed(1)}MB` -} export async function getLastAssistant( sessionID: string, diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts index 748b79789a5..9e4d57c6aec 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts @@ -1,3 +1,4 @@ +import type { TaskStatus } from "../../shared/status-types" export interface StoredToolPart { id: string sessionID: string @@ -6,7 +7,7 @@ export interface StoredToolPart { callID: string tool: string state: { - status: "pending" | "running" | "completed" | "error" + status: TaskStatus input: Record output?: string error?: string diff --git a/src/hooks/architect/event-handler.ts b/src/hooks/architect/event-handler.ts index 81aa7d6c302..6932d9b85c5 100644 --- a/src/hooks/architect/event-handler.ts +++ b/src/hooks/architect/event-handler.ts @@ -2,9 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin" import { clearMissionState, getPlanProgress, readMissionState } from "../../features/mission-state" import { subagentSessions } from "../../features/session-state" import { getAgentConfigKey } from "../../shared/agent-display-names" +import { isAbortError } from "../../shared/is-abort-error" import { log } from "../../shared/logger" import { HOOK_NAME } from "./hook-name" -import { isAbortError } from "./is-abort-error" import { injectMissionContinuation } from "./mission-continuation-injector" import { getLastAgentFromSession } from "./session-last-agent" import type { ArchitectHookOptions, SessionState } from "./types" diff --git a/src/hooks/auto-slash-command/detector.ts b/src/hooks/auto-slash-command/detector.ts index e9f12f11dae..1c449ef8bc3 100644 --- a/src/hooks/auto-slash-command/detector.ts +++ b/src/hooks/auto-slash-command/detector.ts @@ -1,4 +1,4 @@ -import { CODE_BLOCK_PATTERN } from "../../shared" +import { CODE_BLOCK_PATTERN } from "../../shared/code-patterns" import { EXCLUDED_COMMANDS, SLASH_COMMAND_PATTERN, diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index ad55b0a846f..31946c34f12 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -10,9 +10,9 @@ import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, - sanitizeModelField, } from "../../shared" import { isMarkdownFile } from "../../shared/file-utils" +import { sanitizeModelField } from "../../shared/model-sanitizer" import type { ParsedSlashCommand } from "./types" interface CommandScope { diff --git a/src/hooks/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts index 791f123e431..28b98078a05 100644 --- a/src/hooks/hashline-edit-diff-enhancer/hook.ts +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -1,4 +1,5 @@ -import { isWriteTool, log } from "../../shared" +import { log } from "../../shared" +import { isWriteTool } from "../../shared/tool-guards" import { countLineDiffs, generateUnifiedDiff } from "../../tools/hashline-edit/diff-utils" interface HashlineEditDiffEnhancerConfig { diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 79243dfd014..fc879cc1e67 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -1,5 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { isReadTool, isWriteTool } from "../../shared" +import { isReadTool, isWriteTool } from "../../shared/tool-guards" import { computeLineHash } from "../../tools/hashline-edit/hash-computation" const WRITE_SUCCESS_MARKER = "File written successfully." diff --git a/src/hooks/keyword-detector/constants.ts b/src/hooks/keyword-detector/constants.ts index 77799f15e95..1e2b9042a9b 100644 --- a/src/hooks/keyword-detector/constants.ts +++ b/src/hooks/keyword-detector/constants.ts @@ -1,4 +1,4 @@ -export { CODE_BLOCK_PATTERN, INLINE_CODE_PATTERN } from "../../shared" +export { CODE_BLOCK_PATTERN, INLINE_CODE_PATTERN } from "../../shared/code-patterns" export { ANALYZE_MESSAGE, ANALYZE_PATTERN } from "./analyze" export { SEARCH_MESSAGE, SEARCH_PATTERN } from "./search" // Re-export from submodules diff --git a/src/hooks/matrix-loop/with-timeout.ts b/src/hooks/matrix-loop/with-timeout.ts index 703124439ab..7d25d37a511 100644 --- a/src/hooks/matrix-loop/with-timeout.ts +++ b/src/hooks/matrix-loop/with-timeout.ts @@ -1,20 +1 @@ -export async function withTimeout( - promise: Promise, - timeoutMs: number, -): Promise { - let timeoutId: ReturnType | undefined - - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new Error("API timeout")) - }, timeoutMs) - }) - - try { - return await Promise.race([promise, timeoutPromise]) - } finally { - if (timeoutId !== undefined) { - clearTimeout(timeoutId) - } - } -} +export { withTimeout } from "../../shared/with-timeout" diff --git a/src/hooks/preemptive-compaction-degradation-monitor.ts b/src/hooks/preemptive-compaction-degradation-monitor.ts index 7314ab388a6..0e636d7f090 100644 --- a/src/hooks/preemptive-compaction-degradation-monitor.ts +++ b/src/hooks/preemptive-compaction-degradation-monitor.ts @@ -45,23 +45,7 @@ export interface AssistantCompactionMessageInfo { id?: string } -async function withTimeout( - promise: Promise, - timeoutMs: number, - errorMessage: string, -): Promise { - let timeoutID: unknown - - const timeoutPromise = new Promise((_, reject) => { - timeoutID = setTimeout(() => { - reject(new Error(errorMessage)) - }, timeoutMs) - }) - - return await Promise.race([promise, timeoutPromise]).finally(() => { - clearTimeout(timeoutID) - }) -} +import { withTimeout } from "../shared/with-timeout" export function createPostCompactionDegradationMonitor(args: { client: ClientLike diff --git a/src/hooks/preemptive-compaction-trigger.ts b/src/hooks/preemptive-compaction-trigger.ts index a4d1f83e513..b7df115ac49 100644 --- a/src/hooks/preemptive-compaction-trigger.ts +++ b/src/hooks/preemptive-compaction-trigger.ts @@ -17,23 +17,7 @@ const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000 declare function setTimeout(handler: () => void, timeout?: number): unknown declare function clearTimeout(timeoutID: unknown): void -async function withTimeout( - promise: Promise, - timeoutMs: number, - errorMessage: string, -): Promise { - let timeoutID: unknown - - const timeoutPromise = new Promise((_, reject) => { - timeoutID = setTimeout(() => { - reject(new Error(errorMessage)) - }, timeoutMs) - }) - - return await Promise.race([promise, timeoutPromise]).finally(() => { - clearTimeout(timeoutID) - }) -} +import { withTimeout } from "../shared/with-timeout" export async function runPreemptiveCompactionIfNeeded(args: { ctx: PreemptiveCompactionContext diff --git a/src/hooks/read-image-resizer/hook.ts b/src/hooks/read-image-resizer/hook.ts index d11d83c0844..9ea5c041c96 100644 --- a/src/hooks/read-image-resizer/hook.ts +++ b/src/hooks/read-image-resizer/hook.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { isReadTool, log } from "../../shared" -import { getSessionModel } from "../../shared/session-model-state" +import { log } from "../../shared" +import { getSessionModel } from "../../shared/session-state" +import { isReadTool } from "../../shared/tool-guards" import { parseImageDimensions } from "./image-dimensions" import { calculateTargetDimensions, resizeImage } from "./image-resizer" import type { ImageAttachment, ImageDimensions } from "./types" diff --git a/src/hooks/read-image-resizer/image-resizer.ts b/src/hooks/read-image-resizer/image-resizer.ts index b9f4624c804..7661a3508c5 100644 --- a/src/hooks/read-image-resizer/image-resizer.ts +++ b/src/hooks/read-image-resizer/image-resizer.ts @@ -1,4 +1,5 @@ import { log } from "../../shared" +import { getErrorMessage } from "../../shared/error-formatting" import { extractBase64Data } from "../../tools/look-at/mime-type-inference" import { resizeImageFallback } from "./png-fallback-resizer" import type { ImageDimensions, ResizeResult } from "./types" @@ -75,9 +76,6 @@ async function renderResizedBuffer(args: { .toBuffer() } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} function loadSharpModule(): Promise { return Function('return import("sharp").catch(() => null)')() as Promise diff --git a/src/hooks/runtime-fallback/event-handler.ts b/src/hooks/runtime-fallback/event-handler.ts index 8ab46fbcf01..7628e1c1d0d 100644 --- a/src/hooks/runtime-fallback/event-handler.ts +++ b/src/hooks/runtime-fallback/event-handler.ts @@ -1,3 +1,4 @@ +import { isAbortError } from "../../shared/is-abort-error" import { log } from "../../shared/logger" import type { AutoRetryHelpers } from "./auto-retry" import { HOOK_NAME } from "./constants" @@ -6,7 +7,6 @@ import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { getFallbackModelsForSession } from "./fallback-models" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { createFallbackState } from "./fallback-state" -import { isAbortError } from "./is-abort-error" import { SessionCategoryRegistry } from "./session-category-registry" import { createSessionStatusHandler } from "./session-status-handler" import type { HookDeps } from "./types" diff --git a/src/hooks/runtime-fallback/is-abort-error.ts b/src/hooks/runtime-fallback/is-abort-error.ts deleted file mode 100644 index 3a8c92c1a75..00000000000 --- a/src/hooks/runtime-fallback/is-abort-error.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function isAbortError(error: unknown): boolean { - if (!error) return false - - if (typeof error === "object") { - const errObj = error as Record - const name = errObj.name as string | undefined - const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" - - if (name === "MessageAbortedError" || name === "AbortError") return true - if (name === "DOMException" && message.includes("abort")) return true - if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true - } - - if (typeof error === "string") { - const lower = error.toLowerCase() - return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") - } - - return false -} diff --git a/src/hooks/runtime-fallback/session-messages.ts b/src/hooks/runtime-fallback/session-messages.ts index 9e5a194bfbb..8fd95e8f99d 100644 --- a/src/hooks/runtime-fallback/session-messages.ts +++ b/src/hooks/runtime-fallback/session-messages.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../../shared/record-type-guard" export type SessionMessagePart = { type?: string text?: string @@ -8,9 +9,6 @@ export type SessionMessage = { parts?: SessionMessagePart[] } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} function isSessionMessage(value: unknown): value is SessionMessage { return isRecord(value) diff --git a/src/hooks/runtime-fallback/visible-assistant-response.ts b/src/hooks/runtime-fallback/visible-assistant-response.ts index 6b48ae56347..44c28849367 100644 --- a/src/hooks/runtime-fallback/visible-assistant-response.ts +++ b/src/hooks/runtime-fallback/visible-assistant-response.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../../shared/record-type-guard" import type { extractAutoRetrySignal } from "./error-classifier" import type { SessionMessage, SessionMessagePart } from "./session-messages" import { extractSessionMessages } from "./session-messages" @@ -55,7 +56,7 @@ export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof ext const infoParts = message.info?.parts const infoMessageParts = Array.isArray(infoParts) - ? infoParts.filter((part): part is SessionMessagePart => typeof part === "object" && part !== null) + ? infoParts.filter((part): part is SessionMessagePart => isRecord(part)) : undefined const parts = message.parts && message.parts.length > 0 ? message.parts diff --git a/src/hooks/session-recovery/types.ts b/src/hooks/session-recovery/types.ts index 3c6b2c03487..edd36cbe85d 100644 --- a/src/hooks/session-recovery/types.ts +++ b/src/hooks/session-recovery/types.ts @@ -1,3 +1,4 @@ +import type { TaskStatus } from "../../shared/status-types" export interface StoredMessageMeta { id: string @@ -29,7 +30,7 @@ export interface StoredToolPart { callID: string tool: string state: { - status: "pending" | "running" | "completed" | "error" + status: TaskStatus input: Record output?: string error?: string diff --git a/src/hooks/think-mode/detector.ts b/src/hooks/think-mode/detector.ts index 291206efb69..9e4e23b3460 100644 --- a/src/hooks/think-mode/detector.ts +++ b/src/hooks/think-mode/detector.ts @@ -1,4 +1,4 @@ -import { CODE_BLOCK_PATTERN, INLINE_CODE_PATTERN } from "../../shared" +import { CODE_BLOCK_PATTERN, INLINE_CODE_PATTERN } from "../../shared/code-patterns" const ENGLISH_PATTERNS = [ /\bultrathink\b/i, diff --git a/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts b/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts index be536630a87..cb24818df18 100644 --- a/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts +++ b/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts @@ -1,4 +1,5 @@ import type { BackgroundTask } from "../../features/background-agent" +import { isRecord } from "../../shared/record-type-guard" export const THINKING_SUMMARY_MAX_CHARS = 500 as const @@ -20,9 +21,6 @@ function hasData(value: unknown): value is { data?: unknown } { return typeof value === "object" && value !== null && "data" in value } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} export function getMessageInfo(value: unknown): MessageInfo | undefined { if (!isRecord(value)) return undefined diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index 6fc4506ea6d..f2b509dec7d 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../shared/record-type-guard" + type ChatParamsInput = { sessionID: string agent: { name?: string } @@ -13,9 +15,6 @@ type ChatParamsOutput = { options: Record } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} function buildChatParamsInput(raw: unknown): ChatParamsInput | null { if (!isRecord(raw)) return null @@ -58,7 +57,7 @@ function isChatParamsOutput(raw: unknown): raw is ChatParamsOutput { return isRecord(raw.options) } -import { clearSessionTemperature, getSessionTemperature } from "../shared/session-temperature-store" +import { clearSessionTemperature, getSessionTemperature } from "../shared/session-state" export function createChatParamsHandler(args: { anthropicEffort: { "chat.params"?: (input: ChatParamsInput, output: ChatParamsOutput) => Promise } | null diff --git a/src/plugin/unstable-agent-babysitter.ts b/src/plugin/unstable-agent-babysitter.ts index fd9c9766f98..8121a2ac904 100644 --- a/src/plugin/unstable-agent-babysitter.ts +++ b/src/plugin/unstable-agent-babysitter.ts @@ -2,6 +2,7 @@ import type { MatrixxConfig } from "../config" import type { BackgroundManager } from "../features/background-agent" import { createUnstableAgentBabysitterHook } from "../hooks" +import { isRecord } from "../shared/record-type-guard" import type { PluginContext } from "./types" export function createUnstableAgentBabysitter(args: { @@ -19,7 +20,7 @@ export function createUnstableAgentBabysitter(args: { messages: async ({ path }) => { const result = await ctx.client.session.messages({ path }) if (Array.isArray(result)) return result - if (typeof result === "object" && result !== null) { + if (isRecord(result)) { return result } return [] diff --git a/src/shared/delay.ts b/src/shared/delay.ts new file mode 100644 index 00000000000..e0b2c7ebf73 --- /dev/null +++ b/src/shared/delay.ts @@ -0,0 +1,3 @@ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/shared/error-formatting.ts b/src/shared/error-formatting.ts index f08507c82f0..aec8995b41d 100644 --- a/src/shared/error-formatting.ts +++ b/src/shared/error-formatting.ts @@ -60,3 +60,18 @@ export function formatDetailedError(error: unknown, ctx: ErrorContext): string { return lines.join("\n") } + +export function getErrorMessage(error: unknown): string { + if (typeof error === "string") return error + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null) { + const obj = error as Record + if (typeof obj.message === "string") return obj.message + try { + return JSON.stringify(error) + } catch { + return "" + } + } + return String(error) +} diff --git a/src/shared/format-bytes.test.ts b/src/shared/format-bytes.test.ts new file mode 100644 index 00000000000..761ed0502fc --- /dev/null +++ b/src/shared/format-bytes.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "bun:test" +import { formatBytes } from "./format-bytes" + +describe("formatBytes", () => { + it("should return bytes for < 1024", () => { + expect(formatBytes(500)).toBe("500B") + }) + it("should return KB for < 1MB", () => { + expect(formatBytes(2048)).toBe("2.0KB") + }) + it("should return MB for >= 1MB", () => { + expect(formatBytes(1048576)).toBe("1.0MB") + }) + it("should handle 0 bytes", () => { + expect(formatBytes(0)).toBe("0B") + }) +}) diff --git a/src/shared/format-bytes.ts b/src/shared/format-bytes.ts new file mode 100644 index 00000000000..ce685b839fc --- /dev/null +++ b/src/shared/format-bytes.ts @@ -0,0 +1,5 @@ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 106cfb19234..77c9b9af73b 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,42 +1,36 @@ export * from "./agent-tool-restrictions" export * from "./agent-variant" export * from "./binary-downloader" -export * from "./code-patterns" -export * from "./code-patterns" export * from "./command-executor" export * from "./config-errors" export * from "./connected-providers-cache" export * from "./data-path" export * from "./deep-merge" +export * from "./delay" export * from "./dynamic-truncator" export * from "./external-plugin-detector" export * from "./file-reference-resolver" export * from "./file-utils" +export * from "./format-bytes" export * from "./format-duration" export * from "./frontmatter" export * from "./git-worktree" -export * from "./is-object" +export * from "./is-abort-error" export * from "./jsonc-parser" export * from "./logger" export * from "./migration" export * from "./model-availability" export * from "./model-requirements" -export { resolveModelPipeline } from "./model-resolution-pipeline" -export type { - ModelResolutionProvenance, - ModelResolutionRequest, - ModelResolutionResult, -} from "./model-resolution-types" -export * from "./model-resolver" -export * from "./model-sanitizer" +export { + type ModelResolutionProvenance, + type ModelResolutionRequest, + type ModelResolutionResult, + normalizeModel, + resolveModelPipeline, +} from "./model-resolution-pipeline" export * from "./model-suggestion-retry" export * from "./normalize-sdk-response" export * from "./opencode-config-dir" -export type { - OpenCodeBinaryType, - OpenCodeConfigDirOptions, - OpenCodeConfigPaths, -} from "./opencode-config-dir-types" export * from "./opencode-http-api" export * from "./opencode-message-dir" export * from "./opencode-server-auth" @@ -46,14 +40,16 @@ export * from "./opencode-version" export * from "./permission-compat" export * from "./port-utils" export * from "./safe-create-hook" +export * from "./sentinels" export * from "./session-cursor" export * from "./session-directory-resolver" export * from "./session-utils" export * from "./shell-env" +export * from "./status-types" export * from "./system-directive" export * from "./tmux" export * from "./token-cache" -export * from "./tool-guards" export * from "./transcript-path" export * from "./truncate-description" +export * from "./with-timeout" export * from "./zip-extractor" diff --git a/src/shared/is-abort-error.test.ts b/src/shared/is-abort-error.test.ts new file mode 100644 index 00000000000..5f7a2de6782 --- /dev/null +++ b/src/shared/is-abort-error.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test" +import { isAbortError } from "./is-abort-error" + +describe("isAbortError", () => { + it("should return true for MessageAbortedError", () => { + expect(isAbortError(new Error("MessageAbortedError"))).toBe(true) + }) + it("should return true for aborted message", () => { + expect(isAbortError(new Error("The operation was aborted"))).toBe(true) + }) + it("should return true for cancelled message", () => { + expect(isAbortError(new Error("Request cancelled"))).toBe(true) + }) + it("should return true for interrupted message", () => { + expect(isAbortError(new Error("Connection interrupted"))).toBe(true) + }) + it("should return false for other errors", () => { + expect(isAbortError(new Error("Something went wrong"))).toBe(false) + }) + it("should return false for non-Error values", () => { + expect(isAbortError("string")).toBe(false) + expect(isAbortError(null)).toBe(false) + expect(isAbortError(undefined)).toBe(false) + }) + it("should return false for empty input", () => { + expect(isAbortError("")).toBe(false) + }) +}) diff --git a/src/hooks/architect/is-abort-error.ts b/src/shared/is-abort-error.ts similarity index 100% rename from src/hooks/architect/is-abort-error.ts rename to src/shared/is-abort-error.ts diff --git a/src/shared/is-object.ts b/src/shared/is-object.ts deleted file mode 100644 index 97fde1c558b..00000000000 --- a/src/shared/is-object.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null -} diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index fd66ecf3301..be70ea31e2e 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -1,9 +1,40 @@ import * as connectedProvidersCache from "./connected-providers-cache" import { log } from "./logger" import { fuzzyMatchModel } from "./model-availability" -import type { ModelResolutionRequest, ModelResolutionResult } from "./model-resolution-types" +import type { FallbackEntry } from "./model-requirements" -function normalizeModel(model?: string): string | undefined { +export type ModelResolutionRequest = { + intent?: { + globalOverrideModel?: string + uiSelectedModel?: string + userModel?: string + categoryDefaultModel?: string + } + constraints: { + availableModels: Set + connectedProviders?: string[] + } + policy?: { + fallbackChain?: FallbackEntry[] + systemDefaultModel?: string + } +} + +export type ModelResolutionProvenance = + | "override" + | "category-default" + | "provider-fallback" + | "system-default" + +export type ModelResolutionResult = { + model: string + provenance: ModelResolutionProvenance + variant?: string + attempted?: string[] + reason?: string +} + +export function normalizeModel(model?: string): string | undefined { const trimmed = model?.trim() return trimmed || undefined } diff --git a/src/shared/model-resolution-types.ts b/src/shared/model-resolution-types.ts deleted file mode 100644 index f53b333bfee..00000000000 --- a/src/shared/model-resolution-types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { FallbackEntry } from "./model-requirements" - -export type ModelResolutionRequest = { - intent?: { - /** Global override for all agents and categories — set via the global_model config. - * Highest priority; checked before everything else. */ - globalOverrideModel?: string - uiSelectedModel?: string - userModel?: string - categoryDefaultModel?: string - } - constraints: { - availableModels: Set - connectedProviders?: string[] - } - policy?: { - fallbackChain?: FallbackEntry[] - systemDefaultModel?: string - } -} - -export type ModelResolutionProvenance = - | "override" - | "category-default" - | "provider-fallback" - | "system-default" - -export type ModelResolutionResult = { - model: string - provenance: ModelResolutionProvenance - variant?: string - attempted?: string[] - reason?: string -} diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts deleted file mode 100644 index 03eb35df006..00000000000 --- a/src/shared/model-resolver.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { FallbackEntry } from "./model-requirements" -import { resolveModelPipeline } from "./model-resolution-pipeline" -import type { ModelResolutionProvenance } from "./model-resolution-types" - -export type ModelResolutionInput = { - userModel?: string - inheritedModel?: string - systemDefault?: string -} - -/** @deprecated Use ModelResolutionProvenance from model-resolution-types.ts */ -export type ModelSource = ModelResolutionProvenance - -export type ModelResolutionResult = { - model: string - source: ModelResolutionProvenance - variant?: string -} - -export type ExtendedModelResolutionInput = { - uiSelectedModel?: string - userModel?: string - categoryDefaultModel?: string - fallbackChain?: FallbackEntry[] - availableModels: Set - systemDefaultModel?: string -} - -function normalizeModel(model?: string): string | undefined { - const trimmed = model?.trim() - return trimmed || undefined -} - -export function resolveModel(input: ModelResolutionInput): string | undefined { - return ( - normalizeModel(input.userModel) ?? - normalizeModel(input.inheritedModel) ?? - input.systemDefault - ) -} - -export function resolveModelWithFallback( - input: ExtendedModelResolutionInput, -): ModelResolutionResult | undefined { - const { uiSelectedModel, userModel, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input - const resolved = resolveModelPipeline({ - intent: { uiSelectedModel, userModel, categoryDefaultModel }, - constraints: { availableModels }, - policy: { fallbackChain, systemDefaultModel }, - }) - - if (!resolved) { - return undefined - } - - return { - model: resolved.model, - source: resolved.provenance, - variant: resolved.variant, - } -} diff --git a/src/shared/model-suggestion-retry.ts b/src/shared/model-suggestion-retry.ts index 37ebb84eb7d..17c67ae06e1 100644 --- a/src/shared/model-suggestion-retry.ts +++ b/src/shared/model-suggestion-retry.ts @@ -1,5 +1,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" +import { getErrorMessage } from "./error-formatting" import { log } from "./logger" +import { isRecord } from "./record-type-guard" type Client = ReturnType @@ -9,25 +11,11 @@ interface ModelSuggestionInfo { suggestion: string } -function extractMessage(error: unknown): string { - if (typeof error === "string") return error - if (error instanceof Error) return error.message - if (typeof error === "object" && error !== null) { - const obj = error as Record - if (typeof obj.message === "string") return obj.message - try { - return JSON.stringify(error) - } catch { - return "" - } - } - return String(error) -} export function parseModelSuggestion(error: unknown): ModelSuggestionInfo | null { if (!error) return null - if (typeof error === "object") { + if (isRecord(error)) { const errObj = error as Record if (errObj.name === "ProviderModelNotFoundError" && typeof errObj.data === "object" && errObj.data !== null) { @@ -45,14 +33,14 @@ export function parseModelSuggestion(error: unknown): ModelSuggestionInfo | null for (const key of ["data", "error", "cause"] as const) { const nested = errObj[key] - if (nested && typeof nested === "object") { + if (isRecord(nested)) { const result = parseModelSuggestion(nested) if (result) return result } } } - const message = extractMessage(error) + const message = getErrorMessage(error) if (!message) return null const modelMatch = message.match(/model not found:\s*([^/\s]+)\s*\/\s*([^.\s]+)/i) diff --git a/src/shared/opencode-config-dir-types.ts b/src/shared/opencode-config-dir-types.ts deleted file mode 100644 index 0e002d7bcf7..00000000000 --- a/src/shared/opencode-config-dir-types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type OpenCodeBinaryType = "opencode" | "opencode-desktop" - -export type OpenCodeConfigDirOptions = { - binary: OpenCodeBinaryType - version?: string | null - checkExisting?: boolean -} - -export type OpenCodeConfigPaths = { - configDir: string - configJson: string - configJsonc: string - packageJson: string - matrixxConfig: string -} diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index b31cd09aa90..cd36bebb695 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -2,16 +2,21 @@ import { existsSync } from "node:fs" import { homedir } from "node:os" import { join, resolve } from "node:path" -import type { - OpenCodeConfigDirOptions, - OpenCodeConfigPaths, -} from "./opencode-config-dir-types" - -export type { - OpenCodeBinaryType, - OpenCodeConfigDirOptions, - OpenCodeConfigPaths, -} from "./opencode-config-dir-types" +export type OpenCodeBinaryType = "opencode" | "opencode-desktop" + +export type OpenCodeConfigDirOptions = { + binary: OpenCodeBinaryType + version?: string | null + checkExisting?: boolean +} + +export type OpenCodeConfigPaths = { + configDir: string + configJson: string + configJsonc: string + packageJson: string + matrixxConfig: string +} export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop" export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev" diff --git a/src/shared/opencode-server-auth.ts b/src/shared/opencode-server-auth.ts index fb68e5ecfe9..87361bce203 100644 --- a/src/shared/opencode-server-auth.ts +++ b/src/shared/opencode-server-auth.ts @@ -1,4 +1,5 @@ import { log } from "./logger" +import { isRecord } from "./record-type-guard" /** * Builds HTTP Basic Auth header from environment variables. @@ -19,9 +20,6 @@ export function getServerBasicAuthHeader(): string | undefined { type UnknownRecord = Record -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null -} function isRequestFetch(value: unknown): value is (request: Request) => Promise { return typeof value === "function" diff --git a/src/shared/opencode-storage-detection.ts b/src/shared/opencode-storage-detection.ts index 930f9e1f3f1..0f63785dbc4 100644 --- a/src/shared/opencode-storage-detection.ts +++ b/src/shared/opencode-storage-detection.ts @@ -2,9 +2,8 @@ import { existsSync } from "node:fs" import { join } from "node:path" import { getDataDir } from "./data-path" import { isOpenCodeVersionAtLeast, OPENCODE_SQLITE_VERSION } from "./opencode-version" +import { FALSE_PENDING_RETRY, NOT_CACHED } from "./sentinels" -const NOT_CACHED = Symbol("NOT_CACHED") -const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY") let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED export function isSqliteBackend(): boolean { diff --git a/src/shared/opencode-version.ts b/src/shared/opencode-version.ts index 7421d252abd..735e18081d5 100644 --- a/src/shared/opencode-version.ts +++ b/src/shared/opencode-version.ts @@ -1,4 +1,5 @@ import { execSync } from "node:child_process" +import { NOT_CACHED } from "./sentinels" /** * Minimum OpenCode version required for this plugin. @@ -21,7 +22,6 @@ export const OPENCODE_NATIVE_AGENTS_INJECTION_VERSION = "1.1.37" */ export const OPENCODE_SQLITE_VERSION = "1.1.53" -const NOT_CACHED = Symbol("NOT_CACHED") let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED export function parseVersion(version: string): number[] { diff --git a/src/shared/sentinels.ts b/src/shared/sentinels.ts new file mode 100644 index 00000000000..3c10406e7ed --- /dev/null +++ b/src/shared/sentinels.ts @@ -0,0 +1,2 @@ +export const NOT_CACHED = Symbol("NOT_CACHED") +export const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY") diff --git a/src/shared/session-model-state.ts b/src/shared/session-model-state.ts deleted file mode 100644 index a73069ae1d5..00000000000 --- a/src/shared/session-model-state.ts +++ /dev/null @@ -1,8 +0,0 @@ -type SessionModel = { providerID: string; modelID: string } - -const sessionModels = new Map() - -export function getSessionModel(sessionID: string): SessionModel | undefined { - return sessionModels.get(sessionID) -} - diff --git a/src/shared/session-state.ts b/src/shared/session-state.ts new file mode 100644 index 00000000000..f56bc586a5b --- /dev/null +++ b/src/shared/session-state.ts @@ -0,0 +1,49 @@ +/** + * Per-session temperature overrides. + * + * Before prompting a subagent session, store the desired temperature here. + * The category-temperature chat.params hook reads this store and injects + * the value into the LLM call parameters. + */ +const temperatureBySession = new Map() + +export function setSessionTemperature(sessionID: string, temperature: number): void { + temperatureBySession.set(sessionID, temperature) +} + +export function getSessionTemperature(sessionID: string): number | undefined { + return temperatureBySession.get(sessionID) +} + +export function clearSessionTemperature(sessionID: string): void { + temperatureBySession.delete(sessionID) +} + +/** + * Per-session tool availability overrides. + */ +const toolsBySession = new Map>() + +export function setSessionTools(sessionID: string, tools: Record): void { + toolsBySession.set(sessionID, { ...tools }) +} + +export function getSessionTools(sessionID: string): Record | undefined { + const tools = toolsBySession.get(sessionID) + return tools ? { ...tools } : undefined +} + +export function clearSessionTools(): void { + toolsBySession.clear() +} + +/** + * Per-session model tracking. + */ +type SessionModel = { providerID: string; modelID: string } + +const sessionModels = new Map() + +export function getSessionModel(sessionID: string): SessionModel | undefined { + return sessionModels.get(sessionID) +} diff --git a/src/shared/session-temperature-store.ts b/src/shared/session-temperature-store.ts deleted file mode 100644 index 5b55ab2fcce..00000000000 --- a/src/shared/session-temperature-store.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Per-session temperature overrides. - * - * Before prompting a subagent session, store the desired temperature here. - * The category-temperature chat.params hook reads this store and injects - * the value into the LLM call parameters. - */ -const temperatureBySession = new Map() - -export function setSessionTemperature(sessionID: string, temperature: number): void { - temperatureBySession.set(sessionID, temperature) -} - -export function getSessionTemperature(sessionID: string): number | undefined { - return temperatureBySession.get(sessionID) -} - -export function clearSessionTemperature(sessionID: string): void { - temperatureBySession.delete(sessionID) -} diff --git a/src/shared/session-tools-store.ts b/src/shared/session-tools-store.ts deleted file mode 100644 index f717488e642..00000000000 --- a/src/shared/session-tools-store.ts +++ /dev/null @@ -1,14 +0,0 @@ -const store = new Map>() - -export function setSessionTools(sessionID: string, tools: Record): void { - store.set(sessionID, { ...tools }) -} - -export function getSessionTools(sessionID: string): Record | undefined { - const tools = store.get(sessionID) - return tools ? { ...tools } : undefined -} - -export function clearSessionTools(): void { - store.clear() -} diff --git a/src/shared/status-types.ts b/src/shared/status-types.ts new file mode 100644 index 00000000000..14dffc63b93 --- /dev/null +++ b/src/shared/status-types.ts @@ -0,0 +1 @@ +export type TaskStatus = "pending" | "running" | "completed" | "error" diff --git a/src/shared/tmux/tmux-utils/pane-close.ts b/src/shared/tmux/tmux-utils/pane-close.ts index cc6f4b6c4e7..5a16280f10a 100644 --- a/src/shared/tmux/tmux-utils/pane-close.ts +++ b/src/shared/tmux/tmux-utils/pane-close.ts @@ -1,10 +1,8 @@ import { spawn } from "bun" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import { delay } from "../../delay" import { isInsideTmux } from "./environment" -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) -} export async function closeTmuxPane(paneId: string): Promise { const { log } = await import("../../logger") diff --git a/src/shared/tmux/tmux-utils/server-health.ts b/src/shared/tmux/tmux-utils/server-health.ts index f45d8d01b35..52bdcf5e404 100644 --- a/src/shared/tmux/tmux-utils/server-health.ts +++ b/src/shared/tmux/tmux-utils/server-health.ts @@ -1,9 +1,8 @@ +import { delay } from "../../delay" + let serverAvailable: boolean | null = null let serverCheckUrl: string | null = null -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) -} export async function isServerRunning(serverUrl: string): Promise { if (serverCheckUrl === serverUrl && serverAvailable === true) { diff --git a/src/shared/with-timeout.test.ts b/src/shared/with-timeout.test.ts new file mode 100644 index 00000000000..7952ade687e --- /dev/null +++ b/src/shared/with-timeout.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test" +import { withTimeout } from "./with-timeout" + +describe("withTimeout", () => { + it("should resolve before timeout", async () => { + const result = await withTimeout(Promise.resolve("done"), 1000) + expect(result).toBe("done") + }) + it("should reject on timeout with default message", async () => { + const slow = new Promise((resolve) => setTimeout(() => resolve("late"), 500)) + await expect(withTimeout(slow, 10)).rejects.toThrow("Operation timed out after 10ms") + }) + it("should reject on timeout with custom message", async () => { + const slow = new Promise((resolve) => setTimeout(() => resolve("late"), 500)) + await expect(withTimeout(slow, 10, "Custom timeout")).rejects.toThrow("Custom timeout") + }) + it("should reject on promise rejection", async () => { + await expect(withTimeout(Promise.reject(new Error("fail")), 1000)).rejects.toThrow("fail") + }) +}) diff --git a/src/shared/with-timeout.ts b/src/shared/with-timeout.ts new file mode 100644 index 00000000000..1a5b64422bb --- /dev/null +++ b/src/shared/with-timeout.ts @@ -0,0 +1,7 @@ +export function withTimeout(promise: Promise, timeoutMs: number, errorMessage?: string): Promise { + let timeoutId: ReturnType + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(errorMessage ?? `Operation timed out after ${timeoutMs}ms`)), timeoutMs) + }) + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)) +} diff --git a/src/tools/assembly/synthesizer.ts b/src/tools/assembly/synthesizer.ts index 9352d681532..891c6a85573 100644 --- a/src/tools/assembly/synthesizer.ts +++ b/src/tools/assembly/synthesizer.ts @@ -1,4 +1,5 @@ import type { BackgroundManager } from "../../features/background-agent/manager" +import { delay } from "../../shared/delay" import { POLL_INTERVAL_MS } from "./constants" import type { SynthesisResult, VoterResult } from "./types" @@ -137,6 +138,3 @@ function parseSynthesis( return { round, consensus, confidence, disagreements } } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/src/tools/assembly/voter-spawner.ts b/src/tools/assembly/voter-spawner.ts index 8683e061151..db5b03d36bc 100644 --- a/src/tools/assembly/voter-spawner.ts +++ b/src/tools/assembly/voter-spawner.ts @@ -1,4 +1,5 @@ import type { BackgroundManager } from "../../features/background-agent/manager" +import { delay } from "../../shared/delay" import { POLL_INTERVAL_MS, VOTER_TIMEOUT_MS } from "./constants" import type { ProviderModel, VoterResult } from "./types" @@ -95,6 +96,3 @@ Output your analysis in this format: return results } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/src/tools/background-task/delay.ts b/src/tools/background-task/delay.ts index e0b2c7ebf73..373feebeb3e 100644 --- a/src/tools/background-task/delay.ts +++ b/src/tools/background-task/delay.ts @@ -1,3 +1 @@ -export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} +export { delay } from "../../shared/delay" diff --git a/src/tools/background-task/time-format.ts b/src/tools/background-task/time-format.ts index 2f534fa162f..b80fc179fd7 100644 --- a/src/tools/background-task/time-format.ts +++ b/src/tools/background-task/time-format.ts @@ -1,9 +1,10 @@ +import { isRecord } from "../../shared/record-type-guard" export function formatMessageTime(value: unknown): string { if (typeof value === "string") { const date = new Date(value) return Number.isNaN(date.getTime()) ? value : date.toISOString() } - if (typeof value === "object" && value !== null) { + if (isRecord(value)) { if ("created" in value) { const created = (value as { created?: number }).created if (typeof created === "number") { diff --git a/src/tools/delegate-agent/background-executor.ts b/src/tools/delegate-agent/background-executor.ts index 349ad370ff8..dd55b30ba1f 100644 --- a/src/tools/delegate-agent/background-executor.ts +++ b/src/tools/delegate-agent/background-executor.ts @@ -4,7 +4,7 @@ import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/session-state" import { log } from "../../shared" import { formatDetailedError } from "../../shared/error-formatting" -import { getSessionTools } from "../../shared/session-tools-store" +import { getSessionTools } from "../../shared/session-state" import { getMessageDir } from "./message-dir" import type { DelegateAgentArgs } from "./types" diff --git a/src/tools/delegate-task/background-continuation.ts b/src/tools/delegate-task/background-continuation.ts index 7dacf17c41e..d3ca0016f38 100644 --- a/src/tools/delegate-task/background-continuation.ts +++ b/src/tools/delegate-task/background-continuation.ts @@ -1,5 +1,5 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" -import { getSessionTools } from "../../shared/session-tools-store" +import { getSessionTools } from "../../shared/session-state" import { formatDetailedError } from "./error-formatting" import type { ExecutorContext, ParentContext } from "./executor-types" import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index d9f792f52ea..705843067f8 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -1,6 +1,6 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDetailedError } from "../../shared/error-formatting" -import { getSessionTools } from "../../shared/session-tools-store" +import { getSessionTools } from "../../shared/session-state" import type { ExecutorContext, ParentContext } from "./executor-types" import { getTimingConfig } from "./timing" import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" diff --git a/src/tools/delegate-task/categories.ts b/src/tools/delegate-task/categories.ts index 1efdba59258..afc7fdeeb95 100644 --- a/src/tools/delegate-task/categories.ts +++ b/src/tools/delegate-task/categories.ts @@ -2,7 +2,7 @@ import type { CategoriesConfig, CategoryConfig } from "../../config/schema" import { log } from "../../shared/logger" import { isModelAvailable } from "../../shared/model-availability" import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" -import { resolveModel } from "../../shared/model-resolver" +import { normalizeModel } from "../../shared/model-resolution-pipeline" import type { TierResolverContext } from "../../shared/tier-resolver" import { resolveTier } from "../../shared/tier-resolver" import { CATEGORY_PROMPT_APPENDS, DEFAULT_CATEGORIES } from "./constants" @@ -64,11 +64,10 @@ export function resolveCategoryConfig( // Model priority for categories: user override > category default > system default // Categories have explicit models - no inheritance from parent session - const model = resolveModel({ - userModel: effectiveUserModel ?? userConfig?.model, - inheritedModel: effectiveDefaultModel ?? defaultConfig?.model, // Category's built-in model takes precedence over system default - systemDefault: systemDefaultModel, - }) + const model = + normalizeModel(effectiveUserModel ?? userConfig?.model) ?? + normalizeModel(effectiveDefaultModel ?? defaultConfig?.model) ?? + systemDefaultModel const hasUserModelOverride = (effectiveUserModel ?? userConfig?.model) !== undefined const config: CategoryConfig = { ...defaultConfig, diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index d2039be4336..3209c653210 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -4,7 +4,7 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDuration, getMessageDir, normalizeSDKResponse } from "../../shared" import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry" -import { setSessionTools } from "../../shared/session-tools-store" +import { setSessionTools } from "../../shared/session-state" import { getDeliverableTag, isPlanFamily } from "./constants" import type { ExecutorContext, SessionMessage } from "./executor-types" import { type SyncContinuationDeps, syncContinuationDeps } from "./sync-continuation-deps" diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index e273c9cd31c..38765bc8251 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -3,8 +3,7 @@ import { promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry, } from "../../shared/model-suggestion-retry" -import { setSessionTemperature } from "../../shared/session-temperature-store" -import { setSessionTools } from "../../shared/session-tools-store" +import { setSessionTemperature, setSessionTools } from "../../shared/session-state" import { isPlanFamily } from "./constants" import { formatDetailedError } from "./error-formatting" import type { DelegateTaskArgs, OpencodeClient } from "./types" diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index f758588890d..8293f3608bf 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -1,6 +1,6 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDuration, normalizeSDKResponse } from "../../shared" -import { getSessionTools } from "../../shared/session-tools-store" +import { getSessionTools } from "../../shared/session-state" import { formatDetailedError } from "./error-formatting" import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types" import { getTimingConfig } from "./timing" diff --git a/src/tools/look-at/assistant-message-extractor.ts b/src/tools/look-at/assistant-message-extractor.ts index 5482a00d8b5..653af8f56ef 100644 --- a/src/tools/look-at/assistant-message-extractor.ts +++ b/src/tools/look-at/assistant-message-extractor.ts @@ -1,4 +1,4 @@ -import { isObject } from "../../shared" +import { isRecord } from "../../shared/record-type-guard" type MessageTime = { created?: number } @@ -18,14 +18,14 @@ type SessionMessage = { } function asSessionMessage(value: unknown): SessionMessage | null { - if (!isObject(value)) return null + if (!isRecord(value)) return null const info = value.info const parts = value.parts return { - info: isObject(info) + info: isRecord(info) ? { role: typeof info.role === "string" ? info.role : undefined, - time: isObject(info.time) ? { created: typeof info.time.created === "number" ? info.time.created : undefined } : undefined, + time: isRecord(info.time) ? { created: typeof info.time.created === "number" ? info.time.created : undefined } : undefined, } : undefined, parts, @@ -39,7 +39,7 @@ function getCreatedTime(message: SessionMessage): number { function getTextParts(message: SessionMessage): MessagePart[] { if (!Array.isArray(message.parts)) return [] return message.parts - .filter((part): part is Record => isObject(part)) + .filter((part): part is Record => isRecord(part)) .map((part) => ({ type: typeof part.type === "string" ? part.type : undefined, text: typeof part.text === "string" ? part.text : undefined, diff --git a/src/tools/look-at/multimodal-agent-metadata.ts b/src/tools/look-at/multimodal-agent-metadata.ts index 3259162e9ff..89108b68ad7 100644 --- a/src/tools/look-at/multimodal-agent-metadata.ts +++ b/src/tools/look-at/multimodal-agent-metadata.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { isObject, log } from "../../shared" +import { log } from "../../shared" +import { isRecord } from "../../shared/record-type-guard" import { CONSTRUCT_AGENT } from "./constants" type AgentModel = { providerID: string; modelID: string } @@ -16,12 +17,12 @@ type AgentInfo = { } function toAgentInfo(value: unknown): AgentInfo | null { - if (!isObject(value)) return null + if (!isRecord(value)) return null const name = typeof value.name === "string" ? value.name : undefined const variant = typeof value.variant === "string" ? value.variant : undefined const modelValue = value.model const model = - isObject(modelValue) && + isRecord(modelValue) && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" ? { providerID: modelValue.providerID, modelID: modelValue.modelID } @@ -34,7 +35,7 @@ export async function resolveMultimodalLookerAgentMetadata( ): Promise { try { const agentsResult = await ctx.client.app?.agents?.() - const agentsRaw = isObject(agentsResult) ? agentsResult.data : undefined + const agentsRaw = isRecord(agentsResult) ? agentsResult.data : undefined const agents = Array.isArray(agentsRaw) ? agentsRaw.map(toAgentInfo).filter(Boolean) : [] const matched = agents.find( diff --git a/src/tools/session-manager/tools.ts b/src/tools/session-manager/tools.ts index 7b45d4089ef..b3eb89d5e13 100644 --- a/src/tools/session-manager/tools.ts +++ b/src/tools/session-manager/tools.ts @@ -20,12 +20,7 @@ import type { SearchResult, SessionInfoArgs, SessionListArgs, SessionReadArgs, S const SEARCH_TIMEOUT_MS = 60_000 const MAX_SESSIONS_TO_SCAN = 50 -function withTimeout(promise: Promise, ms: number, operation: string): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`${operation} timed out after ${ms}ms`)), ms)), - ]) -} +import { withTimeout } from "../../shared/with-timeout" export function createSessionManagerTools(ctx: PluginInput): Record { // Initialize storage client for SDK-based operations (beta mode) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index 4ac759a343a..992a8e8a314 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -1,8 +1,9 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { basename, join } from "node:path" import { loadBuiltinCommands } from "../../features/builtin-commands" -import { getOpenCodeConfigDir, parseFrontmatter, sanitizeModelField } from "../../shared" +import { getOpenCodeConfigDir, parseFrontmatter } from "../../shared" import { isMarkdownFile } from "../../shared/file-utils" +import { sanitizeModelField } from "../../shared/model-sanitizer" import type { CommandInfo, CommandMetadata, CommandScope } from "./types" function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { diff --git a/tests/hooks/read-image-resizer/hook.test.ts b/tests/hooks/read-image-resizer/hook.test.ts index 8d1c1215d1c..21590cfc1c1 100644 --- a/tests/hooks/read-image-resizer/hook.test.ts +++ b/tests/hooks/read-image-resizer/hook.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" -import * as sessionModelState from "../../../src/shared/session-model-state" +import * as sessionModelState from "../../../src/shared/session-state" import { createReadImageResizerHook } from "../../../src/hooks/read-image-resizer/hook" import * as imageDimensions from "../../../src/hooks/read-image-resizer/image-dimensions" import * as imageResizer from "../../../src/hooks/read-image-resizer/image-resizer" diff --git a/tests/shared/model-resolver.test.ts b/tests/shared/model-resolver.test.ts index 183940879ff..0aec94f3ded 100644 --- a/tests/shared/model-resolver.test.ts +++ b/tests/shared/model-resolver.test.ts @@ -1,108 +1,27 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import * as connectedProvidersCache from "../../src/shared/connected-providers-cache" import * as logger from "../../src/shared/logger" -import { type ExtendedModelResolutionInput, type ModelResolutionInput, resolveModel, resolveModelWithFallback } from "../../src/shared/model-resolver" +import { normalizeModel, resolveModelPipeline, type ModelResolutionRequest, type ModelResolutionProvenance, type ModelResolutionResult } from "../../src/shared/model-resolution-pipeline" -describe("resolveModel", () => { - describe("priority chain", () => { - test("returns userModel when all three are set", () => { - // given - const input: ModelResolutionInput = { - userModel: "anthropic/claude-opus-4-6", - inheritedModel: "openai/gpt-5.2", - systemDefault: "google/gemini-3-pro", - } - - // when - const result = resolveModel(input) - - // then - expect(result).toBe("anthropic/claude-opus-4-6") - }) - - test("returns inheritedModel when userModel is undefined", () => { - // given - const input: ModelResolutionInput = { - userModel: undefined, - inheritedModel: "openai/gpt-5.2", - systemDefault: "google/gemini-3-pro", - } - - // when - const result = resolveModel(input) - - // then - expect(result).toBe("openai/gpt-5.2") - }) - - test("returns systemDefault when both userModel and inheritedModel are undefined", () => { - // given - const input: ModelResolutionInput = { - userModel: undefined, - inheritedModel: undefined, - systemDefault: "google/gemini-3-pro", - } - - // when - const result = resolveModel(input) - - // then - expect(result).toBe("google/gemini-3-pro") - }) +describe("normalizeModel", () => { + test("trims whitespace", () => { + expect(normalizeModel(" abc ")).toBe("abc") }) - describe("empty string handling", () => { - test("treats empty string as unset, uses fallback", () => { - // given - const input: ModelResolutionInput = { - userModel: "", - inheritedModel: "openai/gpt-5.2", - systemDefault: "google/gemini-3-pro", - } - - // when - const result = resolveModel(input) - - // then - expect(result).toBe("openai/gpt-5.2") - }) - - test("treats whitespace-only string as unset, uses fallback", () => { - // given - const input: ModelResolutionInput = { - userModel: " ", - inheritedModel: "", - systemDefault: "google/gemini-3-pro", - } - - // when - const result = resolveModel(input) - - // then - expect(result).toBe("google/gemini-3-pro") - }) + test("returns undefined for empty string", () => { + expect(normalizeModel("")).toBeUndefined() }) - describe("purity", () => { - test("same input returns same output (referential transparency)", () => { - // given - const input: ModelResolutionInput = { - userModel: "anthropic/claude-opus-4-6", - inheritedModel: "openai/gpt-5.2", - systemDefault: "google/gemini-3-pro", - } - - // when - const result1 = resolveModel(input) - const result2 = resolveModel(input) + test("returns undefined for whitespace-only", () => { + expect(normalizeModel(" ")).toBeUndefined() + }) - // then - expect(result1).toBe(result2) - }) + test("returns undefined for undefined", () => { + expect(normalizeModel(undefined)).toBeUndefined() }) }) -describe("resolveModelWithFallback", () => { +describe("resolveModelPipeline", () => { let logSpy: ReturnType beforeEach(() => { @@ -113,761 +32,188 @@ describe("resolveModelWithFallback", () => { logSpy.mockRestore() }) - describe("Step 1: UI Selection (highest priority)", () => { - test("returns uiSelectedModel with override source when provided", () => { - // given - const input: ExtendedModelResolutionInput = { - uiSelectedModel: "opencode/glm-4.7-free", - userModel: "anthropic/claude-opus-4-6", - fallbackChain: [ - { providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then + function buildRequest(overrides: Partial = {}): ModelResolutionRequest { + return { + intent: {}, + constraints: { availableModels: new Set() }, + policy: {}, + ...overrides, + } + } + + describe("Step 1: Global Override", () => { + test("global override takes priority over everything", () => { + const result = resolveModelPipeline(buildRequest({ + intent: { globalOverrideModel: "opencode/glm-4.7-free", uiSelectedModel: "anthropic/claude-opus-4-6", userModel: "openai/gpt-5.2" }, + })) expect(result?.model).toBe("opencode/glm-4.7-free") - expect(result?.source).toBe("override") - expect(logSpy).toHaveBeenCalledWith("Model resolved via UI selection", { model: "opencode/glm-4.7-free" }) + expect(result?.provenance).toBe("override") }) + }) - test("UI selection takes priority over config override", () => { - // given - const input: ExtendedModelResolutionInput = { - uiSelectedModel: "opencode/glm-4.7-free", - userModel: "anthropic/claude-opus-4-6", - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then + describe("Step 2: UI Selection", () => { + test("returns uiSelectedModel", () => { + const result = resolveModelPipeline(buildRequest({ + intent: { uiSelectedModel: "opencode/glm-4.7-free", userModel: "anthropic/claude-opus-4-6" }, + constraints: { availableModels: new Set() }, + policy: { fallbackChain: [{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" }], systemDefaultModel: "google/gemini-3-pro" }, + })) expect(result?.model).toBe("opencode/glm-4.7-free") - expect(result?.source).toBe("override") - }) - - test("whitespace-only uiSelectedModel is treated as not provided", () => { - // given - const input: ExtendedModelResolutionInput = { - uiSelectedModel: " ", - userModel: "anthropic/claude-opus-4-6", - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" }) + expect(result?.provenance).toBe("override") + expect(logSpy).toHaveBeenCalledWith("Model resolved via UI selection", { model: "opencode/glm-4.7-free" }) }) - test("empty string uiSelectedModel falls through to config override", () => { - // given - const input: ExtendedModelResolutionInput = { - uiSelectedModel: "", - userModel: "anthropic/claude-opus-4-6", - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then + test("whitespace-only falls through", () => { + const result = resolveModelPipeline(buildRequest({ + intent: { uiSelectedModel: " ", userModel: "anthropic/claude-opus-4-6" }, + constraints: { availableModels: new Set(["anthropic/claude-opus-4-6"]) }, + policy: { systemDefaultModel: "google/gemini-3-pro" }, + })) expect(result?.model).toBe("anthropic/claude-opus-4-6") }) }) - describe("Step 2: Config Override", () => { - test("returns userModel with override source when userModel is provided", () => { - // given - const input: ExtendedModelResolutionInput = { - userModel: "anthropic/claude-opus-4-6", - fallbackChain: [ - { providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then + describe("Step 3: Config Override", () => { + test("returns userModel with override provenance", () => { + const result = resolveModelPipeline(buildRequest({ + intent: { userModel: "anthropic/claude-opus-4-6" }, + constraints: { availableModels: new Set(["anthropic/claude-opus-4-6"]) }, + policy: { fallbackChain: [{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" }], systemDefaultModel: "google/gemini-3-pro" }, + })) expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("override") - expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" }) - }) - - test("override takes priority even if model not in availableModels", () => { - // given - const input: ExtendedModelResolutionInput = { - userModel: "custom/my-model", - fallbackChain: [ - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.model).toBe("custom/my-model") - expect(result?.source).toBe("override") + expect(result?.provenance).toBe("override") }) test("whitespace-only userModel is treated as not provided", () => { - // given - const input: ExtendedModelResolutionInput = { - userModel: " ", - fallbackChain: [ - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.source).not.toBe("override") - }) - - test("empty string userModel is treated as not provided", () => { - // given - const input: ExtendedModelResolutionInput = { - userModel: "", - fallbackChain: [ - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.source).not.toBe("override") + const result = resolveModelPipeline(buildRequest({ + intent: { userModel: " " }, + constraints: { availableModels: new Set(["anthropic/claude-opus-4-6"]) }, + policy: { fallbackChain: [{ providers: ["anthropic"], model: "claude-opus-4-6" }], systemDefaultModel: "google/gemini-3-pro" }, + })) + expect(result?.provenance).not.toBe("override") }) }) - describe("Step 3: Provider fallback chain", () => { - test("tries providers in order within entry and returns first match", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then + describe("Step 4: Provider fallback chain", () => { + test("tries providers in order", () => { + const result = resolveModelPipeline(buildRequest({ + constraints: { availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]) }, + policy: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" }], systemDefaultModel: "google/gemini-3-pro" }, + })) expect(result?.model).toBe("github-copilot/claude-opus-4-6-preview") - expect(result?.source).toBe("provider-fallback") - expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", { - provider: "github-copilot", - model: "claude-opus-4-6", - match: "github-copilot/claude-opus-4-6-preview", - variant: undefined, - }) - }) - - test("respects provider priority order within entry", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["openai", "anthropic", "google"], model: "gpt-5.2" }, - ], - availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3-pro"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.model).toBe("openai/gpt-5.2") - expect(result?.source).toBe("provider-fallback") - }) - - test("tries next provider when first provider has no match", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic", "opencode"], model: "gpt-5-nano" }, - ], - availableModels: new Set(["opencode/gpt-5-nano"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.model).toBe("opencode/gpt-5-nano") - expect(result?.source).toBe("provider-fallback") + expect(result?.provenance).toBe("provider-fallback") }) - test("uses fuzzy matching within provider", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic", "github-copilot"], model: "claude-opus" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("provider-fallback") + test("cross-provider fuzzy match", () => { + const result = resolveModelPipeline(buildRequest({ + constraints: { availableModels: new Set(["opencode/glm-4.7"]) }, + policy: { fallbackChain: [{ providers: ["zai-coding-plan"], model: "glm-4.7", variant: "high" }], systemDefaultModel: "google/gemini-3-pro" }, + })) + expect(result?.model).toBe("opencode/glm-4.7") + expect(result?.variant).toBe("high") + expect(result?.provenance).toBe("provider-fallback") }) test("skips fallback chain when not provided", () => { - // given - const input: ExtendedModelResolutionInput = { - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.source).toBe("system-default") + const result = resolveModelPipeline(buildRequest({ + constraints: { availableModels: new Set(["anthropic/claude-opus-4-6"]) }, + policy: { systemDefaultModel: "google/gemini-3-pro" }, + })) + expect(result?.provenance).toBe("system-default") }) + }) - test("skips fallback chain when empty", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [], - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.source).toBe("system-default") - }) - - test("case-insensitive fuzzy matching", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic"], model: "CLAUDE-OPUS" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then + describe("Multi-entry fallbackChain", () => { + test("resolves second entry when first unavailable", () => { + const result = resolveModelPipeline(buildRequest({ + constraints: { availableModels: new Set(["anthropic/claude-opus-4-6"]) }, + policy: { + fallbackChain: [ + { providers: ["openai"], model: "gpt-5.2" }, + { providers: ["anthropic"], model: "claude-opus-4-6" }, + ], + systemDefaultModel: "system/default", + }, + })) expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("provider-fallback") - }) - - test("cross-provider fuzzy match when preferred provider unavailable (librarian scenario)", () => { - // given - glm-4.7 is defined for zai-coding-plan, but only opencode has it - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["zai-coding-plan"], model: "glm-4.7" }, - { providers: ["anthropic"], model: "claude-sonnet-4-5" }, - ], - availableModels: new Set(["opencode/glm-4.7", "anthropic/claude-sonnet-4-5"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should find glm-4.7 from opencode via cross-provider fuzzy match - expect(result?.model).toBe("opencode/glm-4.7") - expect(result?.source).toBe("provider-fallback") - expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (cross-provider fuzzy match)", { - model: "glm-4.7", - match: "opencode/glm-4.7", - variant: undefined, - }) - }) - - test("prefers specified provider over cross-provider match", () => { - // given - both zai-coding-plan and opencode have glm-4.7 - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["zai-coding-plan"], model: "glm-4.7" }, - ], - availableModels: new Set(["zai-coding-plan/glm-4.7", "opencode/glm-4.7"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should prefer zai-coding-plan (specified provider) over opencode - expect(result?.model).toBe("zai-coding-plan/glm-4.7") - expect(result?.source).toBe("provider-fallback") - }) - - test("cross-provider match preserves variant from entry", () => { - // given - entry has variant, model found via cross-provider - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["zai-coding-plan"], model: "glm-4.7", variant: "high" }, - ], - availableModels: new Set(["opencode/glm-4.7"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - variant should be preserved - expect(result?.model).toBe("opencode/glm-4.7") - expect(result?.variant).toBe("high") - }) - - test("cross-provider match tries next entry if no match found anywhere", () => { - // given - first entry model not available anywhere, second entry available - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["zai-coding-plan"], model: "nonexistent-model" }, - { providers: ["anthropic"], model: "claude-sonnet-4-5" }, - ], - availableModels: new Set(["anthropic/claude-sonnet-4-5"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should fall through to second entry - expect(result?.model).toBe("anthropic/claude-sonnet-4-5") - expect(result?.source).toBe("provider-fallback") + expect(result?.provenance).toBe("provider-fallback") }) }) - describe("Step 4: System default fallback (no availability match)", () => { - test("returns system default when no availability match found in fallback chain", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic"], model: "nonexistent-model" }, - ], - availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then + describe("System default fallback", () => { + test("returns system default when no match in fallback", () => { + const result = resolveModelPipeline(buildRequest({ + constraints: { availableModels: new Set(["openai/gpt-5.2"]) }, + policy: { fallbackChain: [{ providers: ["anthropic"], model: "nonexistent" }], systemDefaultModel: "google/gemini-3-pro" }, + })) expect(result?.model).toBe("google/gemini-3-pro") - expect(result?.source).toBe("system-default") - expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default") + expect(result?.provenance).toBe("system-default") }) - test("returns undefined when availableModels empty and no connected providers cache exists", () => { - // given - both model cache and connected-providers cache are missing (first run) - const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(), - systemDefaultModel: undefined, // no system default configured - } - - // when - const result = resolveModelWithFallback(input) - - // then - should return undefined to let OpenCode use Provider.defaultModel() + test("returns undefined when no system default and no match", () => { + const result = resolveModelPipeline(buildRequest({ + constraints: { availableModels: new Set(["openai/gpt-5.2"]) }, + policy: { fallbackChain: [{ providers: ["anthropic"], model: "nonexistent" }], systemDefaultModel: undefined }, + })) expect(result).toBeUndefined() - cacheSpy.mockRestore() }) - test("uses connected provider from fallback when availableModels empty but cache exists", () => { - // given - model cache missing but connected-providers cache exists - const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"]) - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic", "openai"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should use connected provider (openai) from fallback chain + test("uses connected provider cache when availableModels empty", () => { + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) + const result = resolveModelPipeline({ + intent: {}, + constraints: { availableModels: new Set() }, + policy: { fallbackChain: [{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" }], systemDefaultModel: "google/gemini-3-pro" }, + }) expect(result?.model).toBe("openai/claude-opus-4-6") - expect(result?.source).toBe("provider-fallback") - cacheSpy.mockRestore() - }) - - test("uses github-copilot when google not connected (construct scenario)", () => { - // given - user has github-copilot but not google connected - const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"]) - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }, - ], - availableModels: new Set(), - systemDefaultModel: "anthropic/claude-sonnet-4-5", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should use github-copilot (second provider) since google not connected - expect(result?.model).toBe("github-copilot/gemini-3-pro") - expect(result?.source).toBe("provider-fallback") + expect(result?.provenance).toBe("provider-fallback") cacheSpy.mockRestore() }) - test("falls through to system default when no provider in fallback is connected", () => { - // given - user only has quotio connected, but fallback chain has anthropic/opencode + test("falls through when no provider in fallback is connected", () => { const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["quotio"]) - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }, - ], - availableModels: new Set(), - systemDefaultModel: "quotio/claude-opus-4-6-20251101", - } - - // when - const result = resolveModelWithFallback(input) - - // then - no provider in fallback is connected, fall through to system default - expect(result?.model).toBe("quotio/claude-opus-4-6-20251101") - expect(result?.source).toBe("system-default") - cacheSpy.mockRestore() - }) - - test("falls through to system default when no cache and systemDefaultModel is provided", () => { - // given - no cache but system default is configured - const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should fall through to system default - expect(result?.model).toBe("google/gemini-3-pro") - expect(result?.source).toBe("system-default") - cacheSpy.mockRestore() - }) - - test("returns system default when fallbackChain is not provided", () => { - // given - const input: ExtendedModelResolutionInput = { - availableModels: new Set(["openai/gpt-5.2"]), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result?.model).toBe("google/gemini-3-pro") - expect(result?.source).toBe("system-default") - }) - }) - - describe("Multi-entry fallbackChain", () => { - test("resolves to claude-opus when OpenAI unavailable but Anthropic available (oracle scenario)", () => { - // given - const availableModels = new Set(["anthropic/claude-opus-4-6"]) - - // when - const result = resolveModelWithFallback({ - fallbackChain: [ - { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" }, - { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, - ], - availableModels, - systemDefaultModel: "system/default", + const result = resolveModelPipeline({ + intent: {}, + constraints: { availableModels: new Set() }, + policy: { fallbackChain: [{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }], systemDefaultModel: "quotio/claude-opus-4-6" }, }) - - // then - expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("provider-fallback") - }) - - test("tries all providers in first entry before moving to second entry", () => { - // given - const availableModels = new Set(["google/gemini-3-pro"]) - - // when - const result = resolveModelWithFallback({ - fallbackChain: [ - { providers: ["openai", "anthropic"], model: "gpt-5.2" }, - { providers: ["google"], model: "gemini-3-pro" }, - ], - availableModels, - systemDefaultModel: "system/default", - }) - - // then - expect(result?.model).toBe("google/gemini-3-pro") - expect(result?.source).toBe("provider-fallback") - }) - - test("returns first matching entry even if later entries have better matches", () => { - // given - const availableModels = new Set([ - "openai/gpt-5.2", - "anthropic/claude-opus-4-6", - ]) - - // when - const result = resolveModelWithFallback({ - fallbackChain: [ - { providers: ["openai"], model: "gpt-5.2" }, - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels, - systemDefaultModel: "system/default", - }) - - // then - expect(result?.model).toBe("openai/gpt-5.2") - expect(result?.source).toBe("provider-fallback") - }) - - test("falls through to system default when none match availability", () => { - // given - const availableModels = new Set(["other/model"]) - - // when - const result = resolveModelWithFallback({ - fallbackChain: [ - { providers: ["openai"], model: "gpt-5.2" }, - { providers: ["anthropic"], model: "claude-opus-4-6" }, - { providers: ["google"], model: "gemini-3-pro" }, - ], - availableModels, - systemDefaultModel: "system/default", - }) - - // then - expect(result?.model).toBe("system/default") - expect(result?.source).toBe("system-default") - }) - }) - - describe("Type safety", () => { - test("result has correct ModelResolutionResult shape", () => { - // given - const input: ExtendedModelResolutionInput = { - userModel: "anthropic/claude-opus-4-6", - availableModels: new Set(), - systemDefaultModel: "google/gemini-3-pro", - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result).toBeDefined() - expect(typeof result?.model).toBe("string") - expect(["override", "provider-fallback", "system-default"]).toContain(result?.source) + expect(result?.model).toBe("quotio/claude-opus-4-6") + expect(result?.provenance).toBe("system-default") + cacheSpy.mockRestore() }) }) - describe("categoryDefaultModel (fuzzy matching for category defaults)", () => { - test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => { - // given - gemini-3-pro is the category default, but only gemini-3-pro-preview is available - const input: ExtendedModelResolutionInput = { - categoryDefaultModel: "google/gemini-3-pro", - fallbackChain: [ - { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }, - ], - availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]), - systemDefaultModel: "anthropic/claude-sonnet-4-5", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should fuzzy match gemini-3-pro → gemini-3-pro-preview + describe("categoryDefaultModel", () => { + test("fuzzy matches categoryDefaultModel", () => { + const result = resolveModelPipeline(buildRequest({ + intent: { categoryDefaultModel: "google/gemini-3-pro" }, + constraints: { availableModels: new Set(["google/gemini-3-pro-preview"]) }, + policy: { systemDefaultModel: "anthropic/claude-sonnet-4-5" }, + })) expect(result?.model).toBe("google/gemini-3-pro-preview") - expect(result?.source).toBe("category-default") + expect(result?.provenance).toBe("category-default") }) - test("categoryDefaultModel uses exact match when available", () => { - // given - exact match exists - const input: ExtendedModelResolutionInput = { - categoryDefaultModel: "google/gemini-3-pro", - fallbackChain: [ - { providers: ["google"], model: "gemini-3-pro" }, - ], - availableModels: new Set(["google/gemini-3-pro", "google/gemini-3-pro-preview"]), - systemDefaultModel: "anthropic/claude-sonnet-4-5", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should use exact match - expect(result?.model).toBe("google/gemini-3-pro") - expect(result?.source).toBe("category-default") - }) - - test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => { - // given - categoryDefaultModel has no match, but fallbackChain does - const input: ExtendedModelResolutionInput = { - categoryDefaultModel: "google/gemini-3-pro", - fallbackChain: [ - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: "system/default", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should fall through to fallbackChain - expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("provider-fallback") - }) - - test("userModel takes priority over categoryDefaultModel", () => { - // given - both userModel and categoryDefaultModel provided - const input: ExtendedModelResolutionInput = { - userModel: "anthropic/claude-opus-4-6", - categoryDefaultModel: "google/gemini-3-pro", - fallbackChain: [ - { providers: ["google"], model: "gemini-3-pro" }, - ], - availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]), - systemDefaultModel: "system/default", - } - - // when - const result = resolveModelWithFallback(input) - - // then - userModel wins + test("falls through to fallbackChain when no match", () => { + const result = resolveModelPipeline(buildRequest({ + intent: { categoryDefaultModel: "google/gemini-3-pro" }, + constraints: { availableModels: new Set(["anthropic/claude-opus-4-6"]) }, + policy: { fallbackChain: [{ providers: ["anthropic"], model: "claude-opus-4-6" }], systemDefaultModel: "system/default" }, + })) expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("override") + expect(result?.provenance).toBe("provider-fallback") }) - test("categoryDefaultModel works when availableModels is empty but connected provider exists", () => { - // given - no availableModels but connected provider cache exists + test("works when availableModels empty but connected provider exists", () => { const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"]) - const input: ExtendedModelResolutionInput = { - categoryDefaultModel: "google/gemini-3-pro", - availableModels: new Set(), - systemDefaultModel: "anthropic/claude-sonnet-4-5", - } - - // when - const result = resolveModelWithFallback(input) - - // then - should use categoryDefaultModel since google is connected + const result = resolveModelPipeline(buildRequest({ + intent: { categoryDefaultModel: "google/gemini-3-pro" }, + constraints: { availableModels: new Set() }, + policy: { systemDefaultModel: "anthropic/claude-sonnet-4-5" }, + })) expect(result?.model).toBe("google/gemini-3-pro") - expect(result?.source).toBe("category-default") + expect(result?.provenance).toBe("category-default") cacheSpy.mockRestore() }) }) - - describe("Optional systemDefaultModel", () => { - test("returns undefined when systemDefaultModel is undefined and no fallback found", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic"], model: "nonexistent-model" }, - ], - availableModels: new Set(["openai/gpt-5.2"]), - systemDefaultModel: undefined, - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result).toBeUndefined() - }) - - test("returns undefined when no fallbackChain and systemDefaultModel is undefined", () => { - // given - const input: ExtendedModelResolutionInput = { - availableModels: new Set(["openai/gpt-5.2"]), - systemDefaultModel: undefined, - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result).toBeUndefined() - }) - - test("still returns override when userModel provided even if systemDefaultModel undefined", () => { - // given - const input: ExtendedModelResolutionInput = { - userModel: "anthropic/claude-opus-4-6", - availableModels: new Set(), - systemDefaultModel: undefined, - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result).toBeDefined() - expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("override") - }) - - test("still returns fallback match when systemDefaultModel undefined", () => { - // given - const input: ExtendedModelResolutionInput = { - fallbackChain: [ - { providers: ["anthropic"], model: "claude-opus-4-6" }, - ], - availableModels: new Set(["anthropic/claude-opus-4-6"]), - systemDefaultModel: undefined, - } - - // when - const result = resolveModelWithFallback(input) - - // then - expect(result).toBeDefined() - expect(result?.model).toBe("anthropic/claude-opus-4-6") - expect(result?.source).toBe("provider-fallback") - }) - }) }) diff --git a/tests/shared/session-tools-store.test.ts b/tests/shared/session-tools-store.test.ts index 3ec879a5330..2364979d5af 100644 --- a/tests/shared/session-tools-store.test.ts +++ b/tests/shared/session-tools-store.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test" -import { clearSessionTools, getSessionTools, setSessionTools } from "../../src/shared/session-tools-store" +import { clearSessionTools, getSessionTools, setSessionTools } from "../../src/shared/session-state" describe("session-tools-store", () => { beforeEach(() => {