diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a0d1a..e187a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ This project follows Semantic Versioning. ## Unreleased +## [0.11.3] - 2026-06-06 + +### Fixed + +- Added timeout-specific Telegram failure replies and safe timeout classifications for nested OpenCode prompt errors. (#57) +- Increased the default gateway prompt completion timeout to 35 minutes and configured the project OpenCode provider timeout to 30 minutes for long research prompts. (#57) + ## [0.11.2] - 2026-06-06 ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0e34388..ae1d9a1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -70,7 +70,7 @@ Login startup is user-level and project-folder scoped. `opencode-remote startup On startup, the gateway checks `opencode.apiUrl`. If it is reachable, the gateway uses that server. If it is not reachable and `opencode.autoStart=true`, the gateway starts `opencode.command serve` and waits for it to become reachable before starting Telegram polling. For local `localhost` and `127.0.0.1` API URLs with a port, auto-start passes that port as `--port` so newer OpenCode CLI versions do not bind a random port. The gateway exits with an error if OpenCode is still unreachable after about 60 seconds. Before polling starts, the gateway refreshes Telegram's slash-command menu for default and private chats. -OpenCode prompt requests use async prompt admission when available, then wait for the matching assistant completion event. `opencode.promptTimeoutMs`, defaulting to 30 minutes, is the completion deadline. The gateway controller serializes prompt sends through the selected active session, and the OpenCode client accepts child subagent session tool, permission, and safe session-error events while that active prompt is running. +OpenCode prompt requests use async prompt admission when available, then wait for the matching assistant completion event. `opencode.promptTimeoutMs`, defaulting to 35 minutes, is the gateway completion deadline. OpenCode provider/model request timeouts are configured separately in OpenCode and may fail long runs earlier; for long research prompts, use a 30-minute provider timeout and keep the gateway deadline above it. The gateway controller serializes prompt sends through the selected active session, and the OpenCode client accepts child subagent session tool, permission, and safe session-error events while that active prompt is running. If the gateway started the OpenCode child process, it stops that child during shutdown. It does not stop an OpenCode server that was already running. diff --git a/FEATURES.md b/FEATURES.md index 5cc99f4..268749b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -11,7 +11,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - Optional local OpenCode startup with `opencode.autoStart=true`. - OpenCode session creation, listing, switching, prompt sending, and stop requests. - OpenCode permission requests surfaced in Telegram with inline approve/deny buttons. -- Long-running OpenCode prompts use a configurable `opencode.promptTimeoutMs` timeout, defaulting to 30 minutes. +- Long-running OpenCode prompts use a configurable `opencode.promptTimeoutMs` timeout, defaulting to 35 minutes. - Editable Telegram activity messages showing OpenCode tool and skill usage during prompts. - Telegram-safe response chunking for long assistant replies. - Published npm CLI package with the `opencode-remote` bin built to `dist/` with `tsdown`. diff --git a/README.md b/README.md index 655aad5..2baaea0 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ The config file is JSON: "opencode": { "apiUrl": "http://localhost:4096", "autoStart": true, - "promptTimeoutMs": 1800000 + "promptTimeoutMs": 2100000 }, "voice": { "enabled": false, @@ -145,7 +145,20 @@ The config file is JSON: `opencode.apiUrl` controls the OpenCode server URL. It defaults to `http://localhost:4096`. When `opencode.autoStart=true` and this URL points to `localhost` or `127.0.0.1` with a port, the gateway starts `opencode serve --port ` so it waits on the same URL it configured. -`opencode.promptTimeoutMs` controls how long the gateway waits for OpenCode to complete a prompt. It defaults to `1800000` milliseconds, or 30 minutes, so slower provider runs and complex subagent workflows have time to finish. +`opencode.promptTimeoutMs` controls how long the gateway waits for OpenCode to complete a prompt. It defaults to `2100000` milliseconds, or 35 minutes, so slower provider runs and complex subagent workflows have time to finish. This is separate from OpenCode's provider timeout. If deep research or slow model calls fail after about 5 minutes, increase the provider timeout in your OpenCode config to 30 minutes and keep the gateway wait above it, for example: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "timeout": 1800000 + } + } + } +} +``` `progressVerbosity` controls the startup default for the prompt activity message in private chats. Supported values are `off`, `new`, `all`, and `verbose`. The default is `verbose`. The Telegram `/progress` command can change this at runtime in private chats. Group chats always suppress the `Activity` message. @@ -162,7 +175,7 @@ opencode-remote config set voice.enabled true opencode-remote config set voice.groqApiKey gsk_... opencode-remote config set voice.mode all -g opencode-remote config set voice.captions true -opencode-remote config set opencode.promptTimeoutMs 1800000 +opencode-remote config set opencode.promptTimeoutMs 2100000 ``` Clear generated voice files from the app-data cache: diff --git a/opencode.jsonc b/opencode.jsonc index 84fc698..7bd2173 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -1,5 +1,12 @@ { "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "timeout": 1800000 + } + } + }, "skills": { "paths": ["./skills/development"] } diff --git a/package.json b/package.json index 3a52859..7599c21 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@crankshift/opencode-remote", "description": "A messenger-based chat interface for OpenCode, starting with Telegram.", - "version": "0.11.2", + "version": "0.11.3", "license": "MIT", "repository": { "type": "git", diff --git a/src/adapters/telegram/bot.js b/src/adapters/telegram/bot.js index ed8924a..44653c2 100644 --- a/src/adapters/telegram/bot.js +++ b/src/adapters/telegram/bot.js @@ -46,6 +46,8 @@ import { } from "./voice.js" const SAFE_ERROR_REPLY = "OpenCode Remote failed while handling that request." +const TIMEOUT_ERROR_REPLY = + "OpenCode timed out while handling that request. For long research prompts, check the OpenCode provider timeout and opencode.promptTimeoutMs, then try again." export async function registerTelegramBotCommands(bot, logger) { for (const { commands, scope } of [ @@ -156,10 +158,14 @@ export function createTelegramBot({ if (typeof bot.catch === "function") { bot.catch(async (botError) => { const logError = logger.error ?? logger.warn - logError.call(logger, { error: botError.error }, "Telegram update handling failed") + logError.call( + logger, + safeTelegramErrorLogContext(botError.error), + "Telegram update handling failed", + ) try { if (botError.ctx?.reply) { - await replyAndRemember(botError.ctx, SAFE_ERROR_REPLY, botMessageMemory) + await replyAndRemember(botError.ctx, telegramErrorReply(botError.error), botMessageMemory) } } catch (replyError) { logger.warn({ error: replyError }, "Could not send Telegram error reply") @@ -843,9 +849,13 @@ export function createTelegramBot({ await handlePhotoMessages(ctx, messages) } catch (error) { const logError = logger.error ?? logger.warn - logError.call(logger, { error }, "Telegram media group handling failed") + logError.call( + logger, + safeTelegramErrorLogContext(error), + "Telegram media group handling failed", + ) try { - await replyAndRemember(ctx, SAFE_ERROR_REPLY, botMessageMemory) + await replyAndRemember(ctx, telegramErrorReply(error), botMessageMemory) } catch (replyError) { logger.warn({ error: replyError }, "Could not send Telegram error reply") } @@ -1763,6 +1773,67 @@ function voiceCaptionForText(text, voiceService) { return text.length <= TELEGRAM_VOICE_CAPTION_LIMIT ? text : null } +function telegramErrorReply(error) { + return hasTimeoutError(error) ? TIMEOUT_ERROR_REPLY : SAFE_ERROR_REPLY +} + +function safeTelegramErrorLogContext(error) { + const context = { + errorName: safeErrorName(error), + errorKind: classifyTelegramError(error), + } + const cause = error?.cause + if (cause) { + context.causeName = safeErrorName(cause) + context.causeKind = classifyTelegramError(cause) + } + return context +} + +function hasTimeoutError(error) { + const seen = new Set() + let current = error + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current) + if (classifyTelegramError(current) === "timeout") { + return true + } + current = current.cause + } + return false +} + +function classifyTelegramError(error) { + if (safeErrorName(error) === "GatewayOpenCodeError") { + return "opencode" + } + const text = [error?.name, error?.code, error?.message] + .filter((value) => typeof value === "string") + .join(" ") + .toLocaleLowerCase("en-US") + + if (text.includes("timeout") || text.includes("timed out")) { + return "timeout" + } + if (text.includes("opencode")) { + return "opencode" + } + return "unknown" +} + +function safeErrorName(error) { + return firstNonEmptyString(error?.name, error?.type, error?.code) ?? "UnknownError" +} + +function firstNonEmptyString(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value.trim() + } + } + return undefined +} + function createTelegramProgressRenderer({ ctx, logger, verbosity, editThrottleMs }) { const state = createProgressTextState({ verbosity }) const enabled = state.verbosity !== "off" diff --git a/src/bin/program.js b/src/bin/program.js index 9f5b2ad..010d736 100644 --- a/src/bin/program.js +++ b/src/bin/program.js @@ -39,7 +39,7 @@ export function createGatewayProgram({ const program = new Command() const afterCreate = createStartupAfterConfigHook({ enableGatewayStartup, output }) - program.name("opencode-remote").description("OpenCode messaging gateway").version("0.11.2") + program.name("opencode-remote").description("OpenCode messaging gateway").version("0.11.3") program .command("setup") diff --git a/src/config/loadConfig.js b/src/config/loadConfig.js index 8acf4d7..5083a28 100644 --- a/src/config/loadConfig.js +++ b/src/config/loadConfig.js @@ -11,7 +11,7 @@ export const SETTINGS_FILE_NAME = "settings.json" const progressVerbositySchema = z.enum(["off", "new", "all", "verbose"]) const voiceModeSchema = z.enum(["off", "on", "all"]) const logLevelSchema = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) -const DEFAULT_OPENCODE_PROMPT_TIMEOUT_MS = 1_800_000 +const DEFAULT_OPENCODE_PROMPT_TIMEOUT_MS = 2_100_000 const positiveTelegramIdSchema = z.coerce .number() .int() diff --git a/tests/adapters/telegramBot.test.js b/tests/adapters/telegramBot.test.js index a1be15a..0850bd5 100644 --- a/tests/adapters/telegramBot.test.js +++ b/tests/adapters/telegramBot.test.js @@ -1399,6 +1399,38 @@ describe("createTelegramBot", () => { expect(reply).toHaveBeenCalledWith("OpenCode Remote failed while handling that request.") }) + test("error handler classifies timeout failures without raw provider details", async () => { + const logger = { warn: vi.fn(), error: vi.fn() } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram(), + controller: {}, + logger, + botFactory: FakeBot, + }) + const timeoutCause = new Error("OpenCode assistant message failed: TimeoutError") + const error = new Error("Could not send prompt to OpenCode") + error.name = "GatewayOpenCodeError" + error.cause = timeoutCause + const reply = vi.fn(async () => undefined) + + await bot.errorHandler({ ctx: { reply }, error }) + + expect(logger.error).toHaveBeenCalledWith( + { + errorName: "GatewayOpenCodeError", + errorKind: "opencode", + causeName: "Error", + causeKind: "timeout", + }, + "Telegram update handling failed", + ) + expect(JSON.stringify(logger.error.mock.calls)).not.toContain("TimeoutError") + expect(reply).toHaveBeenCalledWith( + "OpenCode timed out while handling that request. For long research prompts, check the OpenCode provider timeout and opencode.promptTimeoutMs, then try again.", + ) + }) + test("sessions command truncates labels and uses bounded callback data", async () => { const longTitle = "a".repeat(120) const longId = "ses_".padEnd(120, "x") diff --git a/tests/config/loadConfig.test.js b/tests/config/loadConfig.test.js index fdc95d2..ae57f55 100644 --- a/tests/config/loadConfig.test.js +++ b/tests/config/loadConfig.test.js @@ -44,7 +44,7 @@ describe("loadConfig", () => { apiUrl: "http://localhost:4096", command: "opencode", autoStart: true, - promptTimeoutMs: 1_800_000, + promptTimeoutMs: 2_100_000, workdir: cwd, }, progressVerbosity: "verbose",