diff --git a/CHANGELOG.md b/CHANGELOG.md index 1775980..40797aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ This project follows Semantic Versioning. ## Unreleased +## [0.11.0] - 2026-06-06 + +### Added + +- Added configurable `opencode.promptTimeoutMs` for long-running OpenCode prompt requests, defaulting to 30 minutes. (#51) +- Added active-prompt handling for child subagent session tool, permission, and safe session-error events. (#51) + +### Changed + +- Changed gateway prompt sends to serialize through the selected active session so complex OpenCode runs do not overlap accidentally. (#51) +- Removed generated-media guidance that blocked delegated generation work. (#51) + ## [0.10.5] - 2026-05-31 ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 741fb26..074a55e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -70,6 +70,8 @@ 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 `opencode.promptTimeoutMs`, defaulting to 30 minutes, as the SDK request timeout. 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. ## Build diff --git a/FEATURES.md b/FEATURES.md index 8246d4e..5cc99f4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -11,6 +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. - 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`. @@ -43,7 +44,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - Telegram text, photo, album, voice, and sticker prompts include safe author context, including forwarded original authors and messages sent by anonymous admins or on behalf of chats/channels when Telegram provides usable names. - The bot shows Telegram typing activity while a prompt is running. - In private chats, the bot can show an editable `Activity` message with OpenCode tools and skills used during a prompt. Group chats always suppress this activity message. -- OpenCode permission requests are sent as text with `Allow once`, `Always allow`, and `Deny` buttons, even when voice replies are enabled. +- OpenCode permission requests, including child subagent session permission requests, are sent as text with `Allow once`, `Always allow`, and `Deny` buttons, even when voice replies are enabled. - Incoming text prompts get a temporary eye reaction while processing. - OpenCode can request one Telegram emoji reaction by returning a hidden `[telegram_reaction: ...]` marker, which is removed before the user sees the reply. - When saved sticker packs are available, eligible hidden reaction markers may be answered with a saved sticker reply instead of an emoji reaction. @@ -57,6 +58,8 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - If no active session is selected, the gateway creates one before sending a prompt. - New sessions receive hidden gateway context with no assistant reply so OpenCode understands messenger, voice, activity, and permission behavior without showing a setup response to the user. +- Prompt sends are serialized through the active session so complex OpenCode runs are not overlapped accidentally. +- Child subagent session tool and permission events are associated with the active gateway prompt while it is running. - Selected session state is stored in `opencode-remote.db` under the platform app-data directory and scoped by project identity. - Stopping a task uses OpenCode's session abort API for the active session. - Session state is messenger-neutral in the gateway core, so future adapters can reuse it. diff --git a/README.md b/README.md index 9a3925d..493b22f 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ The config file is JSON: "allowedUserIds": [123456789], "allowedChatIds": [-1001234567890] }, + "opencode": { + "apiUrl": "http://localhost:4096", + "autoStart": true, + "promptTimeoutMs": 1800000 + }, "voice": { "enabled": false, "mode": "on", @@ -140,6 +145,8 @@ 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 an OpenCode prompt request before the SDK times out. It defaults to `1800000` milliseconds, or 30 minutes, so slower provider runs and complex subagent workflows have time to finish. + `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. Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. Custom trigger phrases are configured per group from this DM menu; they are plain text, case-insensitive, and match as bounded words or phrases anywhere in text, captions, and voice transcripts. @@ -155,6 +162,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 ``` Clear generated voice files from the app-data cache: @@ -192,7 +200,7 @@ Telegram text, photo, album, voice, and sticker prompts include safe author cont When a new OpenCode session starts, OpenCode Remote sends hidden gateway context with no assistant reply. This helps the agent understand that voice input may arrive as transcripts and that final text can be delivered as voice notes when voice mode is enabled. -When OpenCode requests permission during a prompt, the bot sends a text message with `Allow once`, `Always allow`, and `Deny` buttons. Permission prompts are always text, including when `/voice on` or `/voice all` would make normal assistant replies voice-only. +When OpenCode requests permission during a prompt, including permission requests from child subagent sessions, the bot sends a text message with `Allow once`, `Always allow`, and `Deny` buttons. Permission prompts are always text, including when `/voice on` or `/voice all` would make normal assistant replies voice-only. Telegram photo albums are handled as one OpenCode prompt when Telegram provides a shared `media_group_id`. The album caption becomes the prompt text. Separate text messages sent after an album are treated as separate prompts. diff --git a/docs/superpowers/plans/2026-05-30-bundled-meme-agent-runtime-assets.md b/docs/superpowers/plans/2026-05-30-bundled-meme-agent-runtime-assets.md index d567c67..88c1de2 100644 --- a/docs/superpowers/plans/2026-05-30-bundled-meme-agent-runtime-assets.md +++ b/docs/superpowers/plans/2026-05-30-bundled-meme-agent-runtime-assets.md @@ -33,7 +33,7 @@ - [x] Install bundled skills under a project-local configured `skills.paths` directory when available, otherwise under `.opencode/skills/opencode-remote-bundled//SKILL.md`. - [x] Remove legacy `.opencode/agent/opencode-remote-meme.md` during install if present. - [x] Keep bundled runtime assets project-local and avoid global OpenCode config writes. -- [x] Make the bundled meme skill direct and bounded, with no task/subagent delegation. +- [x] Make the bundled meme skill bounded by the generated-media contract. - [x] Add local meme renderer and hidden `opencode-remote meme render --spec` CLI. - [x] Restrict renderer remote templates to HTTPS Imgflip template URLs and forbid public meme creation endpoints. - [x] Add safe generated-media marker parsing and Telegram delivery. diff --git a/docs/superpowers/plans/2026-06-06-opencode-subagent-orchestration.md b/docs/superpowers/plans/2026-06-06-opencode-subagent-orchestration.md new file mode 100644 index 0000000..5721aa3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-opencode-subagent-orchestration.md @@ -0,0 +1,188 @@ +# OpenCode Subagent Orchestration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make OpenCode Remote handle long-running, tool-heavy, subagent-driven prompts without dropping child-session permissions or failing at the default SDK request timeout. + +**Architecture:** Keep Telegram rendering in the adapter and OpenCode SDK/event shapes in `src/core/opencode/client.js`. Serialize prompt sends through the gateway controller so one active OpenCode run owns the event stream at a time, then let that run accept child-session tool and permission events safely. Add a bounded prompt timeout config so slow providers like Ollama Cloud can finish instead of failing around five minutes. + +**Tech Stack:** Node.js ESM, `@opencode-ai/sdk`, grammY, Zod config validation, Vitest. + +--- + +### Task 1: Configurable OpenCode Prompt Timeout + +**Files:** +- Modify: `src/config/loadConfig.js` +- Modify: `src/runtime/bootstrap.js` +- Modify: `src/core/opencode/client.js` +- Test: `tests/config/loadConfig.test.js` +- Test: `tests/runtime/bootstrap.test.js` +- Test: `tests/core/opencodeClient.test.js` + +- [ ] **Step 1: Write failing config tests** + +Add tests showing `opencode.promptTimeoutMs` defaults to `1800000` and accepts a custom positive integer. + +- [ ] **Step 2: Run config tests and verify they fail** + +Run: `pnpm exec vitest run tests/config/loadConfig.test.js` + +Expected: FAIL because `promptTimeoutMs` is missing from normalized config. + +- [ ] **Step 3: Implement config parsing** + +Add `promptTimeoutMs: z.number().int().positive().default(1_800_000)` under `opencode`, and include it in the normalized return object. + +- [ ] **Step 4: Write failing SDK/bootstrap tests** + +Add a client factory test proving `createOpenCodeClient({ promptTimeoutMs })` passes `timeout` to `createOpencodeClient`, and a bootstrap test proving runtime passes `resolvedConfig.opencode.promptTimeoutMs` into the OpenCode client factory. + +- [ ] **Step 5: Run targeted tests and verify they fail** + +Run: `pnpm exec vitest run tests/core/opencodeClient.test.js tests/runtime/bootstrap.test.js` + +Expected: FAIL because timeout is not passed through yet. + +- [ ] **Step 6: Implement timeout pass-through** + +Add `promptTimeoutMs` and injectable `sdkFactory` to `createOpenCodeClient`, pass `timeout: promptTimeoutMs` to `createOpencodeClient`, and pass the config value from `runGateway`. + +- [ ] **Step 7: Run targeted tests and verify they pass** + +Run: `pnpm exec vitest run tests/config/loadConfig.test.js tests/core/opencodeClient.test.js tests/runtime/bootstrap.test.js` + +Expected: PASS. + +### Task 2: Serialize Gateway Prompt Sends + +**Files:** +- Modify: `src/core/gateway/controller.js` +- Test: `tests/core/controller.test.js` + +- [ ] **Step 1: Write failing serialization test** + +Add a test that starts two `controller.sendPrompt()` calls on the same active session, blocks the first inside the OpenCode mock, and asserts the second `opencode.sendPrompt` call does not start until the first resolves. + +- [ ] **Step 2: Run controller test and verify it fails** + +Run: `pnpm exec vitest run tests/core/controller.test.js` + +Expected: FAIL because current controller sends both prompts concurrently. + +- [ ] **Step 3: Implement prompt queue** + +Add a controller-local promise queue wrapping `sendPrompt`. Resolve active session and call OpenCode inside the queued action. Ensure rejected prompt calls do not poison the queue by storing `promptQueue = run.catch(() => undefined)`. + +- [ ] **Step 4: Run controller test and verify it passes** + +Run: `pnpm exec vitest run tests/core/controller.test.js` + +Expected: PASS. + +### Task 3: Accept Child Session Tool And Permission Events During Active Runs + +**Files:** +- Modify: `src/core/opencode/client.js` +- Modify: `src/core/gateway/controller.js` +- Modify: `src/core/formatting/progressText.js` +- Modify: `src/adapters/telegram/bot.js` +- Test: `tests/core/opencodeClient.test.js` +- Test: `tests/core/progressText.test.js` + +- [ ] **Step 1: Write failing child permission test** + +Add an OpenCode client test where `sendPrompt("ses_parent", ..., { onSystemEvent, includeChildSessionEvents: true })` receives a `permission.asked` event with `sessionID: "ses_child"` and asserts `onSystemEvent` receives `sessionId: "ses_child"`. + +- [ ] **Step 2: Write failing child tool progress test** + +Add an OpenCode client test where a `message.part.updated` tool event with `sessionID: "ses_child"` is emitted during a parent prompt and `includeChildSessionEvents: true` forwards it with `parentSessionId: "ses_parent"` and `childSession: true`. + +- [ ] **Step 3: Run OpenCode client tests and verify they fail** + +Run: `pnpm exec vitest run tests/core/opencodeClient.test.js` + +Expected: FAIL because child session IDs are currently filtered out. + +- [ ] **Step 4: Implement child-session event acceptance** + +Thread an `includeChildSessionEvents` option through `startPromptEventStream`. Update progress and permission normalization to accept non-parent session IDs when that option is true. Preserve the actual child `sessionId`, add `parentSessionId` for child events, and add `childSession: true` without exposing raw session IDs in Telegram logs. + +- [ ] **Step 5: Make controller enable child events for gateway prompts** + +When controller passes options to `opencode.sendPrompt`, include `includeChildSessionEvents: true`. Preserve existing callbacks and do not put this option into the OpenCode prompt body. + +- [ ] **Step 6: Update formatting for task/subagent progress** + +Keep existing `task` emoji behavior and ensure child-session progress still renders as a normal activity line without exposing session IDs. + +- [ ] **Step 7: Run targeted tests and verify they pass** + +Run: `pnpm exec vitest run tests/core/opencodeClient.test.js tests/core/controller.test.js tests/core/progressText.test.js` + +Expected: PASS. + +### Task 4: Normalize And Log OpenCode Session Errors Safely + +**Files:** +- Modify: `src/core/opencode/client.js` +- Modify: `src/adapters/telegram/bot.js` +- Test: `tests/core/opencodeClient.test.js` +- Test: `tests/adapters/telegramBot.test.js` + +- [ ] **Step 1: Write failing session error normalization test** + +Add a test where the event stream emits `session.error` with a provider-ish error object and asserts `onSystemEvent` receives `{ type: "session.error", sessionId, errorName, errorKind }` without raw payload details. + +- [ ] **Step 2: Run OpenCode client test and verify it fails** + +Run: `pnpm exec vitest run tests/core/opencodeClient.test.js` + +Expected: FAIL because `session.error` is ignored. + +- [ ] **Step 3: Implement safe session error normalization** + +Extend `normalizeOpenCodeSystemEvent` to return `session.error` events. Keep only safe metadata: type, session presence, child-session flag, error name, and a coarse kind such as `provider_auth`, `aborted`, `timeout`, or `unknown`. + +- [ ] **Step 4: Write Telegram logging test** + +Add a Telegram bot test that calls a prompt whose `onSystemEvent` receives a `session.error`, and assert the logger warning uses safe metadata only. + +- [ ] **Step 5: Implement Telegram safe log handling** + +Update `handleSystemEvent` to log `OpenCode session error reported` with safe fields and no raw session ID, provider body, prompt text, or stack trace. + +- [ ] **Step 6: Run targeted tests and verify they pass** + +Run: `pnpm exec vitest run tests/core/opencodeClient.test.js tests/adapters/telegramBot.test.js` + +Expected: PASS. + +### Task 5: Docs And Verification + +**Files:** +- Modify: `README.md` +- Modify: `FEATURES.md` +- Modify: `DEVELOPMENT.md` + +- [ ] **Step 1: Document prompt timeout config and subagent handling** + +Update public docs to mention `opencode.promptTimeoutMs` and that the gateway handles tool/skill/subagent activity and permission events during long prompts. + +- [ ] **Step 2: Run lint** + +Run: `pnpm run lint` + +Expected: PASS. + +- [ ] **Step 3: Run tests** + +Run: `pnpm test` + +Expected: PASS. + +- [ ] **Step 4: Run package check if time permits** + +Run: `pnpm run check` + +Expected: PASS. diff --git a/package.json b/package.json index 37dba38..6831ea0 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.10.5", + "version": "0.11.0", "license": "MIT", "repository": { "type": "git", diff --git a/src/adapters/telegram/bot.js b/src/adapters/telegram/bot.js index 9729f8e..ed8924a 100644 --- a/src/adapters/telegram/bot.js +++ b/src/adapters/telegram/bot.js @@ -1280,6 +1280,20 @@ export function createTelegramBot({ async function handleSystemEvent(ctx, event) { if (event?.type === "permission.requested") { await sendPermissionRequest(ctx, event) + return + } + if (event?.type === "session.error") { + logger.warn?.(safeSessionErrorLogContext(event), "OpenCode session error reported") + } + } + + function safeSessionErrorLogContext(event) { + return { + childSession: event.childSession === true, + errorKind: event.errorKind ?? "unknown", + errorName: event.errorName ?? "UnknownError", + hasParentSessionId: Boolean(event.parentSessionId), + hasSessionId: Boolean(event.sessionId), } } @@ -2044,7 +2058,6 @@ function createGeneratedMediaInstruction(directory, { memeRenderCommand } = {}) return [ "Generated media delivery capability:", `If you create a local image to send back, write it under this exact directory: ${directory}`, - "Do the image work directly in this OpenCode session. Do not call the task tool, delegate to subagents, or load brainstorming/planning skills for generated media.", "For meme requests, use the meme-generation skill and Imgflip template discovery as the primary path. Do not hand-write custom poster art or raw image scripts instead of using a meme template.", "For meme files, call opencode-remote meme render --spec with an Imgflip template.url or allowed local template.imagePath. Use fallback design or image-generation skills only after template discovery fails.", ...(renderCommand diff --git a/src/bin/program.js b/src/bin/program.js index da8d86d..0246853 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.10.5") + program.name("opencode-remote").description("OpenCode messaging gateway").version("0.11.0") program .command("setup") diff --git a/src/config/loadConfig.js b/src/config/loadConfig.js index 7c9abbc..8acf4d7 100644 --- a/src/config/loadConfig.js +++ b/src/config/loadConfig.js @@ -11,6 +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 positiveTelegramIdSchema = z.coerce .number() .int() @@ -45,6 +46,7 @@ const configSchema = z.object({ apiUrl: z.string().url().default("http://localhost:4096"), command: z.string().min(1).default("opencode"), autoStart: z.boolean().default(true), + promptTimeoutMs: z.number().int().positive().default(DEFAULT_OPENCODE_PROMPT_TIMEOUT_MS), workdir: z.string().min(1).nullable().optional(), }) .default({}), @@ -67,6 +69,7 @@ const defaultOpencodeConfig = { apiUrl: "http://localhost:4096", command: "opencode", autoStart: true, + promptTimeoutMs: DEFAULT_OPENCODE_PROMPT_TIMEOUT_MS, } export class GatewayConfigError extends Error { @@ -143,6 +146,8 @@ export function loadConfigFromObject(rawConfig, { configPath, cwd = process.cwd( apiUrl: parsed.data.opencode.apiUrl ?? defaultOpencodeConfig.apiUrl, command: parsed.data.opencode.command ?? defaultOpencodeConfig.command, autoStart: parsed.data.opencode.autoStart ?? defaultOpencodeConfig.autoStart, + promptTimeoutMs: + parsed.data.opencode.promptTimeoutMs ?? defaultOpencodeConfig.promptTimeoutMs, workdir: parsed.data.opencode.workdir || cwd, }, progressVerbosity: parsed.data.progressVerbosity, diff --git a/src/core/gateway/controller.js b/src/core/gateway/controller.js index d69b4f1..825fbc7 100644 --- a/src/core/gateway/controller.js +++ b/src/core/gateway/controller.js @@ -8,6 +8,7 @@ export function createGatewayController({ logger = null, }) { const configuredProgressVerbosity = normalizeProgressVerbosity(defaultProgressVerbosity) + let promptQueue = Promise.resolve() async function getActiveSessionId() { const settings = await store.read() @@ -52,6 +53,16 @@ export function createGatewayController({ return normalizeProgressVerbosity(settings.progressVerbosity, configuredProgressVerbosity) } + function enqueuePrompt(action) { + const run = promptQueue.then(action, action) + promptQueue = run.catch(() => undefined) + return run + } + + function promptRunOptions(options) { + return { ...(options ?? {}), includeChildSessionEvents: true } + } + return { async status() { const settings = await store.read() @@ -91,19 +102,18 @@ export function createGatewayController({ }, async sendPrompt(prompt, options) { - const sessionId = await getActiveSessionId() - logger?.debug?.( - { - hasOptions: options !== undefined, - hasProgressHandler: typeof options?.onProgress === "function", - promptKind: typeof prompt, - }, - "Sending prompt to OpenCode", - ) - if (options === undefined) { - return opencode.sendPrompt(sessionId, prompt) - } - return opencode.sendPrompt(sessionId, prompt, options) + return enqueuePrompt(async () => { + const sessionId = await getActiveSessionId() + logger?.debug?.( + { + hasOptions: options !== undefined, + hasProgressHandler: typeof options?.onProgress === "function", + promptKind: typeof prompt, + }, + "Sending prompt to OpenCode", + ) + return opencode.sendPrompt(sessionId, prompt, promptRunOptions(options)) + }) }, async respondToPermission(sessionId, permissionId, decision) { diff --git a/src/core/opencode/client.js b/src/core/opencode/client.js index fda1533..171fa8f 100644 --- a/src/core/opencode/client.js +++ b/src/core/opencode/client.js @@ -10,15 +10,20 @@ export class GatewayOpenCodeError extends Error { export function createOpenCodeClient({ apiUrl, + promptTimeoutMs, sdkClient = null, + sdkFactory = createOpencodeClient, fetchImpl = globalThis.fetch, } = {}) { const client = sdkClient ?? - createOpencodeClient({ + sdkFactory({ baseUrl: apiUrl, responseStyle: "data", throwOnError: true, + ...(Number.isInteger(promptTimeoutMs) && promptTimeoutMs > 0 + ? { timeout: promptTimeoutMs } + : {}), }) return { @@ -140,10 +145,11 @@ export function createOpenCodeClient({ } async function startPromptEventStream(client, sessionId, options = {}) { - const { onProgress, onSystemEvent } = options + const { onProgress, onSystemEvent, includeChildSessionEvents = false } = options if (typeof onProgress !== "function" && typeof onSystemEvent !== "function") { return noopProgressStream() } + const eventOptions = { includeChildSessionEvents } let eventStream try { @@ -163,13 +169,13 @@ async function startPromptEventStream(client, sessionId, options = {}) { if (stopped) { break } - const progress = normalizeOpenCodeProgressEvent(event, sessionId) + const progress = normalizeOpenCodeProgressEvent(event, sessionId, eventOptions) if (progress && typeof onProgress === "function") { activeCallback = runEventCallback(onProgress, progress) await activeCallback } - const systemEvent = normalizeOpenCodeSystemEvent(event, sessionId) + const systemEvent = normalizeOpenCodeSystemEvent(event, sessionId, eventOptions) if (systemEvent && typeof onSystemEvent === "function") { activeCallback = runEventCallback(onSystemEvent, systemEvent) await activeCallback @@ -234,18 +240,24 @@ function toPromptBody(prompt) { return { parts: toPromptParts(prompt) } } -function normalizeOpenCodeProgressEvent(event, expectedSessionId) { +function normalizeOpenCodeProgressEvent(event, expectedSessionId, options = {}) { if (event?.type !== "message.part.updated") { return null } - const part = event.properties?.part + const properties = eventProperties(event) + const part = properties.part if (part?.type !== "tool") { return null } - const sessionId = firstString(part.sessionID, part.sessionId, event.properties?.sessionID) - if (!sessionId || sessionId !== expectedSessionId) { + const sessionId = firstString( + part.sessionID, + part.sessionId, + properties.sessionID, + properties.sessionId, + ) + if (!sessionId || !isAcceptedSessionEvent(sessionId, expectedSessionId, options)) { return null } @@ -257,32 +269,71 @@ function normalizeOpenCodeProgressEvent(event, expectedSessionId) { const input = part.input ?? part.state?.input ?? part.state?.args ?? part.args const metadata = part.metadata ?? part.state?.metadata - const progress = { - type: "tool.updated", - sessionId, - messageId: firstString(part.messageID, part.messageId, event.properties?.messageID), - partId: firstString(part.id, part.partID, part.partId), - tool, - title: extractToolTitle(part, input, metadata, tool), - status: firstString(part.state?.status, part.status), - input, - } + const progress = withSessionRelationship( + { + type: "tool.updated", + sessionId, + messageId: firstString( + part.messageID, + part.messageId, + properties.messageID, + properties.messageId, + ), + partId: firstString(part.id, part.partID, part.partId), + tool, + title: extractToolTitle(part, input, metadata, tool), + status: firstString(part.state?.status, part.status), + input, + }, + expectedSessionId, + ) if (metadata !== undefined) { progress.metadata = metadata } return progress } -function normalizeOpenCodeSystemEvent(event, expectedSessionId) { - return normalizeOpenCodePermissionEvent(event, expectedSessionId) +function normalizeOpenCodeSystemEvent(event, expectedSessionId, options = {}) { + return ( + normalizeOpenCodePermissionEvent(event, expectedSessionId, options) ?? + normalizeOpenCodeSessionErrorEvent(event, expectedSessionId, options) + ) } -function normalizeOpenCodePermissionEvent(event, expectedSessionId) { +function normalizeOpenCodeSessionErrorEvent(event, expectedSessionId, options = {}) { + if (event?.type !== "session.error") { + return null + } + + const properties = eventProperties(event) + const sessionId = firstString( + properties.sessionID, + properties.sessionId, + properties.session?.id, + properties.info?.id, + ) + if (sessionId && !isAcceptedSessionEvent(sessionId, expectedSessionId, options)) { + return null + } + + const error = properties.error ?? properties + return withSessionRelationship( + { + type: "session.error", + sessionId: sessionId ?? expectedSessionId, + errorName: safeErrorName(error), + errorKind: classifySessionError(error), + }, + expectedSessionId, + ) +} + +function normalizeOpenCodePermissionEvent(event, expectedSessionId, options = {}) { if (event?.type !== "permission.updated" && event?.type !== "permission.asked") { return null } - const properties = event.properties ?? {} + const properties = eventProperties(event) const permission = properties.permission ?? properties.info ?? properties.request ?? properties const sessionId = firstString( permission.sessionID, @@ -292,7 +343,7 @@ function normalizeOpenCodePermissionEvent(event, expectedSessionId) { permission.session?.id, properties.session?.id, ) - if (sessionId && sessionId !== expectedSessionId) { + if (sessionId && !isAcceptedSessionEvent(sessionId, expectedSessionId, options)) { return null } @@ -311,12 +362,15 @@ function normalizeOpenCodePermissionEvent(event, expectedSessionId) { } const metadata = permission.metadata ?? properties.metadata - const systemEvent = { - type: "permission.requested", - sessionId: sessionId ?? expectedSessionId, - permissionId, - title: firstString(permission.title, properties.title) ?? "OpenCode permission request", - } + const systemEvent = withSessionRelationship( + { + type: "permission.requested", + sessionId: sessionId ?? expectedSessionId, + permissionId, + title: firstString(permission.title, properties.title) ?? "OpenCode permission request", + }, + expectedSessionId, + ) const description = firstString( permission.description, @@ -346,6 +400,43 @@ function normalizeOpenCodePermissionEvent(event, expectedSessionId) { return systemEvent } +function eventProperties(event) { + return event?.properties ?? event?.payload ?? {} +} + +function isAcceptedSessionEvent(sessionId, expectedSessionId, { includeChildSessionEvents } = {}) { + return sessionId === expectedSessionId || includeChildSessionEvents === true +} + +function withSessionRelationship(event, expectedSessionId) { + if (event.sessionId && event.sessionId !== expectedSessionId) { + return { ...event, parentSessionId: expectedSessionId, childSession: true } + } + return event +} + +function safeErrorName(error) { + return firstString(error?.name, error?.type, error?.code) ?? "UnknownError" +} + +function classifySessionError(error) { + const text = [error?.name, error?.type, error?.code, error?.message] + .filter((value) => typeof value === "string") + .join(" ") + .toLocaleLowerCase("en-US") + + if (text.includes("auth") || text.includes("permission") || text.includes("credential")) { + return "provider_auth" + } + if (text.includes("abort") || text.includes("cancel")) { + return "aborted" + } + if (text.includes("timeout") || text.includes("timed out")) { + return "timeout" + } + return "unknown" +} + function formatPermissionPatterns(patterns) { if (!Array.isArray(patterns) || patterns.length === 0) { return undefined diff --git a/src/runtime/bootstrap.js b/src/runtime/bootstrap.js index f79b90c..dda07bc 100644 --- a/src/runtime/bootstrap.js +++ b/src/runtime/bootstrap.js @@ -93,7 +93,10 @@ export async function runGateway({ { opencodeServerStarted: server.started === true }, "OpenCode server ready", ) - const opencode = createOpenCodeClient({ apiUrl: resolvedConfig.opencode.apiUrl }) + const opencode = createOpenCodeClient({ + apiUrl: resolvedConfig.opencode.apiUrl, + promptTimeoutMs: resolvedConfig.opencode.promptTimeoutMs, + }) const project = await resolveProjectIdentity({ directory: resolvedConfig.opencode.workdir }) resolvedLogger.debug?.( { projectScoped: project.id !== "global", vcs: project.vcs ?? null }, diff --git a/tests/adapters/telegramBot.test.js b/tests/adapters/telegramBot.test.js index 2fb795e..a1be15a 100644 --- a/tests/adapters/telegramBot.test.js +++ b/tests/adapters/telegramBot.test.js @@ -3230,6 +3230,55 @@ describe("createTelegramBot", () => { ) }) + test("session error events are logged with safe metadata", async () => { + const controller = { + sendPrompt: vi.fn(async (_prompt, options) => { + await options.onSystemEvent({ + type: "session.error", + sessionId: "ses_child", + parentSessionId: "ses_parent", + childSession: true, + errorName: "ProviderAuthError", + errorKind: "provider_auth", + message: "secret provider payload", + }) + return "answer" + }), + } + const logger = { warn: vi.fn(), error: vi.fn() } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram(), + controller, + logger, + botFactory: FakeBot, + }) + const reply = vi.fn(async (text) => ({ message_id: 20, chat: { id: 456 }, text })) + + await bot.messageHandlers.get("message:text")({ + message: { message_id: 10, text: "hello", chat: { id: 456 } }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply, + }) + + expect(logger.warn).toHaveBeenCalledWith( + { + childSession: true, + errorKind: "provider_auth", + errorName: "ProviderAuthError", + hasParentSessionId: true, + hasSessionId: true, + }, + "OpenCode session error reported", + ) + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain("ses_child") + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain("secret provider payload") + expect(reply).toHaveBeenCalledWith("answer") + }) + test("text prompts tell OpenCode how to request Telegram reactions", async () => { const controller = { sendPrompt: vi.fn(async () => "answer"), @@ -3271,7 +3320,6 @@ describe("createTelegramBot", () => { "", "Generated media delivery capability:", "If you create a local image to send back, write it under this exact directory: /cache/generated-media", - "Do the image work directly in this OpenCode session. Do not call the task tool, delegate to subagents, or load brainstorming/planning skills for generated media.", "For meme requests, use the meme-generation skill and Imgflip template discovery as the primary path. Do not hand-write custom poster art or raw image scripts instead of using a meme template.", "For meme files, call opencode-remote meme render --spec with an Imgflip template.url or allowed local template.imagePath. Use fallback design or image-generation skills only after template discovery fails.", "Use this exact render command for meme specs: node /gateway/bin/opencode-remote.js meme render --spec /absolute/path/to/spec.json", @@ -4280,7 +4328,6 @@ describe("createTelegramBot", () => { "", "Generated media delivery capability:", "If you create a local image to send back, write it under this exact directory: /cache/generated-media", - "Do the image work directly in this OpenCode session. Do not call the task tool, delegate to subagents, or load brainstorming/planning skills for generated media.", "For meme requests, use the meme-generation skill and Imgflip template discovery as the primary path. Do not hand-write custom poster art or raw image scripts instead of using a meme template.", "For meme files, call opencode-remote meme render --spec with an Imgflip template.url or allowed local template.imagePath. Use fallback design or image-generation skills only after template discovery fails.", "Use this exact render command for meme specs: node /gateway/bin/opencode-remote.js meme render --spec /absolute/path/to/spec.json", diff --git a/tests/config/loadConfig.test.js b/tests/config/loadConfig.test.js index 3e1c5a2..fdc95d2 100644 --- a/tests/config/loadConfig.test.js +++ b/tests/config/loadConfig.test.js @@ -44,6 +44,7 @@ describe("loadConfig", () => { apiUrl: "http://localhost:4096", command: "opencode", autoStart: true, + promptTimeoutMs: 1_800_000, workdir: cwd, }, progressVerbosity: "verbose", @@ -161,6 +162,27 @@ describe("loadConfig", () => { }) }) + test("normalizes custom OpenCode prompt timeout", () => { + const config = loadConfigFromObject( + { + schemaVersion: 2, + telegram: { + botToken: "token", + allowedUserIds: [12345], + }, + opencode: { + apiUrl: "http://localhost:4096", + command: "opencode", + autoStart: true, + promptTimeoutMs: 900_000, + }, + }, + { configPath: "/project/.opencode-remote/config.json", cwd: "/project" }, + ) + + expect(config.opencode.promptTimeoutMs).toBe(900_000) + }) + test("loads project-local config before global config", async () => { const { cwd, homeDir } = await tempWorkspace() await writeConfig(join(homeDir, ".opencode-remote", "config.json"), { diff --git a/tests/core/controller.test.js b/tests/core/controller.test.js index aed3aa1..f49a64d 100644 --- a/tests/core/controller.test.js +++ b/tests/core/controller.test.js @@ -78,7 +78,9 @@ describe("gatewayController", () => { const controller = createGatewayController({ opencode, store }) await expect(controller.sendPrompt("hello")).resolves.toBe("answer") - expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_1", "hello") + expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_1", "hello", { + includeChildSessionEvents: true, + }) }) test("passes prompt progress options to OpenCode", async () => { @@ -92,7 +94,44 @@ describe("gatewayController", () => { await expect(controller.sendPrompt("hello", options)).resolves.toBe("answer") - expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_1", "hello", options) + expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_1", "hello", { + onProgress, + includeChildSessionEvents: true, + }) + }) + + test("serializes prompt sends through the active session", async () => { + const store = createStore({ activeSessionId: "ses_1" }) + let releaseFirst + const firstPromptBlocker = new Promise((resolve) => { + releaseFirst = resolve + }) + const opencode = { + sendPrompt: vi.fn(async (_sessionId, prompt) => { + if (prompt === "first") { + await firstPromptBlocker + } + return `${prompt} answer` + }), + } + const controller = createGatewayController({ opencode, store }) + + const first = controller.sendPrompt("first") + await new Promise((resolve) => setTimeout(resolve, 0)) + const second = controller.sendPrompt("second") + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(opencode.sendPrompt).toHaveBeenCalledTimes(1) + expect(opencode.sendPrompt).toHaveBeenNthCalledWith(1, "ses_1", "first", { + includeChildSessionEvents: true, + }) + + releaseFirst() + await expect(first).resolves.toBe("first answer") + await expect(second).resolves.toBe("second answer") + expect(opencode.sendPrompt).toHaveBeenNthCalledWith(2, "ses_1", "second", { + includeChildSessionEvents: true, + }) }) test("passes permission decisions to OpenCode", async () => { @@ -158,7 +197,9 @@ describe("gatewayController", () => { const controller = createGatewayController({ opencode, store }) await expect(controller.sendPrompt("hello")).resolves.toBe("answer") - expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_2", "hello") + expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_2", "hello", { + includeChildSessionEvents: true, + }) }) test("primes newly created sessions with gateway context before the first prompt", async () => { @@ -220,7 +261,9 @@ describe("gatewayController", () => { await expect(controller.sendPrompt("hello")).resolves.toBe("answer") expect(opencode.sendContext).not.toHaveBeenCalled() - expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_1", "hello") + expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_1", "hello", { + includeChildSessionEvents: true, + }) }) test("continues sending the prompt when gateway context fails", async () => { @@ -243,7 +286,9 @@ describe("gatewayController", () => { await expect(controller.sendPrompt("hello")).resolves.toBe("answer") - expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_2", "hello") + expect(opencode.sendPrompt).toHaveBeenCalledWith("ses_2", "hello", { + includeChildSessionEvents: true, + }) expect(logger.warn).toHaveBeenCalledWith( { error, sessionId: "ses_2" }, "Could not send OpenCode gateway context", diff --git a/tests/core/opencodeClient.test.js b/tests/core/opencodeClient.test.js index 1d3ac28..a65017d 100644 --- a/tests/core/opencodeClient.test.js +++ b/tests/core/opencodeClient.test.js @@ -2,6 +2,24 @@ import { describe, expect, test, vi } from "vitest" import { createOpenCodeClient } from "../../src/core/opencode/client.js" describe("createOpenCodeClient", () => { + test("passes prompt timeout to the SDK client factory", () => { + const sdkClient = { session: { list: vi.fn(async () => []) } } + const sdkFactory = vi.fn(() => sdkClient) + + createOpenCodeClient({ + apiUrl: "http://localhost:4096", + promptTimeoutMs: 1_800_000, + sdkFactory, + }) + + expect(sdkFactory).toHaveBeenCalledWith({ + baseUrl: "http://localhost:4096", + responseStyle: "data", + throwOnError: true, + timeout: 1_800_000, + }) + }) + test("sends text prompts with the current SDK prompt shape", async () => { const sdkClient = { session: { @@ -33,6 +51,7 @@ describe("createOpenCodeClient", () => { client.sendPrompt("ses_1", "hello", { onProgress, onSystemEvent, + includeChildSessionEvents: true, ignored: "must not reach OpenCode", }), ).resolves.toBe("answer") @@ -344,6 +363,148 @@ describe("createOpenCodeClient", () => { }) }) + test("streams child session permission requests when child events are enabled", async () => { + const stream = createEventStream([ + { + type: "permission.asked", + properties: { + id: "perm_child", + sessionID: "ses_child", + permission: "bash", + patterns: ["pnpm test"], + metadata: { command: "pnpm test" }, + }, + }, + ]) + const onSystemEvent = vi.fn() + const sdkClient = { + event: { list: vi.fn(async () => stream) }, + session: { + prompt: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + return { parts: [{ type: "text", text: "answer" }] } + }), + }, + } + const client = createOpenCodeClient({ sdkClient }) + + await expect( + client.sendPrompt("ses_parent", "hello", { + onSystemEvent, + includeChildSessionEvents: true, + }), + ).resolves.toBe("answer") + + expect(onSystemEvent).toHaveBeenCalledWith({ + type: "permission.requested", + sessionId: "ses_child", + parentSessionId: "ses_parent", + childSession: true, + permissionId: "perm_child", + title: "OpenCode permission request", + description: "pnpm test", + tool: "bash", + metadata: { command: "pnpm test" }, + }) + }) + + test("streams child session tool progress when child events are enabled", async () => { + const stream = createEventStream([ + { + type: "message.part.updated", + properties: { + part: { + id: "part_child", + messageID: "msg_child", + sessionID: "ses_child", + type: "tool", + tool: "bash", + state: { + status: "running", + input: { command: "pnpm test" }, + }, + }, + }, + }, + ]) + const onProgress = vi.fn() + const sdkClient = { + event: { list: vi.fn(async () => stream) }, + session: { + prompt: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + return { parts: [{ type: "text", text: "answer" }] } + }), + }, + } + const client = createOpenCodeClient({ sdkClient }) + + await expect( + client.sendPrompt("ses_parent", "hello", { + onProgress, + includeChildSessionEvents: true, + }), + ).resolves.toBe("answer") + + expect(onProgress).toHaveBeenCalledWith({ + type: "tool.updated", + sessionId: "ses_child", + parentSessionId: "ses_parent", + childSession: true, + messageId: "msg_child", + partId: "part_child", + tool: "bash", + title: undefined, + status: "running", + input: { command: "pnpm test" }, + }) + }) + + test("streams safe child session errors when child events are enabled", async () => { + const stream = createEventStream([ + { + type: "session.error", + properties: { + sessionID: "ses_child", + error: { + name: "ProviderAuthError", + message: "secret provider payload must not be forwarded", + body: { token: "secret" }, + }, + }, + }, + ]) + const onSystemEvent = vi.fn() + const sdkClient = { + event: { list: vi.fn(async () => stream) }, + session: { + prompt: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + return { parts: [{ type: "text", text: "answer" }] } + }), + }, + } + const client = createOpenCodeClient({ sdkClient }) + + await expect( + client.sendPrompt("ses_parent", "hello", { + onSystemEvent, + includeChildSessionEvents: true, + }), + ).resolves.toBe("answer") + + expect(onSystemEvent).toHaveBeenCalledWith({ + type: "session.error", + sessionId: "ses_child", + parentSessionId: "ses_parent", + childSession: true, + errorName: "ProviderAuthError", + errorKind: "provider_auth", + }) + expect(JSON.stringify(onSystemEvent.mock.calls)).not.toContain("secret provider payload") + expect(JSON.stringify(onSystemEvent.mock.calls)).not.toContain("token") + }) + test("responds to permission requests with the current OpenCode SDK payload", async () => { const sdkClient = { postSessionIdPermissionsPermissionId: vi.fn(async () => true), diff --git a/tests/core/progressText.test.js b/tests/core/progressText.test.js index df0dd6f..a460091 100644 --- a/tests/core/progressText.test.js +++ b/tests/core/progressText.test.js @@ -21,6 +21,25 @@ describe("progress text formatting", () => { }) }) + test("formats child session task progress without exposing session IDs", () => { + const state = createProgressTextState({ verbosity: "all" }) + + const result = recordProgressEvent(state, { + type: "tool.updated", + sessionId: "ses_child", + parentSessionId: "ses_parent", + childSession: true, + partId: "part_1", + tool: "task", + title: "Explore codebase", + }) + + expect(result).toEqual({ + changed: true, + text: "Activity\n🤖 task: Explore codebase", + }) + }) + test("all verbosity records repeated invocations of the same tool", () => { const state = createProgressTextState({ verbosity: "all" }) diff --git a/tests/runtime/bootstrap.test.js b/tests/runtime/bootstrap.test.js index 2908694..74ee92d 100644 --- a/tests/runtime/bootstrap.test.js +++ b/tests/runtime/bootstrap.test.js @@ -137,6 +137,43 @@ describe("runGateway", () => { expect(processLike.once).toHaveBeenCalledWith("SIGTERM", expect.any(Function)) }) + test("passes configured OpenCode prompt timeout to the client", async () => { + const server = { stop: vi.fn(async () => undefined) } + const bot = { + api: { setMyCommands: vi.fn(async () => undefined) }, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + } + const createOpenCodeClient = vi.fn(() => ({})) + const config = { + ...testConfig(), + opencode: { ...testConfig().opencode, promptTimeoutMs: 900_000 }, + } + + await runGateway({ + config, + logger: testLogger(), + dependencies: { + ensureOpenCodeServer: vi.fn(async () => server), + createOpenCodeClient, + resolveProjectIdentity: vi.fn(async () => ({ + id: "project-1", + worktree: "/project", + vcs: "git", + })), + createProjectStateStore: vi.fn(() => ({})), + createGatewayController: vi.fn(() => ({})), + createTelegramBot: vi.fn(() => bot), + }, + processLike: { once: vi.fn() }, + }) + + expect(createOpenCodeClient).toHaveBeenCalledWith({ + apiUrl: "http://localhost:4096", + promptTimeoutMs: 900_000, + }) + }) + test("installs bundled meme runtime before ensuring the OpenCode server", async () => { const events = [] const server = { stop: vi.fn(async () => undefined) }