diff --git a/PRODUCT.md b/PRODUCT.md index 787f99eee..bf0b62a02 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -48,7 +48,7 @@ No public inbound ports are required for normal usage. - Browse up to `SESSIONS_LIST_LIMIT` recent root sessions across projects and git worktrees with running, idle, question and permission status; select one to switch project and follow it, including after detach - Switching to an existing session adopts the agent, model, and variant it last ran with - Create a new session -- Use OpenCode-generated session title (based on conversation) +- Use OpenCode-generated session title (based on conversation); a session OpenCode has not named yet is shown as "new session" wherever the bot names a session, and `/status`, `/rename` and `/detach` name the current session with the title OpenCode has for it at that moment ### Task handling @@ -56,19 +56,21 @@ No public inbound ports are required for normal usage. - Accept voice/audio messages, transcribe via Whisper-compatible STT API, and forward recognized text as prompts - Interrupt current task (ESC equivalent) - Optionally accept text, transcribed voice, photos, rich formatted messages with photos, supported documents, and media groups sent while a task is running, at most `MAX_QUEUED_PROMPTS` (5) waiting at a time: on OpenCode V2 they wait in the session inbox and are steered into the running turn (Steer, the V2 default) or start their own run after it (Queue); on V1 the bot holds them, with at most 20 MiB of raw Telegram media bytes checked from reliable `file_size` before downloads; the V1 On/Off choice and the V2 mode are kept separately, so switching versions changes neither -- Handle OpenCode questions with inline options and custom text answers +- Handle OpenCode questions with inline options and custom text answers; the custom answer button is offered only when the question accepts a custom answer - In a multi-select question the custom text becomes one more tickable row next to the options, and Done sends it together with the ticked options -- Send selected/custom answers back to OpenCode (`question.reply`) +- Send selected/custom answers back to OpenCode (`question.reply`); on V2 a tapped choice is sent as the value OpenCode expects, while the buttons and the summary show its label - Handle permission requests interactively (`allow once` / `always` / `reject`) ### Result delivery - Send each completed assistant response after completion signal from SSE +- When the assistant footer is on, every answered turn of the followed session ends with its own footer — including a turn OpenCode starts by itself after a background command or subagent ends (V2) and a prompt typed in an attached OpenCode TUI or Desktop — with that turn's agent and model and the time from its own start; a turn that is aborted, errors, or is stopped from an attached client gets none - In draft streaming mode, assistant text written before a question or permission prompt is sent as a message above that prompt when it appears, and is not sent again when the reply completes - If that send fails, the reply is not sent again; when Telegram accepts sends again, the chat gets a notice that the last assistant reply was not delivered +- In edit streaming mode the chat reads in the order things happened: a reply the agent wrote before its next tool call, thinking, subagent, document, question or permission prompt sits above it, and the next tool opens a new message (in compact mode, a new progress message) below the reply - After a mid-session Telegram outage, the next new message is answered without restarting the app -- Compact output mode shows thinking and writing on one progress message per stretch of work between replies and prompts, from the start of that stretch; the message is removed or marked finished when the reply or prompt lands, or when the run ends if nothing followed -- In full mode, show every foreground tool operation when it starts, without a timer until 20 seconds; edit its line in place as it runs and finishes, keeping parallel operations in start order. A finished call lasting at least 20 seconds shows its total duration. Subagent (`task`) operations use their cards instead of tool lines; a tool delivered as a document loses its running text line when the document arrives +- Compact output mode shows thinking and writing on one progress message per stretch of work between replies and prompts, from the start of that stretch; the message is removed or marked finished when the reply or prompt lands, or when the run ends if nothing followed. A message whose stretch started a background operation (OpenCode V2) stays working on that operation with its timer past the reply, prompt or end of the run, and is removed or marked finished with its own counts when its last background operation ends +- In full mode, show every foreground tool operation when it starts, without a timer until 20 seconds; edit its line in place as it runs and finishes, keeping parallel operations in start order. A finished call lasting at least 20 seconds shows its total duration. Subagent (`task`) operations use their cards instead of tool lines; a tool delivered as a document loses its running text line when the document arrives. A background command or subagent (OpenCode V2) keeps its running line or card, with its timer, after the turn ends and gets its finished line or `✅ Completed` with the total duration when the operation itself ends; after `/abort`, a session switch or a lost event stream it stays as it was - 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. In compact mode, while several tools of one step are in flight, the progress line shows the still-running one (the most recently started if several), with that tool's timer — not a finished sibling. 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 @@ -135,6 +137,7 @@ Current command set: - `/skills` - browse and run OpenCode skills - `/opencode_start` - start local OpenCode server - `/opencode_stop` - stop local OpenCode server; available during an active request and kills the local process even if health is hung +- `/reload` - V2 only: reload the OpenCode configuration (config, plugins, providers and models, agents, commands, skills, MCP) for every loaded project without restarting the server; available during an active request, blocked while an interaction is on screen; the model menu reflects the reloaded providers at once - `/help` - show command help - `/ls` - interactive file browser for the current project directory; a text file can be attached to the next prompt from its detail view diff --git a/README.md b/README.md index 152d66437..cc4a9c7f4 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ opencode-telegram config | `/tasklist` | Browse and delete scheduled tasks | | `/opencode_start` | Start the local OpenCode server on the bot machine | | `/opencode_stop` | Stop the local OpenCode server, including during a run | +| `/reload` | Reload the OpenCode configuration without restarting the server (V2 only) | | `/help` | Show available commands | Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active. Voice/audio messages are transcribed and then sent as prompts when STT is configured. diff --git a/docs/release-notes/v0.26.1.md b/docs/release-notes/v0.26.1.md new file mode 100644 index 000000000..8db7ecf5e --- /dev/null +++ b/docs/release-notes/v0.26.1.md @@ -0,0 +1,15 @@ +## Changes +- **Reload the OpenCode configuration** — on OpenCode V2, `/reload` makes the server pick up changed config, providers, models, agents, MCP servers and skills without a restart, and the model menu shows the result at once. + +## Fixes +- **Plugin provider models survive a server start** — after `/opencode_start` or auto-restart, models from a plugin provider return to the model menu as soon as the server lists them, and a selected plugin model is no longer swapped for the config default. +- **Files accepted while an idle project wakes up** — on OpenCode V2, a photo, PDF or album sent to a model that supports it is no longer refused with "Current model doesn't support image input" after the project sat idle, and the model menu and pinned dashboard no longer come up empty at that moment. +- **Choice answers accepted on V2** — tapping a choice or `true`/`false` in an agent question, such as the web search provider prompt, now reaches the agent as that value instead of failing with "Failed to send answers to agent", and questions that allow no custom answer no longer offer `🔤 Custom answer`. +- **Background commands and subagents keep their timer** — on OpenCode V2, a command or subagent the agent sends to the background now stays running with its 🕒 timer until it actually ends and then shows its total duration, instead of freezing when the agent's turn ends. +- **Footer for turns the bot did not start** — OpenCode's own follow-up turn after a background command or subagent, and a prompt typed in an attached OpenCode TUI or Desktop, now end with the agent, model and duration footer too. +- **Replies stay in order** — with the default `edit` response streaming, a reply the agent wrote before its next command now appears above that command instead of below it. +- **Session name in `/status` and `/new`** — a session OpenCode has not titled yet is now shown as "new session" instead of a blank in `/status`, the `/new` reply and the session lists, and `/status` picks up the generated title once OpenCode names the session. +- **`/recent` with a deleted project folder** — `/recent` now shows the session list instead of a server error when one of the recent sessions belongs to a folder that no longer exists. +- **No false unauthorized warnings** — pinning the session dashboard no longer writes an "Unauthorized access attempt" warning with the bot's own ID to the log. + +Full changelog: https://github.com/grinev/opencode-telegram-bot/compare/v0.26.0...v0.26.1 diff --git a/package-lock.json b/package-lock.json index 43dfd9f1c..3f6135554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@grinev/opencode-telegram-bot", - "version": "0.26.0", + "version": "0.26.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@grinev/opencode-telegram-bot", - "version": "0.26.0", + "version": "0.26.1", "license": "MIT", "dependencies": { "@google-cloud/text-to-speech": "^6.4.0", diff --git a/package.json b/package.json index a9882502c..0c468d90b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@grinev/opencode-telegram-bot", - "version": "0.26.0", + "version": "0.26.1", "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.", "type": "module", "main": "./dist/index.js", diff --git a/src/app/bootstrap/app-container.ts b/src/app/bootstrap/app-container.ts index 1628c1870..6957118ba 100644 --- a/src/app/bootstrap/app-container.ts +++ b/src/app/bootstrap/app-container.ts @@ -65,6 +65,8 @@ export interface AppContainer { resetAggregator(): void; /** Clears response streams, tool trackers, background tracking and run state. */ resetRuntimeStreams(reason: string): void; + /** Stops following background operations; their lines and cards stay as they are. */ + stopBackgroundOperations(reason: string, sessionId?: string): void; /** * Stops ready-restore, the model catalog wait, event listening and the heartbeat, * and clears runtime state. @@ -134,6 +136,8 @@ export function createAppContainer(): AppContainer { resetInteractionError: (scope, reason) => interactionManager.clearErrorScope(scope, reason), resetAggregator: () => summaryAggregator.clear(), resetRuntimeStreams: (reason) => eventSubscriptionService.clearRuntimeState(reason), + stopBackgroundOperations: (reason, sessionId) => + eventSubscriptionService.stopBackgroundOperations(reason, sessionId), cleanupProcess: (reason) => { stopReadyRestore(); diff --git a/src/app/formatters/session-title-formatter.ts b/src/app/formatters/session-title-formatter.ts new file mode 100644 index 000000000..e05ef3a24 --- /dev/null +++ b/src/app/formatters/session-title-formatter.ts @@ -0,0 +1,9 @@ +import { t } from "../../i18n/index.js"; + +/** + * Name a session is shown under. OpenCode V2 leaves a new session untitled until + * it names it after the first prompt; until then it reads like the dashboard's. + */ +export function formatSessionTitle(title: string): string { + return title || t("pinned.default_session_title"); +} diff --git a/src/app/formatters/tool-message-batcher.ts b/src/app/formatters/tool-message-batcher.ts index ababcc103..02de5431c 100644 --- a/src/app/formatters/tool-message-batcher.ts +++ b/src/app/formatters/tool-message-batcher.ts @@ -3,21 +3,26 @@ import { logger } from "../../utils/logger.js"; type SendTextCallback = (sessionId: string, text: string) => Promise; type SendFileCallback = (sessionId: string, fileData: CodeFileData) => Promise; +type TextGate = () => Promise; interface ToolMessageBatcherOptions { sendText: SendTextCallback; sendFile: SendFileCallback; + /** Asked when a text message is queued: what its send has to wait for, if anything. */ + takeTextGate?: (sessionId: string) => TextGate | undefined; } export class ToolMessageBatcher { private readonly sendText: SendTextCallback; private readonly sendFile: SendFileCallback; + private readonly takeTextGate: ToolMessageBatcherOptions["takeTextGate"]; private readonly sessionTasks: Map> = new Map(); private generation = 0; constructor(options: ToolMessageBatcherOptions) { this.sendText = options.sendText; this.sendFile = options.sendFile; + this.takeTextGate = options.takeTextGate; } enqueue(sessionId: string, message: string): void { @@ -31,10 +36,12 @@ export class ToolMessageBatcher { } const expectedGeneration = this.generation; + const gate = this.takeTextGate?.(sessionId); logger.debug(`[ToolBatcher] Sending text message: session=${sessionId}, reason=${reason}`); - void this.enqueueTask(sessionId, () => - this.sendTextSafe(sessionId, normalizedMessage, reason, expectedGeneration), - ); + void this.enqueueTask(sessionId, async () => { + await gate?.(); + await this.sendTextSafe(sessionId, normalizedMessage, reason, expectedGeneration); + }); } enqueueUniqueByPrefix(sessionId: string, message: string, prefix: string): void { diff --git a/src/app/managers/assistant-run-state-manager.ts b/src/app/managers/assistant-run-state-manager.ts index c26e5c5c2..e148cd4e2 100644 --- a/src/app/managers/assistant-run-state-manager.ts +++ b/src/app/managers/assistant-run-state-manager.ts @@ -16,6 +16,8 @@ export interface AssistantRunResolvedInfo { export interface AssistantRunInfo extends AssistantRunStartInfo { sessionId: string; + /** False for a turn the bot only observed: OpenCode's own follow-up or a prompt typed in a client. */ + startedByBot: boolean; actualAgent?: string | undefined; actualProviderID?: string | undefined; actualModelID?: string | undefined; @@ -33,6 +35,7 @@ export class AssistantRunState { resetStreamThrottle(sessionId); this.runs.set(sessionId, { sessionId, + startedByBot: true, startedAt: info.startedAt, configuredAgent: info.configuredAgent, configuredProviderID: info.configuredProviderID, @@ -45,6 +48,22 @@ export class AssistantRunState { ); } + /** Opens a run for a turn the bot did not start; its agent and model come with its reply. */ + startObservedRun(sessionId: string, startedAt: number): void { + if (!sessionId || this.runs.has(sessionId)) { + return; + } + + this.runs.set(sessionId, { + sessionId, + startedByBot: false, + startedAt, + hasCompletedResponse: false, + }); + + logger.debug(`[AssistantRunState] Started observed run: session=${sessionId}`); + } + markResponseCompleted(sessionId: string, info?: AssistantRunResolvedInfo): void { const run = this.runs.get(sessionId); if (!run) { @@ -67,6 +86,10 @@ export class AssistantRunState { return this.runs.has(sessionId); } + hasBotRun(sessionId: string): boolean { + return this.runs.get(sessionId)?.startedByBot === true; + } + isResponseCompleted(sessionId: string): boolean { return this.runs.get(sessionId)?.hasCompletedResponse === true; } diff --git a/src/app/managers/question-manager.ts b/src/app/managers/question-manager.ts index 93829c4a0..5cb4c4888 100644 --- a/src/app/managers/question-manager.ts +++ b/src/app/managers/question-manager.ts @@ -1,7 +1,11 @@ -import type { Question, QuestionState, QuestionAnswer } from "../types/question.js"; +import type { Question, QuestionOption, QuestionState, QuestionAnswer } from "../types/question.js"; import type { InteractionManager } from "./interaction-manager.js"; import { logger } from "../../utils/logger.js"; +function formatOptionLine(option: QuestionOption): string { + return `* ${option.label}: ${option.description}`; +} + export class QuestionManager { constructor(private readonly interactionManager: InteractionManager) {} @@ -105,7 +109,7 @@ export class QuestionManager { const selected = state.selectedOptions.get(questionIndex) || new Set(); const options = Array.from(selected).flatMap((idx) => { const opt = question.options[idx]; - return opt ? [`* ${opt.label}: ${opt.description}`] : []; + return opt ? [formatOptionLine(opt)] : []; }); return options.join("\n"); @@ -183,6 +187,41 @@ export class QuestionManager { return items; } + /** + * The answer items sent to OpenCode for one question. A choice that carries a value is + * sent as that value; one without is sent as its display line, like `getAnswerItems`. + */ + getReplyItems(questionIndex: number): string[] { + const question = this.state?.questions[questionIndex]; + if (!question) { + return []; + } + + const customAnswer = this.getCustomAnswer(questionIndex); + if (!question.multiple && customAnswer) { + return this.getAnswerItems(questionIndex); + } + + const items = Array.from(this.getSelectedOptions(questionIndex)).flatMap((idx) => { + const opt = question.options[idx]; + if (!opt) { + return []; + } + if (opt.value !== undefined) { + return [opt.value]; + } + return formatOptionLine(opt) + .split("\n") + .filter((part) => part.trim()); + }); + + if (question.multiple && customAnswer && this.isCustomAnswerSelected(questionIndex)) { + items.push(customAnswer); + } + + return items; + } + nextQuestion(): void { const state = this.state; if (!state) { diff --git a/src/app/managers/summary-aggregation-manager.ts b/src/app/managers/summary-aggregation-manager.ts index c01b8b2ac..8f9ea26dd 100644 --- a/src/app/managers/summary-aggregation-manager.ts +++ b/src/app/managers/summary-aggregation-manager.ts @@ -141,6 +141,8 @@ export interface SubagentInfo { currentToolCallId?: string | undefined; currentToolStartedAt?: number | undefined; terminalMessage?: string | undefined; + /** Launched in the background: the card outlives its parent's turn. */ + background?: boolean | undefined; createdAt: number; finishedAt?: number | undefined; updatedAt: number; @@ -161,7 +163,12 @@ export interface SessionRetryInfo { type SessionRetryCallback = (retryInfo: SessionRetryInfo) => void; -type SessionIdleCallback = (sessionId: string) => void; +export interface SessionIdleInfo { + /** OpenCode V2 stopped the execution instead of finishing it. */ + interrupted: boolean; +} + +type SessionIdleCallback = (sessionId: string, idleInfo: SessionIdleInfo) => void; type PermissionCallback = (request: PermissionRequest) => void | Promise; @@ -237,6 +244,11 @@ function isUpstreamEmptyResponseText(text: string, isFinal: boolean): boolean { return isFinal ? trimmed.includes(UPSTREAM_EMPTY_RESPONSE_KEY) : true; } +/** The V2 adapter marks the idle of an execution that was interrupted rather than finished. */ +function isInterruptedIdle(properties: object): boolean { + return "interrupted" in properties && properties.interrupted === true; +} + function extractFirstUpdatedFileFromTitle(title: string): string { for (const rawLine of title.split("\n")) { const line = rawLine.trim(); @@ -337,6 +349,8 @@ export class SummaryAggregator { private acceptsSubagentEvents = false; private subagentRunStartedAt = 0; private lastSubagentSnapshot = ""; + // When the current session's running turn began, whoever started it; null between turns. + private liveTurnStartedAt: number | null = null; setBotAndChatId(bot: Bot, chatId: number): void { this.bot = bot; @@ -431,6 +445,16 @@ export class SummaryAggregator { this.onClearedCallback = callback; } + /** Start of the session's running turn, or null when it is not the current one or is idle. */ + getLiveTurnStartedAt(sessionId: string): number | null { + return sessionId === this.currentSessionId ? this.liveTurnStartedAt : null; + } + + /** Forgets the running turn: after a gap in the event stream its end may have been missed. */ + forgetLiveTurn(): void { + this.liveTurnStartedAt = null; + } + holdOutbound(): void { this.outboundHeld = true; } @@ -630,6 +654,7 @@ export class SummaryAggregator { this.acceptsSubagentEvents = false; this.subagentRunStartedAt = 0; this.lastSubagentSnapshot = ""; + this.liveTurnStartedAt = null; this.permissionQueue = Promise.resolve(); this.messageCount = 0; @@ -748,12 +773,42 @@ export class SummaryAggregator { this.lastSubagentSnapshot = ""; } - private retireAllSubagents(): void { + private isRunningBackgroundSubagent(cardId: string): boolean { + const state = this.subagentStates.get(cardId); + return Boolean( + state?.background && (state.status === "pending" || state.status === "running"), + ); + } + + /** A turn boundary: a background subagent still running keeps its card and its events. */ + private retireForegroundSubagents(): void { for (const cardId of [...this.subagentOrder]) { - this.retireSubagent(cardId); + if (!this.isRunningBackgroundSubagent(cardId)) { + this.retireSubagent(cardId); + } } } + /** Stops following background subagents; their cards stay as they were. */ + retireBackgroundSubagents(): void { + for (const cardId of [...this.subagentOrder]) { + if (this.isRunningBackgroundSubagent(cardId)) { + this.retireSubagent(cardId); + } + } + } + + private markBackgroundSubagent(childSessionId: string): void { + const cardId = this.subagentCardIdBySessionId.get(childSessionId); + const state = cardId ? this.subagentStates.get(cardId) : undefined; + if (!state || state.background) { + return; + } + + state.background = true; + this.emitSubagentState(); + } + private emitSubagentState(): void { if (!this.currentSessionId || !this.onSubagentCallback || this.subagentOrder.length === 0) { return; @@ -782,6 +837,7 @@ export class SummaryAggregator { currentToolCallId: state.currentToolCallId, currentToolStartedAt: state.currentToolStartedAt, terminalMessage: state.terminalMessage, + background: state.background, createdAt: state.createdAt, finishedAt: state.finishedAt, updatedAt: state.updatedAt, @@ -808,6 +864,7 @@ export class SummaryAggregator { currentToolCallId: subagent.currentToolCallId, currentToolStartedAt: subagent.currentToolStartedAt, terminalMessage: subagent.terminalMessage, + background: subagent.background, finishedAt: subagent.finishedAt, })), ); @@ -1060,12 +1117,15 @@ export class SummaryAggregator { return; } + // A background subagent's card outlives the run that started it, so an update of + // its session arriving in a later run is not a stale discovery. const createdAt = info.time?.created; if ( - !this.acceptsSubagentEvents || - (this.subagentRunStartedAt > 0 && - typeof createdAt === "number" && - createdAt < this.subagentRunStartedAt) + !this.subagentCardIdBySessionId.has(info.id) && + (!this.acceptsSubagentEvents || + (this.subagentRunStartedAt > 0 && + typeof createdAt === "number" && + createdAt < this.subagentRunStartedAt)) ) { this.finishedSubagentSessionIds.add(info.id); return; @@ -1529,6 +1589,17 @@ export class SummaryAggregator { if (part.tool === "task") { this.updateSubagentFromTaskTool(part.sessionID, input); + + const childSessionId = + "metadata" in state && state.metadata ? state.metadata.sessionId : undefined; + if ( + "status" in state && + state.status === "running" && + input?.background === true && + typeof childSessionId === "string" + ) { + this.markBackgroundSubagent(childSessionId); + } } logger.debug( @@ -2161,6 +2232,11 @@ export class SummaryAggregator { return; } + if (status?.type === "busy") { + this.liveTurnStartedAt ??= Date.now(); + return; + } + if (status?.type !== "retry" || !this.onSessionRetryCallback) { return; } @@ -2200,16 +2276,18 @@ export class SummaryAggregator { } logger.info(`[Aggregator] Session became idle: ${sessionID}`); + this.liveTurnStartedAt = null; this.acceptsSubagentEvents = false; - this.retireAllSubagents(); + this.retireForegroundSubagents(); // Stop typing indicator when session goes idle this.stopTypingIndicator(); if (this.onSessionIdleCallback) { const callback = this.onSessionIdleCallback; + const idleInfo: SessionIdleInfo = { interrupted: isInterruptedIdle(event.properties) }; this.scheduleOutbound(() => { - callback(sessionID); + callback(sessionID, idleInfo); }, true); } } @@ -2263,8 +2341,9 @@ export class SummaryAggregator { } logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`); + this.liveTurnStartedAt = null; this.acceptsSubagentEvents = false; - this.retireAllSubagents(); + this.retireForegroundSubagents(); this.stopTypingIndicator(); if (this.onSessionErrorCallback) { diff --git a/src/app/services/config-reload-service.ts b/src/app/services/config-reload-service.ts new file mode 100644 index 000000000..967f19957 --- /dev/null +++ b/src/app/services/config-reload-service.ts @@ -0,0 +1,82 @@ +import { opencodeV2Client } from "../../opencode/client.js"; +import { refreshModelCatalogAfterConfigReload } from "../../opencode/ready-refresh.js"; +import type { ModelInfo } from "../types/model.js"; +import { logger } from "../../utils/logger.js"; +import { extractErrorMessage } from "../../utils/opencode-error.js"; +import { getStoredModel } from "./model-selection-service.js"; + +// A reload rebuilds every loaded location, MCP servers and plugins included. +const CONFIG_RELOAD_TIMEOUT_MS = 60_000; + +export type ConfigReloadResult = + | { kind: "success"; modelChanged: boolean } + | { kind: "failed"; error: string | null } + | { kind: "timeout" }; + +let reloadInFlight: Promise | null = null; + +function isSameModel(left: ModelInfo, right: ModelInfo): boolean { + return ( + left.providerID === right.providerID && + left.modelID === right.modelID && + left.variant === right.variant + ); +} + +async function reloadAndRefreshModelCatalog(): Promise { + const { error } = await opencodeV2Client.location.reload(); + if (error) { + logger.warn("[ConfigReload] OpenCode rejected the config reload", error); + return { kind: "failed", error: extractErrorMessage(error) }; + } + + logger.info("[ConfigReload] OpenCode configuration reloaded"); + const modelBefore = { ...getStoredModel() }; + try { + await refreshModelCatalogAfterConfigReload(); + } catch (refreshError) { + logger.warn("[ConfigReload] Failed to refresh the model catalog after reload", refreshError); + } + + return { kind: "success", modelChanged: !isSameModel(modelBefore, getStoredModel()) }; +} + +function startReload(): Promise { + const operation = reloadAndRefreshModelCatalog().finally(() => { + if (reloadInFlight === operation) { + reloadInFlight = null; + } + }); + reloadInFlight = operation; + return operation; +} + +/** + * Asks the V2 server to reload its configuration and re-reads the model catalog after it. + * A call made while a reload is in flight joins it. The time limit ends only the wait: a + * reload that succeeds later still refreshes the catalog, and the next call starts afresh. + */ +export async function reloadOpencodeConfig(): Promise { + const operation = reloadInFlight ?? startReload(); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve({ kind: "timeout" }), CONFIG_RELOAD_TIMEOUT_MS); + }); + + try { + const result = await Promise.race([operation, timeout]); + if (result.kind === "timeout") { + logger.warn(`[ConfigReload] Config reload timed out after ${CONFIG_RELOAD_TIMEOUT_MS}ms`); + if (reloadInFlight === operation) { + reloadInFlight = null; + } + } + return result; + } finally { + clearTimeout(timer); + } +} + +export function __resetConfigReloadForTests(): void { + reloadInFlight = null; +} diff --git a/src/app/services/model-capabilities-service.ts b/src/app/services/model-capabilities-service.ts index 3813ef5c9..d0453133d 100644 --- a/src/app/services/model-capabilities-service.ts +++ b/src/app/services/model-capabilities-service.ts @@ -1,31 +1,17 @@ -import { opencodeClient } from "../../opencode/client.js"; import { logger } from "../../utils/logger.js"; -import { getMissingExpectedProviders } from "./model-selection-service.js"; +import { readProvidersWhenListed } from "./model-selection-service.js"; import type { Model } from "@opencode-ai/sdk/v2"; interface ModelCapabilitiesCache { - [key: string]: Model["capabilities"] | null; + [key: string]: Model["capabilities"]; } const capabilitiesCache: ModelCapabilitiesCache = {}; -async function rememberUnlistedModel(cacheKey: string, providerIds: string[]): Promise { - const missingProviders = await getMissingExpectedProviders(providerIds); - - if (missingProviders.length > 0) { - // The server may still be registering these providers; do not remember that as an answer. - logger.debug( - `[ModelCapabilities] Providers list lacks expected providers; not caching ${cacheKey}: missing=${missingProviders.join(",")}`, - ); - return; - } - - capabilitiesCache[cacheKey] = null; -} - /** * Get model capabilities from OpenCode API - * Results are cached in memory per model + * Capabilities of a listed model are cached in memory per model; an unlisted model is not, + * so the next file asks the server again. */ export async function getModelCapabilities( providerID: string, @@ -40,7 +26,7 @@ export async function getModelCapabilities( try { logger.debug(`[ModelCapabilities] Fetching capabilities for ${cacheKey}`); - const response = await opencodeClient.config.providers(); + const response = await readProvidersWhenListed(providerID); if (response.error || !response.data) { logger.error("[ModelCapabilities] API returned error:", response.error); @@ -59,10 +45,6 @@ export async function getModelCapabilities( if (!provider) { logger.warn(`[ModelCapabilities] Provider ${providerID} not found`); - await rememberUnlistedModel( - cacheKey, - providers.map((p) => p.id), - ); return null; } @@ -70,10 +52,6 @@ export async function getModelCapabilities( if (!model) { logger.warn(`[ModelCapabilities] Model ${cacheKey} not found in provider`); - await rememberUnlistedModel( - cacheKey, - providers.map((p) => p.id), - ); return null; } diff --git a/src/app/services/model-context-limit-service.ts b/src/app/services/model-context-limit-service.ts index 66102487c..90ecd8ae8 100644 --- a/src/app/services/model-context-limit-service.ts +++ b/src/app/services/model-context-limit-service.ts @@ -1,7 +1,7 @@ import { opencodeClient } from "../../opencode/client.js"; import { logger } from "../../utils/logger.js"; import { isExpectedOpencodeUnavailableError } from "../../utils/opencode-error.js"; -import { getMissingExpectedProviders } from "./model-selection-service.js"; +import { getMissingExpectedProviders, readProvidersWhenListed } from "./model-selection-service.js"; export const DEFAULT_CONTEXT_LIMIT = 200000; @@ -16,6 +16,43 @@ function getModelKey(providerID: string, modelID: string): string { return `${providerID}/${modelID}`; } +type ProvidersList = NonNullable< + Awaited>["data"] +>["providers"]; + +async function applyProvidersList(providers: ProvidersList): Promise { + if (providers.every((provider) => Object.keys(provider.models).length === 0)) { + // A freshly started server lists no models for a moment; do not keep that as its state. + logger.warn("[ModelContextLimit] Providers list has no models; not caching it"); + return; + } + + contextLimitCache.clear(); + for (const provider of providers) { + for (const [modelID, model] of Object.entries(provider.models)) { + if (model?.limit?.context) { + contextLimitCache.set(getModelKey(provider.id, modelID), model.limit.context); + } + } + } + + const missingProviders = await getMissingExpectedProviders( + providers.map((provider) => provider.id), + ); + if (missingProviders.length > 0) { + // The server may still be registering these providers; read their models again next time. + logger.debug( + `[ModelContextLimit] Providers list lacks expected providers, not caching it: missing=${missingProviders.join(",")}`, + ); + return; + } + + providersCacheExpiresAt = Date.now() + PROVIDER_CACHE_TTL_MS; + logger.debug( + `[ModelContextLimit] Cached limits for ${contextLimitCache.size} provider/model pairs`, + ); +} + async function refreshContextLimitCache(): Promise { if (Date.now() < providersCacheExpiresAt) { return; @@ -39,36 +76,7 @@ async function refreshContextLimitCache(): Promise { return; } - if (data.providers.every((provider) => Object.keys(provider.models).length === 0)) { - // A freshly started server lists no models for a moment; do not keep that as its state. - logger.warn("[ModelContextLimit] Providers list has no models; not caching it"); - return; - } - - contextLimitCache.clear(); - for (const provider of data.providers) { - for (const [modelID, model] of Object.entries(provider.models)) { - if (model?.limit?.context) { - contextLimitCache.set(getModelKey(provider.id, modelID), model.limit.context); - } - } - } - - const missingProviders = await getMissingExpectedProviders( - data.providers.map((provider) => provider.id), - ); - if (missingProviders.length > 0) { - // The server may still be registering these providers; read their models again next time. - logger.debug( - `[ModelContextLimit] Providers list lacks expected providers, not caching it: missing=${missingProviders.join(",")}`, - ); - return; - } - - providersCacheExpiresAt = Date.now() + PROVIDER_CACHE_TTL_MS; - logger.debug( - `[ModelContextLimit] Cached limits for ${contextLimitCache.size} provider/model pairs`, - ); + await applyProvidersList(data.providers); } catch (error) { if (isExpectedOpencodeUnavailableError(error)) { logger.warn("[ModelContextLimit] OpenCode server unavailable; using default context limit"); @@ -101,6 +109,39 @@ export async function getModelContextLimit( return contextLimitCache.get(cacheKey) ?? DEFAULT_CONTEXT_LIMIT; } +/** + * Get the model's own context limit, waiting (bounded) for the server to list its provider. + * @returns The limit, or null when the model is still not listed or the list cannot be read + */ +export async function waitForModelContextLimit( + providerID: string, + modelID: string, +): Promise { + const cacheKey = getModelKey(providerID, modelID); + const cachedLimit = contextLimitCache.get(cacheKey); + if (cachedLimit) { + return cachedLimit; + } + + try { + const { data, error } = await readProvidersWhenListed(providerID); + if (error || !data) { + logger.warn( + "[ModelContextLimit] Failed to fetch providers while waiting for a limit:", + error, + ); + return null; + } + + await applyProvidersList(data.providers); + } catch (error) { + logger.warn("[ModelContextLimit] Error waiting for a context limit:", error); + return null; + } + + return contextLimitCache.get(cacheKey) ?? null; +} + export function __resetModelContextLimitCacheForTests(): void { contextLimitCache.clear(); providersCacheExpiresAt = 0; diff --git a/src/app/services/model-selection-service.ts b/src/app/services/model-selection-service.ts index c4a942261..8349d5678 100644 --- a/src/app/services/model-selection-service.ts +++ b/src/app/services/model-selection-service.ts @@ -33,8 +33,13 @@ export interface StoredModelReconcileResult { storedModelReplaced: boolean; } +const fetchProvidersList = () => opencodeClient.config.providers(); +type ProvidersResponse = Awaited>; + const MODEL_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000; const MODEL_CATALOG_WARMUP_MS = 60 * 1000; +const EXPECTED_PROVIDERS_WAIT_MS = 10 * 1000; +const EXPECTED_PROVIDERS_POLL_INTERVAL_MS = 500; let cachedValidModelKeys: Set | null = null; let cachedAllModels: FavoriteModel[] | null = null; @@ -43,7 +48,11 @@ let cachedModelsByProvider: Map | null = null; let cachedCatalogComplete = false; let modelCatalogCacheExpiresAt = 0; let modelCatalogFetchInFlight: Promise | null = null; +let modelCatalogWaitedFetchInFlight: Promise | null = null; let modelCatalogWarmupEndsAt = 0; +// Providers still not listed after a full wait; no wait is spent on them until one lists them. +const providersPresumedGone = new Set(); +const providersWaitsInFlight = new Map>(); const SEARCH_RESULTS_LIMIT = 10; @@ -116,6 +125,8 @@ function clearModelCatalogCache(): void { */ export function startModelCatalogWarmup(): void { modelCatalogWarmupEndsAt = Date.now() + MODEL_CATALOG_WARMUP_MS; + // The server registers every provider again, so none of them counts as gone any more. + providersPresumedGone.clear(); } export function isModelCatalogWarmupActive(): boolean { @@ -160,8 +171,80 @@ export async function getMissingExpectedProviders( return Array.from(expectedProviderIds).filter((providerId) => !listedProviderIds.has(providerId)); } +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readProvidersUntilListed( + providerID: string | undefined, +): Promise { + const wantedProviderIds = providerID ? [providerID] : Array.from(await getExpectedProviderIds()); + const startedAt = Date.now(); + + for (;;) { + const response = await fetchProvidersList(); + if (response.error || !response.data) { + return response; + } + + // A provider without models is not listed yet: a waking location lists none for a moment. + const listedProviderIds = new Set( + response.data.providers + .filter((provider) => Object.keys(provider.models).length > 0) + .map((provider) => provider.id), + ); + for (const providerId of listedProviderIds) { + providersPresumedGone.delete(providerId); + } + + const awaitedProviderIds = wantedProviderIds.filter( + (providerId) => !listedProviderIds.has(providerId) && !providersPresumedGone.has(providerId), + ); + if (awaitedProviderIds.length === 0) { + return response; + } + + if (Date.now() - startedAt >= EXPECTED_PROVIDERS_WAIT_MS) { + for (const providerId of awaitedProviderIds) { + providersPresumedGone.add(providerId); + } + logger.warn( + `[ModelManager] Providers still not listed after ${EXPECTED_PROVIDERS_WAIT_MS}ms, treating them as gone: missing=${awaitedProviderIds.join(",")}`, + ); + return response; + } + + logger.debug( + `[ModelManager] Providers list lacks awaited providers, retrying: missing=${awaitedProviderIds.join(",")}`, + ); + await delay(EXPECTED_PROVIDERS_POLL_INTERVAL_MS); + } +} + +/** + * Read the providers list, waiting (bounded) while it lacks a provider the caller needs: + * a location OpenCode unloaded while idle lists its providers a few seconds after waking. + * A provider still missing after the full wait counts as gone and is not waited for again + * until a list names it or the server starts again. A failed read is returned at once. + * @param providerID Wait only for this provider; omitted, wait for every expected provider + */ +export function readProvidersWhenListed(providerID?: string): Promise { + const waitKey = providerID ?? ""; + const inFlight = providersWaitsInFlight.get(waitKey); + if (inFlight) { + return inFlight; + } + + const read = readProvidersUntilListed(providerID).finally(() => { + providersWaitsInFlight.delete(waitKey); + }); + providersWaitsInFlight.set(waitKey, read); + return read; +} + async function readModelCatalog(options?: { force?: boolean | undefined; + waitForExpectedProviders?: boolean | undefined; }): Promise { if (!options?.force && cachedValidModelKeys && Date.now() < modelCatalogCacheExpiresAt) { logger.debug( @@ -170,15 +253,22 @@ async function readModelCatalog(options?: { return { validModelKeys: cachedValidModelKeys, isStale: false, isComplete: true }; } - if (modelCatalogFetchInFlight) { + // A waiting read never joins a plain one, which could hand it a list still missing providers. + const waitForExpectedProviders = options?.waitForExpectedProviders ?? false; + const inFlight = waitForExpectedProviders + ? modelCatalogWaitedFetchInFlight + : modelCatalogFetchInFlight; + if (inFlight) { logger.debug("[ModelManager] Awaiting in-flight model catalog refresh"); - return modelCatalogFetchInFlight; + return inFlight; } - modelCatalogFetchInFlight = (async () => { + const read = (async (): Promise => { try { logger.debug("[ModelManager] Refreshing model catalog from OpenCode API"); - const response = await opencodeClient.config.providers(); + const response = waitForExpectedProviders + ? await readProvidersWhenListed() + : await fetchProvidersList(); if (response.error || !response.data) { logModelCatalogRefreshFailure(response.error, "error"); @@ -267,15 +357,25 @@ async function readModelCatalog(options?: { return { validModelKeys: null, isStale: false, isComplete: false }; } finally { - modelCatalogFetchInFlight = null; + if (waitForExpectedProviders) { + modelCatalogWaitedFetchInFlight = null; + } else { + modelCatalogFetchInFlight = null; + } } })(); - return modelCatalogFetchInFlight; + if (waitForExpectedProviders) { + modelCatalogWaitedFetchInFlight = read; + } else { + modelCatalogFetchInFlight = read; + } + return read; } +// The model menu shows favorites and recent, so it waits for all their providers. async function getValidModelKeys(): Promise | null> { - return (await readModelCatalog()).validModelKeys; + return (await readModelCatalog({ waitForExpectedProviders: true })).validModelKeys; } function normalizeFavoriteModels(state: OpenCodeModelState): FavoriteModel[] { @@ -497,7 +597,10 @@ export async function reconcileStoredModelSelection(options?: { export function __resetModelCatalogCacheForTests(): void { clearModelCatalogCache(); modelCatalogFetchInFlight = null; + modelCatalogWaitedFetchInFlight = null; modelCatalogWarmupEndsAt = 0; + providersPresumedGone.clear(); + providersWaitsInFlight.clear(); } /** diff --git a/src/app/services/recent-sessions-service.ts b/src/app/services/recent-sessions-service.ts index 8923ae882..fb110f336 100644 --- a/src/app/services/recent-sessions-service.ts +++ b/src/app/services/recent-sessions-service.ts @@ -1,6 +1,7 @@ import type { GlobalSession } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "../../opencode/client.js"; import { getCurrentSession } from "../stores/settings-store.js"; +import { logger } from "../../utils/logger.js"; export type RecentStatus = "question" | "permission" | "running" | "idle"; type RecentSessionInfo = Pick; @@ -57,19 +58,22 @@ export async function loadRecentSessions(limit: number): Promise + logger.warn(`[Recent] Failed to load ${lookup} for ${directory}; showing it without them:`, error); + if (statusResult.error || !statusResult.data) warnFailed("run statuses", statusResult.error); + if (questionResult.error || !questionResult.data) warnFailed("pending questions", questionResult.error); + if (permissionResult.error || !permissionResult.data) warnFailed("pending permissions", permissionResult.error); const roots = new Set(group.map((session) => session.id)); - const questions = new Set(questionResult.data.map((request) => request.sessionID)); + const questions = new Set((questionResult.data ?? []).map((request) => request.sessionID)); const permissions = new Set(); - for (const request of permissionResult.data) { + for (const request of permissionResult.data ?? []) { const chain = await resolveSessionParentChain(request.sessionID, directory, roots); if (chain) permissions.add(chain.root); } for (const session of group) { - const run = statusResult.data[session.id]?.type; + const run = statusResult.data?.[session.id]?.type; statuses.set(session.id, questions.has(session.id) ? "question" : permissions.has(session.id) diff --git a/src/app/services/session-service.ts b/src/app/services/session-service.ts index b0949f791..4a65bfba5 100644 --- a/src/app/services/session-service.ts +++ b/src/app/services/session-service.ts @@ -5,6 +5,9 @@ import { } from "../stores/settings-store.js"; import { withdrawPromptQueue } from "./prompt-inbox-service.js"; import { promptAttachment } from "../managers/prompt-attachment-manager.js"; +import { opencodeClient } from "../../opencode/client.js"; +import { logger } from "../../utils/logger.js"; +import { isExpectedOpencodeUnavailableError } from "../../utils/opencode-error.js"; import type { SessionInfo } from "../types/session.js"; export type { SessionInfo }; @@ -24,6 +27,33 @@ export function getCurrentSession(): SessionInfo | null { return getSettingsSession() ?? null; } +/** + * Title OpenCode has for the session now. The remembered one goes stale once + * OpenCode names a session after its first prompt, so it is only the fallback. + */ +export async function fetchSessionTitle(session: SessionInfo): Promise { + try { + const { data, error } = await opencodeClient.session.get({ + sessionID: session.id, + directory: session.directory, + }); + + if (!error && data) { + return data.title; + } + + logger.debug(`[SessionService] Could not fetch title for session ${session.id}:`, error); + } catch (error) { + if (isExpectedOpencodeUnavailableError(error)) { + logger.debug("[SessionService] OpenCode server unavailable; using remembered session title"); + } else { + logger.debug(`[SessionService] Could not fetch title for session ${session.id}:`, error); + } + } + + return session.title; +} + export function clearSession(): void { void withdrawPromptQueue("session_cleared"); promptAttachment.clear("session_cleared"); diff --git a/src/app/services/variant-selection-service.ts b/src/app/services/variant-selection-service.ts index 24703a09a..9de94a34f 100644 --- a/src/app/services/variant-selection-service.ts +++ b/src/app/services/variant-selection-service.ts @@ -1,9 +1,8 @@ /** * Variant Manager - manages model variants (reasoning modes) */ -import { opencodeClient } from "../../opencode/client.js"; import { getCurrentModel, setCurrentModel } from "../stores/settings-store.js"; -import { getStoredModel } from "./model-selection-service.js"; +import { getStoredModel, readProvidersWhenListed } from "./model-selection-service.js"; import { logger } from "../../utils/logger.js"; import type { VariantInfo } from "../types/variant.js"; @@ -18,7 +17,7 @@ export async function getAvailableVariants( modelID: string, ): Promise { try { - const { data, error } = await opencodeClient.config.providers(); + const { data, error } = await readProvidersWhenListed(providerID); if (error || !data) { logger.warn("[VariantManager] Failed to fetch providers:", error); diff --git a/src/app/types/question.ts b/src/app/types/question.ts index 9917b4685..e542c61ec 100644 --- a/src/app/types/question.ts +++ b/src/app/types/question.ts @@ -1,6 +1,8 @@ export interface QuestionOption { label: string; description: string; + /** What is sent to OpenCode for this choice when it differs from the text shown (V2 only). */ + value?: string; } export interface Question { @@ -8,6 +10,8 @@ export interface Question { header: string; options: QuestionOption[]; multiple?: boolean; + /** Whether a custom answer is accepted; unstated means accepted. */ + custom?: boolean; } export interface QuestionAnswer { diff --git a/src/bot/callbacks/command-catalog-callback-handler.ts b/src/bot/callbacks/command-catalog-callback-handler.ts index 1a96087a0..be05c9e59 100644 --- a/src/bot/callbacks/command-catalog-callback-handler.ts +++ b/src/bot/callbacks/command-catalog-callback-handler.ts @@ -15,6 +15,7 @@ import { getStoredModel } from "../../app/services/model-selection-service.js"; import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { cancelMenu } from "./feedback.js"; import { attachToSession, @@ -247,7 +248,7 @@ async function ensureSessionForProject( setCurrentSession(sessionInfo); await ingestSessionInfoForCache(session); - await ctx.reply(t("bot.session_created", { title: session.title })); + await ctx.reply(t("bot.session_created", { title: formatSessionTitle(session.title) })); return sessionInfo; } diff --git a/src/bot/callbacks/context-control-callback-handler.ts b/src/bot/callbacks/context-control-callback-handler.ts index e2a85eb04..5dac39cbf 100644 --- a/src/bot/callbacks/context-control-callback-handler.ts +++ b/src/bot/callbacks/context-control-callback-handler.ts @@ -1,7 +1,8 @@ import { Context } from "grammy"; import { getStoredModel } from "../../app/services/model-selection-service.js"; import { opencodeClient } from "../../opencode/client.js"; -import { getCurrentSession } from "../../app/services/session-service.js"; +import { fetchSessionTitle, getCurrentSession } from "../../app/services/session-service.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; import { alert, failure } from "./feedback.js"; @@ -36,7 +37,8 @@ export async function handleCompactDetails(ctx: Context, deps: InlineMenuDeps): } await ctx.answerCallbackQuery(); - await ctx.editMessageText(t("context.confirm_text", { title: session.title }), { + const title = formatSessionTitle(await fetchSessionTitle(session)); + await ctx.editMessageText(t("context.confirm_text", { title }), { reply_markup: appendInlineMenuCancelButton(buildCompactConfirmationMenu(), "context"), }); deps.interactionManager.transition({ diff --git a/src/bot/callbacks/session-callback-handler.ts b/src/bot/callbacks/session-callback-handler.ts index 1efd6dc37..c3d178d5d 100644 --- a/src/bot/callbacks/session-callback-handler.ts +++ b/src/bot/callbacks/session-callback-handler.ts @@ -14,6 +14,7 @@ import { logger } from "../../utils/logger.js"; import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import { config } from "../../config.js"; import { t } from "../../i18n/index.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { alert, failure } from "./feedback.js"; import { attachToSession } from "../../app/services/attach-service.js"; import { renderAssistantFinalPartsSafe } from "../messages/assistant-rendering.js"; @@ -181,7 +182,7 @@ export async function selectSessionById( try { await ctx.api.sendMessage( chatId, - t("sessions.selected", { title: session.title }), + t("sessions.selected", { title: formatSessionTitle(session.title) }), keyboard ? { reply_markup: keyboard } : {}, ); } catch (err) { diff --git a/src/bot/commands/abort-command.ts b/src/bot/commands/abort-command.ts index 0cf2321a9..9521505b7 100644 --- a/src/bot/commands/abort-command.ts +++ b/src/bot/commands/abort-command.ts @@ -14,7 +14,11 @@ type SessionState = "idle" | "busy" | "not-found"; export type AbortCommandDeps = Pick< AppContainer, - "assistantRunState" | "attachManager" | "foregroundSessionState" | "resetInteractions" + | "assistantRunState" + | "attachManager" + | "foregroundSessionState" + | "resetInteractions" + | "stopBackgroundOperations" >; interface AbortCurrentOperationOptions { @@ -98,6 +102,9 @@ export async function abortCurrentOperation( return; } + // What ran in the background stays in the chat as it was at the abort. + deps.stopBackgroundOperations("abort_command", currentSession.id); + let waitingMessageId: number | null = null; let chatId: number | null = null; diff --git a/src/bot/commands/definitions.ts b/src/bot/commands/definitions.ts index a27778b66..40ff2c4fc 100644 --- a/src/bot/commands/definitions.ts +++ b/src/bot/commands/definitions.ts @@ -1,3 +1,4 @@ +import { config, type OpencodeServerVersion } from "../../config.js"; import type { I18nKey } from "../../i18n/en.js"; import { t } from "../../i18n/index.js"; @@ -14,6 +15,8 @@ export interface BotCommandDefinition { interface BotCommandI18nDefinition { command: string; descriptionKey: I18nKey; + /** The command exists only when the bot runs against this OpenCode server version. */ + serverVersion?: OpencodeServerVersion; } /** @@ -39,13 +42,18 @@ const COMMAND_DEFINITIONS: BotCommandI18nDefinition[] = [ { command: "mcps", descriptionKey: "cmd.description.mcps" }, { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" }, { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" }, + { command: "reload", descriptionKey: "cmd.description.reload", serverVersion: "v2" }, { command: "open", descriptionKey: "cmd.description.open" }, { command: "ls", descriptionKey: "cmd.description.ls" }, { command: "help", descriptionKey: "cmd.description.help" }, ]; +const ACTIVE_COMMAND_DEFINITIONS = COMMAND_DEFINITIONS.filter( + ({ serverVersion }) => !serverVersion || serverVersion === config.opencode.serverVersion, +); + export function getLocalizedBotCommands(): BotCommandDefinition[] { - return COMMAND_DEFINITIONS.map(({ command, descriptionKey }) => ({ + return ACTIVE_COMMAND_DEFINITIONS.map(({ command, descriptionKey }) => ({ command, description: t(descriptionKey), })); @@ -53,4 +61,7 @@ export function getLocalizedBotCommands(): BotCommandDefinition[] { export const BOT_COMMANDS: BotCommandDefinition[] = getLocalizedBotCommands(); -export const BUILT_IN_COMMAND_NAMES = ["start", ...COMMAND_DEFINITIONS.map(({ command }) => command)]; +export const BUILT_IN_COMMAND_NAMES = [ + "start", + ...ACTIVE_COMMAND_DEFINITIONS.map(({ command }) => command), +]; diff --git a/src/bot/commands/detach-command.ts b/src/bot/commands/detach-command.ts index be586350d..dd2de2961 100644 --- a/src/bot/commands/detach-command.ts +++ b/src/bot/commands/detach-command.ts @@ -1,7 +1,12 @@ import { CommandContext, Context } from "grammy"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; import { getCurrentProject } from "../../app/stores/settings-store.js"; -import { clearSession, getCurrentSession } from "../../app/services/session-service.js"; +import { + clearSession, + fetchSessionTitle, + getCurrentSession, +} from "../../app/services/session-service.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { detachAttachedSession } from "../../app/services/attach-service.js"; import { clearPromptResponseMode } from "../handlers/prompt.js"; import { logger } from "../../utils/logger.js"; @@ -35,6 +40,8 @@ export async function detachCommand( return; } + const title = await fetchSessionTitle(currentSession); + detachAttachedSession("detach_command", deps); clearPromptResponseMode(currentSession.id); deps.foregroundSessionState.markIdle(currentSession.id); @@ -64,7 +71,7 @@ export async function detachCommand( `[Detach] Detached from session: id=${currentSession.id}, title="${currentSession.title}", project=${currentProject.worktree}`, ); - await ctx.reply(t("detach.success", { title: currentSession.title }), { + await ctx.reply(t("detach.success", { title: formatSessionTitle(title) }), { ...(keyboard ? { reply_markup: keyboard } : {}), }); } catch (error) { diff --git a/src/bot/commands/new-command.ts b/src/bot/commands/new-command.ts index 711fa712b..303d674a0 100644 --- a/src/bot/commands/new-command.ts +++ b/src/bot/commands/new-command.ts @@ -14,6 +14,7 @@ import { isForegroundBusy } from "../../app/services/run-control-service.js"; import { replyBusyBlocked } from "../messages/busy-blocked-renderer.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { attachToSession } from "../../app/services/attach-service.js"; export type NewCommandDeps = Pick< @@ -87,7 +88,7 @@ export async function newCommand(ctx: CommandContext, deps: NewCommandD variantName, ); - await ctx.reply(t("new.created", { title: session.title }), { + await ctx.reply(t("new.created", { title: formatSessionTitle(session.title) }), { reply_markup: keyboard, }); } catch (error) { diff --git a/src/bot/commands/reload-command.ts b/src/bot/commands/reload-command.ts new file mode 100644 index 000000000..71d106289 --- /dev/null +++ b/src/bot/commands/reload-command.ts @@ -0,0 +1,71 @@ +import { CommandContext, Context } from "grammy"; +import type { AppContainer } from "../../app/bootstrap/app-container.js"; +import { + reloadOpencodeConfig, + type ConfigReloadResult, +} from "../../app/services/config-reload-service.js"; +import { t } from "../../i18n/index.js"; +import { logger } from "../../utils/logger.js"; +import { safeBackgroundTask } from "../../utils/safe-background-task.js"; +import { editBotText } from "../messages/telegram-text.js"; +import { + refreshModelViews, + refreshModelViewsAfterLateCatalogSettle, +} from "../services/model-views.js"; + +export type ReloadCommandDeps = Pick; + +function formatReloadResult(result: ConfigReloadResult): string { + if (result.kind === "success") { + return t("reload.success"); + } + if (result.kind === "failed" && result.error) { + return t("reload.failed_with_error", { error: result.error }); + } + return t("reload.failed"); +} + +async function reloadAndReport( + ctx: CommandContext, + statusMessageId: number, + deps: ReloadCommandDeps, +): Promise { + let text: string; + try { + const result = await reloadOpencodeConfig(); + if (result.kind === "success") { + if (result.modelChanged) { + await refreshModelViews(deps); + } + refreshModelViewsAfterLateCatalogSettle(deps, "config_reload"); + } + text = formatReloadResult(result); + } catch (error) { + logger.error("[Bot] Error in /reload command:", error); + text = t("reload.failed"); + } + + await editBotText({ + api: ctx.api, + chatId: ctx.chat.id, + messageId: statusMessageId, + text, + }); +} + +/** + * Command handler for /reload (OpenCode V2 only) + * Reloads the server configuration; the rebuild runs in the background so the bot keeps + * answering while it lasts. + */ +export async function reloadCommand(ctx: CommandContext, deps: ReloadCommandDeps) { + try { + const statusMessage = await ctx.reply(t("reload.reloading")); + safeBackgroundTask({ + taskName: "bot.reloadOpencodeConfig", + task: () => reloadAndReport(ctx, statusMessage.message_id, deps), + }); + } catch (error) { + logger.error("[Bot] Error in /reload command:", error); + } +} diff --git a/src/bot/commands/rename-command.ts b/src/bot/commands/rename-command.ts index 342bdc803..b707fa9cb 100644 --- a/src/bot/commands/rename-command.ts +++ b/src/bot/commands/rename-command.ts @@ -1,6 +1,7 @@ import { CommandContext, Context } from "grammy"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; -import { getCurrentSession } from "../../app/services/session-service.js"; +import { fetchSessionTitle, getCurrentSession } from "../../app/services/session-service.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; import { buildRenameCancelKeyboard } from "../menus/rename-menu.js"; @@ -19,15 +20,13 @@ export async function renameCommand( return; } - const message = await ctx.reply(t("rename.prompt", { title: currentSession.title }), { - reply_markup: buildRenameCancelKeyboard(), - }); - - deps.renameManager.startWaiting( - currentSession.id, - currentSession.directory, - currentSession.title, + const currentTitle = await fetchSessionTitle(currentSession); + const message = await ctx.reply( + t("rename.prompt", { title: formatSessionTitle(currentTitle) }), + { reply_markup: buildRenameCancelKeyboard() }, ); + + deps.renameManager.startWaiting(currentSession.id, currentSession.directory, currentTitle); deps.renameManager.setMessageId(message.message_id); deps.interactionManager.transition({ expectedInput: "text", diff --git a/src/bot/commands/status-command.ts b/src/bot/commands/status-command.ts index b1dcb8bc5..2245d1dc6 100644 --- a/src/bot/commands/status-command.ts +++ b/src/bot/commands/status-command.ts @@ -2,7 +2,8 @@ import { CommandContext, Context } from "grammy"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; import { checkOpencodeHealth } from "../../opencode/server-health.js"; import { getGitWorktreeContext } from "../../app/services/worktree-service.js"; -import { getCurrentSession } from "../../app/services/session-service.js"; +import { fetchSessionTitle, getCurrentSession } from "../../app/services/session-service.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { getCurrentProject } from "../../app/stores/settings-store.js"; import { fetchCurrentAgent } from "../../app/services/agent-selection-service.js"; import { fetchCurrentModel } from "../../app/services/model-selection-service.js"; @@ -75,7 +76,8 @@ export async function statusCommand(ctx: CommandContext, deps: StatusCo const currentSession = getCurrentSession(); if (currentSession) { - message += `\n${t("status.session_selected", { title: currentSession.title })}\n`; + const title = formatSessionTitle(await fetchSessionTitle(currentSession)); + message += `\n${t("status.session_selected", { title })}\n`; } else { message += `\n${t("status.session_not_selected")}\n`; message += t("status.session_hint"); diff --git a/src/bot/events/handlers/assistant-response-handler.ts b/src/bot/events/handlers/assistant-response-handler.ts index 9805cf788..a21211bc2 100644 --- a/src/bot/events/handlers/assistant-response-handler.ts +++ b/src/bot/events/handlers/assistant-response-handler.ts @@ -72,7 +72,7 @@ async function pickUpInboxPrompt( await closeForegroundRun(deps, sessionId, destination, completedRun, "inbox_queue_pickup"); } - if (!deps.assistantRunState.hasRun(sessionId)) { + if (!deps.assistantRunState.hasBotRun(sessionId)) { const session = getCurrentSession(); if (session?.id === sessionId) { await startInboxPromptRun(session, deps, item.responseMode); @@ -242,18 +242,69 @@ function enqueueAssistantReply( } if (streamer.hasPendingSend(sessionId)) { - void streamer.flushPending(sessionId).then(() => { + const deferral = streamer.flushPending(sessionId).then(() => { if (runtime.isAssistantCompletionStarted(sessionId, messageId)) { return; } enqueue(); }); + runtime.setReplyEnqueueDeferral(sessionId, deferral); return; } enqueue(); } +/** + * The first text of an `edit` reply divides what the agent did before it from what + * it does next: the latter goes below the reply. + */ +function beginEditReply(runtime: SessionRuntimeState, sessionId: string, messageId: string): void { + if ( + runtime.getAssistantStreamMode(sessionId, messageId) !== "edit" || + runtime.isReplyBegun(sessionId, messageId) + ) { + return; + } + + runtime.markReplyBegun(sessionId, messageId); + if (isCompactProgressMode()) { + runtime.markCompactCloseDue(sessionId, messageId); + } else { + runtime.startReplyCut(sessionId); + } +} + +/** + * Compact mode: the first activity after a reply's text closes the card above the + * reply, once the reply is out, and opens a new card below it. + */ +export function closeCompactCardBeforeNextActivity( + runtime: SessionRuntimeState, + sessionId: string, +): void { + const streamer = runtime.compactProgressStreamer; + // A close already under way replays this activity into the next card itself. + if (streamer.isHolding(sessionId) || runtime.takeCompactCloseDue(sessionId) === undefined) { + return; + } + + const letOutReply = runtime.takeReplyGate(sessionId); + streamer.holdForClose(sessionId); + void runtime.enqueueCompletionTask(sessionId, async () => { + try { + await letOutReply?.(); + await streamer.finalize(sessionId, getDeleteCompactProgressOnFinish()); + } catch (error) { + streamer.releaseHold(sessionId); + logger.error( + `[Bot] Failed to close the progress card after a reply: session=${sessionId}`, + error, + ); + } + }); +} + /** Streamed replies, their completion, thinking and external user input. */ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): void { const { runtime, policy, summaryAggregator } = deps; @@ -278,17 +329,23 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): preparedStreamPayload.sendOptions = { disable_notification: true }; preparedStreamPayload.editOptions = undefined; + beginEditReply(runtime, sessionId, messageId); enqueueAssistantReply(runtime, sessionId, messageId, preparedStreamPayload); }); summaryAggregator.setOnComplete((sessionId, messageId, messageText, completionInfo) => { + // Its card closed when the next activity came: the open card is below this reply. + const compactCardClosed = runtime.takeCompactClosedForReply(sessionId, messageId); if ( isCompactProgressMode() && policy.getDestination(sessionId) && policy.isForegroundSession(sessionId) ) { runtime.markAssistantCompletionStarted(sessionId, messageId); - if (runtime.stripDeliveredAssistantText(sessionId, messageId, messageText).trim()) { + if ( + !compactCardClosed && + runtime.stripDeliveredAssistantText(sessionId, messageId, messageText).trim() + ) { runtime.compactProgressStreamer.holdForClose(sessionId); } } @@ -319,6 +376,12 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): } try { + // A turn the bot did not start gets its run from its first reply, timed from the turn's + // start; a turn already over (aborted, errored, idle) opens none. + const turnStartedAt = summaryAggregator.getLiveTurnStartedAt(sessionId); + if (turnStartedAt !== null) { + deps.assistantRunState.startObservedRun(sessionId, turnStartedAt); + } deps.assistantRunState.markResponseCompleted(sessionId, { agent: completionInfo.agent, providerID: completionInfo.providerID, @@ -329,8 +392,22 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): const assistantResponseMode = runtime.getAssistantStreamMode(sessionId, messageId); - const remainingText = runtime.stripDeliveredAssistantText(sessionId, messageId, messageText); - if (isCompactProgressMode() && remainingText.trim()) { + // Tool output that started after this reply's text waits below it. + if ( + assistantResponseMode === "edit" && + runtime.toolCallStreamer.hasUnsentMessages(sessionId) + ) { + await runtime.letOutReplies(sessionId); + } + + const remainingText = runtime.stripDeliveredAssistantText( + sessionId, + messageId, + messageText, + ); + const closesCompactCard = + isCompactProgressMode() && !compactCardClosed && Boolean(remainingText.trim()); + if (closesCompactCard) { await runtime.compactProgressStreamer.flushPending(sessionId); } @@ -349,7 +426,7 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): ), }, flushPendingServiceMessages: () => { - runtime.clearToolTracking(sessionId, "assistant_message_completed"); + runtime.clearToolTracking(sessionId, "assistant_message_completed", true); return Promise.all([ runtime.toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"), @@ -369,12 +446,12 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): }, }); - if (isCompactProgressMode() && remainingText.trim()) { + if (closesCompactCard) { await runtime.compactProgressStreamer.finalize( sessionId, getDeleteCompactProgressOnFinish(), ); - } else if (runtime.compactProgressStreamer.isHolding(sessionId)) { + } else if (!compactCardClosed && runtime.compactProgressStreamer.isHolding(sessionId)) { runtime.compactProgressStreamer.releaseHold(sessionId); } @@ -445,6 +522,7 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): }); if (isCompactProgressMode()) { + closeCompactCardBeforeNextActivity(runtime, update.sessionId); if (!runtime.runningToolTracker.newestCallId(update.sessionId)) { runtime.compactProgressStreamer.updateThinking(update.sessionId); } @@ -456,7 +534,7 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): } if (update.isFirstUpdate) { - runtime.clearToolTracking(update.sessionId, "thinking_started"); + runtime.clearToolTracking(update.sessionId, "thinking_started", true); void runtime.toolCallStreamer .breakSession(update.sessionId, "thinking_started") .catch((error) => { diff --git a/src/bot/events/handlers/interaction-handler.ts b/src/bot/events/handlers/interaction-handler.ts index 726adaa59..59a680123 100644 --- a/src/bot/events/handlers/interaction-handler.ts +++ b/src/bot/events/handlers/interaction-handler.ts @@ -45,6 +45,7 @@ async function presentQuestion( runtime.toolCallStreamer.flushSession(sessionId, "question_asked"), ]); await keepAssistantDraftsBeforePrompt(deps, sessionId); + await runtime.letOutReplies(sessionId); // Decide and open the slot in one synchronous step: a permission or a // reset may have landed during the flushes. @@ -118,6 +119,7 @@ async function presentPermission( runtime.toolCallStreamer.flushSession(sessionId, "permission_asked"), ]); await keepAssistantDraftsBeforePrompt(deps, followedSessionId); + await runtime.letOutReplies(followedSessionId); // Decide in one synchronous step: a poll or a reset may have landed during the flushes. if (permissionManager.getDropReason(request, generation)) { diff --git a/src/bot/events/handlers/session-lifecycle-handler.ts b/src/bot/events/handlers/session-lifecycle-handler.ts index 244fd919c..d74747671 100644 --- a/src/bot/events/handlers/session-lifecycle-handler.ts +++ b/src/bot/events/handlers/session-lifecycle-handler.ts @@ -27,21 +27,34 @@ type SessionLifecycleDeps = EventHandlerDeps< export function registerSessionLifecycleHandlers(deps: SessionLifecycleDeps): void { const { runtime, policy, summaryAggregator } = deps; - summaryAggregator.setOnSessionIdle(async (sessionId) => { + summaryAggregator.setOnSessionIdle(async (sessionId, { interrupted }) => { resetStreamThrottle(sessionId); await markAttachedSessionIdle(sessionId, deps); // Dropped immediately when this session is no longer current: the early // returns below would otherwise leave a compact-progress timer armed. // A still-current session keeps the card until after in-flight completion // work, then finalizes it (delete or finished summary). - runtime.clearToolTracking(sessionId, "session_idle"); const canFinalizeCompactProgress = Boolean(policy.getDestination(sessionId)) && policy.isForegroundSession(sessionId); + // Background operations outlive the turn only in the session the user follows. + if (!canFinalizeCompactProgress) { + runtime.stopBackgroundOperations("session_idle", sessionId); + } + runtime.clearToolTracking(sessionId, "session_idle", canFinalizeCompactProgress); if (!canFinalizeCompactProgress) { runtime.compactProgressStreamer.clearSession(sessionId, "session_idle"); } await runtime.getCompletionTask(sessionId)?.catch(() => undefined); + // A turn the bot did not start that was stopped from a client ends like an aborted one. + if ( + interrupted && + deps.assistantRunState.hasRun(sessionId) && + !deps.assistantRunState.hasBotRun(sessionId) + ) { + deps.assistantRunState.clearRun(sessionId, "session_interrupted"); + } + const completedRun = deps.assistantRunState.isResponseCompleted(sessionId) ? deps.assistantRunState.finishRun(sessionId, "session_idle") : null; @@ -74,9 +87,13 @@ export function registerSessionLifecycleHandlers(deps: SessionLifecycleDeps): vo summaryAggregator.setOnSessionError(async (sessionId, message) => { await markAttachedSessionIdle(sessionId, deps); - runtime.clearToolTracking(sessionId, "session_error"); - const destination = policy.getDestination(sessionId); + const keepBackground = Boolean(destination) && policy.isForegroundSession(sessionId); + if (!keepBackground) { + runtime.stopBackgroundOperations("session_error", sessionId); + } + runtime.clearToolTracking(sessionId, "session_error", keepBackground); + if (!destination) { clearPromptResponseMode(sessionId); runtime.compactProgressStreamer.clearSession(sessionId, "session_error_no_bot_context"); @@ -97,7 +114,8 @@ export function registerSessionLifecycleHandlers(deps: SessionLifecycleDeps): vo } runtime.clearAssistantResponseSession(sessionId, "session_error"); - runtime.compactProgressStreamer.clearSession(sessionId, "session_error"); + // Parked cards keep counting their background operations, as their full-mode lines do. + runtime.compactProgressStreamer.discardOpenCard(sessionId, "session_error"); clearPromptResponseMode(sessionId); deps.assistantRunState.clearRun(sessionId, "session_error"); await Promise.all([ diff --git a/src/bot/events/handlers/tool-activity-handler.ts b/src/bot/events/handlers/tool-activity-handler.ts index 946726a61..e288d2ca6 100644 --- a/src/bot/events/handlers/tool-activity-handler.ts +++ b/src/bot/events/handlers/tool-activity-handler.ts @@ -13,7 +13,10 @@ import { formatDuration, formatDurationOverHours, } from "../../../app/formatters/duration-formatter.js"; -import { getSendDiffFileAttachments } from "../../../app/stores/settings-store.js"; +import { + getDeleteCompactProgressOnFinish, + getSendDiffFileAttachments, +} from "../../../app/stores/settings-store.js"; import type { RunningToolTick } from "../../streaming/running-tool-tracker.js"; import type { ToolStreamKey } from "../../streaming/tool-call-streamer.js"; import { @@ -21,6 +24,7 @@ import { type CompactActivity, type SessionRuntimeState, } from "../session-runtime-state.js"; +import { closeCompactCardBeforeNextActivity } from "./assistant-response-handler.js"; import { SUBAGENT_STREAM_PREFIX, isCompactProgressMode, @@ -67,6 +71,11 @@ function prepareDocumentCaption(caption: string): string { return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`; } +/** Launched in the background: the operation outlives the turn that started it. */ +function isBackgroundTool(toolInfo: ToolInfo): boolean { + return toolInfo.input?.background === true; +} + function getCompactToolActivity(toolInfo: ToolInfo): string { if (toolInfo.tool === "task") { return t("progress.compact.task"); @@ -126,6 +135,17 @@ function finalizeLiveToolLine( ); } +/** A finished background line no longer needs its message kept past the turn. */ +function unpinLiveToolLine(runtime: SessionRuntimeState, toolInfo: ToolInfo): void { + if (isBackgroundTool(toolInfo)) { + runtime.toolCallStreamer.unpinEntry( + toolInfo.sessionId, + getLiveToolPrefix(toolInfo.callId), + getToolStreamKey(toolInfo.tool), + ); + } +} + async function renderSubagentCards( runtime: SessionRuntimeState, sessionId: string, @@ -143,23 +163,46 @@ async function renderSubagentCards( continue; } - runtime.toolCallStreamer.replaceByPrefix( - sessionId, - SUBAGENT_STREAM_PREFIX, - text, - getSubagentStreamKey(subagent.cardId), - ); + const streamKey = getSubagentStreamKey(subagent.cardId); + runtime.toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, text, streamKey); + + // A background subagent's card keeps updating after its parent's turn ends. + if (!subagent.background) { + continue; + } + if (subagent.status === "pending" || subagent.status === "running") { + runtime.toolCallStreamer.pinEntry(sessionId, SUBAGENT_STREAM_PREFIX, streamKey); + } else { + runtime.toolCallStreamer.unpinEntry(sessionId, SUBAGENT_STREAM_PREFIX, streamKey); + } } } function handleRunningToolTick({ runtime, policy }: EventHandlerBase, tick: RunningToolTick): void { if (!policy.isForegroundSession(tick.sessionId)) { + // The user moved to another session: what ran in the background here stays as it was. + runtime.stopBackgroundOperations("session_not_current", tick.sessionId); return; } const elapsed = formatElapsed(tick); if (isCompactProgressMode()) { + if (runtime.runningToolTracker.isBackground(tick.callId)) { + const toolInfo = runtime.getRunningToolInfo(tick.sessionId, tick.callId); + const activity = toolInfo ? getCompactToolActivity(toolInfo) : ""; + if (activity) { + runtime.compactProgressStreamer.updateBackgroundOperation( + tick.sessionId, + tick.callId, + appendDuration(activity, elapsed), + ); + } + if (runtime.runningToolTracker.isDetached(tick.callId)) { + return; + } + } + const cached = runtime.getCompactActivity(tick.sessionId); if (!cached || cached.callId !== tick.callId) { return; @@ -204,6 +247,7 @@ async function refreshSubagentCards( } if (!policy.isForegroundSession(sessionId)) { + runtime.stopBackgroundOperations("session_not_current", sessionId); return; } @@ -240,6 +284,15 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { // A failed call is just as finished as a successful one: leaving it tracked // would keep its timer ticking for a tool that already stopped running. const isTerminal = status === "completed" || status === "error"; + const background = isBackgroundTool(toolInfo); + // Read before the release below forgets the call. A background call no longer + // tracked was stopped: its line or card stays as it was. + const backgroundTracked = background && runtime.runningToolTracker.isBackground(callId); + const backgroundDetached = background && runtime.runningToolTracker.isDetached(callId); + + if (compactMode && !isTerminal && !runtime.getRunningToolInfo(sessionId, callId)) { + closeCompactCardBeforeNextActivity(runtime, sessionId); + } if (isTerminal) { if (tracksElapsed) { @@ -249,6 +302,7 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { if (!compactMode && status === "error") { finalizeLiveToolLine(runtime, toolInfo, durationMs); + unpinLiveToolLine(runtime, toolInfo); } // Only a completed call reaches the tool callback, so only it has a @@ -260,17 +314,31 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { runtime.deleteRunningToolInfo(sessionId, callId); } else if (tracksElapsed) { - runtime.runningToolTracker.track(sessionId, callId); + runtime.runningToolTracker.track(sessionId, callId, background); runtime.setRunningToolInfo(toolInfo); if (!compactMode) { const message = formatToolInfo(toolInfo); if (message) { const tick = runtime.runningToolTracker.displayTick(callId); + const streamKey = getToolStreamKey(toolInfo.tool); runtime.toolCallStreamer.replaceByPrefix( sessionId, getLiveToolPrefix(callId), `${RUNNING_ICON} ${tick ? appendDuration(message, formatElapsed(tick)) : message}`, - getToolStreamKey(toolInfo.tool), + streamKey, + ); + if (background) { + runtime.toolCallStreamer.pinEntry(sessionId, getLiveToolPrefix(callId), streamKey); + } + } + } else if (background) { + const activity = getCompactToolActivity(toolInfo); + const tick = runtime.runningToolTracker.displayTick(callId); + if (activity) { + runtime.compactProgressStreamer.addBackgroundOperation( + sessionId, + callId, + tick ? appendDuration(activity, formatElapsed(tick)) : activity, ); } } @@ -280,6 +348,18 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { return; } + if (background && isTerminal) { + // Counted on the card it started in, which closes with it once parked. + void runtime.compactProgressStreamer + .endBackgroundOperation(sessionId, callId, getDeleteCompactProgressOnFinish()) + .catch((error) => { + logger.error("[Bot] Failed to close the progress card of a background operation", error); + }); + if (!backgroundTracked || backgroundDetached) { + return; + } + } + if (isTerminal) { const fallback = pickCompactFallback(runtime, sessionId); if (fallback) { @@ -298,7 +378,7 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { } } - if (status === "completed") { + if (status === "completed" && !background) { runtime.compactProgressStreamer.addToolCall(sessionId, callId); } }); @@ -336,6 +416,7 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { durationMs === undefined ? message : appendDuration(message, formatDuration(durationMs)), getToolStreamKey(toolInfo.tool), ); + unpinLiveToolLine(runtime, toolInfo); } } catch (err) { logger.error("Failed to send tool notification to Telegram:", err); @@ -387,6 +468,7 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { } try { + await runtime.letOutRepliesBeforeDocument(fileInfo.sessionId); runtime.takeCompletedToolDuration(fileInfo.sessionId, fileInfo.callId); runtime.toolCallStreamer.removeByPrefix( fileInfo.sessionId, diff --git a/src/bot/events/session-runtime-state.ts b/src/bot/events/session-runtime-state.ts index 4fb935103..ec8e04c00 100644 --- a/src/bot/events/session-runtime-state.ts +++ b/src/bot/events/session-runtime-state.ts @@ -42,11 +42,20 @@ export interface CompactActivity { activity: string; } +/** What output that began after a reply's text waits for before it opens a new message. */ +export type ReplyGate = () => Promise; + interface RunningToolHooks { onTick: (tick: RunningToolTick) => void; onHeartbeat: (sessionId: string) => void; } +function isRunningBackgroundSubagent(subagent: SubagentInfo): boolean { + return Boolean( + subagent.background && (subagent.status === "pending" || subagent.status === "running"), + ); +} + function sessionKey(sessionId: string, id: string): string { return `${sessionId}:${id}`; } @@ -84,6 +93,16 @@ export class SessionRuntimeState { private readonly assistantLatestTexts = new Map(); private readonly assistantDeliveredTexts = new Map(); private readonly assistantCompletionsStarted = new Set(); + // `edit` replies whose text has begun: output that starts after it goes below the reply. + private readonly begunReplies = new Set(); + // Per session: settling the output that came before the latest reply's text, and the + // compact-mode wait before a reply's first partial reaches its stream. + private readonly replyCuts = new Map>(); + private readonly replyEnqueueDeferrals = new Map>(); + // Compact mode: the reply whose card still has to close before the next activity, and + // the replies whose card was already closed that way. + private readonly compactCloseDue = new Map(); + private readonly compactClosedReplies = new Set(); private readonly thinkingSections = new Map(); private readonly completionTasks = new Map>(); private readonly runningToolInfos = new Map(); @@ -103,6 +122,7 @@ export class SessionRuntimeState { }); this.toolMessageBatcher = new ToolMessageBatcher({ + takeTextGate: (sessionId) => this.takeReplyGate(sessionId), sendText: async (sessionId, text) => { const destination = this.getForegroundDestination(sessionId); if (!destination) { @@ -157,10 +177,19 @@ export class SessionRuntimeState { this.requireForegroundDestination(sessionId, "Compact progress", "delete"), messageId, ), + // A parked card keeps its background operations; the next stretch no longer runs them. + onPark: (sessionId, callIds) => { + this.runningToolTracker.detach(callIds); + const cached = this.compactActivityBySession.get(sessionId); + if (cached && callIds.includes(cached.callId)) { + this.compactActivityBySession.delete(sessionId); + } + }, }); this.toolCallStreamer = new ToolCallStreamer({ throttleMs: getSessionStreamThrottleMs, + takeGate: (sessionId) => this.takeReplyGate(sessionId), sendText: (sessionId, text) => this.delivery.sendText( this.requireForegroundDestination(sessionId, "Tool stream", "send"), @@ -317,11 +346,20 @@ export class SessionRuntimeState { deleteSessionKeys(this.assistantStreamModes, sessionId); deleteSessionKeys(this.assistantLatestTexts, sessionId); deleteSessionKeys(this.assistantDeliveredTexts, sessionId); - for (const key of Array.from(this.assistantCompletionsStarted)) { - if (key.startsWith(`${sessionId}:`)) { - this.assistantCompletionsStarted.delete(key); + for (const keys of [ + this.assistantCompletionsStarted, + this.begunReplies, + this.compactClosedReplies, + ]) { + for (const key of Array.from(keys)) { + if (key.startsWith(`${sessionId}:`)) { + keys.delete(key); + } } } + this.replyCuts.delete(sessionId); + this.replyEnqueueDeferrals.delete(sessionId); + this.compactCloseDue.delete(sessionId); this.assistantEditStreamer.clearSession(sessionId, reason); this.assistantDraftStreamer.clearSession(sessionId, reason); } @@ -333,6 +371,112 @@ export class SessionRuntimeState { ); } + // --- order of a reply and what follows it --- + + isReplyBegun(sessionId: string, messageId: string): boolean { + return this.begunReplies.has(sessionKey(sessionId, messageId)); + } + + markReplyBegun(sessionId: string, messageId: string): void { + this.begunReplies.add(sessionKey(sessionId, messageId)); + } + + /** + * Settles the output that came before a reply's text where it is: queued tool + * messages finish, and the tool stream breaks so later tools open a new message. + */ + startReplyCut(sessionId: string): void { + const previousCut = this.replyCuts.get(sessionId); + const cut = Promise.all([ + previousCut, + this.toolMessageBatcher.flushSession(sessionId, "assistant_reply_started"), + this.toolCallStreamer.breakSession(sessionId, "assistant_reply_started"), + ]) + .then(() => undefined) + .catch((error) => { + logger.error(`[Bot] Failed to settle output before a reply: session=${sessionId}`, error); + }); + this.replyCuts.set(sessionId, cut); + void cut.finally(() => { + if (this.replyCuts.get(sessionId) === cut) { + this.replyCuts.delete(sessionId); + } + }); + } + + setReplyEnqueueDeferral(sessionId: string, deferral: Promise): void { + this.replyEnqueueDeferrals.set(sessionId, deferral); + void deferral.finally(() => { + if (this.replyEnqueueDeferrals.get(sessionId) === deferral) { + this.replyEnqueueDeferrals.delete(sessionId); + } + }); + } + + /** + * Taken when output enters its queue: letting it out sends the replies begun by + * then that are not on screen yet, after the output that came before them. + */ + takeReplyGate(sessionId: string): ReplyGate | undefined { + const sessionPrefix = `${sessionId}:`; + const messageIds = Array.from(this.begunReplies) + .filter((key) => key.startsWith(sessionPrefix)) + .map((key) => key.slice(sessionPrefix.length)); + if (messageIds.length === 0) { + return undefined; + } + + const deferral = this.replyEnqueueDeferrals.get(sessionId); + const cut = this.replyCuts.get(sessionId); + return async () => { + await deferral?.catch(() => undefined); + await cut; + for (const messageId of messageIds) { + await this.assistantEditStreamer.flushPending(sessionId, messageId); + } + }; + } + + async letOutReplies(sessionId: string): Promise { + await this.takeReplyGate(sessionId)?.(); + } + + /** + * A document opens its boundary only once no cut it has not waited for is running, + * so it never joins a boundary a cut is waiting on. + */ + async letOutRepliesBeforeDocument(sessionId: string): Promise { + let cut: Promise | undefined; + do { + cut = this.replyCuts.get(sessionId); + await this.letOutReplies(sessionId); + } while (this.replyCuts.get(sessionId) !== cut); + } + + markCompactCloseDue(sessionId: string, messageId: string): void { + this.compactCloseDue.set(sessionId, messageId); + } + + /** Takes the reply whose card closes now, remembering it was closed that way. */ + takeCompactCloseDue(sessionId: string): string | undefined { + const messageId = this.compactCloseDue.get(sessionId); + if (messageId === undefined) { + return undefined; + } + + this.compactCloseDue.delete(sessionId); + this.compactClosedReplies.add(sessionKey(sessionId, messageId)); + return messageId; + } + + /** At a reply's completion: whether its card already closed, dropping its marks. */ + takeCompactClosedForReply(sessionId: string, messageId: string): boolean { + if (this.compactCloseDue.get(sessionId) === messageId) { + this.compactCloseDue.delete(sessionId); + } + return this.compactClosedReplies.delete(sessionKey(sessionId, messageId)); + } + // --- thinking --- setThinkingSections(sessionId: string, messageId: string, sections: ThinkingSection[]): void { @@ -414,16 +558,76 @@ export class SessionRuntimeState { this.subagentSnapshots.set(sessionId, subagents); } - /** Stops tool timers and drops tool bookkeeping for one session. */ - clearToolTracking(sessionId: string, reason: string): void { - this.runningToolTracker.clearSession(sessionId, reason); - this.runningToolTracker.setHeartbeatActive(sessionId, false); + /** + * Stops tool timers and drops tool bookkeeping for one session. A turn boundary of the + * foreground session passes `keepBackground`: background operations outlive the turn. + */ + clearToolTracking(sessionId: string, reason: string, keepBackground = false): void { + const keptCallIds = new Set( + keepBackground ? this.runningToolTracker.backgroundCallIds(sessionId) : [], + ); + this.runningToolTracker.clearSession(sessionId, reason, keepBackground); this.compactActivityBySession.delete(sessionId); - this.subagentSnapshots.delete(sessionId); - deleteSessionKeys(this.runningToolInfos, sessionId); + const keptSubagents = keepBackground + ? (this.subagentSnapshots.get(sessionId) ?? []).filter(isRunningBackgroundSubagent) + : []; + this.setKeptSubagents(sessionId, keptSubagents); + for (const key of Array.from(this.runningToolInfos.keys())) { + const callId = key.slice(sessionId.length + 1); + if (key.startsWith(`${sessionId}:`) && !keptCallIds.has(callId)) { + this.runningToolInfos.delete(key); + } + } deleteSessionKeys(this.completedToolDurations, sessionId); } + /** + * Stops following background operations of one session, or of all: their lines and + * cards stay as they are and a later completion no longer changes them. + */ + stopBackgroundOperations(reason: string, sessionId?: string): void { + const callIds = this.runningToolTracker.backgroundCallIds(sessionId); + for (const callId of callIds) { + this.runningToolTracker.release(callId); + } + for (const [key, info] of Array.from(this.runningToolInfos)) { + if (callIds.includes(info.callId)) { + this.runningToolInfos.delete(key); + } + } + + this.toolCallStreamer.freezePinnedEntries(sessionId); + this.compactProgressStreamer.dropBackgroundOperations(sessionId); + for (const [snapshotSessionId, subagents] of Array.from(this.subagentSnapshots)) { + if (sessionId === undefined || snapshotSessionId === sessionId) { + this.setKeptSubagents( + snapshotSessionId, + subagents.filter((subagent) => !subagent.background), + ); + } + } + + if (callIds.length > 0) { + logger.debug( + `[Bot] Stopped background operations: session=${sessionId ?? "all"}, count=${callIds.length}, reason=${reason}`, + ); + } + } + + private setKeptSubagents(sessionId: string, subagents: SubagentInfo[]): void { + if (subagents.length === 0) { + this.subagentSnapshots.delete(sessionId); + this.runningToolTracker.setHeartbeatActive(sessionId, false); + return; + } + + this.subagentSnapshots.set(sessionId, subagents); + this.runningToolTracker.setHeartbeatActive( + sessionId, + subagents.some((subagent) => subagent.status === "pending" || subagent.status === "running"), + ); + } + // --- clearing --- /** Drops everything one session holds; other sessions keep running. */ @@ -445,6 +649,11 @@ export class SessionRuntimeState { this.assistantStreamModes.clear(); this.assistantLatestTexts.clear(); this.assistantDeliveredTexts.clear(); + this.begunReplies.clear(); + this.replyCuts.clear(); + this.replyEnqueueDeferrals.clear(); + this.compactCloseDue.clear(); + this.compactClosedReplies.clear(); this.assistantEditStreamer.clearAll(reason); this.assistantDraftStreamer.clearAll(reason); this.thinkingStreamer.clearAll(reason); @@ -469,6 +678,7 @@ export class SessionRuntimeState { this.assistantStreamModes.delete(key); this.assistantLatestTexts.delete(key); this.assistantDeliveredTexts.delete(key); + this.begunReplies.delete(key); } private getAssistantStreamer(mode: ResponseStreamingMode): ResponseStreamer { diff --git a/src/bot/handlers/prompt-queue-dispatch.ts b/src/bot/handlers/prompt-queue-dispatch.ts index 0a9d8cc5e..7d3cb3aed 100644 --- a/src/bot/handlers/prompt-queue-dispatch.ts +++ b/src/bot/handlers/prompt-queue-dispatch.ts @@ -161,7 +161,7 @@ async function sendPromptToInbox( if (!promptQueue.releaseReservation(reservationId)) { return; } - if (!deps.assistantRunState.hasRun(admitted.sessionId)) { + if (!deps.assistantRunState.hasBotRun(admitted.sessionId)) { const session = getCurrentSession(); if (session?.id === admitted.sessionId) { await startInboxPromptRun(session, deps, input.responseMode); diff --git a/src/bot/handlers/prompt.ts b/src/bot/handlers/prompt.ts index 246eeab05..664294e52 100644 --- a/src/bot/handlers/prompt.ts +++ b/src/bot/handlers/prompt.ts @@ -19,6 +19,7 @@ import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import { formatErrorDetails } from "../../utils/error-format.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; import { attachToSession, @@ -268,11 +269,21 @@ export async function processUserPrompt( variantName, ); - await ctx.reply(t("bot.session_created", { title: currentSession.title }), { + await ctx.reply(t("bot.session_created", { title: formatSessionTitle(currentSession.title) }), { reply_markup: keyboard, }); } + if (input.photos.length > 0) { + // Reading capabilities can wait for the model's provider to be listed; finish that before + // the busy check, so a message sent meanwhile cannot start a run this photo then lands in. + const photoModel = (deps.getStoredModel ?? getStoredModel)(); + await (deps.getModelCapabilities ?? getModelCapabilities)( + photoModel.providerID, + photoModel.modelID, + ); + } + const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory); if (sessionIsBusy) { logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`); diff --git a/src/bot/index.ts b/src/bot/index.ts index 4f6714bbd..aec94054c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -6,8 +6,7 @@ import { configureAttachPresentation, restoreAttachedCurrentSession, } from "../app/services/attach-service.js"; -import { getStoredModel } from "../app/services/model-selection-service.js"; -import { waitForLateModelCatalogSettle } from "../opencode/ready-refresh.js"; +import { refreshModelViewsAfterLateCatalogSettle } from "./services/model-views.js"; import { logger } from "../utils/logger.js"; import { safeBackgroundTask } from "../utils/safe-background-task.js"; import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js"; @@ -75,25 +74,6 @@ function isTelegramApiErrorResponse(response: unknown): response is TelegramApiE ); } -// The restore drew the dashboard before the server listed the selected model's provider. -function refreshModelViewsAfterLateCatalogSettle(container: AppContainer, reason: string): void { - safeBackgroundTask({ - taskName: "bot.refreshModelViewsAfterLateCatalogSettle", - task: async () => { - if (!(await waitForLateModelCatalogSettle())) { - return; - } - - await container.pinnedMessageManager.refreshContextLimit(); - await container.pinnedMessageManager.refresh(); - if (container.keyboardManager.isInitialized()) { - container.keyboardManager.updateModel(getStoredModel()); - } - logger.info(`[Bot] Refreshed model views after the model catalog settled: reason=${reason}`); - }, - }); -} - export function createBot( container: AppContainer, localCommandRegistry = LocalCommandRegistry.empty(), diff --git a/src/bot/menus/question-menu.ts b/src/bot/menus/question-menu.ts index f538a9a14..4ba9ed90a 100644 --- a/src/bot/menus/question-menu.ts +++ b/src/bot/menus/question-menu.ts @@ -245,7 +245,7 @@ async function sendAllAnswersToAgent( const allAnswers: string[][] = []; for (let i = 0; i < totalQuestions; i++) { - allAnswers.push(questionManager.getAnswerItems(i)); + allAnswers.push(questionManager.getReplyItems(i)); } logger.info( @@ -410,7 +410,11 @@ function isHighSurrogate(codeUnit: number): boolean { } function buildQuestionKeyboard( - question: { options: Array<{ label: string; description: string }>; multiple?: boolean }, + question: { + options: Array<{ label: string; description: string }>; + multiple?: boolean; + custom?: boolean; + }, selectedOptions: Set, deps: QuestionDataDeps, ): InlineKeyboard { @@ -444,8 +448,11 @@ function buildQuestionKeyboard( logger.debug(`[QuestionHandler] Added submit button`); } - keyboard.text(t("question.button.custom"), `question:custom:${questionIndex}`).row(); - logger.debug(`[QuestionHandler] Added custom answer button`); + // A question without choices can only be answered with custom text + if (question.options.length === 0 || question.custom !== false) { + keyboard.text(t("question.button.custom"), `question:custom:${questionIndex}`).row(); + logger.debug(`[QuestionHandler] Added custom answer button`); + } keyboard.text(t("question.button.cancel"), `question:cancel:${questionIndex}`); logger.debug(`[QuestionHandler] Added cancel button`); diff --git a/src/bot/menus/recent-selection-menu.ts b/src/bot/menus/recent-selection-menu.ts index 21c180599..6a8063e25 100644 --- a/src/bot/menus/recent-selection-menu.ts +++ b/src/bot/menus/recent-selection-menu.ts @@ -2,6 +2,7 @@ import { InlineKeyboard } from "grammy"; import { getProjectFolderName } from "./project-selection-menu.js"; import type { RecentSession, RecentStatus } from "../../app/services/recent-sessions-service.js"; import { t } from "../../i18n/index.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; export const RECENT_CALLBACK_PREFIX = "recent:"; const GLYPHS: Record = { @@ -73,10 +74,11 @@ export function buildRecentMenu(rows: RecentSession[]): { text: string; keyboard const project = names[index] ?? ""; const prefix = `${GLYPHS[status]} [${project}] `; const available = Math.max(0, 64 - [...prefix].length); - const title = [...session.title]; + const displayTitle = formatSessionTitle(session.title); + const title = [...displayTitle]; const trimmed = title.length > available ? `${title.slice(0, Math.max(0, available - 1)).join("")}…` - : session.title; + : displayTitle; keyboard.text(`${prefix}${trimmed}`, `${RECENT_CALLBACK_PREFIX}${index}`).row(); }); return { diff --git a/src/bot/menus/session-selection-menu.ts b/src/bot/menus/session-selection-menu.ts index 740997721..eb17e6aaa 100644 --- a/src/bot/menus/session-selection-menu.ts +++ b/src/bot/menus/session-selection-menu.ts @@ -1,6 +1,7 @@ import { InlineKeyboard } from "grammy"; import { opencodeClient } from "../../opencode/client.js"; import { getDateLocale, t } from "../../i18n/index.js"; +import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { logger } from "../../utils/logger.js"; export const SESSION_CALLBACK_PREFIX = "session:"; @@ -154,7 +155,7 @@ function buildSessionsKeyboard(pageData: SessionPage, pageSize: number): InlineK pageData.sessions.forEach((session, index) => { const date = new Date(session.time.created).toLocaleDateString(localeForDate); - const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`; + const label = `${pageStartIndex + index + 1}. ${formatSessionTitle(session.title)} (${date})`; keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row(); }); diff --git a/src/bot/middleware/auth.ts b/src/bot/middleware/auth.ts index a81cb0fce..3bd6ade5c 100644 --- a/src/bot/middleware/auth.ts +++ b/src/bot/middleware/auth.ts @@ -12,6 +12,9 @@ export async function authMiddleware(ctx: Context, next: NextFunction): Promise< if (userId && userId === config.telegram.allowedUserId) { logger.debug(`[Auth] Access granted for userId=${userId}`); await next(); + } else if (userId && userId === ctx.me.id) { + // Updates authored by the bot itself (e.g. the service message about its own pin) + logger.debug(`[Auth] Ignoring update from the bot itself: userId=${userId}`); } else { // Silently ignore unauthorized users logger.warn(`Unauthorized access attempt from user ID: ${userId}`); diff --git a/src/bot/middleware/interaction-guard-decision.ts b/src/bot/middleware/interaction-guard-decision.ts index daa3e54f9..c7f805e37 100644 --- a/src/bot/middleware/interaction-guard-decision.ts +++ b/src/bot/middleware/interaction-guard-decision.ts @@ -10,6 +10,7 @@ import type { } from "../../app/types/interaction.js"; import { QUEUED_PROMPT_BUTTON_TEXT_PATTERN } from "../message-patterns.js"; import type { LocalCommandRegistry } from "../../app/services/local-command-registry.js"; +import { isKnownCommand } from "../routers/command-utils.js"; export type InteractionGuardDecisionDeps = Pick< AppContainer, @@ -23,6 +24,17 @@ function isBusyAllowedCommand(command: string | undefined, localCommandRegistry? return Boolean(command && (BUSY_ALLOWED_COMMAND_SET.has(command) || localCommandRegistry?.allowsWhenBusy(command))); } +// Allowed while a task runs, but not over an interaction: the server would cancel it under the user. +const BUSY_ALLOWED_OUTSIDE_INTERACTION_COMMANDS = new Set(["/reload"]); + +function isBusyAllowedOutsideInteraction(command: string | undefined): boolean { + return Boolean( + command && + BUSY_ALLOWED_OUTSIDE_INTERACTION_COMMANDS.has(command) && + isKnownCommand(command.slice(1)), + ); +} + function allowsBusyInteraction(kind: InteractionKind | undefined): boolean { return kind === "question" || kind === "permission"; } @@ -172,6 +184,9 @@ export function resolveInteractionGuardDecision( if (isBusyAllowedCommand(command, localCommandRegistry)) { return createAllowDecision(inputType, state, command, true); } + if (!state && isBusyAllowedOutsideInteraction(command)) { + return createAllowDecision(inputType, state, command, true); + } return createBusyBlockDecision(inputType, state, "command_not_allowed", command); } diff --git a/src/bot/pinned/pinned-message-manager.ts b/src/bot/pinned/pinned-message-manager.ts index be86c9899..e699cc466 100644 --- a/src/bot/pinned/pinned-message-manager.ts +++ b/src/bot/pinned/pinned-message-manager.ts @@ -13,9 +13,11 @@ import { import { DEFAULT_CONTEXT_LIMIT, getModelContextLimit, + waitForModelContextLimit, } from "../../app/services/model-context-limit-service.js"; import { getStoredModel } from "../../app/services/model-selection-service.js"; import { isExpectedOpencodeUnavailableError } from "../../utils/opencode-error.js"; +import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import type { FileChange, PinnedMessageState, TokensInfo } from "./pinned-message-types.js"; import { t } from "../../i18n/index.js"; import { @@ -114,6 +116,7 @@ export class PinnedMessageManager { await this.unpinOldMessage(); await this.createPinnedMessage(); } + this.fillContextLimitWhenListed(); await this.loadDiffsFromApi(sessionId); } @@ -147,6 +150,7 @@ export class PinnedMessageManager { if (getPinnedDashboardEnabled()) { await this.updatePinnedMessage(true); } + this.fillContextLimitWhenListed(); await this.loadDiffsFromApi(sessionId); } @@ -721,6 +725,42 @@ export class PinnedMessageManager { } } + /** + * The limit just drawn is the default while the server has not listed the model's provider + * yet (a location waking after idle): fill in the model's own limit once it is listed. + */ + private fillContextLimitWhenListed(): void { + const sessionId = this.state.sessionId; + const model = getStoredModel(); + if (!model.providerID || !model.modelID) { + return; + } + + safeBackgroundTask({ + taskName: "pinned.fillContextLimit", + task: () => waitForModelContextLimit(model.providerID, model.modelID), + onSuccess: async (limit) => { + const currentModel = getStoredModel(); + if ( + limit === null || + limit === this.contextLimit || + this.state.sessionId !== sessionId || + currentModel.providerID !== model.providerID || + currentModel.modelID !== model.modelID + ) { + return; + } + + logger.info( + `[PinnedManager] Context limit filled in after the model was listed: ${model.providerID}/${model.modelID}, limit=${limit}`, + ); + this.contextLimit = limit; + this.state.tokensLimit = limit; + await this.updatePinnedMessage(true); + }, + }); + } + /** * Format the pinned message text */ diff --git a/src/bot/routers/command-router.ts b/src/bot/routers/command-router.ts index 27e06ca36..fc957a22e 100644 --- a/src/bot/routers/command-router.ts +++ b/src/bot/routers/command-router.ts @@ -4,6 +4,7 @@ import { config } from "../../config.js"; import { settingsCommand } from "../commands/settings-command.js"; import { opencodeStartCommand } from "../commands/opencode-start-command.js"; import { opencodeStopCommand } from "../commands/opencode-stop-command.js"; +import { reloadCommand } from "../commands/reload-command.js"; import { projectsCommand } from "../commands/projects-command.js"; import { worktreeCommand } from "../commands/worktree-command.js"; import { openCommand } from "../commands/open-command.js"; @@ -24,6 +25,7 @@ import { startCommand } from "../commands/start-command.js"; import { helpCommand } from "../commands/help-command.js"; import { statusCommand } from "../commands/status-command.js"; import { BOT_COMMANDS } from "../commands/definitions.js"; +import { isKnownCommand } from "./command-utils.js"; import { logger } from "../../utils/logger.js"; import { flushPendingPrompt } from "../handlers/message-merger.js"; import { @@ -89,6 +91,9 @@ export function registerCommandRouter(bot: Bot, deps: CommandRouterDeps bot.command("settings", (ctx) => settingsCommand(ctx, container)); bot.command("opencode_start", (ctx) => opencodeStartCommand(ctx, container)); bot.command("opencode_stop", (ctx) => opencodeStopCommand(ctx, container)); + if (isKnownCommand("reload")) { + bot.command("reload", (ctx) => reloadCommand(ctx, container)); + } bot.command("projects", (ctx) => projectsCommand(ctx, container)); bot.command("worktree", (ctx) => worktreeCommand(ctx, container)); bot.command("open", (ctx) => openCommand(ctx, container)); diff --git a/src/bot/services/event-subscription-service.ts b/src/bot/services/event-subscription-service.ts index 3924183e4..3b8bf6548 100644 --- a/src/bot/services/event-subscription-service.ts +++ b/src/bot/services/event-subscription-service.ts @@ -25,6 +25,7 @@ export interface BotEventSubscriptionService { ensureEventSubscription(directory: string): Promise; setTelegramContext(bot: Bot | null, chatId: number | null): void; clearRuntimeState(reason: string): void; + stopBackgroundOperations(reason: string, sessionId?: string): void; cleanup(reason: string): void; } @@ -83,6 +84,11 @@ class EventSubscriptionService implements BotEventSubscriptionService { this.deps.assistantRunState.clearAll(reason); }; + stopBackgroundOperations = (reason: string, sessionId?: string): void => { + this.deps.summaryAggregator.retireBackgroundSubagents(); + this.runtime.stopBackgroundOperations(reason, sessionId); + }; + cleanup(reason: string): void { stopEventListening(); this.deps.summaryAggregator.clear(); @@ -134,6 +140,12 @@ class EventSubscriptionService implements BotEventSubscriptionService { }; private restoreAfterReconnect(): void { + // The new stream no longer knows the background operations the old one announced, + // so their end would never arrive. + this.stopBackgroundOperations("event_stream_reconnect"); + // An idle missed in the gap must not time a later turn from this one. + this.deps.summaryAggregator.forgetLiveTurn(); + const bot = this.botInstance; const chatId = this.chatIdInstance; if (!bot || !chatId) { diff --git a/src/bot/services/model-views.ts b/src/bot/services/model-views.ts new file mode 100644 index 000000000..cece13681 --- /dev/null +++ b/src/bot/services/model-views.ts @@ -0,0 +1,34 @@ +import type { AppContainer } from "../../app/bootstrap/app-container.js"; +import { getStoredModel } from "../../app/services/model-selection-service.js"; +import { waitForLateModelCatalogSettle } from "../../opencode/ready-refresh.js"; +import { logger } from "../../utils/logger.js"; +import { safeBackgroundTask } from "../../utils/safe-background-task.js"; + +export type ModelViewsDeps = Pick; + +/** Redraws what shows the selected model: the pinned dashboard and the keyboard model button. */ +export async function refreshModelViews(deps: ModelViewsDeps): Promise { + await deps.pinnedMessageManager.refreshContextLimit(); + await deps.pinnedMessageManager.refresh(); + if (deps.keyboardManager.isInitialized()) { + deps.keyboardManager.updateModel(getStoredModel()); + } +} + +// The views were drawn before the server listed the selected model's provider. +export function refreshModelViewsAfterLateCatalogSettle( + deps: ModelViewsDeps, + reason: string, +): void { + safeBackgroundTask({ + taskName: "bot.refreshModelViewsAfterLateCatalogSettle", + task: async () => { + if (!(await waitForLateModelCatalogSettle())) { + return; + } + + await refreshModelViews(deps); + logger.info(`[Bot] Refreshed model views after the model catalog settled: reason=${reason}`); + }, + }); +} diff --git a/src/bot/streaming/compact-progress-streamer.ts b/src/bot/streaming/compact-progress-streamer.ts index dca6e88d0..ec5a2f039 100644 --- a/src/bot/streaming/compact-progress-streamer.ts +++ b/src/bot/streaming/compact-progress-streamer.ts @@ -11,6 +11,8 @@ interface CompactProgressState { latestText: string; toolCallIds: Set; filePaths: Set; + // Background operations started on this card, in start order, with their current text. + backgroundActivities: Map; timer: ReturnType | null; task: Promise; cancelled: boolean; @@ -27,6 +29,8 @@ export interface CompactProgressStreamerOptions { sendText: (sessionId: string, text: string) => Promise; editText: (sessionId: string, messageId: number, text: string) => Promise; deleteText?: (sessionId: string, messageId: number) => Promise; + /** A card kept open past its close for the background operations it still shows. */ + onPark?: (sessionId: string, callIds: string[]) => void; } function getErrorMessage(error: unknown): string { @@ -40,6 +44,7 @@ function createInitialState(sessionId: string): CompactProgressState { latestText: "", toolCallIds: new Set(), filePaths: new Set(), + backgroundActivities: new Map(), timer: null, task: Promise.resolve(true), cancelled: false, @@ -51,16 +56,26 @@ export class CompactProgressStreamer { private readonly states = new Map(); private readonly heldBySession = new Map(); private readonly finalizing = new Set(); + // Cards closed for their stretch that still show a running background operation. + private readonly parked = new Set(); private readonly throttleMs: StreamThrottleMs; private readonly sendText: CompactProgressStreamerOptions["sendText"]; private readonly editText: CompactProgressStreamerOptions["editText"]; private readonly deleteText: CompactProgressStreamerOptions["deleteText"]; - - constructor({ throttleMs, sendText, editText, deleteText }: CompactProgressStreamerOptions) { + private readonly onPark: CompactProgressStreamerOptions["onPark"]; + + constructor({ + throttleMs, + sendText, + editText, + deleteText, + onPark, + }: CompactProgressStreamerOptions) { this.throttleMs = throttleMs; this.sendText = sendText; this.editText = editText; this.deleteText = deleteText; + this.onPark = onPark; } private resolveThrottleMs(sessionId: string): number { @@ -153,22 +168,108 @@ export class CompactProgressStreamer { this.states.get(sessionId)?.filePaths.add(normalizedPath); } + /** Registers a background operation on the open card, or updates its text on its own card. */ + addBackgroundOperation(sessionId: string, callId: string, activity: string): void { + if (!sessionId || !callId || !activity.trim()) { + return; + } + + if (this.findBackgroundCard(sessionId, callId)) { + this.updateBackgroundOperation(sessionId, callId, activity); + return; + } + + this.getOrCreateState(sessionId).backgroundActivities.set(callId, activity.trim()); + } + + updateBackgroundOperation(sessionId: string, callId: string, activity: string): void { + const state = this.findBackgroundCard(sessionId, callId); + if (!state || !activity.trim()) { + return; + } + + state.backgroundActivities.set(callId, activity.trim()); + if (this.parked.has(state)) { + this.showNewestBackgroundOperation(state); + } + } + + /** Counts the ended operation on its own card and closes a parked card left with none. */ + async endBackgroundOperation( + sessionId: string, + callId: string, + deleteOnFinish = false, + ): Promise { + const state = this.findBackgroundCard(sessionId, callId); + if (!state) { + return; + } + + state.backgroundActivities.delete(callId); + state.toolCallIds.add(callId); + if (!this.parked.has(state)) { + return; + } + + if (state.backgroundActivities.size > 0) { + this.showNewestBackgroundOperation(state); + return; + } + + this.parked.delete(state); + this.clearTimer(state); + this.finalizing.add(state); + await this.closeState(state, deleteOnFinish); + } + + /** Forgets background operations of a session (or all); their cards stay as they are. */ + dropBackgroundOperations(sessionId?: string): void { + for (const state of this.states.values()) { + if (sessionId === undefined || state.sessionId === sessionId) { + state.backgroundActivities.clear(); + } + } + this.cancelParked(sessionId); + } + async finalize(sessionId: string, deleteOnFinish = false): Promise { const state = this.states.get(sessionId); if (state) { this.clearTimer(state); this.states.delete(sessionId); - this.finalizing.add(state); + if (state.backgroundActivities.size > 0) { + this.parked.add(state); + } else { + this.finalizing.add(state); + } } const held = this.heldBySession.get(sessionId); this.heldBySession.delete(sessionId); + const parked = state && this.parked.has(state) ? state : undefined; + if (parked) { + this.onPark?.(sessionId, Array.from(parked.backgroundActivities.keys())); + // A timer tick of a background operation held during the close belongs to the + // parked card, not to the next stretch. + if (held?.activity && Array.from(parked.backgroundActivities.values()).includes(held.activity)) { + held.activity = null; + } + } this.replayHeld(sessionId, held); if (!state) { return; } + if (parked) { + this.showNewestBackgroundOperation(parked); + return; + } + + await this.closeState(state, deleteOnFinish); + } + + private async closeState(state: CompactProgressState, deleteOnFinish: boolean): Promise { try { await state.task.catch(() => false); @@ -210,6 +311,23 @@ export class CompactProgressStreamer { } clearSession(sessionId: string, reason: string): void { + const cancelledParked = this.cancelParked(sessionId); + const cleared = this.clearOpenCard(sessionId); + if (!cleared && !cancelledParked) { + return; + } + + logger.debug(`[CompactProgress] Cleared session: session=${sessionId}, reason=${reason}`); + } + + /** Drops the open card only; parked cards keep showing their background operations. */ + discardOpenCard(sessionId: string, reason: string): void { + if (this.clearOpenCard(sessionId)) { + logger.debug(`[CompactProgress] Discarded open card: session=${sessionId}, reason=${reason}`); + } + } + + private clearOpenCard(sessionId: string): boolean { this.heldBySession.delete(sessionId); const state = this.states.get(sessionId); if (state) { @@ -219,11 +337,7 @@ export class CompactProgressStreamer { } const cancelledFinalizing = this.cancelFinalizing(sessionId); - if (!state && !cancelledFinalizing) { - return; - } - - logger.debug(`[CompactProgress] Cleared session: session=${sessionId}, reason=${reason}`); + return Boolean(state) || cancelledFinalizing; } clearAll(reason: string): void { @@ -234,9 +348,52 @@ export class CompactProgressStreamer { } this.states.clear(); this.cancelFinalizing(); + this.cancelParked(); logger.debug(`[CompactProgress] Cleared all sessions: reason=${reason}`); } + private findBackgroundCard(sessionId: string, callId: string): CompactProgressState | undefined { + const open = this.states.get(sessionId); + if (open?.backgroundActivities.has(callId)) { + return open; + } + + for (const state of this.parked) { + if (state.sessionId === sessionId && state.backgroundActivities.has(callId)) { + return state; + } + } + + return undefined; + } + + /** A parked card shows the most recently started background operation still running. */ + private showNewestBackgroundOperation(state: CompactProgressState): void { + const activity = Array.from(state.backgroundActivities.values()).pop(); + if (!activity) { + return; + } + + state.latestText = t("progress.compact.activity", { + header: t("progress.compact.working_header"), + activity, + }); + this.ensureTimer(state); + } + + private cancelParked(sessionId?: string): boolean { + let cancelled = false; + for (const state of Array.from(this.parked)) { + if (sessionId === undefined || state.sessionId === sessionId) { + this.clearTimer(state); + this.cancelState(state); + this.parked.delete(state); + cancelled = true; + } + } + return cancelled; + } + private replayHeld(sessionId: string, held: HeldStretch | undefined): void { if (!held) { return; diff --git a/src/bot/streaming/response-streamer.ts b/src/bot/streaming/response-streamer.ts index 35597fad9..bc028a065 100644 --- a/src/bot/streaming/response-streamer.ts +++ b/src/bot/streaming/response-streamer.ts @@ -293,6 +293,25 @@ export class ResponseStreamer { return { streamed: synced, telegramMessageIds: messageIds }; } + /** + * Sends a stream that has nothing on screen yet right away instead of waiting + * for its timer. A stream already shown, broken or cancelled is left alone. + */ + async flushPending(sessionId: string, messageId: string): Promise { + const state = this.states.get(buildStateKey(sessionId, messageId)); + if (!state || state.cancelled || state.isBroken || state.telegramMessageIds.length > 0) { + return; + } + + this.clearTimer(state); + await this.enqueueTask(state, () => this.flushState(state, "flush_pending")).catch((error) => { + logger.error( + `[ResponseStreamer] Pending stream flush failed: session=${sessionId}, message=${messageId}`, + error, + ); + }); + } + clearMessage(sessionId: string, messageId: string, reason: string): void { const key = buildStateKey(sessionId, messageId); const state = this.states.get(key); diff --git a/src/bot/streaming/running-tool-tracker.ts b/src/bot/streaming/running-tool-tracker.ts index 54477fbcf..00c35c9ea 100644 --- a/src/bot/streaming/running-tool-tracker.ts +++ b/src/bot/streaming/running-tool-tracker.ts @@ -21,6 +21,11 @@ interface TrackedCall { startedAt: number; lastBucketMs?: number; stopped: boolean; + // A background operation outlives the turn that started it. + background: boolean; + // Its progress card was closed for the stretch, so it no longer counts as what the + // session is running now. + detached: boolean; } /** @@ -49,15 +54,51 @@ export class RunningToolTracker { this.onHeartbeat = options.onHeartbeat; } - track(sessionId: string, callId: string): void { - if (!sessionId || !callId || this.calls.has(callId)) { + track(sessionId: string, callId: string, background = false): void { + if (!sessionId || !callId) { return; } - this.calls.set(callId, { sessionId, startedAt: Date.now(), stopped: false }); + const existing = this.calls.get(callId); + if (existing) { + existing.background ||= background; + return; + } + + this.calls.set(callId, { + sessionId, + startedAt: Date.now(), + stopped: false, + background, + detached: false, + }); this.ensureTimer(); } + isBackground(callId: string): boolean { + return this.calls.get(callId)?.background ?? false; + } + + isDetached(callId: string): boolean { + return this.calls.get(callId)?.detached ?? false; + } + + detach(callIds: string[]): void { + for (const callId of callIds) { + const call = this.calls.get(callId); + if (call) { + call.detached = true; + } + } + } + + /** Background calls of a session, or of every session. */ + backgroundCallIds(sessionId?: string): string[] { + return Array.from(this.calls.entries()) + .filter(([, call]) => call.background && (sessionId === undefined || call.sessionId === sessionId)) + .map(([callId]) => callId); + } + /** * Forgets the call and returns how long it ran, but only if it lived past the * threshold. Fast calls return undefined so their output stays as it was. @@ -93,11 +134,11 @@ export class RunningToolTracker { this.stopTimerWhenIdle(); } - clearSession(sessionId: string, reason: string): void { + clearSession(sessionId: string, reason: string, keepBackground = false): void { let clearedAny = this.heartbeatSessions.delete(sessionId); for (const [callId, call] of Array.from(this.calls.entries())) { - if (call.sessionId !== sessionId) { + if (call.sessionId !== sessionId || (keepBackground && call.background)) { continue; } @@ -127,7 +168,7 @@ export class RunningToolTracker { const matches: { callId: string; startedAt: number }[] = []; for (const [callId, call] of this.calls) { - if (call.sessionId === sessionId) { + if (call.sessionId === sessionId && !call.detached) { matches.push({ callId, startedAt: call.startedAt }); } } diff --git a/src/bot/streaming/tool-call-streamer.ts b/src/bot/streaming/tool-call-streamer.ts index e3c0f94ea..d7a5afa29 100644 --- a/src/bot/streaming/tool-call-streamer.ts +++ b/src/bot/streaming/tool-call-streamer.ts @@ -14,8 +14,12 @@ interface ToolCallStreamerOptions { sendText: (sessionId: string, text: string) => Promise; editText: (sessionId: string, telegramMessageId: number, text: string) => Promise; deleteText: (sessionId: string, telegramMessageId: number) => Promise; + /** Asked when a stream starts: what its first new message has to wait for, if anything. */ + takeGate?: (sessionId: string) => ToolStreamGate | undefined; } +export type ToolStreamGate = () => Promise; + interface StreamEntry { prefix?: string; text: string; @@ -37,6 +41,7 @@ interface StreamState { fatalErrorLogged: boolean; deleteWhenEmpty: boolean; held: boolean; + gate: ToolStreamGate | undefined; } function getErrorMessage(error: unknown): string { @@ -120,6 +125,7 @@ export class ToolCallStreamer { private readonly sendText: ToolCallStreamerOptions["sendText"]; private readonly editText: ToolCallStreamerOptions["editText"]; private readonly deleteText: ToolCallStreamerOptions["deleteText"]; + private readonly takeGate: ToolCallStreamerOptions["takeGate"]; private readonly states: Map = new Map(); private readonly allStates: Set = new Set(); private readonly telegramOperationTasks = new Map>(); @@ -128,12 +134,15 @@ export class ToolCallStreamer { private readonly heldSessions = new Set(); private readonly documentBoundaries = new Map; release: () => void }>(); private readonly frozenToolEntries = new Set(); + // Lines of operations that outlive the turn: their message stays editable after a break. + private readonly pinnedEntries = new Set(); constructor(options: ToolCallStreamerOptions) { this.throttleMs = options.throttleMs; this.sendText = options.sendText; this.editText = options.editText; this.deleteText = options.deleteText; + this.takeGate = options.takeGate; } private resolveThrottleMs(sessionId: string): number { @@ -163,7 +172,7 @@ export class ToolCallStreamer { if (!sessionId || !normalizedPrefix || !normalizedText) { return; } - if (this.frozenToolEntries.has(`${sessionId}:${streamKey}:${normalizedPrefix}`)) { + if (this.frozenToolEntries.has(this.getEntryId(sessionId, streamKey, normalizedPrefix))) { return; } @@ -209,6 +218,31 @@ export class ToolCallStreamer { this.ensureTimer(state); } + /** + * Keeps an entry editable past `breakSession`: its message is no longer where new + * output goes, but later updates of the entry still edit it in place. + */ + pinEntry(sessionId: string, prefix: string, streamKey: ToolStreamKey = DEFAULT_STREAM_KEY): void { + const normalizedPrefix = prefix.trim(); + if (sessionId && normalizedPrefix) { + this.pinnedEntries.add(this.getEntryId(sessionId, streamKey, normalizedPrefix)); + } + } + + unpinEntry(sessionId: string, prefix: string, streamKey: ToolStreamKey = DEFAULT_STREAM_KEY): void { + this.pinnedEntries.delete(this.getEntryId(sessionId, streamKey, prefix.trim())); + } + + /** Pinned entries of a session (or of all sessions) stop taking updates and stay as they are. */ + freezePinnedEntries(sessionId?: string): void { + for (const entryId of Array.from(this.pinnedEntries)) { + if (sessionId === undefined || entryId.startsWith(`${sessionId}:`)) { + this.pinnedEntries.delete(entryId); + this.frozenToolEntries.add(entryId); + } + } + } + /** A document divides new tool messages from older, still-editable calls. */ beginDocumentBoundary(sessionId: string): void { const boundary = this.documentBoundaries.get(sessionId); @@ -258,29 +292,53 @@ export class ToolCallStreamer { if (reason === "session_error" || reason === "session_idle") { for (const state of states) { for (const entry of state.entries) { - if (entry.prefix?.startsWith("⏳") && entry.text.startsWith("⏳")) { - this.frozenToolEntries.add(`${sessionId}:${state.key}:${entry.prefix}`); + const entryId = this.getEntryId(sessionId, state.key, entry.prefix ?? ""); + if ( + entry.prefix?.startsWith("⏳") && + entry.text.startsWith("⏳") && + !this.pinnedEntries.has(entryId) + ) { + this.frozenToolEntries.add(entryId); } } } } + // All at once: an update landing while an earlier state syncs must not join a later one. for (const state of states) { state.isBreaking = true; this.clearTimer(state); + } + for (const state of states) { if (state.held) { await this.documentBoundaries.get(sessionId)?.done; } await this.enqueueTask(state, () => this.syncState(state, reason)); + if (this.hasPinnedEntry(state)) { + this.keepAfterBreak(state); + continue; + } this.cancelState(state); this.removeState(state); } logger.debug(`[ToolCallStreamer] Broke session stream: session=${sessionId}, reason=${reason}`); } + /** Whether a session has output waiting for a new message of its own. */ + hasUnsentMessages(sessionId: string): boolean { + return this.getStatesForSession(sessionId).some( + (state) => + !state.cancelled && + !state.isBroken && + state.latestParts.length > state.telegramMessageIds.length, + ); + } + clearSession(sessionId: string, reason: string): void { - for (const key of this.frozenToolEntries) { - if (key.startsWith(`${sessionId}:`)) { - this.frozenToolEntries.delete(key); + for (const entries of [this.frozenToolEntries, this.pinnedEntries]) { + for (const key of entries) { + if (key.startsWith(`${sessionId}:`)) { + entries.delete(key); + } } } this.documentBoundaries.get(sessionId)?.release(); @@ -307,6 +365,7 @@ export class ToolCallStreamer { clearAll(reason: string): void { this.frozenToolEntries.clear(); + this.pinnedEntries.clear(); for (const boundary of this.documentBoundaries.values()) { boundary.release(); } @@ -333,6 +392,31 @@ export class ToolCallStreamer { return `${sessionId}:${streamKey}`; } + private getEntryId(sessionId: string, streamKey: ToolStreamKey, prefix: string): string { + return `${sessionId}:${streamKey}:${prefix}`; + } + + private hasPinnedEntry(state: StreamState): boolean { + return state.entries.some( + (entry) => + entry.prefix !== undefined && + this.pinnedEntries.has(this.getEntryId(state.sessionId, state.key, entry.prefix)), + ); + } + + /** + * Leaves the state findable by its prefixes but no longer current, so new output opens + * a new message; an update that landed during the break is synced now. + */ + private keepAfterBreak(state: StreamState): void { + const stateId = this.getStateId(state.sessionId, state.key); + if (this.states.get(stateId) === state) { + this.states.delete(stateId); + } + state.isBreaking = false; + this.ensureTimer(state); + } + private getStatesForSession(sessionId: string): StreamState[] { return Array.from(this.allStates).filter((state) => state.sessionId === sessionId); } @@ -379,6 +463,7 @@ export class ToolCallStreamer { fatalErrorLogged: false, deleteWhenEmpty: false, held: this.heldSessions.has(sessionId), + gate: this.takeGate?.(sessionId), }; this.states.set(stateId, state); @@ -604,6 +689,14 @@ export class ToolCallStreamer { continue; } + // A break is its caller's own ordering point, so only a stream outside one waits. + if (state.gate && !state.isBreaking) { + await state.gate(); + if (state.cancelled) { + return; + } + } + const messageId = await this.enqueueTelegramOperation(state.sessionId, () => this.sendText(state.sessionId, text), ); diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 39c4423db..2b80cb0b0 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -31,6 +31,7 @@ export const ar: I18nDictionary = { "cmd.description.mcps": "خوادم MCP", "cmd.description.opencode_start": "تشغيل خادم OpenCode", "cmd.description.opencode_stop": "إيقاف خادم OpenCode", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "استعراض ملفات المجلد", "cmd.description.help": "المساعدة", "cmd.description.rename": "تغيير اسم الجلسة الحالية", @@ -297,6 +298,10 @@ export const ar: I18nDictionary = { "opencode_stop.still_running": "لا يزال الخادم يستجيب بعد طلب الإيقاف.", "opencode_stop.success": "✅ تم إيقاف خادم OpenCode بنجاح", "opencode_stop.error": "🔴 حدث خطأ أثناء إيقاف الخادم.\n\nراجع سجلات التطبيق للتفاصيل.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ تم تغيير الوكيل إلى: {name}", "agent.change_error_callback": "تعذر تغيير الوكيل", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 7008344fd..4f9775e99 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -24,6 +24,7 @@ export const de: I18nDictionary = { "cmd.description.mcps": "MCP servers", "cmd.description.opencode_start": "OpenCode-Server starten", "cmd.description.opencode_stop": "OpenCode-Server stoppen", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "Verzeichnisinhalt auflisten", "cmd.description.help": "Hilfe", @@ -322,6 +323,10 @@ export const de: I18nDictionary = { "opencode_stop.success": "✅ OpenCode-Server erfolgreich gestoppt", "opencode_stop.error": "🔴 Beim Stoppen des Servers ist ein Fehler aufgetreten.\n\nSiehe Anwendungslogs für Details.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agent geändert zu: {name}", "agent.change_error_callback": "Agent konnte nicht geändert werden", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2be4c9739..bc862219f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -22,6 +22,7 @@ export const en = { "cmd.description.mcps": "MCP servers", "cmd.description.opencode_start": "Start OpenCode server", "cmd.description.opencode_stop": "Stop OpenCode server", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "List directory contents", "cmd.description.help": "Help", @@ -304,6 +305,10 @@ export const en = { "opencode_stop.success": "✅ OpenCode Server stopped successfully", "opencode_stop.error": "🔴 An error occurred while stopping server.\n\nCheck application logs for details.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agent changed to: {name}", "agent.change_error_callback": "Failed to change agent", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 14f9985a0..dea4f7a98 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -24,6 +24,7 @@ export const es: I18nDictionary = { "cmd.description.mcps": "MCP servers", "cmd.description.opencode_start": "Iniciar servidor OpenCode", "cmd.description.opencode_stop": "Detener servidor OpenCode", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "Listar contenidos del directorio", "cmd.description.help": "Ayuda", @@ -320,6 +321,10 @@ export const es: I18nDictionary = { "opencode_stop.success": "✅ OpenCode Server detenido correctamente", "opencode_stop.error": "🔴 Ocurrió un error al detener el servidor.\n\nRevisa los logs de la aplicación para más detalles.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agente cambiado a: {name}", "agent.change_error_callback": "No se pudo cambiar el agente", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 013d019ef..15f58d03e 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -24,6 +24,7 @@ export const fr: I18nDictionary = { "cmd.description.mcps": "MCP servers", "cmd.description.opencode_start": "Démarrer le serveur OpenCode", "cmd.description.opencode_stop": "Arrêter le serveur OpenCode", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "Lister le contenu du répertoire", "cmd.description.help": "Aide", @@ -323,6 +324,10 @@ export const fr: I18nDictionary = { "opencode_stop.success": "✅ Serveur OpenCode arrêté avec succès", "opencode_stop.error": "🔴 Une erreur s'est produite lors de l'arrêt du serveur.\n\nConsultez les logs de l'application pour plus de détails.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agent défini sur : {name}", "agent.change_error_callback": "Impossible de modifier l'agent", diff --git a/src/i18n/id.ts b/src/i18n/id.ts index b609a6055..814174fc7 100644 --- a/src/i18n/id.ts +++ b/src/i18n/id.ts @@ -31,6 +31,7 @@ export const id: I18nDictionary = { "cmd.description.mcps": "Server MCP", "cmd.description.opencode_start": "Mulai server OpenCode", "cmd.description.opencode_stop": "Hentikan server OpenCode", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "Daftar isi direktori", "cmd.description.help": "Bantuan", @@ -304,6 +305,10 @@ export const id: I18nDictionary = { "opencode_stop.still_running": "Server masih merespons meski sudah diminta berhenti.", "opencode_stop.success": "✅ Server OpenCode berhasil dihentikan", "opencode_stop.error": "🔴 Gagal menghentikan server.\n\nPeriksa log aplikasi untuk detailnya.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agent diubah menjadi: {name}", "agent.change_error_callback": "Gagal mengubah agent", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 13bc9b378..092a9644b 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -24,6 +24,7 @@ export const it: I18nDictionary = { "cmd.description.mcps": "Server MCP", "cmd.description.opencode_start": "Avvia il server OpenCode", "cmd.description.opencode_stop": "Ferma il server OpenCode", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "Elenca il contenuto della directory", "cmd.description.help": "Aiuto", @@ -318,6 +319,10 @@ export const it: I18nDictionary = { "opencode_stop.success": "✅ Server OpenCode arrestato correttamente", "opencode_stop.error": "🔴 Si è verificato un errore durante l'arresto del server.\n\nControlla i log dell'applicazione per i dettagli.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agente modificato in: {name}", "agent.change_error_callback": "Modifica dell'agente non riuscita", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index d10a10512..a69b7e4a8 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -31,6 +31,7 @@ export const ko: I18nDictionary = { "cmd.description.mcps": "MCP 서버", "cmd.description.opencode_start": "OpenCode 서버 시작", "cmd.description.opencode_stop": "OpenCode 서버 중지", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "디렉터리 내용 보기", "cmd.description.help": "도움말", @@ -313,6 +314,10 @@ export const ko: I18nDictionary = { "opencode_stop.success": "✅ OpenCode 서버가 성공적으로 중지되었습니다", "opencode_stop.error": "🔴 서버 중지 중 오류가 발생했습니다.\n\n자세한 내용은 애플리케이션 로그를 확인해 주세요.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ 에이전트가 변경되었습니다: {name}", "agent.change_error_callback": "에이전트 변경 실패", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 6c8e0fcce..396b603ce 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -24,6 +24,7 @@ export const pt: I18nDictionary = { "cmd.description.mcps": "MCP servers", "cmd.description.opencode_start": "Iniciar servidor OpenCode", "cmd.description.opencode_stop": "Parar servidor OpenCode", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "Listar conteúdo do diretório", "cmd.description.help": "Ajuda", @@ -320,6 +321,10 @@ export const pt: I18nDictionary = { "opencode_stop.success": "✅ OpenCode Server encerrado com sucesso", "opencode_stop.error": "🔴 Ocorreu um erro ao encerrar o servidor.\n\nVerifique os logs do aplicativo para mais detalhes.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agente alterado para: {name}", "agent.change_error_callback": "Não foi possível alterar o agente", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 11b06a833..be194ab4e 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -24,6 +24,7 @@ export const ru: I18nDictionary = { "cmd.description.mcps": "MCP серверы", "cmd.description.opencode_start": "Запустить OpenCode сервер", "cmd.description.opencode_stop": "Остановить OpenCode сервер", + "cmd.description.reload": "Перезагрузить конфигурацию OpenCode", "cmd.description.ls": "Список содержимого каталога", "cmd.description.help": "Справка", @@ -308,6 +309,10 @@ export const ru: I18nDictionary = { "opencode_stop.success": "✅ OpenCode Server успешно остановлен", "opencode_stop.error": "🔴 Произошла ошибка при остановке сервера.\n\nПроверьте логи приложения для подробностей.", + "reload.reloading": "🔄 Перезагружаю конфигурацию OpenCode...", + "reload.success": "✅ Конфигурация OpenCode перезагружена", + "reload.failed": "🔴 Не удалось перезагрузить конфигурацию OpenCode", + "reload.failed_with_error": "🔴 Не удалось перезагрузить конфигурацию OpenCode\n\nОшибка: {error}", "agent.changed_message": "✅ Агент изменен на: {name}", "agent.change_error_callback": "Ошибка при смене агента", diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index d09901d82..d18b06dff 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -24,6 +24,7 @@ export const tr: I18nDictionary = { "cmd.description.mcps": "MCP sunucuları", "cmd.description.opencode_start": "OpenCode sunucusunu başlat", "cmd.description.opencode_stop": "OpenCode sunucusunu durdur", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "Dizin içeriğini listele", "cmd.description.help": "Yardım", @@ -304,6 +305,10 @@ export const tr: I18nDictionary = { "opencode_stop.success": "✅ OpenCode Sunucusu başarıyla durduruldu", "opencode_stop.error": "🔴 Sunucu durdurulurken bir hata oluştu.\n\nAyrıntılar için uygulama günlüklerini kontrol edin.", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Ajan değiştirildi: {name}", "agent.change_error_callback": "Ajan değiştirilemedi", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index b93c0f00c..703e3ed4e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -24,6 +24,7 @@ export const zh: I18nDictionary = { "cmd.description.mcps": "MCP servers", "cmd.description.opencode_start": "启动 OpenCode 服务器", "cmd.description.opencode_stop": "停止 OpenCode 服务器", + "cmd.description.reload": "Reload OpenCode configuration", "cmd.description.ls": "列出目录内容", "cmd.description.help": "帮助", @@ -275,6 +276,10 @@ export const zh: I18nDictionary = { "opencode_stop.still_running": "停止请求后服务器仍在响应。", "opencode_stop.success": "✅ OpenCode 服务器已成功停止", "opencode_stop.error": "🔴 停止服务器时发生错误。\n\n请查看应用日志了解详情。", + "reload.reloading": "🔄 Reloading OpenCode configuration...", + "reload.success": "✅ OpenCode configuration reloaded", + "reload.failed": "🔴 Failed to reload OpenCode configuration", + "reload.failed_with_error": "🔴 Failed to reload OpenCode configuration\n\nError: {error}", "agent.changed_message": "✅ Agent 已切换为:{name}", "agent.change_error_callback": "切换 Agent 失败", diff --git a/src/opencode/ready-refresh.ts b/src/opencode/ready-refresh.ts index a94e75d94..232315c9c 100644 --- a/src/opencode/ready-refresh.ts +++ b/src/opencode/ready-refresh.ts @@ -147,6 +147,15 @@ async function refreshModelCatalogAfterReady(reason: string): Promise { }); } +/** + * A config reload registers providers again the way a server start does: the same warm-up + * window and catalog wait, without the session cache warm-up a reload does not affect. + */ +export async function refreshModelCatalogAfterConfigReload(): Promise { + startModelCatalogWarmup(); + await refreshModelCatalogAfterReady("config_reload"); +} + export async function isOpencodeServerHealthy(): Promise { return (await checkOpencodeHealth()).healthy; } diff --git a/src/opencode/v2/client.ts b/src/opencode/v2/client.ts index 5d48072f5..156e72e03 100644 --- a/src/opencode/v2/client.ts +++ b/src/opencode/v2/client.ts @@ -55,6 +55,13 @@ export interface V2ClientExtension { cancel: (params: { sessionID: string; inboxID: string }) => Promise>; }; }; + location: { + /** + * Rebuilds every loaded location from a fresh config; pending permissions and forms are + * cancelled, running sessions continue. Resolves once the rebuilds settle. + */ + reload: () => Promise>; + }; } interface CommandParams { @@ -369,6 +376,13 @@ export function createV2OpencodeClient(options: V2ClientOptions): OpencodeClient project: { list: () => run(async () => (await client.project.list()).map(toV1Project)), }, + location: { + reload: () => + run(async () => { + await client.location.reload(); + return true as const; + }), + }, experimental: { session: { list: (params: { roots?: boolean; limit?: number }) => diff --git a/src/opencode/v2/events.ts b/src/opencode/v2/events.ts index a4c72536e..8e4a3b6a9 100644 --- a/src/opencode/v2/events.ts +++ b/src/opencode/v2/events.ts @@ -17,6 +17,11 @@ export interface V1GlobalEvent { type ToolInput = Record; +/** A V1 idle, plus the mark of an execution that was interrupted rather than finished. */ +type IdleEventProperties = Extract["properties"] & { + interrupted?: true; +}; + interface AssistantMessageState { sessionID: string; created: number; @@ -35,6 +40,12 @@ interface ToolCallState { start: number; } +/** A tool V2 reported done at launch while the operation it started keeps running. */ +interface BackgroundCallState extends ToolCallState { + callID: string; + output: string; +} + interface SessionState { directory?: string; projectID?: string; @@ -56,6 +67,8 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) const sessions = new Map(); const messages = new Map(); const tools = new Map(); + // Keyed by what announces the real end: `shell:` or `session:`. + const backgroundCalls = new Map(); const inbox = new Map(); const rememberDirectory = (sessionID: string, directory: string | undefined) => { @@ -139,8 +152,11 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) properties: { sessionID: part.sessionID, part, time }, }); - const toolPart = (callID: string, state: ToolState): Part | null => { - const call = tools.get(callID); + const toolPart = ( + callID: string, + state: ToolState, + call: ToolCallState | undefined = tools.get(callID), + ): Part | null => { if (!call) { return null; } @@ -169,14 +185,57 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) return call; }; - const idleEvents = (sessionID: string, id: string): Event[] => [ - { - id: `${id}:status`, - type: "session.status", - properties: { sessionID, status: { type: "idle" } }, - }, - { id: `${id}:idle`, type: "session.idle", properties: { sessionID } }, - ]; + const idleEvents = (sessionID: string, id: string, interrupted = false): Event[] => { + // V1 has no way to say a turn was stopped: the idle of an interrupted execution carries it. + const idleProperties: IdleEventProperties = interrupted + ? { sessionID, interrupted: true } + : { sessionID }; + return [ + { + id: `${id}:status`, + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }, + { id: `${id}:idle`, type: "session.idle", properties: idleProperties }, + ]; + }; + + const backgroundKey = (metadata: Record): string | null => { + if (typeof metadata.shellID === "string") { + return `shell:${metadata.shellID}`; + } + if (typeof metadata.sessionID === "string") { + return `session:${metadata.sessionID}`; + } + return null; + }; + + /** The completion V2 never sends for a background call, built when its operation ends. */ + const endBackgroundCall = ( + key: string, + created: number, + end: { error: string } | { metadata: Record }, + ): Event[] => { + const call = backgroundCalls.get(key); + if (!call) { + return []; + } + backgroundCalls.delete(key); + const time = { start: call.start, end: created }; + const state: ToolState = + "error" in end + ? { status: "error", input: call.input, error: end.error, metadata: call.metadata, time } + : { + status: "completed", + input: call.input, + output: call.output, + title: "", + metadata: { ...call.metadata, ...end.metadata }, + time, + }; + const part = toolPart(call.callID, state, call); + return part ? [partUpdated(part, created)] : []; + }; const translatePayload = (event: OpenCodeEvent): Event[] => { const created = @@ -254,9 +313,17 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) }, ]; + // A background subagent ends with its child session; its parent's task call ends there too. case "session.execution.succeeded": case "session.execution.interrupted": - return idleEvents(event.data.sessionID, event.id); + return [ + ...idleEvents( + event.data.sessionID, + event.id, + event.type === "session.execution.interrupted", + ), + ...endBackgroundCall(`session:${event.data.sessionID}`, created, { metadata: {} }), + ]; case "session.execution.failed": return [ @@ -269,8 +336,19 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) }, }, ...idleEvents(event.data.sessionID, event.id), + ...endBackgroundCall(`session:${event.data.sessionID}`, created, { + error: event.data.error.message, + }), ]; + case "shell.exited": + return endBackgroundCall(`shell:${event.data.id}`, created, { + metadata: { + status: event.data.status, + ...(event.data.exit !== undefined ? { exit: event.data.exit } : {}), + }, + }); + case "session.retry.scheduled": return [ { @@ -488,15 +566,38 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) case "session.tool.success": { const data = event.data; const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); - const part = toolPart(data.id, { - status: "completed", - input: call.input, - output: toolContentText(data.content), - title: "", - metadata: { ...call.metadata, ...(data.metadata ?? {}) }, - time: { start: call.start, end: created }, - }); + const metadata: Record = { ...call.metadata, ...(data.metadata ?? {}) }; + const output = toolContentText(data.content); tools.delete(data.id); + + // V2 reports a background command or subagent done as soon as it is launched; it + // stays a running tool here until the operation itself ends. + const key = metadata.status === "running" ? backgroundKey(metadata) : null; + if (key) { + if (typeof metadata.sessionID === "string" && metadata.sessionId === undefined) { + metadata.sessionId = metadata.sessionID; + } + backgroundCalls.set(key, { ...call, metadata, callID: data.id, output }); + const part = toolPart( + data.id, + { status: "running", input: call.input, metadata, time: { start: call.start } }, + call, + ); + return part ? [partUpdated(part, created)] : []; + } + + const part = toolPart( + data.id, + { + status: "completed", + input: call.input, + output, + title: "", + metadata, + time: { start: call.start, end: created }, + }, + call, + ); return part ? [partUpdated(part, created)] : []; } diff --git a/src/opencode/v2/mappers.ts b/src/opencode/v2/mappers.ts index 228d91cbf..9af5fe9b4 100644 --- a/src/opencode/v2/mappers.ts +++ b/src/opencode/v2/mappers.ts @@ -463,7 +463,16 @@ function fieldQuestionText(field: FormField, form: FormInfo): string { return parts.join("\n\n") || field.key; } -function toQuestionInfo(field: FormField, form: FormInfo): QuestionInfo { +/** + * A question built from a form field: each choice also carries the value the server expects, + * and `custom` is stated the way the server enforces it — only an explicit `true` lets a + * non-choice answer through. + */ +type FormQuestionInfo = QuestionInfo & { + options: Array; +}; + +function toQuestionInfo(field: FormField, form: FormInfo): FormQuestionInfo { const question = fieldQuestionText(field, form); const header = fieldHeader(field, form); if (field.type === "multiselect") { @@ -473,8 +482,10 @@ function toQuestionInfo(field: FormField, form: FormInfo): QuestionInfo { options: field.options.map((option) => ({ label: option.label, description: option.description ?? "", + value: option.value, })), multiple: true, + custom: field.custom === true, }; } if (field.type === "string" && field.options && field.options.length > 0) { @@ -484,7 +495,9 @@ function toQuestionInfo(field: FormField, form: FormInfo): QuestionInfo { options: field.options.map((option) => ({ label: option.label, description: option.description ?? "", + value: option.value, })), + custom: field.custom === true, }; } if (field.type === "boolean") { @@ -492,9 +505,10 @@ function toQuestionInfo(field: FormField, form: FormInfo): QuestionInfo { question, header, options: [ - { label: "true", description: "" }, - { label: "false", description: "" }, + { label: "true", description: "", value: "true" }, + { label: "false", description: "", value: "false" }, ], + custom: false, }; } return { question, header, options: [] }; @@ -514,9 +528,12 @@ export function toV1Question(form: FormInfo): QuestionRequest { function optionValue( options: ReadonlyArray<{ value: string; label: string }> | undefined, - label: string, + item: string, ): string { - return options?.find((option) => option.label === label)?.value ?? label; + if (options?.some((option) => option.value === item)) { + return item; + } + return options?.find((option) => option.label === item)?.value ?? item; } function toFieldAnswer( @@ -526,7 +543,7 @@ function toFieldAnswer( const first = answer[0]; switch (field.type) { case "multiselect": - return answer.map((label) => optionValue(field.options, label)); + return answer.map((item) => optionValue(field.options, item)); case "string": return first === undefined ? undefined : optionValue(field.options, first); case "boolean": diff --git a/tests/app/formatters/session-title-formatter.test.ts b/tests/app/formatters/session-title-formatter.test.ts new file mode 100644 index 000000000..2d8631c01 --- /dev/null +++ b/tests/app/formatters/session-title-formatter.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { formatSessionTitle } from "../../../src/app/formatters/session-title-formatter.js"; +import { resetRuntimeLocale, setRuntimeLocale } from "../../../src/i18n/index.js"; + +describe("app/formatters/session-title-formatter", () => { + afterEach(() => { + resetRuntimeLocale(); + }); + + it("shows a session OpenCode has not named yet under the dashboard's name", () => { + setRuntimeLocale("en"); + expect(formatSessionTitle("")).toBe("new session"); + + setRuntimeLocale("ru"); + expect(formatSessionTitle("")).toBe("новая сессия"); + }); + + it("keeps a title OpenCode set as it is", () => { + setRuntimeLocale("en"); + expect(formatSessionTitle("Greeting message")).toBe("Greeting message"); + expect(formatSessionTitle("New session - 2026-09-27T18:58:25.597Z")).toBe( + "New session - 2026-09-27T18:58:25.597Z", + ); + expect(formatSessionTitle(" ")).toBe(" "); + }); +}); diff --git a/tests/app/formatters/tool-message-batcher.test.ts b/tests/app/formatters/tool-message-batcher.test.ts index d4706f49e..c92e24597 100644 --- a/tests/app/formatters/tool-message-batcher.test.ts +++ b/tests/app/formatters/tool-message-batcher.test.ts @@ -135,4 +135,42 @@ describe("summary/tool-message-batcher", () => { expect(sendOrder).toEqual(["text:first", "file:edit_d.ts.txt", "text:second"]); }); }); + + it("waits for the gate a text message was queued with", async () => { + const sendText = vi.fn().mockResolvedValue(undefined); + const gate = createDeferred(); + const takeTextGate = vi + .fn() + .mockReturnValueOnce(() => gate.promise) + .mockReturnValue(undefined); + const batcher = new ToolMessageBatcher({ + sendText, + sendFile: vi.fn().mockResolvedValue(undefined), + takeTextGate, + }); + + batcher.sendTextNow("s1", "gated", "test"); + await new Promise((resolve) => setImmediate(resolve)); + expect(sendText).not.toHaveBeenCalled(); + + gate.resolve(); + await batcher.flushSession("s1", "test"); + expect(sendText).toHaveBeenCalledWith("s1", "gated"); + }); + + it("never gates a file", async () => { + const sendFile = vi.fn().mockResolvedValue(undefined); + const takeTextGate = vi.fn(() => () => new Promise(() => {})); + const batcher = new ToolMessageBatcher({ + sendText: vi.fn().mockResolvedValue(undefined), + sendFile, + takeTextGate, + }); + + batcher.enqueueFile("s1", createFileData("file.ts")); + await batcher.flushSession("s1", "test"); + + expect(sendFile).toHaveBeenCalledTimes(1); + expect(takeTextGate).not.toHaveBeenCalled(); + }); }); diff --git a/tests/app/managers/question-manager.test.ts b/tests/app/managers/question-manager.test.ts index 99e7d465f..0798c265a 100644 --- a/tests/app/managers/question-manager.test.ts +++ b/tests/app/managers/question-manager.test.ts @@ -137,6 +137,58 @@ describe("questionManager", () => { ]); }); + it("sends choice values while the summary keeps the display lines", () => { + const singleWithValues: Question = { + question: "Allow web search?", + header: "search", + custom: false, + options: [ + { label: "Allow search via Exa", description: "", value: "allow" }, + { label: "Disable web search", description: "", value: "disable" }, + ], + }; + const multipleWithValues: Question = { + question: "Pick colours", + header: "colours", + multiple: true, + custom: true, + options: [ + { label: "Red", description: "The colour red", value: "r" }, + { label: "Green", description: "Line one\nline two", value: "g" }, + ], + }; + questionManager.startQuestions([singleWithValues, multipleWithValues], "req-values"); + + questionManager.selectOption(0, 1); + questionManager.selectOption(1, 1); + questionManager.selectOption(1, 0); + questionManager.setCustomAnswer(1, "Blue"); + + expect(questionManager.getReplyItems(0)).toEqual(["disable"]); + expect(questionManager.getReplyItems(1)).toEqual(["g", "r", "Blue"]); + expect(questionManager.getAllAnswers()).toEqual([ + { question: "Allow web search?", answer: "* Disable web search: " }, + { + question: "Pick colours", + answer: "* Green: Line one\nline two\n* Red: The colour red\nBlue", + }, + ]); + }); + + it("sends choices without a value as today's display lines", () => { + questionManager.startQuestions([SINGLE_QUESTION, MULTIPLE_QUESTION, SINGLE_QUESTION], "req-v1"); + + questionManager.selectOption(0, 0); + questionManager.selectOption(1, 2); + questionManager.selectOption(1, 0); + questionManager.setCustomAnswer(1, "Line one\nline two"); + questionManager.setCustomAnswer(2, "First line\nSecond line"); + + for (const index of [0, 1, 2]) { + expect(questionManager.getReplyItems(index)).toEqual(questionManager.getAnswerItems(index)); + } + }); + it("does not toggle a custom answer that was never entered", () => { questionManager.startQuestions([MULTIPLE_QUESTION], "req-3e"); diff --git a/tests/app/managers/summary-aggregation-manager.test.ts b/tests/app/managers/summary-aggregation-manager.test.ts index 9244438de..419b938f2 100644 --- a/tests/app/managers/summary-aggregation-manager.test.ts +++ b/tests/app/managers/summary-aggregation-manager.test.ts @@ -538,6 +538,118 @@ describe("summary/aggregator", () => { expect(card.currentToolStartedAt).toBeUndefined(); }); + function launchInBackground(): void { + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "root-task", + sessionID: "root-session", + messageID: "root-message", + type: "tool", + callID: "call-task", + tool: "task", + state: { + status: "running", + input: { description: "task description", background: true }, + metadata: { sessionId: "child-session-1" }, + time: { start: Date.now() }, + }, + }, + }, + } as unknown as Event); + } + + function emitIdle(sessionID: string): void { + summaryAggregator.processEvent({ + type: "session.idle", + properties: { sessionID }, + } as unknown as Event); + } + + it("keeps a background subagent's card after its parent's turn ends", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + startSubagent(); + launchInBackground(); + emitIdle("root-session"); + + emitChildTool("call-1"); + expect(onSubagent.mock.lastCall?.[1][0]).toMatchObject({ + background: true, + status: "running", + currentToolCallId: "call-1", + }); + + emitIdle("child-session-1"); + expect(onSubagent.mock.lastCall?.[1][0]).toMatchObject({ + status: "completed", + finishedAt: expect.any(Number), + }); + }); + + it("keeps a background card through an update of its session in a later run", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + startSubagent(); + launchInBackground(); + emitIdle("root-session"); + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "user-message-next", + sessionID: "root-session", + role: "user", + time: { created: Date.now() + 1000 }, + }, + }, + } as unknown as Event); + summaryAggregator.processEvent({ + type: "session.updated", + properties: { + info: { + id: "child-session-1", + parentID: "root-session", + title: "task description (@explore subagent)", + slug: "child", + directory: "D:/repo", + projectID: "p1", + version: "1", + time: { created: 1, updated: Date.now() }, + }, + }, + } as unknown as Event); + + emitChildTool("call-2"); + expect(onSubagent.mock.lastCall?.[1][0].currentToolCallId).toBe("call-2"); + }); + + it("still retires a foreground subagent's card when the parent's turn ends", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + startSubagent(); + emitIdle("root-session"); + const callsAfterIdle = onSubagent.mock.calls.length; + + emitChildTool("call-1"); + expect(onSubagent.mock.calls).toHaveLength(callsAfterIdle); + }); + + it("stops following a background subagent once it is retired", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + startSubagent(); + launchInBackground(); + emitIdle("root-session"); + summaryAggregator.retireBackgroundSubagents(); + const callsAfterStop = onSubagent.mock.calls.length; + + emitChildTool("call-1"); + emitIdle("child-session-1"); + expect(onSubagent.mock.calls).toHaveLength(callsAfterStop); + }); + it("re-emits and restarts timing for an identical tool with a new call id", async () => { const onSubagent = vi.fn(); summaryAggregator.setOnSubagent(onSubagent); @@ -1786,7 +1898,88 @@ describe("summary/aggregator", () => { await new Promise((resolve) => setImmediate(resolve)); - expect(onSessionIdle).toHaveBeenCalledWith("session-1"); + expect(onSessionIdle).toHaveBeenCalledWith("session-1", { interrupted: false }); + }); + + it("passes the interrupted mark of an idle to the callback", async () => { + const onSessionIdle = vi.fn(); + summaryAggregator.setOnSessionIdle(onSessionIdle); + summaryAggregator.setSession("session-1"); + + summaryAggregator.processEvent({ + type: "session.idle", + properties: { sessionID: "session-1", interrupted: true }, + } as unknown as Event); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(onSessionIdle).toHaveBeenCalledWith("session-1", { interrupted: true }); + }); + + describe("live turn start", () => { + function emitStatus(sessionID: string, type: "busy" | "idle"): void { + summaryAggregator.processEvent({ + type: "session.status", + properties: { sessionID, status: { type } }, + } as unknown as Event); + } + + function emitIdle(sessionID: string): void { + summaryAggregator.processEvent({ + type: "session.idle", + properties: { sessionID }, + } as unknown as Event); + } + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the first busy of a turn until the session goes idle", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_000); + summaryAggregator.setSession("session-1"); + + emitStatus("session-1", "busy"); + vi.setSystemTime(5_000); + emitStatus("session-1", "busy"); + + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBe(1_000); + expect(summaryAggregator.getLiveTurnStartedAt("session-2")).toBeNull(); + + emitIdle("session-1"); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); + + emitStatus("session-1", "busy"); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBe(5_000); + }); + + it("forgets the turn on a session error, a reconnect and a session change", () => { + summaryAggregator.setSession("session-1"); + + emitStatus("session-1", "busy"); + summaryAggregator.processEvent({ + type: "session.error", + properties: { sessionID: "session-1", error: { message: "boom" } }, + } as unknown as Event); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); + + emitStatus("session-1", "busy"); + summaryAggregator.forgetLiveTurn(); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); + + emitStatus("session-1", "busy"); + summaryAggregator.setSession("session-2"); + expect(summaryAggregator.getLiveTurnStartedAt("session-2")).toBeNull(); + }); + + it("ignores a busy of another session", () => { + summaryAggregator.setSession("session-1"); + + emitStatus("session-2", "busy"); + + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); + }); }); it("passes assistant metadata to onComplete", () => { diff --git a/tests/app/services/config-reload-service.test.ts b/tests/app/services/config-reload-service.test.ts new file mode 100644 index 000000000..c58229f5f --- /dev/null +++ b/tests/app/services/config-reload-service.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + reload: vi.fn(), + refreshModelCatalogAfterConfigReload: vi.fn(), + getStoredModel: vi.fn(), +})); + +vi.mock("../../../src/opencode/client.js", () => ({ + opencodeV2Client: { location: { reload: mocked.reload } }, +})); + +vi.mock("../../../src/opencode/ready-refresh.js", () => ({ + refreshModelCatalogAfterConfigReload: mocked.refreshModelCatalogAfterConfigReload, +})); + +vi.mock("../../../src/app/services/model-selection-service.js", () => ({ + getStoredModel: mocked.getStoredModel, +})); + +import { reloadOpencodeConfig } from "../../../src/app/services/config-reload-service.js"; + +const STORED_MODEL = { providerID: "anthropic", modelID: "claude", variant: "default" }; +const FALLBACK_MODEL = { providerID: "test-provider", modelID: "test-model", variant: "default" }; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("app/services/config-reload-service", () => { + beforeEach(() => { + mocked.reload.mockResolvedValue({ data: true, error: undefined }); + mocked.refreshModelCatalogAfterConfigReload.mockResolvedValue(undefined); + mocked.getStoredModel.mockReturnValue(STORED_MODEL); + }); + + it("refreshes the model catalog after a successful reload", async () => { + const result = await reloadOpencodeConfig(); + + expect(result).toEqual({ kind: "success", modelChanged: false }); + expect(mocked.reload).toHaveBeenCalledOnce(); + expect(mocked.refreshModelCatalogAfterConfigReload).toHaveBeenCalledOnce(); + }); + + it("reports a model the catalog refresh replaced", async () => { + mocked.refreshModelCatalogAfterConfigReload.mockImplementation(async () => { + mocked.getStoredModel.mockReturnValue(FALLBACK_MODEL); + }); + + await expect(reloadOpencodeConfig()).resolves.toEqual({ kind: "success", modelChanged: true }); + }); + + it("still reports success when the catalog refresh fails", async () => { + mocked.refreshModelCatalogAfterConfigReload.mockRejectedValue(new Error("catalog down")); + + await expect(reloadOpencodeConfig()).resolves.toEqual({ kind: "success", modelChanged: false }); + }); + + it("returns the server's error text when the reload is rejected", async () => { + mocked.reload.mockResolvedValue({ + data: undefined, + error: new Error("Invalid config: unknown field"), + }); + + await expect(reloadOpencodeConfig()).resolves.toEqual({ + kind: "failed", + error: "Invalid config: unknown field", + }); + expect(mocked.refreshModelCatalogAfterConfigReload).not.toHaveBeenCalled(); + }); + + it("returns no error text when the error carries none", async () => { + mocked.reload.mockResolvedValue({ data: undefined, error: {} }); + + await expect(reloadOpencodeConfig()).resolves.toEqual({ kind: "failed", error: null }); + }); + + it("joins a reload that is already in flight", async () => { + const pending = deferred<{ data: true; error: undefined }>(); + mocked.reload.mockReturnValue(pending.promise); + + const first = reloadOpencodeConfig(); + const second = reloadOpencodeConfig(); + pending.resolve({ data: true, error: undefined }); + + await expect(first).resolves.toEqual({ kind: "success", modelChanged: false }); + await expect(second).resolves.toEqual({ kind: "success", modelChanged: false }); + expect(mocked.reload).toHaveBeenCalledOnce(); + + await reloadOpencodeConfig(); + expect(mocked.reload).toHaveBeenCalledTimes(2); + }); + + it("times out without abandoning the catalog refresh of a late success", async () => { + vi.useFakeTimers(); + const pending = deferred<{ data: true; error: undefined }>(); + mocked.reload.mockReturnValueOnce(pending.promise); + + const result = reloadOpencodeConfig(); + await vi.advanceTimersByTimeAsync(60_000); + + await expect(result).resolves.toEqual({ kind: "timeout" }); + + const retry = reloadOpencodeConfig(); + await expect(retry).resolves.toEqual({ kind: "success", modelChanged: false }); + expect(mocked.reload).toHaveBeenCalledTimes(2); + + pending.resolve({ data: true, error: undefined }); + await vi.advanceTimersByTimeAsync(0); + expect(mocked.refreshModelCatalogAfterConfigReload).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/app/services/model-capabilities-service.test.ts b/tests/app/services/model-capabilities-service.test.ts index 72fc52e2f..5c6bff25e 100644 --- a/tests/app/services/model-capabilities-service.test.ts +++ b/tests/app/services/model-capabilities-service.test.ts @@ -1,21 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Model } from "@opencode-ai/sdk/v2"; -const { providersMock, getMissingExpectedProvidersMock } = vi.hoisted(() => ({ +const { providersMock } = vi.hoisted(() => ({ providersMock: vi.fn(), - getMissingExpectedProvidersMock: vi.fn(), })); vi.mock("../../../src/app/services/model-selection-service.js", () => ({ - getMissingExpectedProviders: getMissingExpectedProvidersMock, -})); - -vi.mock("../../../src/opencode/client.js", () => ({ - opencodeClient: { - config: { - providers: providersMock, - }, - }, + readProvidersWhenListed: providersMock, })); vi.mock("../../../src/utils/logger.js", () => ({ @@ -64,8 +55,6 @@ describe("model/capabilities", () => { __resetModelCapabilitiesCacheForTests(); providersMock.mockReset(); providersMock.mockResolvedValue(createProvidersResponse({ openai: ["gpt-4o"] })); - getMissingExpectedProvidersMock.mockReset(); - getMissingExpectedProvidersMock.mockResolvedValue([]); }); it("returns and caches the capabilities of a listed model", async () => { @@ -75,11 +64,17 @@ describe("model/capabilities", () => { expect(providersMock).toHaveBeenCalledTimes(1); }); - it("remembers a model a non-empty list does not name as unsupported", async () => { + it("waits for the requested model's provider only", async () => { + await getModelCapabilities("openai", "gpt-4o"); + + expect(providersMock).toHaveBeenCalledWith("openai"); + }); + + it("does not remember a model the list does not name: the next file asks again", async () => { await expect(getModelCapabilities("openai", "retired")).resolves.toBeNull(); await expect(getModelCapabilities("openai", "retired")).resolves.toBeNull(); - expect(providersMock).toHaveBeenCalledTimes(1); + expect(providersMock).toHaveBeenCalledTimes(2); }); it("does not remember an empty providers list", async () => { @@ -105,26 +100,16 @@ describe("model/capabilities", () => { await expect(getModelCapabilities("openai", "gpt-4o")).resolves.toEqual(VISION_CAPABILITIES); }); - it("does not remember a provider missing from a list that lacks expected providers", async () => { - getMissingExpectedProvidersMock.mockResolvedValueOnce(["commandcode"]); - providersMock.mockResolvedValueOnce(createProvidersResponse({ openai: ["gpt-4o"] })); + it("does not remember a provider the list does not name, then reads it once listed", async () => { + await expect(getModelCapabilities("commandcode", "deepseek-v4")).resolves.toBeNull(); providersMock.mockResolvedValue( createProvidersResponse({ openai: ["gpt-4o"], commandcode: ["deepseek-v4"] }), ); - - await expect(getModelCapabilities("commandcode", "deepseek-v4")).resolves.toBeNull(); await expect(getModelCapabilities("commandcode", "deepseek-v4")).resolves.toEqual( VISION_CAPABILITIES, ); - expect(getMissingExpectedProvidersMock).toHaveBeenCalledWith(["openai"]); - }); - - it("remembers a missing provider when the list counts as complete", async () => { - await expect(getModelCapabilities("commandcode", "deepseek-v4")).resolves.toBeNull(); - await expect(getModelCapabilities("commandcode", "deepseek-v4")).resolves.toBeNull(); - - expect(providersMock).toHaveBeenCalledTimes(1); + expect(providersMock).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/app/services/model-context-limit-service.test.ts b/tests/app/services/model-context-limit-service.test.ts index 21eb686cd..52f3d5cf5 100644 --- a/tests/app/services/model-context-limit-service.test.ts +++ b/tests/app/services/model-context-limit-service.test.ts @@ -1,12 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { providersMock, getMissingExpectedProvidersMock } = vi.hoisted(() => ({ - providersMock: vi.fn(), - getMissingExpectedProvidersMock: vi.fn(), -})); +const { providersMock, getMissingExpectedProvidersMock, readProvidersWhenListedMock } = vi.hoisted( + () => ({ + providersMock: vi.fn(), + getMissingExpectedProvidersMock: vi.fn(), + readProvidersWhenListedMock: vi.fn(), + }), +); vi.mock("../../../src/app/services/model-selection-service.js", () => ({ getMissingExpectedProviders: getMissingExpectedProvidersMock, + readProvidersWhenListed: readProvidersWhenListedMock, })); vi.mock("../../../src/opencode/client.js", () => ({ @@ -30,6 +34,7 @@ import { __resetModelContextLimitCacheForTests, DEFAULT_CONTEXT_LIMIT, getModelContextLimit, + waitForModelContextLimit, } from "../../../src/app/services/model-context-limit-service.js"; function createProvidersResponse(limitsByModel: Record) { @@ -57,6 +62,10 @@ describe("app/services/model-context-limit-service", () => { providersMock.mockResolvedValue(createProvidersResponse({ "openai/gpt-4o": 128000 })); getMissingExpectedProvidersMock.mockReset(); getMissingExpectedProvidersMock.mockResolvedValue([]); + readProvidersWhenListedMock.mockReset(); + readProvidersWhenListedMock.mockResolvedValue( + createProvidersResponse({ "openai/gpt-4o": 128000, "commandcode/deepseek-v4": 64000 }), + ); }); afterEach(() => { @@ -119,4 +128,36 @@ describe("app/services/model-context-limit-service", () => { expect(providersMock).toHaveBeenCalledTimes(1); }); + + describe("waitForModelContextLimit", () => { + it("returns a known limit without reading the list", async () => { + await getModelContextLimit("openai", "gpt-4o"); + + await expect(waitForModelContextLimit("openai", "gpt-4o")).resolves.toBe(128000); + expect(readProvidersWhenListedMock).not.toHaveBeenCalled(); + }); + + it("waits for the model's provider, then keeps its limit for the plain read", async () => { + await expect(getModelContextLimit("commandcode", "deepseek-v4")).resolves.toBe( + DEFAULT_CONTEXT_LIMIT, + ); + + await expect(waitForModelContextLimit("commandcode", "deepseek-v4")).resolves.toBe(64000); + expect(readProvidersWhenListedMock).toHaveBeenCalledWith("commandcode"); + await expect(getModelContextLimit("commandcode", "deepseek-v4")).resolves.toBe(64000); + }); + + it("returns nothing when the model is still not listed after the wait", async () => { + await expect(waitForModelContextLimit("anthropic", "claude")).resolves.toBeNull(); + }); + + it("returns nothing when the list cannot be read", async () => { + readProvidersWhenListedMock.mockResolvedValueOnce({ + data: null, + error: new TypeError("fetch failed"), + }); + + await expect(waitForModelContextLimit("commandcode", "deepseek-v4")).resolves.toBeNull(); + }); + }); }); diff --git a/tests/app/services/model-selection-service.test.ts b/tests/app/services/model-selection-service.test.ts index afa8bce9b..45cf112f0 100644 --- a/tests/app/services/model-selection-service.test.ts +++ b/tests/app/services/model-selection-service.test.ts @@ -90,6 +90,7 @@ import { getMissingExpectedProviders, getProviderModels, getProviders, + readProvidersWhenListed, reconcileStoredModelSelection, searchModels, startModelCatalogWarmup, @@ -119,6 +120,8 @@ describe("app/services/model-selection-service", () => { vi.useRealTimers(); resetCurrentModelState(); __resetModelCatalogCacheForTests(); + // Menu reads wait for the providers named in model.json; never read the real one. + process.env.XDG_STATE_HOME = path.join(os.tmpdir(), "opencode-model-test-no-state"); loggerInfoMock.mockReset(); loggerWarnMock.mockReset(); @@ -613,24 +616,20 @@ describe("app/services/model-selection-service", () => { expect(providersMock).toHaveBeenCalledTimes(2); }); - it("hides a favorite until its provider is listed, then shows it", async () => { + it("does not cache a list lacking a favorite's provider, and the menu shows it once listed", async () => { await setupMockModelFile({ favorite: [{ providerID: "commandcode", modelID: "deepseek-v4" }], recent: [], }); startModelCatalogWarmup(); providersMock.mockResolvedValueOnce(createProvidersResponse(BUILT_IN_ONLY)); + await reconcileStoredModelSelection({ forceCatalogRefresh: true }); providersMock.mockResolvedValue(createProvidersResponse(WITH_PLUGIN_PROVIDER)); - const first = await getModelSelectionLists(); - const second = await getModelSelectionLists(); + const lists = await getModelSelectionLists(); await getModelSelectionLists(); - expect(first.favorites).not.toContainEqual({ - providerID: "commandcode", - modelID: "deepseek-v4", - }); - expect(second.favorites).toContainEqual({ + expect(lists.favorites).toContainEqual({ providerID: "commandcode", modelID: "deepseek-v4", }); @@ -660,11 +659,206 @@ describe("app/services/model-selection-service", () => { }); }); + describe("waiting for providers after a location wakes", () => { + const BUILT_IN_ONLY = { + opencode: ["big-pickle"], + openai: ["gpt-4o"], + }; + const WITH_PLUGIN_PROVIDER = { + ...BUILT_IN_ONLY, + commandcode: ["deepseek-v4"], + }; + + function listedProviderIds(response: Awaited>) { + return (response.data?.providers ?? []).map((provider) => provider.id); + } + + // Waiting for the expected providers first reads model.json, which is real file IO. + async function untilProviderReads(count: number): Promise { + await vi.waitFor(() => expect(providersMock.mock.calls.length).toBeGreaterThanOrEqual(count)); + } + + beforeEach(() => { + vi.useFakeTimers(); + setCurrentModelState({ providerID: "commandcode", modelID: "deepseek-v4" }); + }); + + it("returns at once when every awaited provider is listed", async () => { + providersMock.mockResolvedValue(createProvidersResponse(WITH_PLUGIN_PROVIDER)); + + await readProvidersWhenListed(); + + expect(providersMock).toHaveBeenCalledTimes(1); + }); + + it("reads again until the missing provider is listed", async () => { + providersMock.mockResolvedValueOnce(createProvidersResponse(BUILT_IN_ONLY)); + providersMock.mockResolvedValue(createProvidersResponse(WITH_PLUGIN_PROVIDER)); + + const read = readProvidersWhenListed("commandcode"); + await vi.advanceTimersByTimeAsync(500); + + expect(listedProviderIds(await read)).toContain("commandcode"); + expect(providersMock).toHaveBeenCalledTimes(2); + }); + + it("waits past a list with no models", async () => { + providersMock.mockResolvedValueOnce(createProvidersResponse({})); + providersMock.mockResolvedValueOnce(createProvidersResponse({ commandcode: [] })); + providersMock.mockResolvedValue(createProvidersResponse(WITH_PLUGIN_PROVIDER)); + + const read = readProvidersWhenListed("commandcode"); + await vi.advanceTimersByTimeAsync(1000); + + expect(listedProviderIds(await read)).toContain("commandcode"); + expect(providersMock).toHaveBeenCalledTimes(3); + }); + + it("waits for one provider without waiting for the favorites' providers", async () => { + setCurrentModelState({ providerID: "openai", modelID: "gpt-4o" }); + await setupMockModelFile({ + favorite: [{ providerID: "commandcode", modelID: "deepseek-v4" }], + recent: [], + }); + providersMock.mockResolvedValue(createProvidersResponse(BUILT_IN_ONLY)); + + await readProvidersWhenListed("openai"); + expect(providersMock).toHaveBeenCalledTimes(1); + + const fullRead = readProvidersWhenListed(); + await untilProviderReads(2); + await vi.advanceTimersByTimeAsync(500); + expect(providersMock).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(10_000); + await fullRead; + }); + + it("gives up after 10 s and does not wait for that provider again until it is listed", async () => { + providersMock.mockResolvedValue(createProvidersResponse(BUILT_IN_ONLY)); + + const read = readProvidersWhenListed(); + await untilProviderReads(1); + await vi.advanceTimersByTimeAsync(10_000); + expect(listedProviderIds(await read)).not.toContain("commandcode"); + const callsAfterWait = providersMock.mock.calls.length; + + await readProvidersWhenListed(); + expect(providersMock).toHaveBeenCalledTimes(callsAfterWait + 1); + + providersMock.mockResolvedValueOnce(createProvidersResponse(WITH_PLUGIN_PROVIDER)); + await readProvidersWhenListed(); + const waitingAgain = readProvidersWhenListed(); + await untilProviderReads(callsAfterWait + 3); + await vi.advanceTimersByTimeAsync(500); + expect(providersMock).toHaveBeenCalledTimes(callsAfterWait + 4); + await vi.advanceTimersByTimeAsync(10_000); + await waitingAgain; + }); + + it("keeps a provider given up on while lists name it without models", async () => { + setCurrentModelState({ providerID: "openai", modelID: "gpt-4o" }); + await setupMockModelFile({ + favorite: [{ providerID: "commandcode", modelID: "deepseek-v4" }], + recent: [], + }); + providersMock.mockResolvedValue(createProvidersResponse(BUILT_IN_ONLY)); + const read = readProvidersWhenListed(); + await untilProviderReads(1); + await vi.advanceTimersByTimeAsync(10_000); + await read; + + providersMock.mockResolvedValue( + createProvidersResponse({ ...BUILT_IN_ONLY, commandcode: [] }), + ); + await reconcileStoredModelSelection({ forceCatalogRefresh: true }); + const callsBefore = providersMock.mock.calls.length; + await readProvidersWhenListed(); + + expect(providersMock).toHaveBeenCalledTimes(callsBefore + 1); + }); + + it("waits again for a provider given up on once the server starts again", async () => { + providersMock.mockResolvedValue(createProvidersResponse(BUILT_IN_ONLY)); + const read = readProvidersWhenListed(); + await untilProviderReads(1); + await vi.advanceTimersByTimeAsync(10_000); + await read; + const callsAfterWait = providersMock.mock.calls.length; + + startModelCatalogWarmup(); + const waitingAgain = readProvidersWhenListed(); + await untilProviderReads(callsAfterWait + 1); + await vi.advanceTimersByTimeAsync(500); + + expect(providersMock).toHaveBeenCalledTimes(callsAfterWait + 2); + await vi.advanceTimersByTimeAsync(10_000); + await waitingAgain; + }); + + it("returns a failed read at once", async () => { + providersMock.mockResolvedValue({ data: null, error: new TypeError("fetch failed") }); + + const response = await readProvidersWhenListed(); + + expect(response.error).toBeInstanceOf(TypeError); + expect(providersMock).toHaveBeenCalledTimes(1); + }); + + it("shares one wait between concurrent callers", async () => { + providersMock.mockResolvedValueOnce(createProvidersResponse(BUILT_IN_ONLY)); + providersMock.mockResolvedValue(createProvidersResponse(WITH_PLUGIN_PROVIDER)); + + const first = readProvidersWhenListed(); + const second = readProvidersWhenListed(); + await untilProviderReads(1); + await vi.advanceTimersByTimeAsync(500); + + expect(await second).toBe(await first); + expect(providersMock).toHaveBeenCalledTimes(2); + }); + + it("makes the model menu wait, but not the stored-model check", async () => { + providersMock.mockResolvedValue(createProvidersResponse(BUILT_IN_ONLY)); + startModelCatalogWarmup(); + + await reconcileStoredModelSelection({ forceCatalogRefresh: true }); + expect(providersMock).toHaveBeenCalledTimes(1); + + providersMock.mockResolvedValue(createProvidersResponse(WITH_PLUGIN_PROVIDER)); + providersMock.mockResolvedValueOnce(createProvidersResponse(BUILT_IN_ONLY)); + const providers = getProviders(); + await untilProviderReads(2); + await vi.advanceTimersByTimeAsync(500); + + expect((await providers).map((provider) => provider.id)).toContain("commandcode"); + expect(providersMock).toHaveBeenCalledTimes(3); + }); + + it("does not hand a menu read the stored-model check's in-flight list", async () => { + let resolveCheckRead: (value: unknown) => void = () => {}; + providersMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveCheckRead = resolve)), + ); + providersMock.mockResolvedValue(createProvidersResponse(WITH_PLUGIN_PROVIDER)); + startModelCatalogWarmup(); + + const check = reconcileStoredModelSelection({ forceCatalogRefresh: true }); + const menu = getProviders(); + resolveCheckRead(createProvidersResponse(BUILT_IN_ONLY)); + await check; + + expect((await menu).map((provider) => provider.id)).toContain("commandcode"); + expect(providersMock).toHaveBeenCalledTimes(2); + }); + }); + describe("empty model catalog", () => { it("is not cached: the next read asks the server again", async () => { providersMock.mockResolvedValueOnce(createProvidersResponse({})); - await expect(getProviders()).resolves.toEqual([]); + await expect(reconcileStoredModelSelection()).resolves.toMatchObject({ + catalogAvailable: false, + }); const providers = await getProviders(); expect(providersMock).toHaveBeenCalledTimes(2); @@ -679,7 +873,9 @@ describe("app/services/model-selection-service", () => { it("treats providers without models as an empty catalog", async () => { providersMock.mockResolvedValueOnce(createProvidersResponse({ openai: [] })); - await expect(searchModels("gpt")).resolves.toEqual([]); + await expect(reconcileStoredModelSelection()).resolves.toMatchObject({ + catalogAvailable: false, + }); await expect(searchModels("gpt")).resolves.not.toHaveLength(0); expect(providersMock).toHaveBeenCalledTimes(2); }); @@ -690,11 +886,11 @@ describe("app/services/model-selection-service", () => { await reconcileStoredModelSelection({ forceCatalogRefresh: true }); - providersMock - .mockResolvedValueOnce(createProvidersResponse({})) - .mockResolvedValueOnce(createProvidersResponse({})); - await expect(getProviders()).resolves.toEqual([]); - await expect(getProviderModels("openai")).resolves.toEqual([]); + providersMock.mockResolvedValueOnce(createProvidersResponse({})); + await expect(reconcileStoredModelSelection()).resolves.toMatchObject({ + catalogAvailable: false, + }); + expect(providersMock).toHaveBeenCalledTimes(3); }); }); @@ -780,6 +976,7 @@ describe("app/services/model-selection-service", () => { it("uses the provider display name when available", async () => { __resetModelCatalogCacheForTests(); + setCurrentModelState({ providerID: "openai", modelID: "gpt-4o" }); providersMock.mockResolvedValueOnce({ data: { providers: [ diff --git a/tests/app/services/recent-sessions-service.test.ts b/tests/app/services/recent-sessions-service.test.ts index cf19c749e..4cf27f442 100644 --- a/tests/app/services/recent-sessions-service.test.ts +++ b/tests/app/services/recent-sessions-service.test.ts @@ -3,7 +3,7 @@ import { loadRecentSessions } from "../../../src/app/services/recent-sessions-se const mocked = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), status: vi.fn(), questions: vi.fn(), permissions: vi.fn(), - attached: null as { id: string; directory: string } | null, + attached: null as { id: string; directory: string } | null, warn: vi.fn(), })); vi.mock("../../../src/opencode/client.js", () => ({ opencodeClient: { experimental: { session: { list: mocked.list } }, @@ -11,6 +11,7 @@ vi.mock("../../../src/opencode/client.js", () => ({ opencodeClient: { question: { list: mocked.questions }, permission: { list: mocked.permissions }, } })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ getCurrentSession: () => mocked.attached })); +vi.mock("../../../src/utils/logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: mocked.warn, error: vi.fn() } })); const session = (id: string, directory: string, updated: number) => ({ id, directory, title: id, time: { created: updated, updated }, project: null, @@ -20,7 +21,7 @@ describe("cross-project recent session snapshot", () => { beforeEach(() => { mocked.attached = null; mocked.list.mockReset(); mocked.get.mockReset(); mocked.status.mockReset(); - mocked.questions.mockReset(); mocked.permissions.mockReset(); + mocked.questions.mockReset(); mocked.permissions.mockReset(); mocked.warn.mockReset(); mocked.questions.mockResolvedValue({ data: [], error: null }); mocked.permissions.mockResolvedValue({ data: [], error: null }); mocked.status.mockResolvedValue({ data: {}, error: null }); @@ -70,4 +71,57 @@ describe("cross-project recent session snapshot", () => { expect((await loadRecentSessions(10))[0]?.status).toBe("idle"); expect(await loadRecentSessions(10)).toEqual([]); }); + + it("keeps the list and falls back to run status when a folder's pending lookups fail", async () => { + const failure = { data: undefined, error: new Error("UnexpectedStatus: 500") }; + mocked.list.mockResolvedValue({ data: [session("gone", "/deleted", 3), session("q", "/live", 2), session("p", "/live", 1)], error: null }); + mocked.status.mockImplementation(async ({ directory }: { directory: string }) => ({ + data: directory === "/deleted" ? { gone: { type: "busy" } } : {}, error: null, + })); + mocked.questions.mockImplementation(async ({ directory }: { directory: string }) => + directory === "/deleted" ? failure : { data: [{ sessionID: "q" }], error: null }); + mocked.permissions.mockImplementation(async ({ directory }: { directory: string }) => + directory === "/deleted" ? failure : { data: [{ sessionID: "p" }], error: null }); + + const rows = await loadRecentSessions(3); + + expect(rows.map(({ session, status }) => [session.id, status])).toEqual([ + ["gone", "running"], ["q", "question"], ["p", "permission"], + ]); + expect(mocked.warn).toHaveBeenCalledTimes(2); + }); + + it("shows idle or a found pending request when a folder's run-status lookup fails", async () => { + mocked.list.mockResolvedValue({ data: [session("asked", "/repo", 2), session("quiet", "/repo", 1)], error: null }); + mocked.status.mockResolvedValue({ data: undefined, error: new Error("UnexpectedStatus: 500") }); + mocked.questions.mockResolvedValue({ data: [{ sessionID: "asked" }], error: null }); + + expect((await loadRecentSessions(10)).map((row) => row.status)).toEqual(["question", "idle"]); + }); + + it("shows idle for a folder whose every lookup fails", async () => { + const failure = { data: undefined, error: new Error("UnexpectedStatus: 500") }; + mocked.list.mockResolvedValue({ data: [session("gone", "/deleted", 1)], error: null }); + mocked.status.mockResolvedValue(failure); + mocked.questions.mockResolvedValue(failure); + mocked.permissions.mockResolvedValue(failure); + + expect(await loadRecentSessions(10)).toEqual([{ session: session("gone", "/deleted", 1), status: "idle" }]); + expect(mocked.warn).toHaveBeenCalledTimes(3); + }); + + it("keeps a found permission when only the question lookup fails", async () => { + mocked.list.mockResolvedValue({ data: [session("p", "/repo", 1)], error: null }); + mocked.questions.mockResolvedValue({ data: undefined, error: new Error("UnexpectedStatus: 500") }); + mocked.permissions.mockResolvedValue({ data: [{ sessionID: "p" }], error: null }); + + expect((await loadRecentSessions(10))[0]?.status).toBe("permission"); + }); + + it("fails when the session list itself cannot be loaded", async () => { + const error = new Error("UnexpectedStatus: 500"); + mocked.list.mockResolvedValue({ data: undefined, error }); + + await expect(loadRecentSessions(10)).rejects.toBe(error); + }); }); diff --git a/tests/app/services/session-service.test.ts b/tests/app/services/session-service.test.ts index 4a5781ab4..b06024598 100644 --- a/tests/app/services/session-service.test.ts +++ b/tests/app/services/session-service.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const settingsSession = vi.hoisted(() => ({ current: null as { id: string } | null })); +const sessionGet = vi.hoisted(() => vi.fn()); + +vi.mock("../../../src/opencode/client.js", () => ({ + opencodeClient: { session: { get: sessionGet } }, +})); vi.mock("../../../src/app/stores/settings-store.js", () => ({ getCurrentSession: vi.fn(() => settingsSession.current), @@ -14,7 +19,11 @@ vi.mock("../../../src/app/stores/settings-store.js", () => ({ import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; import { promptAttachment } from "../../../src/app/managers/prompt-attachment-manager.js"; -import { clearSession, setCurrentSession } from "../../../src/app/services/session-service.js"; +import { + clearSession, + fetchSessionTitle, + setCurrentSession, +} from "../../../src/app/services/session-service.js"; import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; const SESSION = { id: "session-1", title: "Session 1", directory: "D:\\Projects\\Repo" }; @@ -80,3 +89,46 @@ describe("app/services/session-service", () => { expect(promptAttachment.get()).toBeNull(); }); }); + +describe("app/services/session-service fetchSessionTitle", () => { + beforeEach(() => { + sessionGet.mockReset(); + }); + + it("returns the title OpenCode has for the session now", async () => { + sessionGet.mockResolvedValue({ + data: { id: "session-1", title: "Generated" }, + error: undefined, + }); + + await expect(fetchSessionTitle({ ...SESSION, title: "" })).resolves.toBe("Generated"); + expect(sessionGet).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: SESSION.directory, + }); + }); + + it("keeps an empty title OpenCode reports for an unnamed session", async () => { + sessionGet.mockResolvedValue({ data: { id: "session-1", title: "" }, error: undefined }); + + await expect(fetchSessionTitle(SESSION)).resolves.toBe(""); + }); + + it("falls back to the remembered title when OpenCode answers with an error", async () => { + sessionGet.mockResolvedValue({ data: undefined, error: new Error("not found") }); + + await expect(fetchSessionTitle(SESSION)).resolves.toBe("Session 1"); + }); + + it("falls back to the remembered title when OpenCode answers without a session", async () => { + sessionGet.mockResolvedValue({ data: undefined, error: undefined }); + + await expect(fetchSessionTitle(SESSION)).resolves.toBe("Session 1"); + }); + + it("falls back to the remembered title when the call throws", async () => { + sessionGet.mockRejectedValue(new TypeError("fetch failed")); + + await expect(fetchSessionTitle(SESSION)).resolves.toBe("Session 1"); + }); +}); diff --git a/tests/app/services/variant-selection-service.test.ts b/tests/app/services/variant-selection-service.test.ts index 2a2c33ce0..78b523f49 100644 --- a/tests/app/services/variant-selection-service.test.ts +++ b/tests/app/services/variant-selection-service.test.ts @@ -2,20 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocked = vi.hoisted(() => ({ getStoredModelMock: vi.fn(), + readProvidersWhenListedMock: 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, + readProvidersWhenListed: mocked.readProvidersWhenListedMock, })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ @@ -32,7 +28,50 @@ vi.mock("../../../src/utils/logger.js", () => ({ }, })); -import { setCurrentVariant } from "../../../src/app/services/variant-selection-service.js"; +import { + getAvailableVariants, + setCurrentVariant, +} from "../../../src/app/services/variant-selection-service.js"; + +describe("getAvailableVariants", () => { + beforeEach(() => { + mocked.readProvidersWhenListedMock.mockReset(); + }); + + it("reads the variants once the model's provider is listed", async () => { + mocked.readProvidersWhenListedMock.mockResolvedValue({ + data: { + providers: [ + { + id: "commandcode", + models: { "deepseek-v4": { variants: { high: {}, low: { disabled: true } } } }, + }, + ], + }, + error: null, + }); + + const variants = await getAvailableVariants("commandcode", "deepseek-v4"); + + expect(mocked.readProvidersWhenListedMock).toHaveBeenCalledWith("commandcode"); + expect(variants).toEqual([ + { id: "default" }, + { id: "high", disabled: undefined }, + { id: "low", disabled: true }, + ]); + }); + + it("offers only the default variant when the provider is still not listed", async () => { + mocked.readProvidersWhenListedMock.mockResolvedValue({ + data: { providers: [] }, + error: null, + }); + + await expect(getAvailableVariants("commandcode", "deepseek-v4")).resolves.toEqual([ + { id: "default" }, + ]); + }); +}); describe("setCurrentVariant", () => { beforeEach(() => { diff --git a/tests/bot/callbacks/context-control-callback-handler.test.ts b/tests/bot/callbacks/context-control-callback-handler.test.ts index 46146903b..43f84152a 100644 --- a/tests/bot/callbacks/context-control-callback-handler.test.ts +++ b/tests/bot/callbacks/context-control-callback-handler.test.ts @@ -3,8 +3,8 @@ import type { Context } from "grammy"; import { createTestAppContainer } from "../../helpers/app-container.js"; import { t } from "../../../src/i18n/index.js"; -const mocked = vi.hoisted(() => ({ session: vi.fn(), summarize: vi.fn(), model: vi.fn() })); -vi.mock("../../../src/app/services/session-service.js", () => ({ getCurrentSession: mocked.session })); +const mocked = vi.hoisted(() => ({ session: vi.fn(), title: vi.fn(), summarize: vi.fn(), model: vi.fn() })); +vi.mock("../../../src/app/services/session-service.js", () => ({ getCurrentSession: mocked.session, fetchSessionTitle: mocked.title })); vi.mock("../../../src/app/services/model-selection-service.js", () => ({ getStoredModel: mocked.model })); vi.mock("../../../src/opencode/client.js", () => ({ opencodeClient: { session: { summarize: mocked.summarize } } })); @@ -27,6 +27,7 @@ describe("context compaction flow", () => { beforeEach(() => { vi.clearAllMocks(); mocked.session.mockReturnValue({ id: "session", directory: "/project", title: "Task" }); + mocked.title.mockResolvedValue("Task"); mocked.model.mockReturnValue({ providerID: "provider", modelID: "model" }); mocked.summarize.mockResolvedValue({}); }); @@ -84,6 +85,18 @@ describe("context compaction flow", () => { expect(mocked.summarize).not.toHaveBeenCalled(); }); + it("names the session with the title OpenCode has for it, or as a new session while untitled", async () => { + for (const [fetched, shown] of [["Generated title", "Generated title"], ["", t("pinned.default_session_title")]]) { + mocked.title.mockResolvedValue(fetched); + const deps = createTestAppContainer(); + deps.interactionManager.start({ kind: "inline", expectedInput: "callback", metadata: { menuKind: "context", messageId: 7, stage: "details" } }); + const ctx = callback("compact:details"); + await handleCompactDetails(ctx, deps); + expect(ctx.editMessageText).toHaveBeenCalledWith(t("context.confirm_text", { title: shown }), expect.anything()); + } + expect(mocked.title).toHaveBeenCalledWith({ id: "session", directory: "/project", title: "Task" }); + }); + it("answers the callback before waiting for Telegram to edit the confirmation", async () => { const deps = createTestAppContainer(); deps.interactionManager.start({ kind: "inline", expectedInput: "callback", metadata: { menuKind: "context", messageId: 7, stage: "details" } }); diff --git a/tests/bot/callbacks/question-callback-handler.test.ts b/tests/bot/callbacks/question-callback-handler.test.ts index b949ae0dc..421ebd437 100644 --- a/tests/bot/callbacks/question-callback-handler.test.ts +++ b/tests/bot/callbacks/question-callback-handler.test.ts @@ -514,4 +514,61 @@ describe("bot question menu/callbacks", () => { expect.objectContaining({ answers: [["Only mine"]] }), ); }); + + it("offers the custom answer button only when the question accepts one", async () => { + const keyboardRows = async (question: Question): Promise => { + const api = createApi([900]); + container.questionManager.startQuestions([question], "req-custom-flag"); + await showCurrentQuestion(api, 123, createDeps()); + const options = vi.mocked(api.sendRichMessage).mock.calls[0]?.[2] as { + reply_markup: { inline_keyboard: Array> }; + }; + return options.reply_markup.inline_keyboard.map((row) => defined(row[0]).callback_data); + }; + + expect(await keyboardRows({ ...QUESTION_ONE, custom: false })).toEqual([ + "question:select:0:0", + "question:select:0:1", + "question:cancel:0", + ]); + expect(await keyboardRows({ ...MULTIPLE_QUESTION, custom: false })).toEqual([ + "question:select:0:0", + "question:select:0:1", + "question:submit:0", + "question:cancel:0", + ]); + expect(await keyboardRows(QUESTION_ONE)).toContain("question:custom:0"); + expect(await keyboardRows({ ...QUESTION_ONE, custom: true })).toContain("question:custom:0"); + expect( + await keyboardRows({ header: "Free", question: "Type it", options: [], custom: false }), + ).toEqual(["question:custom:0", "question:cancel:0"]); + }); + + it("sends the tapped choice's value and shows its label in the summary", async () => { + const api = createApi([910, 911]); + const searchQuestion: Question = { + header: "Web search", + question: "Allow OpenCode to search the web?", + custom: false, + options: [ + { label: "Allow search via Exa", description: "", value: "allow" }, + { label: "Disable web search", description: "", value: "disable" }, + ], + }; + + container.questionManager.startQuestions([searchQuestion, QUESTION_ONE], "req-values"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:select:0:0", 910, api); + await pressButton("question:select:1:1", 911, api); + + expect(mocked.questionReplyMock).toHaveBeenCalledWith({ + requestID: "req-values", + directory: "D:/repo", + answers: [["allow"], ["* No: decline"]], + }); + expect(api.sendMessage).toHaveBeenLastCalledWith( + 123, + expect.stringContaining(t("question.summary.answer", { answer: "* Allow search via Exa: " })), + ); + }); }); diff --git a/tests/bot/commands/abort.test.ts b/tests/bot/commands/abort.test.ts index 00059046a..6eba3d07b 100644 --- a/tests/bot/commands/abort.test.ts +++ b/tests/bot/commands/abort.test.ts @@ -286,6 +286,26 @@ describe("bot/commands/abort", () => { expect(shouldSuppressUserAbortSessionError("session-1", "Aborted")).toBe(false); }); + it("stops following background operations of the aborted session", async () => { + mocked.currentSession = { id: "session-1", title: "Session", directory: "D:/repo" }; + mocked.abortMock.mockResolvedValue({ data: true, error: null }); + mocked.statusMock.mockResolvedValue({ data: { "session-1": { type: "idle" } }, error: null }); + const stopBackgroundOperations = vi.fn(); + const ctx = { + chat: { id: 777 }, + reply: vi.fn().mockResolvedValue({ message_id: 88 }), + api: { editMessageText: vi.fn().mockResolvedValue(undefined) }, + } as unknown as Context; + + await abortCurrentOperation( + ctx as never, + { ...createDeps(), stopBackgroundOperations }, + { notifyUser: false }, + ); + + expect(stopBackgroundOperations).toHaveBeenCalledWith("abort_command", "session-1"); + }); + it("can abort silently without progress messages", async () => { activateInteractionState(); diff --git a/tests/bot/commands/commands.test.ts b/tests/bot/commands/commands.test.ts index 4ff129258..e589f27c5 100644 --- a/tests/bot/commands/commands.test.ts +++ b/tests/bot/commands/commands.test.ts @@ -351,6 +351,37 @@ describe("bot/commands/commands", () => { }); }); + it("names a session it had to create as a new session while OpenCode has not named it", async () => { + mocked.currentSession = null; + mocked.sessionCreateMock.mockResolvedValueOnce({ + data: { id: "session-2", title: "" }, + error: null, + }); + container.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); + await handleCommandsCallback(ctx, createDeps()); + + expect(mocked.setCurrentSessionMock).toHaveBeenCalledWith({ + id: "session-2", + title: "", + directory: "D:\\Projects\\Repo", + }); + expect(ctx.reply).toHaveBeenCalledWith( + t("bot.session_created", { title: t("pinned.default_session_title") }), + ); + }); + it("executes selected command with arguments from text message", async () => { container.interactionManager.start({ kind: "custom", diff --git a/tests/bot/commands/definitions.test.ts b/tests/bot/commands/definitions.test.ts new file mode 100644 index 000000000..e3765afc3 --- /dev/null +++ b/tests/bot/commands/definitions.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadDefinitions(serverVersion: "v1" | "v2") { + vi.stubEnv("OPENCODE_SERVER_VERSION", serverVersion); + vi.resetModules(); + return import("../../../src/bot/commands/definitions.js"); +} + +describe("bot/commands/definitions", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("lists /reload right after /opencode_stop on V2", async () => { + const { BOT_COMMANDS, BUILT_IN_COMMAND_NAMES, getLocalizedBotCommands } = + await loadDefinitions("v2"); + + const commands = BOT_COMMANDS.map(({ command }) => command); + expect(commands[commands.indexOf("opencode_stop") + 1]).toBe("reload"); + expect(getLocalizedBotCommands().map(({ command }) => command)).toEqual(commands); + expect(BUILT_IN_COMMAND_NAMES).toContain("reload"); + }); + + it("leaves /reload out everywhere on V1", async () => { + const { BOT_COMMANDS, BUILT_IN_COMMAND_NAMES, getLocalizedBotCommands } = + await loadDefinitions("v1"); + + expect(BOT_COMMANDS.map(({ command }) => command)).not.toContain("reload"); + expect(getLocalizedBotCommands().map(({ command }) => command)).not.toContain("reload"); + expect(BUILT_IN_COMMAND_NAMES).not.toContain("reload"); + }); +}); diff --git a/tests/bot/commands/detach.test.ts b/tests/bot/commands/detach.test.ts index f0ccfb9f5..cf610f85c 100644 --- a/tests/bot/commands/detach.test.ts +++ b/tests/bot/commands/detach.test.ts @@ -9,6 +9,7 @@ const mocked = vi.hoisted(() => ({ currentProject: { id: "project-1", worktree: "D:/repo" } as { id: string; worktree: string } | null, currentSession: null as { id: string; title: string; directory: string } | null, clearSessionMock: vi.fn(), + fetchSessionTitleMock: vi.fn(), detachAttachedSessionMock: vi.fn(), clearAllInteractionStateMock: vi.fn(), pinnedIsInitializedMock: vi.fn(() => true), @@ -30,6 +31,7 @@ vi.mock("../../../src/app/stores/settings-store.js", () => ({ vi.mock("../../../src/app/services/session-service.js", () => ({ getCurrentSession: vi.fn(() => mocked.currentSession), clearSession: mocked.clearSessionMock, + fetchSessionTitle: mocked.fetchSessionTitleMock, })); vi.mock("../../../src/app/services/attach-service.js", () => ({ @@ -77,6 +79,8 @@ describe("bot/commands/detach", () => { }; mocked.clearSessionMock.mockClear(); + mocked.fetchSessionTitleMock.mockReset(); + mocked.fetchSessionTitleMock.mockImplementation(async (session: { title: string }) => session.title); mocked.detachAttachedSessionMock.mockClear(); mocked.clearAllInteractionStateMock.mockClear(); mocked.pinnedIsInitializedMock.mockClear(); @@ -139,6 +143,39 @@ describe("bot/commands/detach", () => { ); }); + it("names the session with the title OpenCode has for it before detaching", async () => { + mocked.currentSession = { id: "session-1", title: "", directory: "D:/repo" }; + mocked.fetchSessionTitleMock.mockResolvedValue("Generated title"); + const ctx = createContext(); + + await detachCommand(ctx as never, createDeps()); + + expect(mocked.fetchSessionTitleMock).toHaveBeenCalledWith({ + id: "session-1", + title: "", + directory: "D:/repo", + }); + expect(defined(mocked.fetchSessionTitleMock.mock.invocationCallOrder[0])).toBeLessThan( + defined(mocked.clearSessionMock.mock.invocationCallOrder[0]), + ); + expect(ctx.reply).toHaveBeenCalledWith( + t("detach.success", { title: "Generated title" }), + expect.any(Object), + ); + }); + + it("names a session OpenCode has not named yet as a new session", async () => { + mocked.fetchSessionTitleMock.mockResolvedValue(""); + const ctx = createContext(); + + await detachCommand(ctx as never, createDeps()); + + expect(ctx.reply).toHaveBeenCalledWith( + t("detach.success", { title: t("pinned.default_session_title") }), + expect.any(Object), + ); + }); + it("returns a no-op message when no session is selected", async () => { mocked.currentSession = null; const ctx = createContext(); diff --git a/tests/bot/commands/new.test.ts b/tests/bot/commands/new.test.ts index 68e16c419..0ddfb99cd 100644 --- a/tests/bot/commands/new.test.ts +++ b/tests/bot/commands/new.test.ts @@ -135,4 +135,24 @@ describe("bot/commands/new", () => { }), ); }); + + it("names a session OpenCode has not named yet as a new session and keeps its title empty", async () => { + mocked.sessionCreateMock.mockResolvedValueOnce({ + data: { id: "session-3", title: "" }, + error: null, + }); + + const ctx = createContext(); + await newCommand(ctx as never, createDeps()); + + expect(mocked.attachToSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: { id: "session-3", title: "", directory: "/repo" }, + }), + ); + expect(ctx.reply).toHaveBeenCalledWith( + t("new.created", { title: t("pinned.default_session_title") }), + expect.anything(), + ); + }); }); diff --git a/tests/bot/commands/reload.test.ts b/tests/bot/commands/reload.test.ts new file mode 100644 index 000000000..9d8b9d064 --- /dev/null +++ b/tests/bot/commands/reload.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CommandContext, Context } from "grammy"; +import { t } from "../../../src/i18n/index.js"; + +const mocked = vi.hoisted(() => ({ + reloadOpencodeConfig: vi.fn(), + refreshModelViews: vi.fn(), + refreshModelViewsAfterLateCatalogSettle: vi.fn(), + editBotText: vi.fn(), +})); + +vi.mock("../../../src/app/services/config-reload-service.js", () => ({ + reloadOpencodeConfig: mocked.reloadOpencodeConfig, +})); + +vi.mock("../../../src/bot/services/model-views.js", () => ({ + refreshModelViews: mocked.refreshModelViews, + refreshModelViewsAfterLateCatalogSettle: mocked.refreshModelViewsAfterLateCatalogSettle, +})); + +vi.mock("../../../src/bot/messages/telegram-text.js", () => ({ + editBotText: mocked.editBotText, +})); + +import { reloadCommand } from "../../../src/bot/commands/reload-command.js"; +import { createTestAppContainer } from "../../helpers/app-container.js"; + +function createContext(messageId = 10): CommandContext { + return { + chat: { id: 42, type: "private" }, + api: {}, + reply: vi.fn().mockResolvedValue({ message_id: messageId }), + } as unknown as CommandContext; +} + +async function runReload(ctx: CommandContext): Promise { + await reloadCommand(ctx, createTestAppContainer()); + await vi.waitFor(() => expect(mocked.editBotText).toHaveBeenCalled()); +} + +describe("bot/commands/reload-command", () => { + beforeEach(() => { + mocked.editBotText.mockResolvedValue(undefined); + mocked.refreshModelViews.mockResolvedValue(undefined); + }); + + it("edits the reloading message into success", async () => { + mocked.reloadOpencodeConfig.mockResolvedValue({ kind: "success", modelChanged: false }); + const ctx = createContext(); + + await runReload(ctx); + + expect(ctx.reply).toHaveBeenCalledWith(t("reload.reloading")); + expect(mocked.editBotText).toHaveBeenCalledWith({ + api: ctx.api, + chatId: 42, + messageId: 10, + text: t("reload.success"), + }); + expect(mocked.refreshModelViews).not.toHaveBeenCalled(); + expect(mocked.refreshModelViewsAfterLateCatalogSettle).toHaveBeenCalledOnce(); + }); + + it("redraws the model views when the reload replaced the selected model", async () => { + mocked.reloadOpencodeConfig.mockResolvedValue({ kind: "success", modelChanged: true }); + + await runReload(createContext()); + + expect(mocked.refreshModelViews).toHaveBeenCalledOnce(); + expect(mocked.editBotText).toHaveBeenCalledWith( + expect.objectContaining({ text: t("reload.success") }), + ); + }); + + it("shows the server's error text on failure", async () => { + mocked.reloadOpencodeConfig.mockResolvedValue({ kind: "failed", error: "Invalid config" }); + + await runReload(createContext()); + + expect(mocked.editBotText).toHaveBeenCalledWith( + expect.objectContaining({ + text: t("reload.failed_with_error", { error: "Invalid config" }), + }), + ); + expect(mocked.refreshModelViewsAfterLateCatalogSettle).not.toHaveBeenCalled(); + }); + + it("shows the bare failure line without error text or on a timeout", async () => { + mocked.reloadOpencodeConfig.mockResolvedValueOnce({ kind: "failed", error: null }); + await runReload(createContext()); + expect(mocked.editBotText).toHaveBeenLastCalledWith( + expect.objectContaining({ text: t("reload.failed") }), + ); + + mocked.editBotText.mockClear(); + mocked.reloadOpencodeConfig.mockResolvedValueOnce({ kind: "timeout" }); + await runReload(createContext()); + expect(mocked.editBotText).toHaveBeenLastCalledWith( + expect.objectContaining({ text: t("reload.failed") }), + ); + }); + + it("ends in the failure line when the reload throws", async () => { + mocked.reloadOpencodeConfig.mockRejectedValue(new Error("boom")); + + await runReload(createContext()); + + expect(mocked.editBotText).toHaveBeenCalledWith( + expect.objectContaining({ text: t("reload.failed") }), + ); + }); + + it("edits each status message when two reloads share one outcome", async () => { + mocked.reloadOpencodeConfig.mockResolvedValue({ kind: "success", modelChanged: false }); + + await reloadCommand(createContext(10), createTestAppContainer()); + await reloadCommand(createContext(11), createTestAppContainer()); + await vi.waitFor(() => expect(mocked.editBotText).toHaveBeenCalledTimes(2)); + + expect(mocked.editBotText.mock.calls.map(([options]) => options.messageId)).toEqual([10, 11]); + }); +}); diff --git a/tests/bot/commands/rename.test.ts b/tests/bot/commands/rename.test.ts index 01caadb3a..305f4fb58 100644 --- a/tests/bot/commands/rename.test.ts +++ b/tests/bot/commands/rename.test.ts @@ -17,6 +17,7 @@ const mocked = vi.hoisted(() => ({ } as { id: string; title: string; directory: string } | null, updateSessionMock: vi.fn(), setCurrentSessionMock: vi.fn(), + fetchSessionTitleMock: vi.fn(), pinnedOnSessionChangeMock: vi.fn(), })); @@ -31,6 +32,7 @@ vi.mock("../../../src/opencode/client.js", () => ({ vi.mock("../../../src/app/services/session-service.js", () => ({ getCurrentSession: vi.fn(() => mocked.currentSession), setCurrentSession: mocked.setCurrentSessionMock, + fetchSessionTitle: mocked.fetchSessionTitleMock, })); function createRenameCommandContext(messageId: number): Context { @@ -95,6 +97,8 @@ describe("bot/commands/rename", () => { error: null, }); mocked.setCurrentSessionMock.mockReset(); + mocked.fetchSessionTitleMock.mockReset(); + mocked.fetchSessionTitleMock.mockResolvedValue("Old title"); mocked.pinnedOnSessionChangeMock.mockReset(); mocked.pinnedOnSessionChangeMock.mockResolvedValue(undefined); }); @@ -114,6 +118,31 @@ describe("bot/commands/rename", () => { expect(interactionState?.metadata.messageId).toBe(555); }); + it("prompts with the title OpenCode has for the session now", async () => { + mocked.fetchSessionTitleMock.mockResolvedValue("Generated title"); + const ctx = createRenameCommandContext(555); + + await renameCommand(ctx as never, createDeps()); + + expect(ctx.reply).toHaveBeenCalledWith( + t("rename.prompt", { title: "Generated title" }), + expect.anything(), + ); + expect(container.renameManager.getSessionInfo()?.currentTitle).toBe("Generated title"); + }); + + it("prompts with the new-session name while OpenCode has not named the session", async () => { + mocked.fetchSessionTitleMock.mockResolvedValue(""); + const ctx = createRenameCommandContext(555); + + await renameCommand(ctx as never, createDeps()); + + expect(ctx.reply).toHaveBeenCalledWith( + t("rename.prompt", { title: t("pinned.default_session_title") }), + expect.anything(), + ); + }); + it("renames session on valid text and clears states", async () => { container.renameManager.startWaiting("session-1", "D:/repo", "Old title"); container.renameManager.setMessageId(555); diff --git a/tests/bot/commands/sessions.test.ts b/tests/bot/commands/sessions.test.ts index 8a68db3ee..1c3e79040 100644 --- a/tests/bot/commands/sessions.test.ts +++ b/tests/bot/commands/sessions.test.ts @@ -262,6 +262,47 @@ describe("bot/commands/sessions", () => { expect(keyboardRows[11]?.[0]?.callback_data).toBe("inline:cancel:session"); }); + it("lists a session OpenCode has not named yet as a new session", async () => { + mocked.sessionListMock.mockResolvedValueOnce({ + data: [{ ...createSession(0), title: "" }], + error: null, + }); + + const ctx = createCommandContext(); + await sessionsCommand(ctx as never, createDeps()); + + const label = getKeyboardButtons(ctx)[0]?.[0]?.text; + expect(label).toMatch(new RegExp(`^1\\. ${t("pinned.default_session_title")} \\(`)); + }); + + it("confirms the selection of an unnamed session as a new session and keeps its title empty", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ + data: { ...createSession(0), title: "" }, + error: null, + }); + + startInteractionForTest(container.interactionManager, { + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:session-1", 456); + await handleSessionSelect(ctx, createDeps()); + + expect(mocked.attachToSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: { id: "session-1", title: "", directory: "/repo" }, + }), + ); + expect((ctx.api.sendMessage as ReturnType).mock.calls[1]?.[1]).toBe( + t("sessions.selected", { title: t("pinned.default_session_title") }), + ); + }); + it("blocks sessions command while foreground session is busy", async () => { container.foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); diff --git a/tests/bot/commands/status.test.ts b/tests/bot/commands/status.test.ts index a88174def..2954b60aa 100644 --- a/tests/bot/commands/status.test.ts +++ b/tests/bot/commands/status.test.ts @@ -10,6 +10,7 @@ const botVersion = (JSON.parse(readFileSync("package.json", "utf-8")) as { versi const mocked = vi.hoisted(() => ({ healthMock: vi.fn(), getCurrentSessionMock: vi.fn(), + fetchSessionTitleMock: vi.fn(), getCurrentProjectMock: vi.fn(), fetchCurrentAgentMock: vi.fn(), fetchCurrentModelMock: vi.fn(), @@ -50,6 +51,7 @@ vi.mock("../../../src/opencode/client.js", () => ({ vi.mock("../../../src/app/services/session-service.js", () => ({ getCurrentSession: mocked.getCurrentSessionMock, + fetchSessionTitle: mocked.fetchSessionTitleMock, })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ @@ -93,6 +95,7 @@ describe("bot/commands/status-command", () => { beforeEach(() => { mocked.healthMock.mockReset(); mocked.getCurrentSessionMock.mockReset(); + mocked.fetchSessionTitleMock.mockReset(); mocked.getCurrentProjectMock.mockReset(); mocked.fetchCurrentAgentMock.mockReset(); mocked.fetchCurrentModelMock.mockReset(); @@ -113,6 +116,7 @@ describe("bot/commands/status-command", () => { mocked.healthMock.mockResolvedValue({ data: { healthy: true, version: "1.0.0" }, error: null }); mocked.getCurrentSessionMock.mockReturnValue({ id: "s1", title: "S", directory: "/repo" }); + mocked.fetchSessionTitleMock.mockResolvedValue("S"); mocked.getCurrentProjectMock.mockReturnValue({ id: "p1", worktree: "/repo", name: "Repo" }); mocked.fetchCurrentAgentMock.mockResolvedValue("build"); mocked.fetchCurrentModelMock.mockReturnValue({ providerID: "openai", modelID: "gpt-5" }); @@ -285,4 +289,42 @@ describe("bot/commands/status-command", () => { expect(message).toContain("Model: 🧠 openai/gpt-5"); expect(message).not.toContain("gpt-5 ("); }); + + it("names the current session with the title OpenCode has for it now", async () => { + mocked.getCurrentSessionMock.mockReturnValue({ id: "s1", title: "", directory: "/repo" }); + mocked.fetchSessionTitleMock.mockResolvedValue("Greeting message"); + + const ctx = { + chat: { id: 42, type: "private" }, + message: { text: "/status" }, + api: {}, + reply: vi.fn(), + } as unknown as Context; + + await statusCommand(ctx as never, createDeps()); + + expect(mocked.fetchSessionTitleMock).toHaveBeenCalledWith({ + id: "s1", + title: "", + directory: "/repo", + }); + const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string; + expect(message).toContain("Current session: Greeting message"); + }); + + it("shows a session OpenCode has not named yet as a new session", async () => { + mocked.fetchSessionTitleMock.mockResolvedValue(""); + + const ctx = { + chat: { id: 42, type: "private" }, + message: { text: "/status" }, + api: {}, + reply: vi.fn(), + } as unknown as Context; + + await statusCommand(ctx as never, createDeps()); + + const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string; + expect(message).toContain("Current session: new session"); + }); }); diff --git a/tests/bot/events/session-runtime-state.test.ts b/tests/bot/events/session-runtime-state.test.ts index 7b67d4259..d55233709 100644 --- a/tests/bot/events/session-runtime-state.test.ts +++ b/tests/bot/events/session-runtime-state.test.ts @@ -116,6 +116,53 @@ describe("bot/events/session-runtime-state", () => { expect(runtime.takeCompletedToolDuration("session-2", "call-0")).toBe(1500); }); + describe("background operations", () => { + function addBackground(sessionId: string): void { + runtime.runningToolTracker.track(sessionId, "call-bg", true); + runtime.setRunningToolInfo({ ...toolInfo(sessionId), callId: "call-bg" }); + runtime.setSubagentSnapshot(sessionId, [ + { cardId: "card-bg", background: true, status: "running" }, + { cardId: "card-fg", status: "running" }, + ] as never); + } + + it("keeps them through a turn boundary and drops the rest of the turn", () => { + addBackground("session-1"); + + runtime.clearToolTracking("session-1", "test", true); + + expect(runtime.runningToolTracker.backgroundCallIds("session-1")).toEqual(["call-bg"]); + expect(runtime.getRunningToolInfo("session-1", "call-bg")).toBeDefined(); + expect(runtime.getRunningToolInfo("session-1", "call-1")).toBeUndefined(); + expect(runtime.getSubagentSnapshot("session-1")?.map((card) => card.cardId)).toEqual([ + "card-bg", + ]); + }); + + it("drops them on any other clear", () => { + addBackground("session-1"); + + runtime.clearToolTracking("session-1", "test"); + + expect(runtime.runningToolTracker.backgroundCallIds("session-1")).toEqual([]); + expect(runtime.getRunningToolInfo("session-1", "call-bg")).toBeUndefined(); + expect(runtime.getSubagentSnapshot("session-1")).toBeUndefined(); + }); + + it("stops them in one session and leaves the other session running", () => { + addBackground("session-1"); + runtime.runningToolTracker.track("session-2", "call-bg-2", true); + + runtime.stopBackgroundOperations("test", "session-1"); + + expect(runtime.runningToolTracker.backgroundCallIds()).toEqual(["call-bg-2"]); + expect(runtime.getRunningToolInfo("session-1", "call-bg")).toBeUndefined(); + expect(runtime.getSubagentSnapshot("session-1")?.map((card) => card.cardId)).toEqual([ + "card-fg", + ]); + }); + }); + it("keeps a session's entries apart from a same-named call in another session", () => { runtime.deleteRunningToolInfo("session-1", "call-1"); @@ -245,4 +292,59 @@ describe("bot/events/session-runtime-state text sent early", () => { "Analysis", ); }); + + describe("reply gate", () => { + it("lets out only the replies begun when the gate was taken, after the cut", async () => { + vi.useRealTimers(); + streamingMode.value = "edit"; + const api = { + sendMessage: vi.fn().mockResolvedValue({ message_id: 10 }), + sendRichMessage: vi + .fn() + .mockRejectedValue(Object.assign(new Error("Bad Request: rich"), { error_code: 400 })), + editMessageText: vi.fn().mockResolvedValue(undefined), + deleteMessage: vi.fn().mockResolvedValue(true), + }; + const policy = { + getDestination: () => ({ api, chatId: 42 }), + isForegroundSession: () => true, + } as unknown as SessionTargetPolicy; + const runtime = new SessionRuntimeState({ policy, getReplyKeyboard: () => undefined }); + let releaseOlderLine: () => void = () => {}; + api.sendMessage.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseOlderLine = () => resolve({ message_id: 9 }); + }), + ); + + runtime.toolCallStreamer.append("session-1", "older tool line"); + runtime.enqueueAssistantResponse("session-1", "reply-1", { + parts: [{ blocks: [], fallbackText: "first reply", source: "plain" }], + }); + runtime.markReplyBegun("session-1", "reply-1"); + runtime.startReplyCut("session-1"); + const gate = runtime.takeReplyGate("session-1"); + + runtime.enqueueAssistantResponse("session-1", "reply-2", { + parts: [{ blocks: [], fallbackText: "second reply", source: "plain" }], + }); + runtime.markReplyBegun("session-1", "reply-2"); + const letOut = gate?.(); + await new Promise((resolve) => setImmediate(resolve)); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + + releaseOlderLine(); + await letOut; + + const sentTexts = api.sendMessage.mock.calls.map((call) => String(call[1])); + expect(sentTexts).toEqual(["older tool line", "first reply"]); + runtime.clearAllOutput("test_cleanup"); + }); + + it("has no gate while no reply has begun", () => { + const runtime = createRuntime(); + expect(runtime.takeReplyGate("session-1")).toBeUndefined(); + }); + }); }); diff --git a/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts b/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts index 9eb5ceee9..1f467d494 100644 --- a/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts +++ b/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts @@ -204,6 +204,18 @@ describe("bot/handlers/prompt-queue-dispatch on OpenCode V2", () => { expect(mocked.startInboxPromptRun).not.toHaveBeenCalled(); }); + it("opens the prompt's own run in place of a turn the bot did not start", async () => { + DEPS.assistantRunState.startObservedRun("ses-1", Date.now()); + mocked.admitPromptToInbox.mockImplementation(async () => { + promptQueue.rememberDeliveredInboxId("msg-1"); + return { sessionId: "ses-1", inboxId: "msg-1" }; + }); + + await tryEnqueuePrompt(makeContext(), createIncomingPrompt("Mid-turn")); + + expect(mocked.startInboxPromptRun).toHaveBeenCalledWith(SESSION, DEPS, undefined); + }); + it("does not apply the queued media cap to prompts that wait in OpenCode", async () => { await expect(rejectQueuedMediaBeforePreparation(makeContext(), undefined)).resolves.toBe(false); await expect(rejectQueuedMediaBeforePreparation(makeContext(), 50 * 1024 * 1024)).resolves.toBe( diff --git a/tests/bot/handlers/prompt.test.ts b/tests/bot/handlers/prompt.test.ts index df5d3db9d..886c245ca 100644 --- a/tests/bot/handlers/prompt.test.ts +++ b/tests/bot/handlers/prompt.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Bot, Context } from "grammy"; import type { FilePartInput } from "@opencode-ai/sdk/v2"; import { + admitPromptToInbox, consumePromptResponseMode, processUserPrompt as processIncomingPrompt, type ProcessPromptDeps, @@ -12,6 +13,7 @@ import { t } from "../../../src/i18n/index.js"; import { logger } from "../../../src/utils/logger.js"; import type { AppContainer } from "../../../src/app/bootstrap/app-container.js"; import { createTestAppContainer } from "../../helpers/app-container.js"; +import { defined } from "../../helpers/defined.js"; const mocked = vi.hoisted(() => ({ resolvePendingAttachmentMock: vi.fn(), @@ -244,6 +246,35 @@ describe("bot/handlers/prompt", () => { expect(mocked.suppressionRegisterMock).toHaveBeenCalledWith("session-1", "Review README"); }); + it("names a session it had to create as a new session while OpenCode has not named it", async () => { + mocked.currentSession = null; + mocked.sessionCreateMock.mockResolvedValueOnce({ + data: { id: "session-2", title: "" }, + error: null, + }); + const ctx = createContext(); + const baseDeps = createDeps(); + const deps: ProcessPromptDeps = { + ...baseDeps, + keyboardManager: { + ...baseDeps.keyboardManager, + getContextInfo: vi.fn(() => null), + } as unknown as AppContainer["keyboardManager"], + }; + + await processUserPrompt(ctx, "Review README", deps); + + expect(mocked.attachToSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: { id: "session-2", title: "", directory: "D:\\Projects\\Repo" }, + }), + ); + expect(ctx.reply).toHaveBeenCalledWith( + t("bot.session_created", { title: t("pinned.default_session_title") }), + expect.anything(), + ); + }); + it("starts prompts through promptAsync instead of the streaming prompt endpoint", async () => { const handled = await processUserPrompt(createContext(), "Review README", createDeps()); @@ -560,6 +591,80 @@ describe("bot/handlers/prompt", () => { expect(mocked.safeBackgroundTaskMock).not.toHaveBeenCalled(); }); + it("reads a photo's capabilities before checking whether the session is busy", async () => { + const getModelCapabilities = vi.fn().mockResolvedValue({ input: { image: true } }); + const deps: ProcessPromptDeps = { + ...createDeps(), + downloadFile: vi.fn().mockResolvedValue({ + buffer: Buffer.from("photo"), + filePath: "photos/photo.jpg", + }), + getModelCapabilities, + }; + + await processIncomingPrompt( + createContext(), + createIncomingPrompt("Describe this", { + photos: [{ fileId: "photo", filename: "photo.jpg", source: "rich" }], + }), + deps, + ); + + const firstCapabilitiesRead = defined(getModelCapabilities.mock.invocationCallOrder[0]); + const firstBusyCheck = defined(mocked.sessionStatusMock.mock.invocationCallOrder[0]); + expect(firstCapabilitiesRead).toBeLessThan(firstBusyCheck); + }); + + it("answers busy for a photo whose session started a run while its model was being read", async () => { + const ctx = createContext(); + const downloadFile = vi.fn(); + const deps: ProcessPromptDeps = { + ...createDeps(), + downloadFile, + getModelCapabilities: vi.fn().mockImplementation(async () => { + mocked.sessionStatusMock.mockResolvedValue({ + data: { "session-1": { type: "busy" } }, + error: null, + }); + return { input: { image: true } }; + }), + }; + + const handled = await processIncomingPrompt( + ctx, + createIncomingPrompt("Describe this", { + photos: [{ fileId: "photo", filename: "photo.jpg", source: "rich" }], + }), + deps, + ); + + expect(handled).toBe(false); + expect(ctx.reply).toHaveBeenCalledWith(t("bot.session_busy")); + expect(downloadFile).not.toHaveBeenCalled(); + }); + + it("gates a photo sent into a running session's inbox by the model's capabilities", async () => { + const ctx = createContext(); + const deps: ProcessPromptDeps = { + ...createDeps(), + downloadFile: vi.fn(), + getModelCapabilities: vi.fn().mockResolvedValue({ input: { image: false } }), + }; + + const admitted = await admitPromptToInbox( + ctx, + createIncomingPrompt("Describe this", { + photos: [{ fileId: "photo", filename: "photo.jpg", source: "rich" }], + }), + deps, + "steer", + ); + + expect(admitted).toBeNull(); + expect(ctx.reply).toHaveBeenCalledWith(t("bot.photo_model_no_image")); + expect(deps.getModelCapabilities).toHaveBeenCalledTimes(1); + }); + describe("pending /ls attachment", () => { const attachmentPart = { type: "file" as const, diff --git a/tests/bot/menus/recent-selection-menu.test.ts b/tests/bot/menus/recent-selection-menu.test.ts index 53df8dbbe..8fbdcb2eb 100644 --- a/tests/bot/menus/recent-selection-menu.test.ts +++ b/tests/bot/menus/recent-selection-menu.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildRecentMenu } from "../../../src/bot/menus/recent-selection-menu.js"; import type { RecentSession } from "../../../src/app/services/recent-sessions-service.js"; +import { t } from "../../../src/i18n/index.js"; const row = (id: string, directory: string, status: RecentSession["status"]): RecentSession => ({ session: { id, directory, title: "A".repeat(100) } as RecentSession["session"], status, @@ -61,4 +62,11 @@ describe("recent session buttons", () => { expect(new Set(labels).size).toBe(3); expect(labels.every((label) => [...label].length <= 64)).toBe(true); }); + + it("shows a session OpenCode has not named yet as a new session", () => { + const { keyboard } = buildRecentMenu([ + { session: { id: "a", directory: "/repo", title: "" } as RecentSession["session"], status: "idle" }, + ]); + expect(keyboard.inline_keyboard.flat()[0]?.text).toBe(`○ [repo] ${t("pinned.default_session_title")}`); + }); }); diff --git a/tests/bot/middleware/auth.test.ts b/tests/bot/middleware/auth.test.ts new file mode 100644 index 000000000..310834e84 --- /dev/null +++ b/tests/bot/middleware/auth.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Context } from "grammy"; + +const mocked = vi.hoisted(() => ({ + loggerDebugMock: vi.fn(), + loggerWarnMock: vi.fn(), +})); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { + debug: mocked.loggerDebugMock, + info: vi.fn(), + warn: mocked.loggerWarnMock, + error: vi.fn(), + }, +})); + +vi.mock("../../../src/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + config: { + ...actual.config, + telegram: { ...actual.config.telegram, allowedUserId: 777 }, + }, + }; +}); + +import { authMiddleware } from "../../../src/bot/middleware/auth.js"; + +const ALLOWED_USER_ID = 777; +const BOT_ID = 555; + +function createContext( + from: { id: number; is_bot: boolean }, + chatId: number, +): { ctx: Context; setMyCommands: ReturnType } { + const setMyCommands = vi.fn().mockResolvedValue(true); + const ctx = { + from, + chat: { id: chatId }, + me: { id: BOT_ID, is_bot: true }, + message: { message_id: 1 }, + api: { setMyCommands }, + } as unknown as Context; + return { ctx, setMyCommands }; +} + +describe("bot/middleware/auth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes updates from the allowed user to the next middleware", async () => { + const { ctx } = createContext({ id: ALLOWED_USER_ID, is_bot: false }, ALLOWED_USER_ID); + const next = vi.fn().mockResolvedValue(undefined); + + await authMiddleware(ctx, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(mocked.loggerWarnMock).not.toHaveBeenCalled(); + }); + + it("drops updates authored by the bot itself without a warning", async () => { + const { ctx, setMyCommands } = createContext({ id: BOT_ID, is_bot: true }, ALLOWED_USER_ID); + const next = vi.fn(); + + await authMiddleware(ctx, next); + + expect(next).not.toHaveBeenCalled(); + expect(mocked.loggerWarnMock).not.toHaveBeenCalled(); + expect(mocked.loggerDebugMock).toHaveBeenCalledWith( + expect.stringContaining("Ignoring update from the bot itself"), + ); + expect(setMyCommands).not.toHaveBeenCalled(); + }); + + it("warns about a foreign user and empties the foreign chat's commands", async () => { + const { ctx, setMyCommands } = createContext({ id: 999, is_bot: false }, 999); + const next = vi.fn(); + + await authMiddleware(ctx, next); + + expect(next).not.toHaveBeenCalled(); + expect(mocked.loggerWarnMock).toHaveBeenCalledWith( + "Unauthorized access attempt from user ID: 999", + ); + expect(setMyCommands).toHaveBeenCalledWith([], { scope: { type: "chat", chat_id: 999 } }); + }); + + it("still warns about another bot", async () => { + const { ctx } = createContext({ id: 888, is_bot: true }, 888); + const next = vi.fn(); + + await authMiddleware(ctx, next); + + expect(next).not.toHaveBeenCalled(); + expect(mocked.loggerWarnMock).toHaveBeenCalledWith( + "Unauthorized access attempt from user ID: 888", + ); + }); + + it("warns about a foreign user in the allowed user's chat without resetting commands", async () => { + const { ctx, setMyCommands } = createContext({ id: 999, is_bot: false }, ALLOWED_USER_ID); + const next = vi.fn(); + + await authMiddleware(ctx, next); + + expect(next).not.toHaveBeenCalled(); + expect(mocked.loggerWarnMock).toHaveBeenCalledWith( + "Unauthorized access attempt from user ID: 999", + ); + expect(setMyCommands).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/bot/middleware/interaction-guard-reload.test.ts b/tests/bot/middleware/interaction-guard-reload.test.ts new file mode 100644 index 000000000..8a161cf4c --- /dev/null +++ b/tests/bot/middleware/interaction-guard-reload.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Context } from "grammy"; +import { createTestAppContainer } from "../../helpers/app-container.js"; +import { startInteractionForTest } from "../../helpers/interaction.js"; +import type { AppContainer } from "../../../src/app/bootstrap/app-container.js"; + +const mocked = vi.hoisted(() => ({ + reloadKnown: true, +})); + +vi.mock("../../../src/bot/routers/command-utils.js", () => ({ + isKnownCommand: (command: string) => command === "reload" && mocked.reloadKnown, +})); + +import { resolveInteractionGuardDecision } from "../../../src/bot/middleware/interaction-guard-decision.js"; + +let deps: AppContainer; + +function reloadContext(): Context { + return { message: { text: "/reload" } } as unknown as Context; +} + +describe("interaction guard: /reload", () => { + beforeEach(() => { + mocked.reloadKnown = true; + deps = createTestAppContainer(); + deps.interactionManager.clear("test_setup"); + }); + + it("allows /reload while a task runs and nothing is on screen", () => { + deps.foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); + + const decision = resolveInteractionGuardDecision(reloadContext(), deps); + + expect(decision.allow).toBe(true); + expect(decision.busy).toBe(true); + }); + + it("blocks /reload while a task runs and a question is on screen", () => { + deps.foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); + startInteractionForTest(deps.interactionManager, { + kind: "question", + expectedInput: "mixed", + }); + + const decision = resolveInteractionGuardDecision(reloadContext(), deps); + + expect(decision.allow).toBe(false); + expect(decision.reason).toBe("command_not_allowed"); + }); + + it("blocks /reload while an inline menu is on screen and nothing runs", () => { + startInteractionForTest(deps.interactionManager, { + kind: "inline", + expectedInput: "callback", + }); + + const decision = resolveInteractionGuardDecision(reloadContext(), deps); + + expect(decision.allow).toBe(false); + expect(decision.reason).toBe("command_not_allowed"); + }); + + it("keeps the busy block for /reload when the server version has no such command", () => { + mocked.reloadKnown = false; + deps.foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); + + const decision = resolveInteractionGuardDecision(reloadContext(), deps); + + expect(decision.allow).toBe(false); + expect(decision.reason).toBe("command_not_allowed"); + expect(decision.busy).toBe(true); + }); +}); diff --git a/tests/bot/pinned/pinned-message-manager.test.ts b/tests/bot/pinned/pinned-message-manager.test.ts index 0bee81124..b655ecb48 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(() => ({ getPinnedDashboardEnabled: vi.fn().mockReturnValue(true), getStoredModel: vi.fn().mockReturnValue(null), getModelContextLimit: vi.fn().mockResolvedValue(204800), + waitForModelContextLimit: vi.fn().mockResolvedValue(null), getGitWorktreeContext: vi.fn(), formatModelDisplayName: vi.fn(() => "test-model"), })); @@ -41,6 +42,7 @@ vi.mock("../../../src/app/services/model-selection-service.js", () => ({ getStor vi.mock("../../../src/app/services/model-context-limit-service.js", () => ({ DEFAULT_CONTEXT_LIMIT: 204800, getModelContextLimit: mocked.getModelContextLimit, + waitForModelContextLimit: mocked.waitForModelContextLimit, })); vi.mock("../../../src/i18n/index.js", async (importOriginal) => { const actual = await importOriginal(); @@ -106,6 +108,8 @@ describe("pinned/manager", () => { mocked.formatModelDisplayName.mockReturnValue("test-model"); mocked.getStoredModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5" }); mocked.getModelContextLimit.mockResolvedValue(204800); + mocked.waitForModelContextLimit.mockReset(); + mocked.waitForModelContextLimit.mockResolvedValue(null); mocked.getPinnedMessageId.mockReturnValue(null); mocked.getPinnedDashboardEnabled.mockReturnValue(true); mocked.opencodeClient.session.messages.mockResolvedValue({ data: [] }); @@ -739,6 +743,55 @@ describe("pinned/manager", () => { expect(pinnedMessageManager.getContextLimit()).toBe(1_000_000); }); + it("fills in the model's own limit on the same message once its provider is listed", async () => { + mocked.waitForModelContextLimit.mockResolvedValue(1_000_000); + const keyboardUpdate = vi.fn(); + pinnedMessageManager.setOnKeyboardUpdate(keyboardUpdate); + + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + + await vi.waitFor(() => expect(pinnedMessageManager.getContextLimit()).toBe(1_000_000)); + expect(mocked.waitForModelContextLimit).toHaveBeenCalledWith("openai", "gpt-5"); + await vi.waitFor(() => + expect(fakeApi.editMessageText).toHaveBeenCalledWith(123, 999, expect.any(String)), + ); + expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(keyboardUpdate).toHaveBeenLastCalledWith(0, 1_000_000)); + }); + + it("fills in the limit after restoring an existing session", async () => { + mocked.getPinnedMessageId.mockReturnValue(777); + pinnedMessageManager.initialize(fakeApi as never, 123); + mocked.waitForModelContextLimit.mockResolvedValue(1_000_000); + + await pinnedMessageManager.restoreExistingSession("ses-1", "Restored session"); + + await vi.waitFor(() => expect(pinnedMessageManager.getContextLimit()).toBe(1_000_000)); + }); + + it("does not edit the message when the limit drawn was already the model's own", async () => { + mocked.waitForModelContextLimit.mockResolvedValue(204800); + + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fakeApi.editMessageText).not.toHaveBeenCalled(); + }); + + it("drops a limit that arrives after the session changed", async () => { + let resolveFirstLimit: (limit: number | null) => void = () => {}; + mocked.waitForModelContextLimit.mockImplementationOnce( + () => new Promise((resolve) => (resolveFirstLimit = resolve)), + ); + + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + await pinnedMessageManager.onSessionChange("ses-2", "Other Session"); + resolveFirstLimit(1_000_000); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(pinnedMessageManager.getContextLimit()).toBe(204800); + }); + it("falls back to the default limit when the model lookup fails", async () => { mocked.getModelContextLimit.mockRejectedValue(new Error("model registry unavailable")); diff --git a/tests/bot/routers/command-router.test.ts b/tests/bot/routers/command-router.test.ts index 5beda3637..52c4757ed 100644 --- a/tests/bot/routers/command-router.test.ts +++ b/tests/bot/routers/command-router.test.ts @@ -58,6 +58,24 @@ describe("bot/routers/command-router", () => { ]); }); + it("registers /reload after /opencode_stop only on V2", async () => { + vi.stubEnv("OPENCODE_SERVER_VERSION", "v2"); + vi.resetModules(); + const router = await import("../../../src/bot/routers/command-router.js"); + const bot = { command: vi.fn(), use: vi.fn() }; + + router.registerCommandRouter(bot as never, { container: createTestAppContainer() }); + + const commands = bot.command.mock.calls.map(([command]) => command); + expect(commands.slice(commands.indexOf("opencode_stop"), commands.indexOf("opencode_stop") + 2)).toEqual([ + "opencode_stop", + "reload", + ]); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + it("flushes a pending prompt before routing a command", async () => { const bot = { command: vi.fn(), use: vi.fn() }; const next = vi.fn(); diff --git a/tests/bot/services/event-subscription-service.lifecycle.test.ts b/tests/bot/services/event-subscription-service.lifecycle.test.ts index f890daee8..0022aebec 100644 --- a/tests/bot/services/event-subscription-service.lifecycle.test.ts +++ b/tests/bot/services/event-subscription-service.lifecycle.test.ts @@ -111,10 +111,17 @@ function emitAssistantCompleted(aggregator: Aggregator): void { } as unknown as Event); } -function emitSessionIdle(aggregator: Aggregator): void { +function emitSessionIdle(aggregator: Aggregator, options: { interrupted?: boolean } = {}): void { aggregator.processEvent({ type: "session.idle", - properties: { sessionID: "session-1" }, + properties: { sessionID: "session-1", ...(options.interrupted ? { interrupted: true } : {}) }, + } as unknown as Event); +} + +function emitSessionBusy(aggregator: Aggregator): void { + aggregator.processEvent({ + type: "session.status", + properties: { sessionID: "session-1", status: { type: "busy" } }, } as unknown as Event); } @@ -509,6 +516,155 @@ describe("bot/services/event-subscription-service lifecycle", () => { }, 30_000); }); + describe("footer of a turn the bot did not start", () => { + async function setup( + options: { showAssistantRunFooter?: boolean; startAssistantRun?: boolean } = {}, + ): Promise<{ api: FakeBotApi; summaryAggregator: Aggregator }> { + return setupService({ + showAssistantRunFooter: options.showAssistantRunFooter ?? true, + ...(options.startAssistantRun ? { startAssistantRun: true } : {}), + }); + } + + async function answerTurn(api: FakeBotApi, summaryAggregator: Aggregator): Promise { + emitAssistantTextPart(summaryAggregator, "Command finished"); + emitAssistantCompleted(summaryAggregator); + await vi.waitFor( + () => { + expect(collectSentTexts(api).some((text) => text.includes("Command finished"))).toBe( + true, + ); + }, + { timeout: STREAM_WAIT_TIMEOUT_MS }, + ); + } + + it("sends one footer timed from the moment the session went busy", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-28T10:00:00Z")); + const { api, summaryAggregator } = await setup(); + + emitSessionBusy(summaryAggregator); + vi.setSystemTime(new Date("2026-09-28T10:01:00Z")); + await answerTurn(api, summaryAggregator); + vi.setSystemTime(new Date("2026-09-28T10:01:05Z")); + emitSessionIdle(summaryAggregator); + + await vi.waitFor( + () => { + expect(findFooterCalls(api)).toHaveLength(1); + }, + { timeout: STREAM_WAIT_TIMEOUT_MS }, + ); + expect(String(findFooterCalls(api)[0]?.[1])).toContain("🕒 1m 5s"); + expect(activeContainer.assistantRunState.hasRun("session-1")).toBe(false); + }, 30_000); + + it("keeps the start of a run the bot opened itself", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-28T10:00:00Z")); + const { api, summaryAggregator } = await setup({ startAssistantRun: true }); + + vi.setSystemTime(new Date("2026-09-28T10:00:05Z")); + emitSessionBusy(summaryAggregator); + await answerTurn(api, summaryAggregator); + vi.setSystemTime(new Date("2026-09-28T10:00:15Z")); + emitSessionIdle(summaryAggregator); + + await vi.waitFor( + () => { + expect(findFooterCalls(api)).toHaveLength(1); + }, + { timeout: STREAM_WAIT_TIMEOUT_MS }, + ); + expect(String(findFooterCalls(api)[0]?.[1])).toContain("🕒 16s"); + }, 30_000); + + it("sends no footer when the turn's start was never seen", async () => { + const { api, summaryAggregator } = await setup(); + + await answerTurn(api, summaryAggregator); + emitSessionIdle(summaryAggregator); + await settle(); + + expect(findFooterCalls(api)).toHaveLength(0); + }, 30_000); + + it("sends no footer when the footer is turned off", async () => { + const { api, summaryAggregator } = await setup({ showAssistantRunFooter: false }); + + emitSessionBusy(summaryAggregator); + await answerTurn(api, summaryAggregator); + emitSessionIdle(summaryAggregator); + await settle(); + + expect(findFooterCalls(api)).toHaveLength(0); + }, 30_000); + + it("sends no footer for a turn that ended with a session error", async () => { + const { api, summaryAggregator } = await setup(); + + emitSessionBusy(summaryAggregator); + await answerTurn(api, summaryAggregator); + emitSessionError(summaryAggregator, "Provider failed"); + emitSessionIdle(summaryAggregator); + await settle(); + + expect(findFooterCalls(api)).toHaveLength(0); + expect(activeContainer.assistantRunState.hasRun("session-1")).toBe(false); + }, 30_000); + + it("sends no footer for a turn stopped from an attached client", async () => { + const { api, summaryAggregator } = await setup(); + + emitSessionBusy(summaryAggregator); + await answerTurn(api, summaryAggregator); + emitSessionIdle(summaryAggregator, { interrupted: true }); + await settle(); + + expect(findFooterCalls(api)).toHaveLength(0); + expect(activeContainer.assistantRunState.hasRun("session-1")).toBe(false); + }, 30_000); + + it("still sends the footer of a bot run stopped from an attached client", async () => { + const { api, summaryAggregator } = await setup({ startAssistantRun: true }); + + emitSessionBusy(summaryAggregator); + await answerTurn(api, summaryAggregator); + emitSessionIdle(summaryAggregator, { interrupted: true }); + + await vi.waitFor( + () => { + expect(findFooterCalls(api)).toHaveLength(1); + }, + { timeout: STREAM_WAIT_TIMEOUT_MS }, + ); + }, 30_000); + + it("forgets the running turn when the event stream reconnects", async () => { + const { summaryAggregator } = await setup(); + const onReconnect = mocked.subscribeToEvents.mock.calls.at(-1)?.[2] as () => void; + + emitSessionBusy(summaryAggregator); + onReconnect(); + + expect(activeContainer.summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); + }, 30_000); + + it("opens no run for a reply that completes after its turn went idle", async () => { + const { api, summaryAggregator } = await setup(); + + emitSessionBusy(summaryAggregator); + emitSessionIdle(summaryAggregator); + await answerTurn(api, summaryAggregator); + emitSessionIdle(summaryAggregator); + await settle(); + + expect(findFooterCalls(api)).toHaveLength(0); + expect(activeContainer.assistantRunState.hasRun("session-1")).toBe(false); + }, 30_000); + }); + describe("streaming mode switching", () => { it("finishes an answer with the streamer it was started with after a switch to draft", async () => { const { api, summaryAggregator } = await setupService({ responseStreamingMode: "edit" }); @@ -968,6 +1124,46 @@ describe("bot/services/event-subscription-service lifecycle", () => { expect(activeContainer.foregroundSessionState.isBusy()).toBe(true); }, 30_000); + it("opens a steered prompt's own run in place of a turn the bot did not start", async () => { + const { summaryAggregator } = await setupService({ showAssistantRunFooter: true }); + const { assistantRunState, foregroundSessionState } = activeContainer; + assistantRunState.startObservedRun("session-1", Date.now()); + await mirrorInboxPrompt("steer"); + + emitExternalUserMessage(summaryAggregator, "Hi"); + await vi.waitFor( + () => { + expect(assistantRunState.hasBotRun("session-1")).toBe(true); + }, + { timeout: STREAM_WAIT_TIMEOUT_MS }, + ); + + expect(foregroundSessionState.isBusy()).toBe(true); + }, 30_000); + + it("closes a turn the bot did not start with its footer before a queued prompt", async () => { + const { api, summaryAggregator } = await setupService({ showAssistantRunFooter: true }); + const { assistantRunState } = activeContainer; + assistantRunState.startObservedRun("session-1", Date.now()); + assistantRunState.markResponseCompleted("session-1", { + agent: "test-agent", + providerID: "test-provider", + modelID: "test-model", + }); + await mirrorInboxPrompt("queue"); + + emitExternalUserMessage(summaryAggregator, "Next task"); + await vi.waitFor( + () => { + expect(assistantRunState.hasBotRun("session-1")).toBe(true); + }, + { timeout: STREAM_WAIT_TIMEOUT_MS }, + ); + + expect(findFooterCalls(api)).toHaveLength(1); + expect(assistantRunState.isResponseCompleted("session-1")).toBe(false); + }, 30_000); + it("remembers a user message that matches no waiting prompt", async () => { const { api, summaryAggregator } = await setupService(); const { promptQueue } = await import("../../../src/app/managers/prompt-queue-manager.js"); diff --git a/tests/bot/services/event-subscription-service.test.ts b/tests/bot/services/event-subscription-service.test.ts index d062692ac..9262c079c 100644 --- a/tests/bot/services/event-subscription-service.test.ts +++ b/tests/bot/services/event-subscription-service.test.ts @@ -442,7 +442,10 @@ function emitQuestionAsked( describe("bot/services/event-subscription-service", () => { let tempHome: string; - let activeService: { cleanup(reason: string): void } | null = null; + let activeService: { + cleanup(reason: string): void; + stopBackgroundOperations(reason: string, sessionId?: string): void; + } | null = null; let activeContainer: AppContainer; beforeEach(async () => { @@ -1401,6 +1404,200 @@ describe("bot/services/event-subscription-service", () => { }); }); + describe("background operations", () => { + function emitBackgroundBash( + summaryAggregator: { processEvent(event: Event): void }, + status: "running" | "completed", + ): void { + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-call-bg", + sessionID: "session-1", + messageID: "message-1", + type: "tool", + callID: "call-bg", + tool: "bash", + state: { + status, + input: { command: "sleep 90", background: true }, + metadata: {}, + ...(status === "completed" ? { output: "ok" } : {}), + }, + }, + }, + } as unknown as Event); + } + + /** Launches a background command, then replies and ends the turn. */ + async function launchAndEndTurn( + api: FakeBotApi, + summaryAggregator: { processEvent(event: Event): void }, + ): Promise { + let nextMessageId = 100; + api.sendMessage.mockImplementation(async () => ({ message_id: nextMessageId++ })); + + useTrackerFakeTimers(); + emitBackgroundBash(summaryAggregator, "running"); + await flushPendingDispatch(); + const lineMessageId = nextMessageId - 1; + emitAssistantTextPart(summaryAggregator, "started"); + emitAssistantCompleted(summaryAggregator); + await flushPendingDispatch(); + emitSessionIdle(summaryAggregator); + await flushPendingDispatch(); + return lineMessageId; + } + + async function useCompactMode(): Promise { + const settingsStore = await import("../../../src/app/stores/settings-store.js"); + settingsStore.setCompactOutputMode(true); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + it("keeps counting a background command after the turn and finishes its line in place", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const lineMessageId = await launchAndEndTurn(api, summaryAggregator); + + await vi.advanceTimersByTimeAsync(30_000); + const running = api.editMessageText.mock.calls.filter((call) => call[1] === lineMessageId); + expect(String(running.at(-1)?.[2])).toMatch(/⏳ .*sleep 90 · 🕒 \d+[ms]/); + + emitBackgroundBash(summaryAggregator, "completed"); + await flushPendingDispatch(); + + const finished = api.editMessageText.mock.calls.filter((call) => call[1] === lineMessageId); + const finalLine = String(finished.at(-1)?.[2]); + expect(finalLine).toContain("sleep 90"); + expect(finalLine).toMatch(/· 🕒 \d+[ms]/); + expect(finalLine).not.toContain("⏳"); + }); + + it("leaves the line as it was when background operations are stopped", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + await launchAndEndTurn(api, summaryAggregator); + await vi.advanceTimersByTimeAsync(25_000); + + activeService?.stopBackgroundOperations("test", "session-1"); + await flushPendingDispatch(); + const textsBefore = collectSentTexts(api).length; + await vi.advanceTimersByTimeAsync(60_000); + emitBackgroundBash(summaryAggregator, "completed"); + await flushPendingDispatch(); + + expect(collectSentTexts(api).slice(textsBefore)).toEqual([]); + }); + + it("keeps a background subagent's card live after the turn and completes it in place", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + let nextMessageId = 100; + api.sendMessage.mockImplementation(async () => ({ message_id: nextMessageId++ })); + + useTrackerFakeTimers(); + emitSubagentStart(summaryAggregator); + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-call-task", + sessionID: "session-1", + messageID: "message-1", + type: "tool", + callID: "call-task", + tool: "task", + state: { + status: "running", + input: { description: "inspect task 1", background: true }, + metadata: { sessionId: "child-session-1" }, + }, + }, + }, + } as unknown as Event); + await flushPendingDispatch(); + const cardMessageId = nextMessageId - 1; + emitAssistantTextPart(summaryAggregator, "launched"); + emitAssistantCompleted(summaryAggregator); + emitSessionIdle(summaryAggregator); + await flushPendingDispatch(); + + emitSubagentTool(summaryAggregator); + await vi.advanceTimersByTimeAsync(25_000); + const cardEdits = () => + api.editMessageText.mock.calls + .filter((call) => call[1] === cardMessageId) + .map((call) => String(call[2])); + expect(cardEdits().at(-1)).toMatch(/npm run lint · 🕒 \d+s/); + + summaryAggregator.processEvent({ + type: "session.idle", + properties: { sessionID: "child-session-1" }, + } as unknown as Event); + await flushPendingDispatch(); + expect(cardEdits().at(-1)).toMatch(/✅ Completed · 🕒/); + }); + + it("stops a background command once its session is no longer current", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const sessionService = await import("../../../src/app/services/session-service.js"); + await launchAndEndTurn(api, summaryAggregator); + + sessionService.setCurrentSession({ id: "session-2", title: "Other", directory: "D:/repo" }); + await vi.advanceTimersByTimeAsync(30_000); + sessionService.setCurrentSession({ id: "session-1", title: "Test session", directory: "D:/repo" }); + const textsBefore = collectSentTexts(api).length; + await vi.advanceTimersByTimeAsync(120_000); + + expect(collectSentTexts(api)).toHaveLength(textsBefore); + }); + + it("stops background operations when the event stream reconnects", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + await launchAndEndTurn(api, summaryAggregator); + const onReconnect = mocked.subscribeToEvents.mock.calls[0]?.[2] as () => void; + + onReconnect(); + const textsBefore = collectSentTexts(api).length; + await vi.advanceTimersByTimeAsync(60_000); + + expect(collectSentTexts(api)).toHaveLength(textsBefore); + }); + + it("keeps the compact card working past the reply and closes it when the command ends", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + await useCompactMode(); + await launchAndEndTurn(api, summaryAggregator); + + await vi.advanceTimersByTimeAsync(30_000); + const texts = collectSentTexts(api); + expect(texts.some((text) => text.includes("✅ Finished Work"))).toBe(false); + expect(texts.at(-1)).toMatch(/⏳ Working[\s\S]*sleep 90 · 🕒 \d+[ms]/); + + emitBackgroundBash(summaryAggregator, "completed"); + await flushPendingDispatch(); + + const finished = collectSentTexts(api).filter((text) => text.includes("✅ Finished Work")); + expect(finished).toHaveLength(1); + expect(finished[0]).toContain("tool calls: 1"); + }); + + it("keeps the parked compact card through a session error in a later turn", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + await useCompactMode(); + await launchAndEndTurn(api, summaryAggregator); + + summaryAggregator.processEvent({ + type: "session.error", + properties: { sessionID: "session-1", error: { name: "UnknownError", data: { message: "boom" } } }, + } as unknown as Event); + await flushPendingDispatch(); + emitBackgroundBash(summaryAggregator, "completed"); + await flushPendingDispatch(); + + expect(collectSentTexts(api).some((text) => text.includes("✅ Finished Work"))).toBe(true); + }); + }); + it("uses edit streaming for visible thinking content when assistant responses use draft mode", async () => { const { api, summaryAggregator } = await setupService(true, { responseStreamingMode: "draft", @@ -2020,4 +2217,276 @@ describe("bot/services/event-subscription-service", () => { ); expect(hasText(waitingPermissionText)).toBe(false); }); + + describe("a reply above what the agent did after it", () => { + function sentMessageTexts(api: FakeBotApi): string[] { + return api.sendMessage.mock.calls.map((call) => String(call[1])); + } + + function indexOfSent(api: FakeBotApi, fragment: string): number { + return sentMessageTexts(api).findIndex((text) => text.includes(fragment)); + } + + function expectSentInOrder(api: FakeBotApi, fragments: string[]): void { + const indexes = fragments.map((fragment) => indexOfSent(api, fragment)); + for (const index of indexes) { + expect(index).toBeGreaterThanOrEqual(0); + } + expect([...indexes].sort((a, b) => a - b)).toEqual(indexes); + } + + async function dispatch(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + } + } + + function emitCommand( + summaryAggregator: { processEvent(event: Event): void }, + callId: string, + command: string, + ): void { + emitBashTool(summaryAggregator, "running", { callId, command }); + emitBashTool(summaryAggregator, "completed", { callId, command }); + } + + it("opens a new message below a reply already on screen for the next command", async () => { + const { api, summaryAggregator } = await setupService(false); + useTrackerFakeTimers(); + + emitCommand(summaryAggregator, "call-1", "Get-ChildItem"); + await flushPendingDispatch(); + emitAssistantTextPart(summaryAggregator, "First reply"); + await flushPendingDispatch(); + emitCommand(summaryAggregator, "call-2", "Get-Content package.json"); + await flushPendingDispatch(); + + expectSentInOrder(api, ["Get-ChildItem", "First reply", "Get-Content package.json"]); + const texts = collectSentTexts(api); + expect( + texts.some((text) => text.includes("Get-ChildItem") && text.includes("Get-Content")), + ).toBe(false); + }); + + it("sends a reply still waiting on its timer above the command that followed it", async () => { + const { api, summaryAggregator } = await setupService(false); + useTrackerFakeTimers(); + + emitCommand(summaryAggregator, "call-1", "Get-ChildItem"); + await flushPendingDispatch(); + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitCommand(summaryAggregator, "call-2", "Get-Content package.json"); + emitAssistantCompleted(summaryAggregator); + await flushPendingDispatch(); + + expectSentInOrder(api, ["Get-ChildItem", "First reply", "Get-Content package.json"]); + expect(sentMessageTexts(api).filter((text) => text.includes("First reply"))).toHaveLength(1); + }); + + it("keeps an earlier command above the reply when its send is rate-limited", async () => { + const { api, summaryAggregator } = await setupService(false); + useTrackerFakeTimers(); + api.sendMessage.mockRejectedValueOnce( + new Error("Call to 'sendMessage' failed! (429: Too Many Requests: retry after 2)"), + ); + + emitCommand(summaryAggregator, "call-1", "Get-ChildItem"); + await dispatch(); + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitCommand(summaryAggregator, "call-2", "Get-Content package.json"); + await flushPendingDispatch(); + await flushPendingDispatch(); + + expectSentInOrder(api, ["Get-ChildItem", "First reply", "Get-Content package.json"]); + }); + + it("sends a pending reply above the thinking message of the next step", async () => { + const { api, summaryAggregator } = await setupService(false, { showThinkingContent: false }); + useTrackerFakeTimers(); + + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitAssistantMessage(summaryAggregator, "message-2"); + emitThinkingPart(summaryAggregator, "next step", "message-2"); + await flushPendingDispatch(); + + expectSentInOrder(api, ["First reply", "💭 Thinking..."]); + }); + + it("sends a pending reply above a subagent card", async () => { + const { api, summaryAggregator } = await setupService(false); + useTrackerFakeTimers(); + + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitSubagentStart(summaryAggregator); + await dispatch(); + emitAssistantCompleted(summaryAggregator); + await flushPendingDispatch(); + + expectSentInOrder(api, ["First reply", "🧩"]); + }); + + it("sends a pending reply above a tool delivered as a document", async () => { + const { api, summaryAggregator } = await setupService(true); + useTrackerFakeTimers(); + + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitWriteTool(summaryAggregator, "completed"); + emitAssistantCompleted(summaryAggregator); + await flushPendingDispatch(); + + await vi.waitFor(() => expect(api.sendDocument).toHaveBeenCalledTimes(1)); + const replyIndex = indexOfSent(api, "First reply"); + expect(replyIndex).toBeGreaterThanOrEqual(0); + expect(defined(api.sendMessage.mock.invocationCallOrder[replyIndex])).toBeLessThan( + defined(api.sendDocument.mock.invocationCallOrder[0]), + ); + }); + + it("sends a pending reply above a question", async () => { + const { api, summaryAggregator } = await setupService(false); + + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitQuestionAsked(summaryAggregator, "q1"); + + await vi.waitFor(() => { + expect(indexOfSent(api, "Which option for q1?")).toBeGreaterThanOrEqual(0); + }); + expectSentInOrder(api, ["First reply", "Which option for q1?"]); + }); + + it("finishes overlapping documents around a reply's text in the order they happened", async () => { + const { api, summaryAggregator } = await setupService(true); + useTrackerFakeTimers(); + const releases: Array<() => void> = []; + api.sendDocument.mockImplementation( + () => + new Promise((resolve) => { + releases.push(() => resolve({ message_id: 101 })); + }), + ); + + emitWriteTool(summaryAggregator, "completed", "call-write-1"); + await vi.waitFor(() => expect(api.sendDocument).toHaveBeenCalledTimes(1)); + emitBashTool(summaryAggregator, "running", { callId: "call-held", command: "sleep 60" }); + await dispatch(); + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitWriteTool(summaryAggregator, "completed", "call-write-2"); + await dispatch(); + + releases.shift()?.(); + await vi.waitFor(() => expect(api.sendDocument).toHaveBeenCalledTimes(2)); + releases.shift()?.(); + await flushPendingDispatch(); + + const replyIndex = indexOfSent(api, "First reply"); + const heldIndex = indexOfSent(api, "sleep 60"); + expect(heldIndex).toBeGreaterThanOrEqual(0); + expect(heldIndex).toBeLessThan(replyIndex); + const replyOrder = defined(api.sendMessage.mock.invocationCallOrder[replyIndex]); + expect(defined(api.sendDocument.mock.invocationCallOrder[0])).toBeLessThan(replyOrder); + expect(replyOrder).toBeLessThan(defined(api.sendDocument.mock.invocationCallOrder[1])); + }); + + it("sends a reply nothing follows once, at completion, as before", async () => { + const { api, summaryAggregator } = await setupService(false); + useTrackerFakeTimers(); + + emitAssistantTextPart(summaryAggregator, "Only reply"); + await dispatch(); + emitAssistantCompleted(summaryAggregator); + await flushPendingDispatch(); + + expect(sentMessageTexts(api).filter((text) => text.includes("Only reply"))).toHaveLength(1); + expect(api.editMessageText).not.toHaveBeenCalled(); + }); + + describe("compact mode", () => { + async function setupCompact(): Promise>> { + const setup = await setupService(false); + const settingsStore = await import("../../../src/app/stores/settings-store.js"); + settingsStore.setCompactOutputMode(true); + await new Promise((resolve) => setTimeout(resolve, 50)); + useTrackerFakeTimers(); + return setup; + } + + it("closes the card above a reply and opens the next one below it", async () => { + const { api, summaryAggregator } = await setupCompact(); + + emitCommand(summaryAggregator, "call-1", "Get-ChildItem"); + await flushPendingDispatch(); + emitAssistantTextPart(summaryAggregator, "First reply"); + await flushPendingDispatch(); + emitBashTool(summaryAggregator, "running", { callId: "call-2", command: "Get-Content" }); + await flushPendingDispatch(); + emitBashTool(summaryAggregator, "completed", { callId: "call-2", command: "Get-Content" }); + emitAssistantCompleted(summaryAggregator); + await flushPendingDispatch(); + emitAssistantMessage(summaryAggregator, "message-2"); + emitAssistantTextPart(summaryAggregator, "Second reply", "message-2"); + await dispatch(); + emitAssistantCompleted(summaryAggregator, "message-2"); + await flushPendingDispatch(); + + const texts = sentMessageTexts(api); + const cards = texts + .map((text, index) => ({ text, index })) + .filter(({ text }) => text.includes("⏳ Working")); + expect(cards).toHaveLength(2); + const firstReply = indexOfSent(api, "First reply"); + const secondReply = indexOfSent(api, "Second reply"); + expect(defined(cards[0]).index).toBeLessThan(firstReply); + expect(firstReply).toBeLessThan(defined(cards[1]).index); + expect(defined(cards[1]).index).toBeLessThan(secondReply); + expect(defined(cards[1]).text).toContain("Get-Content"); + expect(collectSentTexts(api).filter((text) => text.includes("✅ Finished Work"))).toHaveLength( + 2, + ); + }); + + it("sends a reply deferred behind the card above a question", async () => { + const { api, summaryAggregator } = await setupCompact(); + + emitThinkingPart(summaryAggregator, "planning"); + await dispatch(); + emitAssistantTextPart(summaryAggregator, "First reply"); + await dispatch(); + emitQuestionAsked(summaryAggregator, "q1"); + + await vi.waitFor(() => { + expect(indexOfSent(api, "Which option for q1?")).toBeGreaterThanOrEqual(0); + }); + expectSentInOrder(api, ["⏳ Working", "First reply", "Which option for q1?"]); + }); + + it("keeps going when a reply starts while the card is closing", async () => { + const { api, summaryAggregator } = await setupCompact(); + + emitCommand(summaryAggregator, "call-1", "Get-ChildItem"); + await flushPendingDispatch(); + emitAssistantTextPart(summaryAggregator, "First reply"); + await flushPendingDispatch(); + emitBashTool(summaryAggregator, "running", { callId: "call-2", command: "Get-Content" }); + emitAssistantMessage(summaryAggregator, "message-2"); + emitAssistantTextPart(summaryAggregator, "Second reply", "message-2"); + await flushPendingDispatch(); + emitBashTool(summaryAggregator, "completed", { callId: "call-2", command: "Get-Content" }); + emitAssistantCompleted(summaryAggregator); + emitAssistantCompleted(summaryAggregator, "message-2"); + await flushPendingDispatch(); + + await vi.waitFor(() => { + expect(indexOfSent(api, "Second reply")).toBeGreaterThanOrEqual(0); + }); + expect(indexOfSent(api, "First reply")).toBeLessThan(indexOfSent(api, "Second reply")); + }); + }); + }); }); diff --git a/tests/bot/streaming/compact-progress-streamer.test.ts b/tests/bot/streaming/compact-progress-streamer.test.ts index 46b6c361f..8fbe5d0fe 100644 --- a/tests/bot/streaming/compact-progress-streamer.test.ts +++ b/tests/bot/streaming/compact-progress-streamer.test.ts @@ -483,4 +483,141 @@ describe("bot/streaming/compact-progress-streamer", () => { expect(sendText).toHaveBeenCalledTimes(1); expect(editText).toHaveBeenCalledWith("s1", 90, "⏳ Working\nstill working"); }); + + describe("background operations", () => { + function createStreamer() { + let nextId = 10; + const sendText = vi.fn(async (_sessionId: string, _text: string) => nextId++); + const editText = vi.fn( + async (_sessionId: string, _messageId: number, _text: string) => undefined, + ); + const deleteText = vi.fn(async (_sessionId: string, _messageId: number) => undefined); + const onPark = vi.fn(); + const streamer = new CompactProgressStreamer({ + throttleMs: 0, + sendText, + editText, + deleteText, + onPark, + }); + return { streamer, sendText, editText, deleteText, onPark }; + } + + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it("keeps a card with a running background operation open past its close", async () => { + const { streamer, editText, onPark } = createStreamer(); + + streamer.updateActivity("s1", "bash sleep 90"); + streamer.addBackgroundOperation("s1", "call-bg", "bash sleep 90"); + await tick(); + await streamer.finalize("s1"); + streamer.updateBackgroundOperation("s1", "call-bg", "bash sleep 90 · 🕒 30s"); + await tick(); + + expect(onPark).toHaveBeenCalledWith("s1", ["call-bg"]); + expect(editText).toHaveBeenLastCalledWith("s1", 10, "⏳ Working\nbash sleep 90 · 🕒 30s"); + expect(editText.mock.calls.some((call) => call[2].includes("Finished"))).toBe(false); + }); + + it("opens a new card for the next stretch while the parked one stays", async () => { + const { streamer, sendText, editText } = createStreamer(); + + streamer.addBackgroundOperation("s1", "call-bg", "bash sleep 90"); + streamer.updateActivity("s1", "bash sleep 90"); + await tick(); + await streamer.finalize("s1"); + streamer.updateActivity("s1", "💭 Thinking..."); + await tick(); + await streamer.finalize("s1"); + + expect(sendText).toHaveBeenCalledTimes(2); + expect(editText).toHaveBeenCalledWith( + "s1", + 11, + "✅ Finished Work\ntool calls: 0 · changed files: 0", + ); + expect(editText.mock.calls.some((call) => call[1] === 10 && call[2].includes("Finished"))).toBe( + false, + ); + }); + + it("closes the parked card with its own counts when the operation ends", async () => { + const { streamer, editText } = createStreamer(); + + streamer.updateActivity("s1", "bash sleep 90"); + streamer.addToolCall("s1", "call-read"); + streamer.addBackgroundOperation("s1", "call-bg", "bash sleep 90"); + await tick(); + await streamer.finalize("s1"); + await streamer.endBackgroundOperation("s1", "call-bg"); + + expect(editText).toHaveBeenLastCalledWith( + "s1", + 10, + "✅ Finished Work\ntool calls: 2 · changed files: 0", + ); + }); + + it("deletes the parked card when the operation ends with delete on finish", async () => { + const { streamer, deleteText } = createStreamer(); + + streamer.addBackgroundOperation("s1", "call-bg", "bash sleep 90"); + streamer.updateActivity("s1", "bash sleep 90"); + await tick(); + await streamer.finalize("s1", true); + expect(deleteText).not.toHaveBeenCalled(); + + await streamer.endBackgroundOperation("s1", "call-bg", true); + expect(deleteText).toHaveBeenCalledWith("s1", 10); + }); + + it("falls back to the operation still running when the newest one ends", async () => { + const { streamer, editText } = createStreamer(); + + streamer.updateActivity("s1", "second"); + streamer.addBackgroundOperation("s1", "call-1", "first"); + streamer.addBackgroundOperation("s1", "call-2", "second"); + await tick(); + await streamer.finalize("s1"); + await streamer.endBackgroundOperation("s1", "call-2"); + await tick(); + + expect(editText).toHaveBeenLastCalledWith("s1", 10, "⏳ Working\nfirst"); + }); + + it("leaves a parked card as it is once its operations are dropped", async () => { + const { streamer, editText } = createStreamer(); + + streamer.updateActivity("s1", "bash sleep 90"); + streamer.addBackgroundOperation("s1", "call-bg", "bash sleep 90"); + await tick(); + await streamer.finalize("s1"); + await tick(); + const editsBefore = editText.mock.calls.length; + streamer.dropBackgroundOperations("s1"); + streamer.updateBackgroundOperation("s1", "call-bg", "bash sleep 90 · 🕒 1m"); + await streamer.endBackgroundOperation("s1", "call-bg"); + + expect(editText).toHaveBeenCalledTimes(editsBefore); + }); + + it("keeps parked cards when only the open card is discarded", async () => { + const { streamer, editText } = createStreamer(); + + streamer.updateActivity("s1", "bash sleep 90"); + streamer.addBackgroundOperation("s1", "call-bg", "bash sleep 90"); + await tick(); + await streamer.finalize("s1"); + streamer.updateActivity("s1", "next"); + streamer.discardOpenCard("s1", "session_error"); + await streamer.endBackgroundOperation("s1", "call-bg"); + + expect(editText).toHaveBeenLastCalledWith( + "s1", + 10, + "✅ Finished Work\ntool calls: 1 · changed files: 0", + ); + }); + }); }); diff --git a/tests/bot/streaming/response-streamer.test.ts b/tests/bot/streaming/response-streamer.test.ts index e6b06b6b0..1f03f3281 100644 --- a/tests/bot/streaming/response-streamer.test.ts +++ b/tests/bot/streaming/response-streamer.test.ts @@ -879,4 +879,65 @@ describe("bot/streaming/response-streamer", () => { expect(editPart).toHaveBeenCalledTimes(1); expect(editPart).toHaveBeenCalledWith(1, plainPart("second"), undefined, "s1"); }); + + describe("flushPending", () => { + function createStreamer() { + let nextMessageId = 1; + const sendPart = vi.fn(async (part: TelegramRenderedPart) => ({ + messageId: nextMessageId++, + deliveredSignature: signature(part), + })); + const editPart = vi.fn(async (_messageId: number, part: TelegramRenderedPart) => ({ + deliveredSignature: signature(part), + })); + const streamer = new ResponseStreamer({ + throttleMs: 5000, + sendPart, + editPart, + deleteText: vi.fn().mockResolvedValue(undefined), + }); + return { streamer, sendPart, editPart }; + } + + it("sends a stream with nothing on screen yet without waiting for its timer", async () => { + vi.useFakeTimers(); + const { streamer, sendPart } = createStreamer(); + + streamer.enqueue("s1", "m1", { parts: [plainPart("reply")] }); + await streamer.flushPending("s1", "m1"); + + expect(sendPart).toHaveBeenCalledTimes(1); + expect(sendPart).toHaveBeenCalledWith(plainPart("reply"), undefined, "s1"); + + await vi.advanceTimersByTimeAsync(5000); + expect(sendPart).toHaveBeenCalledTimes(1); + }); + + it("leaves a stream already on screen to its timer", async () => { + vi.useFakeTimers(); + const { streamer, sendPart, editPart } = createStreamer(); + + streamer.enqueue("s1", "m1", { parts: [plainPart("reply")] }); + await vi.advanceTimersByTimeAsync(5000); + streamer.enqueue("s1", "m1", { parts: [plainPart("reply grows")] }); + await streamer.flushPending("s1", "m1"); + + expect(sendPart).toHaveBeenCalledTimes(1); + expect(editPart).not.toHaveBeenCalled(); + }); + + it("does nothing for another reply or a broken stream", async () => { + vi.useFakeTimers(); + const { streamer, sendPart } = createStreamer(); + sendPart.mockRejectedValueOnce(new Error("Bad Request: chat not found")); + + streamer.enqueue("s1", "m1", { parts: [plainPart("reply")] }); + await streamer.flushPending("s1", "m2"); + expect(sendPart).not.toHaveBeenCalled(); + + await streamer.flushPending("s1", "m1"); + await streamer.flushPending("s1", "m1"); + expect(sendPart).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/tests/bot/streaming/running-tool-tracker.test.ts b/tests/bot/streaming/running-tool-tracker.test.ts index 8aa79a05f..17257a861 100644 --- a/tests/bot/streaming/running-tool-tracker.test.ts +++ b/tests/bot/streaming/running-tool-tracker.test.ts @@ -180,6 +180,51 @@ describe("bot/streaming/running-tool-tracker", () => { expect(ticks.map((tick) => tick.callId)).toEqual(["call-2"]); }); + it("keeps a background call ticking through a turn-level clear", async () => { + vi.useFakeTimers(); + const { tracker, ticks } = createTracker(); + + tracker.track("s1", "call-fg"); + tracker.track("s1", "call-bg", true); + tracker.clearSession("s1", "test", true); + + await vi.advanceTimersByTimeAsync(20 * SECOND); + + expect(ticks.map((tick) => tick.callId)).toEqual(["call-bg"]); + expect(tracker.backgroundCallIds("s1")).toEqual(["call-bg"]); + }); + + it("drops a background call on a full session clear", () => { + const { tracker } = createTracker(); + + tracker.track("s1", "call-bg", true); + tracker.clearSession("s1", "test"); + + expect(tracker.backgroundCallIds()).toEqual([]); + }); + + it("learns that a call is background from a later running update", () => { + const { tracker } = createTracker(); + + tracker.track("s1", "call-1"); + tracker.track("s1", "call-1", true); + + expect(tracker.isBackground("call-1")).toBe(true); + }); + + it("leaves a detached call out of what the session runs now but keeps it ticking", async () => { + vi.useFakeTimers(); + const { tracker, ticks } = createTracker(); + + tracker.track("s1", "call-bg", true); + tracker.detach(["call-bg"]); + + expect(tracker.newestCallId("s1")).toBeUndefined(); + expect(tracker.isDetached("call-bg")).toBe(true); + await vi.advanceTimersByTimeAsync(20 * SECOND); + expect(ticks.map((tick) => tick.callId)).toEqual(["call-bg"]); + }); + it("returns undefined from newestCallId when the session has no calls", () => { const { tracker } = createTracker(); diff --git a/tests/bot/streaming/tool-call-streamer.test.ts b/tests/bot/streaming/tool-call-streamer.test.ts index 65cd85ab6..8b1b578a7 100644 --- a/tests/bot/streaming/tool-call-streamer.test.ts +++ b/tests/bot/streaming/tool-call-streamer.test.ts @@ -662,6 +662,91 @@ describe("bot/streaming/tool-call-streamer", () => { expect(sendText).toHaveBeenNthCalledWith(2, "s1", "after break"); }); + describe("pinned entries", () => { + function createStreamer() { + let id = 1; + const sendText = vi.fn(async (_sessionId: string, _text: string) => id++); + const editText = vi.fn( + async (_sessionId: string, _messageId: number, _text: string) => undefined, + ); + const streamer = new ToolCallStreamer({ + throttleMs: 0, + sendText, + editText, + deleteText: vi.fn().mockResolvedValue(undefined), + }); + return { streamer, sendText, editText }; + } + + it("keeps editing a pinned line in its own message after the turn ends", async () => { + vi.useFakeTimers(); + const { streamer, sendText, editText } = createStreamer(); + + streamer.replaceByPrefix("s1", "⏳bg", "⏳ 💻 bash sleep 90"); + streamer.pinEntry("s1", "⏳bg"); + await vi.waitFor(() => expect(sendText).toHaveBeenCalledTimes(1)); + await streamer.breakSession("s1", "session_idle"); + + streamer.replaceByPrefix("s1", "⏳bg", "⏳ 💻 bash sleep 90 · 🕒 30s"); + await vi.waitFor(() => expect(editText).toHaveBeenCalledTimes(1)); + streamer.replaceByPrefix("s1", "⏳bg", "💻 bash sleep 90 · 🕒 1m 30s"); + await vi.waitFor(() => expect(editText).toHaveBeenCalledTimes(2)); + + expect(editText).toHaveBeenLastCalledWith("s1", 1, "💻 bash sleep 90 · 🕒 1m 30s"); + expect(sendText).toHaveBeenCalledTimes(1); + }); + + it("opens a new message for output that comes after the break", async () => { + vi.useFakeTimers(); + const { streamer, sendText } = createStreamer(); + + streamer.replaceByPrefix("s1", "⏳bg", "⏳ bg"); + streamer.pinEntry("s1", "⏳bg"); + await vi.waitFor(() => expect(sendText).toHaveBeenCalledTimes(1)); + await streamer.breakSession("s1", "assistant_message_completed"); + + streamer.replaceByPrefix("s1", "⏳next", "⏳ next"); + await vi.waitFor(() => expect(sendText).toHaveBeenCalledTimes(2)); + expect(sendText).toHaveBeenLastCalledWith("s1", "⏳ next"); + }); + + it("lets go of the message at the next break once the line is unpinned", async () => { + vi.useFakeTimers(); + const { streamer, sendText, editText } = createStreamer(); + + streamer.replaceByPrefix("s1", "⏳bg", "⏳ bg"); + streamer.pinEntry("s1", "⏳bg"); + await vi.waitFor(() => expect(sendText).toHaveBeenCalledTimes(1)); + await streamer.breakSession("s1", "session_idle"); + streamer.replaceByPrefix("s1", "⏳bg", "bg done"); + streamer.unpinEntry("s1", "⏳bg"); + await vi.waitFor(() => expect(editText).toHaveBeenCalledTimes(1)); + await streamer.breakSession("s1", "session_idle"); + + streamer.replaceByPrefix("s1", "⏳bg", "bg changed again"); + await vi.advanceTimersByTimeAsync(500); + expect(sendText).toHaveBeenCalledTimes(2); + expect(sendText).toHaveBeenLastCalledWith("s1", "bg changed again"); + }); + + it("ignores later updates of a pinned line once it is frozen", async () => { + vi.useFakeTimers(); + const { streamer, sendText, editText } = createStreamer(); + + streamer.replaceByPrefix("s1", "⏳bg", "⏳ bg · 🕒 30s"); + streamer.pinEntry("s1", "⏳bg"); + await vi.waitFor(() => expect(sendText).toHaveBeenCalledTimes(1)); + await streamer.breakSession("s1", "session_idle"); + streamer.freezePinnedEntries("s1"); + + streamer.replaceByPrefix("s1", "⏳bg", "bg · 🕒 1m 30s"); + await vi.advanceTimersByTimeAsync(500); + + expect(editText).not.toHaveBeenCalled(); + expect(sendText).toHaveBeenCalledTimes(1); + }); + }); + it("reads throttleMs again for the next flush cycle", async () => { vi.useFakeTimers(); @@ -690,4 +775,117 @@ describe("bot/streaming/tool-call-streamer", () => { expect(editText).toHaveBeenCalledTimes(1); expect(editText).toHaveBeenCalledWith("s1", 1, "first\n\nsecond"); }); + + describe("gate", () => { + it("waits for its gate before opening a new message, not before editing it", async () => { + vi.useFakeTimers(); + const sendText = vi.fn().mockResolvedValue(10); + const editText = vi.fn().mockResolvedValue(undefined); + let openGate: () => void = () => {}; + const gate = vi.fn( + () => + new Promise((resolve) => { + openGate = resolve; + }), + ); + const streamer = new ToolCallStreamer({ + throttleMs: 100, + sendText, + editText, + deleteText: vi.fn().mockResolvedValue(undefined), + takeGate: () => gate, + }); + + streamer.append("s1", "first"); + await vi.advanceTimersByTimeAsync(100); + expect(gate).toHaveBeenCalledTimes(1); + expect(sendText).not.toHaveBeenCalled(); + + openGate(); + await vi.waitFor(() => { + expect(sendText).toHaveBeenCalledWith("s1", "first"); + }); + + gate.mockImplementation(() => new Promise(() => {})); + streamer.append("s1", "second"); + await vi.advanceTimersByTimeAsync(100); + expect(editText).toHaveBeenCalledWith("s1", 10, "first\n\nsecond"); + expect(gate).toHaveBeenCalledTimes(1); + }); + + it("does not wait for its gate while the stream is broken", async () => { + vi.useFakeTimers(); + const sendText = vi.fn().mockResolvedValue(10); + const gate = vi.fn(() => new Promise(() => {})); + const streamer = new ToolCallStreamer({ + throttleMs: 100, + sendText, + editText: vi.fn().mockResolvedValue(undefined), + deleteText: vi.fn().mockResolvedValue(undefined), + takeGate: () => gate, + }); + + streamer.append("s1", "line"); + await streamer.breakSession("s1", "test_break"); + + expect(gate).not.toHaveBeenCalled(); + expect(sendText).toHaveBeenCalledWith("s1", "line"); + }); + + it("takes the gate when a stream starts", async () => { + vi.useFakeTimers(); + const takeGate = vi.fn().mockReturnValue(undefined); + const streamer = new ToolCallStreamer({ + throttleMs: 100, + sendText: vi.fn().mockResolvedValue(10), + editText: vi.fn().mockResolvedValue(undefined), + deleteText: vi.fn().mockResolvedValue(undefined), + takeGate, + }); + + streamer.append("s1", "first"); + streamer.append("s1", "second"); + expect(takeGate).toHaveBeenCalledTimes(1); + expect(streamer.hasUnsentMessages("s1")).toBe(true); + + await vi.advanceTimersByTimeAsync(100); + expect(streamer.hasUnsentMessages("s1")).toBe(false); + }); + + it("stops every stream of a session taking lines as soon as a break starts", async () => { + vi.useFakeTimers(); + let releaseFirst: () => void = () => {}; + const sendText = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = () => resolve(10); + }), + ) + .mockResolvedValue(11); + const streamer = new ToolCallStreamer({ + throttleMs: 100, + sendText, + editText: vi.fn().mockResolvedValue(undefined), + deleteText: vi.fn().mockResolvedValue(undefined), + }); + + streamer.append("s1", "default line"); + streamer.append("s1", "todo line", "todo"); + const broken = streamer.breakSession("s1", "test_break"); + streamer.append("s1", "later todo line", "todo"); + await vi.waitFor(() => { + expect(sendText).toHaveBeenCalledTimes(1); + }); + releaseFirst(); + await vi.advanceTimersByTimeAsync(1000); + await broken; + await vi.advanceTimersByTimeAsync(1000); + + const sentTexts = sendText.mock.calls.map((call) => call[1]); + expect(sentTexts).toContain("todo line"); + expect(sentTexts).toContain("later todo line"); + }); + }); }); diff --git a/tests/helpers/reset-singleton-state.ts b/tests/helpers/reset-singleton-state.ts index f0276a19f..d34c091f4 100644 --- a/tests/helpers/reset-singleton-state.ts +++ b/tests/helpers/reset-singleton-state.ts @@ -11,6 +11,7 @@ export async function resetSingletonState(): Promise { { __resetServerHealthStateForTests }, modelSelectionModule, readyRefreshModule, + configReloadModule, loggerModule, ] = await Promise.all([ import("../../src/opencode/events.js"), @@ -24,6 +25,7 @@ export async function resetSingletonState(): Promise { import("../../src/opencode/server-health.js"), import("../../src/app/services/model-selection-service.js"), import("../../src/opencode/ready-refresh.js"), + import("../../src/app/services/config-reload-service.js"), import("../../src/utils/logger.js"), ]); @@ -52,6 +54,13 @@ export async function resetSingletonState(): Promise { readyRefreshModule.__resetReadyRefreshForTests(); } + if ( + "__resetConfigReloadForTests" in configReloadModule && + typeof configReloadModule.__resetConfigReloadForTests === "function" + ) { + configReloadModule.__resetConfigReloadForTests(); + } + if ( "__resetLoggerForTests" in loggerModule && typeof loggerModule.__resetLoggerForTests === "function" diff --git a/tests/opencode/ready-refresh.test.ts b/tests/opencode/ready-refresh.test.ts index 1b68a06f0..74ee45fe3 100644 --- a/tests/opencode/ready-refresh.test.ts +++ b/tests/opencode/ready-refresh.test.ts @@ -41,6 +41,7 @@ vi.mock("../../src/utils/logger.js", () => ({ import { OpencodeReadyLifecycle } from "../../src/opencode/ready-lifecycle.js"; import { __resetReadyRefreshForTests, + refreshModelCatalogAfterConfigReload, refreshSessionCacheAfterOpencodeReady, refreshSessionCacheIfOpencodeReady, stopModelCatalogWait, @@ -150,6 +151,16 @@ describe("opencode/ready-refresh", () => { expect(mocked.startModelCatalogWarmupMock).toHaveBeenCalledTimes(1); }); + it("reloads the model catalog through the warm-up window without the session cache", async () => { + await refreshModelCatalogAfterConfigReload(); + + expect(mocked.startModelCatalogWarmupMock).toHaveBeenCalledTimes(1); + expect(mocked.reconcileStoredModelSelectionMock).toHaveBeenCalledWith({ + forceCatalogRefresh: true, + }); + expect(mocked.warmupSessionDirectoryCacheMock).not.toHaveBeenCalled(); + }); + it("logs refresh failures without throwing", async () => { mocked.warmupSessionDirectoryCacheMock.mockRejectedValueOnce(new Error("refresh failed")); diff --git a/tests/opencode/v2/client.test.ts b/tests/opencode/v2/client.test.ts index c8afa511a..aa2512d89 100644 --- a/tests/opencode/v2/client.test.ts +++ b/tests/opencode/v2/client.test.ts @@ -23,6 +23,7 @@ const fake = vi.hoisted(() => ({ form: { list: vi.fn() }, permission: { request: { list: vi.fn() }, reply: vi.fn() }, event: { subscribe: vi.fn() }, + location: { reload: vi.fn() }, }, })); @@ -222,6 +223,26 @@ describe("opencode/v2/client", () => { }); }); + it("reloads the server configuration", async () => { + fake.client.location.reload.mockResolvedValue(undefined); + const client = createClient() as unknown as V2ClientExtension; + + const result = await client.location.reload(); + + expect(result).toEqual({ data: true, error: undefined }); + expect(fake.client.location.reload).toHaveBeenCalledOnce(); + }); + + it("reports a rejected reload as the call's error", async () => { + const error = new Error("Invalid config"); + fake.client.location.reload.mockRejectedValue(error); + const client = createClient() as unknown as V2ClientExtension; + + const result = await client.location.reload(); + + expect(result).toEqual({ data: undefined, error }); + }); + it("reports a refused admission as the call's error", async () => { fake.client.session.command.mockRejectedValue( Object.assign(new Error("Command not found: nope"), { name: "CommandNotFoundError" }), diff --git a/tests/opencode/v2/events.test.ts b/tests/opencode/v2/events.test.ts index 8be31a83d..d344b0ccb 100644 --- a/tests/opencode/v2/events.test.ts +++ b/tests/opencode/v2/events.test.ts @@ -21,6 +21,30 @@ function payloads(translate: ReturnType, events: } describe("opencode/v2/events", () => { + it("marks the idle of an interrupted execution and only that one", () => { + const translate = createV2EventTranslator(); + + const [interrupted, succeeded, failed] = [ + "session.execution.interrupted", + "session.execution.succeeded", + "session.execution.failed", + ].map((type) => + payloads(translate, [ + event( + type, + type === "session.execution.failed" + ? { sessionID: SESSION, error: { type: "unknown", message: "boom" } } + : { sessionID: SESSION }, + false, + ), + ]).find((item) => item.type === "session.idle"), + ); + + expect(interrupted?.properties).toEqual({ sessionID: SESSION, interrupted: true }); + expect(succeeded?.properties).toEqual({ sessionID: SESSION }); + expect(failed?.properties).toEqual({ sessionID: SESSION }); + }); + it("turns a streamed V2 reply into the V1 message and part events", () => { const translate = createV2EventTranslator(); @@ -236,4 +260,155 @@ describe("opencode/v2/events", () => { expect(envelope?.payload).toMatchObject({ properties: { part: { tool: "unknown" } } }); }); + + describe("background operations", () => { + const base = { sessionID: SESSION, assistantMessageID: MESSAGE, id: "call-bg" }; + + function toolStates(result: ReturnType) { + return result + .filter((item) => item.type === "message.part.updated") + .map((item) => (item.properties as { part: { state: Record } }).part.state); + } + + function launchShell(translate: ReturnType) { + return payloads(translate, [ + event("session.tool.input.started", { ...base, name: "shell" }), + event("session.tool.called", { + ...base, + input: { command: "sleep 90", background: true }, + executed: false, + }), + event("session.tool.success", { + ...base, + content: [{ type: "text", text: "Command moved to the background" }], + metadata: { shellID: "sh-1", status: "running" }, + executed: false, + }), + ]); + } + + it("keeps a background command running when V2 reports it done at launch", () => { + const translate = createV2EventTranslator(); + + const states = toolStates(launchShell(translate)); + + expect(states.map((state) => state.status)).toEqual(["pending", "running", "running"]); + expect(states[2]).toMatchObject({ + input: { command: "sleep 90", background: true }, + metadata: { shellID: "sh-1", status: "running" }, + }); + }); + + it("completes the background command when its shell exits", () => { + const translate = createV2EventTranslator(); + launchShell(translate); + + const result = payloads(translate, [ + event("shell.exited", { id: "sh-other", exit: 0, status: "exited" }), + event("shell.exited", { id: "sh-1", exit: 0, status: "exited" }), + event("shell.exited", { id: "sh-1", exit: 0, status: "exited" }), + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + properties: { + part: { + callID: "call-bg", + tool: "bash", + sessionID: SESSION, + messageID: MESSAGE, + state: { + status: "completed", + output: "Command moved to the background", + metadata: { shellID: "sh-1", status: "exited", exit: 0 }, + time: { start: 1000, end: 1000 }, + }, + }, + }, + }); + }); + + it("leaves a command that finished before its success as an ordinary completion", () => { + const translate = createV2EventTranslator(); + + const result = payloads(translate, [ + event("session.tool.input.started", { ...base, name: "shell" }), + event("session.tool.success", { + ...base, + content: [], + metadata: { shellID: "sh-1", status: "completed" }, + executed: false, + }), + ]); + + expect(toolStates(result).map((state) => state.status)).toEqual(["pending", "completed"]); + }); + + function launchSubagent(translate: ReturnType) { + return payloads(translate, [ + event("session.tool.input.started", { ...base, name: "subagent" }), + event("session.tool.called", { + ...base, + input: { description: "Scan", agent: "general", background: true }, + executed: false, + }), + event("session.tool.success", { + ...base, + content: [{ type: "text", text: "The subagent is working in the background" }], + metadata: { sessionID: "child-1", status: "running" }, + executed: false, + }), + ]); + } + + it("keeps a background subagent's task running and names its child session", () => { + const translate = createV2EventTranslator(); + + const states = toolStates(launchSubagent(translate)); + + expect(states[2]).toMatchObject({ + status: "running", + input: { background: true }, + metadata: { sessionID: "child-1", sessionId: "child-1" }, + }); + }); + + it("completes the parent's task after the child session ends", () => { + const translate = createV2EventTranslator(); + launchSubagent(translate); + + const result = payloads(translate, [ + event("session.execution.succeeded", { sessionID: "child-1" }, false), + ]); + + expect(result.map((item) => item.type)).toEqual([ + "session.status", + "session.idle", + "message.part.updated", + ]); + expect(result[1]).toMatchObject({ properties: { sessionID: "child-1" } }); + expect(result[2]).toMatchObject({ + properties: { + part: { callID: "call-bg", tool: "task", sessionID: SESSION, state: { status: "completed" } }, + }, + }); + }); + + it("fails the parent's task when the child session fails", () => { + const translate = createV2EventTranslator(); + launchSubagent(translate); + + const result = payloads(translate, [ + event( + "session.execution.failed", + { sessionID: "child-1", error: { type: "unknown", message: "boom" } }, + false, + ), + ]); + + expect(result.at(-1)).toMatchObject({ + properties: { part: { callID: "call-bg", state: { status: "error", error: "boom" } } }, + }); + }); + }); }); diff --git a/tests/opencode/v2/mappers.test.ts b/tests/opencode/v2/mappers.test.ts index 4e19f09dc..4856e010c 100644 --- a/tests/opencode/v2/mappers.test.ts +++ b/tests/opencode/v2/mappers.test.ts @@ -73,6 +73,77 @@ describe("opencode/v2/mappers", () => { expect(request.questions[4]?.question).toContain("https://example.com/auth"); }); + it("carries each choice's value and states whether a custom answer is accepted", () => { + const form = createForm([ + { + key: "provider", + type: "string", + custom: false, + options: [ + { value: "allow", label: "Allow search via Exa", description: "Default" }, + { value: "disable", label: "Disable web search" }, + ], + }, + { key: "color", type: "string", options: [{ value: "r", label: "Red" }] }, + { + key: "tags", + type: "multiselect", + custom: true, + options: [{ value: "a", label: "A" }], + }, + { key: "ok", type: "boolean" }, + { key: "note", type: "string" }, + ]); + + const [provider, color, tags, ok, note] = toV1Question(form).questions; + + expect(provider?.options).toEqual([ + { label: "Allow search via Exa", description: "Default", value: "allow" }, + { label: "Disable web search", description: "", value: "disable" }, + ]); + expect(provider?.custom).toBe(false); + expect(color?.custom).toBe(false); + expect(tags?.custom).toBe(true); + expect(tags?.options).toEqual([{ label: "A", description: "", value: "a" }]); + expect(ok?.options).toEqual([ + { label: "true", description: "", value: "true" }, + { label: "false", description: "", value: "false" }, + ]); + expect(ok?.custom).toBe(false); + expect(note).not.toHaveProperty("custom"); + }); + + it("converts chosen values, including one that is another choice's label", () => { + const form = createForm([ + { + key: "pick", + type: "string", + options: [ + { value: "b", label: "a" }, + { value: "a", label: "b" }, + ], + }, + { + key: "tags", + type: "multiselect", + custom: true, + options: [ + { value: "x", label: "X" }, + { value: "y", label: "Y" }, + ], + }, + { key: "yes", type: "boolean" }, + { key: "no", type: "boolean" }, + ]); + + expect(toFormAnswer(form, [["a"], ["x", "y", "typed"], ["true"], ["false"]])).toEqual({ + pick: "a", + tags: ["x", "y", "typed"], + yes: true, + no: false, + }); + }); + it("converts the chosen labels and typed answers back into form values", () => { const form = createForm([ {