diff --git a/PRODUCT.md b/PRODUCT.md index fa9c09b58..ab04d1834 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -62,6 +62,7 @@ No public inbound ports are required for normal usage. - Send each completed assistant response after completion signal from SSE - Show elapsed time for tool calls running longer than 20 seconds, updated on a timer so it keeps counting while a tool blocks without producing output; covers subagent cards and compact mode, and the total duration stays on the finished tool line. A finished subagent card keeps the time its whole run took. Durations use the same `Β· πŸ•’ 1h 2m 3s` format as the assistant run footer +- A subagent card shows Task, Agent, and Model; when OpenCode sends a variant, the Model line is `provider/id (variant)` - Render assistant replies with native Telegram formatting: real tables with the column alignment declared in markdown, bullet lists with their nesting, block quotes that keep their nested content, headings, and syntax-highlighted code. Numbered lists and checklists keep literal markers (`1.`, βœ…/πŸ”²), because Telegram clients number a native ordered list from zero and do not draw the native checkbox at all - Deliver reasoning as a collapsed quote that expands on tap - Hide full model reasoning by default; optionally stream it in the thinking message when explicitly enabled @@ -72,7 +73,7 @@ No public inbound ports are required for normal usage. ### Session status in chat - Keep a pinned status message in the chat -- Show session title, project, model, context usage, and changed files +- Show session title, project, model, context usage, and changed files; when a variant is set, the model line is `provider/id (variant)` - Auto-update status from SSE and tool events - Preserve pinned message ID across bot restarts @@ -109,7 +110,7 @@ Current command set: - `/status` - bot version, server, project, and session status - `/new` - create a new session - `/abort` - stop the current task -- `/detach` - detach the bot from the current session without stopping it +- `/detach` - detach the bot from the current session without stopping it; a later command or prompt HTTP failure for that session is not posted to chat unless the bot has re-attached to it - `/sessions` - show and switch recent sessions - `/messages` - browse user messages in the current session - `/projects` - show and switch projects @@ -148,6 +149,12 @@ Model picker behavior: - Picking a model opens the variant picker right after the confirmation when the model offers more than one selectable variant; a model with only `Default` ends at the confirmation +Agent picker behavior: + +- Picking an agent applies that agent's configured model and/or variant when the agent names + them; a field the agent does not name is left as it is. This is not a model pick and does + not open the variant menu + ### Main features already implemented - [x] Single-user access control by allowed Telegram user ID @@ -158,7 +165,7 @@ Model picker behavior: - [x] Background notifications for detached/non-current sessions in the currently selected project/worktree - [x] Telegram-friendly result delivery, including sending generated code/files when needed - [x] Interactive question and permission handling directly in chat (buttons + custom answers) -- [x] Live pinned session status in chat (project, model, context usage, changed files) +- [x] Live pinned session status in chat (project, model with variant in parentheses when set, context usage, changed files) - [x] In-chat controls for model, agent, variant, and context - [x] Built-in and custom command catalog access (`/commands`) - [x] Trusted local JSON commands from the persistent application home, executed without OpenCode or model tokens diff --git a/README.md b/README.md index 1816d5928..98aaef886 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Languages: English (`en`), Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ© (`ar`), Deutsch (`de`), EspaΓ±ol (`es` - **Live status** β€” pinned message with current project/worktree, model, context usage, and changed files list, updated in real time - **Model switching** β€” pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first), or browse all models by provider - **Agent modes** β€” switch between Plan and Build modes on the fly -- **Subagent activity** β€” watch live subagent progress in chat, including the current task, agent, model, and active tool step +- **Subagent activity** β€” watch live subagent progress in chat, including the current task, agent, model (with variant when set), and active tool step - **Custom Commands** β€” run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation - **Skills Catalog** β€” browse OpenCode skills from an inline menu and run them immediately or with arguments in the next message - **Interactive Q&A** β€” answer agent questions and approve permissions via inline buttons diff --git a/src/app/formatters/subagent-formatter.ts b/src/app/formatters/subagent-formatter.ts index 57a22ae1f..d7bc1b27b 100644 --- a/src/app/formatters/subagent-formatter.ts +++ b/src/app/formatters/subagent-formatter.ts @@ -9,12 +9,17 @@ import { formatCompactToolInfo } from "./summary-formatter.js"; import type { SubagentInfo } from "../managers/summary-aggregation-manager.js"; import type { ToolInfo } from "../managers/summary-aggregation-manager.js"; -function formatModelDisplayName(providerID?: string | null, modelID?: string | null): string { - if (providerID && modelID) { - return `${providerID}/${modelID}`; +function formatModelDisplayName( + providerID?: string | null, + modelID?: string | null, + variant?: string | null, +): string { + const name = providerID && modelID ? `${providerID}/${modelID}` : t("pinned.unknown"); + if (variant) { + return `${name} (${variant})`; } - return t("pinned.unknown"); + return name; } function shouldPreferInputDetails(tool: string, input?: { [key: string]: unknown }): boolean { @@ -121,7 +126,11 @@ export async function renderSubagentCard( subagent: SubagentInfo, now: number = Date.now(), ): Promise { - const modelName = formatModelDisplayName(subagent.providerID, subagent.modelID); + const modelName = formatModelDisplayName( + subagent.providerID, + subagent.modelID, + subagent.variant, + ); const lines = [ `🧩 ${t("subagent.line.task", { task: subagent.description })}`, t("subagent.line.agent", { agent: subagent.agent }), diff --git a/src/app/managers/summary-aggregation-manager.ts b/src/app/managers/summary-aggregation-manager.ts index 00a97f350..1db68398b 100644 --- a/src/app/managers/summary-aggregation-manager.ts +++ b/src/app/managers/summary-aggregation-manager.ts @@ -132,6 +132,7 @@ export interface SubagentInfo { status: SubagentStatus; providerID?: string | undefined; modelID?: string | undefined; + variant?: string | undefined; tokens: TokensInfo; cost: number; currentTool?: string | undefined; @@ -693,6 +694,7 @@ class SummaryAggregator { status: state.status, providerID: state.providerID, modelID: state.modelID, + variant: state.variant, tokens: { ...state.tokens }, cost: state.cost, currentTool: state.currentTool, @@ -718,6 +720,7 @@ class SummaryAggregator { status: subagent.status, providerID: subagent.providerID, modelID: subagent.modelID, + variant: subagent.variant, tokens: subagent.tokens, cost: subagent.cost, currentTool: subagent.currentTool, @@ -1005,6 +1008,7 @@ class SummaryAggregator { sessionID: string; providerID?: string; modelID?: string; + variant?: string; agent?: string; tokens?: { input: number; @@ -1028,6 +1032,9 @@ class SummaryAggregator { if (info.modelID) { subagent.modelID = info.modelID; } + if (info.variant) { + subagent.variant = info.variant; + } if (info.tokens) { subagent.tokens = { input: info.tokens.input, diff --git a/src/app/services/agent-selection-service.ts b/src/app/services/agent-selection-service.ts index dcaa800b6..0bb04e66a 100644 --- a/src/app/services/agent-selection-service.ts +++ b/src/app/services/agent-selection-service.ts @@ -1,6 +1,8 @@ import { opencodeClient } from "../../opencode/client.js"; import { getCurrentAgent, getCurrentProject, setCurrentAgent } from "../stores/settings-store.js"; import { getCurrentSession } from "./session-service.js"; +import { getStoredModel, selectModel } from "./model-selection-service.js"; +import { setCurrentVariant } from "./variant-selection-service.js"; import { logger } from "../../utils/logger.js"; import type { AgentInfo } from "../types/agent.js"; @@ -142,6 +144,62 @@ export function selectAgent(agentName: string): void { setCurrentAgent(agentName); } +function configuredField(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * Apply the listed agent's configured model and/or variant to current settings. + * Independent fields: a missing or empty one is left as it is. Used only by the + * agent picker β€” selectAgent itself stays a name write. + * @returns true when a model or variant was written (so the pinned dashboard can follow) + */ +export async function applyAgentConfiguredSettings(agentName: string): Promise { + try { + const agents = await getAvailableAgents(); + const agent = agents.find((entry) => entry.name === agentName); + if (!agent) { + logger.warn( + `[AgentManager] Could not read configured model/variant for agent "${agentName}"; leaving them unchanged`, + ); + return false; + } + + const providerID = configuredField(agent.model?.providerID); + const modelID = configuredField(agent.model?.modelID); + const variant = configuredField(agent.variant); + const hasModel = Boolean(providerID && modelID); + + if (hasModel && providerID && modelID) { + const storedVariant = variant ?? getStoredModel().variant ?? "default"; + selectModel({ + providerID, + modelID, + variant: storedVariant, + }); + logger.info( + `[AgentManager] Applied agent "${agentName}" model ${providerID}/${modelID} (${storedVariant})`, + ); + return true; + } + + if (variant) { + setCurrentVariant(variant); + logger.info(`[AgentManager] Applied agent "${agentName}" variant ${variant}`); + return true; + } + + return false; + } catch (err) { + logger.warn( + `[AgentManager] Failed to apply configured model/variant for agent "${agentName}"; leaving them unchanged`, + err, + ); + return false; + } +} + /** * Get stored agent from settings (synchronous) * @returns Current agent name or default "build" diff --git a/src/app/services/variant-selection-service.ts b/src/app/services/variant-selection-service.ts index 715d8b161..24703a09a 100644 --- a/src/app/services/variant-selection-service.ts +++ b/src/app/services/variant-selection-service.ts @@ -3,6 +3,7 @@ */ import { opencodeClient } from "../../opencode/client.js"; import { getCurrentModel, setCurrentModel } from "../stores/settings-store.js"; +import { getStoredModel } from "./model-selection-service.js"; import { logger } from "../../utils/logger.js"; import type { VariantInfo } from "../types/variant.js"; @@ -79,15 +80,17 @@ export function getCurrentVariant(): string { * @param variantId Variant ID to set */ export function setCurrentVariant(variantId: string): void { - const currentModel = getCurrentModel(); + const currentModel = getStoredModel(); - if (!currentModel) { + if (!currentModel.providerID || !currentModel.modelID) { logger.warn("[VariantManager] Cannot set variant: no current model"); return; } - currentModel.variant = variantId; - setCurrentModel(currentModel); + setCurrentModel({ + ...currentModel, + variant: variantId, + }); logger.info(`[VariantManager] Variant set to: ${variantId}`); } diff --git a/src/app/types/agent.ts b/src/app/types/agent.ts index 460128b56..933f5d8d1 100644 --- a/src/app/types/agent.ts +++ b/src/app/types/agent.ts @@ -8,6 +8,11 @@ export interface AgentInfo { mode: "subagent" | "primary" | "all"; hidden?: boolean; steps?: number; + model?: { + modelID: string; + providerID: string; + }; + variant?: string; } /** diff --git a/src/bot/callbacks/agent-selection-callback-handler.ts b/src/bot/callbacks/agent-selection-callback-handler.ts index 80a58cfe8..d9e384427 100644 --- a/src/bot/callbacks/agent-selection-callback-handler.ts +++ b/src/bot/callbacks/agent-selection-callback-handler.ts @@ -1,5 +1,8 @@ import { Context } from "grammy"; -import { selectAgent } from "../../app/services/agent-selection-service.js"; +import { + applyAgentConfiguredSettings, + selectAgent, +} from "../../app/services/agent-selection-service.js"; import { getStoredModel } from "../../app/services/model-selection-service.js"; import { formatVariantForButton } from "../../app/services/variant-selection-service.js"; import { getAgentDisplayName } from "../../app/types/agent.js"; @@ -41,13 +44,11 @@ export async function handleAgentSelect(ctx: Context): Promise { const agentName = callbackQuery.data.replace("agent:", ""); - // Select agent and persist selectAgent(agentName); + const settingsApplied = await applyAgentConfiguredSettings(agentName); - // Update keyboard manager state keyboardManager.updateAgent(agentName); - // Update Reply Keyboard with new agent, current model, and context const currentModel = getStoredModel(); const contextInfo = pinnedMessageManager.getContextInfo() ?? @@ -73,9 +74,12 @@ export async function handleAgentSelect(ctx: Context): Promise { clearActiveInlineMenu("agent_selected"); - // Send confirmation message with updated keyboard, then drop the inline menu await switched(ctx, t("agent.changed_message", { name: displayName }), keyboard); + if (settingsApplied) { + await pinnedMessageManager.refresh(); + } + return true; } catch (err) { clearActiveInlineMenu("agent_select_error"); diff --git a/src/bot/callbacks/command-catalog-callback-handler.ts b/src/bot/callbacks/command-catalog-callback-handler.ts index 9359aa0a0..2dbdf7b31 100644 --- a/src/bot/callbacks/command-catalog-callback-handler.ts +++ b/src/bot/callbacks/command-catalog-callback-handler.ts @@ -19,6 +19,7 @@ import { t } from "../../i18n/index.js"; import { cancelMenu } from "./feedback.js"; import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js"; import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js"; +import { attachManager } from "../../app/managers/attach-manager.js"; import { attachToSession, detachAttachedSession, @@ -312,7 +313,9 @@ export async function executeCommand( args, }); logger.error("[Commands] session.command error details:", error); - void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {}); + if (attachManager.isAttachedSession(session.id)) { + void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {}); + } return; } @@ -330,7 +333,9 @@ export async function executeCommand( args, }); logger.error("[Commands] session.command background failure details:", error); - void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {}); + if (attachManager.isAttachedSession(session.id)) { + void ctx.api.sendMessage(ctx.chat!.id, t("commands.execute_error")).catch(() => {}); + } }, }); } diff --git a/src/bot/callbacks/variant-selection-callback-handler.ts b/src/bot/callbacks/variant-selection-callback-handler.ts index e2fdc2e01..c31b99b1f 100644 --- a/src/bot/callbacks/variant-selection-callback-handler.ts +++ b/src/bot/callbacks/variant-selection-callback-handler.ts @@ -93,6 +93,7 @@ export async function handleVariantSelect(ctx: Context): Promise { // Send confirmation message with updated keyboard, then drop the inline menu await switched(ctx, t("variant.changed_message", { name: displayName }), keyboard); + await pinnedMessageManager.refresh(); return true; } catch (err) { diff --git a/src/bot/commands/status-command.ts b/src/bot/commands/status-command.ts index 6dd1d57e2..aaecd204c 100644 --- a/src/bot/commands/status-command.ts +++ b/src/bot/commands/status-command.ts @@ -38,7 +38,10 @@ export async function statusCommand(ctx: CommandContext) { // Add model information const currentModel = fetchCurrentModel(); - const modelDisplay = `🧠 ${currentModel.providerID}/${currentModel.modelID}`; + const modelName = `${currentModel.providerID}/${currentModel.modelID}`; + const modelDisplay = currentModel.variant + ? `🧠 ${modelName} (${currentModel.variant})` + : `🧠 ${modelName}`; message += `${t("status.line.model", { model: modelDisplay })}\n`; const currentProject = getCurrentProject(); diff --git a/src/bot/handlers/prompt.ts b/src/bot/handlers/prompt.ts index a14ddebfb..03d8242a9 100644 --- a/src/bot/handlers/prompt.ts +++ b/src/bot/handlers/prompt.ts @@ -25,6 +25,7 @@ import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js"; import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js"; +import { attachManager } from "../../app/managers/attach-manager.js"; import { attachToSession, detachAttachedSession, @@ -397,7 +398,9 @@ export async function processUserPrompt( logger.error("[Bot] session.promptAsync raw API error object:", error); // Send user-friendly error via API directly because ctx is no longer available - void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {}); + if (attachManager.isAttachedSession(currentSession.id)) { + void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {}); + } return; } @@ -412,7 +415,9 @@ export async function processUserPrompt( logger.error("[Bot] session.promptAsync background task failed", promptErrorLogContext); logger.error("[Bot] session.promptAsync background failure details:", details); logger.error("[Bot] session.promptAsync raw background error object:", error); - void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {}); + if (attachManager.isAttachedSession(currentSession.id)) { + void bot.api.sendMessage(ctx.chat!.id, t("bot.prompt_send_error")).catch(() => {}); + } }, }); diff --git a/src/bot/pinned/pinned-message-format.ts b/src/bot/pinned/pinned-message-format.ts index ac74700fe..3fe7b0d8e 100644 --- a/src/bot/pinned/pinned-message-format.ts +++ b/src/bot/pinned/pinned-message-format.ts @@ -15,9 +15,15 @@ export function formatTokenCount(count: number): string { export function formatModelDisplayName( providerID?: string | null, modelID?: string | null, + variant?: string | null, ): string { if (providerID && modelID) { - return `${providerID}/${modelID}`; + const name = `${providerID}/${modelID}`; + if (variant) { + return `${name} (${variant})`; + } + + return name; } return t("pinned.unknown"); diff --git a/src/bot/pinned/pinned-message-manager.ts b/src/bot/pinned/pinned-message-manager.ts index 890490a16..7232ee2c4 100644 --- a/src/bot/pinned/pinned-message-manager.ts +++ b/src/bot/pinned/pinned-message-manager.ts @@ -710,7 +710,11 @@ class PinnedMessageManager { */ private formatMessage(): string { const currentModel = getStoredModel(); - const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID); + const modelName = formatModelDisplayName( + currentModel.providerID, + currentModel.modelID, + currentModel.variant, + ); const projectDisplayName = this.state.projectBranch ? `${this.state.projectPath}: ${this.state.projectBranch}` : this.state.projectPath; diff --git a/tests/app/formatters/subagent-formatter.test.ts b/tests/app/formatters/subagent-formatter.test.ts index 5b8ece496..37145c2aa 100644 --- a/tests/app/formatters/subagent-formatter.test.ts +++ b/tests/app/formatters/subagent-formatter.test.ts @@ -51,6 +51,67 @@ describe("summary/subagent-formatter", () => { expect(text).not.toContain("Working:"); }); + describe("variant on the Model line", () => { + function buildCard(variant?: string) { + return { + cardId: "card-1", + sessionId: "child-1", + parentSessionId: "root-1", + agent: "explore", + description: "task description", + prompt: "task description", + status: "running" as const, + providerID: "openai", + modelID: "gpt-5.4", + ...(variant !== undefined ? { variant } : {}), + tokens: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } + + it("appends a non-empty variant in parentheses", async () => { + const text = await renderSubagentCard(buildCard("high")); + + expect(text).toContain("Model: openai/gpt-5.4 (high)"); + expect(text).toContain("Agent: explore"); + expect(text).toContain("🧩 Task: task description"); + }); + + it("keeps the Model line unchanged when variant is absent", async () => { + const text = await renderSubagentCard(buildCard()); + + expect(text).toContain("Model: openai/gpt-5.4"); + expect(text).not.toContain("("); + }); + + it("omits an empty variant", async () => { + const text = await renderSubagentCard(buildCard("")); + + expect(text).toContain("Model: openai/gpt-5.4"); + expect(text).not.toContain("()"); + }); + + it("shows a whitespace-only variant as-is", async () => { + const text = await renderSubagentCard(buildCard(" ")); + + expect(text).toContain("Model: openai/gpt-5.4 ( )"); + }); + + it("shows the literal default variant", async () => { + const text = await renderSubagentCard(buildCard("default")); + + expect(text).toContain("Model: openai/gpt-5.4 (default)"); + }); + }); + it("localizes labels and shows terminal completion state", async () => { setRuntimeLocale("ru"); diff --git a/tests/app/managers/summary-aggregation-manager.test.ts b/tests/app/managers/summary-aggregation-manager.test.ts index 1a3c1ab88..973ba8233 100644 --- a/tests/app/managers/summary-aggregation-manager.test.ts +++ b/tests/app/managers/summary-aggregation-manager.test.ts @@ -313,6 +313,119 @@ describe("summary/aggregator", () => { ]); }); + describe("subagent variant from assistant message", () => { + function createChildSession(): void { + summaryAggregator.processEvent({ + type: "session.created", + properties: { + info: { + id: "child-session-1", + parentID: "root-session", + title: "Explore architecture (@explore subagent)", + slug: "child", + directory: "D:/repo", + projectID: "p1", + version: "1", + time: { created: Date.now(), updated: Date.now() }, + }, + }, + } as unknown as Event); + } + + function sendAssistantMessage(fields: { variant?: string }): void { + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "child-message-1", + sessionID: "child-session-1", + role: "assistant", + parentID: "root-message", + providerID: "openai", + modelID: "gpt-5.4", + agent: "explore", + ...fields, + path: { cwd: "D:/repo", root: "D:/repo" }, + mode: "all", + cost: 0, + tokens: { + input: 1, + output: 1, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + } + + it("emits a variant from the child assistant message", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + createChildSession(); + sendAssistantMessage({ variant: "high" }); + + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ + sessionId: "child-session-1", + providerID: "openai", + modelID: "gpt-5.4", + variant: "high", + }), + ]); + }); + + it("emits again when a later message adds only a variant", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + createChildSession(); + sendAssistantMessage({}); + const callsAfterModel = onSubagent.mock.calls.length; + + sendAssistantMessage({ variant: "high" }); + + expect(onSubagent.mock.calls.length).toBeGreaterThan(callsAfterModel); + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ + providerID: "openai", + modelID: "gpt-5.4", + variant: "high", + }), + ]); + }); + + it("keeps the last variant when a later message omits it", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + createChildSession(); + sendAssistantMessage({ variant: "high" }); + sendAssistantMessage({}); + + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ variant: "high" }), + ]); + }); + + it("does not store an empty variant", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + createChildSession(); + sendAssistantMessage({ variant: "" }); + + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ + sessionId: "child-session-1", + }), + ]); + expect(onSubagent.mock.lastCall?.[1][0].variant).toBeUndefined(); + }); + }); + describe("subagent current tool timing", () => { function startSubagent(): void { summaryAggregator.setSession("root-session"); diff --git a/tests/app/services/agent-selection-service.test.ts b/tests/app/services/agent-selection-service.test.ts index ce9f94de2..e7fb6ac4e 100644 --- a/tests/app/services/agent-selection-service.test.ts +++ b/tests/app/services/agent-selection-service.test.ts @@ -25,6 +25,13 @@ const mocked = vi.hoisted(() => { const setCurrentAgentMock = vi.fn((agentName: string) => { currentAgent = agentName; }); + const selectModelMock = vi.fn(); + const getStoredModelMock = vi.fn(() => ({ + providerID: "stored-provider", + modelID: "stored-model", + variant: "high", + })); + const setCurrentVariantMock = vi.fn(); return { appAgentsMock, @@ -33,6 +40,9 @@ const mocked = vi.hoisted(() => { getCurrentSessionMock, getCurrentAgentMock, setCurrentAgentMock, + selectModelMock, + getStoredModelMock, + setCurrentVariantMock, loggerDebugMock: vi.fn(), loggerErrorMock: vi.fn(), loggerInfoMock: vi.fn(), @@ -70,6 +80,15 @@ vi.mock("../../../src/app/services/session-service.js", () => ({ getCurrentSession: mocked.getCurrentSessionMock, })); +vi.mock("../../../src/app/services/model-selection-service.js", () => ({ + selectModel: mocked.selectModelMock, + getStoredModel: mocked.getStoredModelMock, +})); + +vi.mock("../../../src/app/services/variant-selection-service.js", () => ({ + setCurrentVariant: mocked.setCurrentVariantMock, +})); + vi.mock("../../../src/utils/logger.js", () => ({ logger: { debug: mocked.loggerDebugMock, @@ -79,10 +98,21 @@ vi.mock("../../../src/utils/logger.js", () => ({ }, })); -import { fetchCurrentAgent, getAvailableAgents, resolveProjectAgent } from "../../../src/app/services/agent-selection-service.js"; +import { + applyAgentConfiguredSettings, + fetchCurrentAgent, + getAvailableAgents, + resolveProjectAgent, +} from "../../../src/app/services/agent-selection-service.js"; function createAgentResponse( - agents: Array<{ name: string; mode: "primary" | "all" | "subagent"; hidden?: boolean }>, + agents: Array<{ + name: string; + mode: "primary" | "all" | "subagent"; + hidden?: boolean; + model?: { modelID: string; providerID: string }; + variant?: string; + }>, ) { return { data: agents, @@ -98,6 +128,9 @@ describe("agent/manager", () => { mocked.getCurrentSessionMock.mockClear(); mocked.getCurrentAgentMock.mockClear(); mocked.setCurrentAgentMock.mockClear(); + mocked.selectModelMock.mockReset(); + mocked.getStoredModelMock.mockClear(); + mocked.setCurrentVariantMock.mockReset(); mocked.loggerDebugMock.mockReset(); mocked.loggerErrorMock.mockReset(); mocked.loggerInfoMock.mockReset(); @@ -191,3 +224,149 @@ describe("agent/manager", () => { expect(mocked.sessionMessagesMock).not.toHaveBeenCalled(); }); }); + +describe("applyAgentConfiguredSettings", () => { + beforeEach(() => { + mocked.appAgentsMock.mockReset(); + mocked.selectModelMock.mockReset(); + mocked.setCurrentVariantMock.mockReset(); + mocked.getStoredModelMock.mockClear(); + mocked.getStoredModelMock.mockReturnValue({ + providerID: "stored-provider", + modelID: "stored-model", + variant: "high", + }); + mocked.setCurrentProject({ + id: "project-1", + worktree: "/workspace/project-1", + name: "project-1", + }); + }); + + it("writes only the model and preserves the stored variant", async () => { + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { + name: "plan", + mode: "primary", + model: { providerID: "opencode-go", modelID: "kimi" }, + }, + ]), + ); + + const modelApplied = await applyAgentConfiguredSettings("plan"); + + expect(modelApplied).toBe(true); + expect(mocked.selectModelMock).toHaveBeenCalledWith({ + providerID: "opencode-go", + modelID: "kimi", + variant: "high", + }); + expect(mocked.setCurrentVariantMock).not.toHaveBeenCalled(); + }); + + it("writes only the variant and leaves the model", async () => { + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { + name: "plan", + mode: "primary", + variant: "low", + }, + ]), + ); + + const modelApplied = await applyAgentConfiguredSettings("plan"); + + expect(modelApplied).toBe(true); + expect(mocked.selectModelMock).not.toHaveBeenCalled(); + expect(mocked.setCurrentVariantMock).toHaveBeenCalledWith("low"); + }); + + it("writes both when the agent names model and variant", async () => { + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { + name: "plan", + mode: "primary", + model: { providerID: "opencode-go", modelID: "kimi" }, + variant: "max", + }, + ]), + ); + + const modelApplied = await applyAgentConfiguredSettings("plan"); + + expect(modelApplied).toBe(true); + expect(mocked.selectModelMock).toHaveBeenCalledWith({ + providerID: "opencode-go", + modelID: "kimi", + variant: "max", + }); + expect(mocked.setCurrentVariantMock).not.toHaveBeenCalled(); + }); + + it("leaves setters untouched when the agent names neither", async () => { + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([{ name: "plan", mode: "primary" }]), + ); + + const modelApplied = await applyAgentConfiguredSettings("plan"); + + expect(modelApplied).toBe(false); + expect(mocked.selectModelMock).not.toHaveBeenCalled(); + expect(mocked.setCurrentVariantMock).not.toHaveBeenCalled(); + }); + + it("leaves setters untouched when the listing fails", async () => { + mocked.appAgentsMock.mockResolvedValue({ data: null, error: { message: "unavailable" } }); + + const modelApplied = await applyAgentConfiguredSettings("plan"); + + expect(modelApplied).toBe(false); + expect(mocked.selectModelMock).not.toHaveBeenCalled(); + expect(mocked.setCurrentVariantMock).not.toHaveBeenCalled(); + }); + + it("treats empty model provider or id as unset", async () => { + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { + name: "plan", + mode: "primary", + model: { providerID: "", modelID: "kimi" }, + variant: "low", + }, + ]), + ); + + const modelApplied = await applyAgentConfiguredSettings("plan"); + + expect(modelApplied).toBe(true); + expect(mocked.selectModelMock).not.toHaveBeenCalled(); + expect(mocked.setCurrentVariantMock).toHaveBeenCalledWith("low"); + }); + + it("treats an empty variant string as unset and does not overwrite the stored variant", async () => { + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { + name: "plan", + mode: "primary", + model: { providerID: "opencode-go", modelID: "kimi" }, + variant: "", + }, + ]), + ); + + const modelApplied = await applyAgentConfiguredSettings("plan"); + + expect(modelApplied).toBe(true); + expect(mocked.selectModelMock).toHaveBeenCalledWith({ + providerID: "opencode-go", + modelID: "kimi", + variant: "high", + }); + expect(mocked.setCurrentVariantMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/app/services/variant-selection-service.test.ts b/tests/app/services/variant-selection-service.test.ts new file mode 100644 index 000000000..2a2c33ce0 --- /dev/null +++ b/tests/app/services/variant-selection-service.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + getStoredModelMock: vi.fn(), + getCurrentModelMock: vi.fn(), + setCurrentModelMock: vi.fn(), + loggerWarnMock: vi.fn(), + loggerInfoMock: vi.fn(), +})); + +vi.mock("../../../src/opencode/client.js", () => ({ + opencodeClient: { + config: { providers: vi.fn() }, + }, +})); + +vi.mock("../../../src/app/services/model-selection-service.js", () => ({ + getStoredModel: mocked.getStoredModelMock, +})); + +vi.mock("../../../src/app/stores/settings-store.js", () => ({ + getCurrentModel: mocked.getCurrentModelMock, + setCurrentModel: mocked.setCurrentModelMock, +})); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: mocked.loggerInfoMock, + warn: mocked.loggerWarnMock, + error: vi.fn(), + }, +})); + +import { setCurrentVariant } from "../../../src/app/services/variant-selection-service.js"; + +describe("setCurrentVariant", () => { + beforeEach(() => { + mocked.getStoredModelMock.mockReset(); + mocked.getCurrentModelMock.mockReset(); + mocked.setCurrentModelMock.mockReset(); + mocked.loggerWarnMock.mockReset(); + mocked.loggerInfoMock.mockReset(); + }); + + it("persists the fallback model when settings have no currentModel", () => { + mocked.getCurrentModelMock.mockReturnValue(undefined); + mocked.getStoredModelMock.mockReturnValue({ + providerID: "opencode-go", + modelID: "deepseek-v4-flash", + variant: "default", + }); + + setCurrentVariant("low"); + + expect(mocked.setCurrentModelMock).toHaveBeenCalledWith({ + providerID: "opencode-go", + modelID: "deepseek-v4-flash", + variant: "low", + }); + }); + + it("does not write when the fallback model has no provider or id", () => { + mocked.getStoredModelMock.mockReturnValue({ + providerID: "", + modelID: "", + variant: "default", + }); + + setCurrentVariant("low"); + + expect(mocked.setCurrentModelMock).not.toHaveBeenCalled(); + expect(mocked.loggerWarnMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/bot/commands/commands.test.ts b/tests/bot/commands/commands.test.ts index a25aeac9f..5da8db279 100644 --- a/tests/bot/commands/commands.test.ts +++ b/tests/bot/commands/commands.test.ts @@ -12,9 +12,11 @@ import { parseCommandPageCallback, } from "../../../src/bot/menus/command-catalog-menu.js"; import { interactionManager } from "../../../src/app/managers/interaction-manager.js"; +import { attachManager } from "../../../src/app/managers/attach-manager.js"; import { t } from "../../../src/i18n/index.js"; import { defined } from "../../helpers/defined.js"; import { foregroundSessionState } from "../../../src/app/managers/foreground-session-state-manager.js"; +import { logger } from "../../../src/utils/logger.js"; const mocked = vi.hoisted(() => ({ currentProject: { @@ -100,24 +102,6 @@ vi.mock("../../../src/app/services/model-selection-service.js", () => ({ vi.mock("../../../src/utils/safe-background-task.js", () => ({ safeBackgroundTask: vi.fn((options) => { mocked.safeBackgroundTaskMock(options); - try { - const taskPromise = options.task(); - void Promise.resolve(taskPromise) - .then((result) => { - if (options.onSuccess) { - return options.onSuccess(result); - } - }) - .catch((error) => { - if (options.onError) { - return options.onError(error); - } - }); - } catch (error) { - if (options.onError) { - void options.onError(error); - } - } }), })); @@ -190,10 +174,30 @@ function createDeps(): ExecuteCommandDeps { }; } +function getScheduledBackgroundTask(): { + task: () => Promise; + onSuccess?: (value: { error: unknown | null }) => void; + onError?: (error: unknown) => void; +} { + const [[options]] = mocked.safeBackgroundTaskMock.mock.calls as [ + [ + { + task: () => Promise; + onSuccess?: (value: { error: unknown | null }) => void; + onError?: (error: unknown) => void; + }, + ], + ]; + + return options; +} + describe("bot/commands/commands", () => { beforeEach(() => { interactionManager.clear("test_setup"); foregroundSessionState.__resetForTests(); + attachManager.__resetForTests(); + attachManager.attach("session-1", "D:\\Projects\\Repo"); mocked.currentProject = { id: "project-1", @@ -316,7 +320,8 @@ describe("bot/commands/commands", () => { const ctx = createCallbackContext("commands:execute", 400); const handled = await handleCommandsCallback(ctx, createDeps()); - await Promise.resolve(); + const backgroundTask = getScheduledBackgroundTask(); + await backgroundTask.task(); expect(handled).toBe(true); expect(interactionManager.getSnapshot()).toBeNull(); @@ -361,7 +366,8 @@ describe("bot/commands/commands", () => { const ctx = createTextContext("about spring"); const handled = await handleCommandTextArguments(ctx, createDeps()); - await Promise.resolve(); + const backgroundTask = getScheduledBackgroundTask(); + await backgroundTask.task(); expect(handled).toBe(true); expect(interactionManager.getSnapshot()).toBeNull(); @@ -387,6 +393,167 @@ describe("bot/commands/commands", () => { }); }); + it("notifies the user when session.command reports an error while attached", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "mixed", + metadata: { + flow: "commands", + stage: "confirm", + messageId: 400, + projectDirectory: "D:\\Projects\\Repo", + commandName: "poem", + }, + }); + + const ctx = createCallbackContext("commands:execute", 400); + const handled = await handleCommandsCallback(ctx, createDeps()); + const backgroundTask = getScheduledBackgroundTask(); + backgroundTask.onSuccess?.({ error: new Error("command failed") }); + + expect(handled).toBe(true); + expect(ctx.api.sendMessage).toHaveBeenCalledWith(777, t("commands.execute_error")); + }); + + it("notifies the user when session.command rejects while attached", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "mixed", + metadata: { + flow: "commands", + stage: "confirm", + messageId: 400, + projectDirectory: "D:\\Projects\\Repo", + commandName: "poem", + }, + }); + + const ctx = createCallbackContext("commands:execute", 400); + const handled = await handleCommandsCallback(ctx, createDeps()); + const backgroundTask = getScheduledBackgroundTask(); + const startError = new Error("network down"); + mocked.sessionCommandMock.mockRejectedValueOnce(startError); + + await backgroundTask.task().catch((error) => { + backgroundTask.onError?.(error); + }); + + expect(handled).toBe(true); + expect(ctx.api.sendMessage).toHaveBeenCalledWith(777, t("commands.execute_error")); + }); + + it("does not notify the user when session.command reports an error after detach", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "mixed", + metadata: { + flow: "commands", + stage: "confirm", + messageId: 400, + projectDirectory: "D:\\Projects\\Repo", + commandName: "poem", + }, + }); + + const ctx = createCallbackContext("commands:execute", 400); + const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); + const handled = await handleCommandsCallback(ctx, createDeps()); + + attachManager.clear("test_detach"); + + const backgroundTask = getScheduledBackgroundTask(); + backgroundTask.onSuccess?.({ error: new Error("command failed") }); + + expect(handled).toBe(true); + expect(ctx.api.sendMessage).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("does not notify the user when session.command rejects after detach", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "mixed", + metadata: { + flow: "commands", + stage: "confirm", + messageId: 400, + projectDirectory: "D:\\Projects\\Repo", + commandName: "poem", + }, + }); + + const ctx = createCallbackContext("commands:execute", 400); + const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); + const handled = await handleCommandsCallback(ctx, createDeps()); + + attachManager.clear("test_detach"); + + const backgroundTask = getScheduledBackgroundTask(); + const startError = new Error("network down"); + mocked.sessionCommandMock.mockRejectedValueOnce(startError); + + await backgroundTask.task().catch((error) => { + backgroundTask.onError?.(error); + }); + + expect(handled).toBe(true); + expect(ctx.api.sendMessage).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("does not notify the user when session.command fails while attached to another session", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "mixed", + metadata: { + flow: "commands", + stage: "confirm", + messageId: 400, + projectDirectory: "D:\\Projects\\Repo", + commandName: "poem", + }, + }); + + const ctx = createCallbackContext("commands:execute", 400); + const handled = await handleCommandsCallback(ctx, createDeps()); + + attachManager.attach("session-2", "D:\\Projects\\Repo"); + + const backgroundTask = getScheduledBackgroundTask(); + backgroundTask.onSuccess?.({ error: new Error("command failed") }); + + expect(handled).toBe(true); + expect(ctx.api.sendMessage).not.toHaveBeenCalled(); + }); + + it("still notifies the user when session.command fails after re-attach to the same session", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "mixed", + metadata: { + flow: "commands", + stage: "confirm", + messageId: 400, + projectDirectory: "D:\\Projects\\Repo", + commandName: "poem", + }, + }); + + const ctx = createCallbackContext("commands:execute", 400); + const handled = await handleCommandsCallback(ctx, createDeps()); + + attachManager.clear("test_detach"); + attachManager.attach("session-1", "D:\\Projects\\Repo"); + + const backgroundTask = getScheduledBackgroundTask(); + backgroundTask.onSuccess?.({ error: new Error("command failed") }); + + expect(handled).toBe(true); + expect(ctx.api.sendMessage).toHaveBeenCalledWith(777, t("commands.execute_error")); + }); + it("handles stale callback as inactive", async () => { interactionManager.start({ kind: "custom", diff --git a/tests/bot/commands/status.test.ts b/tests/bot/commands/status.test.ts index c2a8cda3f..c7c514aad 100644 --- a/tests/bot/commands/status.test.ts +++ b/tests/bot/commands/status.test.ts @@ -223,4 +223,64 @@ describe("bot/commands/status-command", () => { expect(replyText).toContain("Use /opencode_start to start the server."); expect(replyText).not.toContain("OpenCode version:"); }); + + it("appends a named variant on the Model line", async () => { + mocked.fetchCurrentModelMock.mockReturnValue({ + providerID: "openai", + modelID: "gpt-5", + variant: "low", + }); + + const ctx = { + chat: { id: 42, type: "private" }, + message: { text: "/status" }, + api: {}, + reply: vi.fn(), + } as unknown as Context; + + await statusCommand(ctx as never); + + const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string; + expect(message).toContain("Model: 🧠 openai/gpt-5 (low)"); + }); + + it("shows (default) when the current variant is default", async () => { + mocked.fetchCurrentModelMock.mockReturnValue({ + providerID: "openai", + modelID: "gpt-5", + variant: "default", + }); + + const ctx = { + chat: { id: 42, type: "private" }, + message: { text: "/status" }, + api: {}, + reply: vi.fn(), + } as unknown as Context; + + await statusCommand(ctx as never); + + const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string; + expect(message).toContain("Model: 🧠 openai/gpt-5 (default)"); + }); + + it("omits parentheses when the model has no variant", async () => { + mocked.fetchCurrentModelMock.mockReturnValue({ + providerID: "openai", + modelID: "gpt-5", + }); + + const ctx = { + chat: { id: 42, type: "private" }, + message: { text: "/status" }, + api: {}, + reply: vi.fn(), + } as unknown as Context; + + await statusCommand(ctx as never); + + const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string; + expect(message).toContain("Model: 🧠 openai/gpt-5"); + expect(message).not.toContain("gpt-5 ("); + }); }); diff --git a/tests/bot/handlers/agent.test.ts b/tests/bot/handlers/agent.test.ts index 7f0cacd2c..1c3f7d21d 100644 --- a/tests/bot/handlers/agent.test.ts +++ b/tests/bot/handlers/agent.test.ts @@ -2,19 +2,126 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocked = vi.hoisted(() => ({ getAvailableAgentsMock: vi.fn(), + selectAgentMock: vi.fn(), + applyAgentConfiguredSettingsMock: vi.fn(), + getStoredModelMock: vi.fn(), + ensureActiveInlineMenuMock: vi.fn(), + clearActiveInlineMenuMock: vi.fn(), + keyboardInitializeMock: vi.fn(), + keyboardUpdateAgentMock: vi.fn(), + keyboardUpdateModelMock: vi.fn(), + keyboardUpdateContextMock: vi.fn(), + keyboardGetStateMock: vi.fn(), + pinnedRefreshContextLimitMock: vi.fn(), + pinnedGetContextInfoMock: vi.fn(), + pinnedGetContextLimitMock: vi.fn(), + pinnedRefreshMock: vi.fn(), + createMainKeyboardMock: vi.fn(), + switchedMock: vi.fn(), + showVariantMenuAfterModelChangeMock: vi.fn(), })); vi.mock("../../../src/app/services/agent-selection-service.js", () => ({ fetchCurrentAgent: vi.fn(), getAvailableAgents: mocked.getAvailableAgentsMock, - selectAgent: vi.fn(), + selectAgent: mocked.selectAgentMock, + applyAgentConfiguredSettings: mocked.applyAgentConfiguredSettingsMock, +})); + +vi.mock("../../../src/app/services/model-selection-service.js", () => ({ + getStoredModel: mocked.getStoredModelMock, +})); + +vi.mock("../../../src/bot/menus/inline-menu.js", () => ({ + ensureActiveInlineMenu: mocked.ensureActiveInlineMenuMock, + clearActiveInlineMenu: mocked.clearActiveInlineMenuMock, +})); + +vi.mock("../../../src/bot/keyboards/keyboard-manager.js", () => ({ + keyboardManager: { + initialize: mocked.keyboardInitializeMock, + updateAgent: mocked.keyboardUpdateAgentMock, + updateModel: mocked.keyboardUpdateModelMock, + updateContext: mocked.keyboardUpdateContextMock, + getState: mocked.keyboardGetStateMock, + }, +})); + +vi.mock("../../../src/bot/keyboards/main-reply-keyboard.js", () => ({ + createMainKeyboard: mocked.createMainKeyboardMock, +})); + +vi.mock("../../../src/bot/pinned/pinned-message-manager.js", () => ({ + pinnedMessageManager: { + refreshContextLimit: mocked.pinnedRefreshContextLimitMock, + getContextInfo: mocked.pinnedGetContextInfoMock, + getContextLimit: mocked.pinnedGetContextLimitMock, + refresh: mocked.pinnedRefreshMock, + }, +})); + +vi.mock("../../../src/bot/callbacks/feedback.js", () => ({ + switched: mocked.switchedMock, + failure: vi.fn(), +})); + +vi.mock("../../../src/bot/menus/variant-selection-menu.js", () => ({ + showVariantSelectionMenuAfterModelChange: mocked.showVariantMenuAfterModelChangeMock, })); import { buildAgentSelectionMenu } from "../../../src/bot/menus/agent-selection-menu.js"; +import { handleAgentSelect } from "../../../src/bot/callbacks/agent-selection-callback-handler.js"; +import { t } from "../../../src/i18n/index.js"; +import { getAgentDisplayName } from "../../../src/app/types/agent.js"; + +function mockContext(overrides: Record = {}) { + return { + callbackQuery: undefined, + message: undefined, + chat: { id: 123 }, + api: {}, + answerCallbackQuery: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue({ message_id: 999 }), + deleteMessage: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as import("grammy").Context; +} describe("bot agent selection", () => { beforeEach(() => { mocked.getAvailableAgentsMock.mockReset(); + mocked.selectAgentMock.mockReset(); + mocked.applyAgentConfiguredSettingsMock.mockReset(); + mocked.getStoredModelMock.mockReset(); + mocked.ensureActiveInlineMenuMock.mockReset(); + mocked.clearActiveInlineMenuMock.mockReset(); + mocked.keyboardInitializeMock.mockReset(); + mocked.keyboardUpdateAgentMock.mockReset(); + mocked.keyboardUpdateModelMock.mockReset(); + mocked.keyboardUpdateContextMock.mockReset(); + mocked.keyboardGetStateMock.mockReset(); + mocked.pinnedRefreshContextLimitMock.mockReset(); + mocked.pinnedGetContextInfoMock.mockReset(); + mocked.pinnedGetContextLimitMock.mockReset(); + mocked.pinnedRefreshMock.mockReset(); + mocked.createMainKeyboardMock.mockReset(); + mocked.switchedMock.mockReset(); + mocked.showVariantMenuAfterModelChangeMock.mockReset(); + + mocked.ensureActiveInlineMenuMock.mockResolvedValue(true); + mocked.applyAgentConfiguredSettingsMock.mockResolvedValue(false); + mocked.getStoredModelMock.mockReturnValue({ + providerID: "opencode-go", + modelID: "kimi", + variant: "high", + }); + mocked.pinnedGetContextLimitMock.mockReturnValue(0); + mocked.pinnedGetContextInfoMock.mockReturnValue(null); + mocked.keyboardGetStateMock.mockReturnValue({ variantName: "πŸ’‘ High" }); + mocked.createMainKeyboardMock.mockReturnValue({}); + mocked.switchedMock.mockResolvedValue(undefined); + mocked.pinnedRefreshMock.mockResolvedValue(undefined); + mocked.pinnedRefreshContextLimitMock.mockResolvedValue(undefined); }); it("highlights the selected agent without uppercasing its name", async () => { @@ -28,4 +135,57 @@ describe("bot agent selection", () => { expect(keyboard.inline_keyboard[0]?.[0]?.text).toBe("βœ… πŸ€– Reviewer"); expect(keyboard.inline_keyboard[1]?.[0]?.text).toBe("πŸ› οΈ Build"); }); + + it("applies configured settings, confirms with the existing line, and does not open the variant menu", async () => { + mocked.applyAgentConfiguredSettingsMock.mockResolvedValueOnce(true); + const keyboard = { kind: "main" }; + mocked.createMainKeyboardMock.mockReturnValue(keyboard); + + const ctx = mockContext({ + callbackQuery: { data: "agent:plan" }, + }); + + const result = await handleAgentSelect(ctx); + + expect(result).toBe(true); + expect(mocked.selectAgentMock).toHaveBeenCalledWith("plan"); + expect(mocked.applyAgentConfiguredSettingsMock).toHaveBeenCalledWith("plan"); + expect(mocked.createMainKeyboardMock).toHaveBeenCalledWith( + "plan", + { providerID: "opencode-go", modelID: "kimi", variant: "high" }, + undefined, + "πŸ’‘ High", + ); + expect(mocked.switchedMock).toHaveBeenCalledWith( + ctx, + t("agent.changed_message", { name: getAgentDisplayName("plan") }), + keyboard, + ); + expect(mocked.pinnedRefreshMock).toHaveBeenCalledOnce(); + expect(mocked.showVariantMenuAfterModelChangeMock).not.toHaveBeenCalled(); + }); + + it("refreshes the pinned dashboard when only a variant was applied", async () => { + mocked.applyAgentConfiguredSettingsMock.mockResolvedValueOnce(true); + + const ctx = mockContext({ + callbackQuery: { data: "agent:plan" }, + }); + + await handleAgentSelect(ctx); + + expect(mocked.pinnedRefreshMock).toHaveBeenCalledOnce(); + expect(mocked.showVariantMenuAfterModelChangeMock).not.toHaveBeenCalled(); + }); + + it("does not refresh the pinned dashboard when neither model nor variant was applied", async () => { + const ctx = mockContext({ + callbackQuery: { data: "agent:plan" }, + }); + + await handleAgentSelect(ctx); + + expect(mocked.pinnedRefreshMock).not.toHaveBeenCalled(); + expect(mocked.showVariantMenuAfterModelChangeMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/bot/handlers/prompt.test.ts b/tests/bot/handlers/prompt.test.ts index b961a480b..58d59a4c1 100644 --- a/tests/bot/handlers/prompt.test.ts +++ b/tests/bot/handlers/prompt.test.ts @@ -7,8 +7,10 @@ import { type ProcessPromptDeps, } from "../../../src/bot/handlers/prompt.js"; import { promptAttachment } from "../../../src/app/managers/prompt-attachment-manager.js"; +import { attachManager } from "../../../src/app/managers/attach-manager.js"; import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; import { t } from "../../../src/i18n/index.js"; +import { logger } from "../../../src/utils/logger.js"; const mocked = vi.hoisted(() => ({ resolvePendingAttachmentMock: vi.fn(), @@ -196,6 +198,8 @@ function getScheduledBackgroundTask(): { describe("bot/handlers/prompt", () => { beforeEach(() => { + attachManager.__resetForTests(); + attachManager.attach("session-1", "D:\\Projects\\Repo"); mocked.currentProject = { id: "project-1", worktree: "D:\\Projects\\Repo" }; mocked.currentSession = { id: "session-1", @@ -312,6 +316,85 @@ describe("bot/handlers/prompt", () => { ); }); + it("does not notify the user when promptAsync reports an error after detach", async () => { + const ctx = createContext(); + const deps = createDeps(); + const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); + + const handled = await processUserPrompt(ctx, "Review README", deps); + + expect(handled).toBe(true); + + attachManager.clear("test_detach"); + + const backgroundTask = getScheduledBackgroundTask(); + backgroundTask.onSuccess?.({ error: new Error("request start failed") }); + + expect(deps.bot.api.sendMessage).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("does not notify the user when promptAsync rejects after detach", async () => { + const ctx = createContext(); + const deps = createDeps(); + const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); + + const handled = await processUserPrompt(ctx, "Review README", deps); + + expect(handled).toBe(true); + + attachManager.clear("test_detach"); + + const backgroundTask = getScheduledBackgroundTask(); + const startError = new Error("network down"); + mocked.sessionPromptAsyncMock.mockRejectedValueOnce(startError); + + await backgroundTask.task().catch((error) => { + backgroundTask.onError?.(error); + }); + + expect(deps.bot.api.sendMessage).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("does not notify the user when promptAsync fails while attached to another session", async () => { + const ctx = createContext(); + const deps = createDeps(); + + const handled = await processUserPrompt(ctx, "Review README", deps); + + expect(handled).toBe(true); + + attachManager.attach("session-2", "D:\\Projects\\Repo"); + + const backgroundTask = getScheduledBackgroundTask(); + backgroundTask.onSuccess?.({ error: new Error("request start failed") }); + + expect(deps.bot.api.sendMessage).not.toHaveBeenCalled(); + }); + + it("still notifies the user when promptAsync fails after re-attach to the same session", async () => { + const ctx = createContext(); + const deps = createDeps(); + + const handled = await processUserPrompt(ctx, "Review README", deps); + + expect(handled).toBe(true); + + attachManager.clear("test_detach"); + attachManager.attach("session-1", "D:\\Projects\\Repo"); + + const backgroundTask = getScheduledBackgroundTask(); + backgroundTask.onSuccess?.({ error: new Error("request start failed") }); + + expect(deps.bot.api.sendMessage).toHaveBeenCalledWith( + 777, + "Failed to send request to OpenCode.", + ); + }); + it("does not register suppression entry for file-only prompts", async () => { const handled = await processUserPrompt(createContext(), "", createDeps(), [ { diff --git a/tests/bot/handlers/variant.test.ts b/tests/bot/handlers/variant.test.ts new file mode 100644 index 000000000..e98d71431 --- /dev/null +++ b/tests/bot/handlers/variant.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + getStoredAgentMock: vi.fn(), + resolveProjectAgentMock: vi.fn(), + getStoredModelMock: vi.fn(), + setCurrentVariantMock: vi.fn(), + formatVariantForButtonMock: vi.fn(), + formatVariantForDisplayMock: vi.fn(), + ensureActiveInlineMenuMock: vi.fn(), + clearActiveInlineMenuMock: vi.fn(), + keyboardInitializeMock: vi.fn(), + keyboardUpdateModelMock: vi.fn(), + keyboardUpdateVariantMock: vi.fn(), + keyboardUpdateAgentMock: vi.fn(), + keyboardUpdateContextMock: vi.fn(), + pinnedRefreshContextLimitMock: vi.fn(), + pinnedGetContextInfoMock: vi.fn(), + pinnedGetContextLimitMock: vi.fn(), + pinnedRefreshMock: vi.fn(), + createMainKeyboardMock: vi.fn(), + switchedMock: vi.fn(), + notifyMock: vi.fn(), + failureMock: vi.fn(), +})); + +vi.mock("../../../src/app/services/agent-selection-service.js", () => ({ + getStoredAgent: mocked.getStoredAgentMock, + resolveProjectAgent: mocked.resolveProjectAgentMock, +})); + +vi.mock("../../../src/app/services/model-selection-service.js", () => ({ + getStoredModel: mocked.getStoredModelMock, +})); + +vi.mock("../../../src/app/services/variant-selection-service.js", () => ({ + setCurrentVariant: mocked.setCurrentVariantMock, + formatVariantForButton: mocked.formatVariantForButtonMock, + formatVariantForDisplay: mocked.formatVariantForDisplayMock, +})); + +vi.mock("../../../src/bot/menus/inline-menu.js", () => ({ + ensureActiveInlineMenu: mocked.ensureActiveInlineMenuMock, + clearActiveInlineMenu: mocked.clearActiveInlineMenuMock, +})); + +vi.mock("../../../src/bot/keyboards/keyboard-manager.js", () => ({ + keyboardManager: { + initialize: mocked.keyboardInitializeMock, + updateModel: mocked.keyboardUpdateModelMock, + updateVariant: mocked.keyboardUpdateVariantMock, + updateAgent: mocked.keyboardUpdateAgentMock, + updateContext: mocked.keyboardUpdateContextMock, + }, +})); + +vi.mock("../../../src/bot/keyboards/main-reply-keyboard.js", () => ({ + createMainKeyboard: mocked.createMainKeyboardMock, +})); + +vi.mock("../../../src/bot/pinned/pinned-message-manager.js", () => ({ + pinnedMessageManager: { + refreshContextLimit: mocked.pinnedRefreshContextLimitMock, + getContextInfo: mocked.pinnedGetContextInfoMock, + getContextLimit: mocked.pinnedGetContextLimitMock, + refresh: mocked.pinnedRefreshMock, + }, +})); + +vi.mock("../../../src/bot/callbacks/feedback.js", () => ({ + switched: mocked.switchedMock, + notify: mocked.notifyMock, + failure: mocked.failureMock, +})); + +import { handleVariantSelect } from "../../../src/bot/callbacks/variant-selection-callback-handler.js"; +import { t } from "../../../src/i18n/index.js"; + +function mockContext(overrides: Record = {}) { + return { + callbackQuery: undefined, + message: undefined, + chat: { id: 123 }, + api: {}, + answerCallbackQuery: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue({ message_id: 999 }), + deleteMessage: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as import("grammy").Context; +} + +describe("bot variant selection", () => { + beforeEach(() => { + mocked.getStoredAgentMock.mockReset(); + mocked.resolveProjectAgentMock.mockReset(); + mocked.getStoredModelMock.mockReset(); + mocked.setCurrentVariantMock.mockReset(); + mocked.formatVariantForButtonMock.mockReset(); + mocked.formatVariantForDisplayMock.mockReset(); + mocked.ensureActiveInlineMenuMock.mockReset(); + mocked.clearActiveInlineMenuMock.mockReset(); + mocked.keyboardInitializeMock.mockReset(); + mocked.keyboardUpdateModelMock.mockReset(); + mocked.keyboardUpdateVariantMock.mockReset(); + mocked.keyboardUpdateAgentMock.mockReset(); + mocked.keyboardUpdateContextMock.mockReset(); + mocked.pinnedRefreshContextLimitMock.mockReset(); + mocked.pinnedGetContextInfoMock.mockReset(); + mocked.pinnedGetContextLimitMock.mockReset(); + mocked.pinnedRefreshMock.mockReset(); + mocked.createMainKeyboardMock.mockReset(); + mocked.switchedMock.mockReset(); + mocked.notifyMock.mockReset(); + mocked.failureMock.mockReset(); + + mocked.ensureActiveInlineMenuMock.mockResolvedValue(true); + mocked.getStoredAgentMock.mockReturnValue("build"); + mocked.resolveProjectAgentMock.mockResolvedValue("build"); + mocked.getStoredModelMock.mockReturnValue({ + providerID: "opencode-go", + modelID: "kimi", + variant: "low", + }); + mocked.formatVariantForButtonMock.mockReturnValue("πŸ’­ Low"); + mocked.formatVariantForDisplayMock.mockReturnValue("Low"); + mocked.pinnedGetContextLimitMock.mockReturnValue(0); + mocked.pinnedGetContextInfoMock.mockReturnValue(null); + mocked.createMainKeyboardMock.mockReturnValue({}); + mocked.switchedMock.mockResolvedValue(undefined); + mocked.pinnedRefreshMock.mockResolvedValue(undefined); + mocked.pinnedRefreshContextLimitMock.mockResolvedValue(undefined); + }); + + it("refreshes the pinned dashboard after a successful variant pick", async () => { + const keyboard = { kind: "main" }; + mocked.createMainKeyboardMock.mockReturnValue(keyboard); + + const ctx = mockContext({ + callbackQuery: { data: "variant:low" }, + }); + + const result = await handleVariantSelect(ctx); + + expect(result).toBe(true); + expect(mocked.setCurrentVariantMock).toHaveBeenCalledWith("low"); + expect(mocked.switchedMock).toHaveBeenCalledWith( + ctx, + t("variant.changed_message", { name: "Low" }), + keyboard, + ); + expect(mocked.pinnedRefreshMock).toHaveBeenCalledOnce(); + }); + + it("does not refresh the pin when no model is selected", async () => { + mocked.getStoredModelMock.mockReturnValue({ + providerID: "", + modelID: "", + }); + + const ctx = mockContext({ + callbackQuery: { data: "variant:low" }, + }); + + const result = await handleVariantSelect(ctx); + + expect(result).toBe(true); + expect(mocked.setCurrentVariantMock).not.toHaveBeenCalled(); + expect(mocked.pinnedRefreshMock).not.toHaveBeenCalled(); + expect(mocked.notifyMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/bot/pinned/pinned-message-format.test.ts b/tests/bot/pinned/pinned-message-format.test.ts new file mode 100644 index 000000000..7dd47dbe4 --- /dev/null +++ b/tests/bot/pinned/pinned-message-format.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { formatModelDisplayName } from "../../../src/bot/pinned/pinned-message-format.js"; + +describe("formatModelDisplayName", () => { + it("appends a non-empty variant in parentheses", () => { + expect(formatModelDisplayName("opencode-go", "deepseek-v4-flash", "low")).toBe( + "opencode-go/deepseek-v4-flash (low)", + ); + }); + + it("shows the literal default variant", () => { + expect(formatModelDisplayName("opencode-go", "deepseek-v4-flash", "default")).toBe( + "opencode-go/deepseek-v4-flash (default)", + ); + }); + + it("omits parentheses when variant is absent", () => { + expect(formatModelDisplayName("opencode-go", "deepseek-v4-flash")).toBe( + "opencode-go/deepseek-v4-flash", + ); + }); + + it("omits an empty variant", () => { + expect(formatModelDisplayName("opencode-go", "deepseek-v4-flash", "")).toBe( + "opencode-go/deepseek-v4-flash", + ); + }); + + it("does not append a variant when the model is unknown", () => { + expect(formatModelDisplayName(undefined, undefined, "low")).toBe("Unknown"); + }); +}); diff --git a/tests/bot/pinned/pinned-message-manager.test.ts b/tests/bot/pinned/pinned-message-manager.test.ts index 7bafe998b..1f241b330 100644 --- a/tests/bot/pinned/pinned-message-manager.test.ts +++ b/tests/bot/pinned/pinned-message-manager.test.ts @@ -19,6 +19,7 @@ const mocked = vi.hoisted(() => ({ getStoredModel: vi.fn().mockReturnValue(null), getModelContextLimit: vi.fn().mockResolvedValue(204800), getGitWorktreeContext: vi.fn(), + formatModelDisplayName: vi.fn(() => "test-model"), })); vi.mock("../../../src/opencode/client.js", () => ({ opencodeClient: mocked.opencodeClient })); @@ -60,7 +61,7 @@ vi.mock("../../../src/bot/pinned/pinned-message-format.js", () => ({ DEFAULT_CONTEXT_LIMIT: 204800, formatContextLine: (used: number, limit: number) => `${used}/${limit}`, formatCostLine: (cost: number) => `$${cost.toFixed(2)}`, - formatModelDisplayName: () => "test-model", + formatModelDisplayName: mocked.formatModelDisplayName, })); // Must import AFTER vi.mock calls @@ -95,6 +96,8 @@ describe("pinned/manager", () => { mocked.getCurrentSession.mockReturnValue({ id: "ses-1", title: "Test Session" }); mocked.getCurrentProject.mockReturnValue({ id: "p1", worktree: "D:/repo", name: "repo" }); + mocked.formatModelDisplayName.mockReset(); + mocked.formatModelDisplayName.mockReturnValue("test-model"); mocked.getStoredModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5" }); mocked.getModelContextLimit.mockResolvedValue(204800); mocked.getPinnedMessageId.mockReturnValue(null); @@ -302,6 +305,20 @@ describe("pinned/manager", () => { }); }); + describe("model line", () => { + it("passes the stored variant into the model formatter", async () => { + mocked.getStoredModel.mockReturnValue({ + providerID: "openai", + modelID: "gpt-5", + variant: "low", + }); + + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + + expect(mocked.formatModelDisplayName).toHaveBeenCalledWith("openai", "gpt-5", "low"); + }); + }); + describe("setOnKeyboardUpdate race condition fix", () => { it("fires callback immediately with current state when contextLimit is known", async () => { // Create session to set contextLimit