From 7e4c5f857770a3ec1f0446b1c8b3efe6deb50360 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Wed, 30 Sep 2026 18:14:12 +0300 Subject: [PATCH 1/6] fix(OTB-141): show apply_patch as one line and one document per changed file, never the patch Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 2 +- src/app/formatters/duration-formatter.ts | 10 + src/app/formatters/summary-formatter.ts | 123 ++++++++-- .../managers/summary-aggregation-manager.ts | 116 ++++++---- .../events/handlers/tool-activity-handler.ts | 48 ++-- src/bot/pinned/pinned-message-manager.ts | 20 +- src/opencode/v2/events.ts | 10 +- src/opencode/v2/mappers.ts | 50 ++++- .../app/formatters/duration-formatter.test.ts | 6 + .../summary-aggregation-manager.test.ts | 210 +++++++++++++++++- .../summary-message-formatter.test.ts | 122 ++++++++++ .../bot/pinned/pinned-message-manager.test.ts | 55 +++++ tests/opencode/v2/events.test.ts | 73 ++++++ tests/opencode/v2/mappers.test.ts | 74 ++++++ 14 files changed, 831 insertions(+), 88 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index 45c0ce4cf..c81221c8b 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -75,7 +75,7 @@ No public inbound ports are required for normal usage. - 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. 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 +- 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 file-changing tool (`edit`, `write`, `apply_patch`) names each file it changed on its own line with `(+N -M)`, never the patch text, on both OpenCode versions; with diff-file attachments on, each file arrives as its own document captioned with its line, and a file whose diff is over the size limit keeps its text line. 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 diff --git a/src/app/formatters/duration-formatter.ts b/src/app/formatters/duration-formatter.ts index e15392e2e..19e4d1157 100644 --- a/src/app/formatters/duration-formatter.ts +++ b/src/app/formatters/duration-formatter.ts @@ -25,6 +25,16 @@ export function appendDuration(text: string, elapsed: string): string { return `${text} · ${DURATION_ICON} ${elapsed}`; } +/** Same as appendDuration, on the first line of a message that lists several. */ +export function appendDurationToFirstLine(text: string, elapsed: string): string { + const lineEnd = text.indexOf("\n"); + if (lineEnd < 0) { + return appendDuration(text, elapsed); + } + + return `${appendDuration(text.slice(0, lineEnd), elapsed)}${text.slice(lineEnd)}`; +} + function floorTo(value: number, step: number): number { return Math.floor(value / step) * step; } diff --git a/src/app/formatters/summary-formatter.ts b/src/app/formatters/summary-formatter.ts index 8f37e8843..b623ba62b 100644 --- a/src/app/formatters/summary-formatter.ts +++ b/src/app/formatters/summary-formatter.ts @@ -58,10 +58,12 @@ function getToolDetails(tool: string, input?: { [key: string]: unknown }): strin case "read": case "edit": case "write": - case "apply_patch": const filePath = input.path || input.filePath; if (typeof filePath === "string") return normalizePathForDisplay(filePath); break; + case "apply_patch": + // Only a file path names a patch: its other input is the patch text itself + return getFirstPatchFileFromInput(input); case "bash": if (typeof input.command === "string") return input.command; break; @@ -184,6 +186,93 @@ function countDiffChangesFromText(text: string): { additions: number; deletions: return { additions, deletions }; } +function extractFirstFileFromPatchText(patchText: string): string { + const match = /^\*\*\* (?:Add|Update|Delete) File:[ \t]*(.+?)\s*$/m.exec(patchText); + return match?.[1] ?? ""; +} + +function getFirstPatchFileFromInput(input: { [key: string]: unknown }): string { + const filePath = input.path || input.filePath; + if (typeof filePath === "string" && filePath) { + return normalizePathForDisplay(filePath); + } + + const fileFromPatch = + typeof input.patchText === "string" ? extractFirstFileFromPatchText(input.patchText) : ""; + return fileFromPatch ? normalizePathForDisplay(fileFromPatch) : ""; +} + +export interface PatchFileChange { + path: string; + additions: number; + deletions: number; + diff: string; +} + +/** Every file a finished apply_patch changed, from the per-file list OpenCode reports. */ +export function getPatchFileChanges( + metadata: { [key: string]: unknown } | undefined, +): PatchFileChange[] { + if (!metadata || !Array.isArray(metadata.files)) { + return []; + } + + const changes: PatchFileChange[] = []; + for (const entry of metadata.files) { + if (!isRecord(entry)) { + continue; + } + + const rawPath = [entry.movePath, entry.relativePath, entry.filePath].find( + (value): value is string => typeof value === "string" && value.length > 0, + ); + if (!rawPath) { + continue; + } + + // V1 versions keep a file's own diff under either key + const diff = + typeof entry.diff === "string" + ? entry.diff + : typeof entry.patch === "string" + ? entry.patch + : ""; + const counts = diff + ? countDiffChangesFromText(diff) + : { + additions: typeof entry.additions === "number" ? entry.additions : 0, + deletions: typeof entry.deletions === "number" ? entry.deletions : 0, + }; + + changes.push({ path: normalizePathForDisplay(rawPath), ...counts, diff }); + } + + return changes; +} + +export function formatPatchFileLine(change: PatchFileChange): string { + return `${getToolIcon("apply_patch")} apply_patch ${change.path}${formatDiffLineInfo(change)}`; +} + +function getFirstPatchFile(toolInfo: ToolInfo): string { + const [firstChange] = getPatchFileChanges(toolInfo.metadata); + if (firstChange) { + return firstChange.path; + } + + const filediff = isRecord(toolInfo.metadata?.filediff) ? toolInfo.metadata.filediff : undefined; + if (typeof filediff?.file === "string" && filediff.file) { + return normalizePathForDisplay(filediff.file); + } + + const fileFromTitle = toolInfo.title ? extractFirstUpdatedFileFromTitle(toolInfo.title) : ""; + if (fileFromTitle) { + return normalizePathForDisplay(fileFromTitle); + } + + return toolInfo.input ? getFirstPatchFileFromInput(toolInfo.input) : ""; +} + function extractFirstUpdatedFileFromTitle(title: string): string { for (const rawLine of title.split("\n")) { const line = rawLine.trim(); @@ -215,6 +304,15 @@ export function formatToolInfo(toolInfo: ToolInfo): string | null { return `${toolIcon} ${tool} (${todos.length})\n\n${todosList}`; } + const isCompleted = "status" in toolInfo.state && toolInfo.state.status === "completed"; + + if (tool === "apply_patch" && isCompleted) { + const changes = getPatchFileChanges(toolInfo.metadata); + if (changes.length > 0) { + return changes.map(formatPatchFileLine).join("\n"); + } + } + let details = title || getToolDetails(tool, input); const toolIcon = getToolIcon(tool); @@ -228,15 +326,7 @@ export function formatToolInfo(toolInfo: ToolInfo): string | null { } if (tool === "apply_patch") { - const filediff = isRecord(toolInfo.metadata?.filediff) ? toolInfo.metadata.filediff : undefined; - if (typeof filediff?.file === "string" && filediff.file) { - details = normalizePathForDisplay(filediff.file); - } else if (title) { - const fileFromTitle = extractFirstUpdatedFileFromTitle(title); - if (fileFromTitle) { - details = normalizePathForDisplay(fileFromTitle); - } - } + details = getFirstPatchFile(toolInfo); } const detailsStr = details ? ` ${details}` : ""; @@ -247,8 +337,9 @@ export function formatToolInfo(toolInfo: ToolInfo): string | null { lineInfo = ` (+${lines})`; } + // A patch still running or failed names its first file only, without counts if ( - (tool === "edit" || tool === "apply_patch") && + (tool === "edit" || (tool === "apply_patch" && isCompleted)) && toolInfo.metadata && "filediff" in toolInfo.metadata ) { @@ -262,7 +353,7 @@ export function formatToolInfo(toolInfo: ToolInfo): string | null { }); } - if (tool === "apply_patch" && !lineInfo) { + if (tool === "apply_patch" && isCompleted && !lineInfo) { const diffText = toolInfo.metadata && typeof toolInfo.metadata.diff === "string" ? toolInfo.metadata.diff @@ -280,7 +371,9 @@ export function formatToolInfo(toolInfo: ToolInfo): string | null { export function formatCompactToolInfo(toolInfo: ToolInfo, maxLength = 64, fallback = "-"): string { const formatted = formatToolInfo(toolInfo); - const normalized = formatted?.replace(/\s*\n+\s*/g, " ").trim() ?? ""; + // Like every other tool there, a patch names one target: its first file + const shown = toolInfo.tool === "apply_patch" ? formatted?.split("\n")[0] : formatted; + const normalized = shown?.replace(/\s*\n+\s*/g, " ").trim() ?? ""; if (!normalized) { return fallback; @@ -311,6 +404,10 @@ function hasCompactToolDetails(toolInfo: ToolInfo): boolean { return true; } + if (toolInfo.tool === "apply_patch") { + return getFirstPatchFile(toolInfo).length > 0; + } + return getToolDetails(toolInfo.tool, toolInfo.input).trim().length > 0; } diff --git a/src/app/managers/summary-aggregation-manager.ts b/src/app/managers/summary-aggregation-manager.ts index fa6734a9d..f0f353f0c 100644 --- a/src/app/managers/summary-aggregation-manager.ts +++ b/src/app/managers/summary-aggregation-manager.ts @@ -1,7 +1,12 @@ import { Event, ToolState } from "@opencode-ai/sdk/v2"; import type { Bot } from "grammy"; import type { CodeFileData } from "../formatters/summary-formatter.js"; -import { normalizePathForDisplay, prepareCodeFile } from "../formatters/summary-formatter.js"; +import { + formatPatchFileLine, + getPatchFileChanges, + normalizePathForDisplay, + prepareCodeFile, +} from "../formatters/summary-formatter.js"; import type { Question, QuestionSettledOutcome } from "../types/question.js"; import type { PermissionReply, PermissionRequest } from "../types/permission.js"; import type { FileChange } from "../types/summary.js"; @@ -88,9 +93,16 @@ export interface ToolInfo { hasFileAttachment?: boolean | undefined; } +/** A file a finished call changed; one without a document stays a text line. */ +export interface ToolFileEntry { + fileData: CodeFileData | null; + /** The file's own line, for a call that lists each file it changed. */ + line?: string; +} + export interface ToolFileInfo extends ToolInfo { hasFileAttachment: true; - fileData: CodeFileData; + files: ToolFileEntry[]; } type ToolCallback = (toolInfo: ToolInfo) => void; @@ -193,8 +205,7 @@ type FileChangeCallback = (sessionId: string, change: FileChange) => void; type ClearedCallback = () => void; -interface PreparedToolFileContext { - fileData: CodeFileData | null; +interface PreparedToolFile extends ToolFileEntry { fileChange: FileChange | null; } @@ -1677,12 +1688,8 @@ export class SummaryAggregator { if (!this.processedToolStates.has(completedKey)) { this.processedToolStates.add(completedKey); - const preparedFileContext = this.prepareToolFileContext( - part.tool, - input, - title, - state.metadata, - ); + const preparedFiles = this.prepareToolFiles(part.tool, input, title, state.metadata); + const hasFileAttachment = preparedFiles.some((file) => file.fileData); const toolData: ToolInfo = { sessionId: part.sessionID, @@ -1693,7 +1700,7 @@ export class SummaryAggregator { input, title, metadata: state.metadata, - hasFileAttachment: !!preparedFileContext.fileData, + hasFileAttachment, }; logger.debug( @@ -1705,27 +1712,37 @@ export class SummaryAggregator { this.scheduleOutbound(() => callback(toolData), false); } - if (preparedFileContext.fileData && this.onToolFileCallback) { + if (hasFileAttachment && this.onToolFileCallback) { logger.debug( - `[Aggregator] Sending ${part.tool} file: ${preparedFileContext.fileData.filename} (${preparedFileContext.fileData.buffer.length} bytes)`, + `[Aggregator] Sending ${part.tool} files: ${preparedFiles + .map(({ fileData }) => + fileData ? `${fileData.filename} (${fileData.buffer.length} bytes)` : "text line", + ) + .join(", ")}`, ); const callback = this.onToolFileCallback; - const fileData = preparedFileContext.fileData; + const files = preparedFiles.map(({ fileData, line }): ToolFileEntry => ({ + fileData, + ...(line !== undefined ? { line } : {}), + })); this.scheduleOutbound( () => callback({ ...toolData, hasFileAttachment: true, - fileData, + files, }), false, ); } - if (preparedFileContext.fileChange && this.onFileChangeCallback) { + if (this.onFileChangeCallback) { const callback = this.onFileChangeCallback; - const fileChange = preparedFileContext.fileChange; - this.scheduleOutbound(() => callback(part.sessionID, fileChange), false); + for (const { fileChange } of preparedFiles) { + if (fileChange) { + this.scheduleOutbound(() => callback(part.sessionID, fileChange), false); + } + } } } } @@ -2134,12 +2151,12 @@ export class SummaryAggregator { return texts.filter((text) => !isUpstreamEmptyResponseText(text, isFinal)).join(""); } - private prepareToolFileContext( + private prepareToolFiles( tool: string, input: { [key: string]: unknown } | undefined, title: string | undefined, metadata: { [key: string]: unknown } | undefined, - ): PreparedToolFileContext { + ): PreparedToolFile[] { if (tool === "write" && input) { const filePath = typeof input.filePath === "string" ? normalizePathForDisplay(input.filePath) : ""; @@ -2147,17 +2164,19 @@ export class SummaryAggregator { const hasContent = typeof input.content === "string"; if (!filePath || !hasContent) { - return { fileData: null, fileChange: null }; + return []; } - return { - fileData: prepareCodeFile(content, filePath, "write"), - fileChange: { - file: filePath, - additions: content.split("\n").length, - deletions: 0, + return [ + { + fileData: prepareCodeFile(content, filePath, "write"), + fileChange: { + file: filePath, + additions: content.split("\n").length, + deletions: 0, + }, }, - }; + ]; } if (tool === "edit" && metadata) { @@ -2169,20 +2188,35 @@ export class SummaryAggregator { const diffText = typeof metadata.diff === "string" ? metadata.diff : ""; if (!filePath || !diffText) { - return { fileData: null, fileChange: null }; + return []; } - return { - fileData: prepareCodeFile(diffText, filePath, "edit"), - fileChange: { - file: filePath, - additions: typeof filediff?.additions === "number" ? filediff.additions : 0, - deletions: typeof filediff?.deletions === "number" ? filediff.deletions : 0, + return [ + { + fileData: prepareCodeFile(diffText, filePath, "edit"), + fileChange: { + file: filePath, + additions: typeof filediff?.additions === "number" ? filediff.additions : 0, + deletions: typeof filediff?.deletions === "number" ? filediff.deletions : 0, + }, }, - }; + ]; } if (tool === "apply_patch") { + const changes = getPatchFileChanges(metadata); + if (changes.length > 0) { + return changes.map((change) => ({ + fileData: change.diff ? prepareCodeFile(change.diff, change.path, "edit") : null, + fileChange: { + file: change.path, + additions: change.additions, + deletions: change.deletions, + }, + line: formatPatchFileLine(change), + })); + } + const filediff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; const filePathFromInput = @@ -2205,7 +2239,7 @@ export class SummaryAggregator { : ""; if (!filePath) { - return { fileData: null, fileChange: null }; + return []; } const fileChange = filediff @@ -2225,13 +2259,11 @@ export class SummaryAggregator { })() : null; - return { - fileData: diffText ? prepareCodeFile(diffText, filePath, "edit") : null, - fileChange, - }; + const fileData = diffText ? prepareCodeFile(diffText, filePath, "edit") : null; + return fileData || fileChange ? [{ fileData, fileChange }] : []; } - return { fileData: null, fileChange: null }; + return []; } private hashString(str: string): string { diff --git a/src/bot/events/handlers/tool-activity-handler.ts b/src/bot/events/handlers/tool-activity-handler.ts index e288d2ca6..8bb8b9832 100644 --- a/src/bot/events/handlers/tool-activity-handler.ts +++ b/src/bot/events/handlers/tool-activity-handler.ts @@ -10,6 +10,7 @@ import { renderSubagentCard } from "../../../app/formatters/subagent-formatter.j import { RUNNING_ICON, appendDuration, + appendDurationToFirstLine, formatDuration, formatDurationOverHours, } from "../../../app/formatters/duration-formatter.js"; @@ -410,10 +411,13 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { const message = formatToolInfo(toolInfo); if (message) { const durationMs = runtime.takeCompletedToolDuration(toolInfo.sessionId, toolInfo.callId); + // A patch lists one line per file: the duration stays on the line that was running + const withDuration = + toolInfo.tool === "apply_patch" ? appendDurationToFirstLine : appendDuration; runtime.toolCallStreamer.replaceByPrefix( toolInfo.sessionId, getLiveToolPrefix(toolInfo.callId), - durationMs === undefined ? message : appendDuration(message, formatDuration(durationMs)), + durationMs === undefined ? message : withDuration(message, formatDuration(durationMs)), getToolStreamKey(toolInfo.tool), ); unpinLiveToolLine(runtime, toolInfo); @@ -469,23 +473,41 @@ export function registerToolActivityHandlers(deps: ToolActivityDeps): void { try { await runtime.letOutRepliesBeforeDocument(fileInfo.sessionId); - runtime.takeCompletedToolDuration(fileInfo.sessionId, fileInfo.callId); - runtime.toolCallStreamer.removeByPrefix( - fileInfo.sessionId, - getLiveToolPrefix(fileInfo.callId), - getToolStreamKey(fileInfo.tool), - true, + const durationMs = runtime.takeCompletedToolDuration(fileInfo.sessionId, fileInfo.callId); + const livePrefix = getLiveToolPrefix(fileInfo.callId); + const streamKey = getToolStreamKey(fileInfo.tool); + // A file whose document was too big keeps its line where the running line was + const textLines = fileInfo.files.flatMap(({ fileData, line }) => + !fileData && line ? [line] : [], ); + if (textLines.length > 0) { + const text = textLines.join("\n"); + const firstFileIsText = !fileInfo.files[0]?.fileData; + runtime.toolCallStreamer.replaceByPrefix( + fileInfo.sessionId, + livePrefix, + firstFileIsText && durationMs !== undefined + ? appendDurationToFirstLine(text, formatDuration(durationMs)) + : text, + streamKey, + ); + unpinLiveToolLine(runtime, fileInfo); + } else { + runtime.toolCallStreamer.removeByPrefix(fileInfo.sessionId, livePrefix, streamKey, true); + } runtime.toolCallStreamer.beginDocumentBoundary(fileInfo.sessionId); await runtime.toolCallStreamer.flushSession(fileInfo.sessionId, "tool_file_boundary"); const toolMessage = formatToolInfo(fileInfo); - const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption); - - runtime.toolMessageBatcher.enqueueFile(fileInfo.sessionId, { - ...fileInfo.fileData, - caption, - }); + for (const { fileData, line } of fileInfo.files) { + if (!fileData) { + continue; + } + runtime.toolMessageBatcher.enqueueFile(fileInfo.sessionId, { + ...fileData, + caption: prepareDocumentCaption(line || toolMessage || fileData.caption), + }); + } await runtime.toolMessageBatcher.flushSession(fileInfo.sessionId, "tool_file_boundary"); } catch (err) { logger.error("Failed to send file to Telegram:", err); diff --git a/src/bot/pinned/pinned-message-manager.ts b/src/bot/pinned/pinned-message-manager.ts index e699cc466..9f51cb829 100644 --- a/src/bot/pinned/pinned-message-manager.ts +++ b/src/bot/pinned/pinned-message-manager.ts @@ -16,6 +16,7 @@ import { waitForModelContextLimit, } from "../../app/services/model-context-limit-service.js"; import { getStoredModel } from "../../app/services/model-selection-service.js"; +import { getPatchFileChanges } from "../../app/formatters/summary-formatter.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"; @@ -531,7 +532,24 @@ export class PinnedMessageManager { fileToolCount++; } - if ( + const patchChanges = + toolPart.tool === "apply_patch" ? getPatchFileChanges(toolPart.state.metadata) : []; + + if (patchChanges.length > 0) { + for (const change of patchChanges) { + const existing = filesMap.get(change.path); + if (existing) { + existing.additions += change.additions; + existing.deletions += change.deletions; + } else { + filesMap.set(change.path, { + file: change.path, + additions: change.additions, + deletions: change.deletions, + }); + } + } + } else if ( (toolPart.tool === "edit" || toolPart.tool === "apply_patch") && toolPart.state.metadata && "filediff" in toolPart.state.metadata diff --git a/src/opencode/v2/events.ts b/src/opencode/v2/events.ts index 8e4a3b6a9..834602c50 100644 --- a/src/opencode/v2/events.ts +++ b/src/opencode/v2/events.ts @@ -6,6 +6,7 @@ import { toV1Permission, toV1Question, toV1ToolInput, + toV1ToolMetadata, toV1ToolName, } from "./mappers.js"; @@ -553,7 +554,7 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) case "session.tool.progress": { const data = event.data; const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); - call.metadata = { ...call.metadata, ...data.metadata }; + call.metadata = toV1ToolMetadata(call.tool, { ...call.metadata, ...data.metadata }); const part = toolPart(data.id, { status: "running", input: call.input, @@ -566,7 +567,10 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) case "session.tool.success": { const data = event.data; const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); - const metadata: Record = { ...call.metadata, ...(data.metadata ?? {}) }; + const metadata = toV1ToolMetadata(call.tool, { + ...call.metadata, + ...(data.metadata ?? {}), + }); const output = toolContentText(data.content); tools.delete(data.id); @@ -608,7 +612,7 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) status: "error", input: call.input, error: data.error.message, - metadata: { ...call.metadata, ...(data.metadata ?? {}) }, + metadata: toV1ToolMetadata(call.tool, { ...call.metadata, ...(data.metadata ?? {}) }), time: { start: call.start, end: created }, }); tools.delete(data.id); diff --git a/src/opencode/v2/mappers.ts b/src/opencode/v2/mappers.ts index 9af5fe9b4..d0fa922fd 100644 --- a/src/opencode/v2/mappers.ts +++ b/src/opencode/v2/mappers.ts @@ -35,6 +35,7 @@ import type { SessionStructuredError, SkillInfo, } from "@opencode/client"; +import { isRecord } from "../../utils/type-guards.js"; type JsonRecord = Record; @@ -75,6 +76,48 @@ export function toV1ToolInput(tool: string, input: JsonRecord): JsonRecord { return result; } +/** Adds the V1 file-change field names the formatters read, keeping the V2 ones. */ +export function toV1ToolMetadata(tool: string, metadata: JsonRecord): JsonRecord { + if (!Array.isArray(metadata.files)) { + return metadata; + } + const files = metadata.files.filter(isRecord); + + if (tool === "edit" && metadata.filediff === undefined) { + const file = files[0]; + if (files.length !== 1 || !file || typeof file.file !== "string") { + return metadata; + } + const patch = typeof file.patch === "string" ? file.patch : undefined; + return { + ...metadata, + ...(patch !== undefined && typeof metadata.diff !== "string" ? { diff: patch } : {}), + filediff: { + file: file.file, + ...(patch !== undefined ? { patch } : {}), + additions: file.additions, + deletions: file.deletions, + }, + }; + } + + if (tool === "apply_patch") { + return { + ...metadata, + files: files.map((file) => { + // V2 reports the path relative to the project, and the file's diff under `patch`, + // one of the two keys V1 entries use + if (typeof file.filePath === "string" || typeof file.file !== "string") { + return file; + } + return { ...file, filePath: file.file, relativePath: file.file }; + }), + }; + } + + return metadata; +} + export function toolContentText( content: ReadonlyArray<{ type: string; text?: string }> | undefined, ): string { @@ -174,8 +217,9 @@ function toV1ToolState( return { status: "pending", input: {}, raw: state.input }; } const input = toV1ToolInput(toolName, state.input); + const metadata = state.metadata ? toV1ToolMetadata(toolName, state.metadata) : undefined; if (state.status === "running") { - return { status: "running", input, metadata: state.metadata, time: { start } }; + return { status: "running", input, ...(metadata ? { metadata } : {}), time: { start } }; } if (state.status === "completed") { return { @@ -183,7 +227,7 @@ function toV1ToolState( input, output: toolContentText(state.content), title: "", - metadata: state.metadata ?? {}, + metadata: metadata ?? {}, time: { start, end }, }; } @@ -191,7 +235,7 @@ function toV1ToolState( status: "error", input, error: state.error.message, - ...(state.metadata ? { metadata: state.metadata } : {}), + ...(metadata ? { metadata } : {}), time: { start, end }, }; } diff --git a/tests/app/formatters/duration-formatter.test.ts b/tests/app/formatters/duration-formatter.test.ts index 721716982..471587a5f 100644 --- a/tests/app/formatters/duration-formatter.test.ts +++ b/tests/app/formatters/duration-formatter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + appendDurationToFirstLine, bucketElapsedMs, formatDuration, formatDurationOverHours, @@ -10,6 +11,11 @@ const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; describe("app/formatters/duration-formatter", () => { + it("puts the duration on the first line of a message that lists several", () => { + expect(appendDurationToFirstLine("one", "21s")).toBe("one · 🕒 21s"); + expect(appendDurationToFirstLine("one\ntwo", "21s")).toBe("one · 🕒 21s\ntwo"); + }); + describe("formatDuration", () => { it("renders sub-minute durations as seconds", () => { expect(formatDuration(20 * SECOND)).toBe("20s"); diff --git a/tests/app/managers/summary-aggregation-manager.test.ts b/tests/app/managers/summary-aggregation-manager.test.ts index 810c77827..81430d2e5 100644 --- a/tests/app/managers/summary-aggregation-manager.test.ts +++ b/tests/app/managers/summary-aggregation-manager.test.ts @@ -2339,17 +2339,21 @@ describe("summary/aggregator", () => { sessionId: string; tool: string; hasFileAttachment: boolean; - fileData: { - filename: string; - buffer: Buffer; - }; + files: Array<{ + fileData: { + filename: string; + buffer: Buffer; + }; + }>; }; expect(filePayload.sessionId).toBe("session-1"); expect(filePayload.tool).toBe("apply_patch"); expect(filePayload.hasFileAttachment).toBe(true); - expect(filePayload.fileData.filename).toBe("edit_one.ts.txt"); - expect(filePayload.fileData.buffer.toString("utf8")).toContain("Edit File/Path: src/one.ts"); + expect(filePayload.files[0]?.fileData.filename).toBe("edit_one.ts.txt"); + expect(filePayload.files[0]?.fileData.buffer.toString("utf8")).toContain( + "Edit File/Path: src/one.ts", + ); }); it("sends apply_patch file using title and patchText fallback", () => { @@ -2401,15 +2405,197 @@ describe("summary/aggregator", () => { const filePayload = defined(onToolFile.mock.calls[0]?.[0]) as { hasFileAttachment: boolean; - fileData: { - filename: string; - buffer: Buffer; - }; + files: Array<{ + fileData: { + filename: string; + buffer: Buffer; + }; + }>; }; expect(filePayload.hasFileAttachment).toBe(true); - expect(filePayload.fileData.filename).toBe("edit_README.md.txt"); - expect(filePayload.fileData.buffer.toString("utf8")).toContain("Edit File/Path: README.md"); + expect(filePayload.files[0]?.fileData.filename).toBe("edit_README.md.txt"); + expect(filePayload.files[0]?.fileData.buffer.toString("utf8")).toContain( + "Edit File/Path: README.md", + ); + }); + + it("sends one apply_patch document and one file change per changed file", () => { + const onTool = vi.fn(); + const onToolFile = vi.fn(); + const onFileChange = vi.fn(); + summaryAggregator.setOnTool(onTool); + summaryAggregator.setOnToolFile(onToolFile); + summaryAggregator.setOnFileChange(onFileChange); + summaryAggregator.setSession("session-1"); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "message-multi", + sessionID: "session-1", + role: "assistant", + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-multi", + sessionID: "session-1", + messageID: "message-multi", + type: "tool", + callID: "call-apply-patch-multi", + tool: "apply_patch", + state: { + status: "completed", + title: "Success. Updated the following files:\nM src\\one.ts\nA src\\two.ts", + input: { patchText: "*** Begin Patch" }, + metadata: { + diff: "combined", + files: [ + { + filePath: "D:\\repo\\src\\one.ts", + relativePath: "src\\one.ts", + type: "update", + diff: ["--- a/src/one.ts", "+++ b/src/one.ts", "@@ -1 +1 @@", "-a", "+b"].join( + "\n", + ), + additions: 1, + deletions: 1, + }, + { + filePath: "D:\\repo\\src\\two.ts", + relativePath: "src\\two.ts", + type: "add", + patch: ["--- /dev/null", "+++ b/src/two.ts", "@@ -0,0 +1,2 @@", "+x", "+y"].join( + "\n", + ), + additions: 2, + deletions: 0, + }, + ], + }, + }, + }, + }, + } as unknown as Event); + + expect(defined(onTool.mock.calls[0]?.[0])).toEqual( + expect.objectContaining({ tool: "apply_patch", hasFileAttachment: true }), + ); + expect(onToolFile).toHaveBeenCalledTimes(1); + const filePayload = defined(onToolFile.mock.calls[0]?.[0]) as { + files: Array<{ fileData: { filename: string } | null; line?: string }>; + }; + expect(filePayload.files.map(({ fileData }) => fileData?.filename)).toEqual([ + "edit_one.ts.txt", + "edit_two.ts.txt", + ]); + expect(filePayload.files.map(({ line }) => line)).toEqual([ + "🩹 apply_patch src/one.ts (+1 -1)", + "🩹 apply_patch src/two.ts (+2)", + ]); + expect(onFileChange.mock.calls.map((call) => call[1])).toEqual([ + { file: "src/one.ts", additions: 1, deletions: 1 }, + { file: "src/two.ts", additions: 2, deletions: 0 }, + ]); + }); + + it("leaves an oversized apply_patch file as a text line and keeps the other documents", () => { + const onToolFile = vi.fn(); + const onFileChange = vi.fn(); + summaryAggregator.setOnToolFile(onToolFile); + summaryAggregator.setOnFileChange(onFileChange); + summaryAggregator.setSession("session-1"); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "message-big", + sessionID: "session-1", + role: "assistant", + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-big", + sessionID: "session-1", + messageID: "message-big", + type: "tool", + callID: "call-apply-patch-big", + tool: "apply_patch", + state: { + status: "completed", + input: { patchText: "*** Begin Patch" }, + metadata: { + files: [ + { + relativePath: "src/huge.ts", + diff: "+" + "x".repeat(101 * 1024), + }, + { + relativePath: "src/small.ts", + diff: "+one", + }, + ], + }, + }, + }, + }, + } as unknown as Event); + + const filePayload = defined(onToolFile.mock.calls[0]?.[0]) as { + files: Array<{ fileData: { filename: string } | null; line?: string }>; + }; + expect(filePayload.files).toEqual([ + { fileData: null, line: "🩹 apply_patch src/huge.ts (+1)" }, + expect.objectContaining({ + fileData: expect.objectContaining({ filename: "edit_small.ts.txt" }), + line: "🩹 apply_patch src/small.ts (+1)", + }), + ]); + expect(onFileChange).toHaveBeenCalledTimes(2); + }); + + it("adds nothing for a failed apply_patch", () => { + const onToolFile = vi.fn(); + const onFileChange = vi.fn(); + summaryAggregator.setOnToolFile(onToolFile); + summaryAggregator.setOnFileChange(onFileChange); + summaryAggregator.setSession("session-1"); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-failed", + sessionID: "session-1", + messageID: "message-failed", + type: "tool", + callID: "call-apply-patch-failed", + tool: "apply_patch", + state: { + status: "error", + input: { patchText: "*** Begin Patch\n*** Update File: src/one.ts\n@@\n-a\n+b" }, + error: "patch verification failed", + }, + }, + }, + } as unknown as Event); + + expect(onToolFile).not.toHaveBeenCalled(); + expect(onFileChange).not.toHaveBeenCalled(); }); it("fires onTokens with isCompleted=true when message has completed timestamp", () => { diff --git a/tests/bot/messages/summary-message-formatter.test.ts b/tests/bot/messages/summary-message-formatter.test.ts index fc43a25d1..e262139bc 100644 --- a/tests/bot/messages/summary-message-formatter.test.ts +++ b/tests/bot/messages/summary-message-formatter.test.ts @@ -403,4 +403,126 @@ describe("bot/messages/summary-message-formatter", () => { const writeFile = prepareCodeFile("content", "D:/repo/src/absolute-write.ts", "write"); expect(writeFile?.buffer.toString("utf8")).toContain("Write File/Path: src/absolute-write.ts"); }); + + describe("apply_patch per file", () => { + const patchInfo = ( + status: "running" | "completed" | "error", + fields: { + input?: Record; + metadata?: Record; + title?: string; + }, + ) => ({ + sessionId: "s1", + messageId: "m-patch", + callId: "c-patch", + tool: "apply_patch", + state: { status } as never, + ...fields, + }); + + it("keeps the V1 single-file line with relative forward-slash path and counts from its diff", () => { + const text = formatToolInfo( + patchInfo("completed", { + title: "Success. Updated the following files:\nM src\\main\\ChatConfig.kt", + metadata: { + diff: "combined", + files: [ + { + filePath: "D:\\repo\\src\\main\\ChatConfig.kt", + relativePath: "src\\main\\ChatConfig.kt", + type: "update", + diff: ["--- a", "+++ b", "@@ -1,2 +1,3 @@", "-old", "+new", "+more", " same"].join( + "\n", + ), + additions: 9, + deletions: 9, + }, + ], + }, + }), + ); + + expect(text).toBe("🩹 apply_patch src/main/ChatConfig.kt (+2 -1)"); + }); + + it("lists every file of a multi-file patch on its own line", () => { + const text = formatToolInfo( + patchInfo("completed", { + metadata: { + files: [ + { filePath: "src/a.ts", relativePath: "src/a.ts", patch: "+one\n-two" }, + { filePath: "src/new.ts", relativePath: "src/new.ts", patch: "+x\n+y" }, + { filePath: "src/gone.ts", relativePath: "src/gone.ts", patch: "-x" }, + { relativePath: "src/old.ts", movePath: "D:/repo/src/moved.ts", diff: "" }, + { file: "src/v2.ts", relativePath: "src/v2.ts", additions: 4, deletions: 1 }, + ], + }, + }), + ); + + expect(text).toBe( + [ + "🩹 apply_patch src/a.ts (+1 -1)", + "🩹 apply_patch src/new.ts (+2)", + "🩹 apply_patch src/gone.ts (-1)", + "🩹 apply_patch src/moved.ts", + "🩹 apply_patch src/v2.ts (+4 -1)", + ].join("\n"), + ); + }); + + it("names the first file of a running patch from its headers, never the patch text", () => { + const patchText = [ + "*** Begin Patch", + "*** Update File: src/first.ts", + "@@", + "-a", + "+b", + "*** Add File: src/second.ts", + "+c", + "*** End Patch", + ].join("\n"); + + expect(formatToolInfo(patchInfo("running", { input: { patchText } }))).toBe( + "🩹 apply_patch src/first.ts", + ); + expect( + formatToolInfo( + patchInfo("running", { input: { patchText: patchText.replace(/\n/g, "\r\n") } }), + ), + ).toBe("🩹 apply_patch src/first.ts"); + expect(formatToolInfo(patchInfo("running", { input: {} }))).toBe("🩹 apply_patch"); + expect( + formatToolInfo(patchInfo("running", { input: { patchText: "*** Begin Patch\n@@" } })), + ).toBe("🩹 apply_patch"); + }); + + it("shows a failed patch as its first file without counts", () => { + const text = formatToolInfo( + patchInfo("error", { + input: { patchText: "*** Begin Patch\n*** Update File: src/first.ts\n@@\n-a\n+b\n+c" }, + }), + ); + + expect(text).toBe("🩹 apply_patch src/first.ts"); + }); + + it("keeps only the first file in the compact activity line", () => { + const running = patchInfo("running", { + input: { patchText: "*** Begin Patch\n*** Update File: src/first.ts\n@@\n-a\n+b" }, + }); + expect(formatCompactToolActivity(running, 128)).toBe("🩹 apply_patch src/first.ts"); + + const finished = patchInfo("completed", { + metadata: { + files: [ + { relativePath: "src/a.ts", diff: "+one" }, + { relativePath: "src/b.ts", diff: "+two" }, + ], + }, + }); + expect(formatCompactToolActivity(finished, 128)).toBe("🩹 apply_patch src/a.ts (+1)"); + }); + }); }); diff --git a/tests/bot/pinned/pinned-message-manager.test.ts b/tests/bot/pinned/pinned-message-manager.test.ts index b655ecb48..fa3b5a430 100644 --- a/tests/bot/pinned/pinned-message-manager.test.ts +++ b/tests/bot/pinned/pinned-message-manager.test.ts @@ -600,6 +600,61 @@ describe("pinned/manager", () => { ]); }); + it("restores every file of a multi-file apply_patch from session messages", async () => { + mocked.opencodeClient.session.messages.mockResolvedValue({ + data: [ + { + info: { role: "assistant" }, + parts: [ + { + type: "tool", + tool: "apply_patch", + state: { + status: "completed", + metadata: { + diff: "combined", + files: [ + { filePath: "D:/repo/src/a.ts", relativePath: "src/a.ts", diff: "+x\n-y" }, + { filePath: "D:/repo/src/b.ts", relativePath: "src/b.ts", diff: "+z" }, + ], + }, + }, + }, + { + type: "tool", + tool: "apply_patch", + state: { + status: "completed", + metadata: { + files: [ + { + file: "src/b.ts", + filePath: "src/b.ts", + relativePath: "src/b.ts", + patch: "+one\n+two", + }, + ], + }, + }, + }, + { + type: "tool", + tool: "apply_patch", + state: { status: "error", input: { patchText: "*** Update File: src/c.ts" } }, + }, + ], + }, + ], + }); + + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + + expect(pinnedMessageManager.getState().changedFiles).toEqual([ + { file: "src/a.ts", additions: 1, deletions: 1 }, + { file: "src/b.ts", additions: 3, deletions: 0 }, + ]); + }); + it("leaves the diff list empty when neither source reports file changes", async () => { await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); diff --git a/tests/opencode/v2/events.test.ts b/tests/opencode/v2/events.test.ts index d344b0ccb..fa759f42c 100644 --- a/tests/opencode/v2/events.test.ts +++ b/tests/opencode/v2/events.test.ts @@ -141,6 +141,79 @@ describe("opencode/v2/events", () => { }); }); + it("gives a V2 edit and a patch the V1 file-change metadata", () => { + const translate = createV2EventTranslator(); + const file = { file: "src/a.ts", patch: "+x", status: "modified", additions: 1, deletions: 0 }; + + const [, , edited] = payloads(translate, [ + event("session.tool.input.started", { + sessionID: SESSION, + assistantMessageID: MESSAGE, + id: "call-edit", + name: "edit", + }), + event("session.tool.called", { + sessionID: SESSION, + assistantMessageID: MESSAGE, + id: "call-edit", + input: { path: "src/a.ts" }, + executed: false, + }), + event("session.tool.success", { + sessionID: SESSION, + assistantMessageID: MESSAGE, + id: "call-edit", + content: [], + metadata: { files: [file] }, + executed: false, + }), + ]); + expect(edited).toMatchObject({ + properties: { + part: { + tool: "edit", + state: { metadata: { diff: "+x", filediff: { file: "src/a.ts", additions: 1 } } }, + }, + }, + }); + + const [, , , failed] = payloads(translate, [ + event("session.tool.input.started", { + sessionID: SESSION, + assistantMessageID: MESSAGE, + id: "call-patch", + name: "patch", + }), + event("session.tool.called", { + sessionID: SESSION, + assistantMessageID: MESSAGE, + id: "call-patch", + input: { patchText: "*** Begin Patch" }, + executed: false, + }), + event("session.tool.progress", { + sessionID: SESSION, + assistantMessageID: MESSAGE, + id: "call-patch", + metadata: { files: [file] }, + }), + event("session.tool.failed", { + sessionID: SESSION, + assistantMessageID: MESSAGE, + id: "call-patch", + error: { type: "tool.execution", message: "patch verification failed" }, + }), + ]); + expect(failed).toMatchObject({ + properties: { + part: { + tool: "apply_patch", + state: { status: "error", metadata: { files: [{ relativePath: "src/a.ts" }] } }, + }, + }, + }); + }); + it("reports a failed run as a session error followed by idle", () => { const translate = createV2EventTranslator(); diff --git a/tests/opencode/v2/mappers.test.ts b/tests/opencode/v2/mappers.test.ts index 4856e010c..14a90d547 100644 --- a/tests/opencode/v2/mappers.test.ts +++ b/tests/opencode/v2/mappers.test.ts @@ -6,6 +6,7 @@ import { toV1Providers, toV1Question, toV1ToolInput, + toV1ToolMetadata, toV1ToolName, toV2PromptInput, } from "../../../src/opencode/v2/mappers.js"; @@ -32,6 +33,79 @@ describe("opencode/v2/mappers", () => { }); }); + it("adds the V1 file-change metadata while keeping the V2 files", () => { + const v2File = { + file: "src/a.ts", + patch: "Index: src/a.ts\n+one", + status: "modified", + additions: 1, + deletions: 0, + }; + + expect(toV1ToolMetadata("edit", { files: [v2File], truncated: false })).toEqual({ + files: [v2File], + truncated: false, + diff: v2File.patch, + filediff: { file: "src/a.ts", patch: v2File.patch, additions: 1, deletions: 0 }, + }); + expect( + toV1ToolMetadata("apply_patch", { files: [v2File, { ...v2File, file: "b.ts" }] }), + ).toEqual({ + files: [ + { ...v2File, filePath: "src/a.ts", relativePath: "src/a.ts" }, + { ...v2File, file: "b.ts", filePath: "b.ts", relativePath: "b.ts" }, + ], + }); + + const v1Patch = { diff: "all", files: [{ filePath: "D:/repo/a.ts", relativePath: "a.ts" }] }; + expect(toV1ToolMetadata("apply_patch", v1Patch)).toEqual(v1Patch); + const v1Edit = { diff: "d", filediff: { file: "a.ts", additions: 1, deletions: 0 } }; + expect(toV1ToolMetadata("edit", v1Edit)).toBe(v1Edit); + expect(toV1ToolMetadata("read", { truncated: false })).toEqual({ truncated: false }); + }); + + it("carries the V1 file-change metadata into restored patch parts", () => { + const message = { + type: "assistant", + id: "msg-1", + agent: "build", + model: { id: "m", providerID: "p" }, + time: { created: 1, completed: 3 }, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 0, + content: [ + { + type: "tool", + id: "call-1", + name: "patch", + state: { + status: "completed", + input: { patchText: "*** Begin Patch" }, + content: [{ type: "text", text: "Success." }], + metadata: { + files: [ + { file: "src/a.ts", patch: "+x", status: "added", additions: 1, deletions: 0 }, + ], + }, + }, + time: { created: 1, ran: 2, completed: 3 }, + }, + ], + } as unknown as SessionMessageInfo; + + const part = toV1Message(message, "ses-1", "D:/repo")?.parts.find( + (item) => item.type === "tool", + ); + + expect(part).toMatchObject({ + tool: "apply_patch", + state: { + status: "completed", + metadata: { files: [{ file: "src/a.ts", relativePath: "src/a.ts" }] }, + }, + }); + }); + it("maps every visible form field into the question UI", () => { const form = createForm([ { From e90a4bbfb84370a8e2fdf4911b1d76c32bef6a35 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Wed, 30 Sep 2026 19:06:38 +0300 Subject: [PATCH 2/6] fix(OTB-133): dismiss the question in OpenCode when a poll is cancelled Cancel on an agent's poll now sends question.reject for the whole request, for the main agent and subagents on V1 and V2. The poll turns into "Poll cancelled" once OpenCode takes it; a Cancel that does not get through keeps the poll answerable with the delivery warning. A poll cancelled outside Telegram now stays in the chat with its outcome line instead of being deleted. Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 1 + src/app/managers/question-manager.ts | 68 +++++- src/app/services/attach-service.ts | 4 +- src/app/types/question.ts | 6 + .../callbacks/question-callback-handler.ts | 115 +++++++++- .../events/handlers/interaction-handler.ts | 23 +- src/bot/menus/question-menu.ts | 78 ++++++- tests/app/managers/question-manager.test.ts | 31 +++ tests/app/services/attach-service.test.ts | 7 + .../question-callback-handler.test.ts | 198 +++++++++++++++++- .../event-subscription-service.test.ts | 89 ++++++++ tests/helpers/interaction.ts | 3 + 12 files changed, 604 insertions(+), 19 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index c81221c8b..79f43c215 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -59,6 +59,7 @@ No public inbound ports are required for normal usage. - Handle OpenCode questions with inline options and custom text answers; the custom answer button is offered only when the question accepts a custom answer - Questions asked by a subagent of the followed session appear in the chat like the main agent's and are answered to that subagent - A question answered or cancelled outside Telegram (OpenCode TUI, web, another client) closes the poll on screen: its buttons go and a line says it was answered or cancelled outside Telegram +- The poll's Cancel button dismisses the whole question request in OpenCode (`question.reject`), for the main agent and for a subagent alike: once OpenCode takes it the poll turns into `❌ Poll cancelled`, answers already chosen are not sent, and the agent's turn ends without a reply or footer; a Cancel that does not reach OpenCode leaves the poll answerable with a line saying so - 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`); 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`), from the main agent and from subagents of the followed session diff --git a/src/app/managers/question-manager.ts b/src/app/managers/question-manager.ts index 6af83c63b..a9c4dd81d 100644 --- a/src/app/managers/question-manager.ts +++ b/src/app/managers/question-manager.ts @@ -1,4 +1,10 @@ -import type { Question, QuestionOption, QuestionState, QuestionAnswer } from "../types/question.js"; +import type { + Question, + QuestionOption, + QuestionState, + QuestionAnswer, + QuestionSettledOutcome, +} from "../types/question.js"; import type { InteractionManager } from "./interaction-manager.js"; import { logger } from "../../utils/logger.js"; @@ -61,6 +67,9 @@ export class QuestionManager { requestID, sessionId, answeredFromTelegram: false, + dismissing: false, + settledWhileDismissing: null, + lastCancelFailed: false, }, }); return true; @@ -86,6 +95,63 @@ export class QuestionManager { return this.state?.answeredFromTelegram ?? false; } + /** + * Cancel was tapped: the dismissal is being sent to OpenCode. A custom-text wait ends + * here, so text typed afterwards is never taken as an answer. + */ + startDismissal(): void { + const state = this.state; + if (state) { + state.dismissing = true; + state.settledWhileDismissing = null; + state.lastCancelFailed = false; + state.customInputQuestionIndex = null; + } + } + + isDismissing(): boolean { + return this.state?.dismissing ?? false; + } + + /** The dismissal did not reach OpenCode: the poll stays answerable. */ + failDismissal(): void { + const state = this.state; + if (state) { + state.dismissing = false; + state.settledWhileDismissing = null; + state.lastCancelFailed = true; + } + } + + /** OpenCode reported the question settled while the dismissal was on its way. */ + noteSettledWhileDismissing(outcome: QuestionSettledOutcome): void { + const state = this.state; + if (state?.dismissing && state.settledWhileDismissing !== "answered") { + state.settledWhileDismissing = outcome; + } + } + + getSettledWhileDismissing(): QuestionSettledOutcome | null { + return this.state?.settledWhileDismissing ?? null; + } + + hasLastCancelFailed(): boolean { + return this.state?.lastCancelFailed ?? false; + } + + clearLastCancelFailed(): void { + const state = this.state; + if (state) { + state.lastCancelFailed = false; + } + } + + /** Answers or a dismissal are being sent from Telegram: the poll is left to that send. */ + isSettlingFromTelegram(): boolean { + const state = this.state; + return (state?.answeredFromTelegram ?? false) || (state?.dismissing ?? false); + } + getCurrentQuestion(): Question | null { const state = this.state; return state?.questions[state.currentIndex] ?? null; diff --git a/src/app/services/attach-service.ts b/src/app/services/attach-service.ts index 95888aa9e..34af29283 100644 --- a/src/app/services/attach-service.ts +++ b/src/app/services/attach-service.ts @@ -247,7 +247,7 @@ function snapshotTrackedRequests(deps: RestoreAfterReconnectDeps): TrackedReques * while the stream was down: they end as answered outside Telegram, and waiting requests * that are gone leave the queue. Only requests tracked before the lists were requested * are checked — one that arrived meanwhile is missing from the lists without being - * settled. Answers being sent from Telegram are left to that send. + * settled. Answers or a dismissal being sent from Telegram are left to that send. */ async function settleRequestsGoneWhileDisconnected( deps: RestoreAfterReconnectDeps, @@ -266,7 +266,7 @@ async function settleRequestsGoneWhileDisconnected( shownId && !pendingIds.has(shownId) && deps.questionManager.getRequestID() === shownId && - !deps.questionManager.isAnsweredFromTelegram() && + !deps.questionManager.isSettlingFromTelegram() && attachPresentation ) { await attachPresentation.closeQuestionSettledOutside(deps.bot.api, deps.chatId); diff --git a/src/app/types/question.ts b/src/app/types/question.ts index c54a3c08a..4edfa0778 100644 --- a/src/app/types/question.ts +++ b/src/app/types/question.ts @@ -31,6 +31,12 @@ export interface QuestionState { requestID: string | null; sessionId: string; answeredFromTelegram: boolean; + /** Cancel was tapped and the dismissal is on its way to OpenCode. */ + dismissing: boolean; + /** How OpenCode reported the question settled while the dismissal was on its way. */ + settledWhileDismissing: QuestionSettledOutcome | null; + /** The last Cancel did not reach OpenCode and nothing was tapped since. */ + lastCancelFailed: boolean; } /** How a question was settled outside Telegram. */ diff --git a/src/bot/callbacks/question-callback-handler.ts b/src/bot/callbacks/question-callback-handler.ts index b6370c69e..b358db635 100644 --- a/src/bot/callbacks/question-callback-handler.ts +++ b/src/bot/callbacks/question-callback-handler.ts @@ -1,15 +1,24 @@ import type { Context } from "grammy"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; +import { opencodeClient } from "../../opencode/client.js"; +import { getCurrentProject } from "../../app/stores/settings-store.js"; +import { getCurrentSession } from "../../app/services/session-service.js"; import { clearQuestionInteraction, + closeQuestionCancelled, + closeQuestionSettledOutside, showCurrentQuestion, showNextQuestion, + showQuestionDeliveryWarning, syncQuestionInteractionState, updateQuestionMessage, } from "../menus/question-menu.js"; import { t } from "../../i18n/index.js"; import { logger } from "../../utils/logger.js"; -import { alert, cancelPrompt } from "./feedback.js"; +import { isOpencodeNotFoundError } from "../../utils/opencode-error.js"; +import { isRecord } from "../../utils/type-guards.js"; +import { safeBackgroundTask } from "../../utils/safe-background-task.js"; +import { alert } from "./feedback.js"; export type QuestionCallbackDeps = Pick< AppContainer, @@ -26,6 +35,16 @@ function getCallbackMessageId(ctx: Context): number | null { return typeof messageId === "number" ? messageId : null; } +/** How OpenCode took a dismissal: accepted, gone (already settled) or not reached. */ +type DismissalResult = "accepted" | "gone" | "failed"; + +function isQuestionRequestNotFound(error: unknown): boolean { + return ( + isOpencodeNotFoundError(error) || + (isRecord(error) && error._tag === "QuestionNotFoundError") + ); +} + export async function handleQuestionCallback( ctx: Context, deps: QuestionCallbackDeps, @@ -51,6 +70,14 @@ export async function handleQuestionCallback( return true; } + if (deps.questionManager.isDismissing()) { + // The dismissal is on its way: no button of the poll does anything until it lands. + await ctx.answerCallbackQuery(); + return true; + } + + deps.questionManager.clearLastCancelFailed(); + const parts = data.split(":"); const action = parts[1]; const questionIndex = parseInt(parts[2] ?? "", 10); @@ -224,9 +251,91 @@ async function handleToggleCustomAnswer( } async function handleCancelPoll(ctx: Context, deps: QuestionCallbackDeps): Promise { - deps.questionManager.cancel(); + const { questionManager } = deps; + const requestID = questionManager.getRequestID(); + const directory = getCurrentSession()?.directory ?? getCurrentProject()?.worktree; + const chatId = ctx.chat?.id; + + if (!requestID || !directory || chatId === undefined) { + // Nothing can be sent: the poll stays answerable and says the Cancel did not get through. + logger.error("[QuestionHandler] No requestID or project for dismissing the question"); + await ctx.answerCallbackQuery(); + if (chatId !== undefined) { + await showQuestionDeliveryWarning(ctx.api, chatId, deps); + } + return; + } - await cancelPrompt(ctx, "question.cancelled"); + questionManager.startDismissal(); + syncQuestionInteractionState( + "callback", + questionManager.getCurrentIndex(), + questionManager.getActiveMessageId(), + deps, + ); + + // The dismissal is marked as being sent: a lost toast must not keep it from going out. + await ctx.answerCallbackQuery({ text: t("common.cancelled") }).catch((err) => { + logger.warn("[QuestionHandler] Failed to answer the cancel callback:", err); + }); + + logger.info(`[QuestionHandler] Dismissing question via question.reject: requestID=${requestID}`); + + safeBackgroundTask({ + taskName: "question.reject", + task: async (): Promise => { + const { error } = await opencodeClient.question.reject({ requestID, directory }); + if (!error) { + return "accepted"; + } + + if (isQuestionRequestNotFound(error)) { + logger.debug(`[QuestionHandler] Question already settled: requestID=${requestID}`); + return "gone"; + } + + logger.error(`[QuestionHandler] Failed to dismiss question: requestID=${requestID}`, error); + return "failed"; + }, + onSuccess: (result) => finishDismissal(ctx.api, chatId, deps, requestID, result), + onError: () => finishDismissal(ctx.api, chatId, deps, requestID, "failed"), + }); +} + +/** + * Ends the poll once OpenCode answered the dismissal, or leaves it answerable with a + * warning when the dismissal did not get through. A poll something else already ended + * stays as it is. + */ +async function finishDismissal( + api: Context["api"], + chatId: number, + deps: QuestionCallbackDeps, + requestID: string, + result: DismissalResult, +): Promise { + const { questionManager } = deps; + if (!questionManager.isDismissing() || questionManager.getRequestID() !== requestID) { + logger.info(`[QuestionHandler] Dismissal finished for a poll already closed: ${requestID}`); + return; + } + + const settled = questionManager.getSettledWhileDismissing(); + logger.info( + `[QuestionHandler] Dismissal finished: requestID=${requestID}, result=${result}, settled=${settled ?? "none"}`, + ); + + if (settled === "answered") { + await closeQuestionSettledOutside(api, chatId, "answered", deps); + } else if (result === "gone") { + await closeQuestionSettledOutside(api, chatId, "cancelled", deps); + } else if (result === "accepted" || settled === "cancelled") { + // A lost reply still counts when OpenCode reported the question dismissed. + await closeQuestionCancelled(api, chatId, deps); + } else { + questionManager.failDismissal(); + await showQuestionDeliveryWarning(api, chatId, deps); + } } export async function handleQuestionTextAnswer( diff --git a/src/bot/events/handlers/interaction-handler.ts b/src/bot/events/handlers/interaction-handler.ts index 915b5ef55..94ed81954 100644 --- a/src/bot/events/handlers/interaction-handler.ts +++ b/src/bot/events/handlers/interaction-handler.ts @@ -2,7 +2,11 @@ import { getDeleteCompactProgressOnFinish } from "../../../app/stores/settings-s import { logger } from "../../../utils/logger.js"; import type { PermissionRequest } from "../../../app/types/permission.js"; import type { Question } from "../../../app/types/question.js"; -import { closeQuestionSettledOutside, showCurrentQuestion } from "../../menus/question-menu.js"; +import { + closeQuestionCancelled, + closeQuestionSettledOutside, + showCurrentQuestion, +} from "../../menus/question-menu.js"; import { applyPermissionPromptChanges, showPermissionRequest, @@ -197,6 +201,11 @@ export function registerInteractionHandlers(deps: InteractionDeps): void { return; } + // A dismissal or answers sent from Telegram fail or end the tool: the poll is left to them. + if (questionManager.isSettlingFromTelegram()) { + return; + } + logger.info("[Bot] Question tool failed, clearing active poll and deleting messages"); const messageIds = questionManager.getMessageIds(); @@ -218,6 +227,12 @@ export function registerInteractionHandlers(deps: InteractionDeps): void { return; } + // The dismissal sent by Cancel is on its way: its result decides how the poll ends. + if (questionManager.isDismissing()) { + questionManager.noteSettledWhileDismissing(outcome); + return; + } + // The poll is being answered from Telegram: this event is OpenCode confirming it. if (questionManager.isAnsweredFromTelegram()) { return; @@ -229,6 +244,12 @@ export function registerInteractionHandlers(deps: InteractionDeps): void { return; } + // The Cancel reported as not delivered did get through after all. + if (outcome === "cancelled" && questionManager.hasLastCancelFailed()) { + await closeQuestionCancelled(destination.api, destination.chatId, deps); + return; + } + await closeQuestionSettledOutside(destination.api, destination.chatId, outcome, deps); }); diff --git a/src/bot/menus/question-menu.ts b/src/bot/menus/question-menu.ts index 41db4aa1c..2adf65d8a 100644 --- a/src/bot/menus/question-menu.ts +++ b/src/bot/menus/question-menu.ts @@ -239,24 +239,84 @@ export async function closeQuestionSettledOutside( ? "question.settled_outside.answered" : "question.settled_outside.cancelled", ); + const part = question ? formatQuestionDetailsPart(question, deps, line) : null; logger.info( `[QuestionHandler] Poll settled outside Telegram: requestID=${questionManager.getRequestID()}, outcome=${outcome}`, ); - if (question && messageId !== null) { - await editRenderedBotPart({ - api: bot, - chatId, - messageId, - part: formatQuestionDetailsPart(question, deps, line), - }).catch((err) => { + // Release the poll before the edit: the question tool's error, which follows a dismissal, + // must not find it active and delete it. + clearQuestionInteraction("question_settled_outside", deps); + questionManager.clear(); + + if (part && messageId !== null) { + await editRenderedBotPart({ api: bot, chatId, messageId, part }).catch((err) => { logger.warn("[QuestionHandler] Failed to close the settled poll message:", err); }); } +} - clearQuestionInteraction("question_settled_outside", deps); - questionManager.clear(); +/** + * Closes the poll after OpenCode took the dismissal sent by its Cancel button: the question + * message is replaced by the cancelled line, and the slot is released. + */ +export async function closeQuestionCancelled( + bot: Context["api"], + chatId: number, + deps: QuestionStateDeps, +): Promise { + const { questionManager } = deps; + const messageId = questionManager.getActiveMessageId(); + + // Released before the edit, like a poll settled outside Telegram. + questionManager.cancel(); + + if (messageId !== null) { + await bot.editMessageText(chatId, messageId, t("question.cancelled")).catch((err) => { + logger.warn("[QuestionHandler] Failed to close the cancelled poll message:", err); + }); + } +} + +/** + * The dismissal did not reach OpenCode: the question keeps its text and buttons and says so. + */ +export async function showQuestionDeliveryWarning( + bot: Context["api"], + chatId: number, + deps: QuestionDataDeps, +): Promise { + const { questionManager } = deps; + const question = questionManager.getCurrentQuestion(); + const messageId = questionManager.getActiveMessageId(); + if (!question || messageId === null) { + return; + } + + await editRenderedBotPart({ + api: bot, + chatId, + messageId, + part: formatQuestionDetailsPart(question, deps, t("permission.delivery_failed")), + options: { + reply_markup: buildQuestionKeyboard( + question, + questionManager.getSelectedOptions(questionManager.getCurrentIndex()), + deps, + ), + }, + }).catch((err) => { + // The warning from an earlier failed Cancel may still be on the poll. + if (!isMessageNotModifiedError(err)) { + logger.warn("[QuestionHandler] Failed to show the delivery warning in the poll:", err); + } + }); +} + +function isMessageNotModifiedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.toLowerCase().includes("message is not modified"); } async function sendAllAnswersToAgent( diff --git a/tests/app/managers/question-manager.test.ts b/tests/app/managers/question-manager.test.ts index 4ce841536..0289b5b8d 100644 --- a/tests/app/managers/question-manager.test.ts +++ b/tests/app/managers/question-manager.test.ts @@ -69,6 +69,37 @@ describe("questionManager", () => { expect(questionManager.isAnsweredFromTelegram()).toBe(false); }); + it("tracks a dismissal sent by Cancel", () => { + questionManager.startQuestions([SINGLE_QUESTION], "req-1", "session-1"); + questionManager.startCustomInput(0); + expect(questionManager.isDismissing()).toBe(false); + expect(questionManager.isSettlingFromTelegram()).toBe(false); + + questionManager.startDismissal(); + expect(questionManager.isDismissing()).toBe(true); + expect(questionManager.isSettlingFromTelegram()).toBe(true); + expect(questionManager.isWaitingForCustomInput(0)).toBe(false); + + questionManager.noteSettledWhileDismissing("answered"); + questionManager.noteSettledWhileDismissing("cancelled"); + expect(questionManager.getSettledWhileDismissing()).toBe("answered"); + + questionManager.failDismissal(); + expect(questionManager.isDismissing()).toBe(false); + expect(questionManager.getSettledWhileDismissing()).toBeNull(); + expect(questionManager.hasLastCancelFailed()).toBe(true); + + questionManager.noteSettledWhileDismissing("cancelled"); + expect(questionManager.getSettledWhileDismissing()).toBeNull(); + + questionManager.clearLastCancelFailed(); + expect(questionManager.hasLastCancelFailed()).toBe(false); + + questionManager.startDismissal(); + questionManager.startQuestions([SINGLE_QUESTION], "req-2", "session-1"); + expect(questionManager.isDismissing()).toBe(false); + }); + it("keeps no custom-answer input once the poll is closed", () => { questionManager.startQuestions([SINGLE_QUESTION], "req-1", "session-1"); questionManager.startCustomInput(0); diff --git a/tests/app/services/attach-service.test.ts b/tests/app/services/attach-service.test.ts index a0cd6a38f..4784fe246 100644 --- a/tests/app/services/attach-service.test.ts +++ b/tests/app/services/attach-service.test.ts @@ -517,6 +517,13 @@ describe("attach/service", () => { await restorePendingInteractionsAfterReconnect({ ...deps, bot: createBot(), chatId: 777 }); expect(mocked.closeQuestionSettledOutsideMock).not.toHaveBeenCalled(); + + container.questionManager.startQuestions([], "question-2", "session-1"); + container.questionManager.startDismissal(); + + await restorePendingInteractionsAfterReconnect({ ...deps, bot: createBot(), chatId: 777 }); + + expect(mocked.closeQuestionSettledOutsideMock).not.toHaveBeenCalled(); }); it("drops waiting requests OpenCode no longer lists", async () => { diff --git a/tests/bot/callbacks/question-callback-handler.test.ts b/tests/bot/callbacks/question-callback-handler.test.ts index bb66e54e2..b821c9a1f 100644 --- a/tests/bot/callbacks/question-callback-handler.test.ts +++ b/tests/bot/callbacks/question-callback-handler.test.ts @@ -16,12 +16,14 @@ import type { AppContainer } from "../../../src/app/bootstrap/app-container.js"; const mocked = vi.hoisted(() => ({ questionReplyMock: vi.fn(), + questionRejectMock: vi.fn(), })); vi.mock("../../../src/opencode/client.js", () => ({ opencodeClient: { question: { reply: mocked.questionReplyMock, + reject: mocked.questionRejectMock, }, }, })); @@ -127,6 +129,8 @@ describe("bot question menu/callbacks", () => { container.interactionManager.clear("test_setup"); mocked.questionReplyMock.mockReset(); mocked.questionReplyMock.mockResolvedValue({ data: true, error: undefined }); + mocked.questionRejectMock.mockReset(); + mocked.questionRejectMock.mockResolvedValue({ data: true, error: undefined }); }); it("shows question details and keyboard in one message", async () => { @@ -322,7 +326,7 @@ describe("bot question menu/callbacks", () => { }); }); - it("cancels poll and clears question interaction", async () => { + it("dismisses the question in OpenCode on Cancel and then closes the poll", async () => { const api = createApi([300]); container.questionManager.startQuestions([QUESTION_ONE], "req-4", "session-1"); @@ -333,13 +337,201 @@ describe("bot question menu/callbacks", () => { expect(handled).toBe(true); expect(cancelCtx.answerCallbackQuery).toHaveBeenCalledWith({ text: t("common.cancelled") }); - expect(cancelCtx.editMessageText).toHaveBeenCalledWith(t("question.cancelled")); + expect(mocked.questionRejectMock).toHaveBeenCalledWith({ + requestID: "req-4", + directory: "D:/repo", + }); + await vi.waitFor(() => { + expect(api.editMessageText).toHaveBeenCalledWith(123, 300, t("question.cancelled")); + }); + expect(cancelCtx.editMessageText).not.toHaveBeenCalled(); expect(api.deleteMessage).not.toHaveBeenCalled(); expect(container.questionManager.isActive()).toBe(false); - expect(container.questionManager.getTotalQuestions()).toBe(0); expect(container.interactionManager.getSnapshot()).toBeNull(); }); + it("keeps the poll open and ignores its buttons while the dismissal is on its way", async () => { + const api = createApi([301]); + let resolveReject: (value: { data: boolean; error: undefined }) => void = () => {}; + mocked.questionRejectMock.mockReturnValue( + new Promise((resolve) => { + resolveReject = resolve; + }), + ); + + container.questionManager.startQuestions([QUESTION_ONE], "req-wait", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:custom:0", 301, api); + await pressButton("question:cancel:0", 301, api); + + expect(container.questionManager.isActive()).toBe(true); + expect(container.questionManager.isWaitingForCustomInput(0)).toBe(false); + expect(api.editMessageText).not.toHaveBeenCalled(); + + const selectCtx = createCallbackContext("question:select:0:0", 301, api); + await handleQuestionCallback(selectCtx, createDeps()); + await pressButton("question:cancel:0", 301, api); + + expect(selectCtx.answerCallbackQuery).toHaveBeenCalledWith(); + expect(mocked.questionReplyMock).not.toHaveBeenCalled(); + expect(mocked.questionRejectMock).toHaveBeenCalledTimes(1); + + const textCtx = createTextContext("my custom", api); + await handleQuestionTextAnswer(textCtx, createDeps()); + expect(textCtx.reply).toHaveBeenCalledWith(t("question.use_custom_button_first")); + + resolveReject({ data: true, error: undefined }); + await vi.waitFor(() => { + expect(container.questionManager.isActive()).toBe(false); + }); + }); + + it("leaves the poll answerable with a warning when the dismissal fails", async () => { + const api = createApi([302, 303]); + mocked.questionRejectMock.mockResolvedValueOnce({ + data: undefined, + error: new Error("fetch failed"), + }); + + container.questionManager.startQuestions([QUESTION_ONE], "req-fail", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:cancel:0", 302, api); + + const editMock = api.editMessageText as unknown as ReturnType; + await vi.waitFor(() => { + expect(editMock).toHaveBeenCalled(); + }); + const [, messageId, content, options] = defined(editMock.mock.calls[0]); + expect(messageId).toBe(302); + expect(JSON.stringify(content)).toContain(t("permission.delivery_failed")); + expect(JSON.stringify(content)).toContain(QUESTION_ONE.question); + expect(options).toHaveProperty("reply_markup"); + expect(container.questionManager.isActive()).toBe(true); + expect(container.questionManager.isDismissing()).toBe(false); + expect(container.questionManager.hasLastCancelFailed()).toBe(true); + + await pressButton("question:cancel:0", 302, api); + + expect(mocked.questionRejectMock).toHaveBeenCalledTimes(2); + await vi.waitFor(() => { + expect(api.editMessageText).toHaveBeenCalledWith(123, 302, t("question.cancelled")); + }); + expect(container.questionManager.isActive()).toBe(false); + }); + + it("warns instead of closing the poll when there is no request to dismiss", async () => { + const api = createApi([310]); + + container.questionManager.startQuestions([QUESTION_ONE], "", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:cancel:0", 310, api); + + expect(mocked.questionRejectMock).not.toHaveBeenCalled(); + const editMock = api.editMessageText as unknown as ReturnType; + const [, messageId, content, options] = defined(editMock.mock.calls[0]); + expect(messageId).toBe(310); + expect(JSON.stringify(content)).toContain(t("permission.delivery_failed")); + expect(options).toHaveProperty("reply_markup"); + expect(container.questionManager.isActive()).toBe(true); + }); + + it("answers normally when a choice is tapped after a failed dismissal", async () => { + const api = createApi([304, 305]); + mocked.questionRejectMock.mockResolvedValueOnce({ data: undefined, error: { name: "Boom" } }); + + container.questionManager.startQuestions([QUESTION_ONE], "req-choice", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:cancel:0", 304, api); + await vi.waitFor(() => { + expect(container.questionManager.hasLastCancelFailed()).toBe(true); + }); + + await pressButton("question:select:0:1", 304, api); + + expect(mocked.questionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ requestID: "req-choice" }), + ); + expect(container.questionManager.isActive()).toBe(false); + }); + + it.each([ + [{ name: "NotFoundError", data: { message: "gone" } }], + [{ _tag: "QuestionNotFoundError", requestID: "req-gone", message: "gone" }], + ])("closes the poll as cancelled outside when OpenCode no longer has it", async (error) => { + const api = createApi([306]); + mocked.questionRejectMock.mockResolvedValueOnce({ data: undefined, error }); + + container.questionManager.startQuestions([QUESTION_ONE], "req-gone", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:cancel:0", 306, api); + + const editMock = api.editMessageText as unknown as ReturnType; + await vi.waitFor(() => { + expect(editMock).toHaveBeenCalled(); + }); + const [, , content, options] = defined(editMock.mock.calls[0]); + expect(JSON.stringify(content)).toContain(t("question.settled_outside.cancelled")); + expect(options ?? {}).not.toHaveProperty("reply_markup"); + expect(container.questionManager.isActive()).toBe(false); + }); + + it("closes the poll as answered outside when OpenCode reported an answer meanwhile", async () => { + const api = createApi([307]); + mocked.questionRejectMock.mockImplementationOnce(async () => { + container.questionManager.noteSettledWhileDismissing("answered"); + return { data: undefined, error: { name: "NotFoundError" } }; + }); + + container.questionManager.startQuestions([QUESTION_ONE], "req-answered", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:cancel:0", 307, api); + + const editMock = api.editMessageText as unknown as ReturnType; + await vi.waitFor(() => { + expect(editMock).toHaveBeenCalled(); + }); + expect(JSON.stringify(defined(editMock.mock.calls[0])[2])).toContain( + t("question.settled_outside.answered"), + ); + }); + + it("closes the poll as cancelled when the reply was lost but OpenCode reported the dismissal", async () => { + const api = createApi([308]); + mocked.questionRejectMock.mockImplementationOnce(async () => { + container.questionManager.noteSettledWhileDismissing("cancelled"); + throw new Error("socket hang up"); + }); + + container.questionManager.startQuestions([QUESTION_ONE], "req-lost", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:cancel:0", 308, api); + + await vi.waitFor(() => { + expect(api.editMessageText).toHaveBeenCalledWith(123, 308, t("question.cancelled")); + }); + expect(container.questionManager.isActive()).toBe(false); + }); + + it("changes nothing when the poll was ended before the dismissal came back", async () => { + const api = createApi([309]); + let resolveReject: (value: { data: boolean; error: undefined }) => void = () => {}; + mocked.questionRejectMock.mockReturnValue( + new Promise((resolve) => { + resolveReject = resolve; + }), + ); + + container.questionManager.startQuestions([QUESTION_ONE], "req-reset", "session-1"); + await showCurrentQuestion(api, 123, createDeps()); + await pressButton("question:cancel:0", 309, api); + + container.interactionManager.reset("abort_command"); + resolveReject({ data: true, error: undefined }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(api.editMessageText).not.toHaveBeenCalled(); + }); + it("requires at least one selected option on multiple submit", async () => { const api = createApi([400]); diff --git a/tests/bot/services/event-subscription-service.test.ts b/tests/bot/services/event-subscription-service.test.ts index 6efc16978..2c916d8e8 100644 --- a/tests/bot/services/event-subscription-service.test.ts +++ b/tests/bot/services/event-subscription-service.test.ts @@ -435,6 +435,27 @@ function emitQuestionSettled( } as unknown as Event); } +/** OpenCode fails the question tool when its question is dismissed. */ +function emitQuestionToolError( + summaryAggregator: { processEvent(event: Event): void }, + sessionID = "session-1", +): void { + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-question", + sessionID, + messageID: "message-1", + type: "tool", + callID: "call-question", + tool: "question", + state: { status: "error", input: {}, metadata: {}, error: "dismissed" }, + }, + }, + } as unknown as Event); +} + function emitQuestionAsked( summaryAggregator: { processEvent(event: Event): void }, requestID: string, @@ -2322,6 +2343,74 @@ describe("bot/services/event-subscription-service", () => { expect(pollEdits(api, 720)[0]).toContain(t("question.settled_outside.cancelled")); }); + it("keeps a poll cancelled outside Telegram when the question tool fails right after", async () => { + const { api, summaryAggregator } = await setupService(true); + const { questionManager } = getInteractionManagers(); + api.sendMessage.mockResolvedValueOnce({ message_id: 721 }); + + emitQuestionAsked(summaryAggregator, "question-1"); + await vi.waitFor(() => { + expect(questionManager.getActiveMessageId()).toBe(721); + }); + // The edit takes a network round trip: the tool error lands while it is on its way. + api.editMessageText.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(true), 10)), + ); + + emitQuestionSettled(summaryAggregator, "question.rejected", "question-1"); + emitQuestionToolError(summaryAggregator); + await settle(); + + expect(questionManager.isActive()).toBe(false); + expect(pollEdits(api, 721)[0]).toContain(t("question.settled_outside.cancelled")); + expect(api.deleteMessage).not.toHaveBeenCalledWith(42, 721); + }); + + it("leaves a poll whose Cancel is on its way to the dismissal", async () => { + const { api, summaryAggregator } = await setupService(true); + const { questionManager } = getInteractionManagers(); + api.sendMessage.mockResolvedValueOnce({ message_id: 722 }); + + emitQuestionAsked(summaryAggregator, "question-1"); + await vi.waitFor(() => { + expect(questionManager.getActiveMessageId()).toBe(722); + }); + questionManager.startDismissal(); + + emitQuestionSettled(summaryAggregator, "question.rejected", "question-1"); + emitQuestionToolError(summaryAggregator); + await settle(); + + expect(questionManager.isActive()).toBe(true); + expect(questionManager.getSettledWhileDismissing()).toBe("cancelled"); + expect(pollEdits(api, 722)).toHaveLength(0); + expect(api.deleteMessage).not.toHaveBeenCalledWith(42, 722); + }); + + it("ends a poll whose Cancel was reported lost as cancelled once OpenCode reports it", async () => { + const { api, summaryAggregator } = await setupService(true); + const { questionManager } = getInteractionManagers(); + api.sendMessage.mockResolvedValueOnce({ message_id: 723 }); + + emitQuestionAsked(summaryAggregator, "question-1"); + await vi.waitFor(() => { + expect(questionManager.getActiveMessageId()).toBe(723); + }); + questionManager.startDismissal(); + questionManager.failDismissal(); + api.editMessageText.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(true), 10)), + ); + + emitQuestionSettled(summaryAggregator, "question.rejected", "question-1"); + emitQuestionToolError(summaryAggregator); + await settle(); + + expect(questionManager.isActive()).toBe(false); + expect(api.editMessageText).toHaveBeenCalledWith(42, 723, t("question.cancelled")); + expect(api.deleteMessage).not.toHaveBeenCalledWith(42, 723); + }); + it("leaves a poll answered from Telegram to its own summary", async () => { const { api, summaryAggregator } = await setupService(true); const { questionManager } = getInteractionManagers(); diff --git a/tests/helpers/interaction.ts b/tests/helpers/interaction.ts index 23ee21bf2..4d0518841 100644 --- a/tests/helpers/interaction.ts +++ b/tests/helpers/interaction.ts @@ -34,6 +34,9 @@ export function startInteractionForTest( requestID: null, sessionId: "session-test", answeredFromTelegram: false, + dismissing: false, + settledWhileDismissing: null, + lastCancelFailed: false, }, }); case "permission": From 53bfb2490985d3401cd01b3e76a3e0716e251dd3 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Wed, 30 Sep 2026 20:25:00 +0300 Subject: [PATCH 3/6] fix(OTB-136): end a V2 run lost with a restarted server and show the resumed run as its own turn When the OpenCode V2 server restarts mid-run (/opencode_stop then /opencode_start, a restart outside the bot, a crash), the chat now ends the interrupted turn as after /abort: running tool lines and the compact card end, no footer, and the permission prompt or poll on screen ends "Not answered" instead of "Answered outside Telegram". The run V2 resumes shows as a turn OpenCode started by itself, with its own prompt, reply and footer. The bot tells a restart from a reconnect by the server's process id; when it cannot tell, the OTB-132 line stays. No tool is shown under a made-up "unknown" name, and a pending request shown by several restores at once (bot start, attach, reconnect) gets a single prompt. V1 is unchanged. Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 9 +- src/app/bootstrap/app-container.ts | 6 + .../managers/background-session-manager.ts | 4 + src/app/managers/permission-manager.ts | 40 ++- .../managers/summary-aggregation-manager.ts | 29 +- src/app/services/attach-service.ts | 91 +++++- src/bot/commands/opencode-stop-command.ts | 3 + src/bot/events/session-runtime-state.ts | 6 + src/bot/menus/permission-menu.ts | 18 ++ src/bot/menus/question-menu.ts | 57 +++- src/bot/services/attach-presentation.ts | 2 + .../services/event-subscription-service.ts | 136 ++++++++- src/i18n/ar.ts | 1 + src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/i18n/fr.ts | 1 + src/i18n/id.ts | 1 + src/i18n/it.ts | 1 + src/i18n/ko.ts | 1 + src/i18n/pt.ts | 1 + src/i18n/ru.ts | 1 + src/i18n/tr.ts | 1 + src/i18n/zh.ts | 1 + src/opencode/events.ts | 22 +- src/opencode/v2/client.ts | 36 ++- src/opencode/v2/events.ts | 68 +++-- .../summary-aggregation-manager.test.ts | 22 ++ tests/app/services/attach-service.test.ts | 100 +++++++ tests/bot/commands/opencode-stop.test.ts | 20 ++ tests/bot/menus/permission-menu.test.ts | 41 +++ ...ent-subscription-service.lifecycle.test.ts | 6 +- ...vent-subscription-service.lost-run.test.ts | 263 ++++++++++++++++++ .../event-subscription-service.test.ts | 6 +- tests/opencode/events.test.ts | 29 ++ tests/opencode/v2/client.test.ts | 61 ++++ tests/opencode/v2/events.test.ts | 54 +++- 37 files changed, 1063 insertions(+), 78 deletions(-) create mode 100644 tests/bot/services/event-subscription-service.lost-run.test.ts diff --git a/PRODUCT.md b/PRODUCT.md index 79f43c215..2df9430f2 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -63,20 +63,23 @@ No public inbound ports are required for normal usage. - 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`); 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`), from the main agent and from subagents of the followed session -- A permission prompt stays in the chat when it ends: its buttons go and a last line names the outcome — the decision tapped here, the decision made outside Telegram (or just that it was answered there, when OpenCode does not say how), or "not answered" when it was dropped by `/abort`, `/detach`, `/opencode_stop` or the end of the run +- A permission prompt stays in the chat when it ends: its buttons go and a last line names the outcome — the decision tapped here, the decision made outside Telegram (or just that it was answered there, when OpenCode does not say how), or "not answered" when it was dropped by `/abort`, `/detach`, `/opencode_stop`, a restart of the OpenCode V2 server or the end of the run - An answer that does not reach OpenCode leaves the prompt answerable with a warning line; tapping again sends it again -- After the event stream reconnects, prompts on screen that OpenCode no longer has pending are closed as answered outside Telegram +- After the event stream reconnects to the same server, prompts and polls on screen that OpenCode no longer has pending are closed as answered (or cancelled) outside Telegram; when the bot cannot tell whether the server restarted, the same line is used +- On OpenCode V2, a poll on screen when the server goes away — `/opencode_stop`, a restart outside the bot, a crash — keeps its question text, loses its buttons and ends with `⏹ Not answered` +- Starting the bot, attaching, `/sessions` and an event-stream reconnect show one prompt per distinct pending permission request (identical ones grouped) and one poll per pending question, however many of them run at once ### 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 +- OpenCode V2 resumes a run interrupted by a server restart (`/opencode_stop` then `/opencode_start`, a restart outside the bot, a crash and auto-restart); the chat ends the interrupted turn as after `/abort` — its tool lines, cards and compact progress end, no footer — and shows the resumed run as a turn OpenCode started by itself, with its own prompts, reply and footer, timed from when the bot saw it again - 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. 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 file-changing tool (`edit`, `write`, `apply_patch`) names each file it changed on its own line with `(+N -M)`, never the patch text, on both OpenCode versions; with diff-file attachments on, each file arrives as its own document captioned with its line, and a file whose diff is over the size limit keeps its text line. 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 +- 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 file-changing tool (`edit`, `write`, `apply_patch`) names each file it changed on its own line with `(+N -M)`, never the patch text, on both OpenCode versions; with diff-file attachments on, each file arrives as its own document captioned with its line, and a file whose diff is over the size limit keeps its text line. 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. A foreground line whose call ended while the event stream was down also stays as it was; the bot never shows a tool it did not see start - 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 diff --git a/src/app/bootstrap/app-container.ts b/src/app/bootstrap/app-container.ts index 6957118ba..89724e8e1 100644 --- a/src/app/bootstrap/app-container.ts +++ b/src/app/bootstrap/app-container.ts @@ -67,6 +67,11 @@ export interface AppContainer { resetRuntimeStreams(reason: string): void; /** Stops following background operations; their lines and cards stay as they are. */ stopBackgroundOperations(reason: string, sessionId?: string): void; + /** + * On V2, ends the followed session's run in the chat as after `/abort` once its server + * is gone: the poll on screen closes as not answered, running lines end, no footer. + */ + endRunLostWithServer(reason: string): Promise; /** * Stops ready-restore, the model catalog wait, event listening and the heartbeat, * and clears runtime state. @@ -138,6 +143,7 @@ export function createAppContainer(): AppContainer { resetRuntimeStreams: (reason) => eventSubscriptionService.clearRuntimeState(reason), stopBackgroundOperations: (reason, sessionId) => eventSubscriptionService.stopBackgroundOperations(reason, sessionId), + endRunLostWithServer: (reason) => eventSubscriptionService.endRunLostWithServer(reason), cleanupProcess: (reason) => { stopReadyRestore(); diff --git a/src/app/managers/background-session-manager.ts b/src/app/managers/background-session-manager.ts index 91e5f56e3..678ed1778 100644 --- a/src/app/managers/background-session-manager.ts +++ b/src/app/managers/background-session-manager.ts @@ -1,5 +1,6 @@ import type { Event, Message, Session } from "@opencode-ai/sdk/v2"; import { isScheduledTaskSessionIgnored } from "../services/scheduled-task-session-ignore-service.js"; +import { getCurrentSession } from "../stores/settings-store.js"; import { logger } from "../../utils/logger.js"; export type BackgroundSessionNotificationKind = @@ -131,6 +132,9 @@ class BackgroundSessionTracker { } this.pendingAssistantResponsesBySessionId.delete(sessionId); + logger.info( + `[BackgroundSessionTracker] Assistant replied in a background session: session=${sessionId}, followed=${getCurrentSession()?.id ?? "none"}`, + ); this.emitNotification({ kind: "assistant_response", sessionId, diff --git a/src/app/managers/permission-manager.ts b/src/app/managers/permission-manager.ts index a0c74c68f..90716bdee 100644 --- a/src/app/managers/permission-manager.ts +++ b/src/app/managers/permission-manager.ts @@ -27,6 +27,9 @@ function createEmptyState(): PermissionState { export class PermissionManager { private resolvedRequestIDs = new Set(); private resolvedGeneration = 0; + // Prompts on their way to Telegram, by signature: a second show of the same request + // waits for the first to land instead of sending its own prompt. + private presenting = new Map>(); constructor(private readonly interactionManager: InteractionManager) {} @@ -69,6 +72,27 @@ export class PermissionManager { return this.getResolvedRequestIDs().has(request.id) ? "resolved" : null; } + /** The send of a prompt with this request's signature, while one is on its way. */ + getPresenting(request: PermissionRequest): Promise | undefined { + return this.presenting.get(this.getRequestSignature(request)); + } + + /** Marks a prompt with this request's signature as on its way; the returned call ends that. */ + claimPresenting(request: PermissionRequest): () => void { + const signature = this.getRequestSignature(request); + let release = () => {}; + const landed = new Promise((resolve) => { + release = () => { + if (this.presenting.get(signature) === landed) { + this.presenting.delete(signature); + } + resolve(); + }; + }); + this.presenting.set(signature, landed); + return release; + } + /** * Register a new permission request message */ @@ -314,9 +338,14 @@ export class PermissionManager { /** * An OpenCode request was settled: it leaves its prompt, and the prompt ends once its - * last request is settled. `reply` is the decision OpenCode reported, when it did. + * last request is settled. `reply` is the decision OpenCode reported, when it did; + * `lost` says the request went with a server that stopped, so nobody answered it. */ - settleRequest(requestID: string, reply: PermissionReply | null): PermissionPromptChange[] { + settleRequest( + requestID: string, + reply: PermissionReply | null, + lost = false, + ): PermissionPromptChange[] { this.getResolvedRequestIDs().add(requestID); this.interactionManager.dropWaitingPermission(requestID); @@ -342,7 +371,7 @@ export class PermissionManager { continue; } - const outcome = this.getSettledOutcome(state, messageId, request, requestID, reply); + const outcome = this.getSettledOutcome(state, messageId, request, requestID, reply, lost); this.removePrompt(state, messageId, request); changes.push({ messageId, request, openCount: 0, outcome }); } @@ -414,6 +443,7 @@ export class PermissionManager { request: PermissionRequest, requestID: string, reply: PermissionReply | null, + lost: boolean, ): PermissionOutcome { const send = state.sendsByMessageId.get(messageId); if (send?.requestIds.includes(requestID)) { @@ -427,6 +457,10 @@ export class PermissionManager { } } + if (lost) { + return { kind: "not_answered" }; + } + return reply ? { kind: "replied", reply, outside: true } : { kind: "settled_outside" }; } diff --git a/src/app/managers/summary-aggregation-manager.ts b/src/app/managers/summary-aggregation-manager.ts index f0f353f0c..212c05478 100644 --- a/src/app/managers/summary-aggregation-manager.ts +++ b/src/app/managers/summary-aggregation-manager.ts @@ -186,7 +186,7 @@ export interface SessionIdleInfo { interrupted: boolean; } -type SessionIdleCallback = (sessionId: string, idleInfo: SessionIdleInfo) => void; +type SessionIdleCallback = (sessionId: string, idleInfo: SessionIdleInfo) => void | Promise; type PermissionCallback = (request: PermissionRequest) => void | Promise; @@ -377,6 +377,8 @@ export class SummaryAggregator { private lastSubagentSnapshot = ""; // When the current session's running turn began, whoever started it; null between turns. private liveTurnStartedAt: number | null = null; + /** Counts ends of the current session's turns, so a late status answer can tell one passed. */ + private turnEndCount = 0; setBotAndChatId(bot: Bot, chatId: number): void { this.bot = bot; @@ -489,6 +491,23 @@ export class SummaryAggregator { this.liveTurnStartedAt = null; } + /** A mark to pass to `startMissedLiveTurn`, taken before the session status is read. */ + getTurnEndMark(): number { + return this.turnEndCount; + } + + /** + * Starts the current session's turn from now when a busy status the stream missed says it + * runs, unless a turn already runs or one ended since the mark was taken. + */ + startMissedLiveTurn(sessionId: string, mark: number): void { + if (sessionId !== this.currentSessionId || mark !== this.turnEndCount) { + return; + } + + this.liveTurnStartedAt ??= Date.now(); + } + holdOutbound(): void { this.outboundHeld = true; } @@ -689,6 +708,7 @@ export class SummaryAggregator { this.subagentRunStartedAt = 0; this.lastSubagentSnapshot = ""; this.liveTurnStartedAt = null; + this.turnEndCount++; this.permissionQueue = Promise.resolve(); this.messageCount = 0; @@ -2334,6 +2354,7 @@ export class SummaryAggregator { logger.info(`[Aggregator] Session became idle: ${sessionID}`); this.emitSessionRunEnded(sessionID); this.liveTurnStartedAt = null; + this.turnEndCount++; this.acceptsSubagentEvents = false; this.retireForegroundSubagents(); @@ -2343,9 +2364,8 @@ export class SummaryAggregator { if (this.onSessionIdleCallback) { const callback = this.onSessionIdleCallback; const idleInfo: SessionIdleInfo = { interrupted: isInterruptedIdle(event.properties) }; - this.scheduleOutbound(() => { - callback(sessionID, idleInfo); - }, true); + // Returned, so a drain of held work waits for the run to be closed in the chat. + this.scheduleOutbound(() => callback(sessionID, idleInfo), true); } } @@ -2401,6 +2421,7 @@ export class SummaryAggregator { logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`); this.emitSessionRunEnded(sessionID); this.liveTurnStartedAt = null; + this.turnEndCount++; this.acceptsSubagentEvents = false; this.retireForegroundSubagents(); this.stopTypingIndicator(); diff --git a/src/app/services/attach-service.ts b/src/app/services/attach-service.ts index 34af29283..81e37bf8e 100644 --- a/src/app/services/attach-service.ts +++ b/src/app/services/attach-service.ts @@ -36,6 +36,7 @@ export interface AttachPresentationDeps { changes: PermissionPromptChange[], ): Promise; closeQuestionSettledOutside(api: Bot["api"], chatId: number): Promise; + closeQuestionNotAnswered(api: Bot["api"], chatId: number): Promise; } type PendingQuestion = NonNullable< @@ -244,16 +245,18 @@ function snapshotTrackedRequests(deps: RestoreAfterReconnectDeps): TrackedReques /** * After a reconnect, prompts on screen that OpenCode no longer has pending were settled - * while the stream was down: they end as answered outside Telegram, and waiting requests - * that are gone leave the queue. Only requests tracked before the lists were requested - * are checked — one that arrived meanwhile is missing from the lists without being - * settled. Answers or a dismissal being sent from Telegram are left to that send. + * while the stream was down: they end as answered outside Telegram — or as not answered + * when the server restarted, since they went with it — and waiting requests that are gone + * leave the queue. Only requests tracked before the lists were requested are checked — one + * that arrived meanwhile is missing from the lists without being settled. Answers or a + * dismissal being sent from Telegram are left to that send. */ async function settleRequestsGoneWhileDisconnected( deps: RestoreAfterReconnectDeps, tracked: TrackedRequestsSnapshot, questions: PendingQuestion[] | null, permissions: PendingPermission[] | null, + serverRestarted: boolean, ): Promise { if (questions) { const pendingIds = new Set(questions.map((request) => request.id)); @@ -269,7 +272,11 @@ async function settleRequestsGoneWhileDisconnected( !deps.questionManager.isSettlingFromTelegram() && attachPresentation ) { - await attachPresentation.closeQuestionSettledOutside(deps.bot.api, deps.chatId); + if (serverRestarted) { + await attachPresentation.closeQuestionNotAnswered(deps.bot.api, deps.chatId); + } else { + await attachPresentation.closeQuestionSettledOutside(deps.bot.api, deps.chatId); + } } } @@ -282,13 +289,32 @@ async function settleRequestsGoneWhileDisconnected( const settleable = new Set(deps.permissionManager.getSettleableRequestIds()); const changes = tracked.shownPermissionIds .filter((requestID) => !pendingIds.has(requestID) && settleable.has(requestID)) - .flatMap((requestID) => deps.permissionManager.settleRequest(requestID, null)); + .flatMap((requestID) => + deps.permissionManager.settleRequest(requestID, null, serverRestarted), + ); if (changes.length > 0 && attachPresentation) { await attachPresentation.applyPermissionPromptChanges(deps.bot.api, deps.chatId, changes); } } } +/** A poll already on screen or waiting, so a restore must not show it again. */ +function isQuestionTracked(deps: AttachRestoreDeps, requestID: string): boolean { + return ( + deps.questionManager.getRequestID() === requestID || + deps.interactionManager.isWaitingOrReleasing(requestID) + ); +} + +/** A permission already on screen, settled or waiting, so a restore must not show it again. */ +function isPermissionTracked(deps: AttachRestoreDeps, request: PermissionRequest): boolean { + return ( + deps.permissionManager.hasRequest(request.id) || + deps.permissionManager.isResolved(request.id) || + deps.interactionManager.isWaitingOrReleasing(request.id) + ); +} + export async function attachToSession(deps: AttachSessionDeps): Promise { const { bot, chatId, session, ensureEventSubscription, forceFullRestore = false } = deps; const { attachManager, permissionManager, questionManager, summaryAggregator } = deps; @@ -349,6 +375,7 @@ export async function attachToSession(deps: AttachSessionDeps): Promise isQuestionTracked(deps, requestID), ) : false; @@ -361,6 +388,7 @@ export async function attachToSession(deps: AttachSessionDeps): Promise isPermissionTracked(deps, request), ) : 0; } @@ -443,14 +471,38 @@ async function dropSavedSessionIfMissing( return true; } +/** + * After a restart V2 resumes the interrupted run before the stream is back, so the resumed + * turn's busy status was never seen: a busy session starts its turn from now, unless a turn + * ended since `mark` was taken. + */ +async function startMissedLiveTurn( + deps: RestoreAfterReconnectDeps, + sessionId: string, + directory: string, + mark: number, +): Promise { + const { data: statuses, error } = await opencodeClient.session.status({ directory }); + if (error) { + logger.warn("[Attach] Failed to load session status after a server restart:", error); + return; + } + + if (getAttachBusyStatus(sessionId, statuses)) { + deps.summaryAggregator.startMissedLiveTurn(sessionId, mark); + } +} + /** * The event stream does not replay what was missed while it was down, so after a reconnect * the prompts on screen are checked against what OpenCode still has pending, and the * followed session's pending questions and permissions are loaded again. Anything still - * on screen or waiting is left alone. + * on screen or waiting is left alone. `serverRestarted` says the reconnect reached another + * server process than before. */ export async function restorePendingInteractionsAfterReconnect( deps: RestoreAfterReconnectDeps, + serverRestarted = false, ): Promise { const attached = deps.attachManager.getSnapshot(); if (!attached) { @@ -461,9 +513,23 @@ export async function restorePendingInteractionsAfterReconnect( const [pendingQuestions, pendingPermissions] = await Promise.all([ listPendingQuestions(attached.directory), listPendingPermissions(attached.directory), + serverRestarted + ? startMissedLiveTurn( + deps, + attached.sessionId, + attached.directory, + deps.summaryAggregator.getTurnEndMark(), + ) + : undefined, ]); - await settleRequestsGoneWhileDisconnected(deps, tracked, pendingQuestions, pendingPermissions); + await settleRequestsGoneWhileDisconnected( + deps, + tracked, + pendingQuestions, + pendingPermissions, + serverRestarted, + ); const restoredQuestion = pendingQuestions ? await restorePendingQuestions( @@ -473,9 +539,7 @@ export async function restorePendingInteractionsAfterReconnect( attached.sessionId, attached.directory, pendingQuestions, - (requestID) => - deps.questionManager.getRequestID() === requestID || - deps.interactionManager.isWaitingOrReleasing(requestID), + (requestID) => isQuestionTracked(deps, requestID), ) : false; @@ -487,10 +551,7 @@ export async function restorePendingInteractionsAfterReconnect( attached.sessionId, attached.directory, pendingPermissions, - (request) => - deps.permissionManager.hasRequest(request.id) || - deps.permissionManager.isResolved(request.id) || - deps.interactionManager.isWaitingOrReleasing(request.id), + (request) => isPermissionTracked(deps, request), ) : 0; diff --git a/src/bot/commands/opencode-stop-command.ts b/src/bot/commands/opencode-stop-command.ts index ea3e8ac77..c0742dc8f 100644 --- a/src/bot/commands/opencode-stop-command.ts +++ b/src/bot/commands/opencode-stop-command.ts @@ -18,6 +18,7 @@ import { clearPromptResponseMode } from "../handlers/prompt.js"; export type OpencodeStopCommandDeps = Pick< AppContainer, | "attachManager" + | "endRunLostWithServer" | "foregroundSessionState" | "opencodeReadyLifecycle" | "resetInteractions" @@ -38,6 +39,8 @@ async function releaseLocalStateAfterServerStop(deps: OpencodeStopCommandDeps): sessionIds.add(attached.sessionId); } + // The stopped server never ends its run: the chat ends it as after /abort (V2 only). + await deps.endRunLostWithServer(STOP_REASON); deps.resetRuntimeStreams(STOP_REASON); deps.foregroundSessionState.clearAll(STOP_REASON); diff --git a/src/bot/events/session-runtime-state.ts b/src/bot/events/session-runtime-state.ts index ec8e04c00..8e2474a5a 100644 --- a/src/bot/events/session-runtime-state.ts +++ b/src/bot/events/session-runtime-state.ts @@ -523,6 +523,12 @@ export class SessionRuntimeState { return this.runningToolInfos.get(sessionKey(sessionId, callId)); } + getRunningToolInfos(sessionId: string): ToolInfo[] { + return Array.from(this.runningToolInfos.values()).filter( + (info) => info.sessionId === sessionId, + ); + } + setRunningToolInfo(toolInfo: ToolInfo): void { this.runningToolInfos.set(sessionKey(toolInfo.sessionId, toolInfo.callId), toolInfo); } diff --git a/src/bot/menus/permission-menu.ts b/src/bot/menus/permission-menu.ts index d24142027..ca6d7227d 100644 --- a/src/bot/menus/permission-menu.ts +++ b/src/bot/menus/permission-menu.ts @@ -106,11 +106,26 @@ export async function showPermissionRequest( const { interactionManager, permissionManager, summaryAggregator } = deps; logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`); + // Restores and the event stream can show one request side by side: an equivalent prompt + // on its way is waited for, then this request joins it or, when it is the same, stops. + for ( + let inFlight = permissionManager.getPresenting(request); + inFlight; + inFlight = permissionManager.getPresenting(request) + ) { + await inFlight; + } + if (permissionManager.getDropReason(request, generation)) { logger.debug(`[PermissionHandler] Skipping stale or already resolved request: ${request.id}`); return; } + if (permissionManager.hasRequest(request.id)) { + logger.debug(`[PermissionHandler] Skipping request already on screen: ${request.id}`); + return; + } + const grouped = permissionManager.addEquivalentRequest(request, generation); if (grouped) { // Re-render the visible prompt so the user can see the answer will apply @@ -138,6 +153,7 @@ export async function showPermissionRequest( const text = formatPermissionText(request); const keyboard = buildPermissionKeyboard(); + const releasePresenting = permissionManager.claimPresenting(request); try { const message = await bot.sendMessage(chatId, text, { @@ -167,6 +183,8 @@ export async function showPermissionRequest( } catch (err) { logger.error("[PermissionHandler] Failed to send permission message:", err); throw err; + } finally { + releasePresenting(); } } diff --git a/src/bot/menus/question-menu.ts b/src/bot/menus/question-menu.ts index 2adf65d8a..77ef162f2 100644 --- a/src/bot/menus/question-menu.ts +++ b/src/bot/menus/question-menu.ts @@ -231,28 +231,63 @@ export async function closeQuestionSettledOutside( outcome: QuestionSettledOutcome, deps: QuestionStateDeps, ): Promise { - const { questionManager } = deps; - const question = questionManager.getCurrentQuestion(); - const messageId = questionManager.getActiveMessageId(); - const line = t( - outcome === "answered" - ? "question.settled_outside.answered" - : "question.settled_outside.cancelled", + logger.info( + `[QuestionHandler] Poll settled outside Telegram: requestID=${deps.questionManager.getRequestID()}, outcome=${outcome}`, ); - const part = question ? formatQuestionDetailsPart(question, deps, line) : null; + await closeQuestionWithLine( + bot, + chatId, + t( + outcome === "answered" + ? "question.settled_outside.answered" + : "question.settled_outside.cancelled", + ), + "question_settled_outside", + deps, + ); +} +/** + * Closes the poll on screen that OpenCode lost with its server (stopped or restarted): it + * keeps its text, loses its buttons and says it was not answered. + */ +export async function closeQuestionNotAnswered( + bot: Context["api"], + chatId: number, + deps: QuestionStateDeps, +): Promise { logger.info( - `[QuestionHandler] Poll settled outside Telegram: requestID=${questionManager.getRequestID()}, outcome=${outcome}`, + `[QuestionHandler] Poll lost with the OpenCode server: requestID=${deps.questionManager.getRequestID()}`, + ); + await closeQuestionWithLine( + bot, + chatId, + t("question.not_answered"), + "question_not_answered", + deps, ); +} + +async function closeQuestionWithLine( + bot: Context["api"], + chatId: number, + line: string, + reason: "question_settled_outside" | "question_not_answered", + deps: QuestionStateDeps, +): Promise { + const { questionManager } = deps; + const question = questionManager.getCurrentQuestion(); + const messageId = questionManager.getActiveMessageId(); + const part = question ? formatQuestionDetailsPart(question, deps, line) : null; // Release the poll before the edit: the question tool's error, which follows a dismissal, // must not find it active and delete it. - clearQuestionInteraction("question_settled_outside", deps); + clearQuestionInteraction(reason, deps); questionManager.clear(); if (part && messageId !== null) { await editRenderedBotPart({ api: bot, chatId, messageId, part }).catch((err) => { - logger.warn("[QuestionHandler] Failed to close the settled poll message:", err); + logger.warn("[QuestionHandler] Failed to close the poll message:", err); }); } } diff --git a/src/bot/services/attach-presentation.ts b/src/bot/services/attach-presentation.ts index 909c7367c..5934a655b 100644 --- a/src/bot/services/attach-presentation.ts +++ b/src/bot/services/attach-presentation.ts @@ -6,6 +6,7 @@ import { type PermissionMenuDeps, } from "../menus/permission-menu.js"; import { + closeQuestionNotAnswered, closeQuestionSettledOutside, showCurrentQuestion, type QuestionMenuDeps, @@ -63,5 +64,6 @@ export function createAttachPresentation( applyPermissionPromptChanges(api, chatId, changes, deps), closeQuestionSettledOutside: (api, chatId) => closeQuestionSettledOutside(api, chatId, "answered", deps), + closeQuestionNotAnswered: (api, chatId) => closeQuestionNotAnswered(api, chatId, deps), }; } diff --git a/src/bot/services/event-subscription-service.ts b/src/bot/services/event-subscription-service.ts index 3b8bf6548..175363fc0 100644 --- a/src/bot/services/event-subscription-service.ts +++ b/src/bot/services/event-subscription-service.ts @@ -1,5 +1,7 @@ import { Bot, Context } from "grammy"; +import type { Event } from "@opencode-ai/sdk/v2"; import { config } from "../../config.js"; +import { opencodeServerVersion } from "../../opencode/client.js"; import { getCurrentSession } from "../../app/services/session-service.js"; import { restorePendingInteractionsAfterReconnect } from "../../app/services/attach-service.js"; import { logger } from "../../utils/logger.js"; @@ -7,7 +9,13 @@ import { clearPromptResponseMode } from "../handlers/prompt.js"; import { setPromptResponseModeClearerForReconciliation } from "../../app/services/busy-reconciliation-service.js"; import { createEventRouter } from "../../app/services/event-router.js"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; -import { stopEventListening, subscribeToEvents } from "../../opencode/events.js"; +import { + stopEventListening, + subscribeToEvents, + type ReconnectInfo, +} from "../../opencode/events.js"; +import type { ToolInfo } from "../../app/managers/summary-aggregation-manager.js"; +import { closeQuestionNotAnswered } from "../menus/question-menu.js"; import { SessionRuntimeState } from "../events/session-runtime-state.js"; import type { SessionTargetPolicy, @@ -26,6 +34,7 @@ export interface BotEventSubscriptionService { setTelegramContext(bot: Bot | null, chatId: number | null): void; clearRuntimeState(reason: string): void; stopBackgroundOperations(reason: string, sessionId?: string): void; + endRunLostWithServer(reason: string): Promise; cleanup(reason: string): void; } @@ -45,6 +54,50 @@ export type EventSubscriptionServiceDeps = Pick< | "summaryAggregator" >; +/** The failure OpenCode reports for a call it stopped, built for a call whose server went away. */ +function buildFailedToolEvent(tool: ToolInfo, now: number): Event { + const start = "time" in tool.state && tool.state.time ? tool.state.time.start : now; + return { + id: `${tool.callId}:lost`, + type: "message.part.updated", + properties: { + sessionID: tool.sessionId, + part: { + id: tool.callId, + sessionID: tool.sessionId, + messageID: tool.messageId, + type: "tool", + callID: tool.callId, + tool: tool.tool, + state: { + status: "error", + input: tool.input ?? {}, + error: "", + ...(tool.metadata ? { metadata: tool.metadata } : {}), + time: { start, end: now }, + }, + }, + time: now, + }, + }; +} + +/** The idle OpenCode sends for an interrupted execution, marked the way the V2 adapter does. */ +function buildInterruptedIdleEvents(sessionId: string): Event[] { + return [ + { + id: `${sessionId}:lost:status`, + type: "session.status", + properties: { sessionID: sessionId, status: { type: "idle" } }, + }, + { + id: `${sessionId}:lost:idle`, + type: "session.idle", + properties: { sessionID: sessionId, interrupted: true } as { sessionID: string }, + }, + ]; +} + export function createEventSubscriptionService( deps: EventSubscriptionServiceDeps, ): BotEventSubscriptionService { @@ -133,18 +186,80 @@ class EventSubscriptionService implements BotEventSubscriptionService { deps, isForegroundSession: policy.isForegroundSession, }), - () => this.restoreAfterReconnect(), + (info) => this.restoreAfterReconnect(info), ).catch((err) => { logger.error("Failed to subscribe to events:", err); }); }; - 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(); + /** + * Ends the followed session's run in the chat the way `/abort` does, for a V2 server that + * ended it without saying so (stopped or restarted): the poll on screen closes as not + * answered, then the events OpenCode sends on an abort — each running call failed, then + * an interrupted idle — go through the usual pipeline. Everything up to the first await + * runs at once, so events that arrive later are handled after the ending. + */ + endRunLostWithServer = async (reason: string): Promise => { + if (opencodeServerVersion !== "v2") { + return; + } + + const { deps, runtime } = this; + const destination = this.getChatDestination(); + // First: failing the poll's question call below would delete the poll, as after /abort. + const pollClosed = + destination && deps.questionManager.isActive() + ? closeQuestionNotAnswered(destination.api, destination.chatId, deps) + : Promise.resolve(); + + // The server took every session's background operations with it. + this.stopBackgroundOperations(reason); + deps.summaryAggregator.forgetLiveTurn(); + + const sessionId = getCurrentSession()?.id; + if (!sessionId) { + await pollClosed; + return; + } + + const runningTools = runtime.getRunningToolInfos(sessionId); + if (!deps.assistantRunState.hasRun(sessionId) && runningTools.length === 0) { + await pollClosed; + return; + } + + logger.info( + `[Bot] Ending the run lost with the OpenCode server: session=${sessionId}, runningTools=${runningTools.length}, reason=${reason}`, + ); + deps.assistantRunState.clearRun(sessionId, reason); + + const { summaryAggregator } = deps; + summaryAggregator.holdOutbound(); + const now = Date.now(); + for (const tool of runningTools) { + summaryAggregator.processEvent(buildFailedToolEvent(tool, now)); + } + for (const event of buildInterruptedIdleEvents(sessionId)) { + summaryAggregator.processEvent(event); + } + + await Promise.all([pollClosed, summaryAggregator.drainOutbound(0)]); + }; + + private restoreAfterReconnect({ serverRestarted }: ReconnectInfo): void { + if (serverRestarted === true) { + // The restarted server lost the run and resumes it as a new turn: the old one ends + // here before any event of the new connection is handled. + this.endRunLostWithServer("opencode_restarted").catch((error) => { + logger.warn("[Bot] Failed to end the run lost with the OpenCode server:", error); + }); + } else { + // 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; @@ -152,7 +267,10 @@ class EventSubscriptionService implements BotEventSubscriptionService { return; } - restorePendingInteractionsAfterReconnect({ ...this.deps, bot, chatId }).catch((error) => { + restorePendingInteractionsAfterReconnect( + { ...this.deps, bot, chatId }, + serverRestarted === true, + ).catch((error) => { logger.warn("[Bot] Failed to restore pending requests after event stream reconnect:", error); }); } diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index d5a0a3bd3..8d0cd5836 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -407,6 +407,7 @@ export const ar: I18nDictionary = { "question.cancelled": "❌ تم إلغاء الاستبيان", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "تم استلام الإجابة بالفعل، يرجى الانتظار...", "question.completed_no_answers": "✅ اكتمل الاستبيان (بدون إجابات)", "question.no_active_project": "❌ لا يوجد مشروع نشط", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index c215582a6..5f78cd6f2 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -436,6 +436,7 @@ export const de: I18nDictionary = { "question.cancelled": "❌ Umfrage abgebrochen", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Antwort bereits erhalten, bitte warten...", "question.completed_no_answers": "✅ Umfrage abgeschlossen (keine Antworten)", "question.no_active_project": "❌ Kein aktives Projekt", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1fc7cf4c8..61e91c136 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -417,6 +417,7 @@ export const en = { "question.cancelled": "❌ Poll cancelled", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Answer already received, please wait...", "question.completed_no_answers": "✅ Poll completed (no answers)", "question.no_active_project": "❌ No active project", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2330a2ef6..630b5cf62 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -433,6 +433,7 @@ export const es: I18nDictionary = { "question.cancelled": "❌ Encuesta cancelada", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Respuesta ya recibida, espera...", "question.completed_no_answers": "✅ Encuesta completada (sin respuestas)", "question.no_active_project": "❌ No hay un proyecto activo", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index b285978ce..34aec8c08 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -437,6 +437,7 @@ export const fr: I18nDictionary = { "question.cancelled": "❌ Sondage annulé", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Réponse déjà reçue, veuillez patienter...", "question.completed_no_answers": "✅ Sondage terminé (aucune réponse)", "question.no_active_project": "❌ Aucun projet actif", diff --git a/src/i18n/id.ts b/src/i18n/id.ts index b44cd8173..43e422a8a 100644 --- a/src/i18n/id.ts +++ b/src/i18n/id.ts @@ -416,6 +416,7 @@ export const id: I18nDictionary = { "question.cancelled": "❌ Pertanyaan dibatalkan", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Jawaban sudah diterima, tunggu sebentar...", "question.completed_no_answers": "✅ Pertanyaan selesai (tanpa jawaban)", "question.no_active_project": "❌ Tidak ada proyek aktif", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index d6787729c..df73a1103 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -432,6 +432,7 @@ export const it: I18nDictionary = { "question.cancelled": "❌ Sondaggio annullato", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Risposta già ricevuta, attendi...", "question.completed_no_answers": "✅ Sondaggio completato (nessuna risposta)", "question.no_active_project": "❌ Nessun progetto attivo", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index aa31bf75b..d6568ecc8 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -426,6 +426,7 @@ export const ko: I18nDictionary = { "question.cancelled": "❌ 설문이 취소되었습니다", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "답변이 이미 접수되었습니다. 잠시만 기다려 주세요...", "question.completed_no_answers": "✅ 설문 완료 (답변 없음)", "question.no_active_project": "❌ 활성 프로젝트가 없습니다", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 2ea1851cb..dce0e2f41 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -434,6 +434,7 @@ export const pt: I18nDictionary = { "question.cancelled": "❌ Enquete cancelada", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Resposta já recebida, aguarde...", "question.completed_no_answers": "✅ Enquete concluída (sem respostas)", "question.no_active_project": "❌ Nenhum projeto ativo", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 7794559e5..bb507b904 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -420,6 +420,7 @@ export const ru: I18nDictionary = { "question.cancelled": "❌ Опрос отменен", "question.settled_outside.answered": "☑️ Ответ дан вне Telegram", "question.settled_outside.cancelled": "❌ Отменено вне Telegram", + "question.not_answered": "⏹ Без ответа", "question.answer_already_received": "Ответ уже получен, подождите...", "question.completed_no_answers": "✅ Опрос завершен (без ответов)", "question.no_active_project": "❌ Нет активного проекта", diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 172955b38..d6936585f 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -417,6 +417,7 @@ export const tr: I18nDictionary = { "question.cancelled": "❌ Anket iptal edildi", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "Yanıt zaten alındı, lütfen bekleyin...", "question.completed_no_answers": "✅ Anket tamamlandı (yanıt yok)", "question.no_active_project": "❌ Etkin proje yok", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 6f09db585..e32c16ecd 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -385,6 +385,7 @@ export const zh: I18nDictionary = { "question.cancelled": "❌ 投票已取消", "question.settled_outside.answered": "☑️ Answered outside Telegram", "question.settled_outside.cancelled": "❌ Cancelled outside Telegram", + "question.not_answered": "⏹ Not answered", "question.answer_already_received": "已收到答案,请稍候...", "question.completed_no_answers": "✅ 投票完成(无答案)", "question.no_active_project": "❌ 没有活动项目", diff --git a/src/opencode/events.ts b/src/opencode/events.ts index 680a079ad..36b55c581 100644 --- a/src/opencode/events.ts +++ b/src/opencode/events.ts @@ -11,8 +11,16 @@ export interface EventEnvelope { } export type EventCallback = (envelope: EventEnvelope) => void; +/** + * What a reconnect tells about the server: `serverRestarted` is true when it is another + * process than before, false when it is the same one, null when that cannot be told (V1, + * or no answer). + */ +export interface ReconnectInfo { + serverRestarted: boolean | null; +} /** Runs once the stream delivers again after it dropped, since missed events are not replayed. */ -export type ReconnectCallback = () => void; +export type ReconnectCallback = (info: ReconnectInfo) => void; type EventStreamSource = "global" | "legacy"; type EventStreamSubscription = { source: EventStreamSource; @@ -187,6 +195,15 @@ function normalizeEvent( return { directory, event: rawEvent }; } +/** The V2 adapter marks the connect event with whether the server restarted. */ +function getReconnectInfo(event: Event): ReconnectInfo { + if (event.type !== "server.connected") { + return { serverRestarted: null }; + } + const restarted = (event.properties as { restarted?: unknown }).restarted; + return { serverRestarted: typeof restarted === "boolean" ? restarted : null }; +} + async function subscribeToGlobalEventStream(signal: AbortSignal): Promise { const globalEvents = (opencodeClient as OptionalGlobalEventClient).global; if (!globalEvents?.event) { @@ -334,6 +351,7 @@ export async function subscribeToEvents( if (streamDropped) { streamDropped = false; const reconnectSnapshot = reconnectCallback; + const reconnectInfo = getReconnectInfo(normalizedEvent.event); if (reconnectSnapshot) { setImmediate(() => { if (streamAbortController !== controller || listenerGeneration !== generation) { @@ -341,7 +359,7 @@ export async function subscribeToEvents( } try { - reconnectSnapshot(); + reconnectSnapshot(reconnectInfo); } catch (error) { logger.error("[Events] Reconnect callback failed:", error); } diff --git a/src/opencode/v2/client.ts b/src/opencode/v2/client.ts index 156e72e03..328b6b524 100644 --- a/src/opencode/v2/client.ts +++ b/src/opencode/v2/client.ts @@ -78,6 +78,7 @@ const MESSAGE_PAGE_SIZE = 200; const MAX_MESSAGE_PAGES = 50; const COMPLETION_POLL_INTERVAL_MS = 250; const COMPLETION_POLL_ATTEMPTS = 40; +const SERVER_IDENTITY_TIMEOUT_MS = 2_000; function isNotFound(error: Error): boolean { // Declared answers are named after their tag (SessionNotFoundError, ...); an undeclared @@ -147,6 +148,36 @@ export function createV2OpencodeClient(options: V2ClientOptions): OpencodeClient forms.set(form.id, form); }; + // The process behind the last stream connection: a different one on the next connection + // means the server restarted in between. + let lastServerPid: number | null = null; + + /** Whether the server restarted since the previous connection; undefined when unknown. */ + async function readServerRestart(): Promise { + let timer: ReturnType | undefined; + try { + const info = await Promise.race([ + client.server.info(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), SERVER_IDENTITY_TIMEOUT_MS); + }), + ]); + if (!info) { + // The server behind this connection is unknown: the next answer cannot be compared. + lastServerPid = null; + return undefined; + } + const previousPid = lastServerPid; + lastServerPid = info.pid; + return previousPid === null ? undefined : previousPid !== info.pid; + } catch { + lastServerPid = null; + return undefined; + } finally { + clearTimeout(timer); + } + } + async function applySelection( sessionID: string, agent: string | undefined, @@ -313,7 +344,10 @@ export function createV2OpencodeClient(options: V2ClientOptions): OpencodeClient ...(signal ? { signal } : {}), onActivity, })) { - for (const translated of translate(event)) { + // Read before the connect is handed on, so it is known ahead of the events after it. + const restarted = + event.type === "server.connected" ? await readServerRestart() : undefined; + for (const translated of translate(event, restarted)) { if (translated.payload.type === "permission.asked") { permissionSessions.set( translated.payload.properties.id, diff --git a/src/opencode/v2/events.ts b/src/opencode/v2/events.ts index 834602c50..3665f5569 100644 --- a/src/opencode/v2/events.ts +++ b/src/opencode/v2/events.ts @@ -23,6 +23,14 @@ type IdleEventProperties = Extract["properties" interrupted?: true; }; +/** + * A V1 connect, plus whether the server behind it is another process than the one the + * previous connection reached; absent when that cannot be told. + */ +export interface ConnectedEventProperties { + restarted?: boolean; +} + interface AssistantMessageState { sessionID: string; created: number; @@ -172,20 +180,6 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) }; }; - const ensureToolCall = ( - sessionID: string, - messageID: string, - callID: string, - created: number, - ) => { - let call = tools.get(callID); - if (!call) { - call = { sessionID, messageID, tool: "unknown", input: {}, metadata: {}, start: created }; - tools.set(callID, call); - } - return call; - }; - 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 @@ -238,13 +232,18 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) return part ? [partUpdated(part, created)] : []; }; - const translatePayload = (event: OpenCodeEvent): Event[] => { + const translatePayload = (event: OpenCodeEvent, restarted?: boolean): Event[] => { const created = "created" in event && typeof event.created === "number" ? event.created : Date.now(); switch (event.type) { - case "server.connected": - return [{ id: event.id, type: "server.connected", properties: {} }]; + case "server.connected": { + // Calls started before this connection never get a start here: their later events + // are dropped rather than shown under a made-up name. + tools.clear(); + const properties: ConnectedEventProperties = restarted === undefined ? {} : { restarted }; + return [{ id: event.id, type: "server.connected", properties } as Event]; + } case "session.created": { const data = event.data; @@ -531,15 +530,24 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) case "session.tool.input.started": { const data = event.data; - const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); - call.tool = toV1ToolName(data.name); + tools.set(data.id, { + sessionID: data.sessionID, + messageID: data.assistantMessageID, + tool: toV1ToolName(data.name), + input: {}, + metadata: {}, + start: created, + }); const part = toolPart(data.id, { status: "pending", input: {}, raw: "" }); return part ? [partUpdated(part, created)] : []; } case "session.tool.called": { const data = event.data; - const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); + const call = tools.get(data.id); + if (!call) { + return []; + } call.input = toV1ToolInput(call.tool, data.input); call.start = created; const part = toolPart(data.id, { @@ -553,7 +561,10 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) case "session.tool.progress": { const data = event.data; - const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); + const call = tools.get(data.id); + if (!call) { + return []; + } call.metadata = toV1ToolMetadata(call.tool, { ...call.metadata, ...data.metadata }); const part = toolPart(data.id, { status: "running", @@ -566,7 +577,10 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) case "session.tool.success": { const data = event.data; - const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); + const call = tools.get(data.id); + if (!call) { + return []; + } const metadata = toV1ToolMetadata(call.tool, { ...call.metadata, ...(data.metadata ?? {}), @@ -607,7 +621,10 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) case "session.tool.failed": { const data = event.data; - const call = ensureToolCall(data.sessionID, data.assistantMessageID, data.id, created); + const call = tools.get(data.id); + if (!call) { + return []; + } const part = toolPart(data.id, { status: "error", input: call.input, @@ -675,7 +692,8 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) } }; - return (event: OpenCodeEvent): V1GlobalEvent[] => { + /** `restarted` is the connect event's mark, when the caller could tell. */ + return (event: OpenCodeEvent, restarted?: boolean): V1GlobalEvent[] => { const location = "location" in event ? event.location?.directory : undefined; const data = "data" in event ? (event.data as { sessionID?: unknown }) : undefined; const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined; @@ -683,7 +701,7 @@ export function createV2EventTranslator(options: V2EventTranslatorOptions = {}) rememberDirectory(sessionID, location); } const directory = location ?? (sessionID ? sessions.get(sessionID)?.directory : undefined); - return translatePayload(event).map((payload) => ({ + return translatePayload(event, restarted).map((payload) => ({ ...(directory ? { directory } : {}), payload, })); diff --git a/tests/app/managers/summary-aggregation-manager.test.ts b/tests/app/managers/summary-aggregation-manager.test.ts index 81430d2e5..e9cfa90b9 100644 --- a/tests/app/managers/summary-aggregation-manager.test.ts +++ b/tests/app/managers/summary-aggregation-manager.test.ts @@ -1954,6 +1954,28 @@ describe("summary/aggregator", () => { expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBe(5_000); }); + it("starts a turn the stream missed unless one runs or one ended since the mark", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_000); + summaryAggregator.setSession("session-1"); + + const mark = summaryAggregator.getTurnEndMark(); + summaryAggregator.startMissedLiveTurn("session-2", mark); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); + + summaryAggregator.startMissedLiveTurn("session-1", mark); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBe(1_000); + + vi.setSystemTime(2_000); + summaryAggregator.startMissedLiveTurn("session-1", mark); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBe(1_000); + + const staleMark = summaryAggregator.getTurnEndMark(); + emitIdle("session-1"); + summaryAggregator.startMissedLiveTurn("session-1", staleMark); + expect(summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); + }); + it("forgets the turn on a session error, a reconnect and a session change", () => { summaryAggregator.setSession("session-1"); diff --git a/tests/app/services/attach-service.test.ts b/tests/app/services/attach-service.test.ts index 4784fe246..e9b83ecdc 100644 --- a/tests/app/services/attach-service.test.ts +++ b/tests/app/services/attach-service.test.ts @@ -44,6 +44,9 @@ const mocked = vi.hoisted(() => ({ showCurrentQuestionMock: vi.fn(), showPermissionRequestMock: vi.fn(), closeQuestionSettledOutsideMock: vi.fn(), + closeQuestionNotAnsweredMock: vi.fn(), + getTurnEndMarkMock: vi.fn(() => 7), + startMissedLiveTurnMock: vi.fn(), applyPermissionPromptChangesMock: vi.fn(), ensureEventSubscriptionMock: vi.fn(), stopEventListeningMock: vi.fn(), @@ -83,6 +86,7 @@ vi.mock("../../../src/opencode/events.js", () => ({ vi.mock("../../../src/bot/menus/question-menu.js", () => ({ showCurrentQuestion: mocked.showCurrentQuestionMock, closeQuestionSettledOutside: mocked.closeQuestionSettledOutsideMock, + closeQuestionNotAnswered: mocked.closeQuestionNotAnsweredMock, })); vi.mock("../../../src/bot/menus/permission-menu.js", () => ({ @@ -97,6 +101,8 @@ function createDeps(): AppContainer { setSession: mocked.setSessionSummaryMock, setBotAndChatId: mocked.setBotAndChatIdMock, registerRestoredPermissionChild: mocked.registerRestoredPermissionChildMock, + getTurnEndMark: mocked.getTurnEndMarkMock, + startMissedLiveTurn: mocked.startMissedLiveTurnMock, clear: vi.fn(), } as unknown as AppContainer["summaryAggregator"], pinnedMessageManager: { @@ -202,6 +208,9 @@ describe("attach/service", () => { mocked.showPermissionRequestMock.mockResolvedValue(undefined); mocked.closeQuestionSettledOutsideMock.mockReset(); mocked.closeQuestionSettledOutsideMock.mockResolvedValue(undefined); + mocked.closeQuestionNotAnsweredMock.mockReset(); + mocked.closeQuestionNotAnsweredMock.mockResolvedValue(undefined); + mocked.startMissedLiveTurnMock.mockReset(); mocked.applyPermissionPromptChangesMock.mockReset(); mocked.applyPermissionPromptChangesMock.mockResolvedValue(undefined); mocked.ensureEventSubscriptionMock.mockReset(); @@ -580,6 +589,97 @@ describe("attach/service", () => { expect(mocked.applyPermissionPromptChangesMock).not.toHaveBeenCalled(); expect(container.permissionManager.isActiveMessage(501)).toBe(true); }); + + describe("after the server restarted", () => { + it("ends a permission prompt OpenCode no longer lists as not answered", async () => { + container.permissionManager.startPermission(permission, 501); + + await restorePendingInteractionsAfterReconnect( + { ...deps, bot: createBot(), chatId: 777 }, + true, + ); + + expect(mocked.applyPermissionPromptChangesMock).toHaveBeenCalledWith( + expect.anything(), + 777, + [expect.objectContaining({ messageId: 501, outcome: { kind: "not_answered" } })], + expect.anything(), + ); + }); + + it("closes a poll OpenCode no longer lists as not answered", async () => { + container.questionManager.startQuestions([], "question-1", "session-1"); + + await restorePendingInteractionsAfterReconnect( + { ...deps, bot: createBot(), chatId: 777 }, + true, + ); + + expect(mocked.closeQuestionNotAnsweredMock).toHaveBeenCalledWith( + expect.anything(), + 777, + expect.anything(), + ); + expect(mocked.closeQuestionSettledOutsideMock).not.toHaveBeenCalled(); + }); + + it("starts the resumed turn the stream missed when the session is busy", async () => { + mocked.sessionStatusMock.mockResolvedValue({ + data: { "session-1": { type: "busy" } }, + error: null, + }); + + await restorePendingInteractionsAfterReconnect( + { ...deps, bot: createBot(), chatId: 777 }, + true, + ); + + expect(mocked.startMissedLiveTurnMock).toHaveBeenCalledWith("session-1", 7); + }); + + it("starts no turn for an idle session", async () => { + mocked.sessionStatusMock.mockResolvedValue({ data: {}, error: null }); + + await restorePendingInteractionsAfterReconnect( + { ...deps, bot: createBot(), chatId: 777 }, + true, + ); + + expect(mocked.startMissedLiveTurnMock).not.toHaveBeenCalled(); + }); + }); + + it("reads no session status after a reconnect to the same server", async () => { + mocked.sessionStatusMock.mockClear(); + + await restorePendingInteractionsAfterReconnect({ ...deps, bot: createBot(), chatId: 777 }); + + expect(mocked.sessionStatusMock).not.toHaveBeenCalled(); + expect(mocked.startMissedLiveTurnMock).not.toHaveBeenCalled(); + }); + }); + + it("does not show again on attach a permission already settled", async () => { + const request = { + id: "permission-1", + sessionID: "session-1", + permission: "edit", + patterns: ["*"], + metadata: {}, + always: [], + }; + container.permissionManager.settleRequest("permission-1", "once"); + mocked.permissionListMock.mockResolvedValue({ data: [request], error: null }); + + await attachToSession({ + ...deps, + bot: createBot(), + chatId: 777, + session: { id: "session-1", title: "Session One", directory: "D:\\Projects\\Repo" }, + ensureEventSubscription: mocked.ensureEventSubscriptionMock, + }); + + expect(mocked.showPermissionRequestMock).not.toHaveBeenCalled(); }); it("skips the reconnect restore when no session is followed", async () => { diff --git a/tests/bot/commands/opencode-stop.test.ts b/tests/bot/commands/opencode-stop.test.ts index 0e99271bd..35e093c00 100644 --- a/tests/bot/commands/opencode-stop.test.ts +++ b/tests/bot/commands/opencode-stop.test.ts @@ -196,6 +196,26 @@ describe("bot/commands/opencode-stop-command", () => { expect(order).toEqual(["withdraw", "kill"]); }); + it("ends the lost run in the chat after the kill and before the local reset", async () => { + const ctx = createContext(); + const order: string[] = []; + mocked.findServerPidMock.mockResolvedValue(456); + mocked.killServerProcessMock.mockImplementation(async () => { + order.push("kill"); + return true; + }); + mocked.clearRuntimeStateMock.mockImplementation(() => { + order.push("reset"); + }); + const endRunLostWithServer = vi.fn(async (reason: string) => { + order.push(`end:${reason}`); + }); + + await opencodeStopCommand(ctx as never, { ...createDeps(), endRunLostWithServer }); + + expect(order).toEqual(["kill", "end:opencode_stop", "reset"]); + }); + it("clears busy sessions and attached state after a successful stop", async () => { const ctx = createContext(); mocked.findServerPidMock.mockResolvedValue(456); diff --git a/tests/bot/menus/permission-menu.test.ts b/tests/bot/menus/permission-menu.test.ts index 3d978fbc6..1295c7ebb 100644 --- a/tests/bot/menus/permission-menu.test.ts +++ b/tests/bot/menus/permission-menu.test.ts @@ -204,4 +204,45 @@ describe("bot/menus/permission-menu", () => { expect(sendMessage).not.toHaveBeenCalled(); }); + + it("sends one prompt when the same request is shown twice at once", async () => { + const { api, sendMessage } = createApi(() => {}); + + await Promise.all([ + showPermissionRequest(api, 42, PERMISSION, deps), + showPermissionRequest(api, 42, PERMISSION, deps), + showPermissionRequest(api, 42, PERMISSION, deps), + ]); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(deps.permissionManager.getMessageIds()).toEqual([201]); + expect(deps.permissionManager.getRequestIDs(201)).toEqual(["perm-1"]); + }); + + it("groups an equivalent request shown while the first prompt is being sent", async () => { + const { api, sendMessage } = createApi(() => {}); + vi.mocked(api.editMessageText).mockResolvedValue(true); + + await Promise.all([ + showPermissionRequest(api, 42, PERMISSION, deps), + showPermissionRequest(api, 42, { ...PERMISSION, id: "perm-2" }, deps), + ]); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(deps.permissionManager.getRequestIDs(201)).toEqual(["perm-1", "perm-2"]); + }); + + it("lets a waiting show go on when the first send fails", async () => { + const { api, sendMessage } = createApi(() => {}); + sendMessage.mockRejectedValueOnce(new Error("telegram down")); + + const results = await Promise.allSettled([ + showPermissionRequest(api, 42, PERMISSION, deps), + showPermissionRequest(api, 42, PERMISSION, deps), + ]); + + expect(results.map((result) => result.status)).toEqual(["rejected", "fulfilled"]); + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(deps.permissionManager.getMessageIds()).toEqual([201]); + }); }); diff --git a/tests/bot/services/event-subscription-service.lifecycle.test.ts b/tests/bot/services/event-subscription-service.lifecycle.test.ts index 0022aebec..5a30fb537 100644 --- a/tests/bot/services/event-subscription-service.lifecycle.test.ts +++ b/tests/bot/services/event-subscription-service.lifecycle.test.ts @@ -643,10 +643,12 @@ describe("bot/services/event-subscription-service lifecycle", () => { 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; + const onReconnect = mocked.subscribeToEvents.mock.calls.at(-1)?.[2] as (info: { + serverRestarted: boolean | null; + }) => void; emitSessionBusy(summaryAggregator); - onReconnect(); + onReconnect({ serverRestarted: null }); expect(activeContainer.summaryAggregator.getLiveTurnStartedAt("session-1")).toBeNull(); }, 30_000); diff --git a/tests/bot/services/event-subscription-service.lost-run.test.ts b/tests/bot/services/event-subscription-service.lost-run.test.ts new file mode 100644 index 000000000..d9c673734 --- /dev/null +++ b/tests/bot/services/event-subscription-service.lost-run.test.ts @@ -0,0 +1,263 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Bot, Context } from "grammy"; +import type { Event } from "@opencode-ai/sdk/v2"; +import { setRuntimeMode } from "../../../src/runtime/mode.js"; +import { resetSingletonState } from "../../helpers/reset-singleton-state.js"; +import { t } from "../../../src/i18n/index.js"; +import { createTestAppContainer } from "../../helpers/app-container.js"; +import type { AppContainer } from "../../../src/app/bootstrap/app-container.js"; +import type { BotEventSubscriptionService } from "../../../src/bot/services/event-subscription-service.js"; + +const mocked = vi.hoisted(() => ({ + subscribeToEvents: vi.fn(), + stopEventListening: vi.fn(), + restorePendingInteractionsAfterReconnect: vi.fn(), +})); + +vi.mock("../../../src/opencode/events.js", () => ({ + subscribeToEvents: mocked.subscribeToEvents, + stopEventListening: mocked.stopEventListening, +})); + +vi.mock("../../../src/opencode/client.js", async (importOriginal) => ({ + ...(await importOriginal()), + opencodeServerVersion: "v2", +})); + +vi.mock("../../../src/app/services/attach-service.js", async (importOriginal) => ({ + ...(await importOriginal()), + restorePendingInteractionsAfterReconnect: mocked.restorePendingInteractionsAfterReconnect, +})); + +type FakeBotApi = { + sendMessage: ReturnType; + sendRichMessage: ReturnType; + sendMessageDraft: ReturnType; + editMessageText: ReturnType; + editRichMessage: ReturnType; + deleteMessage: ReturnType; + sendDocument: ReturnType; +}; + +function createFakeBot(): { bot: Bot; api: FakeBotApi } { + const richUnavailable = Object.assign(new Error("Bad Request: rich message unavailable"), { + error_code: 400, + }); + const api: FakeBotApi = { + sendMessage: vi.fn().mockResolvedValue({ message_id: 100 }), + sendRichMessage: vi.fn().mockRejectedValue(richUnavailable), + sendMessageDraft: vi.fn().mockResolvedValue(undefined), + editMessageText: vi.fn().mockResolvedValue(undefined), + editRichMessage: vi.fn().mockRejectedValue(richUnavailable), + deleteMessage: vi.fn().mockResolvedValue(undefined), + sendDocument: vi.fn().mockResolvedValue({ message_id: 101 }), + }; + + return { bot: { api } as unknown as Bot, api }; +} + +function collectSentTexts(api: FakeBotApi): string[] { + return [ + ...api.sendMessage.mock.calls.map((call) => String(call[1])), + ...api.editMessageText.mock.calls.map((call) => String(call[2])), + ]; +} + +function emitTool( + container: AppContainer, + tool: string, + status: "running", + callId: string, + input: Record, +): void { + container.summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: `part-${callId}`, + sessionID: "session-1", + messageID: "message-1", + type: "tool", + callID: callId, + tool, + state: { status, input, metadata: {}, time: { start: Date.now() - 5_000 } }, + }, + }, + } as unknown as Event); +} + +async function settle(): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(6_000); + } +} + +describe("bot/services/event-subscription-service lost run", () => { + let tempHome = ""; + let service: BotEventSubscriptionService | null = null; + let container: AppContainer; + let api: FakeBotApi; + + beforeEach(async () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", "test-telegram-token"); + vi.stubEnv("TELEGRAM_ALLOWED_USER_ID", "123456789"); + vi.stubEnv("OPENCODE_MODEL_PROVIDER", "test-provider"); + vi.stubEnv("OPENCODE_MODEL_ID", "test-model"); + vi.stubEnv("OPENCODE_TELEGRAM_HOME", await mkdtemp(path.join(os.tmpdir(), "lost-run-"))); + tempHome = process.env.OPENCODE_TELEGRAM_HOME!; + setRuntimeMode("installed"); + + mocked.subscribeToEvents.mockReset().mockResolvedValue(undefined); + mocked.restorePendingInteractionsAfterReconnect.mockReset().mockResolvedValue(undefined); + + const settingsStore = await import("../../../src/app/stores/settings-store.js"); + settingsStore.__resetSettingsForTests(); + await resetSingletonState(); + + const [{ createEventSubscriptionService }, sessionService] = await Promise.all([ + import("../../../src/bot/services/event-subscription-service.js"), + import("../../../src/app/services/session-service.js"), + ]); + sessionService.setCurrentSession({ id: "session-1", title: "Test", directory: "D:/repo" }); + settingsStore.setCompactOutputMode(false); + settingsStore.setShowAssistantRunFooter(true); + + const fake = createFakeBot(); + api = fake.api; + container = createTestAppContainer(); + service = createEventSubscriptionService(container); + service.clearRuntimeState("test_setup"); + container.assistantRunState.startRun("session-1", { + startedAt: Date.now() - 1000, + configuredAgent: "build", + configuredProviderID: "test-provider", + configuredModelID: "test-model", + }); + service.setTelegramContext(fake.bot, 42); + await service.ensureEventSubscription("D:/repo"); + container.summaryAggregator.setSession("session-1"); + container.summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { id: "message-1", sessionID: "session-1", role: "assistant", time: { created: 1 } }, + }, + } as unknown as Event); + + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"], + }); + }); + + afterEach(async () => { + vi.useRealTimers(); + await new Promise((resolve) => setTimeout(resolve, 20)); + service?.cleanup("test_cleanup"); + service = null; + const settingsStore = await import("../../../src/app/stores/settings-store.js"); + settingsStore.__resetSettingsForTests(); + vi.unstubAllEnvs(); + await rm(tempHome, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + }); + + it("ends the running line as after /abort, with no footer and the run cleared", async () => { + emitTool(container, "bash", "running", "call-bash", { command: "npm test" }); + await settle(); + + const ending = service!.endRunLostWithServer("opencode_stop"); + await settle(); + await ending; + + const texts = collectSentTexts(api); + const last = texts.filter((text) => text.includes("npm test")).at(-1) ?? ""; + expect(last).not.toContain("⏳"); + expect(texts.some((text) => text.includes("🧠"))).toBe(false); + expect(container.assistantRunState.hasRun("session-1")).toBe(false); + + const sentBefore = collectSentTexts(api).length; + await vi.advanceTimersByTimeAsync(120_000); + expect(collectSentTexts(api)).toHaveLength(sentBefore); + }); + + it("finishes ending the lines before it resolves, so a reset right after loses nothing", async () => { + emitTool(container, "bash", "running", "call-bash", { command: "npm test" }); + await settle(); + + // /opencode_stop resets runtime streams as soon as the ending resolves. + const ending = service!.endRunLostWithServer("opencode_stop").then(() => { + service!.clearRuntimeState("opencode_stop"); + }); + await settle(); + await ending; + + const last = collectSentTexts(api).filter((text) => text.includes("npm test")).at(-1) ?? ""; + expect(last).not.toContain("⏳"); + }); + + it("closes the poll on screen as not answered instead of deleting it", async () => { + emitTool(container, "question", "running", "call-question", {}); + await settle(); + container.questionManager.startQuestions( + [{ question: "Red or blue?", header: "Color", options: [{ label: "Red", description: "" }] }], + "form-1", + "session-1", + ); + container.questionManager.addMessageId(700); + container.questionManager.setActiveMessageId(700); + + const ending = service!.endRunLostWithServer("opencode_restarted"); + await settle(); + await ending; + + expect(api.deleteMessage).not.toHaveBeenCalledWith(42, 700); + const pollEdit = api.editMessageText.mock.calls.find((call) => call[1] === 700); + expect(JSON.stringify(pollEdit)).toContain(t("question.not_answered")); + expect(container.questionManager.isActive()).toBe(false); + }); + + it("does nothing to the chat when nothing of the session runs", async () => { + container.assistantRunState.clearRun("session-1", "test"); + const sentBefore = collectSentTexts(api).length; + + const ending = service!.endRunLostWithServer("opencode_restarted"); + await settle(); + await ending; + + expect(collectSentTexts(api)).toHaveLength(sentBefore); + }); + + it("ends the lost run on a reconnect to a restarted server, then restores with the mark", async () => { + emitTool(container, "bash", "running", "call-bash", { command: "npm test" }); + await settle(); + const onReconnect = mocked.subscribeToEvents.mock.calls.at(-1)?.[2] as (info: { + serverRestarted: boolean | null; + }) => void; + + onReconnect({ serverRestarted: true }); + await settle(); + + expect(container.assistantRunState.hasRun("session-1")).toBe(false); + expect(mocked.restorePendingInteractionsAfterReconnect).toHaveBeenCalledWith( + expect.anything(), + true, + ); + }); + + it("keeps the run on a reconnect to the same server", async () => { + const onReconnect = mocked.subscribeToEvents.mock.calls.at(-1)?.[2] as (info: { + serverRestarted: boolean | null; + }) => void; + + onReconnect({ serverRestarted: false }); + await settle(); + + expect(container.assistantRunState.hasRun("session-1")).toBe(true); + expect(mocked.restorePendingInteractionsAfterReconnect).toHaveBeenCalledWith( + expect.anything(), + false, + ); + }); +}); diff --git a/tests/bot/services/event-subscription-service.test.ts b/tests/bot/services/event-subscription-service.test.ts index 2c916d8e8..5aa605829 100644 --- a/tests/bot/services/event-subscription-service.test.ts +++ b/tests/bot/services/event-subscription-service.test.ts @@ -1591,9 +1591,11 @@ describe("bot/services/event-subscription-service", () => { 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; + const onReconnect = mocked.subscribeToEvents.mock.calls[0]?.[2] as (info: { + serverRestarted: boolean | null; + }) => void; - onReconnect(); + onReconnect({ serverRestarted: null }); const textsBefore = collectSentTexts(api).length; await vi.advanceTimersByTimeAsync(60_000); diff --git a/tests/opencode/events.test.ts b/tests/opencode/events.test.ts index fa5399bc4..2f937fb84 100644 --- a/tests/opencode/events.test.ts +++ b/tests/opencode/events.test.ts @@ -413,6 +413,35 @@ describe("opencode/events", () => { expect(onReconnect).toHaveBeenCalledTimes(1); }); expect(subscribeMock).toHaveBeenCalledTimes(1); + expect(onReconnect).toHaveBeenCalledWith({ serverRestarted: null }); + + stopEventListening(); + await subscription; + }); + + it("hands the connect event's restart mark to the reconnect callback", async () => { + subscribeMock + .mockResolvedValueOnce({ + stream: createStream([{ type: "server.connected", properties: {} }]), + }) + .mockImplementationOnce(async (_params, options: { signal: AbortSignal }) => { + return { + stream: createOpenStream( + [{ type: "server.connected", properties: { restarted: true } }], + options.signal, + ), + }; + }); + const onReconnect = vi.fn(); + + const subscription = subscribeToEvents("D:/repo", vi.fn(), onReconnect); + + await vi.waitFor( + () => { + expect(onReconnect).toHaveBeenCalledWith({ serverRestarted: true }); + }, + { timeout: 3000 }, + ); stopEventListening(); await subscription; diff --git a/tests/opencode/v2/client.test.ts b/tests/opencode/v2/client.test.ts index aa2512d89..3eeb045a2 100644 --- a/tests/opencode/v2/client.test.ts +++ b/tests/opencode/v2/client.test.ts @@ -400,4 +400,65 @@ describe("opencode/v2/client", () => { expect(globalEvents[1]?.directory).toBe("D:/repo"); expect(projectEvents).toEqual(["server.connected", "session.status"]); }); + + describe("server restart mark", () => { + type ConnectClient = { + global: { + event: () => Promise<{ + stream: AsyncIterable<{ payload: { type: string; properties: object } }>; + }>; + }; + }; + + async function connect(client: ConnectClient): Promise { + for await (const envelope of (await client.global.event()).stream) { + if (envelope.payload.type === "server.connected") { + return envelope.payload.properties; + } + } + return undefined; + } + + beforeEach(() => { + fake.client.event.subscribe.mockImplementation(() => + (async function* () { + yield { id: "evt-1", type: "server.connected", data: {} }; + })(), + ); + }); + + it("leaves the first connection unmarked, then marks the same and another server", async () => { + const client = createClient() as unknown as ConnectClient; + fake.client.server.info.mockResolvedValue({ version: "2.0.18", pid: 100 }); + + expect(await connect(client)).toEqual({}); + expect(await connect(client)).toEqual({ restarted: false }); + + fake.client.server.info.mockResolvedValue({ version: "2.0.18", pid: 200 }); + expect(await connect(client)).toEqual({ restarted: true }); + }); + + it("leaves the connection unmarked when the server does not tell who it is", async () => { + const client = createClient() as unknown as ConnectClient; + fake.client.server.info.mockResolvedValue({ version: "2.0.18", pid: 100 }); + await connect(client); + + fake.client.server.info.mockRejectedValue(new Error("down")); + + expect(await connect(client)).toEqual({}); + }); + + it("compares nothing against a server it could not identify", async () => { + const client = createClient() as unknown as ConnectClient; + fake.client.server.info.mockResolvedValue({ version: "2.0.18", pid: 100 }); + await connect(client); + fake.client.server.info.mockRejectedValueOnce(new Error("down")); + await connect(client); + + fake.client.server.info.mockResolvedValue({ version: "2.0.18", pid: 200 }); + + expect(await connect(client)).toEqual({}); + expect(await connect(client)).toEqual({ restarted: false }); + }); + }); }); diff --git a/tests/opencode/v2/events.test.ts b/tests/opencode/v2/events.test.ts index fa759f42c..e531cf1ec 100644 --- a/tests/opencode/v2/events.test.ts +++ b/tests/opencode/v2/events.test.ts @@ -331,7 +331,59 @@ describe("opencode/v2/events", () => { }), ); - expect(envelope?.payload).toMatchObject({ properties: { part: { tool: "unknown" } } }); + expect(envelope).toBeUndefined(); + }); + + it("drops every event of a call it never saw start instead of naming it", () => { + const translate = createV2EventTranslator(); + const base = { sessionID: SESSION, assistantMessageID: MESSAGE, id: "call-lost" }; + + const result = payloads(translate, [ + event("session.tool.called", { ...base, input: { command: "ls" }, executed: false }), + event("session.tool.progress", { ...base, metadata: { output: "x" } }), + event("session.tool.success", { + ...base, + content: [{ type: "text", text: "done" }], + executed: true, + }), + event("session.tool.failed", { + ...base, + error: { type: "unknown", message: "interrupted" }, + executed: false, + }), + ]); + + expect(result).toEqual([]); + }); + + it("forgets the calls it knew once the stream connects again", () => { + const translate = createV2EventTranslator(); + const base = { sessionID: SESSION, assistantMessageID: MESSAGE, id: "call-1" }; + translate(event("session.tool.input.started", { ...base, name: "shell" })); + translate(event("server.connected", {})); + + const result = payloads(translate, [ + event("session.tool.failed", { + ...base, + error: { type: "unknown", message: "interrupted" }, + executed: false, + }), + ]); + + expect(result).toEqual([]); + }); + + it("marks the connect event with whether the server restarted, when told", () => { + const translate = createV2EventTranslator(); + + expect(translate(event("server.connected", {}), true)[0]?.payload).toMatchObject({ + type: "server.connected", + properties: { restarted: true }, + }); + expect(translate(event("server.connected", {}), false)[0]?.payload).toMatchObject({ + properties: { restarted: false }, + }); + expect(translate(event("server.connected", {}))[0]?.payload.properties).toEqual({}); }); describe("background operations", () => { From 81bae1c29e19454595b73f1523ee45c15c255f8f Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Wed, 30 Sep 2026 21:47:24 +0300 Subject: [PATCH 4/6] fix(OTB-142): leave messages waiting for a running session to it on /detach /detach no longer withdraws messages waiting for the running turn. On OpenCode V2 they stay in the session inbox; on V1 the bot sends them to the detached session after its turn ends, with the agent and model selected at /detach. A message still being prepared at /detach follows the same session. Later session switches leave them alone; /abort in that session and /opencode_stop withdraw them. Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 6 +- README.md | 4 +- src/app/managers/prompt-handover-manager.ts | 158 ++++++++ src/app/managers/prompt-queue-manager.ts | 37 ++ src/bot/commands/abort-command.ts | 6 + src/bot/commands/detach-command.ts | 4 + src/bot/commands/opencode-stop-command.ts | 5 +- .../handlers/assistant-response-handler.ts | 3 + .../handlers/session-lifecycle-handler.ts | 3 + src/bot/handlers/document-handler.ts | 16 +- src/bot/handlers/media-group-handler.ts | 21 +- src/bot/handlers/photo-handler.ts | 16 +- src/bot/handlers/prompt-handover.ts | 351 +++++++++++++++++ src/bot/handlers/prompt-queue-dispatch.ts | 37 +- src/bot/handlers/prompt.ts | 205 +++++++--- src/bot/handlers/voice-handler.ts | 17 +- src/bot/index.ts | 7 +- src/bot/routers/message-router.ts | 7 + .../managers/prompt-handover-manager.test.ts | 102 +++++ .../app/managers/prompt-queue-manager.test.ts | 48 +++ tests/bot/commands/abort.test.ts | 32 ++ tests/bot/commands/detach.test.ts | 20 + tests/bot/commands/opencode-stop.test.ts | 11 +- tests/bot/handlers/prompt-handover.test.ts | 356 ++++++++++++++++++ .../handlers/prompt-queue-dispatch-v2.test.ts | 37 ++ .../handlers/prompt-queue-dispatch.test.ts | 87 +++++ tests/bot/handlers/voice.test.ts | 1 + tests/bot/rich-message-routing.test.ts | 21 ++ tests/helpers/reset-singleton-state.ts | 12 + 29 files changed, 1540 insertions(+), 90 deletions(-) create mode 100644 src/app/managers/prompt-handover-manager.ts create mode 100644 src/bot/handlers/prompt-handover.ts create mode 100644 tests/app/managers/prompt-handover-manager.test.ts create mode 100644 tests/bot/handlers/prompt-handover.test.ts diff --git a/PRODUCT.md b/PRODUCT.md index 2df9430f2..075ceb385 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -55,7 +55,7 @@ No public inbound ports are required for normal usage. - Send text prompts to OpenCode - 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 +- 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; `/detach` leaves waiting messages to the detached session, where they are sent as if the bot had stayed attached, with the agent and model selected at `/detach` - Handle OpenCode questions with inline options and custom text answers; the custom answer button is offered only when the question accepts a custom answer - Questions asked by a subagent of the followed session appear in the chat like the main agent's and are answered to that subagent - A question answered or cancelled outside Telegram (OpenCode TUI, web, another client) closes the poll on screen: its buttons go and a line says it was answered or cancelled outside Telegram @@ -132,7 +132,7 @@ Current command set: - `/status` - bot version, server, project, and session status - `/new` - create a new session - `/abort` - stop the current task -- `/detach` - detach the bot from the current session without stopping it; a later command or prompt HTTP failure for that session is not posted to chat unless the bot has re-attached to it +- `/detach` - detach the bot from the current session without stopping it; messages waiting for its running task stay with it and reach it as if the bot had stayed attached (no buttons, withdrawn only by `/abort` there or `/opencode_stop`); a later command or prompt HTTP failure for that session is not posted to chat unless the bot has re-attached to it - `/sessions` - show and switch recent sessions - `/recent` - show recent sessions across projects and worktrees with their status and switch directly to one - `/messages` - browse user messages in the current session @@ -219,7 +219,7 @@ Agent picker behavior: - [x] Attaching a project file from `/ls` to the next prompt as a native OpenCode file part - [x] `/messages` command: browse session messages with revert and fork functionality - [x] Optional message queue for text, voice, photos, rich formatted messages with photos, documents, and media groups sent while the agent is busy, managed from the bottom keyboard -- [x] OpenCode V2: messages sent mid-run are steered into the running turn or queued in the session inbox (Off / Queue / Steer in `/settings`), withdrawable until picked up +- [x] OpenCode V2: messages sent mid-run are steered into the running turn or queued in the session inbox (Off / Queue / Steer in `/settings`), withdrawable until picked up or until `/detach` leaves them to the session - [x] Native Telegram rich message formatting for assistant replies (Bot API 10.1) - [x] Incoming Telegram rich formatted messages (Bot API 10.1): converted to Markdown, accepted anywhere text is accepted, with photos attached and unsupported message types answered explicitly - [x] Startup either reaches Telegram polling or the process exits: transient Telegram failures are retried in-process; a bad token or other fatal startup error exits with code 1 diff --git a/README.md b/README.md index cc4a9c7f4..bf151cbcb 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es` - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies in `/settings` - **File attachments** — send images, PDF documents, and text-based files to OpenCode, including multiple files in one Telegram album - **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks) -- **Message queue** — messages sent while the agent is busy are held and sent one by one afterwards (Queue) or, on OpenCode V2, steered into the running task (Steer); each waiting message is a bottom-keyboard button you can tap to withdraw it +- **Message queue** — messages sent while the agent is busy are held and sent one by one afterwards (Queue) or, on OpenCode V2, steered into the running task (Steer); each waiting message is a bottom-keyboard button you can tap to withdraw it; `/detach` leaves waiting messages to the session they were sent to - **Context control** — tap the bottom 📊 button to see context usage and the latest assistant message's tokens and cost; compact from the details with an inline confirmation - **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions - **Git worktree switching** — browse and switch between existing git worktrees for the current repository with `/worktree` @@ -309,6 +309,8 @@ Runtime preferences are changed from `/settings` and stored in `settings.json`: With the message queue on, text, transcribed voice, photos, rich formatted messages with photos, supported documents, and media groups sent while the agent is busy are accepted instead of being turned down. At most `MAX_QUEUED_PROMPTS` (5) messages wait at a time. Waiting messages appear as buttons above the usual bottom-keyboard grid — tap one to withdraw it — and `/abort`, `/opencode_stop` or a session/project switch withdraws them all. When a waiting message is picked up, its button disappears and its text is quoted as external user input. +`/detach` does not withdraw them: they stay with the detached session and reach it as if the bot had stayed attached, with the agent and model selected at `/detach` — a message still being transcribed or downloaded included. Their buttons leave the keyboard and they no longer count toward the limit. A later session or project switch leaves them alone; `/abort` after returning to that session, or `/opencode_stop`, withdraws them. Picked up while detached, they show nothing in the chat beyond the usual background notification; back in the session before pickup, each is quoted as external user input when it starts. + On OpenCode V2 a waiting message is sent to OpenCode at once and waits in the session's inbox, not in the bot: with `Steer` the running task picks it up at its next step and keeps going in the same progress message with one footer at the end; with `Queue` it starts its own run once the task finishes. Nothing is held by the bot, so there is no queued-media size limit, and after a bot restart the buttons are gone while OpenCode still delivers the messages. On OpenCode V1 the bot holds the messages itself and sends them one at a time as each run finishes. Its queue also holds at most 20 MiB of raw Telegram media bytes in total; the limit is checked from reliable Telegram `file_size` metadata before media is downloaded or prepared, while base64 data-URI expansion is not counted. Queued media without a reliable source size is refused while the task is busy. diff --git a/src/app/managers/prompt-handover-manager.ts b/src/app/managers/prompt-handover-manager.ts new file mode 100644 index 000000000..4e0c7851f --- /dev/null +++ b/src/app/managers/prompt-handover-manager.ts @@ -0,0 +1,158 @@ +import { logger } from "../../utils/logger.js"; +import type { IncomingPrompt } from "../types/prompt.js"; +import type { QueuedPromptInbox } from "./prompt-queue-manager.js"; + +/** Agent, model and variant the chat had when the session was detached. */ +export interface HandoverSelection { + agent: string | undefined; + providerID: string; + modelID: string; + variant?: string | undefined; +} + +/** A V1 prompt the bot still holds for a detached session. */ +export interface HandedOverPrompt extends IncomingPrompt { + selection: HandoverSelection; +} + +/** What waits for one session the bot was detached from. */ +export interface SessionHandover { + sessionId: string; + directory: string; + selection: HandoverSelection; + prompts: HandedOverPrompt[]; + inboxEntries: QueuedPromptInbox[]; + detachSeq: number; + turnInFlight: boolean; +} + +/** The busy session a message arrived for, taken before the message is prepared. */ +export interface ArrivalTicket { + sessionId: string; + detachSeq: number; +} + +/** + * Prompt Handover - messages that waited for a running turn when the bot was detached + * from the session. They are no longer the chat's queue: no buttons, no cap, and session + * switches do not withdraw them. Only /abort in that session and /opencode_stop do. + * Kept in memory only, like the prompt queue. + * Singleton pattern + */ +class PromptHandoverManager { + private sessions = new Map(); + private detachSeq = 0; + + /** Records a detach from the session; later messages prepared for it follow it there. */ + recordDetach( + session: { id: string; directory: string }, + selection: HandoverSelection, + ): SessionHandover { + this.detachSeq += 1; + const existing = this.sessions.get(session.id); + if (existing) { + existing.directory = session.directory; + existing.selection = { ...selection }; + existing.detachSeq = this.detachSeq; + return existing; + } + + const record: SessionHandover = { + sessionId: session.id, + directory: session.directory, + selection: { ...selection }, + prompts: [], + inboxEntries: [], + detachSeq: this.detachSeq, + turnInFlight: false, + }; + this.sessions.set(session.id, record); + return record; + } + + get(sessionId: string): SessionHandover | null { + return this.sessions.get(sessionId) ?? null; + } + + takeTicket(sessionId: string): ArrivalTicket { + return { sessionId, detachSeq: this.detachSeq }; + } + + /** Whether the ticket's session was detached after the message arrived. */ + wasDetachedSince(ticket: ArrivalTicket): boolean { + const record = this.sessions.get(ticket.sessionId); + return Boolean(record && record.detachSeq > ticket.detachSeq); + } + + addPrompt(sessionId: string, prompt: HandedOverPrompt): boolean { + const record = this.sessions.get(sessionId); + if (!record) { + return false; + } + record.prompts.push(prompt); + logger.debug( + `[PromptHandover] Prompt handed over: session=${sessionId}, size=${record.prompts.length}`, + ); + return true; + } + + addInboxEntry(inbox: QueuedPromptInbox): boolean { + const record = this.sessions.get(inbox.sessionId); + if (!record) { + return false; + } + record.inboxEntries.push({ ...inbox }); + logger.debug( + `[PromptHandover] Inbox prompt handed over: session=${inbox.sessionId}, inboxId=${inbox.inboxId}`, + ); + return true; + } + + takeNextPrompt(sessionId: string): HandedOverPrompt | null { + return this.sessions.get(sessionId)?.prompts.shift() ?? null; + } + + setTurnInFlight(sessionId: string, inFlight: boolean): void { + const record = this.sessions.get(sessionId); + if (record) { + record.turnInFlight = inFlight; + } + } + + /** Whether V1 prompts handed over to the session are still to be sent or answered. */ + hasPendingPrompts(sessionId: string): boolean { + const record = this.sessions.get(sessionId); + return Boolean(record && (record.prompts.length > 0 || record.turnInFlight)); + } + + /** OpenCode picked the prompt up: it can no longer be withdrawn. */ + forgetInboxId(inboxId: string): void { + for (const record of this.sessions.values()) { + record.inboxEntries = record.inboxEntries.filter((entry) => entry.inboxId !== inboxId); + } + } + + /** Drops everything handed over to the session; returns the inbox entries to cancel. */ + withdraw(sessionId: string, reason: string): QueuedPromptInbox[] { + const record = this.sessions.get(sessionId); + if (!record) { + return []; + } + this.sessions.delete(sessionId); + logger.info( + `[PromptHandover] Withdrew handed-over prompts: session=${sessionId}, reason=${reason}, prompts=${record.prompts.length}, inbox=${record.inboxEntries.length}`, + ); + return record.inboxEntries; + } + + withdrawAll(reason: string): QueuedPromptInbox[] { + return [...this.sessions.keys()].flatMap((sessionId) => this.withdraw(sessionId, reason)); + } + + __resetForTests(): void { + this.sessions.clear(); + this.detachSeq = 0; + } +} + +export const promptHandover = new PromptHandoverManager(); diff --git a/src/app/managers/prompt-queue-manager.ts b/src/app/managers/prompt-queue-manager.ts index aa8689c7a..cf7525817 100644 --- a/src/app/managers/prompt-queue-manager.ts +++ b/src/app/managers/prompt-queue-manager.ts @@ -51,6 +51,8 @@ class PromptQueueManager { private nextId = 1; private queuedMediaBytes = 0; private reservations = new Set(); + /** Reservations handed over at /detach, by the session they were admitted to. */ + private handedOverReservations = new Map(); private deliveredInboxIds: string[] = []; add(input: QueuedPromptInput): QueuedPrompt | null { @@ -185,6 +187,40 @@ class PromptQueueManager { return this.queuedMediaBytes; } + /** + * Empties the queue for /detach and returns the items it removed. Admissions still on + * their way stay with the detached session: their reservations are handed over rather + * than dropped, and a later clear leaves them alone. + */ + handOver(sessionId: string, reason: string): QueuedPrompt[] { + for (const reservationId of this.reservations) { + this.handedOverReservations.set(reservationId, sessionId); + } + + logger.info( + `[PromptQueue] Handed queue over: reason=${reason}, session=${sessionId}, count=${this.items.length}, reservations=${this.reservations.size}`, + ); + const removed = this.items; + this.items = []; + this.reservations.clear(); + this.queuedMediaBytes = 0; + return removed; + } + + /** Releases a handed-over reservation; returns false when it was not handed over or was withdrawn. */ + releaseHandedOverReservation(reservationId: string): boolean { + return this.handedOverReservations.delete(reservationId); + } + + /** Withdraws handed-over reservations of one session, or of all when none is given. */ + withdrawHandedOverReservations(sessionId?: string): void { + for (const [reservationId, reservedSessionId] of this.handedOverReservations) { + if (sessionId === undefined || reservedSessionId === sessionId) { + this.handedOverReservations.delete(reservationId); + } + } + } + /** Empties the queue, reservations included, and returns the items it removed. */ clear(reason: string): QueuedPrompt[] { this.deliveredInboxIds = []; @@ -207,6 +243,7 @@ class PromptQueueManager { this.nextId = 1; this.queuedMediaBytes = 0; this.reservations.clear(); + this.handedOverReservations.clear(); this.deliveredInboxIds = []; } } diff --git a/src/bot/commands/abort-command.ts b/src/bot/commands/abort-command.ts index 9521505b7..6e64a5dcc 100644 --- a/src/bot/commands/abort-command.ts +++ b/src/bot/commands/abort-command.ts @@ -6,6 +6,7 @@ import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; import { markAttachedSessionIdle } from "../../app/services/attach-service.js"; import { clearPromptResponseMode } from "../handlers/prompt.js"; +import { withdrawHandedOverPrompts } from "../handlers/prompt-handover.js"; import { markUserAbortRequested } from "../../app/managers/abort-suppression-manager.js"; import { withdrawPromptQueue } from "../../app/services/prompt-inbox-service.js"; import { promptAttachment } from "../../app/managers/prompt-attachment-manager.js"; @@ -194,5 +195,10 @@ export async function abortCommand( ctx: CommandContext, deps: AbortCommandDeps, ): Promise { + // Only /abort itself: /start shares the abort below but leaves what /detach handed over. + const currentSession = getCurrentSession(); + if (currentSession) { + await withdrawHandedOverPrompts(currentSession.id, "abort_command"); + } await abortCurrentOperation(ctx, deps); } diff --git a/src/bot/commands/detach-command.ts b/src/bot/commands/detach-command.ts index dd2de2961..b14c6f990 100644 --- a/src/bot/commands/detach-command.ts +++ b/src/bot/commands/detach-command.ts @@ -9,6 +9,7 @@ import { import { formatSessionTitle } from "../../app/formatters/session-title-formatter.js"; import { detachAttachedSession } from "../../app/services/attach-service.js"; import { clearPromptResponseMode } from "../handlers/prompt.js"; +import { handOverPromptQueue } from "../handlers/prompt-handover.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; @@ -42,6 +43,9 @@ export async function detachCommand( const title = await fetchSessionTitle(currentSession); + // What waits for the running turn stays with the session: clearing it below then + // finds nothing to withdraw. + await handOverPromptQueue(currentSession); detachAttachedSession("detach_command", deps); clearPromptResponseMode(currentSession.id); deps.foregroundSessionState.markIdle(currentSession.id); diff --git a/src/bot/commands/opencode-stop-command.ts b/src/bot/commands/opencode-stop-command.ts index c0742dc8f..0b336126a 100644 --- a/src/bot/commands/opencode-stop-command.ts +++ b/src/bot/commands/opencode-stop-command.ts @@ -14,6 +14,7 @@ import { promptQueue } from "../../app/managers/prompt-queue-manager.js"; import { withdrawPromptQueue } from "../../app/services/prompt-inbox-service.js"; import { markAttachedSessionIdle } from "../../app/services/attach-service.js"; import { clearPromptResponseMode } from "../handlers/prompt.js"; +import { withdrawAllHandedOverPrompts } from "../handlers/prompt-handover.js"; export type OpencodeStopCommandDeps = Pick< AppContainer, @@ -85,8 +86,10 @@ export async function opencodeStopCommand( const statusMessage = await ctx.reply(t("opencode_stop.stopping", { pid })); - // The OpenCode V2 inbox outlives the process, so waiting prompts are withdrawn first. + // The OpenCode V2 inbox outlives the process, so waiting prompts are withdrawn first, + // those handed over at /detach included. await withdrawPromptQueue(STOP_REASON); + await withdrawAllHandedOverPrompts(STOP_REASON); const stopped = await killServerProcess(pid, 5000); if (!stopped) { diff --git a/src/bot/events/handlers/assistant-response-handler.ts b/src/bot/events/handlers/assistant-response-handler.ts index a21211bc2..2a63d1cab 100644 --- a/src/bot/events/handlers/assistant-response-handler.ts +++ b/src/bot/events/handlers/assistant-response-handler.ts @@ -6,6 +6,7 @@ import { } from "../../../app/stores/settings-store.js"; import { clearPromptResponseMode, startInboxPromptRun } from "../../handlers/prompt.js"; import { promptQueue, type QueuedPrompt } from "../../../app/managers/prompt-queue-manager.js"; +import { promptHandover } from "../../../app/managers/prompt-handover-manager.js"; import { buildExternalUserInputNotification } from "../../../app/services/external-user-input-service.js"; import { getCurrentSession } from "../../../app/services/session-service.js"; import { sendBotText } from "../../messages/telegram-text.js"; @@ -487,6 +488,8 @@ export function registerAssistantResponseHandlers(deps: AssistantResponseDeps): } // The admission of this prompt may still be on its way back from OpenCode. promptQueue.rememberDeliveredInboxId(messageId); + // A prompt handed over at /detach can no longer be withdrawn once picked up. + promptHandover.forgetInboxId(messageId); const destination = policy.getDestination(sessionId); if (!destination) { diff --git a/src/bot/events/handlers/session-lifecycle-handler.ts b/src/bot/events/handlers/session-lifecycle-handler.ts index d74747671..2dcd653ae 100644 --- a/src/bot/events/handlers/session-lifecycle-handler.ts +++ b/src/bot/events/handlers/session-lifecycle-handler.ts @@ -4,6 +4,7 @@ import { markAttachedSessionIdle } from "../../../app/services/attach-service.js import { shouldSuppressUserAbortSessionError } from "../../../app/managers/abort-suppression-manager.js"; import { clearPromptResponseMode } from "../../handlers/prompt.js"; import { dispatchNextQueuedPrompt } from "../../handlers/prompt-queue-dispatch.js"; +import { wakePromptHandover } from "../../handlers/prompt-handover.js"; import { resetStreamThrottle } from "../../streaming/stream-throttle.js"; import { formatSessionMessage, @@ -28,6 +29,7 @@ export function registerSessionLifecycleHandlers(deps: SessionLifecycleDeps): vo const { runtime, policy, summaryAggregator } = deps; summaryAggregator.setOnSessionIdle(async (sessionId, { interrupted }) => { + wakePromptHandover(sessionId); resetStreamThrottle(sessionId); await markAttachedSessionIdle(sessionId, deps); // Dropped immediately when this session is no longer current: the early @@ -86,6 +88,7 @@ export function registerSessionLifecycleHandlers(deps: SessionLifecycleDeps): vo }); summaryAggregator.setOnSessionError(async (sessionId, message) => { + wakePromptHandover(sessionId); await markAttachedSessionIdle(sessionId, deps); const destination = policy.getDestination(sessionId); const keepBackground = Boolean(destination) && policy.isForegroundSession(sessionId); diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index b73642024..225605b0b 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -17,6 +17,7 @@ import { flushPendingPrompt } from "./message-merger.js"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; import { rejectQueuedMediaBeforePreparation, + takeArrivalTicket, tryEnqueuePromptIfBusy, } from "./prompt-queue-dispatch.js"; @@ -52,6 +53,7 @@ export async function handleDocumentMessage( } flushPendingPrompt(ctx.chat!.id); + const ticket = takeArrivalTicket(); const caption = ctx.message.caption || ""; const mimeType = doc.mime_type || ""; @@ -63,11 +65,15 @@ export async function handleDocumentMessage( ): Promise => { const input = createIncomingPrompt(text, { fileParts }); if ( - await tryEnqueuePromptIfBusy(ctx, { - ...input, - displayText: caption.trim() || filename, - mediaBytes, - }) + await tryEnqueuePromptIfBusy( + ctx, + { + ...input, + displayText: caption.trim() || filename, + mediaBytes, + }, + ticket, + ) ) { return; } diff --git a/src/bot/handlers/media-group-handler.ts b/src/bot/handlers/media-group-handler.ts index 6ab434710..a19952655 100644 --- a/src/bot/handlers/media-group-handler.ts +++ b/src/bot/handlers/media-group-handler.ts @@ -12,11 +12,13 @@ import { toDataUri, } from "../../app/services/file-download-service.js"; import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js"; +import type { ArrivalTicket } from "../../app/managers/prompt-handover-manager.js"; import { createIncomingPrompt, type IncomingPrompt } from "../../app/types/prompt.js"; import { flushPendingPrompt } from "./message-merger.js"; import { handleUnsupportedMessages } from "./unsupported-message-handler.js"; import { rejectQueuedMediaBeforePreparation, + takeArrivalTicket, tryEnqueuePromptIfBusy, } from "./prompt-queue-dispatch.js"; @@ -60,6 +62,8 @@ type ValidMediaGroupItem = }; interface MediaGroupBatch { + /** Taken with the first item, before the album is prepared. */ + ticket: ArrivalTicket | undefined; timer: ReturnType; items: PendingMediaGroupItem[]; } @@ -131,6 +135,7 @@ export class MediaGroupAttachmentHandler { } this.batches.set(key, { + ticket: takeArrivalTicket(), items: [item], timer: this.createFlushTimer(key), }); @@ -252,12 +257,16 @@ export class MediaGroupAttachmentHandler { .map((item) => item.caption.trim()) .filter((caption) => caption.length > 0); if ( - await tryEnqueuePromptIfBusy(replyCtx, { - ...createIncomingPrompt(promptText, { fileParts }), - displayText: captions.join(" / ") || `[Album: ${items.length} files]`, - fileParts, - ...(mediaBytes === undefined ? {} : { mediaBytes }), - }) + await tryEnqueuePromptIfBusy( + replyCtx, + { + ...createIncomingPrompt(promptText, { fileParts }), + displayText: captions.join(" / ") || `[Album: ${items.length} files]`, + fileParts, + ...(mediaBytes === undefined ? {} : { mediaBytes }), + }, + batch.ticket, + ) ) { return; } diff --git a/src/bot/handlers/photo-handler.ts b/src/bot/handlers/photo-handler.ts index 76de9788c..72837b981 100644 --- a/src/bot/handlers/photo-handler.ts +++ b/src/bot/handlers/photo-handler.ts @@ -4,6 +4,7 @@ import { flushPendingPrompt } from "./message-merger.js"; import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js"; import { rejectQueuedMediaBeforePreparation, + takeArrivalTicket, tryEnqueuePromptIfBusy, } from "./prompt-queue-dispatch.js"; @@ -22,6 +23,7 @@ export async function handlePhotoMessage(ctx: Context, deps: PhotoHandlerDeps): } flushPendingPrompt(ctx.chat!.id); + const ticket = takeArrivalTicket(); const caption = ctx.message.caption || ""; const largestPhoto = photos[photos.length - 1]; @@ -35,11 +37,15 @@ export async function handlePhotoMessage(ctx: Context, deps: PhotoHandlerDeps): return; } if ( - await tryEnqueuePromptIfBusy(ctx, { - ...input, - displayText: caption.trim() || "[Photo]", - ...(largestPhoto.file_size === undefined ? {} : { mediaBytes: largestPhoto.file_size }), - }) + await tryEnqueuePromptIfBusy( + ctx, + { + ...input, + displayText: caption.trim() || "[Photo]", + ...(largestPhoto.file_size === undefined ? {} : { mediaBytes: largestPhoto.file_size }), + }, + ticket, + ) ) { return; } diff --git a/src/bot/handlers/prompt-handover.ts b/src/bot/handlers/prompt-handover.ts new file mode 100644 index 000000000..7b80f60ac --- /dev/null +++ b/src/bot/handlers/prompt-handover.ts @@ -0,0 +1,351 @@ +import type { Context } from "grammy"; +import { + promptHandover, + type ArrivalTicket, + type HandedOverPrompt, + type HandoverSelection, + type SessionHandover, +} from "../../app/managers/prompt-handover-manager.js"; +import { promptQueue, type QueuedPromptInput } from "../../app/managers/prompt-queue-manager.js"; +import { getStoredAgent, resolveProjectAgent } from "../../app/services/agent-selection-service.js"; +import { getStoredModel } from "../../app/services/model-selection-service.js"; +import { cancelInboxPrompt } from "../../app/services/prompt-inbox-service.js"; +import { getCurrentSession } from "../../app/services/session-service.js"; +import { getPromptQueueMode } from "../../app/stores/settings-store.js"; +import type { SessionInfo } from "../../app/types/session.js"; +import { config } from "../../config.js"; +import { t } from "../../i18n/index.js"; +import { opencodeClient, opencodeServerVersion } from "../../opencode/client.js"; +import { formatErrorDetails } from "../../utils/error-format.js"; +import { logger } from "../../utils/logger.js"; +import { isOpencodeNotFoundError } from "../../utils/opencode-error.js"; +import { + admitHandedOverPromptToInbox, + prepareHandedOverPrompt, + type ProcessPromptDeps, +} from "./prompt.js"; + +const STATUS_POLL_INTERVAL_MS = 1500; +/** How long a sent prompt may stay unseen as busy before its turn counts as already over. */ +const TURN_START_GRACE_MS = 5000; + +// Received once at startup, like the queue dispatcher: the delivery loop runs without +// an update context. +let handoverDeps: ProcessPromptDeps | null = null; +// Continues the attached queue, which waits behind prompts handed over to its session. +let onHandoverDrained: (() => Promise) | null = null; + +const runningLoops = new Set(); +const wakers = new Map void>(); + +export function initializePromptHandover( + deps: ProcessPromptDeps, + onDrained: () => Promise, +): void { + handoverDeps = deps; + onHandoverDrained = onDrained; +} + +async function readSelection(): Promise { + const agent = await resolveProjectAgent(getStoredAgent()); + const model = getStoredModel(); + return { + agent, + providerID: model.providerID, + modelID: model.modelID, + variant: model.variant, + }; +} + +/** + * /detach: what waits for the running turn stays with the session instead of being + * withdrawn. V2 inbox entries are left in OpenCode; V1 prompts are sent by the bot + * after the turn ends. Must run before the session is cleared. + */ +export async function handOverPromptQueue(session: SessionInfo): Promise { + const selection = await readSelection(); + promptHandover.recordDetach(session, selection); + + for (const item of promptQueue.handOver(session.id, "detach_command")) { + if (item.inbox) { + promptHandover.addInboxEntry(item.inbox); + continue; + } + promptHandover.addPrompt(session.id, { + text: item.text, + fileParts: item.fileParts, + photos: item.photos, + selection, + }); + } + + startDeliveryLoop(session.id); +} + +/** + * A message whose preparation outlived /detach goes to the session it was sent to, as one + * sent right before /detach. Returns false when that session was not detached since. + */ +export async function handOverPreparedPrompt( + ctx: Context, + ticket: ArrivalTicket, + input: QueuedPromptInput, +): Promise { + const record = promptHandover.get(ticket.sessionId); + if (!record || !promptHandover.wasDetachedSince(ticket) || !handoverDeps) { + return false; + } + logger.info( + `[PromptHandover] Prepared prompt follows its detached session: session=${record.sessionId}`, + ); + + if (opencodeServerVersion !== "v2") { + promptHandover.addPrompt(record.sessionId, { + text: input.text, + fileParts: [...input.fileParts], + photos: [...input.photos], + selection: record.selection, + }); + startDeliveryLoop(record.sessionId); + return true; + } + + const delivery = getPromptQueueMode() === "steer" ? "steer" : "queue"; + const inboxId = await admitHandedOverPromptToInbox( + ctx.api, + input, + { id: record.sessionId, directory: record.directory }, + record.selection, + delivery, + handoverDeps, + ); + if (!inboxId) { + await notifyDeliveryFailure(record.sessionId); + return true; + } + + const inbox = { sessionId: record.sessionId, inboxId, delivery } as const; + if (!promptHandover.addInboxEntry(inbox)) { + // Withdrawn by /abort or /opencode_stop while the prompt was on its way. + await cancelInboxPrompt(inbox, "withdrawn_during_handover"); + } + return true; +} + +/** /abort in the session: what was handed over to it is withdrawn with the rest. */ +export async function withdrawHandedOverPrompts(sessionId: string, reason: string): Promise { + promptQueue.withdrawHandedOverReservations(sessionId); + const inboxEntries = promptHandover.withdraw(sessionId, reason); + wakers.get(sessionId)?.(); + await Promise.all(inboxEntries.map((inbox) => cancelInboxPrompt(inbox, reason))); +} + +/** /opencode_stop: everything handed over to any session is withdrawn. */ +export async function withdrawAllHandedOverPrompts(reason: string): Promise { + promptQueue.withdrawHandedOverReservations(); + const inboxEntries = promptHandover.withdrawAll(reason); + for (const wake of wakers.values()) { + wake(); + } + await Promise.all(inboxEntries.map((inbox) => cancelInboxPrompt(inbox, reason))); +} + +/** A session went idle or failed: its delivery loop checks the status now. */ +export function wakePromptHandover(sessionId: string): void { + wakers.get(sessionId)?.(); +} + +function startDeliveryLoop(sessionId: string): void { + if (!promptHandover.hasPendingPrompts(sessionId)) { + return; + } + if (runningLoops.has(sessionId)) { + wakePromptHandover(sessionId); + return; + } + + const record = promptHandover.get(sessionId); + if (!record) { + return; + } + + runningLoops.add(sessionId); + void runDeliveryLoop(record) + .catch((err) => { + logger.error(`[PromptHandover] Delivery loop failed: session=${sessionId}`, err); + }) + .finally(() => { + runningLoops.delete(sessionId); + // Prompts handed over while the loop was finishing - to this record, or to a new one + // after a withdrawal and another /detach - start it again. + startDeliveryLoop(sessionId); + }); +} + +function isLive(record: SessionHandover): boolean { + return promptHandover.get(record.sessionId) === record; +} + +/** Sends the V1 prompts one by one, each after the session's running turn has ended. */ +async function runDeliveryLoop(record: SessionHandover): Promise { + const { sessionId } = record; + + while (isLive(record) && record.prompts.length > 0) { + if (!(await waitUntilIdle(record))) { + return; + } + + const prompt = promptHandover.takeNextPrompt(sessionId); + if (!prompt) { + break; + } + + promptHandover.setTurnInFlight(sessionId, true); + try { + const sent = await sendHandedOverPrompt(record, prompt); + if (sent) { + await waitForTurnEnd(record); + } + } finally { + promptHandover.setTurnInFlight(sessionId, false); + } + } + + // The attached queue waited behind the handed-over prompts. + if (isLive(record) && getCurrentSession()?.id === sessionId) { + void onHandoverDrained?.(); + } +} + +type SessionStatus = "busy" | "idle" | "unknown"; + +async function readSessionStatus(record: SessionHandover): Promise { + try { + const { data, error } = await opencodeClient.session.status({ directory: record.directory }); + if (error || !data) { + logger.debug(`[PromptHandover] Failed to read session status: session=${record.sessionId}`); + return "unknown"; + } + const status = (data as Record)[record.sessionId]; + return status?.type === "busy" || status?.type === "retry" ? "busy" : "idle"; + } catch (err) { + logger.debug( + `[PromptHandover] Failed to read session status: session=${record.sessionId}`, + err, + ); + return "unknown"; + } +} + +function waitForWakeOrTimeout(sessionId: string, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done(): void { + clearTimeout(timer); + if (wakers.get(sessionId) === done) { + wakers.delete(sessionId); + } + resolve(); + } + wakers.set(sessionId, done); + }); +} + +/** False when the hand-over was withdrawn meanwhile. */ +async function waitUntilIdle(record: SessionHandover): Promise { + while (isLive(record)) { + if ((await readSessionStatus(record)) === "idle") { + return isLive(record); + } + await waitForWakeOrTimeout(record.sessionId, STATUS_POLL_INTERVAL_MS); + } + return false; +} + +/** Waits for the turn the prompt started; one too quick to be seen busy ends after the grace. */ +async function waitForTurnEnd(record: SessionHandover): Promise { + const sentAt = Date.now(); + let seenBusy = false; + + while (isLive(record)) { + await waitForWakeOrTimeout(record.sessionId, STATUS_POLL_INTERVAL_MS); + const status = await readSessionStatus(record); + if (status === "busy") { + seenBusy = true; + } else if (status === "idle" && (seenBusy || Date.now() - sentAt >= TURN_START_GRACE_MS)) { + return; + } + } +} + +async function sendHandedOverPrompt( + record: SessionHandover, + prompt: HandedOverPrompt, +): Promise { + const deps = handoverDeps; + if (!deps) { + return false; + } + + const session = { id: record.sessionId, directory: record.directory }; + try { + const promptOptions = await prepareHandedOverPrompt( + deps.bot.api, + prompt, + session, + prompt.selection, + deps, + ); + if (!promptOptions) { + logger.warn(`[PromptHandover] Handed-over prompt has nothing to send: session=${session.id}`); + return false; + } + if (!isLive(record)) { + return false; + } + + logger.info(`[PromptHandover] Sending handed-over prompt: session=${session.id}`); + const { error } = await opencodeClient.session.promptAsync(promptOptions); + if (!error) { + return true; + } + + logger.error( + `[PromptHandover] OpenCode refused the handed-over prompt: session=${session.id}`, + formatErrorDetails(error, 6000), + ); + if (isOpencodeNotFoundError(error)) { + promptHandover.withdraw(session.id, "session_not_found"); + } + } catch (err) { + logger.error(`[PromptHandover] Failed to send handed-over prompt: session=${session.id}`, err); + } + + await notifyDeliveryFailure(session.id); + return false; +} + +/** A failed delivery is posted only when the bot has re-attached to that session. */ +async function notifyDeliveryFailure(sessionId: string): Promise { + const deps = handoverDeps; + if (!deps?.attachManager.isAttachedSession(sessionId)) { + return; + } + + await deps.bot.api + .sendMessage(config.telegram.allowedUserId, t("bot.prompt_send_error")) + .catch((err) => { + logger.error("[PromptHandover] Failed to report a handed-over prompt failure:", err); + }); +} + +/** Test helper: forgets the dependencies and running loops. */ +export function __resetPromptHandoverForTests(): void { + handoverDeps = null; + onHandoverDrained = null; + for (const wake of wakers.values()) { + wake(); + } + wakers.clear(); + runningLoops.clear(); +} diff --git a/src/bot/handlers/prompt-queue-dispatch.ts b/src/bot/handlers/prompt-queue-dispatch.ts index 7d3cb3aed..a8c365b3b 100644 --- a/src/bot/handlers/prompt-queue-dispatch.ts +++ b/src/bot/handlers/prompt-queue-dispatch.ts @@ -5,6 +5,7 @@ import { promptQueue, type QueuedPromptInput, } from "../../app/managers/prompt-queue-manager.js"; +import { promptHandover, type ArrivalTicket } from "../../app/managers/prompt-handover-manager.js"; import type { IncomingPrompt } from "../../app/types/prompt.js"; import { buildExternalUserInputNotification } from "../../app/services/external-user-input-service.js"; import { @@ -25,6 +26,7 @@ import { startInboxPromptRun, type ProcessPromptDeps, } from "./prompt.js"; +import { handOverPreparedPrompt } from "./prompt-handover.js"; // The queue helpers are called from the guard and the media handlers without // deps, so the dispatcher receives them once at startup instead. Until then the @@ -46,7 +48,13 @@ export function initializePromptQueueDispatch(deps: ProcessPromptDeps): void { } function isBusy(): boolean { - return promptDeps !== null && isForegroundBusy(promptDeps); + return promptDeps !== null && (isForegroundBusy(promptDeps) || waitsForHandedOverPrompts()); +} + +/** Prompts handed over to the current session at /detach go before anything sent since. */ +function waitsForHandedOverPrompts(): boolean { + const session = getCurrentSession(); + return Boolean(session && promptHandover.hasPendingPrompts(session.id)); } function isPromptQueueEnabled(): boolean { @@ -150,11 +158,18 @@ async function sendPromptToInbox( const admitted = await admitPromptToInbox(ctx, input, deps, delivery); if (!admitted) { promptQueue.releaseReservation(reservationId); + promptQueue.releaseHandedOverReservation(reservationId); return; } const inbox = { sessionId: admitted.sessionId, inboxId: admitted.inboxId, delivery } as const; + // Handed over by /detach while on its way: it stays with that session, and nothing is shown. + if (promptQueue.releaseHandedOverReservation(reservationId)) { + promptHandover.addInboxEntry(inbox); + return; + } + // OpenCode may deliver the prompt before the send returns (the turn had just ended): // the pickup has already been shown, so there is no button, only the run to open. if (promptQueue.wasInboxIdDelivered(admitted.inboxId)) { @@ -196,10 +211,30 @@ async function replyInboxAdmission(ctx: Context, delivery: "steer" | "queue"): P ); } +/** + * The session a message arriving now would wait for, taken before the message is + * prepared; undefined when it would not wait. + */ +export function takeArrivalTicket(): ArrivalTicket | undefined { + const session = getCurrentSession(); + if (!session || !isPromptQueueEnabled() || !isBusy()) { + return undefined; + } + return promptHandover.takeTicket(session.id); +} + +/** + * Queues a prepared prompt when the session is busy. A message whose preparation + * outlived /detach goes to the session it arrived for, as one sent right before. + */ export async function tryEnqueuePromptIfBusy( ctx: Context, input: QueuedPromptInput, + ticket?: ArrivalTicket, ): Promise { + if (ticket && promptHandover.wasDetachedSince(ticket) && isQueueablePrompt(input)) { + return handOverPreparedPrompt(ctx, ticket, input); + } return isBusy() && tryEnqueuePrompt(ctx, input); } diff --git a/src/bot/handlers/prompt.ts b/src/bot/handlers/prompt.ts index 664294e52..bd987746b 100644 --- a/src/bot/handlers/prompt.ts +++ b/src/bot/handlers/prompt.ts @@ -38,6 +38,7 @@ import { supportsInput, } from "../../app/services/model-capabilities-service.js"; import type { IncomingPrompt } from "../../app/types/prompt.js"; +import type { HandoverSelection } from "../../app/managers/prompt-handover-manager.js"; /** Module-level references for async callbacks that don't have ctx. */ let botInstance: Bot | null = null; @@ -406,31 +407,20 @@ async function preparePromptRequest( ): Promise { const currentAgent = await resolveProjectAgent(getStoredAgent()); const storedModel = (deps.getStoredModel ?? getStoredModel)(); - const preparedInput = await prepareTelegramPhotos(ctx, input, deps, storedModel); + const preparedInput = await prepareTelegramPhotos(ctx.api, input, deps, storedModel, (text) => + ctx.reply(text), + ); if (!preparedInput) { return null; } - // Build parts array with text and files - const parts: Array = []; - - // Add text part if present - if (preparedInput.text.trim().length > 0) { - parts.push({ type: "text", text: preparedInput.text }); - } - - // Add file parts - parts.push(...preparedInput.fileParts); - // A file picked in /ls belongs to this prompt. Capture whether one existed before // resolving it: the resolver clears the attachment on every failed check, so afterwards // a null result can no longer tell "nothing was attached" from "it went stale". const pendingAttachment = promptAttachment.get(); const attachmentPart = await resolvePendingAttachment(currentSession.directory); - if (attachmentPart) { - parts.push(attachmentPart); - } else if (pendingAttachment) { + if (!attachmentPart && pendingAttachment) { await ctx.reply(t("attachment.invalid")); } @@ -443,39 +433,14 @@ async function preparePromptRequest( await retireAttachmentConfirmation(ctx, pendingAttachment.confirmationMessageId); } - // If no text and files exist, use a placeholder - if (parts.length === 0 || (parts.length > 0 && parts.every((p) => p.type === "file"))) { - if (preparedInput.fileParts.length > 0) { - // Files without text - add a minimal system prompt - const attachmentText = - preparedInput.fileParts.length === 1 ? "See attached file" : "See attached files"; - parts.unshift({ type: "text", text: attachmentText }); - } - } - - // Counted from `parts` rather than `fileParts`: a file attached through /ls is added - // above and would otherwise be missing from the logs. - const filePartCount = parts.filter((part) => part.type === "file").length; - - const promptOptions: PromptRequestOptions = { - sessionID: currentSession.id, - directory: currentSession.directory, - parts, - ...(currentAgent ? { agent: currentAgent } : {}), - }; + const promptOptions = buildPromptOptions(currentSession, preparedInput, attachmentPart, { + agent: currentAgent, + ...storedModel, + }); - // Use stored model (from settings or config) - if (storedModel.providerID && storedModel.modelID) { - promptOptions.model = { - providerID: storedModel.providerID, - modelID: storedModel.modelID, - }; - - // Add variant if specified - if (storedModel.variant) { - promptOptions.variant = storedModel.variant; - } - } + // Counted from `parts` rather than `fileParts`: a file attached through /ls is among + // them and would otherwise be missing from the logs. + const filePartCount = promptOptions.parts.filter((part) => part.type === "file").length; return { promptOptions, @@ -495,6 +460,53 @@ async function preparePromptRequest( }; } +/** Agent, model and variant a prompt is sent with. */ +type PromptSelection = { + agent: string | undefined; + providerID: string; + modelID: string; + variant?: string | undefined; +}; + +/** Text first, then files; files without text get a minimal placeholder prompt. */ +function buildPromptOptions( + session: { id: string; directory: string }, + input: IncomingPrompt, + attachmentPart: FilePartInput | null, + selection: PromptSelection, +): PromptRequestOptions { + const parts: Array = []; + if (input.text.trim().length > 0) { + parts.push({ type: "text", text: input.text }); + } + parts.push(...input.fileParts); + if (attachmentPart) { + parts.push(attachmentPart); + } + + if (!parts.some((part) => part.type === "text") && input.fileParts.length > 0) { + const attachmentText = + input.fileParts.length === 1 ? "See attached file" : "See attached files"; + parts.unshift({ type: "text", text: attachmentText }); + } + + const promptOptions: PromptRequestOptions = { + sessionID: session.id, + directory: session.directory, + parts, + ...(selection.agent ? { agent: selection.agent } : {}), + }; + + if (selection.providerID && selection.modelID) { + promptOptions.model = { providerID: selection.providerID, modelID: selection.modelID }; + if (selection.variant) { + promptOptions.variant = selection.variant; + } + } + + return promptOptions; +} + export type PromptRunDeps = Pick< AppContainer, "assistantRunState" | "attachManager" | "foregroundSessionState" @@ -565,18 +577,17 @@ export async function admitPromptToInbox( logger.info( `[Bot] Sending prompt to the session inbox: session=${currentSession.id}, delivery=${delivery}, fileCount=${prepared.promptErrorLogContext.fileCount}`, ); - const { data, error } = await opencodeV2Client.session.promptAsync({ - ...prepared.promptOptions, + const inboxId = await sendToSessionInbox( + prepared.promptOptions, delivery, - }); - if (error || !data) { - logger.error("[Bot] OpenCode refused the inbox prompt", prepared.promptErrorLogContext); - logger.error("[Bot] Inbox prompt error details:", formatErrorDetails(error, 6000)); + prepared.promptErrorLogContext, + ); + if (!inboxId) { await ctx.reply(t("bot.prompt_send_error")); return null; } - return { sessionId: currentSession.id, inboxId: data.inboxID }; + return { sessionId: currentSession.id, inboxId }; } catch (err) { logger.error(`[Bot] Failed to send prompt to the inbox: session=${currentSession.id}`, err); await ctx.reply(t("bot.prompt_send_error")); @@ -584,11 +595,85 @@ export async function admitPromptToInbox( } } +/** Sends a prepared prompt into the session inbox; returns its inbox id, or null (logged). */ +async function sendToSessionInbox( + promptOptions: PromptRequestOptions, + delivery: V2InboxDelivery, + logContext: Record, +): Promise { + const { data, error } = await opencodeV2Client.session.promptAsync({ + ...promptOptions, + delivery, + }); + if (error || !data) { + logger.error("[Bot] OpenCode refused the inbox prompt", logContext); + logger.error("[Bot] Inbox prompt error details:", formatErrorDetails(error, 6000)); + return null; + } + return data.inboxID; +} + +type HandedOverPromptDeps = Pick; + +/** + * Turns a prompt handed over at /detach into the OpenCode request for that session, with + * the selection taken at /detach. Nothing is posted to the chat and no /ls file rides + * along; photos the model cannot read are dropped as the attached queue drops them. + * Returns null when nothing is left to send. + */ +export async function prepareHandedOverPrompt( + api: Context["api"], + input: IncomingPrompt, + session: { id: string; directory: string }, + selection: HandoverSelection, + deps: HandedOverPromptDeps, +): Promise { + const preparedInput = await prepareTelegramPhotos(api, input, deps, selection, null); + if (!preparedInput) { + return null; + } + + const promptOptions = buildPromptOptions(session, preparedInput, null, selection); + return promptOptions.parts.length > 0 ? promptOptions : null; +} + +/** + * Sends a prompt handed over at /detach into that session's OpenCode V2 inbox. Returns + * the inbox id, or null when it was not sent; failures are logged, not posted. + */ +export async function admitHandedOverPromptToInbox( + api: Context["api"], + input: IncomingPrompt, + session: { id: string; directory: string }, + selection: HandoverSelection, + delivery: V2InboxDelivery, + deps: HandedOverPromptDeps, +): Promise { + try { + const promptOptions = await prepareHandedOverPrompt(api, input, session, selection, deps); + if (!promptOptions) { + return null; + } + + logger.info( + `[Bot] Sending handed-over prompt to the session inbox: session=${session.id}, delivery=${delivery}`, + ); + return await sendToSessionInbox(promptOptions, delivery, { sessionId: session.id }); + } catch (err) { + logger.error(`[Bot] Failed to send handed-over prompt to the inbox: session=${session.id}`, err); + return null; + } +} + +/** Replies in the chat while a prompt is prepared; null for a prompt that shows nothing. */ +type PromptPreparationReply = ((text: string) => Promise) | null; + async function prepareTelegramPhotos( - ctx: Context, + api: Context["api"], input: IncomingPrompt, - deps: ProcessPromptDeps, + deps: HandedOverPromptDeps, storedModel: { providerID: string; modelID: string }, + reply: PromptPreparationReply, ): Promise { if (input.photos.length === 0) { return input; @@ -602,7 +687,7 @@ async function prepareTelegramPhotos( `[Bot] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support image input`, ); const onlyStandalone = input.photos.every((photo) => photo.source === "standalone"); - await ctx.reply( + await reply?.( input.photos.some((photo) => photo.source === "album") ? t("bot.media_group_not_processed") : t("bot.photo_model_no_image"), @@ -614,7 +699,7 @@ async function prepareTelegramPhotos( } const isAlbum = input.photos.every((photo) => photo.source === "album"); - await ctx.reply( + await reply?.( isAlbum || input.photos.length > 1 ? t("bot.files_downloading") : t("bot.photo_downloading"), ); @@ -623,7 +708,7 @@ async function prepareTelegramPhotos( try { const downloadedParts: FilePartInput[] = []; for (const photo of input.photos) { - const downloaded = await downloadFile(ctx.api, photo.fileId); + const downloaded = await downloadFile(api, photo.fileId); downloadedParts.push({ type: "file", mime: "image/jpeg", @@ -640,7 +725,7 @@ async function prepareTelegramPhotos( }; } catch (err) { logger.error("[Bot] Error downloading Telegram photo input:", err); - await ctx.reply(isAlbum ? t("bot.media_group_download_error") : t("bot.photo_download_error")); + await reply?.(isAlbum ? t("bot.media_group_download_error") : t("bot.photo_download_error")); return null; } } diff --git a/src/bot/handlers/voice-handler.ts b/src/bot/handlers/voice-handler.ts index 0405ed24e..a10709f9a 100644 --- a/src/bot/handlers/voice-handler.ts +++ b/src/bot/handlers/voice-handler.ts @@ -19,7 +19,7 @@ import { t } from "../../i18n/index.js"; import { buildTelegramFileUrl } from "../../app/services/file-download-service.js"; import { buildQuotedNotification } from "../../app/services/quoted-notification.js"; import { editBotText } from "../messages/telegram-text.js"; -import { tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; +import { takeArrivalTicket, tryEnqueuePromptIfBusy } from "./prompt-queue-dispatch.js"; const TELEGRAM_DOWNLOAD_TIMEOUT_MS = 30_000; const TELEGRAM_DOWNLOAD_MAX_REDIRECTS = 3; @@ -195,6 +195,7 @@ export async function handleVoiceMessage(ctx: Context, deps: VoiceMessageDeps): } flushPendingPrompt(ctx.chat!.id); + const ticket = takeArrivalTicket(); // Check if STT is configured if (!sttConfigured()) { @@ -261,11 +262,15 @@ export async function handleVoiceMessage(ctx: Context, deps: VoiceMessageDeps): const responseMode = currentTtsMode === "all" || currentTtsMode === "auto" ? "text_and_tts" : "text_only"; if ( - await tryEnqueuePromptIfBusy(ctx, { - ...createIncomingPrompt(textForLLM), - displayText: recognizedText, - responseMode, - }) + await tryEnqueuePromptIfBusy( + ctx, + { + ...createIncomingPrompt(textForLLM), + displayText: recognizedText, + responseMode, + }, + ticket, + ) ) { return; } diff --git a/src/bot/index.ts b/src/bot/index.ts index aec94054c..65615eecb 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -14,7 +14,11 @@ import { telegramOutageNoticeService } from "../app/services/telegram-outage-not import { flushTelegramOutageNotices, isUnretriedTelegramSend } from "./telegram-outage-notices.js"; import { LocalCommandRegistry } from "../app/services/local-command-registry.js"; import { registerCallbackRouter } from "./callbacks/callback-router.js"; -import { initializePromptQueueDispatch } from "./handlers/prompt-queue-dispatch.js"; +import { + dispatchNextQueuedPrompt, + initializePromptQueueDispatch, +} from "./handlers/prompt-queue-dispatch.js"; +import { initializePromptHandover } from "./handlers/prompt-handover.js"; import { normalizeRichMessage } from "./handlers/rich-message-handler.js"; import { authMiddleware } from "./middleware/auth.js"; import { interactionGuardMiddleware } from "./middleware/interaction-guard.js"; @@ -90,6 +94,7 @@ export function createBot( container.setTelegramContext(bot, config.telegram.allowedUserId); initializePromptQueueDispatch({ ...container, bot }); + initializePromptHandover({ ...container, bot }, dispatchNextQueuedPrompt); container.setReadyRestoreHandler(async (reason) => { const restored = await restoreAttachedCurrentSession({ diff --git a/src/bot/routers/message-router.ts b/src/bot/routers/message-router.ts index 2a8d972d7..2d97a1a48 100644 --- a/src/bot/routers/message-router.ts +++ b/src/bot/routers/message-router.ts @@ -27,6 +27,7 @@ import { handleDocumentMessage } from "../handlers/document-handler.js"; import { createMediaGroupAttachmentMiddleware } from "../handlers/media-group-handler.js"; import { handlePhotoMessage } from "../handlers/photo-handler.js"; import { queuePromptForMerging } from "../handlers/message-merger.js"; +import { tryEnqueuePromptIfBusy } from "../handlers/prompt-queue-dispatch.js"; import { handleCatalogTextArguments } from "../handlers/text-message-handler.js"; import { handleVoiceMessage } from "../handlers/voice-handler.js"; import { unknownCommandMiddleware } from "../middleware/unknown-command.js"; @@ -234,6 +235,12 @@ export function registerMessageRouter(bot: Bot, deps: MessageRouterDeps return; } + // The guard lets text through while prompts handed over at /detach still wait for + // this session; it queues behind them. + if (await tryEnqueuePromptIfBusy(ctx, input)) { + return; + } + queuePromptForMerging(ctx, input, botDeps, config.bot.messageMergeWindowMs); logger.debug( diff --git a/tests/app/managers/prompt-handover-manager.test.ts b/tests/app/managers/prompt-handover-manager.test.ts new file mode 100644 index 000000000..23ab46f46 --- /dev/null +++ b/tests/app/managers/prompt-handover-manager.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { promptHandover } from "../../../src/app/managers/prompt-handover-manager.js"; +import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; + +const SESSION = { id: "ses-1", directory: "D:/repo" }; +const SELECTION = { agent: "build", providerID: "p", modelID: "m", variant: "high" }; + +function handedOver(text: string) { + return { ...createIncomingPrompt(text), selection: SELECTION }; +} + +describe("app/managers/prompt-handover-manager", () => { + beforeEach(() => { + promptHandover.__resetForTests(); + }); + + it("accepts nothing for a session that was never detached", () => { + expect(promptHandover.addPrompt("ses-1", handedOver("lost"))).toBe(false); + expect( + promptHandover.addInboxEntry({ sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }), + ).toBe(false); + expect(promptHandover.hasPendingPrompts("ses-1")).toBe(false); + }); + + it("keeps the prompts of a detached session in order until taken", () => { + promptHandover.recordDetach(SESSION, SELECTION); + promptHandover.addPrompt("ses-1", handedOver("first")); + promptHandover.addPrompt("ses-1", handedOver("second")); + + expect(promptHandover.takeNextPrompt("ses-1")?.text).toBe("first"); + expect(promptHandover.hasPendingPrompts("ses-1")).toBe(true); + expect(promptHandover.takeNextPrompt("ses-1")?.text).toBe("second"); + expect(promptHandover.hasPendingPrompts("ses-1")).toBe(false); + }); + + it("counts a sent prompt as pending until its turn is over", () => { + promptHandover.recordDetach(SESSION, SELECTION); + + promptHandover.setTurnInFlight("ses-1", true); + expect(promptHandover.hasPendingPrompts("ses-1")).toBe(true); + + promptHandover.setTurnInFlight("ses-1", false); + expect(promptHandover.hasPendingPrompts("ses-1")).toBe(false); + }); + + it("follows a ticket to its session only when that session was detached after it", () => { + const before = promptHandover.takeTicket("ses-1"); + promptHandover.recordDetach(SESSION, SELECTION); + const after = promptHandover.takeTicket("ses-1"); + + expect(promptHandover.wasDetachedSince(before)).toBe(true); + expect(promptHandover.wasDetachedSince(after)).toBe(false); + expect(promptHandover.wasDetachedSince({ sessionId: "ses-2", detachSeq: 0 })).toBe(false); + }); + + it("forgets an inbox entry once OpenCode picked it up", () => { + promptHandover.recordDetach(SESSION, SELECTION); + promptHandover.addInboxEntry({ sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }); + promptHandover.addInboxEntry({ sessionId: "ses-1", inboxId: "msg-2", delivery: "steer" }); + + promptHandover.forgetInboxId("msg-1"); + + expect(promptHandover.withdraw("ses-1", "abort_command")).toEqual([ + { sessionId: "ses-1", inboxId: "msg-2", delivery: "steer" }, + ]); + }); + + it("withdraws one session and leaves the others", () => { + promptHandover.recordDetach(SESSION, SELECTION); + promptHandover.recordDetach({ id: "ses-2", directory: "D:/other" }, SELECTION); + promptHandover.addPrompt("ses-1", handedOver("one")); + promptHandover.addPrompt("ses-2", handedOver("two")); + + promptHandover.withdraw("ses-1", "abort_command"); + + expect(promptHandover.get("ses-1")).toBeNull(); + expect(promptHandover.hasPendingPrompts("ses-2")).toBe(true); + }); + + it("withdraws every session and returns all their inbox entries", () => { + promptHandover.recordDetach(SESSION, SELECTION); + promptHandover.recordDetach({ id: "ses-2", directory: "D:/other" }, SELECTION); + promptHandover.addInboxEntry({ sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }); + promptHandover.addInboxEntry({ sessionId: "ses-2", inboxId: "msg-2", delivery: "queue" }); + + const withdrawn = promptHandover.withdrawAll("opencode_stop"); + + expect(withdrawn.map((entry) => entry.inboxId)).toEqual(["msg-1", "msg-2"]); + expect(promptHandover.get("ses-1")).toBeNull(); + expect(promptHandover.get("ses-2")).toBeNull(); + }); + + it("keeps earlier prompts when the same session is detached again", () => { + promptHandover.recordDetach(SESSION, SELECTION); + promptHandover.addPrompt("ses-1", handedOver("first")); + + promptHandover.recordDetach(SESSION, { ...SELECTION, modelID: "other" }); + + expect(promptHandover.get("ses-1")?.selection.modelID).toBe("other"); + expect(promptHandover.takeNextPrompt("ses-1")?.selection.modelID).toBe("m"); + }); +}); diff --git a/tests/app/managers/prompt-queue-manager.test.ts b/tests/app/managers/prompt-queue-manager.test.ts index d4de2ffbf..6bed63141 100644 --- a/tests/app/managers/prompt-queue-manager.test.ts +++ b/tests/app/managers/prompt-queue-manager.test.ts @@ -207,4 +207,52 @@ describe("app/managers/prompt-queue-manager", () => { expect(promptQueue.wasInboxIdDelivered("msg-1")).toBe(false); }); }); + + describe("hand-over at /detach", () => { + it("empties the queue, frees the cap and returns the items in order", () => { + promptQueue.add({ ...prompt("first"), mediaBytes: 100 }); + promptQueue.add(prompt("second")); + + const handedOver = promptQueue.handOver("ses-1", "detach_command"); + + expect(handedOver.map((item) => item.text)).toEqual(["first", "second"]); + expect(promptQueue.size()).toBe(0); + expect(promptQueue.mediaSize()).toBe(0); + }); + + it("keeps reservations on their way out of the cap and out of later clears", () => { + for (let index = 0; index < MAX_QUEUED_PROMPTS; index++) { + promptQueue.reserve(); + } + const reservationId = "reserved-1"; + + promptQueue.handOver("ses-1", "detach_command"); + promptQueue.clear("session_switched"); + + expect(promptQueue.isFull()).toBe(false); + expect(promptQueue.releaseHandedOverReservation(reservationId)).toBe(true); + expect(promptQueue.releaseHandedOverReservation(reservationId)).toBe(false); + }); + + it("releases handed-over reservations only of the withdrawn session", () => { + const first = promptQueue.reserve()!; + promptQueue.handOver("ses-1", "detach_command"); + const second = promptQueue.reserve()!; + promptQueue.handOver("ses-2", "detach_command"); + + promptQueue.withdrawHandedOverReservations("ses-1"); + + expect(promptQueue.releaseHandedOverReservation(first)).toBe(false); + expect(promptQueue.releaseHandedOverReservation(second)).toBe(true); + }); + + it("releases every handed-over reservation when no session is given", () => { + const reservationId = promptQueue.reserve()!; + promptQueue.handOver("ses-1", "detach_command"); + + promptQueue.withdrawHandedOverReservations(); + + expect(promptQueue.releaseHandedOverReservation(reservationId)).toBe(false); + }); + }); }); diff --git a/tests/bot/commands/abort.test.ts b/tests/bot/commands/abort.test.ts index 7c33bd206..848d7dd18 100644 --- a/tests/bot/commands/abort.test.ts +++ b/tests/bot/commands/abort.test.ts @@ -12,6 +12,7 @@ import { shouldSuppressUserAbortSessionError, } from "../../../src/app/managers/abort-suppression-manager.js"; import { createTestAppContainer } from "../../helpers/app-container.js"; +import { defined } from "../../helpers/defined.js"; import type { AppContainer } from "../../../src/app/bootstrap/app-container.js"; const mocked = vi.hoisted(() => ({ @@ -22,6 +23,7 @@ const mocked = vi.hoisted(() => ({ markAttachedSessionIdleMock: vi.fn(), clearPromptResponseModeMock: vi.fn(), inboxCancelMock: vi.fn(), + withdrawHandedOverPromptsMock: vi.fn(), })); vi.mock("../../../src/app/services/session-service.js", () => ({ @@ -48,6 +50,10 @@ vi.mock("../../../src/bot/handlers/prompt.js", () => ({ clearPromptResponseMode: mocked.clearPromptResponseModeMock, })); +vi.mock("../../../src/bot/handlers/prompt-handover.js", () => ({ + withdrawHandedOverPrompts: mocked.withdrawHandedOverPromptsMock, +})); + const TEST_QUESTION: Question = { header: "Q1", question: "Pick one", @@ -94,9 +100,35 @@ describe("bot/commands/abort", () => { mocked.markAttachedSessionIdleMock.mockReset(); mocked.markAttachedSessionIdleMock.mockResolvedValue(undefined); mocked.clearPromptResponseModeMock.mockReset(); + mocked.withdrawHandedOverPromptsMock.mockReset().mockResolvedValue(undefined); __resetUserAbortErrorSuppressionForTests(); }); + it("withdraws what /detach handed over to the session before aborting it", 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 ctx = { chat: { id: 1 }, reply: vi.fn().mockResolvedValue({ message_id: 1 }) }; + + await abortCommand(ctx as never, createDeps()); + + expect(mocked.withdrawHandedOverPromptsMock).toHaveBeenCalledWith("session-1", "abort_command"); + expect( + defined(mocked.withdrawHandedOverPromptsMock.mock.invocationCallOrder[0]), + ).toBeLessThan(defined(mocked.abortMock.mock.invocationCallOrder[0])); + }); + + it("leaves handed-over messages alone in the abort /start shares", 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 ctx = { chat: { id: 1 }, reply: vi.fn().mockResolvedValue({ message_id: 1 }) }; + + await abortCurrentOperation(ctx as never, createDeps(), { notifyUser: false }); + + expect(mocked.withdrawHandedOverPromptsMock).not.toHaveBeenCalled(); + }); + function markSessionBusy(): void { container.foregroundSessionState.markBusy("session-1", "D:/repo"); } diff --git a/tests/bot/commands/detach.test.ts b/tests/bot/commands/detach.test.ts index cf610f85c..27b4a67f7 100644 --- a/tests/bot/commands/detach.test.ts +++ b/tests/bot/commands/detach.test.ts @@ -22,6 +22,7 @@ const mocked = vi.hoisted(() => ({ foregroundMarkIdleMock: vi.fn(), assistantClearRunMock: vi.fn(), clearPromptResponseModeMock: vi.fn(), + handOverPromptQueueMock: vi.fn().mockResolvedValue(undefined), })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ @@ -42,6 +43,10 @@ vi.mock("../../../src/bot/handlers/prompt.js", () => ({ clearPromptResponseMode: mocked.clearPromptResponseModeMock, })); +vi.mock("../../../src/bot/handlers/prompt-handover.js", () => ({ + handOverPromptQueue: mocked.handOverPromptQueueMock, +})); + function createDeps() { return createTestAppContainer({ resetInteractions: mocked.clearAllInteractionStateMock, @@ -98,6 +103,20 @@ describe("bot/commands/detach", () => { mocked.foregroundMarkIdleMock.mockClear(); mocked.assistantClearRunMock.mockClear(); mocked.clearPromptResponseModeMock.mockClear(); + mocked.handOverPromptQueueMock.mockClear(); + }); + + it("hands waiting messages over to the session before clearing it, so none is withdrawn", async () => { + await detachCommand(createContext() as never, createDeps()); + + expect(mocked.handOverPromptQueueMock).toHaveBeenCalledWith({ + id: "session-1", + title: "Long Run", + directory: "D:/repo", + }); + expect(defined(mocked.handOverPromptQueueMock.mock.invocationCallOrder[0])).toBeLessThan( + defined(mocked.clearSessionMock.mock.invocationCallOrder[0]), + ); }); it("detaches selected session locally without stopping the OpenCode session", async () => { @@ -183,6 +202,7 @@ describe("bot/commands/detach", () => { await detachCommand(ctx as never, createDeps()); expect(ctx.reply).toHaveBeenCalledWith(t("detach.no_active_session")); + expect(mocked.handOverPromptQueueMock).not.toHaveBeenCalled(); expect(mocked.detachAttachedSessionMock).not.toHaveBeenCalled(); expect(mocked.clearSessionMock).not.toHaveBeenCalled(); }); diff --git a/tests/bot/commands/opencode-stop.test.ts b/tests/bot/commands/opencode-stop.test.ts index 35e093c00..8a42eaf0a 100644 --- a/tests/bot/commands/opencode-stop.test.ts +++ b/tests/bot/commands/opencode-stop.test.ts @@ -55,6 +55,11 @@ vi.mock("../../../src/bot/handlers/prompt.js", () => ({ })); const withdrawPromptQueueMock = vi.hoisted(() => vi.fn()); +const withdrawAllHandedOverPromptsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../src/bot/handlers/prompt-handover.js", () => ({ + withdrawAllHandedOverPrompts: withdrawAllHandedOverPromptsMock, +})); vi.mock("../../../src/app/services/prompt-inbox-service.js", () => ({ withdrawPromptQueue: withdrawPromptQueueMock, @@ -184,6 +189,9 @@ describe("bot/commands/opencode-stop-command", () => { withdrawPromptQueueMock.mockReset().mockImplementation(async () => { order.push("withdraw"); }); + withdrawAllHandedOverPromptsMock.mockReset().mockImplementation(async () => { + order.push("withdraw_handed_over"); + }); mocked.findServerPidMock.mockResolvedValue(456); mocked.killServerProcessMock.mockImplementation(async () => { order.push("kill"); @@ -193,7 +201,8 @@ describe("bot/commands/opencode-stop-command", () => { await opencodeStopCommand(ctx as never, createDeps()); expect(withdrawPromptQueueMock).toHaveBeenCalledWith("opencode_stop"); - expect(order).toEqual(["withdraw", "kill"]); + expect(withdrawAllHandedOverPromptsMock).toHaveBeenCalledWith("opencode_stop"); + expect(order).toEqual(["withdraw", "withdraw_handed_over", "kill"]); }); it("ends the lost run in the chat after the kill and before the local reset", async () => { diff --git a/tests/bot/handlers/prompt-handover.test.ts b/tests/bot/handlers/prompt-handover.test.ts new file mode 100644 index 000000000..ed7cdc228 --- /dev/null +++ b/tests/bot/handlers/prompt-handover.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Context } from "grammy"; +import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; + +const mocked = vi.hoisted(() => ({ + version: "v1" as "v1" | "v2", + status: "busy" as "busy" | "idle", + statusMock: vi.fn(), + promptAsyncMock: vi.fn(), + resolveProjectAgentMock: vi.fn(), + getStoredModelMock: vi.fn(), + cancelInboxPromptMock: vi.fn(), + getCurrentSessionMock: vi.fn(), + getPromptQueueModeMock: vi.fn(), + admitHandedOverPromptToInboxMock: vi.fn(), +})); + +vi.mock("../../../src/opencode/client.js", () => ({ + opencodeClient: { + session: { status: mocked.statusMock, promptAsync: mocked.promptAsyncMock }, + }, + opencodeV2Client: {}, + get opencodeServerVersion() { + return mocked.version; + }, +})); + +vi.mock("../../../src/app/services/agent-selection-service.js", () => ({ + getStoredAgent: vi.fn(() => "build"), + resolveProjectAgent: mocked.resolveProjectAgentMock, +})); + +vi.mock("../../../src/app/services/model-selection-service.js", () => ({ + getStoredModel: mocked.getStoredModelMock, +})); + +vi.mock("../../../src/app/services/prompt-inbox-service.js", () => ({ + cancelInboxPrompt: mocked.cancelInboxPromptMock, +})); + +vi.mock("../../../src/app/services/session-service.js", () => ({ + getCurrentSession: mocked.getCurrentSessionMock, +})); + +vi.mock("../../../src/app/stores/settings-store.js", () => ({ + getPromptQueueMode: mocked.getPromptQueueModeMock, +})); + +vi.mock("../../../src/bot/handlers/prompt.js", async (importOriginal) => ({ + ...(await importOriginal()), + admitHandedOverPromptToInbox: mocked.admitHandedOverPromptToInboxMock, +})); + +import { promptHandover } from "../../../src/app/managers/prompt-handover-manager.js"; +import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { config } from "../../../src/config.js"; +import { t } from "../../../src/i18n/index.js"; +import { + __resetPromptHandoverForTests, + handOverPreparedPrompt, + handOverPromptQueue, + initializePromptHandover, + wakePromptHandover, + withdrawAllHandedOverPrompts, + withdrawHandedOverPrompts, +} from "../../../src/bot/handlers/prompt-handover.js"; +import { createTestAppContainer } from "../../helpers/app-container.js"; + +const SESSION = { id: "ses-1", title: "Session", directory: "D:/repo" }; +const MODEL = { providerID: "p", modelID: "m", variant: "high" }; + +let sendMessageMock: ReturnType; +let onDrainedMock: ReturnType Promise>>; +let deps: ReturnType; + +function createDeps() { + return { + ...createTestAppContainer(), + bot: { api: { sendMessage: sendMessageMock } } as never, + }; +} + +function sentTexts(): string[] { + return mocked.promptAsyncMock.mock.calls.map( + ([options]) => (options as { parts: Array<{ text: string }> }).parts[0]?.text ?? "", + ); +} + +function makeContext(): Context { + return { chat: { id: 42 }, api: {}, reply: vi.fn() } as unknown as Context; +} + +describe("bot/handlers/prompt-handover", () => { + beforeEach(() => { + vi.useFakeTimers(); + promptQueue.__resetForTests(); + promptHandover.__resetForTests(); + __resetPromptHandoverForTests(); + mocked.version = "v1"; + mocked.status = "busy"; + mocked.statusMock.mockReset().mockImplementation(async () => ({ + data: { [SESSION.id]: { type: mocked.status } }, + })); + mocked.promptAsyncMock.mockReset().mockImplementation(async () => { + mocked.status = "busy"; + return { data: undefined }; + }); + mocked.resolveProjectAgentMock.mockReset().mockResolvedValue("build"); + mocked.getStoredModelMock.mockReset().mockReturnValue(MODEL); + mocked.cancelInboxPromptMock.mockReset().mockResolvedValue(undefined); + mocked.getCurrentSessionMock.mockReset().mockReturnValue(null); + mocked.getPromptQueueModeMock.mockReset().mockReturnValue("steer"); + mocked.admitHandedOverPromptToInboxMock.mockReset().mockResolvedValue("msg-9"); + sendMessageMock = vi.fn().mockResolvedValue(undefined); + onDrainedMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + deps = createDeps(); + initializePromptHandover(deps, onDrainedMock); + }); + + afterEach(() => { + __resetPromptHandoverForTests(); + vi.useRealTimers(); + }); + + describe("on OpenCode V1", () => { + it("sends the held prompts after the running turn, one per turn, with the selection at /detach", async () => { + promptQueue.add(createIncomingPrompt("first")); + promptQueue.add(createIncomingPrompt("second")); + + await handOverPromptQueue(SESSION); + mocked.getStoredModelMock.mockReturnValue({ providerID: "x", modelID: "y" }); + expect(promptQueue.size()).toBe(0); + + await vi.advanceTimersByTimeAsync(3000); + expect(mocked.promptAsyncMock).not.toHaveBeenCalled(); + + mocked.status = "idle"; + await vi.advanceTimersByTimeAsync(1500); + expect(sentTexts()).toEqual(["first"]); + expect(mocked.promptAsyncMock).toHaveBeenCalledWith({ + sessionID: "ses-1", + directory: "D:/repo", + parts: [{ type: "text", text: "first" }], + agent: "build", + model: { providerID: "p", modelID: "m" }, + variant: "high", + }); + + await vi.advanceTimersByTimeAsync(6000); + expect(sentTexts()).toEqual(["first"]); + + mocked.status = "idle"; + await vi.advanceTimersByTimeAsync(1500); + expect(sentTexts()).toEqual(["first", "second"]); + }); + + it("continues the attached queue once the last handed-over turn in the current session ends", async () => { + mocked.getCurrentSessionMock.mockReturnValue(SESSION); + mocked.status = "idle"; + promptQueue.add(createIncomingPrompt("only")); + + await handOverPromptQueue(SESSION); + await vi.advanceTimersByTimeAsync(0); + expect(sentTexts()).toEqual(["only"]); + await vi.advanceTimersByTimeAsync(1500); + expect(promptHandover.hasPendingPrompts("ses-1")).toBe(true); + expect(onDrainedMock).not.toHaveBeenCalled(); + + mocked.status = "idle"; + await vi.advanceTimersByTimeAsync(1500); + + expect(promptHandover.hasPendingPrompts("ses-1")).toBe(false); + expect(onDrainedMock).toHaveBeenCalledTimes(1); + }); + + it("counts a turn too quick to be seen busy as over after the grace window", async () => { + mocked.status = "idle"; + mocked.promptAsyncMock.mockResolvedValue({ data: undefined }); + promptQueue.add(createIncomingPrompt("quick")); + promptQueue.add(createIncomingPrompt("next")); + + await handOverPromptQueue(SESSION); + await vi.advanceTimersByTimeAsync(3000); + expect(sentTexts()).toEqual(["quick"]); + + await vi.advanceTimersByTimeAsync(3000); + expect(sentTexts()).toEqual(["quick", "next"]); + }); + + it("checks at once when the session goes idle", async () => { + promptQueue.add(createIncomingPrompt("first")); + await handOverPromptQueue(SESSION); + await vi.advanceTimersByTimeAsync(0); + + mocked.status = "idle"; + wakePromptHandover("ses-1"); + await vi.advanceTimersByTimeAsync(0); + + expect(sentTexts()).toEqual(["first"]); + }); + + it("sends the next prompt after a failed one and posts the failure only when re-attached", async () => { + mocked.status = "idle"; + mocked.promptAsyncMock.mockResolvedValueOnce({ error: { name: "BadRequest" } }); + promptQueue.add(createIncomingPrompt("fails")); + promptQueue.add(createIncomingPrompt("goes")); + deps.attachManager.attach("ses-1", "D:/repo"); + + await handOverPromptQueue(SESSION); + await vi.advanceTimersByTimeAsync(0); + + expect(sentTexts()).toEqual(["fails", "goes"]); + expect(sendMessageMock).toHaveBeenCalledWith( + config.telegram.allowedUserId, + t("bot.prompt_send_error"), + ); + }); + + it("posts nothing about a failed prompt while detached", async () => { + mocked.status = "idle"; + mocked.promptAsyncMock.mockResolvedValueOnce({ error: { name: "BadRequest" } }); + promptQueue.add(createIncomingPrompt("fails")); + + await handOverPromptQueue(SESSION); + await vi.advanceTimersByTimeAsync(0); + + expect(sentTexts()).toEqual(["fails"]); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + it("sends nothing once /abort in that session withdrew them", async () => { + promptQueue.add(createIncomingPrompt("withdrawn")); + await handOverPromptQueue(SESSION); + + await withdrawHandedOverPrompts("ses-1", "abort_command"); + mocked.status = "idle"; + await vi.advanceTimersByTimeAsync(6000); + + expect(mocked.promptAsyncMock).not.toHaveBeenCalled(); + expect(promptHandover.get("ses-1")).toBeNull(); + }); + + it("sends prompts handed over again while a withdrawn loop is still finishing", async () => { + promptQueue.add(createIncomingPrompt("old")); + await handOverPromptQueue(SESSION); + await vi.advanceTimersByTimeAsync(0); + + let resolveStatus: (value: unknown) => void = () => undefined; + mocked.statusMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveStatus = resolve)), + ); + await vi.advanceTimersByTimeAsync(1500); + + await withdrawHandedOverPrompts("ses-1", "abort_command"); + promptQueue.add(createIncomingPrompt("new")); + await handOverPromptQueue(SESSION); + mocked.status = "idle"; + resolveStatus({ data: { [SESSION.id]: { type: "idle" } } }); + await vi.advanceTimersByTimeAsync(0); + + expect(sentTexts()).toEqual(["new"]); + }); + + it("sends nothing once /opencode_stop withdrew everything", async () => { + promptQueue.add(createIncomingPrompt("withdrawn")); + await handOverPromptQueue(SESSION); + + await withdrawAllHandedOverPrompts("opencode_stop"); + mocked.status = "idle"; + await vi.advanceTimersByTimeAsync(6000); + + expect(mocked.promptAsyncMock).not.toHaveBeenCalled(); + }); + + it("hands a prompt prepared across /detach over to the session it arrived for", async () => { + const ticket = promptHandover.takeTicket("ses-1"); + await handOverPromptQueue(SESSION); + mocked.status = "idle"; + + await expect( + handOverPreparedPrompt(makeContext(), ticket, createIncomingPrompt("transcribed")), + ).resolves.toBe(true); + await vi.advanceTimersByTimeAsync(0); + + expect(sentTexts()).toEqual(["transcribed"]); + }); + + it("leaves a prompt that arrived after the detach to the normal path", async () => { + await handOverPromptQueue(SESSION); + const ticket = promptHandover.takeTicket("ses-1"); + + await expect( + handOverPreparedPrompt(makeContext(), ticket, createIncomingPrompt("later")), + ).resolves.toBe(false); + }); + }); + + describe("on OpenCode V2", () => { + beforeEach(() => { + mocked.version = "v2"; + }); + + it("leaves waiting inbox prompts in OpenCode at /detach and clears their buttons", async () => { + promptQueue.confirmReservation(promptQueue.reserve()!, { + displayText: "steered", + inbox: { sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }, + }); + + await handOverPromptQueue(SESSION); + + expect(promptQueue.size()).toBe(0); + expect(mocked.cancelInboxPromptMock).not.toHaveBeenCalled(); + expect(mocked.promptAsyncMock).not.toHaveBeenCalled(); + expect(promptHandover.get("ses-1")?.inboxEntries).toEqual([ + { sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }, + ]); + }); + + it("cancels the handed-over inbox prompts on /opencode_stop", async () => { + promptQueue.confirmReservation(promptQueue.reserve()!, { + displayText: "steered", + inbox: { sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }, + }); + await handOverPromptQueue(SESSION); + + await withdrawAllHandedOverPrompts("opencode_stop"); + + expect(mocked.cancelInboxPromptMock).toHaveBeenCalledWith( + { sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }, + "opencode_stop", + ); + }); + + it("sends a prompt prepared across /detach into that session's inbox with the selection at /detach", async () => { + const ticket = promptHandover.takeTicket("ses-1"); + await handOverPromptQueue(SESSION); + const input = createIncomingPrompt("transcribed"); + const ctx = makeContext(); + + await expect(handOverPreparedPrompt(ctx, ticket, input)).resolves.toBe(true); + + expect(mocked.admitHandedOverPromptToInboxMock).toHaveBeenCalledWith( + ctx.api, + input, + { id: "ses-1", directory: "D:/repo" }, + { agent: "build", ...MODEL }, + "steer", + deps, + ); + expect(ctx.reply).not.toHaveBeenCalled(); + expect(promptHandover.get("ses-1")?.inboxEntries).toEqual([ + { sessionId: "ses-1", inboxId: "msg-9", delivery: "steer" }, + ]); + }); + }); +}); diff --git a/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts b/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts index 1f467d494..95756476e 100644 --- a/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts +++ b/tests/bot/handlers/prompt-queue-dispatch-v2.test.ts @@ -47,6 +47,7 @@ vi.mock("../../../src/app/services/session-service.js", async (importOriginal) = })); import { MAX_QUEUED_PROMPTS, promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { promptHandover } from "../../../src/app/managers/prompt-handover-manager.js"; import { __resetPromptQueueDispatchForTests, dispatchNextQueuedPrompt, @@ -78,6 +79,7 @@ function makeContext(): Context { describe("bot/handlers/prompt-queue-dispatch on OpenCode V2", () => { beforeEach(() => { promptQueue.__resetForTests(); + promptHandover.__resetForTests(); __resetPromptQueueDispatchForTests(); DEPS.assistantRunState.clearAll("test"); replyMock = vi.fn().mockResolvedValue(undefined); @@ -163,6 +165,41 @@ describe("bot/handlers/prompt-queue-dispatch on OpenCode V2", () => { expect(replyMock).not.toHaveBeenCalled(); }); + it("leaves a prompt on its way at /detach with that session, even after a switch", async () => { + mocked.admitPromptToInbox.mockImplementation(async () => { + promptHandover.recordDetach(SESSION, { agent: "build", providerID: "p", modelID: "m" }); + promptQueue.handOver("ses-1", "detach_command"); + promptQueue.clear("session_switched"); + return { sessionId: "ses-1", inboxId: "msg-1" }; + }); + + await tryEnqueuePrompt(makeContext(), createIncomingPrompt("Right before detach")); + + expect(mocked.cancelInboxPrompt).not.toHaveBeenCalled(); + expect(replyMock).not.toHaveBeenCalled(); + expect(promptQueue.size()).toBe(0); + expect(promptHandover.get("ses-1")?.inboxEntries).toEqual([ + { sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }, + ]); + }); + + it("cancels a prompt on its way at /detach once the hand-over is withdrawn", async () => { + mocked.admitPromptToInbox.mockImplementation(async () => { + promptHandover.recordDetach(SESSION, { agent: "build", providerID: "p", modelID: "m" }); + promptQueue.handOver("ses-1", "detach_command"); + promptQueue.withdrawHandedOverReservations("ses-1"); + promptHandover.withdraw("ses-1", "abort_command"); + return { sessionId: "ses-1", inboxId: "msg-1" }; + }); + + await tryEnqueuePrompt(makeContext(), createIncomingPrompt("Withdrawn")); + + expect(mocked.cancelInboxPrompt).toHaveBeenCalledWith( + { sessionId: "ses-1", inboxId: "msg-1", delivery: "steer" }, + "withdrawn_during_admission", + ); + }); + it("cancels a prompt whose queue was cleared while it was on its way", async () => { mocked.admitPromptToInbox.mockImplementation(async () => { promptQueue.clear("abort_command"); diff --git a/tests/bot/handlers/prompt-queue-dispatch.test.ts b/tests/bot/handlers/prompt-queue-dispatch.test.ts index 63ebf09ab..f51410f0c 100644 --- a/tests/bot/handlers/prompt-queue-dispatch.test.ts +++ b/tests/bot/handlers/prompt-queue-dispatch.test.ts @@ -8,6 +8,12 @@ const getPromptQueueModeMock = vi.hoisted(() => vi.fn()); const isForegroundBusyMock = vi.hoisted(() => vi.fn()); const getKeyboardMock = vi.hoisted(() => vi.fn()); const sendBotTextMock = vi.hoisted(() => vi.fn()); +const getCurrentSettingsSessionMock = vi.hoisted(() => vi.fn()); +const handOverPreparedPromptMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../src/bot/handlers/prompt-handover.js", () => ({ + handOverPreparedPrompt: handOverPreparedPromptMock, +})); vi.mock("../../../src/bot/handlers/prompt.js", async (importOriginal) => ({ ...(await importOriginal()), @@ -16,6 +22,7 @@ vi.mock("../../../src/bot/handlers/prompt.js", async (importOriginal) => ({ vi.mock("../../../src/app/stores/settings-store.js", () => ({ getPromptQueueMode: getPromptQueueModeMock, + getCurrentSession: getCurrentSettingsSessionMock, })); vi.mock("../../../src/app/services/run-control-service.js", () => ({ @@ -27,12 +34,15 @@ vi.mock("../../../src/bot/messages/telegram-text.js", () => ({ })); import { MAX_QUEUED_PROMPTS, promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { promptHandover } from "../../../src/app/managers/prompt-handover-manager.js"; import { __resetPromptQueueDispatchForTests, dispatchNextQueuedPrompt, initializePromptQueueDispatch, shouldSuggestPromptQueue as shouldSuggestPromptQueueInput, + takeArrivalTicket, tryEnqueuePrompt as tryEnqueueInput, + tryEnqueuePromptIfBusy, } from "../../../src/bot/handlers/prompt-queue-dispatch.js"; import type { AppContainer } from "../../../src/app/bootstrap/app-container.js"; import { createTestAppContainer } from "../../helpers/app-container.js"; @@ -66,7 +76,10 @@ function shouldSuggestPromptQueue(text: string): boolean { describe("bot/handlers/prompt-queue-dispatch", () => { beforeEach(() => { promptQueue.__resetForTests(); + promptHandover.__resetForTests(); __resetPromptQueueDispatchForTests(); + getCurrentSettingsSessionMock.mockReset().mockReturnValue(undefined); + handOverPreparedPromptMock.mockReset().mockResolvedValue(true); replyMock = vi.fn().mockResolvedValue(undefined); processUserPromptMock.mockReset().mockResolvedValue(true); getPromptQueueModeMock.mockReset().mockReturnValue("queue"); @@ -76,6 +89,80 @@ describe("bot/handlers/prompt-queue-dispatch", () => { initializePromptQueueDispatch(DEPS); }); + describe("prompts handed over at /detach", () => { + const SESSION = { id: "ses-1", title: "Session", directory: "D:/repo" }; + const SELECTION = { agent: "build", providerID: "p", modelID: "m" }; + + function handOverOnePrompt(): void { + promptHandover.recordDetach(SESSION, SELECTION); + promptHandover.addPrompt("ses-1", { ...createIncomingPrompt("handed over"), selection: SELECTION }); + } + + it("queues a message sent after re-attaching behind them while the session is idle", async () => { + handOverOnePrompt(); + getCurrentSettingsSessionMock.mockReturnValue(SESSION); + + await expect( + tryEnqueuePromptIfBusy(makeContext(), createIncomingPrompt("after re-attach")), + ).resolves.toBe(true); + await dispatchNextQueuedPrompt(); + + expect(promptQueue.list().map((item) => item.text)).toEqual(["after re-attach"]); + expect(processUserPromptMock).not.toHaveBeenCalled(); + }); + + it("leaves messages of another session alone", async () => { + handOverOnePrompt(); + getCurrentSettingsSessionMock.mockReturnValue({ ...SESSION, id: "ses-2" }); + + await expect( + tryEnqueuePromptIfBusy(makeContext(), createIncomingPrompt("elsewhere")), + ).resolves.toBe(false); + }); + + it("takes an arrival ticket only while the message would wait", () => { + getCurrentSettingsSessionMock.mockReturnValue(SESSION); + expect(takeArrivalTicket()).toBeUndefined(); + + isForegroundBusyMock.mockReturnValue(true); + expect(takeArrivalTicket()).toEqual({ sessionId: "ses-1", detachSeq: 0 }); + + getPromptQueueModeMock.mockReturnValue("off"); + expect(takeArrivalTicket()).toBeUndefined(); + }); + + it("hands a message prepared across /detach over to its session instead of queueing it", async () => { + getCurrentSettingsSessionMock.mockReturnValue(SESSION); + isForegroundBusyMock.mockReturnValue(true); + const ticket = takeArrivalTicket(); + promptHandover.recordDetach(SESSION, SELECTION); + getCurrentSettingsSessionMock.mockReturnValue(undefined); + isForegroundBusyMock.mockReturnValue(false); + + const ctx = makeContext(); + const input = createIncomingPrompt("transcribed"); + + await expect(tryEnqueuePromptIfBusy(ctx, input, ticket)).resolves.toBe(true); + + expect(handOverPreparedPromptMock).toHaveBeenCalledWith(ctx, ticket, input); + expect(promptQueue.size()).toBe(0); + expect(replyMock).not.toHaveBeenCalled(); + }); + + it("keeps the normal path for a ticket whose session was not detached since", async () => { + getCurrentSettingsSessionMock.mockReturnValue(SESSION); + isForegroundBusyMock.mockReturnValue(true); + const ticket = takeArrivalTicket(); + + await expect( + tryEnqueuePromptIfBusy(makeContext(), createIncomingPrompt("still busy"), ticket), + ).resolves.toBe(true); + + expect(handOverPreparedPromptMock).not.toHaveBeenCalled(); + expect(promptQueue.list().map((item) => item.text)).toEqual(["still busy"]); + }); + }); + describe("tryEnqueuePrompt", () => { it("does nothing when the setting is disabled", async () => { getPromptQueueModeMock.mockReturnValue("off"); diff --git a/tests/bot/handlers/voice.test.ts b/tests/bot/handlers/voice.test.ts index 744726bc5..bd1694012 100644 --- a/tests/bot/handlers/voice.test.ts +++ b/tests/bot/handlers/voice.test.ts @@ -15,6 +15,7 @@ const mocked = vi.hoisted(() => ({ vi.mock("../../../src/app/stores/settings-store.js", () => ({ getTtsMode: mocked.getTtsModeMock, getPromptQueueMode: mocked.getPromptQueueModeMock, + getCurrentSession: vi.fn(() => undefined), })); vi.mock("../../../src/utils/logger.js", () => ({ diff --git a/tests/bot/rich-message-routing.test.ts b/tests/bot/rich-message-routing.test.ts index 13b50d6d9..6b02d9bd5 100644 --- a/tests/bot/rich-message-routing.test.ts +++ b/tests/bot/rich-message-routing.test.ts @@ -4,6 +4,8 @@ import type { RichBlock, Update, UserFromGetMe } from "grammy/types"; import { config } from "../../src/config.js"; import { t } from "../../src/i18n/index.js"; import { promptQueue } from "../../src/app/managers/prompt-queue-manager.js"; +import { promptHandover } from "../../src/app/managers/prompt-handover-manager.js"; +import { createIncomingPrompt } from "../../src/app/types/prompt.js"; import type { AppContainer } from "../../src/app/bootstrap/app-container.js"; const mocked = vi.hoisted(() => ({ @@ -15,6 +17,7 @@ const mocked = vi.hoisted(() => ({ handleCatalogTextArguments: vi.fn(), statusCommand: vi.fn(), getPromptQueueMode: vi.fn(), + getCurrentSession: vi.fn(), })); vi.mock("../../src/bot/handlers/message-merger.js", () => ({ @@ -53,6 +56,7 @@ vi.mock("../../src/app/stores/settings-store.js", async (importOriginal) => { return { ...actual, getPromptQueueMode: mocked.getPromptQueueMode, + getCurrentSession: mocked.getCurrentSession, }; }); @@ -143,7 +147,24 @@ describe("bot/rich-message-routing", () => { mocked.handleCatalogTextArguments.mockReset().mockResolvedValue(false); mocked.statusCommand.mockReset().mockResolvedValue(undefined); mocked.getPromptQueueMode.mockReset().mockReturnValue("off"); + mocked.getCurrentSession.mockReset().mockReturnValue(undefined); promptQueue.__resetForTests(); + promptHandover.__resetForTests(); + }); + + it("queues a prompt behind messages handed over at /detach after re-attaching", async () => { + const session = { id: "ses-1", title: "Session", directory: "D:/repo" }; + const selection = { agent: "build", providerID: "p", modelID: "m" }; + mocked.getPromptQueueMode.mockReturnValue("queue"); + mocked.getCurrentSession.mockReturnValue(session); + promptHandover.recordDetach(session, selection); + promptHandover.addPrompt("ses-1", { ...createIncomingPrompt("handed over"), selection }); + const { bot } = createRoutingBot(); + + await bot.handleUpdate(richUpdate([{ type: "paragraph", text: "Sent after re-attaching" }])); + + expect(mocked.queuePromptForMerging).not.toHaveBeenCalled(); + expect(promptQueue.list().map((item) => item.text)).toEqual(["Sent after re-attaching"]); }); it("routes a converted rich sentence as an ordinary prompt", async () => { diff --git a/tests/helpers/reset-singleton-state.ts b/tests/helpers/reset-singleton-state.ts index d34c091f4..f87b38ca9 100644 --- a/tests/helpers/reset-singleton-state.ts +++ b/tests/helpers/reset-singleton-state.ts @@ -5,11 +5,13 @@ export async function resetSingletonState(): Promise { { __resetMessageMergerForTests }, { promptQueue }, { __resetPromptQueueDispatchForTests }, + { promptHandover }, { promptAttachment }, { __resetStreamThrottleForTests }, { telegramOutageNoticeService }, { __resetServerHealthStateForTests }, modelSelectionModule, + promptHandoverDeliveryModule, readyRefreshModule, configReloadModule, loggerModule, @@ -19,11 +21,13 @@ export async function resetSingletonState(): Promise { import("../../src/bot/handlers/message-merger.js"), import("../../src/app/managers/prompt-queue-manager.js"), import("../../src/bot/handlers/prompt-queue-dispatch.js"), + import("../../src/app/managers/prompt-handover-manager.js"), import("../../src/app/managers/prompt-attachment-manager.js"), import("../../src/bot/streaming/stream-throttle.js"), import("../../src/app/services/telegram-outage-notice-service.js"), import("../../src/opencode/server-health.js"), import("../../src/app/services/model-selection-service.js"), + import("../../src/bot/handlers/prompt-handover.js"), import("../../src/opencode/ready-refresh.js"), import("../../src/app/services/config-reload-service.js"), import("../../src/utils/logger.js"), @@ -34,6 +38,7 @@ export async function resetSingletonState(): Promise { __resetMessageMergerForTests(); promptQueue.__resetForTests(); __resetPromptQueueDispatchForTests(); + promptHandover.__resetForTests(); promptAttachment.__resetForTests(); telegramOutageNoticeService.__resetForTests(); __resetSessionDirectoryCacheForTests(); @@ -47,6 +52,13 @@ export async function resetSingletonState(): Promise { modelSelectionModule.__resetModelCatalogCacheForTests(); } + if ( + "__resetPromptHandoverForTests" in promptHandoverDeliveryModule && + typeof promptHandoverDeliveryModule.__resetPromptHandoverForTests === "function" + ) { + promptHandoverDeliveryModule.__resetPromptHandoverForTests(); + } + if ( "__resetReadyRefreshForTests" in readyRefreshModule && typeof readyRefreshModule.__resetReadyRefreshForTests === "function" From 64f513dd0bd4ccfcb2f6d686ae2d967323ca332d Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Wed, 30 Sep 2026 21:59:28 +0300 Subject: [PATCH 5/6] chore: npm audit fix --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f6135554..c1fd6de77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -674,9 +674,9 @@ "license": "MIT" }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -1935,9 +1935,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3178,9 +3178,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" From 3c172426b7ad17e034882dc0e37f50af8ea130e7 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Wed, 30 Sep 2026 22:06:51 +0300 Subject: [PATCH 6/6] chore(release): v0.26.2 --- docs/release-notes/v0.26.2.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 docs/release-notes/v0.26.2.md diff --git a/docs/release-notes/v0.26.2.md b/docs/release-notes/v0.26.2.md new file mode 100644 index 000000000..e13cc4c75 --- /dev/null +++ b/docs/release-notes/v0.26.2.md @@ -0,0 +1,11 @@ +## Fixes +- **Subagent questions reach the chat** — a question asked by a subagent now appears as an ordinary poll you can answer in Telegram, instead of never showing up while the run silently waits for it. +- **Permission prompts and polls keep their outcome** — a finished permission prompt is no longer deleted and a poll settled elsewhere no longer keeps live buttons: both stay in the chat with a closing line such as `✅ Allowed once`, `☑️ Answered outside Telegram` or `⏹ Not answered`. +- **Undelivered permission replies can be retried** — when a permission reply does not reach OpenCode, the prompt now keeps its buttons with a "tap again" warning instead of disappearing behind an error while the request stays stuck. +- **Poll Cancel reaches the agent** — tapping `❌ Cancel` on an agent's poll now dismisses the question in OpenCode, so the turn ends and queued messages run instead of the agent waiting for an answer indefinitely. +- **`apply_patch` shows a summary, not the patch** — file changes made with `apply_patch`, as GPT models do, now show one `🩹 apply_patch (+N -M)` line and diff document for every changed file, instead of the whole patch on OpenCode V2 or only the first file on V1. +- **`edit` on OpenCode V2** — file edits now show their `(+N -M)` counts and diff document as on V1, and the compact card's `changed files` counts files changed by `edit` and `apply_patch` instead of showing 0. +- **OpenCode V2 restart during a run** — the interrupted turn now ends as after `/abort` and the run OpenCode resumes on its own shows as a separate turn, with no stray `🛠️ unknown` line, background-session notice or duplicate permission prompts. +- **Messages kept on `/detach`** — a message sent to a running session right before `/detach` is no longer lost and reaches that session once its turn picks it up or ends, on both V1 and V2. + +Full changelog: https://github.com/grinev/opencode-telegram-bot/compare/v0.26.1...v0.26.2 diff --git a/package-lock.json b/package-lock.json index c1fd6de77..8e275eaa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@grinev/opencode-telegram-bot", - "version": "0.26.1", + "version": "0.26.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@grinev/opencode-telegram-bot", - "version": "0.26.1", + "version": "0.26.2", "license": "MIT", "dependencies": { "@google-cloud/text-to-speech": "^6.4.0", diff --git a/package.json b/package.json index 0c468d90b..544d808d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@grinev/opencode-telegram-bot", - "version": "0.26.1", + "version": "0.26.2", "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.", "type": "module", "main": "./dist/index.js",