diff --git a/packages/coding-agent/docs/refactor.md b/packages/coding-agent/docs/refactor.md index 1fb2dc50..4b3d0a22 100644 --- a/packages/coding-agent/docs/refactor.md +++ b/packages/coding-agent/docs/refactor.md @@ -575,11 +575,11 @@ async getAvailableModels(): Promise[]> { **Verification:** 1. `npm run check` passes -- [ ] Add `ModelCycleResult` interface -- [ ] Add `setModel()` method -- [ ] Add `cycleModel()` method with scoped/available variants -- [ ] Add `getAvailableModels()` method -- [ ] Verify with `npm run check` +- [x] Add `ModelCycleResult` interface +- [x] Add `setModel()` method +- [x] Add `cycleModel()` method with scoped/available variants +- [x] Add `getAvailableModels()` method +- [x] Verify with `npm run check` --- @@ -639,11 +639,11 @@ supportsThinking(): boolean { **Verification:** 1. `npm run check` passes -- [ ] Add `setThinkingLevel()` method -- [ ] Add `cycleThinkingLevel()` method -- [ ] Add `supportsThinking()` method -- [ ] Add `setQueueMode()` method and `queueMode` getter (see below) -- [ ] Verify with `npm run check` +- [x] Add `setThinkingLevel()` method +- [x] Add `cycleThinkingLevel()` method +- [x] Add `supportsThinking()` method +- [x] Add `setQueueMode()` method and `queueMode` getter (see below) +- [x] Verify with `npm run check` **Queue mode (add to same WP):** ```typescript diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 81a643f3..1881e140 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -16,7 +16,7 @@ import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; import { getModelsPath } from "../config.js"; -import { getApiKeyForModel } from "../model-config.js"; +import { getApiKeyForModel, getAvailableModels } from "../model-config.js"; import type { SessionManager } from "../session-manager.js"; import type { SettingsManager } from "../settings-manager.js"; import { expandSlashCommand, type FileSlashCommand } from "../slash-commands.js"; @@ -46,6 +46,14 @@ export interface PromptOptions { attachments?: Attachment[]; } +/** Result from cycleModel() */ +export interface ModelCycleResult { + model: Model; + thinkingLevel: ThinkingLevel; + /** Whether cycling through scoped models (--models flag) or all available */ + isScoped: boolean; +} + // ============================================================================ // AgentSession Class // ============================================================================ @@ -300,4 +308,160 @@ export class AgentSession { this._queuedMessages = []; // Note: caller should re-subscribe after reset if needed } + + // ========================================================================= + // Model Management + // ========================================================================= + + /** + * Set model directly. + * Validates API key, saves to session and settings. + * @throws Error if no API key available for the model + */ + async setModel(model: Model): Promise { + const apiKey = await getApiKeyForModel(model); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}/${model.id}`); + } + + this.agent.setModel(model); + this.sessionManager.saveModelChange(model.provider, model.id); + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + } + + /** + * Cycle to next model. + * Uses scoped models (from --models flag) if available, otherwise all available models. + * @returns The new model info, or null if only one model available + */ + async cycleModel(): Promise { + if (this._scopedModels.length > 0) { + return this._cycleScopedModel(); + } + return this._cycleAvailableModel(); + } + + private async _cycleScopedModel(): Promise { + if (this._scopedModels.length <= 1) return null; + + const currentModel = this.model; + let currentIndex = this._scopedModels.findIndex( + (sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider, + ); + + if (currentIndex === -1) currentIndex = 0; + const nextIndex = (currentIndex + 1) % this._scopedModels.length; + const next = this._scopedModels[nextIndex]; + + // Validate API key + const apiKey = await getApiKeyForModel(next.model); + if (!apiKey) { + throw new Error(`No API key for ${next.model.provider}/${next.model.id}`); + } + + // Apply model + this.agent.setModel(next.model); + this.sessionManager.saveModelChange(next.model.provider, next.model.id); + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + + // Apply thinking level (silently use "off" if not supported) + const effectiveThinking = next.model.reasoning ? next.thinkingLevel : "off"; + this.agent.setThinkingLevel(effectiveThinking); + this.sessionManager.saveThinkingLevelChange(effectiveThinking); + this.settingsManager.setDefaultThinkingLevel(effectiveThinking); + + return { model: next.model, thinkingLevel: effectiveThinking, isScoped: true }; + } + + private async _cycleAvailableModel(): Promise { + const { models: availableModels, error } = await getAvailableModels(); + if (error) throw new Error(`Failed to load models: ${error}`); + if (availableModels.length <= 1) return null; + + const currentModel = this.model; + let currentIndex = availableModels.findIndex( + (m) => m.id === currentModel?.id && m.provider === currentModel?.provider, + ); + + if (currentIndex === -1) currentIndex = 0; + const nextIndex = (currentIndex + 1) % availableModels.length; + const nextModel = availableModels[nextIndex]; + + const apiKey = await getApiKeyForModel(nextModel); + if (!apiKey) { + throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); + } + + this.agent.setModel(nextModel); + this.sessionManager.saveModelChange(nextModel.provider, nextModel.id); + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + + return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; + } + + /** + * Get all available models with valid API keys. + */ + async getAvailableModels(): Promise[]> { + const { models, error } = await getAvailableModels(); + if (error) throw new Error(error); + return models; + } + + // ========================================================================= + // Thinking Level Management + // ========================================================================= + + /** + * Set thinking level. + * Silently uses "off" if model doesn't support thinking. + * Saves to session and settings. + */ + setThinkingLevel(level: ThinkingLevel): void { + const effectiveLevel = this.supportsThinking() ? level : "off"; + this.agent.setThinkingLevel(effectiveLevel); + this.sessionManager.saveThinkingLevelChange(effectiveLevel); + this.settingsManager.setDefaultThinkingLevel(effectiveLevel); + } + + /** + * Cycle to next thinking level. + * @returns New level, or null if model doesn't support thinking + */ + cycleThinkingLevel(): ThinkingLevel | null { + if (!this.supportsThinking()) return null; + + const modelId = this.model?.id || ""; + const supportsXhigh = modelId.includes("codex-max"); + const levels: ThinkingLevel[] = supportsXhigh + ? ["off", "minimal", "low", "medium", "high", "xhigh"] + : ["off", "minimal", "low", "medium", "high"]; + + const currentIndex = levels.indexOf(this.thinkingLevel); + const nextIndex = (currentIndex + 1) % levels.length; + const nextLevel = levels[nextIndex]; + + this.setThinkingLevel(nextLevel); + return nextLevel; + } + + /** + * Check if current model supports thinking/reasoning. + */ + supportsThinking(): boolean { + return !!this.model?.reasoning; + } + + // ========================================================================= + // Queue Mode Management + // ========================================================================= + + /** + * Set message queue mode. + * Saves to settings. + */ + setQueueMode(mode: "all" | "one-at-a-time"): void { + this.agent.setQueueMode(mode); + this.settingsManager.setQueueMode(mode); + } }